diff --git a/.gitignore b/.gitignore index 0b2e6e5..6c00279 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ logs/ last_error.txt log_*.log .cursorrules +_Phase* \ No newline at end of file diff --git a/DeadCellsMultiplayerMod.csproj b/DeadCellsMultiplayerMod.csproj index b137424..6615cf1 100644 --- a/DeadCellsMultiplayerMod.csproj +++ b/DeadCellsMultiplayerMod.csproj @@ -51,7 +51,12 @@ true + + + + + diff --git a/Ghost/KingActiveSkillsManager.cs b/Ghost/KingActiveSkillsManager.cs deleted file mode 100644 index 94bc038..0000000 --- a/Ghost/KingActiveSkillsManager.cs +++ /dev/null @@ -1,66 +0,0 @@ -using dc.en; -using dc.hl.types; -using dc.pr; -using dc.tool; -using dc.tool.hero; -using ModCore.Storage; - -namespace DeadCellsMultiplayerMod.Ghost.GhostBase -{ - public class KingActiveSkillsManager : HeroActiveSkillsManager, IHxbitSerializable - { - private static Hero? lastKnownHero; - private static readonly Random rng = new(); - - private Hero? me; - private GhostKing? king; - private Level? lvl; - - public InventItem? equippedWeapon; - - public KingActiveSkillsManager() : base(GetFallbackHero()) - { - me = lastKnownHero; - } - - public KingActiveSkillsManager(Hero hero, GhostKing kingSkin, Level level) : base(hero) - { - me = hero; - king = kingSkin; - lvl = level; - lastKnownHero = hero; - } - - - public override void init() - { - if (this.activeSkills == null) - this.activeSkills = new ArrayObj(); - if (this.passivePowers == null) - this.passivePowers = new ArrayObj(); - base.init(); - } - - - object IHxbitSerializable.GetData() - { - return new(); - } - - void IHxbitSerializable.SetData(object data) - { - } - - private static Hero GetFallbackHero() - { - var hero = ModEntry.me ?? dc.pr.Game.Class.ME?.hero; - if (hero != null) - return hero; - - if (lastKnownHero != null) - return lastKnownHero; - - throw new InvalidOperationException("KingActiveSkillsManager deserialization requires a Hero."); - } - } -} diff --git a/Ghost/KingWeapon/KingWeaponsManager.cs b/Ghost/KingWeapon/KingWeaponsManager.cs index 0f47868..5526b08 100644 --- a/Ghost/KingWeapon/KingWeaponsManager.cs +++ b/Ghost/KingWeapon/KingWeaponsManager.cs @@ -70,6 +70,7 @@ public void update() private void UpdateCore() { var hitchStart = RuntimeHitchWatch.Start(); + var perfEnabled = RuntimeHitchWatch.Enabled; if(hero == null) return; var inv = king.inventory; if(inventory == null && inv != null) @@ -131,12 +132,13 @@ private void UpdateCore() KingWeaponSupport.ActivateRemoteWeapon(candidate, king); pendingInterrupts = 0; - LogKingWeaponsStepIfSlow( - "KingWeaponsManager.Rebuild", - rebuildStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"pendingSlot={pendingSlot} permanentId={item.permanentId} kind={kindId} weapon={weapon?.GetType().Name ?? "null"}")); + if(perfEnabled) + LogKingWeaponsStepIfSlow( + "KingWeaponsManager.Rebuild", + rebuildStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"pendingSlot={pendingSlot} permanentId={item.permanentId} kind={kindId} weapon={weapon?.GetType().Name ?? "null"}")); } var activeWeapon = weapon; @@ -198,15 +200,16 @@ private void UpdateCore() ReleaseShield(now); } - LogKingWeaponsStepIfSlow( - "KingWeaponsManager.ShieldUpdate", - shieldStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"shieldActive={(_shieldActive ? 1 : 0)} pendingAttacks={pendingAttacks} pendingInterrupts={pendingInterrupts} weapon={activeWeapon.GetType().Name}")); + if(perfEnabled) + LogKingWeaponsStepIfSlow( + "KingWeaponsManager.ShieldUpdate", + shieldStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"shieldActive={(_shieldActive ? 1 : 0)} pendingAttacks={pendingAttacks} pendingInterrupts={pendingInterrupts} weapon={activeWeapon.GetType().Name}")); var shieldTotalMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); - if(shieldTotalMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) + if(perfEnabled && shieldTotalMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) { RuntimeHitchWatch.LogSlow( ModEntry.Instance?.Logger, @@ -449,12 +452,13 @@ private void ReleaseShield(long now) _lastShieldReleaseTimestamp = now; ClearShieldAffects(); RestoreRemoteIdlePose(); - LogKingWeaponsStepIfSlow( - "KingWeaponsManager.ReleaseShield", - hitchStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"weapon={weapon?.GetType().Name ?? "null"}")); + if(RuntimeHitchWatch.Enabled) + LogKingWeaponsStepIfSlow( + "KingWeaponsManager.ReleaseShield", + hitchStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"weapon={weapon?.GetType().Name ?? "null"}")); } private void RestoreRemoteIdlePose() diff --git a/Ghost/Kinghead.cs b/Ghost/Kinghead.cs index fca5abd..dfbfc1b 100644 --- a/Ghost/Kinghead.cs +++ b/Ghost/Kinghead.cs @@ -82,6 +82,7 @@ public override void init(Level parent, dc.h2d.Object fromUI, Ref fromUI1) this.forcedCustomHead = null!; this._customHeadInfoCache = null!; TryResolveRemoteCustomHeadInfo(remoteHeadSkin); + bool isBaseFlameDefault = string.Equals(remoteHeadSkin, "BaseFlame", StringComparison.Ordinal); if (headSprite != null) { headMaterial = headSprite.frameData?.tile; @@ -98,6 +99,7 @@ public override void init(Level parent, dc.h2d.Object fromUI, Ref fromUI1) headParticleContainer = new dc.h2d.Object(fromUI); } InitBaseHead(parent, headParticleContainer, fromUI1); + TintBaseHeadEye(isBaseFlameDefault); RebuildHeadParticles(headParticleContainer, headMaterial); this.heroHasHead = true; this.alwaysShowHead = true; @@ -105,6 +107,7 @@ public override void init(Level parent, dc.h2d.Object fromUI, Ref fromUI1) return; } InitBaseHead(parent, fromUI, fromUI1); + TintBaseHeadEye(isBaseFlameDefault); this.heroHasHead = true; this.alwaysShowHead = true; this.alwaysShowEye = true; @@ -181,6 +184,28 @@ private void InitBaseHead(Level parent, dc.h2d.Object attachParent, Ref fr } } + private void TintBaseHeadEye(bool isBaseFlameDefault) + { + if (!isBaseFlameDefault) + return; + try + { + var eyeSpr = this.eye; + if (eyeSpr == null) + return; + + // Mirrors the lobby default head: the base homunculus eye star + // (fxSmallStar, Add blend) is tinted warm-orange 0xFFBF00 when usable. + var color = eyeSpr.color; + color.x = 1.0; + color.y = 191.0 / 255.0; + color.z = 0.0; + } + catch + { + } + } + private void RebuildHeadParticles(dc.h2d.Object particleParent, dc.h2d.Tile? material) { if (material == null) diff --git a/Interaction/InteractionSync.Cache.cs b/Interaction/InteractionSync.Cache.cs new file mode 100644 index 0000000..2d659ff --- /dev/null +++ b/Interaction/InteractionSync.Cache.cs @@ -0,0 +1,41 @@ +using dc.en; +using dc.en.inter; + +namespace DeadCellsMultiplayerMod.Interaction; + +public partial class InteractionSync +{ + private sealed class LevelInteractionCache + { + public readonly List Doors = new(); + public readonly List Elevators = new(); + public readonly List VineLadders = new(); + public readonly List Teleports = new(); + public readonly List Portals = new(); + public readonly List PressurePlates = new(); + public readonly List Buttons = new(); + public readonly List TreasureChests = new(); + public readonly List SwitchBossRunes = new(); + public readonly List TriggerElevators = new(); + public readonly List TriggerTeleports = new(); + public readonly List TriggerPortals = new(); + public readonly List TriggerButtons = new(); + + public void Clear() + { + Doors.Clear(); + Elevators.Clear(); + VineLadders.Clear(); + Teleports.Clear(); + Portals.Clear(); + PressurePlates.Clear(); + Buttons.Clear(); + TreasureChests.Clear(); + SwitchBossRunes.Clear(); + TriggerElevators.Clear(); + TriggerTeleports.Clear(); + TriggerPortals.Clear(); + TriggerButtons.Clear(); + } + } +} diff --git a/Interaction/InteractionSync.Doors.cs b/Interaction/InteractionSync.Doors.cs index b79e2dc..fc0bc07 100644 --- a/Interaction/InteractionSync.Doors.cs +++ b/Interaction/InteractionSync.Doors.cs @@ -173,13 +173,13 @@ private string GetAuthoritativeDoorState(Door door) // A locked door is script-controlled (boss arena seals). Never advertise it as open: // the stale _openedDoors entry from walking through it earlier otherwise made the 2s // heartbeat force the client's sealed door back open for the whole fight. - if (TryReadBooleanMember(door, "locked", "isLocked")) + if (TryReadBooleanMember(door, LockedMemberNames)) return "state_closed"; - if (TryReadBooleanMember(door, "opened", "isOpen", "open")) + if (TryReadBooleanMember(door, OpenedMemberNames)) return "state_open"; - var ratio = TryReadNumericMember(door, "ratio", "openRatio", "openingRatio", "curRatio"); + var ratio = TryReadNumericMember(door, RatioMemberNames); if (ratio.HasValue) return ratio.Value > 0.45 ? "state_open" : "state_closed"; @@ -191,12 +191,64 @@ private bool ShouldRejectRemoteDoorOpen(Door door) { // Script-sealed doors (boss arena locks) must never be reopened by replayed remote // events or heartbeats; the local fight script owns them until the encounter ends. - if (TryReadBooleanMember(door, "locked", "isLocked")) + if (TryReadBooleanMember(door, LockedMemberNames)) return true; return DeadCellsMultiplayerMod.Mobs.MobsSynchronization.MobsSynchronization.HasLivingTrackedBoss(); } + // Per-frame host hot path: BroadcastAuthoritativeDoorStates re-reads every door's native + // state (broken/locked/opened/ratio) once per frame. Raw Type.GetProperty/GetField would + // re-run a full metadata scan per door per frame. Member names are fixed literals and the + // resolved PropertyInfo/FieldInfo are stable for the runtime type, so cache resolution per + // (Type, name). Not-found names are cached too so missing members are probed only once. + // Main-thread only (IOnHeroUpdate.OnHeroUpdate). + private readonly struct DoorMemberResolution + { + public readonly PropertyInfo? Property; + public readonly FieldInfo? Field; + + public DoorMemberResolution(PropertyInfo? property, FieldInfo? field) + { + Property = property; + Field = field; + } + } + + private static readonly Dictionary<(System.Type Type, string Name), DoorMemberResolution> s_doorMemberResolutions = new(); + + // Fixed literal probe lists for GetAuthoritativeDoorState/ShouldRejectRemoteDoorOpen. + // Passed to params helpers as pre-built arrays so the per-frame door broadcast does not + // allocate a fresh params String[] per door per frame. + private static readonly string[] LockedMemberNames = { "locked", "isLocked" }; + private static readonly string[] OpenedMemberNames = { "opened", "isOpen", "open" }; + private static readonly string[] RatioMemberNames = { "ratio", "openRatio", "openingRatio", "curRatio" }; + + private static DoorMemberResolution ResolveDoorMember(System.Type type, string name) + { + var key = (type, name); + if (s_doorMemberResolutions.TryGetValue(key, out var cached)) + return cached; + + PropertyInfo? property = null; + FieldInfo? field = null; + try + { + property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (property?.CanRead != true) + property = null; + field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + } + catch + { + // Ignore optional/generated members that cannot be resolved in this game build. + } + + var resolution = new DoorMemberResolution(property, field); + s_doorMemberResolutions[key] = resolution; + return resolution; + } + private static double? TryReadNumericMember(object instance, params string[] names) { if (instance == null) @@ -207,18 +259,12 @@ private bool ShouldRejectRemoteDoorOpen(Door door) { try { - var property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - if (property?.CanRead == true) - { - var value = property.GetValue(instance); - if (value != null) - return Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture); - } + var member = ResolveDoorMember(type, name); + if (member.Property?.GetValue(instance) is { } pv) + return Convert.ToDouble(pv, System.Globalization.CultureInfo.InvariantCulture); - var field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - var fieldValue = field?.GetValue(instance); - if (fieldValue != null) - return Convert.ToDouble(fieldValue, System.Globalization.CultureInfo.InvariantCulture); + if (member.Field?.GetValue(instance) is { } fv) + return Convert.ToDouble(fv, System.Globalization.CultureInfo.InvariantCulture); } catch { @@ -466,12 +512,11 @@ private static bool TryReadBooleanMember(object instance, params string[] names) { try { - var property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - if (property?.CanRead == true && property.GetValue(instance) is bool propertyValue) + var member = ResolveDoorMember(type, name); + if (member.Property?.GetValue(instance) is bool propertyValue) return propertyValue; - var field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - if (field?.GetValue(instance) is bool fieldValue) + if (member.Field?.GetValue(instance) is bool fieldValue) return fieldValue; } catch diff --git a/Interaction/InteractionSync.cs b/Interaction/InteractionSync.cs index 473bad0..40c5150 100644 --- a/Interaction/InteractionSync.cs +++ b/Interaction/InteractionSync.cs @@ -5,6 +5,7 @@ using dc.pr; using dc.tool.atk; using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using DeadCellsMultiplayerMod.PortableCore; using HaxeProxy.Runtime; using ModCore.Events; using ModCore.Events.Interfaces.Game.Hero; @@ -18,40 +19,6 @@ public partial class InteractionSync : IOnAdvancedModuleInitializing, IOnHeroUpdate { - private sealed class LevelInteractionCache - { - public readonly List Doors = new(); - public readonly List Elevators = new(); - public readonly List VineLadders = new(); - public readonly List Teleports = new(); - public readonly List Portals = new(); - public readonly List PressurePlates = new(); - public readonly List Buttons = new(); - public readonly List TreasureChests = new(); - public readonly List SwitchBossRunes = new(); - public readonly List TriggerElevators = new(); - public readonly List TriggerTeleports = new(); - public readonly List TriggerPortals = new(); - public readonly List TriggerButtons = new(); - - public void Clear() - { - Doors.Clear(); - Elevators.Clear(); - VineLadders.Clear(); - Teleports.Clear(); - Portals.Clear(); - PressurePlates.Clear(); - Buttons.Clear(); - TreasureChests.Clear(); - SwitchBossRunes.Clear(); - TriggerElevators.Clear(); - TriggerTeleports.Clear(); - TriggerPortals.Clear(); - TriggerButtons.Clear(); - } - } - private const double PosTolerance = 1.0; private const double PlatePosTolerance = 8.0; @@ -228,8 +195,7 @@ private static bool IsInteractionEventForCurrentLevel(string? eventLevelId) private static T SafeRead(Func fn, T fallback) { - try { return fn(); } - catch { return fallback; } + return NativeCall.Read(fn, fallback); } private static void ApplyAndRelease(List events, Action> apply) @@ -352,12 +318,21 @@ private void EnsureInteractionRuntimeLevel(Level? level) for (var i = 0; i < staleDoorAnchors.Count; i++) _doorStableAnchors.Remove(staleDoorAnchors[i]); + ClearRuntimeStateForLevel(); + } + + private void ClearRuntimeStateForLevel() + { _lastAuthoritativeDoorState.Clear(); _lastDoorStateSentTickMs.Clear(); _elevatorLastInterSendTickMs.Clear(); _elevatorLastAppliedSequence.Clear(); _pressurePlateLastAppliedSequence.Clear(); _buttonActivationState.Clear(); + CachedInteractionLevelData.Clear(); + _cachedInteractionLevel = null; + _cachedInteractionEntityCount = -1; + _cachedInteractionTriggerCount = -1; ClearPersistentInteractionStateForLevel(); } diff --git a/LaunchSync/RunLaunchCoordinator.cs b/LaunchSync/RunLaunchCoordinator.cs index 8dc3afa..6dcb58f 100644 --- a/LaunchSync/RunLaunchCoordinator.cs +++ b/LaunchSync/RunLaunchCoordinator.cs @@ -3,12 +3,24 @@ namespace DeadCellsMultiplayerMod; +internal enum PendingLaunchAction +{ + None, + LoadSave, + NewGame +} + /// /// DCCM integration adapter for the portable run-launch contracts. It owns no Dead Cells /// objects: the menu and User.newGame hooks call it before performing game-specific work. /// internal static class RunLaunchCoordinator { + internal readonly record struct PendingLaunchIntent( + PendingLaunchAction Action, + bool Custom, + bool StreamEnabled); + private static readonly object Sync = new(); private static ILogger? _log; @@ -17,6 +29,8 @@ internal static class RunLaunchCoordinator private static long _stateSequence; private static Guid _hostSessionId; private static long _nextRunId; + private static PendingLaunchIntent _pendingLaunchIntent = + new(PendingLaunchAction.NewGame, false, false); private static RunLaunchDescriptor? _hostDescriptor; private static RunLaunchDescriptor? _remoteDescriptor; @@ -50,6 +64,58 @@ internal static void Initialize(ILogger logger) } } + internal static CoopSessionSnapshot GetSessionSnapshot() + { + lock (Sync) + { + return _state.Snapshot; + } + } + + internal static NetRole CurrentRole + { + get + { + lock (Sync) + return _role; + } + } + + internal static PortableCore.CoopSessionPhase MapClientLaunchPhaseToSessionPhase( + LobbySession.ClientLaunchPhase phase) + { + return phase switch + { + LobbySession.ClientLaunchPhase.Lobby => PortableCore.CoopSessionPhase.Lobby, + LobbySession.ClientLaunchPhase.IntentReceived => PortableCore.CoopSessionPhase.LaunchCommitted, + LobbySession.ClientLaunchPhase.AwaitingPrereqs => PortableCore.CoopSessionPhase.LaunchCommitted, + LobbySession.ClientLaunchPhase.Armed => PortableCore.CoopSessionPhase.LaunchCommitted, + LobbySession.ClientLaunchPhase.Starting => PortableCore.CoopSessionPhase.LoadingLevel, + LobbySession.ClientLaunchPhase.InRun => PortableCore.CoopSessionPhase.Playing, + LobbySession.ClientLaunchPhase.RestartPending => PortableCore.CoopSessionPhase.TransitionCommitted, + _ => PortableCore.CoopSessionPhase.Faulted + }; + } + + internal static PendingLaunchIntent GetPendingLaunchIntent() + { + lock (Sync) + { + return _pendingLaunchIntent; + } + } + + internal static void SetPendingLaunchIntent( + PendingLaunchAction action, + bool custom, + bool streamEnabled) + { + lock (Sync) + { + _pendingLaunchIntent = new PendingLaunchIntent(action, custom, streamEnabled); + } + } + internal static void OnRoleChanged(NetRole previous, NetRole next) { if (previous == next) @@ -882,6 +948,7 @@ private static void ResetLocked(NetRole role, string reason) _stateSequence = 0; _hostSessionId = role == NetRole.Host ? Guid.NewGuid() : Guid.Empty; _nextRunId = 0; + _pendingLaunchIntent = new PendingLaunchIntent(PendingLaunchAction.NewGame, false, false); _hostDescriptor = null; _remoteDescriptor = null; _hostExecutedSequence = 0; diff --git a/Mobs/MobSyncTrace.cs b/Mobs/MobSyncTrace.cs index 933d698..bf8e9ed 100644 --- a/Mobs/MobSyncTrace.cs +++ b/Mobs/MobSyncTrace.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; using DeadCellsMultiplayerMod; using Serilog; @@ -8,6 +11,20 @@ namespace DeadCellsMultiplayerMod.Mobs.MobsSynchronization; /// Opt-in verbose tracing for mob sync (env DCCM_MOB_SYNC_TRACE=1 or debug settings). internal static class MobSyncTrace { + private static long s_hostObservationCalls; + private static long s_hostVisibleObservations; + private static long s_hostAnimPayloadBuilds; + private static long s_hostStatePayloadBuilds; + private static long s_wireSendBatches; + private static long s_wireSendEntries; + private static long s_wireSendBytes; + private static long s_wireReceiveBatches; + private static long s_wireReceiveEntries; + private static long s_wireReceiveBytes; + private static long s_lastPerfLogTicks; + private static readonly ConcurrentDictionary s_focusLogLastTicks = new(); + private static readonly long FocusLogIntervalTicks = Math.Max(1L, Stopwatch.Frequency); + private static readonly bool EnvTraceEnabled = string.Equals( Environment.GetEnvironmentVariable("DCCM_MOB_SYNC_TRACE"), "1", @@ -20,6 +37,77 @@ internal static class MobSyncTrace public static bool Enabled => EnvTraceEnabled || MultiplayerSettingsStorage.DebugMobsSyncTrace; public static bool AssertEnabled => EnvAssertEnabled || MultiplayerSettingsStorage.DebugMobsSyncTrace; + public static void RecordHostObservation(bool visible, bool builtAnimPayload, bool builtStatePayload) + { + if (!Enabled) + return; + + Interlocked.Increment(ref s_hostObservationCalls); + if (visible) + Interlocked.Increment(ref s_hostVisibleObservations); + if (builtAnimPayload) + Interlocked.Increment(ref s_hostAnimPayloadBuilds); + if (builtStatePayload) + Interlocked.Increment(ref s_hostStatePayloadBuilds); + } + + public static void RecordWireSend(string kind, int entries, int bytes) + { + if (!Enabled) + return; + + Interlocked.Increment(ref s_wireSendBatches); + Interlocked.Add(ref s_wireSendEntries, entries); + Interlocked.Add(ref s_wireSendBytes, bytes); + } + + public static void RecordWireReceive(string kind, int entries, int bytes) + { + if (!Enabled) + return; + + Interlocked.Increment(ref s_wireReceiveBatches); + Interlocked.Add(ref s_wireReceiveEntries, entries); + Interlocked.Add(ref s_wireReceiveBytes, bytes); + } + + public static void FlushPerformance(string role) + { + if (!Enabled) + return; + + var now = Stopwatch.GetTimestamp(); + var previous = Interlocked.Read(ref s_lastPerfLogTicks); + if (previous != 0 && now - previous < Stopwatch.Frequency * 5L) + return; + Interlocked.Exchange(ref s_lastPerfLogTicks, now); + + var observations = Interlocked.Exchange(ref s_hostObservationCalls, 0); + var visible = Interlocked.Exchange(ref s_hostVisibleObservations, 0); + var animBuilds = Interlocked.Exchange(ref s_hostAnimPayloadBuilds, 0); + var stateBuilds = Interlocked.Exchange(ref s_hostStatePayloadBuilds, 0); + var sendBatches = Interlocked.Exchange(ref s_wireSendBatches, 0); + var sendEntries = Interlocked.Exchange(ref s_wireSendEntries, 0); + var sendBytes = Interlocked.Exchange(ref s_wireSendBytes, 0); + var receiveBatches = Interlocked.Exchange(ref s_wireReceiveBatches, 0); + var receiveEntries = Interlocked.Exchange(ref s_wireReceiveEntries, 0); + var receiveBytes = Interlocked.Exchange(ref s_wireReceiveBytes, 0); + + Log.Information( + "[MobSync] PERF role={Role} observe={Observe} visible={Visible} animBuild={AnimBuild} stateBuild={StateBuild} sendBatches={SendBatches} sendEntries={SendEntries} sendBytes={SendBytes} recvBatches={ReceiveBatches} recvEntries={ReceiveEntries} recvBytes={ReceiveBytes}", + role ?? string.Empty, + observations, + visible, + animBuilds, + stateBuilds, + sendBatches, + sendEntries, + sendBytes, + receiveBatches, + receiveEntries, + receiveBytes); + } + public static void LogSendStatesBatch(string role, IReadOnlyList states) { if (!Enabled || states == null || states.Count == 0) @@ -183,7 +271,7 @@ public static void LogRegisterTracked(string role, int syncId, int localIndex, s public static void LogBindSyncId(string reason, int syncId, string mobType, double x, double y) { - if (!Enabled) + if (!Enabled && syncId != MobsSynchronization.ClientFocusDesyncSyncId) return; Log.Information( @@ -715,6 +803,42 @@ public static void LogFallbackMatchResolved( rebound); } + public static void LogClientTombstoneCreated(int syncId, string mobType, string reason) + { + if (!Enabled) + return; + + Log.Information( + "[MobSync] ◆ TOMBSTONE created syncId={SyncId} type={MobType} reason={Reason}", + syncId, + mobType ?? string.Empty, + reason ?? string.Empty); + } + + public static void LogClientTombstoneRecovery(int syncId, string mobType, bool recovered, string reason) + { + if (!Enabled) + return; + + Log.Information( + "[MobSync] ◆ TOMBSTONE recovery syncId={SyncId} type={MobType} recovered={Recovered} reason={Reason}", + syncId, + mobType ?? string.Empty, + recovered, + reason ?? string.Empty); + } + + public static void LogClientTombstoneCleared(int syncId, string reason) + { + if (!Enabled) + return; + + Log.Information( + "[MobSync] ◆ TOMBSTONE cleared syncId={SyncId} reason={Reason}", + syncId, + reason ?? string.Empty); + } + public static void LogPacketGenerationRejected(string context, int packetGeneration, int currentGeneration, int count) { Log.Warning( @@ -740,7 +864,7 @@ public static void LogIncomingHitApply( bool replaySpecial, bool forceDie) { - if (!Enabled) + if (!Enabled && syncId != MobsSynchronization.ClientFocusDesyncSyncId) return; Log.Information( @@ -828,4 +952,86 @@ private static string SummarizeOneEvent(string? raw) return $"attack skill={skill}"; } + + public static void LogRemoveAttempt( + int syncId, + string reason, + bool destroyed, + int authoritativeClientMobDieDepth, + string role) + { + Log.Information( + "[MobSync] REMOVE_ATTEMPT syncId={SyncId} reason={Reason} destroyed={Destroyed} depth={Depth} role={Role}", + syncId, + reason ?? string.Empty, + destroyed, + authoritativeClientMobDieDepth, + role ?? string.Empty); + } + + public static void LogResolveFail( + int syncId, + bool hasIdToMob, + bool hasMobToId, + bool destroyed, + int generation, + string reason) + { + Log.Information( + "[MobSync] RESOLVE_FAIL syncId={SyncId} hasIdToMob={HasIdToMob} hasMobToId={HasMobToId} destroyed={Destroyed} generation={Generation} reason={Reason}", + syncId, + hasIdToMob, + hasMobToId, + destroyed, + generation, + reason ?? string.Empty); + } + + /// Phase 20.1 temporary: hit miss immediately before tombstone recovery. + public static void LogHitResolveFail(int syncId, string reason) + { + Log.Information( + "[MobSync] HIT_RESOLVE_FAIL syncId={SyncId} reason={Reason}", + syncId, + reason ?? string.Empty); + } + + /// Phase 20.1 temporary: always-on lifecycle breadcrumb for the focus sync id. + public static void LogFocusSyncLifecycle(string evt, int syncId, string detail) + { + if (syncId != MobsSynchronization.ClientFocusDesyncSyncId) + return; + + // Focus tracing is useful for transitions, but state packets can arrive every frame. + // Keep one breadcrumb per event kind per second so diagnostics cannot dominate runtime IO. + var eventKey = evt ?? string.Empty; + var now = Stopwatch.GetTimestamp(); + var previous = s_focusLogLastTicks.GetOrAdd(eventKey, 0L); + if (previous != 0 && now - previous < FocusLogIntervalTicks) + return; + s_focusLogLastTicks[eventKey] = now; + + Log.Information( + "[MobSync] FOCUS syncId={SyncId} event={Event} detail={Detail}", + syncId, + evt ?? string.Empty, + detail ?? string.Empty); + } + + /// Phase 20.1 temporary: once-per-interval ClientConsume tombstone/miss counters. + public static void LogClientDiagPerf( + int tombstoneLookups, + int tombstoneHits, + int resolveFails, + int hitResolveFails, + int missingSyncPackets) + { + Log.Information( + "[MobSync] DIAG_PERF tombstoneLookups={TombstoneLookups} tombstoneHits={TombstoneHits} resolveFails={ResolveFails} hitResolveFails={HitResolveFails} missingSyncPackets={MissingSyncPackets}", + tombstoneLookups, + tombstoneHits, + resolveFails, + hitResolveFails, + missingSyncPackets); + } } diff --git a/Mobs/MonsterSynchronization.Attacks.cs b/Mobs/MonsterSynchronization.Attacks.cs index 649e1a4..b61cd7b 100644 --- a/Mobs/MonsterSynchronization.Attacks.cs +++ b/Mobs/MonsterSynchronization.Attacks.cs @@ -503,6 +503,7 @@ private static int AddTrackedMobLocked(Mob mob) existingIndex = FindExactTrackedMobIndexLocked(existingMob); if (existingIndex < 0) { + LogRemoveAttemptLocked(existingMob, syncId, "add_tracked_stale_forward"); IdToMob.Remove(syncId); } else @@ -659,6 +660,7 @@ private static void ResetMobTrackingStateLocked() // Per-encounter boss arena state must not survive into the next level/fight. BeholderArenaSync.Reset(); s_hostDeathTombstonesBySyncId.Clear(); + s_clientMobTombstonesBySyncId.Clear(); s_lastHostAuthoritativeFullResyncFrame = -99999.0; s_lastHostAuthoritativeFullResyncToken = 0; s_hostAuthoritativeBootstrapResyncsRemaining = 0; @@ -746,19 +748,41 @@ private static bool IsIncomingMobIdentityReady() } } - private static void RemoveTrackedMobLocked(Mob mob) + private static void RemoveTrackedMobLocked(Mob mob, string reason) { if (mob == null) return; s_trackedMobValidationPending = true; + var mappedSyncId = -1; + if (MobToId.TryGetValue(mob, out var ownedSyncId)) + mappedSyncId = ownedSyncId; + LogRemoveAttemptLocked(mob, mappedSyncId, reason); var index = FindExactTrackedMobIndexLocked(mob); if (index >= 0) { - RemoveTrackedMobAtIndexLocked(index); + RemoveTrackedMobAtIndexLocked(index, reason, alreadyLogged: true); return; } + // Client-only: a canonical owner that is no longer in trackedMobs can still hold a + // live host-owned mapping. Capture the tombstone before wiping it. An alias whose + // MobToId is missing, or whose IdToMob points at a different wrapper, must not wipe + // the canonical identity. Host keeps the previous alias-only cleanup. + if (mappedSyncId > 0 && IsClient(LobbySession.NetRef)) + { + var forwardIsThis = IdToMob.TryGetValue(mappedSyncId, out var forwardMob) && + ReferenceEquals(forwardMob, mob); + var forwardMissing = !IdToMob.TryGetValue(mappedSyncId, out var existingForward) || + existingForward == null; + if (forwardIsThis || forwardMissing) + { + TryCaptureClientTombstoneForMappedMobLocked(mob, reason); + IdToMob.Remove(mappedSyncId); + MobToId.Remove(mob); + } + } + // This can be a temporary managed wrapper for a still-live canonical native mob. Remove // only alias-local caches; never remove IdToMob/MobToId owned by the canonical wrapper. s_mobSyncAliases.Remove(mob); @@ -861,13 +885,21 @@ private static int FindExactTrackedMobIndexLocked(Mob mob) return -1; } - private static void RemoveTrackedMobAtIndexLocked(int index) + private static void RemoveTrackedMobAtIndexLocked(int index, string reason, bool alreadyLogged = false) { if (index < 0 || index >= trackedMobs.Count) return; s_trackedMobValidationPending = true; var mob = trackedMobs[index]; + if (!alreadyLogged) + { + var mappedSyncId = -1; + if (mob != null && MobToId.TryGetValue(mob, out var ownedSyncId)) + mappedSyncId = ownedSyncId; + LogRemoveAttemptLocked(mob, mappedSyncId, reason); + } + TryCaptureClientTombstoneForMappedMobLocked(mob, reason); CleanupTrackedMobCachesLocked(mob); if (mob != null) { @@ -1153,6 +1185,7 @@ private static bool TryGetTrackedMobBySyncIdLocked(int syncId, out Mob? mob) if (mappedMob == null) { MobSyncTrace.LogStaleTrackedMapping(syncId, -1, "null_mob"); + LogRemoveAttemptLocked(null, syncId, "stale_null_mob"); IdToMob.Remove(syncId); s_trackedMobValidationPending = true; return false; @@ -1181,6 +1214,7 @@ private static bool TryGetTrackedMobBySyncIdLocked(int syncId, out Mob? mob) else { MobSyncTrace.LogStaleTrackedMapping(syncId, localIndex, "untracked_mob"); + LogRemoveAttemptLocked(mappedMob, syncId, "stale_untracked_mob"); IdToMob.Remove(syncId); s_trackedMobValidationPending = true; return false; @@ -1190,6 +1224,7 @@ private static bool TryGetTrackedMobBySyncIdLocked(int syncId, out Mob? mob) var canonicalMob = trackedMobs[localIndex]; if (canonicalMob == null) { + LogRemoveAttemptLocked(null, syncId, "stale_canonical_null"); IdToMob.Remove(syncId); s_trackedMobValidationPending = true; return false; @@ -1283,6 +1318,10 @@ private static bool TryGetTrackedMobBySyncIdLocked(int syncId, out Mob? mob) syncId, localIndex, mappedSyncId == syncId ? "registry_missing" : $"registry_mismatch:{mappedSyncId}"); + LogRemoveAttemptLocked( + mappedMob, + syncId, + mappedSyncId == syncId ? "stale_registry_missing" : "stale_registry_mismatch"); IdToMob.Remove(syncId); s_trackedMobValidationPending = true; return false; @@ -1300,9 +1339,14 @@ private static void InvalidateTrackedSyncCacheLocked(int syncId, string reason) if (IdToMob.TryGetValue(syncId, out var mappedMob) && mappedMob != null) { MobSyncTrace.LogStaleTrackedMapping(syncId, FindTrackedMobIndexLocked(mappedMob), reason); + LogRemoveAttemptLocked(mappedMob, syncId, "invalidate:" + reason); if (MobToId.TryGetValue(mappedMob, out var reverseSyncId) && reverseSyncId == syncId) MobToId.Remove(mappedMob); } + else + { + LogRemoveAttemptLocked(null, syncId, "invalidate:" + reason); + } IdToMob.Remove(syncId); s_trackedMobValidationPending = true; @@ -1388,7 +1432,7 @@ private static void PruneInvalidTrackedMobsLocked() var mob = trackedMobs[i]; if (mob == null) { - RemoveTrackedMobAtIndexLocked(i); + RemoveTrackedMobAtIndexLocked(i, "prune_null"); continue; } @@ -1418,9 +1462,7 @@ private static void PruneInvalidTrackedMobsLocked() } if (shouldRemove) - { - RemoveTrackedMobAtIndexLocked(i); - } + RemoveTrackedMobAtIndexLocked(i, "prune_invalid"); } } @@ -1566,23 +1608,63 @@ private static bool TryGetMobSyncId(Mob mob, out int syncId) if (syncId < 0) return null; + var hadIdToMob = IdToMob.TryGetValue(syncId, out var preMob) && preMob != null; + var hadMobToId = preMob != null && MobToId.TryGetValue(preMob, out var preRev) && preRev == syncId; + var preDestroyed = ReadMobDestroyedSafe(preMob); + if (TryGetTrackedMobBySyncIdLocked(syncId, out var mappedMob) && mappedMob != null) + { + if (IsClient(LobbySession.NetRef) && + IsInvalidMappedReplicaLocked(mappedMob) && + MappingOwnsSyncIdLocked(mappedMob, syncId)) + { + RemoveTrackedMobLocked(mappedMob, "mapped_but_invalid"); + LogResolveFailLocked( + syncId, + hadIdToMob, + hadMobToId, + true, + "mapped_but_invalid"); + return null; + } + + LogFocusSyncLifecycleLocked(syncId, "RESOLVE_OK", "tracked"); return mappedMob; + } if (!IdToMob.TryGetValue(syncId, out var mob) || mob == null || !IsSyncMob(mob)) + { + LogResolveFailLocked( + syncId, + hadIdToMob, + hadMobToId, + preDestroyed, + !hadIdToMob ? "missing_idtomob" : (mob == null ? "forward_null" : "not_sync_mob")); return null; + } try { if (!DoesLevelMatchCurrentIdentityLocked(mob._level)) + { + LogResolveFailLocked(syncId, true, hadMobToId, ReadMobDestroyedSafe(mob), "level_mismatch"); return null; + } } catch { + LogResolveFailLocked(syncId, true, hadMobToId, true, "level_check_threw"); return null; } - return AddTrackedMobLocked(mob) >= 0 ? mob : null; + if (AddTrackedMobLocked(mob) >= 0) + { + LogFocusSyncLifecycleLocked(syncId, "RESOLVE_OK", "readded"); + return mob; + } + + LogResolveFailLocked(syncId, true, hadMobToId, ReadMobDestroyedSafe(mob), "add_tracked_failed"); + return null; } private static Mob? ResolveTrackedMobForIncomingStateLocked(NetNode.MobStateSnapshot state, HashSet? reservedMobs) @@ -1688,6 +1770,22 @@ private static bool TryGetMobSyncId(Mob mob, out int syncId) return anchoredBoss; } + // Last-resort client tombstone recovery: the host still owns this sync id and pushes + // states for it, but the local replica was removed without a confirmed death. Recreate + // the replica from the host's push data instead of dropping the state forever. + if (TryRecoverTombstonedSyncIdLocked( + state.Index, + state.Type, + state.X, + state.Y, + out var tombstoneRecovered) && + tombstoneRecovered != null) + { + if (bossEntityId > 0) + RememberClientBossEntityIdLocked(tombstoneRecovered, bossEntityId); + return tombstoneRecovered; + } + return null; } @@ -2007,7 +2105,10 @@ private static void TryRebindTrackedMobSyncIdLocked(Mob mob, int syncId) var hadOldSyncId = MobToId.TryGetValue(mob, out var oldSyncId); if (hadOldSyncId && oldSyncId >= 0 && oldSyncId != syncId) + { + LogRemoveAttemptLocked(mob, oldSyncId, "rebind_vacate_old_id"); ClearPerSyncIdCachesLocked(oldSyncId); + } if (IdToMob.TryGetValue(syncId, out var displacedMob) && displacedMob != null && !ReferenceEquals(displacedMob, mob)) diff --git a/Mobs/MonsterSynchronization.ClientReceive.cs b/Mobs/MonsterSynchronization.ClientReceive.cs index c84f6dd..e12d04c 100644 --- a/Mobs/MonsterSynchronization.ClientReceive.cs +++ b/Mobs/MonsterSynchronization.ClientReceive.cs @@ -270,7 +270,12 @@ private static void ApplyIncomingHostMobStates(IReadOnlyList if (!ShouldAcceptPacketGenerationLocked(attack.Generation, ref rejectedCount, ref rejectedGeneration)) continue; mob = ResolveTrackedMobForIncomingAttackLocked(attack); + // Dormant client tombstone: the host is pushing an attack for a sync id whose + // local replica was removed without a confirmed death. Recreate it so the attack + // has a target to apply to instead of being buffered/dropped. + if (mob == null && + TryRecoverTombstonedSyncIdLocked(attack.Index, attack.Type, attack.X, attack.Y, out var attackRecovered) && + attackRecovered != null) + { + mob = attackRecovered; + } // Phase 3: deterministically drop a replayed / out-of-order boss attack whose // sequence was already applied for this stable identity. Pure check only — the // high-water mark is advanced *after* the attack is actually queued below. @@ -1927,6 +1951,12 @@ private static void ApplyIncomingMobDies(IReadOnlyList dies) if (!ShouldAcceptPacketGenerationLocked(die.Generation, ref rejectedCount, ref rejectedGeneration)) continue; + // A host-confirmed death always wins over a dormant client tombstone: the + // identity is dead, never resurrected. Clear the tombstone so no later state/hit + // can recreate a replica for a mob the host has confirmed dead. + if (IsClient(LobbySession.NetRef) && HasClientMobTombstoneLocked(die.MobIndex)) + RemoveClientMobTombstoneLocked(die.MobIndex, "authoritative_mobdie"); + var mob = ResolveMobFromDieLocked(die); if (mob == null) { @@ -2525,6 +2555,8 @@ private static bool ShouldSendHostContactPacket(Mob mob, Entity? target) } var missReason = registryMob == null ? "missing_sync_id" : "type_mismatch"; + s_diagHitResolveFails++; + MobSyncTrace.LogHitResolveFail(hit.MobIndex, missReason); MobSyncTrace.LogIncomingMappingMismatch( "hit", hit.MobIndex, @@ -2556,7 +2588,30 @@ private static bool ShouldSendHostContactPacket(Mob mob, Entity? target) // syncId can only be stale. (type_mismatch means the host DOES have a mob there — // echoing a death then could kill a legitimate mob.) if (registryMob == null) + { + // A dormant client tombstone means the host still owns this sync id and is pushing + // hits for it. Recreate the replica from the hit's own identity instead of letting + // missing_sync_id silently drop the damage forever. + if (TryRecoverTombstonedSyncIdLocked( + hit.MobIndex, + hit.Type, + hit.X, + hit.Y, + out var tombstoneHitMob) && + tombstoneHitMob != null) + { + // The replica is bound to the sync id, so the incoming hit now resolves to it. + return tombstoneHitMob; + } + + // The id is tombstoned (recovery still cooling down or creation pending). It is NOT + // a ghost the host has abandoned — skip the death echo or the client would spook + // itself into despawning the very mob the host is still fighting. + if (HasClientMobTombstoneLocked(hit.MobIndex)) + return null; + RecordGhostHitMissLocked(hit); + } else RequestAuthoritativeHitReconcileLocked(registryMob, hit.MobIndex, "type_and_position_mismatch"); @@ -2937,25 +2992,37 @@ private static bool MobHitRegistryStillTrustworthyLocked(Mob mob, NetNode.MobHit { var mob = ResolveTrackedMobBySyncIdLocked(mobIndex); if (mob == null || !IsSyncMob(mob)) + { + if (mob == null) + LogFocusSyncLifecycleLocked(mobIndex, "HIT_RESOLVE", "tracked_null"); + else + LogFocusSyncLifecycleLocked(mobIndex, "HIT_RESOLVE", "not_sync_mob"); return null; + } try { if (mob.destroyed || mob._level == null) { s_trackedMobValidationPending = true; + LogFocusSyncLifecycleLocked( + mobIndex, + "HIT_RESOLVE", + mob.destroyed ? "mapped_but_destroyed" : "mapped_but_level_null"); return null; } if (!DoesLevelMatchCurrentIdentityLocked(mob._level)) { s_trackedMobValidationPending = true; + LogFocusSyncLifecycleLocked(mobIndex, "HIT_RESOLVE", "mapped_but_level_mismatch"); return null; } } catch { s_trackedMobValidationPending = true; + LogFocusSyncLifecycleLocked(mobIndex, "HIT_RESOLVE", "mapped_but_threw"); return null; } diff --git a/Mobs/MonsterSynchronization.ClientTombstone.cs b/Mobs/MonsterSynchronization.ClientTombstone.cs new file mode 100644 index 0000000..7e85236 --- /dev/null +++ b/Mobs/MonsterSynchronization.ClientTombstone.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using dc.en; + +namespace DeadCellsMultiplayerMod.Mobs.MobsSynchronization +{ + public partial class MobsSynchronization + { + /// + /// Records a client-side dormant tombstone for a host-owned sync id whose local replica is + /// being removed WITHOUT a host-confirmed death (native unregister, pruning of a destroyed / + /// level-mismatched mob, renderer culling, etc.). Host-confirmed deaths must NOT tombstone: + /// the authoritative MOBDIE already owns that cleanup, and resurrecting a confirmed-dead mob + /// is the regression this whole mechanism exists to avoid. + /// + /// + /// Metadata-only: never stores a Mob reference. Type/x/y are best-effort snapshots taken + /// while the mob is still readable; failures leave them empty and recovery uses whatever the + /// incoming host packet carries instead. + /// + private static void TryCreateClientMobTombstoneLocked(Mob? mob, int syncId, string reason = "client_unregister") + { + if (mob == null || syncId <= 0) + { + LogTombstoneCreateSkippedLocked(syncId, "null_or_invalid_id"); + return; + } + if (!IsClient(LobbySession.NetRef)) + { + LogTombstoneCreateSkippedLocked(syncId, "not_client"); + return; + } + if (authoritativeClientMobDieDepth > 0) + { + LogTombstoneCreateSkippedLocked(syncId, "authoritative_mobdie"); + return; + } + if (s_levelIdentityToken <= 0) + { + LogTombstoneCreateSkippedLocked(syncId, "identity_not_ready"); + return; + } + + if (!MobToId.TryGetValue(mob, out var ownedSyncId) || ownedSyncId != syncId) + { + LogTombstoneCreateSkippedLocked(syncId, "mobtoid_mismatch"); + return; + } + + if (s_clientMobTombstonesBySyncId.Count >= ClientMobTombstoneMaxCount) + { + LogTombstoneCreateSkippedLocked(syncId, "tombstone_cap"); + return; + } + + var type = string.Empty; + try { type = BuildMobStateTypeSignature(mob); } catch { } + if (string.IsNullOrWhiteSpace(type)) + hostMobTypeBySyncId.TryGetValue(syncId, out type); + + double x = 0.0; + double y = 0.0; + try + { + if (!mob.destroyed) + { + x = GetWorldX(mob); + y = GetWorldY(mob); + } + } + catch + { + } + + var frame = GetCurrentFrame(mob); + var tomb = new ClientMobTombstone + { + SyncId = syncId, + Generation = s_levelIdentityToken, + Type = type ?? string.Empty, + X = double.IsFinite(x) ? x : 0.0, + Y = double.IsFinite(y) ? y : 0.0, + CreatedFrame = frame, + LastSeenFrame = frame, + LastRecreateAttemptFrame = -99999.0, + RecreateFailCount = 0 + }; + + s_clientMobTombstonesBySyncId[syncId] = tomb; + MobSyncTrace.LogClientTombstoneCreated(syncId, tomb.Type, reason ?? "client_unregister"); + } + + /// + /// Shared client mapping-removal boundary. Snapshot a Design-A tombstone while + /// still owns the id, immediately before that mapping is wiped. + /// Host, authoritative death, identity-not-ready, and mismatched owners are no-ops. + /// + private static void TryCaptureClientTombstoneForMappedMobLocked(Mob? mob, string reason) + { + if (mob == null) + return; + if (!MobToId.TryGetValue(mob, out var syncId) || syncId <= 0) + return; + TryCreateClientMobTombstoneLocked(mob, syncId, reason); + } + + private static bool MappingOwnsSyncIdLocked(Mob mob, int syncId) + { + if (mob == null || syncId <= 0) + return false; + if (MobToId.TryGetValue(mob, out var owned) && owned == syncId) + return true; + return IdToMob.TryGetValue(syncId, out var forward) && ReferenceEquals(forward, mob); + } + + private static bool IsInvalidMappedReplicaLocked(Mob? mob) + { + if (mob == null) + return true; + try + { + if (mob.destroyed || mob._level == null) + return true; + return !DoesLevelMatchCurrentIdentityLocked(mob._level); + } + catch + { + return true; + } + } + + private static bool HasClientMobTombstoneLocked(int syncId) + { + return syncId > 0 && IsClient(LobbySession.NetRef) && s_clientMobTombstonesBySyncId.ContainsKey(syncId); + } + + /// + /// Last-resort resolver for a host packet that missed every existing mapping and fallback. + /// When a dormant tombstone exists for this sync id, recreate a replica from the host's own + /// pushed identity and rebind it. No new replica logic: reuses the proven MOBREG primitive. + /// Enforces safety bounds (generation, TTL, recreate cooldown, failure cap) and is only ever + /// invoked on miss paths, never on the hot resolve path. + /// + private static bool TryRecoverTombstonedSyncIdLocked( + int syncId, + string? incomingType, + double x, + double y, + out Mob? recovered) + { + recovered = null; + if (syncId <= 0 || !IsClient(LobbySession.NetRef)) + return false; + + s_diagTombstoneLookups++; + if (!s_clientMobTombstonesBySyncId.TryGetValue(syncId, out var tomb) || tomb == null) + return false; + s_diagTombstoneHits++; + + var frame = GetCurrentFrame(null); + + // Generation change (level reset/rebuild) invalidates every tombstone outright. + if (tomb.Generation != s_levelIdentityToken) + { + RemoveClientMobTombstoneLocked(syncId, "generation_changed"); + return false; + } + + // TTL is measured from LAST host activity so a mob the host keeps pushing for a long + // fight stays recoverable, while an id the host went silent on is forgotten. + if (frame - tomb.LastSeenFrame >= ClientMobTombstoneRetainFrames) + { + RemoveClientMobTombstoneLocked(syncId, "ttl_expired"); + return false; + } + + tomb.LastSeenFrame = frame; + + // Recreate cooldown: rate-limits duplicate attempts without starving recovery. + if (frame - tomb.LastRecreateAttemptFrame < ClientMobTombstoneRecreateCooldownFrames) + return false; + if (tomb.RecreateFailCount >= ClientMobTombstoneMaxRecreateFails) + { + RemoveClientMobTombstoneLocked(syncId, "recreate_fail_cap"); + return false; + } + + // The host now claims a different runtime mob type under this id (in-place replacement, + // elite transform, boss phase swap). The tombstone is stale against that newer identity. + if (!string.IsNullOrWhiteSpace(incomingType) && + !string.IsNullOrWhiteSpace(tomb.Type) && + !TypesReferToSameMobClass(incomingType, tomb.Type)) + { + RemoveClientMobTombstoneLocked(syncId, "identity_replaced"); + return false; + } + + var effectiveType = string.IsNullOrWhiteSpace(incomingType) ? tomb.Type ?? string.Empty : incomingType; + var useX = double.IsFinite(x) ? x : tomb.X; + var useY = double.IsFinite(y) ? y : tomb.Y; + + tomb.LastRecreateAttemptFrame = frame; + + var replica = TryCreateClientMobReplica(effectiveType, useX, useY); + if (replica == null) + { + tomb.RecreateFailCount++; + MobSyncTrace.LogClientTombstoneRecovery(syncId, effectiveType, false, "replica_create_failed"); + return false; + } + + TryRebindTrackedMobSyncIdLocked(replica, syncId); + + // TryRebindTrackedMobSyncIdLocked can early-return without binding when the level + // identity is not ready yet. Only a confirmed IdToMob binding is a successful recovery; + // otherwise keep the tombstone and let the next miss retry after the cooldown. + if (!IdToMob.TryGetValue(syncId, out var boundMob) || !ReferenceEquals(boundMob, replica)) + { + tomb.RecreateFailCount++; + MobSyncTrace.LogClientTombstoneRecovery(syncId, effectiveType, false, "rebind_not_ready"); + return false; + } + + // A successful bind of ANY mob to this sync id supersedes the dormant tombstone. The + // explicit Remove here (rather than relying on TryRebindTrackedMobSyncIdLocked) keeps + // the recovery log entry specific; both paths are idempotent. + s_clientMobTombstonesBySyncId.Remove(syncId); + MobSyncTrace.LogClientTombstoneRecovery(syncId, effectiveType, true, "replica_bound"); + recovered = replica; + return true; + } + + private static void RemoveClientMobTombstoneLocked(int syncId, string reason) + { + if (s_clientMobTombstonesBySyncId.Remove(syncId, out var tomb)) + MobSyncTrace.LogClientTombstoneCleared(syncId, reason); + } + + private static void LogTombstoneCreateSkippedLocked(int syncId, string reason) + { + LogFocusSyncLifecycleLocked(syncId, "TOMBSTONE_SKIP", reason); + } + + private static void LogFocusUnregisterWithoutMappingLocked(Mob? mob) + { + var syncId = ClientFocusDesyncSyncId; + if (mob != null && MobToId.TryGetValue(mob, out var mapped) && mapped > 0) + syncId = mapped; + LogFocusSyncLifecycleLocked(syncId, "UNREGISTER_NO_MAPPING", "mobtoid_missing"); + } + + private static void LogFocusSyncLifecycleLocked(int syncId, string evt, string detail) + { + if (syncId != ClientFocusDesyncSyncId) + return; + MobSyncTrace.LogFocusSyncLifecycle(evt, syncId, detail); + } + + private static string GetMobSyncRoleLabelLocked() + { + var net = LobbySession.NetRef; + if (net == null || !net.IsAlive) + return "none"; + return net.IsHost ? "host" : "client"; + } + + private static bool ReadMobDestroyedSafe(Mob? mob) + { + if (mob == null) + return true; + try { return mob.destroyed; } + catch { return true; } + } + + private static void LogRemoveAttemptLocked(Mob? mob, int syncId, string reason) + { + MobSyncTrace.LogRemoveAttempt( + syncId, + reason, + ReadMobDestroyedSafe(mob), + authoritativeClientMobDieDepth, + GetMobSyncRoleLabelLocked()); + LogFocusSyncLifecycleLocked(syncId, "REMOVE", reason); + } + + private static void LogResolveFailLocked( + int syncId, + bool hasIdToMob, + bool hasMobToId, + bool destroyed, + string reason) + { + s_diagResolveFails++; + var frame = GetCurrentFrame(null); + var shouldLog = syncId == ClientFocusDesyncSyncId || + frame - s_diagLastResolveFailLogFrame >= 15.0 || + s_diagLastResolveFailSyncId != syncId; + if (shouldLog) + { + s_diagLastResolveFailLogFrame = frame; + s_diagLastResolveFailSyncId = syncId; + MobSyncTrace.LogResolveFail( + syncId, + hasIdToMob, + hasMobToId, + destroyed, + s_levelIdentityToken, + reason); + } + + LogFocusSyncLifecycleLocked(syncId, "RESOLVE_FAIL", reason); + } + + private static void FlushClientDiagPerfLocked() + { + if (!IsClient(LobbySession.NetRef)) + return; + + var frame = GetCurrentFrame(null); + if (frame - s_diagLastPerfFlushFrame < 30.0) + return; + + s_diagLastPerfFlushFrame = frame; + MobSyncTrace.LogClientDiagPerf( + s_diagTombstoneLookups, + s_diagTombstoneHits, + s_diagResolveFails, + s_diagHitResolveFails, + s_diagMissingSyncPackets); + s_diagTombstoneLookups = 0; + s_diagTombstoneHits = 0; + s_diagResolveFails = 0; + s_diagHitResolveFails = 0; + s_diagMissingSyncPackets = 0; + } + + /// Compares two type signatures by their runtime class key so "Rampager|Rampager" equals "Rampager". + private static bool TypesReferToSameMobClass(string? a, string? b) + { + if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) + return true; + var classA = ExtractRuntimeClassKey(a); + var classB = ExtractRuntimeClassKey(b); + if (string.IsNullOrWhiteSpace(classA) || string.IsNullOrWhiteSpace(classB)) + return true; + return string.Equals(classA, classB, StringComparison.Ordinal); + } + } +} \ No newline at end of file diff --git a/Mobs/MonsterSynchronization.Constants.cs b/Mobs/MonsterSynchronization.Constants.cs index b42177f..aa62dac 100644 --- a/Mobs/MonsterSynchronization.Constants.cs +++ b/Mobs/MonsterSynchronization.Constants.cs @@ -43,6 +43,14 @@ public partial class MobsSynchronization private const double HostAuthoritativeDeathTombstoneRetainFrames = 30.0 * 20.0; /// After this many frames a locally culled 0-HP client mob is hidden if vanilla never wakes it for a safe onDie. private const double ClientPendingCulledDeathHideFrames = 30.0 * 6.0; + /// Client-side dormant tombstone for a host-owned sync id whose local replica was removed without a host-confirmed death. + private const double ClientMobTombstoneRetainFrames = 30.0 * 30.0; + /// Cap on simultaneous client tombstones so a desync storm cannot grow the dictionary unbounded. + private const int ClientMobTombstoneMaxCount = 128; + /// Cooldown between replica-recreate attempts for a tombstoned sync id (rate-limits recreation without starving recovery). + private const double ClientMobTombstoneRecreateCooldownFrames = 10.0; + /// After this many failed recreate attempts the tombstone is released (e.g. an unconstructable host-only type). + private const int ClientMobTombstoneMaxRecreateFails = 4; /// Distance where the client stops smoothing and snaps to the host to avoid drawing enemies through walls. private const double ClientAuthoritativeHardSnapDistancePx = 24.0 * 10.0; /// Clamp host velocity prediction so reduced-rate packets cannot overshoot through walls on clients. diff --git a/Mobs/MonsterSynchronization.DirtyQueue.cs b/Mobs/MonsterSynchronization.DirtyQueue.cs index eeb210e..e6727f6 100644 --- a/Mobs/MonsterSynchronization.DirtyQueue.cs +++ b/Mobs/MonsterSynchronization.DirtyQueue.cs @@ -45,6 +45,10 @@ private readonly struct HostMobObservedState public readonly string MobType; public readonly string StatePayload; public readonly bool VisibleForSync; + // The managed registration the state was observed from. Mob.type and the runtime class + // are immutable for a mob instance, so while the same registration keeps the syncId we + // reuse the previous MobType instead of rebuilding the signature every frame. + public readonly Mob MobRef; public HostMobObservedState( double x, @@ -55,7 +59,8 @@ public HostMobObservedState( string animPayload, string mobType, string statePayload, - bool visibleForSync) + bool visibleForSync, + Mob mobRef) { X = x; Y = y; @@ -66,6 +71,7 @@ public HostMobObservedState( MobType = mobType ?? string.Empty; StatePayload = statePayload ?? string.Empty; VisibleForSync = visibleForSync; + MobRef = mobRef; } } @@ -98,6 +104,24 @@ private static void ObserveHostMobForDirtyQueue(Mob mob) if (!TryGetMobSyncId(mob, out var syncId) || syncId <= 0) return; + // Phase 16 relevance-before-observation gate. When no connected client has registered + // interest in this mob (MOBDRAW IsOutOfGame=false), skip the snapshot build and dirty + // detection entirely: O(1) lock + TryGetValue + Count, no allocations, no per-client + // enumeration. Interest is re-established by TryApplyHostDrawRequestLocked, which + // re-opens the set AND enqueues State|ForceState on the same frame, so the client still + // receives a current authoritative state (the existing re-interest mechanism - no second + // resync added). SetHostClientInterestLocked invalidates the observed/last-sent caches + // when the last interested client leaves, so that ForceState rebuild is guaranteed fresh. + // + // Lifecycle and repair traffic deliberately bypasses this gate: MOBREG/MOBUNREG go + // through the registry flush, MOBDIE goes through Hook_Mob_onDie/SendMobDie, and every + // forced repair (boss phase, stall recovery, remote activation, rebind) enqueues dirty + // directly via QueueHostMobDirty/EnqueueHostMobDirtyLocked. The periodic resync + // scheduler (FlushHostPriorityResync) also iterates all tracked mobs unconditionally, + // which is what bootstraps a freshly connecting client before it can send any MOBDRAW. + if (!IsMobClientVisibleForSync(syncId)) + return; + double x; double y; int dir; @@ -146,10 +170,19 @@ private static void ObserveHostMobForDirtyQueue(Mob mob) } var animPayload = needsAnim ? BuildAnimPayload(mob) : string.Empty; - var mobType = BuildMobStateTypeSignature(mob); + // BuildMobStateTypeSignature allocates (type string + runtime-class FullName + joined + // string) and runs per tracked host mob per frame. The previous signature can only be + // stale when a different mob took over the syncId, so reuse it while the same managed + // registration owns the slot and only rebuild for first observation / rebind. + var mobType = hasPrevious && + !string.IsNullOrEmpty(previous.MobType) && + ReferenceEquals(previous.MobRef, mob) + ? previous.MobType + : BuildMobStateTypeSignature(mob); var statePayload = needsStatePayload ? BuildHostMobStatePayload(mob) : (hasPrevious ? previous.StatePayload : string.Empty); + MobSyncTrace.RecordHostObservation(visibleForSync, needsAnim, needsStatePayload); lock (Sync) { @@ -197,7 +230,8 @@ private static void ObserveHostMobForDirtyQueue(Mob mob) animPayload, mobType, statePayload, - visibleForSync); + visibleForSync, + mob); if (flags != HostMobDirtyFlags.None) EnqueueHostMobDirtyLocked(syncId, flags); diff --git a/Mobs/MonsterSynchronization.FrameConsume.cs b/Mobs/MonsterSynchronization.FrameConsume.cs index 26ed80f..61c7ce7 100644 --- a/Mobs/MonsterSynchronization.FrameConsume.cs +++ b/Mobs/MonsterSynchronization.FrameConsume.cs @@ -34,6 +34,11 @@ private static void RunClientIncomingFrameConsume(NetNode net) // After all authoritative packets for this frame have been applied, complete any boss // death the packets alone could not finish (unresolved MOBDIE, stranded zero-life boss). ProcessClientBossDeathWatchdog(); + + lock (Sync) + { + FlushClientDiagPerfLocked(); + } } } } diff --git a/Mobs/MonsterSynchronization.HostSend.cs b/Mobs/MonsterSynchronization.HostSend.cs index 5c04de6..7b1caf6 100644 --- a/Mobs/MonsterSynchronization.HostSend.cs +++ b/Mobs/MonsterSynchronization.HostSend.cs @@ -75,7 +75,19 @@ private static void SetHostClientInterestLocked(int mobSyncId, int userId, bool existing.Remove(userId); if (existing.Count <= 0) + { hostClientInterestUsersBySyncId.Remove(mobSyncId); + + // Phase 16: the last interested client just left this mob, so it is now eligible + // for the relevance-before-observation fast-path skip. Drop the observed and + // last-sent caches carried over from the old interest window: their payload + // strings were built against a possibly-changed mob and would be reused as-is by + // TryBuildHostMobDeltaSnapshot on a later re-interest ForceState, which would + // send stale type/state/animation data. Removing them forces the next + // observation (or ForceState build) to reconstruct a fresh authoritative payload. + hostObservedMobStatesBySyncId.Remove(mobSyncId); + hostLastSentMobStatesBySyncId.Remove(mobSyncId); + } return; } @@ -96,6 +108,54 @@ private static void ClearHostClientInterestLocked() hostClientInterestUsersBySyncId.Clear(); } + private static readonly List s_hostInterestPurgeSyncIdsScratch = new(); + + /// + /// Phase 17: purge every interest entry owned by a single client. Called from the host + /// disconnect funnel (HandleNetworkDisconnectGhostCleanup) so a disconnected client can + /// never keep mobs "interested" — stale userIds otherwise held the Phase 16 relevance gate + /// open and kept the host observing/sending for mobs nobody is looking at, until the next + /// level reset. + /// + internal static void RemoveHostClientInterestForUser(int userId) + { + if (userId <= 0) + return; + + lock (Sync) + { + if (hostClientInterestUsersBySyncId.Count == 0) + return; + + s_hostInterestPurgeSyncIdsScratch.Clear(); + foreach (var pair in hostClientInterestUsersBySyncId) + { + var users = pair.Value; + if (users == null) + continue; + users.Remove(userId); + if (users.Count == 0) + s_hostInterestPurgeSyncIdsScratch.Add(pair.Key); + } + + if (s_hostInterestPurgeSyncIdsScratch.Count == 0) + return; + + for (var i = 0; i < s_hostInterestPurgeSyncIdsScratch.Count; i++) + { + var syncId = s_hostInterestPurgeSyncIdsScratch[i]; + hostClientInterestUsersBySyncId.Remove(syncId); + // Phase 16 invariant: the last interested client left, so drop the observed and + // last-sent caches. A later re-interest ForceState then rebuilds a fresh + // authoritative payload instead of reusing one built for the old client. + hostObservedMobStatesBySyncId.Remove(syncId); + hostLastSentMobStatesBySyncId.Remove(syncId); + } + + s_hostInterestPurgeSyncIdsScratch.Clear(); + } + } + private static void TryRecoverClientSyncMobLifeAfterLocalDamage(Mob? mob, int fallbackLife) { if (mob == null || mob.destroyed) diff --git a/Mobs/MonsterSynchronization.Registry.cs b/Mobs/MonsterSynchronization.Registry.cs index 5e54f48..c80aff5 100644 --- a/Mobs/MonsterSynchronization.Registry.cs +++ b/Mobs/MonsterSynchronization.Registry.cs @@ -9,7 +9,6 @@ namespace DeadCellsMultiplayerMod.Mobs.MobsSynchronization /// Host-owned NetId registry. Native game entity ids and client entity-list indexes are never /// identities. The host assigns monotonic NetIds per level generation; clients only bind local /// references to those NetIds via MOBREG or a one-shot type+spawn state bind. - /// Conceptual portable form: . /// public partial class MobsSynchronization { diff --git a/Mobs/MonsterSynchronization.cs b/Mobs/MonsterSynchronization.cs index d046e8e..62dc2ff 100644 --- a/Mobs/MonsterSynchronization.cs +++ b/Mobs/MonsterSynchronization.cs @@ -190,6 +190,36 @@ private sealed class HostDeathTombstone private static readonly List s_hostDeathTombstoneScratch = new(); private static readonly List s_hostDeathTombstoneStateScratch = new(); private static readonly List s_hostDeathTombstoneRemoveScratch = new(); + /// + /// Client-side dormant tombstone: metadata-only memory of a host-owned sync id whose local + /// replica was removed WITHOUT a host-confirmed death. Kept only on the client. When a later + /// host packet (state/hit) misses every resolver, the tombstone lets the client recreate the + /// replica from the host's own push data instead of silently dropping it forever. Never holds + /// a Mob reference; never survives an authoritative MOBDIE, level reset, or generation change. + /// + private sealed class ClientMobTombstone + { + public int SyncId; + public int Generation; + public string Type = string.Empty; + public double X; + public double Y; + public double CreatedFrame; + public double LastSeenFrame; + public double LastRecreateAttemptFrame = -99999.0; + public int RecreateFailCount; + } + private static readonly Dictionary s_clientMobTombstonesBySyncId = new(); + /// Phase 20.1 temporary focus id for the original Rampager regression. Remove after the audit. + public const int ClientFocusDesyncSyncId = 43; + private static int s_diagTombstoneLookups; + private static int s_diagTombstoneHits; + private static int s_diagResolveFails; + private static int s_diagHitResolveFails; + private static int s_diagMissingSyncPackets; + private static double s_diagLastPerfFlushFrame = -99999.0; + private static double s_diagLastResolveFailLogFrame = -99999.0; + private static int s_diagLastResolveFailSyncId; private static int s_ghostHitMissGeneration; private const int GhostHitMissMinCount = 3; private const double GhostHitMissMinSeconds = 2.0; @@ -602,6 +632,8 @@ private void OnFrameUpdateCore(double dt) if (flushMs >= RuntimeHitchWatch.MobSyncFlushSlowThresholdMs) RuntimeHitchWatch.LogSlow(modEntry.Logger, "MobsSynchronization.HostFlush", flushMs, BuildRuntimeQueueDetails()); + MobSyncTrace.FlushPerformance("host"); + return; } @@ -619,6 +651,8 @@ private void OnFrameUpdateCore(double dt) var flushMs = RuntimeHitchWatch.GetElapsedMilliseconds(flushStart); if (flushMs >= RuntimeHitchWatch.MobSyncFlushSlowThresholdMs) RuntimeHitchWatch.LogSlow(modEntry.Logger, "MobsSynchronization.ClientFlush", flushMs, BuildRuntimeQueueDetails()); + + MobSyncTrace.FlushPerformance("client"); } } @@ -1068,9 +1102,20 @@ private static void Hook_Level_unregisterEntity(Hook_Level.orig_unregisterEntity lock (Sync) { if (ShouldRetainMobSyncIdOnTemporaryUnregisterLocked(self, mob)) + { + if (MobToId.TryGetValue(mob, out var retainedSyncId)) + LogFocusSyncLifecycleLocked(retainedSyncId, "UNREGISTER_RETAINED", "temporary_unregister"); DetachTrackedMobForTemporaryUnregisterLocked(mob); + } else - RemoveTrackedMobLocked(mob); + { + // The full removal path wipes MobToId/IdToMob. Capture the sync id and, when + // the removal is NOT a host-confirmed death, remember it as a dormant + // client tombstone so a later host state/hit can still recreate the replica. + if (!MobToId.ContainsKey(mob)) + LogFocusUnregisterWithoutMappingLocked(mob); + RemoveTrackedMobLocked(mob, "unregister_entity"); + } } } @@ -1168,7 +1213,8 @@ private void Hook_Mob_postUpdate(Hook_Mob.orig_postUpdate orig, Mob self) ObserveClientMobForDirtyQueue(self); ApplyClientAnimationStateBeforeUpdate(self); - TryRepairClientMobAttackTarget(self); + if (IsClientNetworkAttackActive(self)) + TryRepairClientMobAttackTarget(self); } return; @@ -1287,7 +1333,7 @@ private static void Hook_Mob_onDie(Hook_Mob.orig_onDie orig, Mob self) lock (Sync) { - RemoveTrackedMobLocked(self); + RemoveTrackedMobLocked(self, "on_die"); } } diff --git a/ModEntry/ModEntry.GhostSync.cs b/ModEntry/ModEntry.GhostSync.cs index e1d1e81..9023a6a 100644 --- a/ModEntry/ModEntry.GhostSync.cs +++ b/ModEntry/ModEntry.GhostSync.cs @@ -26,6 +26,7 @@ private bool IsRemoteCombatGraceActive() private void UpdateGhostHeads() { var hitchStart = RuntimeHitchWatch.Start(); + var perfEnabled = RuntimeHitchWatch.Enabled; var main = dc.Main.Class.ME; if (main == null || main.user == null) { @@ -56,12 +57,13 @@ private void UpdateGhostHeads() RecreateClientHead(i); if (clientHeads[i] != null) recreatedHeads++; - LogGhostRuntimeStepIfSlow( - "ModEntry.UpdateGhostHeads.RecreateClientHead", - recreateStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"slot={i} remoteId={clientIds[i]} pending={CountPendingClientHeadRecreate()}")); + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.UpdateGhostHeads.RecreateClientHead", + recreateStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"slot={i} remoteId={clientIds[i]} pending={CountPendingClientHeadRecreate()}")); } var head = clientHeads[i]; @@ -77,12 +79,13 @@ private void UpdateGhostHeads() RecreateClientHead(i); if (clientHeads[i] != null) recreatedHeads++; - LogGhostRuntimeStepIfSlow( - "ModEntry.UpdateGhostHeads.RecreateClientHead", - recreateStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"slot={i} remoteId={clientIds[i]} pending={CountPendingClientHeadRecreate()}")); + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.UpdateGhostHeads.RecreateClientHead", + recreateStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"slot={i} remoteId={clientIds[i]} pending={CountPendingClientHeadRecreate()}")); } continue; } @@ -100,16 +103,17 @@ private void UpdateGhostHeads() ? 0 : now + (long)(Stopwatch.Frequency * GhostHeadDormantUpdateSeconds); updatedHeadFx++; - LogGhostRuntimeStepIfSlow( - "ModEntry.UpdateGhostHeads.HeadFx", - fxStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"slot={i} remoteId={clientIds[i]}")); + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.UpdateGhostHeads.HeadFx", + fxStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"slot={i} remoteId={clientIds[i]}")); } var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); - if (hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) + if (perfEnabled && hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) { RuntimeHitchWatch.LogSlow( Logger, @@ -399,6 +403,10 @@ internal static void SetClientSkin(int remoteId, string? skin) var prev = clientSkins[index]; clientSkins[index] = cleaned; + // LobbyBeheaded reads the same per-client cache. Refresh the title UI even when the + // GhostKing has not been created yet (the normal lobby connection case). + MultiplayerModUI.Connection.ConnectionUI.NotifyConnectionsChanged(); + var client = clients[index]; if (client != null && !IsRemoteKingTransitionActive) { @@ -422,6 +430,8 @@ internal static void SetClientHeadSkin(int remoteId, string? skin) var prev = clientHeadSkins[index]; clientHeadSkins[index] = cleaned; + MultiplayerModUI.Connection.ConnectionUI.NotifyConnectionsChanged(); + var client = clients[index]; if (client != null) client.RemoteHeadSkinId = cleaned; @@ -442,6 +452,7 @@ private void RecreateClientHead(int slot) return; var hitchStart = RuntimeHitchWatch.Start(); + var perfEnabled = RuntimeHitchWatch.Enabled; if (slot < 0 || slot >= clients.Length) return; @@ -482,17 +493,19 @@ private void RecreateClientHead(int slot) remoteHeadSkin = previousGlobalHead; } - LogGhostRuntimeStepIfSlow( - "ModEntry.RecreateClientHead", - hitchStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"slot={slot} remoteId={clientIds[slot]} hadExisting={(hadExisting ? 1 : 0)} desiredHead={desiredHead}")); + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.RecreateClientHead", + hitchStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"slot={slot} remoteId={clientIds[slot]} hadExisting={(hadExisting ? 1 : 0)} desiredHead={desiredHead}")); } private void ReceiveGhostCoords() { var hitchStart = RuntimeHitchWatch.Start(); + var perfEnabled = RuntimeHitchWatch.Enabled; var net = _net; var ghost = _ghost; if (net == null || me == null || ghost == null) return; @@ -531,12 +544,13 @@ private void ReceiveGhostCoords() { QueueClientDisposeWithTransition(index); disposedSlots++; - LogGhostRuntimeStepIfSlow( - "ModEntry.ReceiveGhostCoords.Remote", - remoteStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"remoteId={remote.Id} slot={index} disposed=1 anim={(remote.HasAnim ? 1 : 0)} headAnim={(remote.HasHeadAnim ? 1 : 0)}")); + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.ReceiveGhostCoords.Remote", + remoteStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"remoteId={remote.Id} slot={index} disposed=1 anim={(remote.HasAnim ? 1 : 0)} headAnim={(remote.HasHeadAnim ? 1 : 0)}")); continue; } @@ -620,12 +634,13 @@ private void ReceiveGhostCoords() ghost.SetLabel(client, newLabel); clientLabels[index] = newLabel; updatedLabels++; - LogGhostRuntimeStepIfSlow( - "ModEntry.ReceiveGhostCoords.SetLabel", - labelStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"remoteId={remote.Id} slot={index} label={newLabel}")); + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.ReceiveGhostCoords.SetLabel", + labelStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"remoteId={remote.Id} slot={index} label={newLabel}")); } if (remote.HasAnim && @@ -641,12 +656,13 @@ private void ReceiveGhostCoords() clientLastBodyAnimGs[index] = remote.AnimG; playedAnims++; headDirty = true; - LogGhostRuntimeStepIfSlow( - "ModEntry.ReceiveGhostCoords.PlayGhostAnim", - animStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"remoteId={remote.Id} slot={index} anim={remote.Anim}")); + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.ReceiveGhostCoords.PlayGhostAnim", + animStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"remoteId={remote.Id} slot={index} anim={remote.Anim}")); } if (remote.HasHeadAnim && !string.IsNullOrWhiteSpace(remote.HeadAnim) && @@ -657,27 +673,29 @@ private void ReceiveGhostCoords() clientLastHeadAnims[index] = remote.HeadAnim; playedHeadAnims++; headDirty = true; + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.ReceiveGhostCoords.PlayGhostHeadAnim", + headAnimStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"remoteId={remote.Id} slot={index} anim={remote.HeadAnim}")); + } + + if (perfEnabled) LogGhostRuntimeStepIfSlow( - "ModEntry.ReceiveGhostCoords.PlayGhostHeadAnim", - headAnimStart, + "ModEntry.ReceiveGhostCoords.Remote", + remoteStart, string.Create( System.Globalization.CultureInfo.InvariantCulture, - $"remoteId={remote.Id} slot={index} anim={remote.HeadAnim}")); - } - - LogGhostRuntimeStepIfSlow( - "ModEntry.ReceiveGhostCoords.Remote", - remoteStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"remoteId={remote.Id} slot={index} created={(hadClientBefore ? 0 : 1)} downed={(useDownedOffset ? 1 : 0)} anim={(remote.HasAnim ? 1 : 0)} headAnim={(remote.HasHeadAnim ? 1 : 0)}")); + $"remoteId={remote.Id} slot={index} created={(hadClientBefore ? 0 : 1)} downed={(useDownedOffset ? 1 : 0)} anim={(remote.HasAnim ? 1 : 0)} headAnim={(remote.HasHeadAnim ? 1 : 0)}")); if (headDirty) MarkGhostHeadDirty(index, immediate: true); } var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); - if (hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) + if (perfEnabled && hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) { RuntimeHitchWatch.LogSlow( Logger, @@ -789,6 +807,7 @@ private void CancelPendingClientDispose(int slot) return existingDuringTransition; var hitchStart = RuntimeHitchWatch.Start(); + var perfEnabled = RuntimeHitchWatch.Enabled; if (slot < 0 || slot >= clients.Length) return null; @@ -850,12 +869,13 @@ private void CancelPendingClientDispose(int slot) { } - LogGhostRuntimeStepIfSlow( - "ModEntry.EnsureClientKingSlot", - hitchStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"slot={slot} remoteId={clientIds[slot]} created=1 skin={(string.IsNullOrWhiteSpace(knownSkin) ? 0 : 1)} head={(string.IsNullOrWhiteSpace(knownHead) ? 0 : 1)}")); + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.EnsureClientKingSlot", + hitchStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"slot={slot} remoteId={clientIds[slot]} created=1 skin={(string.IsNullOrWhiteSpace(knownSkin) ? 0 : 1)} head={(string.IsNullOrWhiteSpace(knownHead) ? 0 : 1)}")); return created; } @@ -1426,6 +1446,7 @@ private void DisposeClientSlot(int slot, bool clearIdentity) private void ReceiveGhostWeapons() { var hitchStart = RuntimeHitchWatch.Start(); + var perfEnabled = RuntimeHitchWatch.Enabled; var net = _net; if (net == null || me == null) return; @@ -1449,16 +1470,17 @@ private void ReceiveGhostWeapons() if (!TryApplyRemoteWeaponUpdate(update.Id, update.Kind, update.Slot, update.PermanentId, update.Ammo)) continue; applied++; - LogGhostRuntimeStepIfSlow( - "ModEntry.ReceiveGhostWeapons.ApplyRemoteWeaponUpdate", - updateStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"remoteId={update.Id} slot={update.Slot} permanentId={update.PermanentId} ammo={(update.Ammo.HasValue ? update.Ammo.Value : -1)}")); + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.ReceiveGhostWeapons.ApplyRemoteWeaponUpdate", + updateStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"remoteId={update.Id} slot={update.Slot} permanentId={update.PermanentId} ammo={(update.Ammo.HasValue ? update.Ammo.Value : -1)}")); } var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); - if (hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) + if (perfEnabled && hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) { RuntimeHitchWatch.LogSlow( Logger, @@ -1493,6 +1515,7 @@ private void DrainRemoteCombatQueuesAfterLevelChange() private void ReceiveGhostAttacks() { var hitchStart = RuntimeHitchWatch.Start(); + var perfEnabled = RuntimeHitchWatch.Enabled; var net = _net; if (net == null || me == null) return; @@ -1517,12 +1540,13 @@ private void ReceiveGhostAttacks() if (TryHandleRemoteDiveAttack(attack, localId)) { diveHandled++; - LogGhostRuntimeStepIfSlow( - "ModEntry.ReceiveGhostAttacks.Remote", - attackStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"remoteId={attack.Id} slot={attack.Slot} dive=1 action={attack.Action}")); + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.ReceiveGhostAttacks.Remote", + attackStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"remoteId={attack.Id} slot={attack.Slot} dive=1 action={attack.Action}")); continue; } @@ -1556,16 +1580,17 @@ private void ReceiveGhostAttacks() clientLastBodyAnimGs[index] = null; queuedAttacks++; - LogGhostRuntimeStepIfSlow( - "ModEntry.ReceiveGhostAttacks.Remote", - attackStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"remoteId={attack.Id} slot={attack.Slot} dive=0 action={attack.Action} kind={attack.Kind ?? string.Empty}")); + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.ReceiveGhostAttacks.Remote", + attackStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"remoteId={attack.Id} slot={attack.Slot} dive=0 action={attack.Action} kind={attack.Kind ?? string.Empty}")); } var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); - if (hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) + if (perfEnabled && hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) { RuntimeHitchWatch.LogSlow( Logger, @@ -1585,6 +1610,7 @@ private void ReceiveGhostAttacks() private void UpdateGhostWeapons() { var hitchStart = RuntimeHitchWatch.Start(); + var perfEnabled = RuntimeHitchWatch.Enabled; var activeManagers = 0; for (int i = 0; i < clients.Length; i++) { @@ -1593,16 +1619,17 @@ private void UpdateGhostWeapons() activeManagers++; var managerStart = RuntimeHitchWatch.Start(); client.kingWeaponsManager.update(); - LogGhostRuntimeStepIfSlow( - "ModEntry.UpdateGhostWeapons.Manager", - managerStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"slot={i} remoteId={clientIds[i]} shield={(client.kingWeaponsManager.IsShieldActive ? 1 : 0)}")); + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.UpdateGhostWeapons.Manager", + managerStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"slot={i} remoteId={clientIds[i]} shield={(client.kingWeaponsManager.IsShieldActive ? 1 : 0)}")); } var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); - if (hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) + if (perfEnabled && hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) { RuntimeHitchWatch.LogSlow( Logger, @@ -1823,6 +1850,7 @@ private bool TryApplyRemoteWeaponUpdate(int remoteId, string? kindId, int slot, private void ApplyRemoteWeaponUpdate(int remoteId, string? kindId, int slot, int permanentId, int? ammo = null) { var hitchStart = RuntimeHitchWatch.Start(); + var perfEnabled = RuntimeHitchWatch.Enabled; if (string.IsNullOrWhiteSpace(kindId)) return; if (slot < -1 || slot > 1 || permanentId < 0) return; var net = _net; @@ -1915,12 +1943,13 @@ private void ApplyRemoteWeaponUpdate(int remoteId, string? kindId, int slot, int // InventItem into the ghost inventory for the rest of the run. TryRemoveSupersededRemoteWeapon(inv, currentSlotItem, existing); - LogGhostRuntimeStepIfSlow( - "ModEntry.ApplyRemoteWeaponUpdate", - hitchStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"remoteId={remoteId} slot={slot} permanentId={permanentId} ammo={(ammo.HasValue ? ammo.Value : -1)} kind={cleaned}")); + if (perfEnabled) + LogGhostRuntimeStepIfSlow( + "ModEntry.ApplyRemoteWeaponUpdate", + hitchStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"remoteId={remoteId} slot={slot} permanentId={permanentId} ammo={(ammo.HasValue ? ammo.Value : -1)} kind={cleaned}")); } private static void TryRemoveSupersededRemoteWeapon(Inventory inv, InventItem? superseded, InventItem replacement) @@ -2020,6 +2049,17 @@ internal void HandleNetworkDisconnectGhostCleanup(NetRole role) if (remoteId <= 0 || activeRemoteIds.Contains(remoteId)) continue; + try + { + // Phase 17: this client is gone from the network session. Purge its mob + // interest so it can never keep mobs "interested" (stale userIds otherwise + // kept the mob-sync Phase 16 relevance gate open until the next level reset). + global::DeadCellsMultiplayerMod.Mobs.MobsSynchronization.MobsSynchronization.RemoveHostClientInterestForUser(remoteId); + } + catch + { + } + try { DisposeClientSlot(i, clearIdentity: true); diff --git a/ModEntry/ModEntry.cs b/ModEntry/ModEntry.cs index c715415..ba91666 100644 --- a/ModEntry/ModEntry.cs +++ b/ModEntry/ModEntry.cs @@ -14,6 +14,7 @@ using Rand = dc.libs.Rand; using dc.ui.hud; using dc.h2d; +using dc.shader; using Hashlink.Virtuals; using dc.tool; using dc.tool.mainSkills; @@ -96,6 +97,14 @@ internal static void MarkSteamUnavailable(string reason) public dc.pr.Game? game; + // Main-thread-only per-slot projection of remote players for the in-world GhostKing + // pipeline. Slot index is derived from remote id via TryGetClientIndex (compact 0..n-1 + // range, stable within a session). Compare with NetNode._remotes: that structure is the + // network-layer RECEIVE buffer (id-keyed, network-thread-written under _sync, session + // lifetime, feeds forwarding / late-join / snapshot building), whereas these arrays hold + // the APPLIED render state (normalized skin/head, diffed labels/pos/anim/fx, recreatable + // GhostKing/Kinghead objects). Same logical players, different projections + lifetimes; + // keep-both is intentional (see comment on NetNode._remotes). public static GhostKing[] clients = new GhostKing[NetNode.MaxClientSlots]; public static Kinghead?[] clientHeads = new Kinghead?[NetNode.MaxClientSlots]; public static string?[] clientLabels = new string?[NetNode.MaxClientSlots]; @@ -538,6 +547,7 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) entry.Logger.Information("[NetMod][MobSyncStack] mode=host-authoritative-vanilla-ai-transport-neutral-replica-keyframes"); entry.Logger.Information("[NetMod][SteamCallbacks] mode=main-thread-only-no-timer"); Hook_Game.init += Hook_gameinit; + Hook_GlowKey.applyGlowData += Hook_GlowKey_applyGlowData; Hook_Hero.wakeup += hook_hero_wakeup; Hook_Hero.onLevelChanged += hook_level_changed; Hook_Game.activateSubLevel += Hook_Game_activateSubLevel; @@ -588,12 +598,40 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) Ghost.KingWeaponHooks.Install(); } + private static void Hook_GlowKey_applyGlowData( + Hook_GlowKey.orig_applyGlowData orig, + GlowKey self, + int index, + Hashlink.Virtuals.virtual_animationIntensity_animationScale_animationSpeed_animationTextureMask_inner_key_outer_power_ glowData) + { + // Custom head/body glow data can contain more entries than the shader has allocated. + // Grow the count before the vanilla write instead of allowing an indexed native write + // to address an unallocated color slot. + if (index >= 0 && self.colorsCount__ <= index) + { + self.colorsCount__ = index + 1; + self.constModified = true; + } + + orig(self, index, glowData); + } + private void Hook_Hero_applySkin(Hook_Hero.orig_applySkin orig, Hero self, dc.String skinId) { orig(self, skinId); try { + _ConnectionUI.RememberLocalHeroSkin(skinId?.ToString(), "applySkin"); + try + { + var applyUser = dc.Main.Class.ME?.user ?? self?._level?.game?.user; + _ConnectionUI.RememberLocalHeroSkinFromUser(applyUser, "applySkin.user"); + } + catch + { + } + if (_netRole == NetRole.None) return; @@ -848,6 +886,7 @@ private void Hook_HiddenTrigger_trigger(Hook_HiddenTrigger.orig_trigger orig, Hi private void Hook_User_unserialize(Hook_User.orig_unserialize orig, User self, dc.hxbit.Serializer v) { orig(self, v); + try { _ConnectionUI.RememberLocalHeroSkinFromUser(self, "unserialize"); } catch { } if (_netRole == NetRole.Client) GameDataSync.CaptureOriginalUserData(self, allowReplaceWhenBetter: true); } @@ -1268,7 +1307,7 @@ public void OnFrameUpdate(double dt) TraceActiveCineTypeChange(); var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); - if (hitchMs >= RuntimeHitchWatch.ModFrameSlowThresholdMs) + if (RuntimeHitchWatch.Enabled && hitchMs >= RuntimeHitchWatch.ModFrameSlowThresholdMs) { RuntimeHitchWatch.LogSlow( Logger, @@ -1350,7 +1389,7 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) LogHeroUpdateStepIfSlow("ModEntry.OnHeroUpdate.UpdateGhostHeads", stepStart, null); var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); - if (hitchMs >= RuntimeHitchWatch.ModHeroSlowThresholdMs) + if (RuntimeHitchWatch.Enabled && hitchMs >= RuntimeHitchWatch.ModHeroSlowThresholdMs) { RuntimeHitchWatch.LogSlow( Logger, diff --git a/PortableCore/CoopSessionSnapshot.cs b/PortableCore/CoopSessionSnapshot.cs new file mode 100644 index 0000000..ef7a846 --- /dev/null +++ b/PortableCore/CoopSessionSnapshot.cs @@ -0,0 +1,7 @@ +namespace DeadCellsMultiplayerMod.PortableCore; + +/// Immutable read model for session consumers that must not access the coordinator internals. +internal readonly record struct CoopSessionSnapshot( + CoopSessionPhase Phase, + long TransitionSequence, + string LastReason); diff --git a/PortableCore/CoopSessionStateMachine.cs b/PortableCore/CoopSessionStateMachine.cs index a20bc21..6271631 100644 --- a/PortableCore/CoopSessionStateMachine.cs +++ b/PortableCore/CoopSessionStateMachine.cs @@ -22,6 +22,7 @@ internal sealed class CoopSessionStateMachine public CoopSessionPhase Phase { get; private set; } = CoopSessionPhase.Disconnected; public long TransitionSequence { get; private set; } public string LastReason { get; private set; } = string.Empty; + public CoopSessionSnapshot Snapshot => new(Phase, TransitionSequence, LastReason); public bool TryTransition( CoopSessionPhase next, diff --git a/PortableCore/NativeCall.cs b/PortableCore/NativeCall.cs new file mode 100644 index 0000000..6dcb1a0 --- /dev/null +++ b/PortableCore/NativeCall.cs @@ -0,0 +1,30 @@ +namespace DeadCellsMultiplayerMod.PortableCore; + +/// Small boundary for HashLink/native calls that may throw during teardown or reload. +internal static class NativeCall +{ + public static bool Try(Action action) + { + try + { + action(); + return true; + } + catch + { + return false; + } + } + + public static T Read(Func read, T fallback) + { + try + { + return read(); + } + catch + { + return fallback; + } + } +} diff --git a/PortableCore/NetEntityId.cs b/PortableCore/NetEntityId.cs deleted file mode 100644 index 452dcd0..0000000 --- a/PortableCore/NetEntityId.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace DeadCellsMultiplayerMod.PortableCore; - -/// -/// Stable identity assigned by the host authority when an entity is spawned. -/// Runtime object addresses, native game ids, list indexes, names, and positions are not identities. -/// Wire traffic uses a compact int NetId + level generation; this struct is the conceptual form -/// (generation + spawn sequence + archetype) used by host registry bookkeeping. -/// -internal readonly record struct NetEntityId( - int LevelGeneration, - long SpawnSequence, - string Archetype) -{ - public bool IsValid => - LevelGeneration > 0 && - SpawnSequence > 0 && - !string.IsNullOrWhiteSpace(Archetype); - - public override string ToString() => - $"{LevelGeneration}:{SpawnSequence}:{Archetype}"; -} diff --git a/ProtocolReplay.Tests/LaunchProtocolReplayTests.cs b/ProtocolReplay.Tests/LaunchProtocolReplayTests.cs new file mode 100644 index 0000000..0a07df9 --- /dev/null +++ b/ProtocolReplay.Tests/LaunchProtocolReplayTests.cs @@ -0,0 +1,112 @@ +using DeadCellsMultiplayerMod.PortableCore; +using DeadCellsMultiplayerMod.Network; +using DeadCellsMultiplayerMod.Tools; +using Xunit; + +namespace DeadCellsMultiplayerMod.ProtocolReplay.Tests; + +public sealed class LaunchProtocolReplayTests +{ + [Fact] + public void CommitLine_RoundTripsDescriptor() + { + var descriptor = CreateDescriptor(); + + var line = RunLaunchWireCodec.BuildCommitLine(descriptor); + Assert.StartsWith("RUNCOMMIT|", line, StringComparison.Ordinal); + Assert.True( + RunLaunchWireCodec.TryDecodeCommit(line["RUNCOMMIT|".Length..], out var decoded, out var error), + error); + Assert.NotNull(decoded); + Assert.Equal(descriptor, decoded); + } + + [Fact] + public void ReplayRejectsStaleAndIllegalTransitions() + { + var state = new CoopSessionStateMachine(); + + Assert.True(state.TryTransition(CoopSessionPhase.Lobby, 1, "connected", out var error), error); + Assert.True(state.TryTransition(CoopSessionPhase.LaunchCommitted, 2, "commit", out error), error); + Assert.False(state.TryTransition(CoopSessionPhase.Playing, 2, "stale", out error)); + Assert.Contains("Stale transition", error, StringComparison.Ordinal); + Assert.False(state.TryTransition(CoopSessionPhase.Disconnected, 3, "illegal", out error)); + Assert.Contains("Illegal co-op session transition", error, StringComparison.Ordinal); + Assert.Equal(CoopSessionPhase.LaunchCommitted, state.Phase); + } + + [Fact] + public void MalformedAndOversizedCommitPayloadsAreRejected() + { + Assert.False(RunLaunchWireCodec.TryDecodeCommit("not-base64", out _, out var malformedError)); + Assert.False(string.IsNullOrWhiteSpace(malformedError)); + + var oversized = new string('A', 64 * 1024 + 1); + Assert.False(RunLaunchWireCodec.TryDecodeCommit(oversized, out _, out var oversizedError)); + Assert.Contains("exceeds", oversizedError, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ClientLaunchGateMapsToPortableSessionPhases() + { + Assert.Equal( + CoopSessionPhase.LaunchCommitted, + RunLaunchCoordinator.MapClientLaunchPhaseToSessionPhase(LobbySession.ClientLaunchPhase.Armed)); + Assert.Equal( + CoopSessionPhase.LoadingLevel, + RunLaunchCoordinator.MapClientLaunchPhaseToSessionPhase(LobbySession.ClientLaunchPhase.Starting)); + Assert.Equal( + CoopSessionPhase.Playing, + RunLaunchCoordinator.MapClientLaunchPhaseToSessionPhase(LobbySession.ClientLaunchPhase.InRun)); + } + + [Fact] + public void RealtimeBudgetDropsOnlyWhenWindowIsFull() + { + var budget = new NetPacketBudget(maxBytes: 10, windowMilliseconds: 1000); + + Assert.True(budget.TryConsume(6)); + Assert.False(budget.TryConsume(5)); + Assert.Equal(1, budget.DroppedPackets); + Assert.Equal(5, budget.DroppedBytes); + } + + [Fact] + public void LifecycleTrackerInvalidatesOldGenerationOnStop() + { + var tracker = new LifecycleTracker("replay"); + var generation = tracker.Start(); + + Assert.True(tracker.IsCurrent(generation)); + Assert.True(tracker.TryBeginStop()); + Assert.False(tracker.IsCurrent(generation)); + + tracker.MarkDisposed(); + Assert.Equal(LifecycleState.Disposed, tracker.Snapshot.State); + } + + private static RunLaunchDescriptor CreateDescriptor() + { + return new RunLaunchDescriptor( + Guid.Parse("11111111-1111-1111-1111-111111111111"), + RunId: 7, + Sequence: 3, + ProtocolVersion: 17, + RunSeed: 12345, + LaunchKind: "normal", + InitialLevelId: "PrisonStart", + InitialLevelSeed: 42.5, + Difficulty: 2, + BossCells: 2, + BossRush: false, + DlcFlags: 0, + SaveSlot: 1, + BossRushSeed: 0, + LevelGenSeed: 0, + BossRushTier: 0, + Route: string.Empty, + BossSequence: string.Empty, + Modifiers: 0, + TargetArena: "PrisonStart"); + } +} diff --git a/ProtocolReplay.Tests/ProtocolReplay.Tests.csproj b/ProtocolReplay.Tests/ProtocolReplay.Tests.csproj new file mode 100644 index 0000000..a751cfc --- /dev/null +++ b/ProtocolReplay.Tests/ProtocolReplay.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + false + true + DeadCellsMultiplayerMod.ProtocolReplay.Tests + enable + enable + + + + + + + + + + + + diff --git a/Tools/CreateColor.cs b/Tools/CreateColor.cs index 739a380..3c38f8c 100644 --- a/Tools/CreateColor.cs +++ b/Tools/CreateColor.cs @@ -2,11 +2,6 @@ namespace DeadCellsMultiplayerMod.Tools { public static class MultiColor { - public static int CreateColor(int r, int g, int b, int a = 255) - { - return (a << 24) | (r << 16) | (g << 8) | b; - } - public static int ColorFromHex(string hex) { if (hex.StartsWith("#")) hex = hex.Substring(1); diff --git a/Tools/DailyLeaderboardGuard.cs b/Tools/DailyLeaderboardGuard.cs new file mode 100644 index 0000000..7432dba --- /dev/null +++ b/Tools/DailyLeaderboardGuard.cs @@ -0,0 +1,86 @@ +using System; +using dc.h2d; +using dc.pr; +using dc.ui; +using HaxeProxy.Runtime; +using Serilog; + +namespace DeadCellsMultiplayerMod.Tools +{ + /// Disables the vanilla Daily Challenge leaderboard panel and its network refresh. + internal static class DailyLeaderboardGuard + { + private static bool _hooksInstalled; + private static bool _logged; + + internal static void Initialize() + { + if (_hooksInstalled) + return; + + try + { + Hook__LeaderboardPanel.__constructor__ += OnConstructed; + Hook_LeaderboardPanel.set_visible += OnSetVisible; + Hook_LeaderboardPanel.refreshData += OnRefreshData; + Hook_LeaderboardPanel.update += OnUpdate; + _hooksInstalled = true; + Log.Information("[NetMod] Daily Challenge leaderboard disabled"); + } + catch (Exception ex) + { + Log.Warning(ex, "[NetMod] Failed to install Daily Challenge leaderboard guard"); + } + } + + private static void OnConstructed( + Hook__LeaderboardPanel.orig___constructor__ orig, + LeaderboardPanel self, + TitleScreen screen) + { + orig(self, screen); + Suppress(self); + } + + private static bool OnSetVisible( + Hook_LeaderboardPanel.orig_set_visible orig, + LeaderboardPanel self, + bool visible) + { + var result = orig(self, false); + Suppress(self); + return result; + } + + private static void OnRefreshData( + Hook_LeaderboardPanel.orig_refreshData orig, + LeaderboardPanel self, + Ref force) + { + Suppress(self); + } + + private static void OnUpdate( + Hook_LeaderboardPanel.orig_update orig, + LeaderboardPanel self) + { + orig(self); + Suppress(self); + } + + private static void Suppress(LeaderboardPanel? panel) + { + if (panel == null) + return; + + try { panel.root?.set_visible(false); } catch { } + try { panel.mainFlow?.set_visible(false); } catch { } + + if (_logged) + return; + + _logged = true; + Log.Information("[NetMod] Suppressed vanilla Daily Challenge leaderboard panel"); + } + } +} diff --git a/Tools/LifecycleTracker.cs b/Tools/LifecycleTracker.cs new file mode 100644 index 0000000..ab9a83b --- /dev/null +++ b/Tools/LifecycleTracker.cs @@ -0,0 +1,74 @@ +using System.Threading; + +namespace DeadCellsMultiplayerMod.Tools; + +internal enum LifecycleState +{ + Created, + Running, + Stopping, + Disposed +} + +internal readonly record struct LifecycleSnapshot( + string Owner, + LifecycleState State, + long Generation, + int OwnedResources); + +/// Small ownership guard for async game/network resources. +internal sealed class LifecycleTracker +{ + private readonly string _owner; + private int _state = (int)LifecycleState.Created; + private long _generation; + private int _ownedResources; + + internal LifecycleTracker(string owner) + { + _owner = owner; + } + + internal LifecycleSnapshot Snapshot => new( + _owner, + (LifecycleState)Volatile.Read(ref _state), + Interlocked.Read(ref _generation), + Volatile.Read(ref _ownedResources)); + + internal long Start() + { + Interlocked.Increment(ref _generation); + Interlocked.Exchange(ref _state, (int)LifecycleState.Running); + return Interlocked.Read(ref _generation); + } + + internal bool TryBeginStop() + { + return Interlocked.CompareExchange( + ref _state, + (int)LifecycleState.Stopping, + (int)LifecycleState.Running) == (int)LifecycleState.Running; + } + + internal void MarkDisposed() + { + Interlocked.Exchange(ref _state, (int)LifecycleState.Disposed); + Interlocked.Exchange(ref _ownedResources, 0); + } + + internal bool IsCurrent(long generation) => + generation == Interlocked.Read(ref _generation) && + (LifecycleState)Volatile.Read(ref _state) == LifecycleState.Running; + + internal void OwnResource() => Interlocked.Increment(ref _ownedResources); + + internal void ReleaseResource() + { + var current = Volatile.Read(ref _ownedResources); + while (current > 0 && + Interlocked.CompareExchange(ref _ownedResources, current - 1, current) != current) + { + current = Volatile.Read(ref _ownedResources); + } + } +} diff --git a/Tools/UiChrome.cs b/Tools/UiChrome.cs index cbd6ea0..2b8f3a0 100644 --- a/Tools/UiChrome.cs +++ b/Tools/UiChrome.cs @@ -190,7 +190,8 @@ public static void DrawContentCard( double h, double radius, int fillColor, - int edgeColor) + int edgeColor, + int accentColor = 0) { if (g == null || w <= 1.0 || h <= 1.0) return; @@ -210,6 +211,17 @@ public static void DrawContentCard( g.drawRect(lx, y + 2.0, lw, 2.0); g.endFill(); } + + // Optional menu-only signal mark. The lobby card keeps the quieter vanilla chrome. + if (accentColor != 0) + { + double accentA = 0.72; + int accentW = (int)System.Math.Min(92.0, System.Math.Max(24.0, w * 0.22)); + g.beginFill(Ref.From(ref accentColor), Ref.From(ref accentA)); + g.drawRect(x + radius, y + 2.0, accentW, 2.0); + g.drawRect(x + 2.0, y + radius, 2.0, System.Math.Min(46.0, h - radius * 2.0)); + g.endFill(); + } } } } diff --git a/UI/ConnectionUI/ConnectionUI.LobbyBeheaded.cs b/UI/ConnectionUI/ConnectionUI.LobbyBeheaded.cs index d801f54..fef619c 100644 --- a/UI/ConnectionUI/ConnectionUI.LobbyBeheaded.cs +++ b/UI/ConnectionUI/ConnectionUI.LobbyBeheaded.cs @@ -1,8 +1,13 @@ -using System; +using System; using dc; using dc.h2d; +using dc.haxe.ds; +using dc.hl.types; +using dc.hxd; using dc.libs.heaps.slib; +using dc.libs.heaps.slib._AnimManager; using dc.shader; +using dc.tool._AnimationTrack; using Hashlink.Virtuals; using HaxeProxy.Runtime; using ModCore.Modules; @@ -16,14 +21,27 @@ namespace DeadCellsMultiplayerMod.MultiplayerModUI.Connection { /// /// Lobby beheaded row: four fixed seats with UIChrome plates, hero sprites, and nicks. - /// Uses the title-screen shader stack (ColorMap + DirLighted + NormalMap) — ColorMap alone is not cached. + /// Uses the title-screen shader stack (ColorMap + DirLighted + NormalMap). ColorMap alone is not cached. /// public partial class ConnectionUI { private const string DefaultLobbySkin = "PrisonerDefault"; + private const string DefaultLobbyHead = "BaseFlame"; + + /// Log each head's detail once per session so lobby rebuilds don't spam the log. + private static readonly System.Collections.Generic.HashSet _loggedHeadIds = new(); + private static readonly System.Collections.Generic.HashSet _loggedHeadBone = new(); /// Four lobby beheaded seats under / beside the lobby code card. private dc.h2d.Object? _lobbyBeheadedRoot; + private MainPageLightingInitializer? _lobbyLighting; + private readonly List _lobbyBeheadedSkinIds = new(); + private readonly List _lobbyBeheadedHeadIds = new(); + private readonly List _lobbyHeadSprites = new(); + private readonly List _lobbyBeheadedSilhouette = new(); + private readonly List _lobbyBodyLastAnimCursor = new(); + private bool _lobbyBeheadedNeedsSkinRebind; + private static readonly Dictionary _lobbyAnimTracksByModel = new(); private readonly List animlist = new() { "idle", "idle", "idle", "idle" }; @@ -41,6 +59,17 @@ private void ClearLobbyBeheadedSprites() try { this._lobbyBeheadedRoot?.remove(); } catch { } this._lobbyBeheadedRoot = null; + this._lobbyBeheadedSkinIds.Clear(); + this._lobbyBeheadedHeadIds.Clear(); + for (int i = 0; i < this._lobbyHeadSprites.Count; i++) + { + try { this._lobbyHeadSprites[i]?.remove(); } catch { } + } + this._lobbyHeadSprites.Clear(); + LobbyHeadFx.ClearAll(); + this._lobbyBeheadedSilhouette.Clear(); + this._lobbyBodyLastAnimCursor.Clear(); + this._lobbyBeheadedNeedsSkinRebind = false; } private void HideLobbyBeheadedSprites() @@ -73,16 +102,17 @@ private void PlaceLobbyBeheadedUnderPlayerList( const double beheadedScale = 2.75; const double gapBetweenBeheaded = 24.0; const double approxTileWidth = 48.0; - const double approxTileHeight = 56.0; + const double approxTileHeight = 80.0; const double nickGap = 6.0; const double boxPadX = 20.0; - const double boxPadY = 18.0; + const double boxPadY = 28.0; // Whole row on screen (negative = left). const double rootXNudge = -5.0; // Plate vs beheaded — tune so the art sits in the middle of the box. // Negative X = box left; negative Y = box up. const double boxOffsetX = 5.0; - const double boxOffsetY = -75.0; + const double boxOffsetY = -95.0; + const double headOffsetY = -48.0; double scale = beheadedScale * uiScale; double bodyW = approxTileWidth * scale; @@ -137,20 +167,34 @@ private void PlaceLobbyBeheadedUnderPlayerList( string skinId = occupied ? slot.Skin : DefaultLobbySkin; if (string.IsNullOrWhiteSpace(skinId)) skinId = DefaultLobbySkin; + string headId = occupied ? slot.Head : DefaultLobbyHead; + if (string.IsNullOrWhiteSpace(headId)) + headId = DefaultLobbyHead; var spr = CreateLobbyBeheaded(skinId, i, scale); - if (spr == null) - { - Log.Warning("[ConnectionUI] Lobby beheaded[{Index}] create returned null", i); - } - else + if (spr != null) { this._lobbyBeheadedRoot.addChild(spr); spr.x = sprX; spr.y = sprY; + ApplyLobbyBeheadedSkin(spr, skinId, ResolveLobbyBeheadedAnim(i), i, "create"); if (!occupied) ApplyLobbyBeheadedSilhouette(spr); this.sprites.Add(spr); + this._lobbyBeheadedSkinIds.Add(skinId); + this._lobbyBeheadedHeadIds.Add(headId); + this._lobbyBeheadedSilhouette.Add(!occupied); + this._lobbyBodyLastAnimCursor.Add(-1); + + var head = CreateLobbyHead(headId, scale); + if (head != null) + { + this._lobbyBeheadedRoot.addChild(head); + PositionLobbyHeadOnBone(head, spr, skinId, headOffsetY * uiScale, headId); + if (!occupied) + ApplyLobbyBeheadedSilhouette(head); + this._lobbyHeadSprites.Add(head); + } } } catch (Exception ex) @@ -160,7 +204,10 @@ private void PlaceLobbyBeheadedUnderPlayerList( string nick = ResolveLobbySlotNick(slot); if (string.IsNullOrWhiteSpace(nick)) + { + BringLobbyHeadsToFront(); continue; + } var nickText = Assets.Class.makeText( nick.AsHaxeString(), @@ -183,9 +230,11 @@ private void PlaceLobbyBeheadedUnderPlayerList( // Nick follows the box, not the geometric slot. nickText.y = plateY + slotH + nickGap * uiScale; this.connectionLabels.Add(nickText); + BringLobbyHeadsToFront(); } try { this._lobbyBeheadedRoot.set_visible(true); } catch { } + this._lobbyBeheadedNeedsSkinRebind = this.sprites.Count > 0; } private static string ResolveLobbySlotNick(_ConnectionUI.LobbyPlayerSlot slot) @@ -203,30 +252,23 @@ private static string ResolveLobbySlotNick(_ConnectionUI.LobbyPlayerSlot slot) return slot.Nick; } + private string ResolveLobbyBeheadedAnim(int index) + { + return index >= 0 && index < this.animlist.Count ? this.animlist[index] : "idle"; + } + private HSprite? CreateLobbyBeheaded(string skinId, int index, double scale) { if (string.IsNullOrWhiteSpace(skinId)) skinId = DefaultLobbySkin; - string skinanim = index >= 0 && index < this.animlist.Count ? this.animlist[index] : "idle"; - virtual_colorMap_consoleCmdId_glowData_group_head_incompatibleHeads_item_model_onlyDefaultHead_scarfBlendMode_scarfs_ skinInfo; - try - { - skinInfo = Cdb.Class.getSkinInfo(skinId.AsHaxeString()); - } - catch (Exception ex) - { - Log.Warning("[ConnectionUI] getSkinInfo({Skin}) failed: {Message}; using {Fallback}", skinId, ex.Message, DefaultLobbySkin); - skinId = DefaultLobbySkin; - skinInfo = Cdb.Class.getSkinInfo(DefaultLobbySkin.AsHaxeString()); - } + string skinanim = ResolveLobbyBeheadedAnim(index); + if (!TryResolveLobbySkinInfo(ref skinId, out var skinInfo) || skinInfo == null) + return null; SpriteLib g = Assets.Class.getHeroLib(skinInfo); if (g == null) - { - Log.Warning("[ConnectionUI] getHeroLib returned null for {Skin}", skinId); return null; - } var spr = new HSprite(g, skinanim.AsHaxeString(), Ref.Null, null); @@ -238,32 +280,45 @@ private static string ResolveLobbySlotNick(_ConnectionUI.LobbyPlayerSlot slot) pivot.isUndefined = false; this.spriteui = spr; - // Must match title-screen hero linker cache: - // Base2d + ColorMap + DirLighted + NormalMap. ColorMap alone is NOT cached → invisible. - initColorMap(skinId, skinanim); - AnimManager animManager = spr.get_anim().play(skinanim.AsHaxeString(), null, null).loop(null); - animManager.genSpeed = 0.4; + EnsureLobbyBodyIdleAnim(spr, skinanim); double absScale = System.Math.Abs(scale); // Default idle faces right; negative scaleX faces left. spr.scaleX = -absScale; spr.scaleY = absScale; + try { spr.smooth = false; } catch { } spr.set_visible(true); return spr; } - private static void ApplyLobbyBeheadedSilhouette(HSprite spr) + private static void ApplyLobbyBeheadedSilhouette(dc.h2d.Object obj) { try { - var color = spr.color; - if (color == null) + if (obj == null) return; - // Dark silhouette, not pure black (pure black disappears on navy plates). - color.x = 0.18; - color.y = 0.18; - color.z = 0.22; + + if (obj is HSprite spr) + { + var color = spr.color; + if (color != null) + { + // Dark silhouette, not pure black (pure black disappears on navy plates). + color.x = 0.18; + color.y = 0.18; + color.z = 0.22; + } + } + + var children = obj.children; + if (children == null) + return; + for (int i = 0; i < children.length; i++) + { + if (children.array[i] is dc.h2d.Object child) + ApplyLobbyBeheadedSilhouette(child); + } } catch { @@ -271,10 +326,16 @@ private static void ApplyLobbyBeheadedSilhouette(HSprite spr) } private void EnsureLobbyBeheadedLighting() + { + PushLobbyBeheadedLighting(); + } + + private void PushLobbyBeheadedLighting() { try { - _ = new MainPageLightingInitializer(this); + this._lobbyLighting ??= new MainPageLightingInitializer(this); + this._lobbyLighting.Apply(this); } catch (Exception ex) { @@ -282,6 +343,119 @@ private void EnsureLobbyBeheadedLighting() } } + private void FlushPendingLobbyBeheadedSkinApply() + { + if (!this._lobbyBeheadedNeedsSkinRebind) + return; + + this._lobbyBeheadedNeedsSkinRebind = false; + PushLobbyBeheadedLighting(); + + int count = System.Math.Min(this.sprites.Count, this._lobbyBeheadedSkinIds.Count); + for (int i = 0; i < count; i++) + { + var spr = this.sprites[i]; + if (spr == null) + continue; + + ApplyLobbyBeheadedSkin(spr, this._lobbyBeheadedSkinIds[i], ResolveLobbyBeheadedAnim(i), i, "rebind"); + if (i < this._lobbyBeheadedSilhouette.Count && this._lobbyBeheadedSilhouette[i]) + ApplyLobbyBeheadedSilhouette(spr); + + if (i < this._lobbyHeadSprites.Count && this._lobbyHeadSprites[i] != null) + { + if (i < this._lobbyBeheadedSilhouette.Count && this._lobbyBeheadedSilhouette[i]) + ApplyLobbyBeheadedSilhouette(this._lobbyHeadSprites[i]); + } + } + } + + internal void TickLobbyHeadBones() + { + int n = System.Math.Min(this.sprites.Count, this._lobbyHeadSprites.Count); + n = System.Math.Min(n, this._lobbyBeheadedSkinIds.Count); + double dt = GetLobbyAnimDt(); + for (int i = 0; i < n; i++) + { + var body = this.sprites[i]; + var head = this._lobbyHeadSprites[i]; + if (body == null || head == null) + continue; + + int lastCursor = i < this._lobbyBodyLastAnimCursor.Count ? this._lobbyBodyLastAnimCursor[i] : -1; + int cursor = ReadLobbyAnimCursor(body); + // Title HSprites only step during sync(). If the cursor did not move since last + // tick, drive AnimManager here the same way an entity does before updateHeadFx. + if (cursor == lastCursor) + { + try { body.get_anim()?._update(dt); } catch { } + cursor = ReadLobbyAnimCursor(body); + } + + while (this._lobbyBodyLastAnimCursor.Count <= i) + this._lobbyBodyLastAnimCursor.Add(-1); + this._lobbyBodyLastAnimCursor[i] = cursor; + + string headId = i < this._lobbyBeheadedHeadIds.Count ? this._lobbyBeheadedHeadIds[i] : DefaultLobbyHead; + PositionLobbyHeadOnBone(head, body, this._lobbyBeheadedSkinIds[i], fallbackLocalY: 0, headId); + } + } + + private double GetLobbyAnimDt() + { + try + { + if (this.tmod > 0.0) + return this.tmod; + } + catch + { + } + + return 1.0; + } + + private static int ReadLobbyAnimCursor(HSprite sprite) + { + try + { + var stack = sprite.get_anim()?.stack; + if (stack != null && stack.length > 0) + { + AnimInstance? top = null; + try { top = stack.getDyn(0) as AnimInstance; } catch { } + if (top == null) + { + try { top = stack.array[0] as AnimInstance; } catch { } + } + if (top != null) + return top.animCursor; + } + } + catch + { + } + + return sprite.frame; + } + + private static void EnsureLobbyBodyIdleAnim(HSprite spr, string animGroup) + { + if (spr == null) + return; + if (string.IsNullOrWhiteSpace(animGroup)) + animGroup = "idle"; + + try + { + AnimManager anim = spr.get_anim().play(animGroup.AsHaxeString(), null, null).loop(null); + anim.genSpeed = 0.4; + } + catch + { + } + } + public void playallanims(HSprite hSprite) { try @@ -307,27 +481,78 @@ public void playallanims(HSprite hSprite) /// /// Title-screen beheaded path: ColorMap + DirLighted + NormalMap. - /// Do not strip DirLighted/NormalMap — ColorMap alone is missing from the shader cache. + /// Do not strip DirLighted/NormalMap. ColorMap alone is missing from the shader cache. /// public void initColorMap(string colorMap, string? animGroup = null) { if (this.spriteui == null) return; + ApplyLobbyBeheadedSkin(this.spriteui, colorMap, animGroup, -1, "initColorMap"); + } + + private static bool TryResolveLobbySkinInfo( + ref string skinId, + out virtual_colorMap_consoleCmdId_glowData_group_head_incompatibleHeads_item_model_onlyDefaultHead_scarfBlendMode_scarfs_? skinInfo) + { + skinInfo = null; + if (string.IsNullOrWhiteSpace(skinId)) + skinId = DefaultLobbySkin; + + try + { + skinInfo = Cdb.Class.getSkinInfo(skinId.AsHaxeString()); + if (skinInfo != null) + return true; + } + catch + { + } + + // Same object GameDataSync/GhostKing use after reading user.heroSkin: + // getHeroSkinInfos() already is the CDB row. consoleCmdId is not a getSkinInfo key. + if (_ConnectionUI.TryGetCachedLocalSkinInfo(out var cached) && cached != null) + { + skinInfo = cached; + return true; + } + + skinId = DefaultLobbySkin; + try + { + skinInfo = Cdb.Class.getSkinInfo(DefaultLobbySkin.AsHaxeString()); + return skinInfo != null; + } + catch + { + return false; + } + } + + private void ApplyLobbyBeheadedSkin(HSprite spr, string? skinId, string? animGroup, int index, string reason) + { + if (spr == null) + return; + + string resolvedSkin = string.IsNullOrWhiteSpace(skinId) ? DefaultLobbySkin : skinId; + if (!TryResolveLobbySkinInfo(ref resolvedSkin, out var skinInfo) || skinInfo == null) + return; + + PushLobbyBeheadedLighting(); + this.spriteui = spr; - string skinId = string.IsNullOrWhiteSpace(colorMap) ? DefaultLobbySkin : colorMap; try { - dc.shader.ColorMap existing = (dc.shader.ColorMap)this.spriteui.getShader(dc.shader.ColorMap.Class); + dc.shader.ColorMap existing = (dc.shader.ColorMap)spr.getShader(dc.shader.ColorMap.Class); if (existing != null) - this.spriteui.removeShader(existing); + spr.removeShader(existing); - DirLighted existingLight = (DirLighted)this.spriteui.getShader(DirLighted.Class); + DirLighted existingLight = (DirLighted)spr.getShader(DirLighted.Class); if (existingLight != null) - this.spriteui.removeShader(existingLight); + spr.removeShader(existingLight); - NormalMap existingNormal = (NormalMap)this.spriteui.getShader(NormalMap.Class); + NormalMap existingNormal = (NormalMap)spr.getShader(NormalMap.Class); if (existingNormal != null) - this.spriteui.removeShader(existingNormal); + spr.removeShader(existingNormal); } catch { @@ -335,35 +560,589 @@ public void initColorMap(string colorMap, string? animGroup = null) try { - var skinInfo = Cdb.Class.getSkinInfo(skinId.AsHaxeString()); - dc.h3d.mat.Texture heroColorMap = Assets.Class.getHeroColorMap(skinInfo); - if (heroColorMap == null) + SpriteLib heroLib = Assets.Class.getHeroLib(skinInfo); + if (heroLib != null && !ReferenceEquals(spr.lib, heroLib)) { - Log.Warning("[ConnectionUI] getHeroColorMap returned null for {Skin}", skinId); - return; + int startFrame = 0; + bool stopAllAnims = false; + spr.set(heroLib, (animGroup ?? "idle").AsHaxeString(), Ref.From(ref startFrame), Ref.From(ref stopAllAnims)); } - this.spriteui.addShader(new dc.shader.ColorMap(heroColorMap)); - this.spriteui.addShader(new DirLighted()); + EnsureLobbyBodyIdleAnim(spr, animGroup ?? "idle"); + + dc.h3d.mat.Texture? heroColorMap = ResolveLobbyHeroColorMap(skinInfo, resolvedSkin); + if (heroColorMap == null) + return; + + EnsureLobbyColorMapTextureReady(heroColorMap); + + // Match the working lobby renderer: ColorMap, NormalMap, then DirLighted. + // DirLighted must be last so its shadow pass sees the remapped hero colors. + spr.addShader(new dc.shader.ColorMap(heroColorMap)); dc.h3d.mat.Texture? normalMap = null; + try { normalMap = spr.lib?.getNormalMapFromSprite(spr); } catch { } + if (normalMap != null) + { + try { spr.addOrUpdateNormalMapTexture(normalMap); } catch { } + if (spr.getShader(NormalMap.Class) == null) + spr.addShader(new NormalMap(normalMap)); + } + + var dirLight = new DirLighted(); + spr.addShader(dirLight); + ApplyLobbyHeroLightDefaults(spr, dirLight); + + try { spr.smooth = false; } catch { } + } + catch + { + } + } + + private static void ApplyLobbyHeroLightDefaults(HSprite spr, DirLighted light) + { + try + { + var globals = spr.getScene().ctx.manager.globals; + var shaderGlobals = light.shader.globals; + int shadowId = -1; + int dirId = -1; + + for (int i = 0; i < shaderGlobals.length; i++) + { + var global = shaderGlobals.getDyn(i); + var name = global.v.name.ToString(); + var parent = global.v.parent.name.ToString(); + if (parent != "light") + continue; + + if (name == "shadowColor") + shadowId = global.globalId; + else if (name == "dirVec") + dirId = global.globalId; + + if (shadowId != -1 && dirId != -1) + break; + } + + if (shadowId != -1) + { + globals.map.set(shadowId, new dc.h3d.Vector( + Ref.In(1), Ref.In(1), Ref.In(1), Ref.In(1))); + } + + if (dirId != -1) + { + globals.map.set(dirId, new dc.h3d.Vector( + Ref.In(0), Ref.In(0), Ref.In(1), Ref.In(1))); + } + } + catch + { + } + } + + private void BringLobbyHeadsToFront() + { + if (this._lobbyBeheadedRoot == null) + return; + + for (int i = 0; i < this._lobbyHeadSprites.Count; i++) + { + var head = this._lobbyHeadSprites[i]; + if (head == null) + continue; + try { this._lobbyBeheadedRoot.addChild(head); } catch { } + } + } + + private static dc.h2d.Object? CreateLobbyHead(string headId, double scale) + { + if (string.IsNullOrWhiteSpace(headId)) + headId = DefaultLobbyHead; + + var root = new dc.h2d.Object(null); + + if (!LobbyHeadSkin.TryResolve(headId, out var atlas, out var parts, out var glowData, out var particleEffects)) + { + AttachDefaultLobbyHeadContent(root, scale, headId); + return root; + } + + if (parts.Count == 0) + { + AttachDefaultLobbyHeadContent(root, scale, headId); + return root; + } + + var lib = LobbyHeadSkin.LoadAtlas(atlas); + if (lib == null) + { + AttachDefaultLobbyHeadContent(root, scale, headId); + return root; + } + + int mainIndex = 0; + for (int i = 1; i < parts.Count; i++) + { + if (parts[i].PartNumber == 0) + { + mainIndex = i; + break; + } + } + + // Game layout: every part is a sibling of the head container at its (unscaled) + // CDB offset, scaled by headScale * part.Scale. Nesting parts under a mirrored, + // scaled root is what mis-placed the eye. + bool firstTime = _loggedHeadIds.Add(headId); + int added = 0; + for (int i = 0; i < parts.Count; i++) + { + var part = parts[i]; + if (!LobbyHeadSkin.TryResolveGroup(lib, part.Group, part.IdleAnim, out var group)) + continue; + + var partSpr = CreateLobbyHeadPart(lib, group, scale * part.Scale, part, glowData); + if (partSpr == null) + continue; + + try { root.addChild(partSpr); } catch { } + added++; + if (firstTime) + { + Log.Information( + "[ConnectionUI] Lobby head part head={Head} part={Part} group={Group} anim={Anim} spd={Spd} offset={OX},{OY} scale={S}", + headId, part.PartNumber, group, part.IdleAnim ?? group, part.IdleAnimSpeed ?? 0.5, + part.OffsetX, part.OffsetY, global::System.Math.Round(scale * part.Scale, 3)); + } + } + + if (added == 0) + { + AttachDefaultLobbyHeadContent(root, scale, headId); + return root; + } + + // Body faces left. Flip the whole custom-head assembly on X so part offsets + // (eye, flame) stay on the correct side. + try { root.scaleX = -1; } catch { } + + return root; + } + + private static void AttachDefaultLobbyHeadContent(dc.h2d.Object root, double scale, string headId) + { + try { LobbyHeadFx.AttachDefaultHeadFx(root, scale, headId, mirrorX: true); } catch { } + + try + { + var star = CreateDefaultLobbyHeadStar(scale); + if (star != null) + { + try { root.addChild(star); } catch { } + } + } + catch + { + } + + if (_loggedHeadIds.Add(headId)) + Log.Information("[ConnectionUI] Lobby default head head={Head} (star + particle FX)", headId); + } + + /// + /// Game-accurate default head: no customHead sprite exists for BaseFlame (it is particles only), + /// so the game renders the homunculus eye (fxSmallStar, Add blend) as the visible head sprite. + /// + private static HSprite? CreateDefaultLobbyHeadStar(double scale) + { + try + { + SpriteLib fx = Assets.Class.fx; + if (fx == null) + return null; + + var spr = new HSprite(fx, "fxSmallStar".AsHaxeString(), Ref.Null, null); + SpritePivot pivot = spr.pivot; + pivot.centerFactorX = 0.5; + pivot.centerFactorY = 0.5; + pivot.usingFactor = true; + pivot.isUndefined = false; + + try { spr.rotation = 1.57; } catch { } + try { spr.posChanged = true; } catch { } + try { spr.blendMode = new dc.h2d.BlendMode.Add(); } catch { } + + // The game tints the homunculus eye warm-orange (0xFFBF00) when the skill is usable. try { - string group = string.IsNullOrWhiteSpace(animGroup) ? "idle" : animGroup; - normalMap = this.spriteui.lib?.getNormalMapFromGroup(group.AsHaxeString()); + var color = spr.color; + color.x = 1.0; + color.y = 191.0 / 255.0; + color.z = 0.0; + } + catch { } + + double absScale = System.Math.Abs(scale); + // The homunculus eye star sits on the black head; keep it visible. + double starScale = absScale * 0.55; + spr.scaleX = -starScale; + spr.scaleY = starScale; + try { spr.smooth = false; } catch { } + spr.set_visible(true); + return spr; + } + catch + { + return null; + } + } + + private static HSprite? CreateLobbyHeadPart( + SpriteLib lib, + string group, + double scale, + LobbyHeadSkin.Part part, + dc.hl.types.ArrayObj? glowData) + { + if (lib == null || string.IsNullOrWhiteSpace(group)) + return null; + + var spr = new HSprite(lib, group.AsHaxeString(), Ref.Null, null); + SpritePivot pivot = spr.pivot; + pivot.centerFactorX = 0.5; + pivot.centerFactorY = 0.5; + pivot.usingFactor = true; + pivot.isUndefined = false; + + // The violet head tint: the game colors the head parts with GradientHiLo + // (lo = colorDark ?? colorLight, hi = colorLight). The body-skin ColorMap + // must NOT be applied to the head — that is what rendered it gray. + if (part.ColorDark.HasValue || part.ColorLight.HasValue) + { + try + { + int? lo = part.ColorDark ?? part.ColorLight; + int? hi = part.ColorLight ?? part.ColorDark; + if (lo.HasValue && hi.HasValue) + spr.addShader(new GradientHiLo(lo.Value, hi.Value, null)); } catch { - try { normalMap = this.spriteui.lib?.getNormalMapFromSprite(this.spriteui); } catch { } } + } - if (normalMap != null) - this.spriteui.addShader(new NormalMap(normalMap)); + if (glowData != null && glowData.length > 0) + { + try { spr.addShader(new GlowKey(glowData)); } catch { } + } + + try + { + var normal = lib.getNormalMapFromSprite(spr); + if (normal != null) + spr.addShader(new NormalMap(normal)); + } + catch + { + } + + // Eye (part 1) is additive in the game, matching the glowing homunculus eye. + if (part.PartNumber == 1) + { + try { spr.blendMode = new dc.h2d.BlendMode.Add(); } catch { } + } + + try + { + AnimManager anim = spr.get_anim().play(group.AsHaxeString(), null, null).loop(null); + anim.genSpeed = part.IdleAnimSpeed ?? 0.5; + } + catch + { + } + + double absScale = System.Math.Abs(scale); + // Facing is applied on the head root (scaleX = -1). Parts keep a positive scale + // so CDB offsets are mirrored with the assembly instead of flipping in place. + spr.scaleX = absScale; + spr.scaleY = absScale; + spr.x = part.OffsetX; + spr.y = part.OffsetY; + try { spr.smooth = false; } catch { } + spr.set_visible(true); + return spr; + } + + private static dc.h3d.mat.Texture? ResolveLobbyHeroColorMap( + virtual_colorMap_consoleCmdId_glowData_group_head_incompatibleHeads_item_model_onlyDefaultHead_scarfBlendMode_scarfs_ skinInfo, + string skinId) + { + try + { + return Assets.Class.getHeroColorMap(skinInfo); + } + catch + { + return null; + } + } + + private static void EnsureLobbyColorMapTextureReady(dc.h3d.mat.Texture texture) + { + if (texture == null) + return; + + try { _ = texture.width; } catch { } + try { _ = texture.height; } catch { } + } + + /// + /// Same headBone placement as Kinghead: origin at the sprite frame, then the + /// headBone animation track. Lobby bodies are UI-scaled, so track pixels + /// are multiplied by abs(scale). dir comes from scaleX (left-facing = -1). + /// + private void PositionLobbyHeadOnBone( + dc.h2d.Object head, + HSprite body, + string skinId, + double fallbackLocalY, + string? headId) + { + if (head == null || body == null) + return; + + if (TryGetLobbyHeadBonePosition(body, skinId, out var hx, out var hy)) + { + hy -= CustomLobbyHeadNeckLift(head, headId, body.scaleY); + head.x = hx; + head.y = hy; + try { head.posChanged = true; } catch { } + return; + } + + if (fallbackLocalY == 0) + { + double lift = CustomLobbyHeadNeckLift(head, headId, body.scaleY); + if (lift == 0) + return; + head.x = body.x; + head.y = body.y - lift; + try { head.posChanged = true; } catch { } + return; + } + + head.x = body.x; + head.y = body.y + fallbackLocalY - CustomLobbyHeadNeckLift(head, headId, body.scaleY); + try { head.posChanged = true; } catch { } + } + + /// + /// Custom heads (root scaleX = -1) sit a bit low on the stump vs in-game. + /// + private static double CustomLobbyHeadNeckLift(dc.h2d.Object head, string? headId, double bodyScaleY) + { + bool custom = false; + try + { + if (head != null && head.scaleX < 0) + custom = true; + } + catch + { + } + + if (!custom && + !string.IsNullOrWhiteSpace(headId) && + !string.Equals(headId, DefaultLobbyHead, StringComparison.Ordinal)) + custom = true; + + if (!custom) + return 0; + + double s = System.Math.Abs(bodyScaleY); + if (s < 0.001) + s = 1.0; + return 5.0 * s; + } + + private static bool TryGetLobbyHeadBonePosition(HSprite sprite, string skinId, out double headX, out double headY) + { + headX = 0; + headY = 0; + if (sprite == null) + return false; + + var tracks = ResolveLobbyAnimationTracks(skinId); + if (tracks == null) + { + LogLobbyHeadBoneMiss(skinId, sprite, "no-tracks"); + return false; + } + + var headSkeleton = ResolveLobbyHeadSkeleton(tracks, sprite); + if (headSkeleton == null) + { + LogLobbyHeadBoneMiss(skinId, sprite, "no-headBone"); + return false; + } + + var frameData = sprite.frameData; + var pivot = sprite.pivot; + if (frameData == null || pivot == null) + return false; + + int frame = sprite.frame; + int cursor = ReadLobbyAnimCursor(sprite); + double dir = sprite.scaleX < 0 ? -1.0 : 1.0; + double s = System.Math.Abs(sprite.scaleY); + if (s < 0.001) + s = 1.0; + + // Kinghead uses sprite.frame. Idle tracks are packed per anim step, so if the atlas + // frame is stuck, fall back to animCursor (the timeline index). + int trackFrame = frame; + double x0 = AnimationTrack_Impl_.Class.x(headSkeleton, frame); + double y0 = AnimationTrack_Impl_.Class.y(headSkeleton, frame); + if (cursor != frame) + { + double x1 = AnimationTrack_Impl_.Class.x(headSkeleton, cursor); + double y1 = AnimationTrack_Impl_.Class.y(headSkeleton, cursor); + if (x1 != x0 || y1 != y0) + trackFrame = cursor; + } + + headX = sprite.x - frameData.realWid * pivot.centerFactorX * dir * s; + headX += AnimationTrack_Impl_.Class.x(headSkeleton, trackFrame) * dir * s; + headY = sprite.y - frameData.realHei * pivot.centerFactorY * s - 3.0 * s; + headY += AnimationTrack_Impl_.Class.y(headSkeleton, trackFrame) * s; + + if (_loggedHeadBone.Add(skinId + "|" + (sprite.groupName?.ToString() ?? ""))) + { + Log.Information( + "[ConnectionUI] headBone skin={Skin} group={Group} frame={Frame} cursor={Cursor} trackFrame={Track} xy={X},{Y}", + skinId, + sprite.groupName?.ToString(), + frame, + cursor, + trackFrame, + global::System.Math.Round(headX, 2), + global::System.Math.Round(headY, 2)); + } + + return true; + } + + private static void LogLobbyHeadBoneMiss(string skinId, HSprite sprite, string reason) + { + string group = sprite?.groupName?.ToString() ?? ""; + if (!_loggedHeadBone.Add("miss|" + reason + "|" + skinId + "|" + group)) + return; + Log.Warning("[ConnectionUI] headBone miss reason={Reason} skin={Skin} group={Group}", reason, skinId, group); + } + + private static ArrayBytes_Int? ResolveLobbyHeadSkeleton(StringMap tracks, HSprite sprite) + { + if (tracks == null) + return null; + + ArrayBytes_Int? TryGroup(dc.String? key) + { + if (key == null) + return null; + try + { + var groupTracks = tracks.get(key) as StringMap; + return groupTracks?.get("headBone".AsHaxeString()) as ArrayBytes_Int; + } + catch + { + return null; + } + } + + var bone = TryGroup(sprite.groupName); + if (bone != null) + return bone; + + try + { + var stack = sprite.get_anim()?.stack; + if (stack != null && stack.length > 0) + { + var top = stack.getDyn(0) as AnimInstance; + bone = TryGroup(top?.group); + if (bone != null) + return bone; + } + } + catch + { + } + + bone = TryGroup("idle".AsHaxeString()); + if (bone != null) + return bone; + + string playing = sprite.groupName?.ToString() ?? string.Empty; + ArrayBytes_Int? idleBone = null; + ArrayBytes_Int? anyBone = null; + try + { + var keys = tracks.keys(); + while (keys.hasNext()) + { + var key = keys.next(); + var found = TryGroup(key); + if (found == null) + continue; + + anyBone ??= found; + string name = key?.ToString() ?? string.Empty; + if (!string.IsNullOrEmpty(playing) && + string.Equals(name, playing, StringComparison.OrdinalIgnoreCase)) + return found; + if (idleBone == null && name.IndexOf("idle", StringComparison.OrdinalIgnoreCase) >= 0) + idleBone = found; + } + } + catch + { + } + + return idleBone ?? anyBone; + } + + private static StringMap? ResolveLobbyAnimationTracks(string skinId) + { + if (string.IsNullOrWhiteSpace(skinId)) + skinId = DefaultLobbySkin; + + if (_lobbyAnimTracksByModel.TryGetValue(skinId, out var cached) && cached != null) + return cached; + + StringMap? tracks = null; + try + { + string resolved = skinId; + if (!TryResolveLobbySkinInfo(ref resolved, out var skinInfo) || skinInfo?.model == null) + return cached; + + dc._String _String = dc.String.Class; + dc.String path = "atlas/".AsHaxeString(); + path = _String.__add__(_String.__add__(path, skinInfo.model), "_tracks.json".AsHaxeString()); + tracks = Assets.Class.getAnimationTracks(Res.Class.load(path)); } catch (Exception ex) { - Log.Warning("[ConnectionUI] initColorMap({Skin}) failed: {Message}", skinId, ex.Message); + Log.Warning("[ConnectionUI] Lobby headBone tracks failed skin={Skin}: {Message}", skinId, ex.Message); } + + if (tracks != null) + _lobbyAnimTracksByModel[skinId] = tracks; + return tracks; } } } diff --git a/UI/ConnectionUI/ConnectionUI.cs b/UI/ConnectionUI/ConnectionUI.cs index 0465a71..6f7975e 100644 --- a/UI/ConnectionUI/ConnectionUI.cs +++ b/UI/ConnectionUI/ConnectionUI.cs @@ -123,6 +123,11 @@ internal sealed class PendingInfo private bool _menuVisible; private int _layoutW = 255; private int _layoutH = 720; + private double _lastLayoutUiScale = double.NaN; + private double _lastLayoutTextBoost = double.NaN; + private int _layoutMetricProbeCountdown; + private const int LayoutMetricProbeIntervalFrames = 10; + private int _lobbyCodeProbeCountdown; private Graphics? _hoverBorder; private static readonly int HoverBorderColor = 0x59D5FF; /// Same callback as the screen's Back/Disconnect button; fired on Escape. @@ -130,9 +135,12 @@ internal sealed class PendingInfo private static ConnectionUI? Instance; private HSprite? spriteui; + private readonly LifecycleTracker _lifecycle = new("ConnectionUI"); + private readonly long _lifecycleGeneration; public ConnectionUI(Process parent) : base(parent) { + _lifecycleGeneration = _lifecycle.Start(); Instance = this; this.createRoot(parent.root); MainPageLightingInitializer mainPage = new MainPageLightingInitializer(this); @@ -177,7 +185,8 @@ public static bool set_visible try { - if (instance.root == null || instance.destroyed) + if (!instance._lifecycle.IsCurrent(instance._lifecycleGeneration) || + instance.root == null || instance.destroyed) { Instance = null; return null; @@ -255,6 +264,7 @@ public static void BeginMenu() { PendingButtons.Clear(); PendingInfos.Clear(); + _ConnectionUI.InvalidateLobbyPlayerSlots(); var instance = TryGetLiveInstance(); if (instance != null) { @@ -319,6 +329,7 @@ public static void ShowLobbyMode() var instance = TryGetLiveInstance(); if (instance == null) return; + _ConnectionUI.InvalidateLobbyPlayerSlots(); instance._mode = UiMode.Lobby; instance._menuVisible = false; instance._keepLobbyVisible = false; @@ -395,8 +406,10 @@ private void RebuildMenuScreen() : System.Math.Min(bgWidth - padX * 2.0 - lobbyReserve, SideContentWidth * uiScale); double colGap = 14.0 * uiScale; double rowGap = 16.0 * uiScale; - // Same typography boost as the hub for every styled menu screen. - double menuText = textUi * 1.55; + // Keep button labels at their existing scale; menu information gets the requested + // additional readability boost without changing button or prompt text. + double buttonText = textUi * 1.55; + double menuText = buttonText * MenuTextScaleBoost; double cursorY; if (this._keepLobbyVisible) @@ -453,7 +466,8 @@ private void RebuildMenuScreen() cardH, CardCornerRadius * uiScale, ContentCardFill, - ContentCardEdge); + ContentCardEdge, + accentColor: AccentColor); } foreach (var info in PendingInfos) @@ -490,14 +504,14 @@ private void RebuildMenuScreen() double btnH = System.Math.Max( GetButtonHeight(actions[i], showHelp: true, uiScale, styled: true), GetButtonHeight(actions[i + 1], showHelp: true, uiScale, styled: true)); - PlaceMenuButton(actions[i], clusterX, cursorY, btnW, btnH, menuText, uiScale, showHelp: true, centerText: true, styled: true); - PlaceMenuButton(actions[i + 1], clusterX + btnW + colGap, cursorY, btnW, btnH, menuText, uiScale, showHelp: true, centerText: true, styled: true); + PlaceMenuButton(actions[i], clusterX, cursorY, btnW, btnH, buttonText, uiScale, showHelp: true, centerText: true, styled: true); + PlaceMenuButton(actions[i + 1], clusterX + btnW + colGap, cursorY, btnW, btnH, buttonText, uiScale, showHelp: true, centerText: true, styled: true); cursorY += btnH + rowGap; } else { double btnH = GetButtonHeight(actions[i], showHelp: true, uiScale, styled: true); - PlaceMenuButton(actions[i], clusterX, cursorY, clusterW, btnH, menuText, uiScale, showHelp: true, centerText: true, styled: true); + PlaceMenuButton(actions[i], clusterX, cursorY, clusterW, btnH, buttonText, uiScale, showHelp: true, centerText: true, styled: true); cursorY += btnH + rowGap; } } @@ -505,7 +519,7 @@ private void RebuildMenuScreen() for (int i = 0; i < backs.Count; i++) { double btnH = GetButtonHeight(backs[i], showHelp: true, uiScale, styled: true); - PlaceMenuButton(backs[i], clusterX, cursorY, clusterW, btnH, menuText, uiScale, showHelp: true, centerText: true, styled: true); + PlaceMenuButton(backs[i], clusterX, cursorY, clusterW, btnH, buttonText, uiScale, showHelp: true, centerText: true, styled: true); cursorY += btnH + rowGap; } } @@ -516,7 +530,7 @@ private void RebuildMenuScreen() { var btn = PendingButtons[i]; double btnH = GetButtonHeight(btn, showHelp: true, uiScale, styled: true); - PlaceMenuButton(btn, padX, cursorY, listW, btnH, menuText, uiScale, showHelp: true, centerText: false, styled: true); + PlaceMenuButton(btn, padX, cursorY, listW, btnH, buttonText, uiScale, showHelp: true, centerText: false, styled: true); cursorY += btnH + rowGap; } } @@ -1021,6 +1035,7 @@ public void updateConnections() public static void NotifyConnectionsChanged() { + _ConnectionUI.InvalidateLobbyPlayerSlots(); TryGetLiveInstance()?.updateConnections(); } @@ -1174,6 +1189,17 @@ private void UpdateLobbyIdLabel(bool forceRefreshText) if (this._lobbyPanelRoot == null) return; + if (!forceRefreshText) + { + if (this._lobbyCodeProbeCountdown > 0) + { + this._lobbyCodeProbeCountdown--; + return; + } + + this._lobbyCodeProbeCountdown = LayoutMetricProbeIntervalFrames; + } + string? lobbyCode = null; try { @@ -1220,7 +1246,7 @@ private void clean() // Button column width inside the full-screen panel (non-hub menus). private const int SideContentWidth = 560; private const double NavButtonClusterWidth = 780.0; - + private const double MenuTextScaleBoost = 1.15; public override void onResize() { base.onResize(); @@ -1242,6 +1268,10 @@ public override void onResize() // Panel is always absolute full screen — never a short/cropped box. BuildFullScreenPanel(screenWidth, screenHeight); + this._lastLayoutUiScale = UiScale.GetResolutionScale(); + this._lastLayoutTextBoost = GetWindowedTextBoost(); + this._layoutMetricProbeCountdown = LayoutMetricProbeIntervalFrames; + this._lobbyCodeProbeCountdown = LayoutMetricProbeIntervalFrames; bool showLobbyCard = this._mode == UiMode.Lobby || this._keepLobbyVisible; if (showLobbyCard) @@ -1320,11 +1350,47 @@ private void BGtext() public override void update() { + var perfEnabled = RuntimeHitchWatch.Enabled; + var perfStart = perfEnabled ? RuntimeHitchWatch.Start() : 0L; base.update(); + + // Some display-mode changes do not dispatch Process.onResize. Detect them from the + // live window and rebuild after the game has applied the new metrics, otherwise text + // keeps the old bitmap scale/positions while the panel has already moved. + if (this._layoutMetricProbeCountdown > 0) + { + this._layoutMetricProbeCountdown--; + } + else + { + this._layoutMetricProbeCountdown = LayoutMetricProbeIntervalFrames; + try + { + var win = dc.hxd.Window.Class.getInstance(); + var liveUiScale = UiScale.GetResolutionScale(); + var liveTextBoost = GetWindowedTextBoost(); + if (win != null && + (win.get_width() != this._layoutW || + win.get_height() != this._layoutH || + double.IsNaN(this._lastLayoutUiScale) || + System.Math.Abs(liveUiScale - this._lastLayoutUiScale) > 0.001 || + System.Math.Abs(liveTextBoost - this._lastLayoutTextBoost) > 0.001)) + { + this.onResize(); + } + } + catch + { + } + } + bool promptWasOpen = this._promptOpen; TickTextPrompt(); TickMenuEscape(promptWasOpen); + // Lobby head particle emitters advance on a fixed 60fps step (game baseFps). + try { LobbyHeadFx.TickAll(1.0 / 60.0); } catch { } + if (this._mode != UiMode.Menu || this._keepLobbyVisible) { var slots = _ConnectionUI.GetLobbyPlayerSlots(); @@ -1332,11 +1398,56 @@ public override void update() RebuildLobbyPanelContent(slots); else UpdateLobbyIdLabel(forceRefreshText: false); + + FlushPendingLobbyBeheadedSkinApply(); } else if (this._menuVisible) { UpdateLobbyIdLabel(forceRefreshText: false); } + + // After skin rebind: step idle, then place heads on this frame's headBone. + try { TickLobbyHeadBones(); } catch { } + + if (perfEnabled) + { + var elapsedMs = RuntimeHitchWatch.GetElapsedMilliseconds(perfStart); + if (elapsedMs >= RuntimeHitchWatch.ModFrameSlowThresholdMs) + RuntimeHitchWatch.LogSlow(Log.Logger, "ConnectionUI.Update", elapsedMs); + } + } + + public override void onDispose() + { + if (!_lifecycle.TryBeginStop()) + { + base.onDispose(); + return; + } + + try + { + clean(); + } + catch + { + } + finally + { + _lifecycle.MarkDisposed(); + if (ReferenceEquals(Instance, this)) + Instance = null; + base.onDispose(); + } + } + + public override void postUpdate() + { + base.postUpdate(); + // Title-screen processes can overwrite DirLighted globals after our update(). + // Push them again so ColorMap stays in the linked shader while the lobby is up. + if (this._lobbyBeheadedRoot != null) + PushLobbyBeheadedLighting(); } /// Escape = same as Back/Disconnect, unless the text prompt consumed Escape this frame. diff --git a/UI/ConnectionUI/CoopIdentity.cs b/UI/ConnectionUI/CoopIdentity.cs index 969bbf3..b101d76 100644 --- a/UI/ConnectionUI/CoopIdentity.cs +++ b/UI/ConnectionUI/CoopIdentity.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Globalization; +using DeadCellsMultiplayerMod.MultiplayerModUI.Connection; namespace DeadCellsMultiplayerMod { @@ -68,7 +69,9 @@ internal static void SendCoopStateToRemote() internal static void NotifyMultiplayerSaveSlotChanged() { InvalidateLocalContinueSaveStateCache(); + _ConnectionUI.RefreshLocalHeroCosmeticsForSaveSlot(); SendCoopStateToRemote(); + SendLocalCosmeticsToRemote(); RequestLobbyMenuRefresh(); } @@ -91,7 +94,8 @@ internal static void PrepareCoopIdentityForPendingLaunch(PendingLaunchAction act var shouldCreate = false; lock (Sync) { - if (!_pendingNewCoopWorldIdAssigned || _pendingLaunchAction != PendingLaunchAction.NewGame) + if (!_pendingNewCoopWorldIdAssigned || + RunLaunchCoordinator.GetPendingLaunchIntent().Action != PendingLaunchAction.NewGame) { _pendingNewCoopWorldIdAssigned = true; shouldCreate = true; @@ -124,7 +128,7 @@ internal static void TryStoreRemoteCoopIdForPendingNewGame() if (_role != NetRole.Client || !_receivedLaunchPayload || !_receivedNewCoopWorldPrepared || - _pendingLaunchAction != PendingLaunchAction.NewGame) + RunLaunchCoordinator.GetPendingLaunchIntent().Action != PendingLaunchAction.NewGame) { return; } diff --git a/UI/ConnectionUI/LobbyHeadFx.cs b/UI/ConnectionUI/LobbyHeadFx.cs new file mode 100644 index 0000000..d1e23f5 --- /dev/null +++ b/UI/ConnectionUI/LobbyHeadFx.cs @@ -0,0 +1,533 @@ +using System; +using dc; +using dc.h2d; +using dc.libs.heaps.slib; +using HaxeProxy.Runtime; +using ModCore.Utilities; +using Serilog; + +namespace DeadCellsMultiplayerMod.MultiplayerModUI.Connection +{ + /// + /// Lightweight, self-contained particle emitter for lobby head FX. + /// Mirrors the game's BaseHead* particle confs (cdb_extract\particleConf\HeadFx): + /// fxDotWhite motes with life/alpha-fade/scale-shrink/gravity-driven motion, + /// spawned under the head root so it moves with the lobby head. + /// + internal static class LobbyHeadFx + { + private static readonly List Active = new(); + + internal static void AttachDefaultHeadFx( + dc.h2d.Object root, + double headScale, + string headId, + bool mirrorX = false, + double yOffset = 0.0) + { + try + { + if (root == null) + return; + + var defs = LookupDefs(headId); + if (defs == null || defs.Count == 0) + return; + + var fx = Assets.Class.fx; + if (fx == null) + return; + + Active.Add(new Emitter(root, fx, defs, headScale, mirrorX, yOffset)); + } + catch (Exception ex) + { + Log.Warning("[ConnectionUI] Lobby head FX attach failed: {Message}", ex.Message); + } + } + + internal static void TickAll(double dt) + { + for (int i = Active.Count - 1; i >= 0; i--) + { + var emitter = Active[i]; + if (emitter == null || emitter.Root == null || emitter.Root.parent == null) + { + emitter?.Dispose(); + Active.RemoveAt(i); + continue; + } + emitter.Tick(dt); + } + } + + internal static void ClearAll() + { + for (int i = 0; i < Active.Count; i++) + Active[i]?.Dispose(); + Active.Clear(); + } + + private static List? LookupDefs(string headId) + { + try + { + if (string.IsNullOrWhiteSpace(headId)) + headId = "BaseFlame"; + var rows = ModEntry.customHeads; + if (rows?.array == null) + return BaseFlameDefs(); + + for (int i = 0; i < rows.array.length; i++) + { + var row = rows.getDyn(i); + if (!string.Equals(row.item?.ToString(), headId, StringComparison.Ordinal)) + continue; + + var defs = new List(); + var fx = row.particleEffects; + if (fx != null) + { + for (int f = 0; f < fx.length; f++) + { + var conf = fx.getDyn(f)?.particleConf?.ToString(); + var def = GetConfDef(conf, (int)(fx.getDyn(f)?.blendMode ?? -1)); + if (def != null) + defs.Add(def); + } + } + return defs.Count > 0 ? defs : BaseFlameDefs(); + } + } + catch + { + } + + return BaseFlameDefs(); + } + + private static FxDef? GetConfDef(string? conf, int blendMode) + { + if (string.IsNullOrWhiteSpace(conf)) + return null; + + foreach (var def in AllDefs) + { + if (string.Equals(def.Id, conf, StringComparison.OrdinalIgnoreCase)) + { + if (blendMode >= 0) + { + var clone = def.Clone(); + clone.AddBlend = blendMode == 1 || def.AddBlend; + return clone; + } + return def; + } + } + return null; + } + + private static List BaseFlameDefs() + { + var defs = new List(); + foreach (var def in AllDefs) + { + if (def.Id.StartsWith("BaseHead", StringComparison.Ordinal) && def.ScaleMax > 0) + defs.Add(def); + } + return defs; + } + + private static readonly FxDef[] AllDefs = + { + new FxDef + { + Id = "BaseHeadCore", + SprName = "fxDotWhite", + Count = 11, + AddBlend = false, + LifeMin = 0.5, + LifeMax = 1.0, + SpeedMin = 3.0, + SpeedMax = 5.0, + VYUp = 0.8, + Frict = 0.88, + GravX = 0.0, + GravY = 0.0, + PosMinX = 3.0, + PosMaxX = 4.0, + PosMinY = 2.0, + PosMaxY = 4.0, + YMirror = true, + AlphaMin = 0.4, + AlphaMax = 0.7, + FadeIn = 0.1, + FadeOut = 0.5, + ScaleMin = 1.0, + ScaleMax = 3.0, + ScaleMul = 0.97, + RotMax = 6.28, + RotSpeedMax = 0.1, + // The game tints these to the head's black silhouette color (headBlack bit, fxFlags & 4). + ColorStart = 0x1E1E22, + }, + new FxDef + { + Id = "BaseHeadSmoke", + SprName = "fxDotWhite", + Count = 4, + AddBlend = false, + LifeMin = 0.2, + LifeMax = 0.4, + SpeedMin = 0.0, + SpeedMax = 1.2, + VYUp = 1.2, + Frict = 0.88, + GravX = 0.0, + GravY = -0.07, + PosMinX = -1.0, + PosMaxX = 1.0, + PosMinY = -2.0, + PosMaxY = 0.0, + AlphaMin = 0.3, + AlphaMax = 0.5, + FadeIn = 0.1, + FadeOut = 0.5, + ScaleMin = 2.0, + ScaleMax = 2.0, + ScaleMul = 0.98, + RotMax = 6.28, + RotSpeedMax = 0.1, + // headBlack tint like BaseHeadCore. + ColorStart = 0x2E2E33, + }, + new FxDef + { + Id = "BaseHeadHomunculus", + SprName = "fxDirt", + Count = 4, + AddBlend = false, + LifeMin = 0.4, + LifeMax = 0.6, + SpeedMin = 0.0, + SpeedMax = 0.6, + VYUp = 0.6, + Frict = 0.9, + GravX = 0.0, + GravY = 0.0, + PosMinX = -1.0, + PosMaxX = 1.0, + PosMinY = -1.0, + PosMaxY = 2.0, + AlphaMin = 0.35, + AlphaMax = 0.5, + FadeIn = 0.1, + FadeOut = 0.3, + // No scaleProps in the real conf -> game default scale 1. Was wrongly 2.5-3.5. + ScaleMin = 1.0, + ScaleMax = 1.0, + ScaleMul = 1.0, + RotMax = 6.28, + RotSpeedMax = 0.0, + // Real conf color is 0x1F4B2E (dark green), but at headScale that green body + // reads as a homunculus blob. Render it as the black head silhouette texture. + ColorStart = 0x131512, + }, + new FxDef + { + Id = "BaseHeadEyeCore", + SprName = "fxDotWhite", + Count = 6, + AddBlend = true, + LifeMin = 0.2, + LifeMax = 0.5, + SpeedMin = 0.0, + SpeedMax = 0.8, + VYUp = 0.8, + Frict = 0.9, + GravX = 0.0, + GravY = -0.05, + PosMinX = -1.7, + PosMaxX = 1.7, + PosMinY = -0.7, + PosMaxY = 2.7, + AlphaMin = 0.3, + AlphaMax = 0.5, + FadeIn = 0.1, + FadeOut = 0.5, + ScaleMin = 1.0, + ScaleMax = 2.0, + ScaleMul = 0.97, + RotMax = 6.28, + RotSpeedMax = 0.1, + ColorStart = 0xC71F3D, + }, + }; + + internal sealed class FxDef + { + public string Id = string.Empty; + public string SprName = "fxDotWhite"; + public int Count = 4; + public bool AddBlend; + public double LifeMin = 0.5; + public double LifeMax = 1.0; + public double SpeedMin; + public double SpeedMax; + public double VYUp; + public double Frict = 0.9; + public double GravX; + public double GravY; + public double PosMinX; + public double PosMaxX; + public double PosMinY; + public double PosMaxY; + public bool YMirror; + public double AlphaMin = 0.4; + public double AlphaMax = 0.7; + public double FadeIn = 0.1; + public double FadeOut = 0.5; + public double ScaleMin = 1.0; + public double ScaleMax = 3.0; + public double ScaleMul = 0.97; + public double RotMax = 6.28; + public double RotSpeedMax = 0.1; + public int? ColorStart; + public double AlphaFlicker; + + public FxDef Clone() + { + return (FxDef)MemberwiseClone(); + } + } + + private sealed class Particle + { + public HSprite? Spr; + public double Life = 1.0; + public double Age; + public double MaxAlpha = 1.0; + public double Scale = 1.0; + public double ScaleMul = 1.0; + public double Rot; + public double RotSpeed; + public double VX; + public double VY; + public double GravX; + public double GravY; + public double FadeIn; + public double FadeOut; + public double OffX; + public double OffY; + + public double FadeAlpha(double fadeIn, double fadeOut) + { + if (Age < fadeIn) + return MaxAlpha * (fadeIn > 0 ? Age / fadeIn : 1.0); + double left = Life - Age; + if (left < fadeOut) + return MaxAlpha * (fadeOut > 0 ? left / fadeOut : 0.0); + return MaxAlpha; + } + } + + private sealed class Emitter + { + private static readonly System.Random Rng = new System.Random(); + + public dc.h2d.Object Root; + private readonly SpriteLib _fx; + private readonly double _headScale; + private readonly bool _mirrorX; + private readonly double _yOffset; + private readonly List _defs = new List(); + private readonly List _particles = new List(); + private bool _disposed; + + public Emitter( + dc.h2d.Object root, + SpriteLib fx, + List defs, + double headScale, + bool mirrorX = false, + double yOffset = 0.0) + { + Root = root; + _fx = fx; + _headScale = headScale; + _mirrorX = mirrorX; + _yOffset = yOffset; + + for (int i = 0; i < defs.Count; i++) + { + int count = defs[i].Count; + for (int p = 0; p < count; p++) + { + _particles.Add(new Particle()); + _defs.Add(defs[i]); + } + } + + for (int i = 0; i < _particles.Count; i++) + Spawn(_particles[i], _defs[i]); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + try + { + for (int i = 0; i < _particles.Count; i++) + { + var spr = _particles[i].Spr; + if (spr != null && spr.parent != null) + spr.parent.removeChild(spr); + } + } + catch + { + } + } + + public void Tick(double dt) + { + if (_disposed || Root.parent == null) + return; + + for (int i = 0; i < _particles.Count; i++) + TickParticle(_particles[i], _defs[i], dt); + } + + private void TickParticle(Particle p, FxDef def, double dt) + { + var spr = p.Spr; + if (spr == null) + return; + + p.Age += dt; + if (p.Age >= p.Life) + { + Spawn(p, def); + return; + } + + p.VX += p.GravX; + p.VY += p.GravY; + p.VX *= def.Frict; + p.VY *= def.Frict; + + p.OffX += p.VX * _headScale; + p.OffY += p.VY * _headScale; + + spr.x = _mirrorX ? -p.OffX : p.OffX; + spr.y = p.OffY + _yOffset * _headScale; + + double scale = p.Scale; + if (global::System.Math.Abs(p.ScaleMul - 1.0) > 0.0001) + scale = p.Scale * p.ScaleMul; + + spr.scaleX = (_mirrorX ? -scale : scale) * _headScale; + spr.scaleY = scale * _headScale; + + p.Rot += p.RotSpeed * dt; + try { spr.rotation = p.Rot; } catch { } + + try { spr.set_visible(true); } catch { } + try + { + double alpha = p.FadeAlpha(p.FadeIn, p.FadeOut); + if (def.AlphaFlicker > 0) + alpha *= 1.0f - (0.7f * (float)Rng.NextDouble() * (float)def.AlphaFlicker); + spr.alpha = alpha; + } + catch { } + } + + private void Spawn(Particle p, FxDef def) + { + var spr = p.Spr; + if (spr == null) + { + if (string.IsNullOrWhiteSpace(def.SprName)) + return; + try + { + spr = new HSprite(_fx, def.SprName.AsHaxeString(), Ref.Null, null); + SpritePivot pivot = spr.pivot; + pivot.centerFactorX = 0.5; + pivot.centerFactorY = 0.5; + pivot.usingFactor = true; + pivot.isUndefined = false; + if (def.AddBlend) + spr.blendMode = new dc.h2d.BlendMode.Add(); + try { spr.smooth = false; } catch { } + if (def.ColorStart.HasValue) + { + try + { + int c = def.ColorStart.Value; + var color = spr.color; + color.x = ((c >> 16) & 0xFF) / 255.0; + color.y = ((c >> 8) & 0xFF) / 255.0; + color.z = (c & 0xFF) / 255.0; + } + catch + { + } + } + Root.addChild(spr); + p.Spr = spr; + } + catch + { + return; + } + } + + p.Life = Rand(def.LifeMin, def.LifeMax); + p.Age = Rand(0.0, p.Life * 0.6); + p.FadeIn = def.FadeIn; + p.FadeOut = def.FadeOut; + p.MaxAlpha = Rand(def.AlphaMin, def.AlphaMax); + p.Scale = Rand(def.ScaleMin, def.ScaleMax); + p.ScaleMul = def.ScaleMul; + p.Rot = Rand(0.0, def.RotMax); + p.RotSpeed = Rand(-def.RotSpeedMax, def.RotSpeedMax); + p.GravX = def.GravX; + p.GravY = def.GravY; + p.VX = Rand(def.SpeedMin, def.SpeedMax) * (NextBool() ? 1.0 : -1.0); + p.VY = -def.VYUp + Rand(-0.05, 0.05); + + p.OffX = Rand(def.PosMinX, def.PosMaxX) * _headScale; + p.OffY = Rand(def.PosMinY, def.PosMaxY) * _headScale; + if (def.YMirror && NextBool()) + p.OffY = -p.OffY; + if (NextBool()) + p.OffX = -p.OffX; + + spr.x = _mirrorX ? -p.OffX : p.OffX; + spr.y = p.OffY + _yOffset * _headScale; + spr.scaleX = (_mirrorX ? -p.Scale : p.Scale) * _headScale; + spr.scaleY = p.Scale * _headScale; + try { spr.rotation = p.Rot; } catch { } + try { spr.alpha = p.FadeAlpha(p.FadeIn, p.FadeOut); } catch { } + try { spr.set_visible(true); } catch { } + try { spr.posChanged = true; } catch { } + } + + private static double Rand(double min, double max) + { + if (max <= min) + return min; + return min + Rng.NextDouble() * (max - min); + } + + private static bool NextBool() + { + return Rng.NextDouble() < 0.5; + } + } + } +} \ No newline at end of file diff --git a/UI/ConnectionUI/LobbyHeadSkin.cs b/UI/ConnectionUI/LobbyHeadSkin.cs new file mode 100644 index 0000000..80fbe05 --- /dev/null +++ b/UI/ConnectionUI/LobbyHeadSkin.cs @@ -0,0 +1,304 @@ +using System; +using dc; +using dc.hl.types; +using dc.libs.heaps.slib; +using ModCore.Utilities; +using Serilog; + +namespace DeadCellsMultiplayerMod.MultiplayerModUI.Connection +{ + /// + /// Kinghead-style CDB lookup only: atlas + part sprites. No live HeroHead. + /// + internal static class LobbyHeadSkin + { + internal readonly struct Part + { + public Part(string group, double offsetX, double offsetY, int? colorLight, int? colorDark, double scale, int partNumber, string? idleAnim, double? idleAnimSpeed) + { + Group = group; + OffsetX = offsetX; + OffsetY = offsetY; + ColorLight = colorLight; + ColorDark = colorDark; + Scale = scale; + PartNumber = partNumber; + IdleAnim = idleAnim; + IdleAnimSpeed = idleAnimSpeed; + } + + public string Group { get; } + public double OffsetX { get; } + public double OffsetY { get; } + public int? ColorLight { get; } + public int? ColorDark { get; } + public double Scale { get; } + public int PartNumber { get; } + public string? IdleAnim { get; } + public double? IdleAnimSpeed { get; } + } + + private static SpriteLib? _cachedLib; + private static string _cachedAtlas = string.Empty; + + internal static bool TryResolve(string headId, out string atlas, out List parts, out ArrayObj? glowData, out List particleEffects) + { + atlas = "customHead"; + parts = new List(); + glowData = null; + particleEffects = new List(); + if (string.IsNullOrWhiteSpace(headId)) + return false; + + try + { + var rows = ModEntry.customHeads; + if (rows?.array == null) + return false; + + for (int i = 0; i < rows.array.length; i++) + { + var row = rows.getDyn(i); + if (!string.Equals(row.item?.ToString(), headId, StringComparison.Ordinal)) + continue; + + try + { + string rowAtlas = row.atlas?.ToString() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(rowAtlas)) + atlas = rowAtlas; + } + catch + { + } + + try + { + var g = row.glowData; + if (g != null && g.getDyn(0) != null) + { + var glowArr = ArrayUtils.CreateDyn(); + glowArr.array.pushDyn(g.getDyn(0)); + glowData = (ArrayObj)glowArr.array; + } + } + catch + { + } + + try + { + var props = row.properties; + if (props != null) + { + for (int p = 0; p < props.length; p++) + { + var part = props.getDyn(p); + string group = part.baseSpr?.ToString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(group)) + continue; + + double ox = 0; + double oy = 0; + int? colorLight = null; + int? colorDark = null; + double scale = 1; + int partNumber = 0; + string? idleAnim = null; + double? idleAnimSpeed = null; + try { ox = (double)part.offsetX; } catch { } + try { oy = (double)part.offsetY; } catch { } + try { scale = (double)part.scale; } catch { } + try { partNumber = (int)part.part; } catch { } + try + { + int raw = (int)part.colorLight; + if (raw != 0) + colorLight = raw; + } + catch + { + } + try + { + int raw = (int)part.colorDark; + if (raw != 0) + colorDark = raw; + } + catch + { + } + + try + { + var anims = part.anims; + if (anims != null && anims.length > 0) + { + var states = anims.getDyn(0)?.states; + if (states != null) + { + for (int s = 0; s < states.length; s++) + { + var st = states.getDyn(s); + if ((int)st.state != 0) + continue; + idleAnim = st.animId?.ToString(); + try { idleAnimSpeed = (double)st.animSpd; } catch { } + break; + } + } + } + } + catch + { + } + + parts.Add(new Part(group, ox, oy, colorLight, colorDark, scale, partNumber, idleAnim, idleAnimSpeed)); + } + } + } + catch + { + } + +try + { + var fx = row.particleEffects; + if (fx != null) + { + for (int f = 0; f < fx.length; f++) + { + var confName = fx.getDyn(f)?.particleConf?.ToString(); + if (!string.IsNullOrWhiteSpace(confName)) + particleEffects.Add(confName); + } + } + } + catch + { + } + + return true; + } + } + catch (Exception ex) + { + Log.Warning("[ConnectionUI] Lobby head CDB failed headId={HeadId}: {Message}", headId, ex.Message); + } + + return false; + } + + internal static SpriteLib? LoadAtlas(string atlas) + { + if (string.IsNullOrWhiteSpace(atlas)) + atlas = "customHead"; + + if (_cachedLib != null && string.Equals(_cachedAtlas, atlas, StringComparison.Ordinal)) + return _cachedLib; + + try + { + DynamicLoadAtlas id = Assets.Class.getDynamicLoadAtlasEnumFromString(atlas.AsHaxeString()); + Assets.Class.loadAtlas(id); + var lib = Assets.Class.tryGetAtlas(id); + if (lib == null) + return null; + + _cachedLib = lib; + _cachedAtlas = atlas; + return lib; + } + catch (Exception ex) + { + Log.Warning("[ConnectionUI] Lobby head atlas failed atlas={Atlas}: {Message}", atlas, ex.Message); + return null; + } + } + + internal static bool TryResolveGroup(SpriteLib lib, string baseSpr, out string group) + { + return TryResolveGroup(lib, baseSpr, null, out group); + } + + internal static bool TryResolveGroup(SpriteLib lib, string baseSpr, string? preferAnim, out string group) + { + group = baseSpr; + if (string.IsNullOrWhiteSpace(baseSpr) || lib?.groups == null) + return false; + + string? best = null; + int bestScore = int.MinValue; + string? bestAnimated = null; + int bestAnimatedScore = int.MinValue; + try + { + var keys = lib.groups.keys(); + while (keys.hasNext()) + { + string key = keys.next().ToString(); + int score = ScoreGroup(key, baseSpr); + if (score <= bestScore) + continue; + bestScore = score; + best = key; + } + + if (!string.IsNullOrWhiteSpace(preferAnim)) + { + // The CDB IdleAnim (e.g. BobbyFlammeIdle) pins the exact idle timeline, + // which wins over the loose baseSpr suffix match (Fall/Run variants). + keys = lib.groups.keys(); + while (keys.hasNext()) + { + string key = keys.next().ToString(); + if (!key.EndsWith("/" + preferAnim, StringComparison.Ordinal) + && !string.Equals(key, preferAnim, StringComparison.Ordinal)) + continue; + int score = ScoreGroup(key, preferAnim); + if (score <= bestAnimatedScore) + continue; + bestAnimatedScore = score; + bestAnimated = key; + } + if (bestAnimated != null && bestAnimatedScore >= 200) + { + group = bestAnimated; + return true; + } + } + } + catch + { + } + + if (string.IsNullOrEmpty(best) || bestScore < 200) + return false; + + group = best; + return true; + } + + private static int ScoreGroup(string group, string baseSpr) + { + // Loose "contains" matches pick the wrong tile (the grey shield on the legs). + bool pathTail = group.EndsWith("/" + baseSpr, StringComparison.Ordinal); + bool exact = string.Equals(group, baseSpr, StringComparison.Ordinal); + if (!pathTail && !exact) + return int.MinValue; + + int score = pathTail ? 260 : 200; + if (group.IndexOf("Idle", StringComparison.OrdinalIgnoreCase) >= 0) + score += 40; + if (group.IndexOf("Fall", StringComparison.OrdinalIgnoreCase) >= 0 + || group.IndexOf("Run", StringComparison.OrdinalIgnoreCase) >= 0 + || group.IndexOf("Fire", StringComparison.OrdinalIgnoreCase) >= 0 + || group.IndexOf("Atk", StringComparison.OrdinalIgnoreCase) >= 0) + { + score -= 60; + } + + return score; + } + } +} diff --git a/UI/ConnectionUI/LobbySession.Connection.cs b/UI/ConnectionUI/LobbySession.Connection.cs index 66e2a3f..4bf4f5e 100644 --- a/UI/ConnectionUI/LobbySession.Connection.cs +++ b/UI/ConnectionUI/LobbySession.Connection.cs @@ -15,6 +15,26 @@ namespace DeadCellsMultiplayerMod { internal static partial class LobbySession { + internal static string GetSteamLobbyCodeForUi() + { + if (!string.IsNullOrWhiteSpace(_steamLobbyCode)) + return _steamLobbyCode; + + if (_steamLobbyId > 0) + return SteamConnect.BuildLobbyCodeFromLobbyId(_steamLobbyId); + + return string.Empty; + } + + internal static bool TryCopySteamLobbyCodeFromUi() + { + var code = GetSteamLobbyCodeForUi(); + if (string.IsNullOrWhiteSpace(code)) + return false; + + return SteamConnect.TryCopyLobbyCodeToClipboard(code); + } + internal static void AbortClientWorldSync(string reason) { if (CurrentRole != NetRole.Client) @@ -314,6 +334,7 @@ public static void NotifyRemoteConnected(NetRole role) SendUsernameToRemote(); SendLocalReadyState(); SendCoopStateToRemote(); + SendLocalCosmeticsToRemote(); if (role == NetRole.Host) { @@ -344,6 +365,26 @@ public static void NotifyRemoteConnected(NetRole role) RequestLobbyMenuRefresh(); } + private static void SendLocalCosmeticsToRemote() + { + var net = NetRef; + if (net == null || !net.IsAlive) + return; + + try + { + // Lobby cosmetics must be sent before a game/hero bootstrap exists. Resolve through + // the same title/save/cache path that renders LobbyBeheaded, then force the first + // wire send through NetNode so a previous session's dedup state cannot suppress it. + net.SendHeroSkin(_ConnectionUI.ResolveLocalHeroSkin()); + net.SendHeroHeadSkin(_ConnectionUI.ResolveLocalHeroHeadSkin()); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to send lobby cosmetics: {Message}", ex.Message); + } + } + internal static void NotifyClientConnectAttempt(int attempt) { if (_menuSelection == NetRole.Client) diff --git a/UI/ConnectionUI/LobbySession.Snapshot.cs b/UI/ConnectionUI/LobbySession.Snapshot.cs new file mode 100644 index 0000000..f3b0d90 --- /dev/null +++ b/UI/ConnectionUI/LobbySession.Snapshot.cs @@ -0,0 +1,42 @@ +namespace DeadCellsMultiplayerMod +{ + internal static partial class LobbySession + { + internal readonly record struct LobbySessionSnapshot( + NetRole Role, + bool InActualRun, + bool AutoStartTriggered, + bool LocalReady, + NetRole MenuSelection, + bool InHostStatusMenu, + bool InClientWaitingMenu, + PendingLaunchAction PendingLaunchAction, + bool PendingLaunchCustom, + bool PendingLaunchStreamEnabled, + bool SteamJoinLobbyResolvePending, + string Username, + string RemoteUsername); + + internal static LobbySessionSnapshot ReadSessionSnapshot() + { + lock (Sync) + { + var launchIntent = RunLaunchCoordinator.GetPendingLaunchIntent(); + return new LobbySessionSnapshot( + RunLaunchCoordinator.CurrentRole, + _inActualRun, + _autoStartTriggered, + _localReady, + _menuSelection, + _inHostStatusMenu, + _inClientWaitingMenu, + launchIntent.Action, + launchIntent.Custom, + launchIntent.StreamEnabled, + _steamJoinLobbyResolvePending, + _username ?? string.Empty, + _remoteUsername ?? string.Empty); + } + } + } +} diff --git a/UI/ConnectionUI/LobbySession.State.cs b/UI/ConnectionUI/LobbySession.State.cs new file mode 100644 index 0000000..5df19a9 --- /dev/null +++ b/UI/ConnectionUI/LobbySession.State.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using dc.pr; +using dc.ui; +using DeadCellsMultiplayerMod.MultiplayerModUI.Connection; +using DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI; +using ModCore.Modules; +using Serilog; + +namespace DeadCellsMultiplayerMod +{ + internal static partial class LobbySession + { + internal static readonly object Sync = new(); + internal static ILogger? _log; + internal static ILogger? Log => _log; + internal static NetRole _role = NetRole.None; + internal static bool _inActualRun; + internal static int? _serverSeed; + internal static int? _remoteSeed; + internal static int? _pendingClientRestartSeed; + internal static string _pendingClientRestartReason = string.Empty; + internal const int MaxSeed = 999_999; + public static NetNode? NetRef { get; set; } + + internal static bool _menuHooksAttached; + internal static bool _addMenuHookRegistered; + internal static WeakReference? _titleScreenRef; + internal static string _mpIp = "127.0.0.1"; + internal static int _mpPort = 1234; + internal static NetRole _menuSelection = NetRole.None; + internal enum ConnectionTransport + { + Lan, + Steam + } + internal static ConnectionTransport _menuTransport = ConnectionTransport.Lan; + internal static SteamConnect.SteamLobbyVisibility _steamLobbyVisibility = + SteamConnect.SteamLobbyVisibility.FriendsOnly; + internal static ulong _steamLobbyId; + internal static string _steamLobbyCode = string.Empty; + internal static ulong _steamHostSteamId; + internal static bool _steamJoinLobbyResolvePending; + internal static ulong? _pendingOverlayJoinLobbyId; + internal static bool _steamFriendJoinPageActive; + internal static bool _steamFriendLobbyRefreshInFlight; + internal static long _nextSteamFriendLobbyRefreshTicks; + internal static string _steamFriendLobbySignature = string.Empty; + internal static List _steamFriendLobbies = new(); + internal const int SteamFriendLobbyRefreshMs = 2500; + internal const int ClientConnectMaxAttempts = 3; + internal static bool _pendingAutoStart; + internal static bool _autoStartTriggered; + internal static bool _continueLaunchInProgress; + internal static DateTime _continueLaunchStartedAt = DateTime.MinValue; + internal const int ContinueLaunchGuardMs = 6000; + internal static DateTime _autoStartRetryAt = DateTime.MinValue; + internal const int DeathRestartCooldownMs = 1000; + internal static DateTime _deathRestartCooldownUntil = DateTime.MinValue; + internal const string AutoStartMutexName = "DeadCellsMultiplayerMod.AutoStart"; + internal static bool _mainMenuButtonAdded; + internal static bool _suppressAutoButton; + internal static bool _worldExitHandled; + internal static bool _hostDisconnectCountdownActive; + internal static WeakReference? _hostDisconnectCountdownGameRef; + internal static DateTime _hostDisconnectCountdownUntil = DateTime.MinValue; + internal static int _lastHostDisconnectCountdown = -1; + internal const int HostDisconnectCountdownSeconds = 5; + internal static bool _hostDisconnectSavePending; + internal static DateTime _hostDisconnectSaveRetryAt = DateTime.MinValue; + internal static DateTime _hostDisconnectSaveDeadline = DateTime.MinValue; + internal const int HostDisconnectSaveRetryMs = 500; + internal const int HostDisconnectSaveMaxSeconds = 10; + internal static bool _seedArrived; + internal static string _username = "guest"; + internal static string _remoteUsername = "guest"; + internal static string _playerId = Guid.NewGuid().ToString("N"); + public static string Username => ReadSessionSnapshot().Username; + public static string RemoteUsername => ReadSessionSnapshot().RemoteUsername; + + internal static bool IsSteamJoinLobbyResolvePending() => ReadSessionSnapshot().SteamJoinLobbyResolvePending; + internal static bool _localReady; + internal static List _playersDisplay = new(); + internal static bool _inHostStatusMenu; + internal static bool _inClientWaitingMenu; + internal static int _menuRebuildDepth; + internal static bool _genArrived; + internal static LevelDescSync? _cachedLevelDescSync; + internal static readonly object TextInputSync = new(); + internal static WeakReference? _activeTextInputRef; + internal static bool _activeTextInputNoSpaces; + internal const int KeyCtrl = 17; + internal const int KeyLCtrl = 162; + internal const int KeyRCtrl = 163; + internal const int KeyC = 67; + internal const int KeyV = 86; + internal const int KeySpace = 32; + internal const int KeyEsc = 27; + internal const uint CfUnicodeText = 13; + internal const uint GmemMoveable = 0x0002; + + [DllImport("user32.dll")] + private static extern bool OpenClipboard(IntPtr hWndNewOwner); + [DllImport("user32.dll")] + private static extern bool CloseClipboard(); + [DllImport("user32.dll")] + private static extern bool EmptyClipboard(); + [DllImport("user32.dll")] + private static extern IntPtr GetClipboardData(uint uFormat); + [DllImport("user32.dll")] + private static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem); + [DllImport("user32.dll")] + private static extern bool IsClipboardFormatAvailable(uint format); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GlobalLock(IntPtr hMem); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GlobalUnlock(IntPtr hMem); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GlobalAlloc(uint uFlags, UIntPtr dwBytes); + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GlobalFree(IntPtr hMem); + } +} diff --git a/UI/ConnectionUI/LobbySession.cs b/UI/ConnectionUI/LobbySession.cs index 38707c3..ca44347 100644 --- a/UI/ConnectionUI/LobbySession.cs +++ b/UI/ConnectionUI/LobbySession.cs @@ -16,137 +16,6 @@ namespace DeadCellsMultiplayerMod { internal static partial class LobbySession { - internal static readonly object Sync = new(); - internal static ILogger? _log; - internal static ILogger? Log => _log; - internal static NetRole _role = NetRole.None; - internal static bool _inActualRun; - internal static int? _serverSeed; - internal static int? _remoteSeed; - internal static int? _pendingClientRestartSeed; - internal static string _pendingClientRestartReason = string.Empty; - internal const int MaxSeed = 999_999; - public static NetNode? NetRef { get; set; } - - internal static bool _menuHooksAttached; - internal static bool _addMenuHookRegistered; - internal static WeakReference? _titleScreenRef; - internal static string _mpIp = "127.0.0.1"; - internal static int _mpPort = 1234; - internal static NetRole _menuSelection = NetRole.None; - internal enum ConnectionTransport - { - Lan, - Steam - } - internal static ConnectionTransport _menuTransport = ConnectionTransport.Lan; - internal static SteamConnect.SteamLobbyVisibility _steamLobbyVisibility = - SteamConnect.SteamLobbyVisibility.FriendsOnly; - internal static ulong _steamLobbyId; - internal static string _steamLobbyCode = string.Empty; - internal static ulong _steamHostSteamId; - internal static bool _steamJoinLobbyResolvePending; - internal static ulong? _pendingOverlayJoinLobbyId; - internal static bool _steamFriendJoinPageActive; - internal static bool _steamFriendLobbyRefreshInFlight; - internal static long _nextSteamFriendLobbyRefreshTicks; - internal static string _steamFriendLobbySignature = string.Empty; - internal static List _steamFriendLobbies = new(); - internal const int SteamFriendLobbyRefreshMs = 2500; - internal const int ClientConnectMaxAttempts = 3; - internal static bool _pendingAutoStart; - internal static bool _autoStartTriggered; - internal static bool _continueLaunchInProgress; - internal static DateTime _continueLaunchStartedAt = DateTime.MinValue; - internal const int ContinueLaunchGuardMs = 6000; - internal static DateTime _autoStartRetryAt = DateTime.MinValue; - internal const int DeathRestartCooldownMs = 1000; - internal static DateTime _deathRestartCooldownUntil = DateTime.MinValue; - internal const string AutoStartMutexName = "DeadCellsMultiplayerMod.AutoStart"; - internal static bool _mainMenuButtonAdded; - internal static bool _suppressAutoButton; - internal static bool _worldExitHandled; - internal static bool _hostDisconnectCountdownActive; - internal static WeakReference? _hostDisconnectCountdownGameRef; - internal static DateTime _hostDisconnectCountdownUntil = DateTime.MinValue; - internal static int _lastHostDisconnectCountdown = -1; - internal const int HostDisconnectCountdownSeconds = 5; - internal static bool _hostDisconnectSavePending; - internal static DateTime _hostDisconnectSaveRetryAt = DateTime.MinValue; - internal static DateTime _hostDisconnectSaveDeadline = DateTime.MinValue; - internal const int HostDisconnectSaveRetryMs = 500; - internal const int HostDisconnectSaveMaxSeconds = 10; - internal static bool _seedArrived; - internal static string _username = "guest"; - internal static string _remoteUsername = "guest"; - internal static string _playerId = Guid.NewGuid().ToString("N"); - public static string Username => _username; - public static string RemoteUsername => _remoteUsername; - - internal static string GetSteamLobbyCodeForUi() - { - if (!string.IsNullOrWhiteSpace(_steamLobbyCode)) - return _steamLobbyCode; - - if (_steamLobbyId > 0) - return SteamConnect.BuildLobbyCodeFromLobbyId(_steamLobbyId); - - return string.Empty; - } - - internal static bool TryCopySteamLobbyCodeFromUi() - { - var code = GetSteamLobbyCodeForUi(); - if (string.IsNullOrWhiteSpace(code)) - return false; - - return SteamConnect.TryCopyLobbyCodeToClipboard(code); - } - - /// True while clipboard/overlay join is resolving the Steam lobby (before ). - internal static bool IsSteamJoinLobbyResolvePending() => _steamJoinLobbyResolvePending; - internal static bool _localReady; - internal static List _playersDisplay = new(); - internal static bool _inHostStatusMenu; - internal static bool _inClientWaitingMenu; - /// Prevents nested host/client status menu rebuilds when addMenu hook runs ProcessMainThreadQueue before orig. - internal static int _menuRebuildDepth; - internal static bool _genArrived; - internal static LevelDescSync? _cachedLevelDescSync; - internal static readonly object TextInputSync = new(); - internal static WeakReference? _activeTextInputRef; - internal static bool _activeTextInputNoSpaces; - internal const int KeyCtrl = 17; - internal const int KeyLCtrl = 162; - internal const int KeyRCtrl = 163; - internal const int KeyC = 67; - internal const int KeyV = 86; - internal const int KeySpace = 32; - internal const int KeyEsc = 27; - // Win32 clipboard helpers for text input shortcuts. - internal const uint CfUnicodeText = 13; - internal const uint GmemMoveable = 0x0002; - [DllImport("user32.dll")] - private static extern bool OpenClipboard(IntPtr hWndNewOwner); - [DllImport("user32.dll")] - private static extern bool CloseClipboard(); - [DllImport("user32.dll")] - private static extern bool EmptyClipboard(); - [DllImport("user32.dll")] - private static extern IntPtr GetClipboardData(uint uFormat); - [DllImport("user32.dll")] - private static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem); - [DllImport("user32.dll")] - private static extern bool IsClipboardFormatAvailable(uint format); - [DllImport("kernel32.dll", SetLastError = true)] - private static extern IntPtr GlobalLock(IntPtr hMem); - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool GlobalUnlock(IntPtr hMem); - [DllImport("kernel32.dll", SetLastError = true)] - private static extern IntPtr GlobalAlloc(uint uFlags, UIntPtr dwBytes); - [DllImport("kernel32.dll", SetLastError = true)] - private static extern IntPtr GlobalFree(IntPtr hMem); - public static void Initialize(ILogger logger) { logger.Information("\x1b[32m[[ModEntry.LobbySession] Initializing LobbySession...]\x1b[0m "); @@ -184,9 +53,6 @@ public static void Initialize(ILogger logger) _nextSteamFriendLobbyRefreshTicks = 0; _steamFriendLobbySignature = string.Empty; _steamFriendLobbies.Clear(); - _pendingLaunchAction = PendingLaunchAction.NewGame; - _pendingLaunchCustom = false; - _pendingLaunchStreamEnabled = false; _hasAuthoritativePendingNewGameLaunch = false; _authoritativePendingNewGameCustom = false; _authoritativePendingNewGameStreamEnabled = false; @@ -238,24 +104,19 @@ public static void MarkInRun() internal static bool IsClientInActualRun() { - lock (Sync) - { - return _role == NetRole.Client && _inActualRun; - } + var state = ReadSessionSnapshot(); + return state.Role == NetRole.Client && state.InActualRun; } internal static bool IsInActualRun() { - lock (Sync) - { - return _inActualRun; - } + return ReadSessionSnapshot().InActualRun; } /// Read-only current session role, for diagnostics outside this class. internal static NetRole CurrentRole { - get { lock (Sync) { return _role; } } + get { return RunLaunchCoordinator.CurrentRole; } } public static void SetRole(NetRole role) diff --git a/UI/ConnectionUI/MainPageLightingInitializer.cs b/UI/ConnectionUI/MainPageLightingInitializer.cs index 9ccc846..90d1f77 100644 --- a/UI/ConnectionUI/MainPageLightingInitializer.cs +++ b/UI/ConnectionUI/MainPageLightingInitializer.cs @@ -1,41 +1,72 @@ -using dc.haxe.ds; using HaxeProxy.Runtime; namespace DeadCellsMultiplayerMod.MultiplayerModUI.Connection.LightingInitializer { + /// + /// Title-screen DirLighted globals (slots 2/3). ColorMap+DirLighted will not remap + /// lobby beheaded skins unless these stay set on the scene that actually draws them. + /// public class MainPageLightingInitializer { - public MainPageLightingInitializer(ConnectionUI connection) { - InitializeLightingForMainPage(connection); + EnsureVectors(); + Apply(connection); } public dc.h3d.Vector? ligttshadow; public dc.h3d.Vector? lightDirVecto; - private void InitializeLightingForMainPage(ConnectionUI uI) + public void Apply(ConnectionUI? ui) { + if (ui?.root == null) + return; - double x = 1.0; - double y = 0; - double z = 0; - double w = 1.0; - this.ligttshadow = new dc.h3d.Vector(Ref.From(ref y), Ref.From(ref z), Ref.From(ref w), Ref.From(ref x)); - + EnsureVectors(); - x = (double)-1; - y = (double)0; - z = (double)-1; - this.lightDirVecto = new dc.h3d.Vector(Ref.From(ref x), Ref.From(ref y), Ref.From(ref z), Ref.Null); - this.lightDirVecto.normalize(); + try + { + var scene = ui.root.getScene(); + var ctx = scene?.ctx; + var map = ctx?.manager?.globals?.map; + if (map == null) + return; - dc.h2d.RenderContext ctx = uI.root.getScene().ctx; - IntMap map = ctx.manager.globals.map; - map.set(2, ligttshadow); - map.set(3, lightDirVecto); + map.set(2, ligttshadow); + map.set(3, lightDirVecto); + } + catch + { + } } + private void EnsureVectors() + { + if (this.ligttshadow == null) + { + double x = 1.0; + double y = 1.0; + double z = 1.0; + double w = 1.0; + this.ligttshadow = new dc.h3d.Vector( + Ref.From(ref x), + Ref.From(ref y), + Ref.From(ref z), + Ref.From(ref w)); + } + if (this.lightDirVecto == null) + { + double x = 0; + double y = 0; + double z = 1.0; + double w = 1.0; + this.lightDirVecto = new dc.h3d.Vector( + Ref.From(ref x), + Ref.From(ref y), + Ref.From(ref z), + Ref.From(ref w)); + } + } } -} \ No newline at end of file +} diff --git a/UI/ConnectionUI/MultiplayerSaves.cs b/UI/ConnectionUI/MultiplayerSaves.cs index f5a4b8e..fa3a652 100644 --- a/UI/ConnectionUI/MultiplayerSaves.cs +++ b/UI/ConnectionUI/MultiplayerSaves.cs @@ -63,11 +63,12 @@ internal static string GetMultiplayerSaveButtonLabel() internal static void OpenMultiplayerSlotMenu(TitleScreen screen) { - _multiplayerSaveMenuReturnRole = _inHostStatusMenu + var state = ReadSessionSnapshot(); + _multiplayerSaveMenuReturnRole = state.InHostStatusMenu ? NetRole.Host - : _inClientWaitingMenu + : state.InClientWaitingMenu ? NetRole.Client - : _role; + : state.Role; OpenSaveMenu(screen, MultiplayerSaveMenuKind.MultiplayerSlots); } @@ -112,6 +113,12 @@ internal static void Hook_TitleScreen_onLeavingSaveMenu(Hook_TitleScreen.orig_on orig(self); + try + { + NotifyMultiplayerSaveSlotChanged(); + } + catch { } + _multiplayerSaveMenuReturnRole = NetRole.None; if (returnRole == NetRole.Host) diff --git a/UI/ConnectionUI/ReadySync.cs b/UI/ConnectionUI/ReadySync.cs index 98f5d9b..8abc039 100644 --- a/UI/ConnectionUI/ReadySync.cs +++ b/UI/ConnectionUI/ReadySync.cs @@ -60,16 +60,23 @@ internal static void PrepareLobbyForNewNetworkSession(bool clearRemoteCoopState internal static void ToggleLocalReadyFromMenu(TitleScreen screen) { - SetLocalReady(!_localReady, sendToRemote: true, refreshMenu: true); + SetLocalReady(!ReadSessionSnapshot().LocalReady, sendToRemote: true, refreshMenu: true); screen.ShouldAutoHideConnectionUI(true); } internal static void SetLocalReady(bool ready, bool sendToRemote, bool refreshMenu) { - if (_localReady == ready && !refreshMenu) + bool changed; + lock (Sync) + { + changed = _localReady != ready; + if (changed) + _localReady = ready; + } + + if (!changed && !refreshMenu) return; - _localReady = ready; if (sendToRemote) SendLocalReadyState(); if (refreshMenu) @@ -82,9 +89,11 @@ internal static void SendLocalReadyState() if (net == null || !net.IsAlive || net.id <= 0) return; + var ready = ReadSessionSnapshot().LocalReady; + try { - net.SendReady(_localReady); + net.SendReady(ready); } catch (Exception ex) { @@ -104,11 +113,9 @@ internal static void RequestLobbyMenuRefresh() { MainThreadPump.EnqueueMainThreadCoalesced("ui:lobby-ready-refresh", () => { - lock (Sync) - { - if (_inActualRun || _autoStartTriggered) - return; - } + var state = ReadSessionSnapshot(); + if (state.InActualRun || state.AutoStartTriggered) + return; var screen = GetTitleScreen(); if (screen == null) @@ -130,16 +137,17 @@ internal 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) + var state = ReadSessionSnapshot(); + var localId = net?.id ?? (state.Role == NetRole.Host ? 1 : 0); + var localName = string.IsNullOrWhiteSpace(state.Username) ? "Guest" : state.Username.Trim(); + if (state.Role != NetRole.None) { _playersDisplay.Add(new PlayerInfo { UserId = localId, Name = localName, - Ready = _localReady, - IsHost = _role == NetRole.Host + Ready = state.LocalReady, + IsHost = state.Role == NetRole.Host }); } @@ -163,9 +171,9 @@ internal static void RefreshPlayersDisplayFromNetwork() var name = _ConnectionUI.GetPlayerName(localId, remote.Id, remote.Username ?? string.Empty); if (string.IsNullOrWhiteSpace(name) && remote.Id == 1 && - !string.IsNullOrWhiteSpace(_remoteUsername)) + !string.IsNullOrWhiteSpace(state.RemoteUsername)) { - name = _remoteUsername.Trim(); + name = state.RemoteUsername.Trim(); } if (string.IsNullOrWhiteSpace(name)) @@ -195,18 +203,14 @@ internal static void RefreshPlayersDisplayFromNetwork() internal static string GetReadyButtonLabel() { - return _localReady ? "Ready: On" : "Ready: Off"; + return ReadSessionSnapshot().LocalReady ? "Ready: On" : "Ready: Off"; } internal static string GetPendingLaunchSummaryLabel(TitleScreen? screen) { - PendingLaunchAction action; - bool custom; - lock (Sync) - { - action = _pendingLaunchAction; - custom = _pendingLaunchCustom; - } + var state = ReadSessionSnapshot(); + var action = state.PendingLaunchAction; + var custom = state.PendingLaunchCustom; if (action == PendingLaunchAction.LoadSave) { diff --git a/UI/ConnectionUI/RunLaunchFlow.ClientLaunchSession.cs b/UI/ConnectionUI/RunLaunchFlow.ClientLaunchSession.cs index 4855b40..7f96136 100644 --- a/UI/ConnectionUI/RunLaunchFlow.ClientLaunchSession.cs +++ b/UI/ConnectionUI/RunLaunchFlow.ClientLaunchSession.cs @@ -19,20 +19,30 @@ internal enum ClientLaunchPhase internal static ClientLaunchPhase _clientLaunchPhase = ClientLaunchPhase.Lobby; + private static void SetClientLaunchPhaseLocked(ClientLaunchPhase phase) + { + _clientLaunchPhase = phase; + _pendingAutoStart = phase == ClientLaunchPhase.Armed; + + // Starting is the only phase that owns an active auto-start claim. All other transitions + // release an old claim so a later prerequisite update can arm the client again safely. + if (phase != ClientLaunchPhase.Starting) + _autoStartTriggered = false; + } + internal static void ResetClientLaunchSessionLocked() { - _clientLaunchPhase = ClientLaunchPhase.Lobby; + SetClientLaunchPhaseLocked(ClientLaunchPhase.Lobby); } internal static void MarkClientLaunchInRunLocked() { - _clientLaunchPhase = ClientLaunchPhase.InRun; + SetClientLaunchPhaseLocked(ClientLaunchPhase.InRun); } internal static void MarkClientLaunchRestartPendingLocked() { - _clientLaunchPhase = ClientLaunchPhase.RestartPending; - _pendingAutoStart = false; + SetClientLaunchPhaseLocked(ClientLaunchPhase.RestartPending); } /// @@ -61,19 +71,19 @@ internal static void ReevaluateClientLaunchArmLocked() { if (_role != NetRole.Client) { - _clientLaunchPhase = ClientLaunchPhase.Lobby; + SetClientLaunchPhaseLocked(ClientLaunchPhase.Lobby); return; } if (_pendingClientRestartSeed.HasValue) { - _clientLaunchPhase = ClientLaunchPhase.RestartPending; + SetClientLaunchPhaseLocked(ClientLaunchPhase.RestartPending); return; } if (_inActualRun) { - _clientLaunchPhase = ClientLaunchPhase.InRun; + SetClientLaunchPhaseLocked(ClientLaunchPhase.InRun); return; } @@ -87,30 +97,27 @@ internal static void ReevaluateClientLaunchArmLocked() _remoteCustomGameDataReady; if (!hasIntent) { - _clientLaunchPhase = ClientLaunchPhase.Lobby; + SetClientLaunchPhaseLocked(ClientLaunchPhase.Lobby); return; } - _clientLaunchPhase = ClientLaunchPhase.IntentReceived; + SetClientLaunchPhaseLocked(ClientLaunchPhase.IntentReceived); if (!IsPendingLaunchReadyForAutoStartLocked()) { - _clientLaunchPhase = ClientLaunchPhase.AwaitingPrereqs; - _pendingAutoStart = false; + SetClientLaunchPhaseLocked(ClientLaunchPhase.AwaitingPrereqs); return; } // Fresh NewGame still requires the structured commit/execute barrier. - if (_pendingLaunchAction != PendingLaunchAction.LoadSave && + if (RunLaunchCoordinator.GetPendingLaunchIntent().Action != PendingLaunchAction.LoadSave && !CanAutoStartStructuredClientLaunchLocked()) { - _clientLaunchPhase = ClientLaunchPhase.AwaitingPrereqs; - _pendingAutoStart = false; + SetClientLaunchPhaseLocked(ClientLaunchPhase.AwaitingPrereqs); return; } - _pendingAutoStart = true; - _clientLaunchPhase = ClientLaunchPhase.Armed; + SetClientLaunchPhaseLocked(ClientLaunchPhase.Armed); } /// @@ -128,21 +135,20 @@ internal static bool TryClaimClientAutoStartLocked() return false; } - if (_pendingLaunchAction != PendingLaunchAction.LoadSave && + if (RunLaunchCoordinator.GetPendingLaunchIntent().Action != PendingLaunchAction.LoadSave && !CanAutoStartStructuredClientLaunchLocked()) { return false; } _autoStartTriggered = true; - _clientLaunchPhase = ClientLaunchPhase.Starting; + SetClientLaunchPhaseLocked(ClientLaunchPhase.Starting); return true; } internal static void ReleaseClientAutoStartClaimLocked() { _autoStartTriggered = false; - _pendingAutoStart = true; - _clientLaunchPhase = ClientLaunchPhase.Armed; + SetClientLaunchPhaseLocked(ClientLaunchPhase.Armed); } } diff --git a/UI/ConnectionUI/RunLaunchFlow.MultiplayerLaunch.cs b/UI/ConnectionUI/RunLaunchFlow.MultiplayerLaunch.cs index 36a522a..73272d4 100644 --- a/UI/ConnectionUI/RunLaunchFlow.MultiplayerLaunch.cs +++ b/UI/ConnectionUI/RunLaunchFlow.MultiplayerLaunch.cs @@ -8,17 +8,7 @@ namespace DeadCellsMultiplayerMod { internal static partial class LobbySession { - internal enum PendingLaunchAction - { - None, - LoadSave, - NewGame - } - internal static bool _launchHooksAttached; - internal static PendingLaunchAction _pendingLaunchAction = PendingLaunchAction.NewGame; - internal static bool _pendingLaunchCustom; - internal static bool _pendingLaunchStreamEnabled; internal static bool _hasAuthoritativePendingNewGameLaunch; internal static bool _authoritativePendingNewGameCustom; internal static bool _authoritativePendingNewGameStreamEnabled; @@ -70,9 +60,7 @@ internal static void RememberPendingLaunch(PendingLaunchAction action, bool cust lock (Sync) { - _pendingLaunchAction = action; - _pendingLaunchCustom = custom; - _pendingLaunchStreamEnabled = streamEnabled; + RunLaunchCoordinator.SetPendingLaunchIntent(action, custom, streamEnabled); if (action == PendingLaunchAction.NewGame && sendToRemote && !assignNewCoopWorld) _pendingNewCoopWorldIdAssigned = false; InvalidateGeneratePayloadCacheLocked(); @@ -171,7 +159,7 @@ internal static bool ResolveCurrentSaveIsCustom(TitleScreen? screen) lock (Sync) { - return _pendingLaunchCustom; + return RunLaunchCoordinator.GetPendingLaunchIntent().Custom; } } @@ -736,9 +724,10 @@ internal static string BuildGeneratePayloadJson(LevelDescSync? levelDesc) var hostHasContinueSave = HasLocalContinueSaveState(out _); lock (Sync) { - action = _pendingLaunchAction; - custom = _pendingLaunchCustom; - streamEnabled = _pendingLaunchStreamEnabled; + var intent = RunLaunchCoordinator.GetPendingLaunchIntent(); + action = intent.Action; + custom = intent.Custom; + streamEnabled = intent.StreamEnabled; newCoopWorldPrepared = _pendingNewCoopWorldIdAssigned; } @@ -788,9 +777,7 @@ internal static void ApplyReceivedPendingLaunch(string? actionText, bool launchC _hasAuthoritativePendingNewGameLaunch = action == PendingLaunchAction.NewGame; _authoritativePendingNewGameCustom = action == PendingLaunchAction.NewGame && launchCustom; _authoritativePendingNewGameStreamEnabled = action == PendingLaunchAction.NewGame && launchStreamEnabled; - _pendingLaunchAction = action; - _pendingLaunchCustom = launchCustom; - _pendingLaunchStreamEnabled = launchStreamEnabled; + RunLaunchCoordinator.SetPendingLaunchIntent(action, launchCustom, launchStreamEnabled); // Opening Custom Mode announces custom=true before CGDATA exists. Clear readiness // until the host's saved customGameData file arrives — but only if we have not // already received that file. This announcement is re-published by the host launch @@ -848,9 +835,10 @@ internal static void SendLaunchModeToRemote() bool newCoopWorldPrepared; lock (Sync) { - action = _pendingLaunchAction; - custom = _pendingLaunchCustom; - streamEnabled = _pendingLaunchStreamEnabled; + var intent = RunLaunchCoordinator.GetPendingLaunchIntent(); + action = intent.Action; + custom = intent.Custom; + streamEnabled = intent.StreamEnabled; newCoopWorldPrepared = _pendingNewCoopWorldIdAssigned; } @@ -891,7 +879,7 @@ internal static void SendLaunchModeToRemote() internal static bool IsPendingLaunchReadyForAutoStartLocked() { - if (_pendingLaunchAction == PendingLaunchAction.LoadSave) + if (RunLaunchCoordinator.GetPendingLaunchIntent().Action == PendingLaunchAction.LoadSave) { if (!CanClientAcceptContinueLaunchLocked(out var reason)) { @@ -912,7 +900,7 @@ internal static bool IsPendingLaunchReadyForAutoStartLocked() return false; } - if (_pendingLaunchCustom && !_remoteCustomGameDataReady) + if (RunLaunchCoordinator.GetPendingLaunchIntent().Custom && !_remoteCustomGameDataReady) { LogClientLaunchBlockLocked("custom mode rules not received"); return false; @@ -978,8 +966,10 @@ internal static void LogClientLaunchBlockLocked(string reason) _log?.Information( "[NetMod][RunLaunch] Client launch waiting: {Reason} " + - "(gen={Gen} seed={Seed} commit={Commit} exec={Exec} seedSeq={SeedSeq} bossRune={BossRune} custom={Custom})", + "(phase={ClientPhase}/{PortablePhase} gen={Gen} seed={Seed} commit={Commit} exec={Exec} seedSeq={SeedSeq} bossRune={BossRune} custom={Custom})", reason, + _clientLaunchPhase, + RunLaunchCoordinator.MapClientLaunchPhaseToSessionPhase(_clientLaunchPhase), _genArrived, _seedArrived, _structuredLaunchCommitArrived, @@ -1002,9 +992,10 @@ internal static void TryAutoStartPendingLaunch(TitleScreen? screen) bool streamEnabled; lock (Sync) { - action = _pendingLaunchAction; - custom = _pendingLaunchCustom; - streamEnabled = _pendingLaunchStreamEnabled; + var intent = RunLaunchCoordinator.GetPendingLaunchIntent(); + action = intent.Action; + custom = intent.Custom; + streamEnabled = intent.StreamEnabled; } if (action == PendingLaunchAction.LoadSave) diff --git a/UI/ConnectionUI/RunLaunchFlow.cs b/UI/ConnectionUI/RunLaunchFlow.cs index 2b962c6..10e60f0 100644 --- a/UI/ConnectionUI/RunLaunchFlow.cs +++ b/UI/ConnectionUI/RunLaunchFlow.cs @@ -205,8 +205,8 @@ internal static void ReceiveRunLaunchCommitPayload(string payload) if (isNewCommit) { _structuredLaunchExecuteSequence = 0; - _pendingAutoStart = false; - _autoStartTriggered = false; + _pendingAutoStart = false; + _autoStartTriggered = false; // A genuinely new launch gets a fresh level-graph preference window; carrying the // previous launch's expiry over would skip the wait for a graph that is about to // arrive for this run. diff --git a/UI/ConnectionUI/TitleMenuHooks.cs b/UI/ConnectionUI/TitleMenuHooks.cs index e3ef14b..f2d7ffc 100644 --- a/UI/ConnectionUI/TitleMenuHooks.cs +++ b/UI/ConnectionUI/TitleMenuHooks.cs @@ -5,6 +5,7 @@ using ModCore.Utilities; using ModCore.Modules; using DeadCellsMultiplayerMod.UI; +using DeadCellsMultiplayerMod.Tools; using DeadCellsMultiplayerMod.MultiplayerModUI.Connection; namespace DeadCellsMultiplayerMod @@ -31,6 +32,7 @@ internal static void InitializeMenuUiHooks() try { LoadConfig(); + DailyLeaderboardGuard.Initialize(); MultiplayerSaves.InitializeMultiplayerSaveHooks(); InitializeMultiplayerLaunchHooks(); Hook_TitleScreen.mainMenu += MainMenuHook; @@ -60,6 +62,8 @@ internal static void MainMenuHook(Hook_TitleScreen.orig_mainMenu orig, TitleScre ConnectionUI.set_visible = false; orig(self); + try { _ConnectionUI.RememberLocalHeroSkinFromUser(self?.user, "titleScreen.mainMenu"); } catch { } + EnsureMainMenuMultiplayerButton(self); ProcessPendingOverlayJoinRequest(self); } diff --git a/UI/ConnectionUI/_ConnectionUI.cs b/UI/ConnectionUI/_ConnectionUI.cs index 3780e48..f67feef 100644 --- a/UI/ConnectionUI/_ConnectionUI.cs +++ b/UI/ConnectionUI/_ConnectionUI.cs @@ -1,7 +1,9 @@ using dc; using dc.pr; +using dc.tool; using dc.ui; using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using Hashlink.Virtuals; using ModCore.Events; using ModCore.Utilities; using System.Collections.Generic; @@ -22,21 +24,32 @@ internal readonly struct LobbyPlayerSlot public readonly bool Occupied; public readonly string Nick; public readonly string Skin; + public readonly string Head; public readonly bool IsHost; public readonly bool IsYou; public readonly bool IsConnecting; - public LobbyPlayerSlot(bool occupied, string nick, string skin, bool isHost, bool isYou, bool isConnecting) + public LobbyPlayerSlot(bool occupied, string nick, string skin, string head, bool isHost, bool isYou, bool isConnecting) { Occupied = occupied; Nick = nick ?? string.Empty; Skin = string.IsNullOrWhiteSpace(skin) ? "PrisonerDefault" : skin.Trim(); + Head = string.IsNullOrWhiteSpace(head) ? "BaseFlame" : head.Trim(); IsHost = isHost; IsYou = isYou; IsConnecting = isConnecting; } - public static LobbyPlayerSlot Empty => new(false, string.Empty, "PrisonerDefault", false, false, false); + public static LobbyPlayerSlot Empty => new(false, string.Empty, "PrisonerDefault", "BaseFlame", false, false, false); + } + + private static List? _cachedLobbyPlayerSlots; + private static string _cachedLobbyPlayerSlotsSignature = string.Empty; + + internal static void InvalidateLobbyPlayerSlots() + { + _cachedLobbyPlayerSlots = null; + _cachedLobbyPlayerSlotsSignature = string.Empty; } public static List GetAllPlayerNames() @@ -74,32 +87,38 @@ public static List GetAllPlayerNames() /// internal static List GetLobbyPlayerSlots() { + if (_cachedLobbyPlayerSlots != null) + return _cachedLobbyPlayerSlots; + int capacity = LobbySlotCount; var slots = new List(capacity); for (int i = 0; i < capacity; i++) slots.Add(LobbyPlayerSlot.Empty); var net = ModEntry._net; + var session = LobbySession.ReadSessionSnapshot(); if (net == null) { - if (LobbySession.IsSteamJoinLobbyResolvePending()) + if (session.SteamJoinLobbyResolvePending) { slots[0] = new LobbyPlayerSlot( occupied: true, nick: SteamLobbyConnectingMarker, skin: "PrisonerDefault", + head: "BaseFlame", isHost: false, isYou: true, isConnecting: true); } - return slots; + return CacheLobbyPlayerSlots(slots); } - var localName = LobbySession.Username; + var localName = session.Username; if (string.IsNullOrWhiteSpace(localName)) localName = "Guest"; var localSkin = ResolveLocalHeroSkin(); + var localHead = ResolveLocalHeroHeadSkin(); var hasSnapshots = net.TryGetRemoteUserSnapshots(out var snapshots); try { @@ -113,10 +132,11 @@ internal static List GetLobbyPlayerSlots() occupied: true, nick: "connecting...", skin: "PrisonerDefault", + head: "BaseFlame", isHost: false, isYou: true, isConnecting: true); - return slots; + return CacheLobbyPlayerSlots(slots); } if (isHost) @@ -125,6 +145,7 @@ internal static List GetLobbyPlayerSlots() occupied: true, nick: localName, skin: localSkin, + head: localHead, isHost: true, isYou: true, isConnecting: false); @@ -142,10 +163,12 @@ internal static List GetLobbyPlayerSlots() string displayName = GetPlayerName(localId, remote.Id, remote.Username ?? string.Empty); string skin = ResolveRemoteSkin(localId, remote.Id); + string head = ResolveRemoteHeadSkin(localId, remote.Id); slots[write++] = new LobbyPlayerSlot( occupied: true, nick: displayName, skin: skin, + head: head, isHost: false, isYou: false, isConnecting: false); @@ -156,6 +179,7 @@ internal static List GetLobbyPlayerSlots() { string hostName = "Host"; string hostSkin = "PrisonerDefault"; + string hostHead = "BaseFlame"; if (hasSnapshots) { for (int i = 0; i < snapshots.Count; i++) @@ -166,6 +190,7 @@ internal static List GetLobbyPlayerSlots() hostName = GetPlayerName(localId, remote.Id, remote.Username ?? string.Empty); hostSkin = ResolveRemoteSkin(localId, remote.Id); + hostHead = ResolveRemoteHeadSkin(localId, remote.Id); break; } } @@ -186,10 +211,19 @@ internal static List GetLobbyPlayerSlots() hostSkin = cachedHost.Trim(); } + if (string.IsNullOrWhiteSpace(hostHead) || + string.Equals(hostHead, "BaseFlame", StringComparison.Ordinal)) + { + var cachedHostHead = ModEntry.Instance?.remoteHeadSkin; + if (!string.IsNullOrWhiteSpace(cachedHostHead)) + hostHead = cachedHostHead.Trim(); + } + slots[0] = new LobbyPlayerSlot( occupied: true, nick: hostName, skin: hostSkin, + head: hostHead, isHost: true, isYou: false, isConnecting: false); @@ -198,6 +232,7 @@ internal static List GetLobbyPlayerSlots() occupied: true, nick: localName, skin: localSkin, + head: localHead, isHost: false, isYou: true, isConnecting: false); @@ -213,10 +248,12 @@ internal static List GetLobbyPlayerSlots() string displayName = GetPlayerName(localId, remote.Id, remote.Username ?? string.Empty); string skin = ResolveRemoteSkin(localId, remote.Id); + string head = ResolveRemoteHeadSkin(localId, remote.Id); slots[write++] = new LobbyPlayerSlot( occupied: true, nick: displayName, skin: skin, + head: head, isHost: false, isYou: false, isConnecting: false); @@ -224,7 +261,7 @@ internal static List GetLobbyPlayerSlots() } } - return slots; + return CacheLobbyPlayerSlots(slots); } finally { @@ -236,6 +273,12 @@ internal static List GetLobbyPlayerSlots() /// Compact signature so lobby UI refreshes when nick OR skin changes. internal static string BuildLobbySlotsSignature(List slots) { + if (ReferenceEquals(slots, _cachedLobbyPlayerSlots) && + !string.IsNullOrEmpty(_cachedLobbyPlayerSlotsSignature)) + { + return _cachedLobbyPlayerSlotsSignature; + } + var sb = new StringBuilder(slots.Count * 24); for (int i = 0; i < slots.Count; i++) { @@ -248,6 +291,8 @@ internal static string BuildLobbySlotsSignature(List slots) sb.Append('|'); sb.Append(s.Skin); sb.Append('|'); + sb.Append(s.Head); + sb.Append('|'); sb.Append(s.IsHost ? 'H' : '-'); sb.Append(s.IsYou ? 'Y' : '-'); sb.Append(s.IsConnecting ? 'C' : '-'); @@ -255,6 +300,13 @@ internal static string BuildLobbySlotsSignature(List slots) return sb.ToString(); } + private static List CacheLobbyPlayerSlots(List slots) + { + _cachedLobbyPlayerSlots = slots; + _cachedLobbyPlayerSlotsSignature = BuildLobbySlotsSignature(slots); + return slots; + } + public static string GetPlayerName(int localId, int remoteId, string remoteUsername) { if (ModEntry.TryGetClientIndex(localId, remoteId, out var slotIndex)) @@ -282,42 +334,413 @@ public static bool ShouldAutoHideConnectionUI(this TitleScreen titleScreen, bool return visible; } + private static string? _cachedLocalHeroSkin; + private static string _cachedLocalHeroSkinSource = string.Empty; + private static int _cachedLocalHeroSkinSlot = int.MinValue; + private static int _tryLoadLocalHeroSkinSlot = int.MinValue; + private static bool _preferCachedLocalHeroSkinForSlot; + private static virtual_colorMap_consoleCmdId_glowData_group_head_incompatibleHeads_item_model_onlyDefaultHead_scarfBlendMode_scarfs_? _cachedLocalSkinInfo; + private static string? _cachedLocalHeroHeadSkin; + + internal static void RememberLocalHeroSkinFromUser(User? user, string source) + { + if (user == null) + return; + + CacheLocalSkinInfoFromUser(user); + RememberLocalHeroHeadSkin(ReadUserHeroHeadSkinId(user)); + + if (!TryReadUserHeroSkinId(user, out var skin, out var via)) + return; + + RememberLocalHeroSkin(skin, source + "/" + via); + } + + internal static void RememberLocalHeroSkin(string? skin, string source) + { + // Same identifier GameDataSync.SendHeroSkin sends: user.heroSkin, not consoleCmdId. + var normalized = CleanHeroSkinId(skin); + if (string.IsNullOrWhiteSpace(normalized)) + return; + + int slot = ResolveCurrentSaveSlot(); + bool changed = !string.Equals(_cachedLocalHeroSkin, normalized, StringComparison.Ordinal) + || _cachedLocalHeroSkinSlot != slot; + _cachedLocalHeroSkin = normalized; + _cachedLocalHeroSkinSource = source ?? string.Empty; + _cachedLocalHeroSkinSlot = slot; + _ = changed; + } + + internal static void RememberLocalHeroHeadSkin(string? head) + { + var normalized = CleanHeroSkinId(head); + if (string.IsNullOrWhiteSpace(normalized)) + return; + _cachedLocalHeroHeadSkin = normalized; + } + + internal static void RefreshLocalHeroCosmeticsForSaveSlot() + { + InvalidateLobbyPlayerSlots(); + InvalidateLocalHeroSkinCacheIfSlotChanged(); + _tryLoadLocalHeroSkinSlot = int.MinValue; + _preferCachedLocalHeroSkinForSlot = false; + + try + { + // TitleScreen.user can remain bound to the previous slot until the save-menu + // transition completes. Save.tryLoad follows options.curSlot and is authoritative + // immediately after a slot selection. + var loaded = TryLoadLocalUserSkinOnce(forceReload: true); + if (loaded != null) + { + _preferCachedLocalHeroSkinForSlot = true; + return; + } + + var user = TryResolveLocalUser(out _); + if (user != null) + RememberLocalHeroSkinFromUser(user, "saveSlotChanged"); + } + catch + { + } + } + internal static string ResolveLocalHeroSkin() { try { - User? user = null; - try { user = Main.Class.ME?.user; } catch { } + InvalidateLocalHeroSkinCacheIfSlotChanged(); + + if (_preferCachedLocalHeroSkinForSlot && + _cachedLocalHeroSkinSlot == ResolveCurrentSaveSlot() && + !string.IsNullOrWhiteSpace(_cachedLocalHeroSkin)) + { + return _cachedLocalHeroSkin; + } + + var user = TryResolveLocalUser(out var userSource); if (user == null) + TryLoadLocalUserSkinOnce(); + + string? rawHeroSkin = null; + string? infosCmd = null; + string? infosRaw = null; + if (user != null) + TryReadUserHeroSkinFields(user, out rawHeroSkin, out infosCmd, out infosRaw); + + string? chosen = null; + string chosenSource = "fallback"; + if (TryPickPreferredSkin(infosCmd, infosRaw, rawHeroSkin, out chosen, out chosenSource)) + { + RememberLocalHeroSkin(chosen, userSource + "/" + chosenSource); + } + else if (!string.IsNullOrWhiteSpace(_cachedLocalHeroSkin)) + { + chosen = _cachedLocalHeroSkin; + chosenSource = "cache:" + _cachedLocalHeroSkinSource; + } + else if (_cachedLocalSkinInfo != null) { - try { user = Game.Class.ME?.user; } catch { } + chosen = "local-save"; + chosenSource = "cachedSkinInfo"; } - string? raw = null; - try { raw = user?.heroSkin?.ToString(); } catch { } + if (!string.IsNullOrWhiteSpace(chosen)) + return chosen; + } + catch + { + } - if (!string.IsNullOrWhiteSpace(raw)) + return "PrisonerDefault"; + } + + private static User? TryResolveLocalUser(out string source) + { + source = "none"; + try + { + var screen = LobbySession.GetTitleScreen(); + if (screen?.user != null) { - var cleaned = raw.Replace("|", "/").Trim(); - try - { - var info = Cdb.Class.getSkinInfo(cleaned.AsHaxeString()); - var cmd = info?.consoleCmdId?.ToString(); - if (!string.IsNullOrWhiteSpace(cmd)) - return cmd.Replace("|", "/").Trim(); - } - catch + source = "titleScreen"; + return screen.user; + } + } + catch + { + } + + try + { + var user = Main.Class.ME?.user; + if (user != null) + { + source = "main"; + return user; + } + } + catch + { + } + + try + { + var user = Game.Class.ME?.user; + if (user != null) + { + source = "game"; + return user; + } + } + catch + { + } + + return null; + } + + private static User? TryLoadLocalUserSkinOnce(bool forceReload = false) + { + int slot = ResolveCurrentSaveSlot(); + if (!forceReload && !string.IsNullOrWhiteSpace(_cachedLocalHeroSkin) && _cachedLocalHeroSkinSlot == slot) + return null; + if (_tryLoadLocalHeroSkinSlot == slot) + return null; + + _tryLoadLocalHeroSkinSlot = slot; + try + { + var loaded = Save.Class.tryLoad.Invoke(); + if (loaded == null) + return null; + + RememberLocalHeroSkinFromUser(loaded, "tryLoad"); + return loaded; + } + catch + { + return null; + } + } + + private static void InvalidateLocalHeroSkinCacheIfSlotChanged() + { + int slot = ResolveCurrentSaveSlot(); + if (_cachedLocalHeroSkinSlot == int.MinValue || _cachedLocalHeroSkinSlot == slot) + return; + + _cachedLocalHeroSkin = null; + _cachedLocalHeroSkinSource = string.Empty; + _cachedLocalHeroSkinSlot = int.MinValue; + _cachedLocalSkinInfo = null; + _cachedLocalHeroHeadSkin = null; + } + + private static int ResolveCurrentSaveSlot() + { + try + { + var current = Main.Class.ME?.options?.curSlot; + if (current.HasValue && current.Value >= 0) + return current.Value; + } + catch + { + } + + return 0; + } + + private static bool TryReadUserHeroSkinId(User user, out string skin, out string via) + { + skin = string.Empty; + via = "none"; + TryReadUserHeroSkinFields(user, out var rawHeroSkin, out var infosCmd, out var infosRaw); + if (!TryPickPreferredSkin(infosCmd, infosRaw, rawHeroSkin, out var chosen, out via) || + string.IsNullOrWhiteSpace(chosen)) + { + return false; + } + + skin = chosen; + return true; + } + + private static void TryReadUserHeroSkinFields( + User user, + out string? rawHeroSkin, + out string? infosCmd, + out string? infosRaw) + { + rawHeroSkin = null; + infosCmd = null; + infosRaw = null; + try { rawHeroSkin = user.heroSkin?.ToString(); } catch { } + try + { + var infos = user.getHeroSkinInfos(); + if (infos == null) + return; + try { infosRaw = infos.ToString(); } catch { } + try { infosCmd = infos.consoleCmdId?.ToString(); } catch { } + } + catch + { + } + } + + private static bool TryPickPreferredSkin( + string? infosCmd, + string? infosRaw, + string? rawHeroSkin, + out string chosen, + out string via) + { + chosen = string.Empty; + via = "fallback"; + + // GameDataSync.SendHeroSkin / GhostKing both use user.heroSkin, not consoleCmdId. + var heroSkin = CleanHeroSkinId(rawHeroSkin); + if (!string.IsNullOrWhiteSpace(heroSkin)) + { + chosen = heroSkin; + via = "heroSkin"; + return true; + } + + var rawInfos = CleanHeroSkinId(infosRaw); + if (!string.IsNullOrWhiteSpace(rawInfos) && !IsConsoleCmdSkinId(rawInfos, infosCmd)) + { + chosen = rawInfos; + via = "getHeroSkinInfos"; + return true; + } + + return false; + } + + private static void CacheLocalSkinInfoFromUser(User user) + { + try + { + var infos = user.getHeroSkinInfos(); + if (infos != null) + _cachedLocalSkinInfo = infos; + } + catch + { + } + } + + internal static bool TryGetCachedLocalSkinInfo( + out virtual_colorMap_consoleCmdId_glowData_group_head_incompatibleHeads_item_model_onlyDefaultHead_scarfBlendMode_scarfs_? skinInfo) + { + skinInfo = _cachedLocalSkinInfo; + return skinInfo != null; + } + + private static string CleanHeroSkinId(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return string.Empty; + + return raw.Replace("|", "/").Replace("\r", string.Empty).Replace("\n", string.Empty).Trim(); + } + + private static bool IsConsoleCmdSkinId(string candidate, string? infosCmd) + { + if (string.IsNullOrWhiteSpace(candidate) || string.IsNullOrWhiteSpace(infosCmd)) + return false; + return string.Equals(candidate.Trim(), infosCmd.Trim(), StringComparison.OrdinalIgnoreCase); + } + + internal static string ResolveLocalHeroHeadSkin() + { + try + { + InvalidateLocalHeroSkinCacheIfSlotChanged(); + + if (_preferCachedLocalHeroSkinForSlot && + _cachedLocalHeroSkinSlot == ResolveCurrentSaveSlot() && + !string.IsNullOrWhiteSpace(_cachedLocalHeroHeadSkin)) + { + return _cachedLocalHeroHeadSkin; + } + + var user = TryResolveLocalUser(out _); + if (user == null) + TryLoadLocalUserSkinOnce(); + + if (user != null) + { + var fromUser = ReadUserHeroHeadSkinId(user); + if (!string.IsNullOrWhiteSpace(fromUser)) { + RememberLocalHeroHeadSkin(fromUser); + return fromUser; } - - return cleaned; } + + if (!string.IsNullOrWhiteSpace(_cachedLocalHeroHeadSkin)) + return _cachedLocalHeroHeadSkin; } catch { } - return "PrisonerDefault"; + return "BaseFlame"; + } + + private static string ReadUserHeroHeadSkinId(User user) + { + try + { + var raw = CleanHeroSkinId(user.heroHeadSkin?.ToString()); + if (!string.IsNullOrWhiteSpace(raw)) + return raw; + } + catch + { + } + + try + { + var infos = user.getHeroHeadSkinInfos(); + var item = CleanHeroSkinId(infos?.item?.ToString()); + if (!string.IsNullOrWhiteSpace(item)) + return item; + } + catch + { + } + + return string.Empty; + } + + private static string ResolveRemoteHeadSkin(int localId, int remoteId) + { + if (ModEntry.TryGetClientIndex(localId, remoteId, out var slotIndex)) + { + if ((uint)slotIndex < (uint)ModEntry.clientHeadSkins.Length) + { + var known = ModEntry.clientHeadSkins[slotIndex]; + if (!string.IsNullOrWhiteSpace(known)) + return known.Replace("|", "/").Trim(); + } + } + + if (remoteId == 1) + { + var hostHead = ModEntry.Instance?.remoteHeadSkin; + if (!string.IsNullOrWhiteSpace(hostHead)) + return hostHead.Replace("|", "/").Trim(); + } + + return "BaseFlame"; } private static string ResolveRemoteSkin(int localId, int remoteId) diff --git a/server/NetTrafficDiagnostics.cs b/server/NetTrafficDiagnostics.cs new file mode 100644 index 0000000..d20a7f8 --- /dev/null +++ b/server/NetTrafficDiagnostics.cs @@ -0,0 +1,65 @@ +using System.Diagnostics; +using System.Threading; +using Serilog; + +namespace DeadCellsMultiplayerMod.Network; + +/// Allocation-free aggregate transport diagnostics. Individual packets are never logged. +internal static class NetTrafficDiagnostics +{ + private static long _sentLines; + private static long _sentBytes; + private static long _receivedLines; + private static long _receivedBytes; + private static long _sendErrors; + private static long _budgetDrops; + private static long _lastFlushTicks; + + public static void RecordSent(int bytes) + { + Interlocked.Increment(ref _sentLines); + Interlocked.Add(ref _sentBytes, Math.Max(0, bytes)); + } + + public static void RecordReceived(int bytes) + { + Interlocked.Increment(ref _receivedLines); + Interlocked.Add(ref _receivedBytes, Math.Max(0, bytes)); + } + + public static void RecordSendError() + { + Interlocked.Increment(ref _sendErrors); + } + + public static void RecordBudgetDrop() + { + Interlocked.Increment(ref _budgetDrops); + } + + public static void TryFlush(ILogger log, string role) + { + var now = Stopwatch.GetTimestamp(); + var previous = Interlocked.Read(ref _lastFlushTicks); + if (previous != 0 && now - previous < Stopwatch.Frequency * 5L) + return; + Interlocked.Exchange(ref _lastFlushTicks, now); + + var sentLines = Interlocked.Exchange(ref _sentLines, 0); + var sentBytes = Interlocked.Exchange(ref _sentBytes, 0); + var receivedLines = Interlocked.Exchange(ref _receivedLines, 0); + var receivedBytes = Interlocked.Exchange(ref _receivedBytes, 0); + var sendErrors = Interlocked.Exchange(ref _sendErrors, 0); + var budgetDrops = Interlocked.Exchange(ref _budgetDrops, 0); + + log.Information( + "[NetTraffic] PERF role={Role} sentLines={SentLines} sentBytes={SentBytes} receivedLines={ReceivedLines} receivedBytes={ReceivedBytes} sendErrors={SendErrors} budgetDrops={BudgetDrops}", + role ?? string.Empty, + sentLines, + sentBytes, + receivedLines, + receivedBytes, + sendErrors, + budgetDrops); + } +} diff --git a/server/NetTransport.cs b/server/NetTransport.cs new file mode 100644 index 0000000..dcf270b --- /dev/null +++ b/server/NetTransport.cs @@ -0,0 +1,57 @@ +using System.Diagnostics; +using DeadCellsMultiplayerMod; + +namespace DeadCellsMultiplayerMod.Network; + +internal enum NetTransportKind +{ + Tcp, + Steam +} + +internal readonly record struct NetTransportSnapshot( + NetTransportKind Kind, + NetRole Role, + bool HasConnection, + bool IsDisposed); + +internal sealed class NetPacketBudget +{ + private readonly long _windowTicks; + private readonly int _maxBytes; + private readonly object _sync = new(); + private long _windowStartedTicks; + private int _usedBytes; + internal long DroppedPackets { get; private set; } + internal long DroppedBytes { get; private set; } + + internal NetPacketBudget(int maxBytes, double windowMilliseconds) + { + _maxBytes = Math.Max(1, maxBytes); + _windowTicks = Math.Max(1L, (long)(Stopwatch.Frequency * windowMilliseconds / 1000.0)); + } + + internal bool TryConsume(int bytes) + { + var count = Math.Max(0, bytes); + var now = Stopwatch.GetTimestamp(); + lock (_sync) + { + if (_windowStartedTicks == 0 || now - _windowStartedTicks >= _windowTicks) + { + _windowStartedTicks = now; + _usedBytes = 0; + } + + if (count > _maxBytes - _usedBytes) + { + DroppedPackets++; + DroppedBytes += count; + return false; + } + + _usedBytes += count; + return true; + } + } +} diff --git a/server/ProtocolWire.cs b/server/ProtocolWire.cs new file mode 100644 index 0000000..6879ee0 --- /dev/null +++ b/server/ProtocolWire.cs @@ -0,0 +1,59 @@ +using System.Text; + +namespace DeadCellsMultiplayerMod.Network; + +internal enum ProtocolErrorCode +{ + None, + EmptyInput, + InvalidLimit, + Oversized, + EncodingFailed +} + +internal readonly record struct ProtocolEncodeResult( + bool Success, + byte[] Bytes, + ProtocolErrorCode Error); + +/// Shared framing and token rules for the line protocol. +internal static class ProtocolWire +{ + public static bool TryEncode(string? line, int maxBytes, out byte[] bytes) + { + var result = Encode(line, maxBytes); + bytes = result.Bytes; + return result.Success; + } + + public static ProtocolEncodeResult Encode(string? line, int maxBytes) + { + if (string.IsNullOrEmpty(line)) + return new(false, Array.Empty(), ProtocolErrorCode.EmptyInput); + if (maxBytes <= 0) + return new(false, Array.Empty(), ProtocolErrorCode.InvalidLimit); + + try + { + var bytes = Encoding.UTF8.GetBytes(line); + return bytes.Length <= maxBytes + ? new(true, bytes, ProtocolErrorCode.None) + : new(false, Array.Empty(), ProtocolErrorCode.Oversized); + } + catch + { + return new(false, Array.Empty(), ProtocolErrorCode.EncodingFailed); + } + } + + public static string SanitizeToken(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; + } +} diff --git a/server/server.NetNode.Build.cs b/server/server.NetNode.Build.cs index 5c897b0..aab8f5d 100644 --- a/server/server.NetNode.Build.cs +++ b/server/server.NetNode.Build.cs @@ -1,4 +1,5 @@ using System.Globalization; +using DeadCellsMultiplayerMod.Network; public sealed partial class NetNode @@ -74,13 +75,7 @@ private static string BuildHpLine(int id, int life, int maxLife, int lif, int bo 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; + return ProtocolWire.SanitizeToken(value, maxLength); } private bool TryBuildLocalHpLine(out string line) diff --git a/server/server.NetNode.Cleanup.cs b/server/server.NetNode.Cleanup.cs index 480d304..8dcaddc 100644 --- a/server/server.NetNode.Cleanup.cs +++ b/server/server.NetNode.Cleanup.cs @@ -51,11 +51,11 @@ private void CleanupClient() }); } - private RemoteState GetOrCreateRemoteLocked(int id) + private RemotePlayerState GetOrCreateRemoteLocked(int id) { if (!_remotes.TryGetValue(id, out var state)) { - state = new RemoteState(id); + state = new RemotePlayerState(id); _remotes[id] = state; } return state; @@ -75,6 +75,17 @@ private void RemoveRemoteLocked(int id) } } + private void RemovePendingPeerStateLocked(int userId) + { + RemoveRemoteLocked(userId); + _pendingAttacks.RemoveAll(a => a.Id == userId); + _pendingMobHits.RemoveAll(h => h.UserId == userId); + _pendingMobDies.RemoveAll(d => d.UserId == userId); + _pendingExitReadyStates.RemoveAll(s => s.UserId == userId); + _pendingPlayerDownStates.RemoveAll(s => s.UserId == userId); + _pendingPlayerReviveRequests.RemoveAll(s => s.ReviverId == userId || s.TargetId == userId); + } + private static int? ResolvePayloadId(string payload, int? senderId, out string cleanedPayload) { cleanedPayload = payload; diff --git a/server/server.NetNode.Consume.cs b/server/server.NetNode.Consume.cs index 1086152..c01d930 100644 --- a/server/server.NetNode.Consume.cs +++ b/server/server.NetNode.Consume.cs @@ -154,12 +154,7 @@ public bool TryConsumeRemoteWeaponSnapshots(out List snaps } public bool TryConsumeRemoteAttacks(out List attacks) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingAttacks, out attacks); - } - } + => TryConsumePending(_pendingAttacks, out attacks); public void ClearMobSyncQueues() { @@ -201,60 +196,30 @@ public void ClearRemoteRoomMarkers() } public bool TryConsumeMobStates(out List snapshot) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingMobStates, out snapshot); - } - } + => TryConsumePending(_pendingMobStates, out snapshot); public bool TryConsumeMobMoves(out List moves) { lock (_sync) { - return TryConsumePendingListLocked(ref _pendingMobMoves, out moves); + return _pendingMobMoves.TryConsume(out moves); } } public bool TryConsumeMobHits(out List hits) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingMobHits, out hits); - } - } + => TryConsumePending(_pendingMobHits, out hits); public bool TryConsumeMobDies(out List dies) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingMobDies, out dies); - } - } + => TryConsumePending(_pendingMobDies, out dies); public bool TryConsumeMobAttacks(out List attacks) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingMobAttacks, out attacks); - } - } + => TryConsumePending(_pendingMobAttacks, out attacks); public bool TryConsumeMobDraws(out List draws) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingMobDraws, out draws); - } - } + => TryConsumePending(_pendingMobDraws, out draws); public bool TryConsumeMobRegistry(out List entries) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingMobRegistry, out entries); - } - } + => TryConsumePending(_pendingMobRegistry, out entries); private static bool TryConsumePendingListLocked(ref List pending, out List snapshot) { @@ -269,133 +234,61 @@ private static bool TryConsumePendingListLocked(ref List pending, out List return true; } - public bool TryConsumeExitReadyStates(out List states) + private bool TryConsumePending(PendingQueue pending, out List snapshot) { lock (_sync) { - return TryConsumePendingListLocked(ref _pendingExitReadyStates, out states); + return pending.TryConsume(out snapshot); } } + public bool TryConsumeExitReadyStates(out List states) + => TryConsumePending(_pendingExitReadyStates, out states); + public bool TryConsumeExitTransitionCommits(out List commits) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingExitTransitionCommits, out commits); - } - } + => TryConsumePending(_pendingExitTransitionCommits, out commits); public bool TryConsumeBossCineLevelIds(out List levelIds) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingBossCineLevelIds, out levelIds); - } - } + => TryConsumePending(_pendingBossCineLevelIds, out levelIds); public bool TryConsumeBossHeroTeleportEvents(out List events) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingBossHeroTeleports, out events); - } - } + => TryConsumePending(_pendingBossHeroTeleports, out events); public bool TryConsumePlayerDownStates(out List states) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingPlayerDownStates, out states); - } - } + => TryConsumePending(_pendingPlayerDownStates, out states); public bool TryConsumePlayerReviveRequests(out List requests) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingPlayerReviveRequests, out requests); - } - } + => TryConsumePending(_pendingPlayerReviveRequests, out requests); public bool TryConsumeInterDoorEvents(out List events) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingInterDoorEvents, out events); - } - } + => TryConsumePending(_pendingInterDoorEvents, out events); public bool TryConsumeInterElevatorEvents(out List events) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingInterElevatorEvents, out events); - } - } + => TryConsumePending(_pendingInterElevatorEvents, out events); public bool TryConsumeInterElevatorStateEvents(out List events) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingInterElevatorStateEvents, out events); - } - } + => TryConsumePending(_pendingInterElevatorStateEvents, out events); public bool TryConsumeInterPressurePlateEvents(out List events) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingInterPressurePlateEvents, out events); - } - } + => TryConsumePending(_pendingInterPressurePlateEvents, out events); public bool TryConsumeInterTreasureChestEvents(out List events) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingInterTreasureChestEvents, out events); - } - } + => TryConsumePending(_pendingInterTreasureChestEvents, out events); public bool TryConsumeInterVineLadderEvents(out List events) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingInterVineLadderEvents, out events); - } - } + => TryConsumePending(_pendingInterVineLadderEvents, out events); public bool TryConsumeInterTeleportEvents(out List events) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingInterTeleportEvents, out events); - } - } + => TryConsumePending(_pendingInterTeleportEvents, out events); public bool TryConsumeInterBreakableGroundEvents(out List events) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingInterBreakableGroundEvents, out events); - } - } + => TryConsumePending(_pendingInterBreakableGroundEvents, out events); public bool TryConsumeBossRuneUpdateCells(out List events) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingBossRuneUpdateCells, out events); - } - } + => TryConsumePending(_pendingBossRuneUpdateCells, out events); public bool TryConsumeInterPortalEvents(out List events) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingInterPortalEvents, out events); - } - } + => TryConsumePending(_pendingInterPortalEvents, out events); public bool TryGetRemoteHpSnapshots(out List snapshot) { diff --git a/server/server.NetNode.Dispose.cs b/server/server.NetNode.Dispose.cs index 4ec8b93..50250ca 100644 --- a/server/server.NetNode.Dispose.cs +++ b/server/server.NetNode.Dispose.cs @@ -17,6 +17,7 @@ private static void WaitForTaskShutdown(Task? task, int timeoutMs) /// public void Dispose() { + _lifecycle.TryBeginStop(); if (Interlocked.Exchange(ref _disposeState, 1) != 0) return; @@ -170,5 +171,6 @@ public void Dispose() // buys nothing, while an in-flight SendLineToStreamSafe on another thread would observe an // ObjectDisposedException from WaitAsync during shutdown - noise on Windows, and one more // way to fault a background task while the Linux runtime is already unwinding. + _lifecycle.MarkDisposed(); } } diff --git a/server/server.NetNode.PendingState.cs b/server/server.NetNode.PendingState.cs new file mode 100644 index 0000000..2d09e11 --- /dev/null +++ b/server/server.NetNode.PendingState.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using DeadCellsMultiplayerMod.AdvancedCoop; +using DeadCellsMultiplayerMod.Interaction; + +public sealed partial class NetNode +{ + private sealed class PendingQueue + { + private readonly int _maxCount; + private List _items = new(); + internal long DroppedCount { get; private set; } + + internal PendingQueue(int maxCount) + { + _maxCount = Math.Max(1, maxCount); + } + + internal void Clear() => _items.Clear(); + + internal int RemoveAll(Predicate match) => _items.RemoveAll(match); + + internal void AddBounded(T item, int maxCount) + { + if (_items.Count >= _maxCount) + { + DroppedCount += _items.Count - _maxCount + 1; + _items.RemoveRange(0, _items.Count - _maxCount + 1); + } + _items.Add(item); + } + + internal void AppendBounded(IReadOnlyList items, int maxCount) + { + if (items == null || items.Count == 0) + return; + + var firstIncoming = Math.Max(0, items.Count - _maxCount); + var incomingCount = items.Count - firstIncoming; + var overflow = _items.Count + incomingCount - _maxCount; + if (overflow > 0) + { + DroppedCount += overflow; + if (overflow >= _items.Count) + _items.Clear(); + else + _items.RemoveRange(0, overflow); + } + + for (var i = firstIncoming; i < items.Count; i++) + _items.Add(items[i]); + } + + internal bool TryConsume(out List snapshot) + { + if (_items.Count == 0) + { + snapshot = EmptyListCache.Instance; + return false; + } + + snapshot = _items; + _items = RentConsumedList(snapshot.Count); + return true; + } + } + + private sealed class LatestValueQueue where TKey : notnull + { + private readonly int _maxCount; + private List _items = new(); + private readonly Dictionary _slots = new(); + internal long DroppedCount { get; private set; } + + internal LatestValueQueue(int maxCount) + { + _maxCount = Math.Max(1, maxCount); + } + + internal void Clear() + { + _items.Clear(); + _slots.Clear(); + } + + internal void Upsert(TKey key, TValue value) + { + if (_slots.TryGetValue(key, out var slot)) + { + _items[slot] = value; + return; + } + + if (_items.Count >= _maxCount) + { + DroppedCount += _items.Count; + _items.Clear(); + _slots.Clear(); + } + + _slots[key] = _items.Count; + _items.Add(value); + } + + internal bool TryConsume(out List snapshot) + { + if (_items.Count == 0) + { + snapshot = EmptyListCache.Instance; + return false; + } + + snapshot = _items; + _items = RentConsumedList(snapshot.Count); + _slots.Clear(); + return true; + } + } + + private readonly PendingQueue _pendingAttacks = new(PendingAttackLimit); + private readonly PendingQueue _pendingMobStates = new(PendingMobStateLimit); + private readonly LatestValueQueue<(int Generation, int SyncId), MobMoveSnapshot> _pendingMobMoves = new(PendingMobMoveLimit); + private readonly PendingQueue _pendingMobHits = new(PendingMobHitLimit); + private readonly PendingQueue _pendingMobDies = new(PendingMobDieLimit); + private readonly PendingQueue _pendingMobAttacks = new(PendingMobAttackLimit); + private readonly PendingQueue _pendingMobDraws = new(PendingMobDrawLimit); + private readonly PendingQueue _pendingMobRegistry = new(PendingMobStateLimit); + private readonly PendingQueue _pendingExitReadyStates = new(PendingControlStateLimit); + private readonly PendingQueue _pendingExitTransitionCommits = new(PendingControlStateLimit); + private HostSpawnAnchor? _latestHostSpawnAnchor; + private readonly PendingQueue _pendingPlayerDownStates = new(PendingControlStateLimit); + private readonly PendingQueue _pendingPlayerReviveRequests = new(PendingControlStateLimit); + private readonly PendingQueue _pendingBossCineLevelIds = new(PendingBossCineLimit); + private readonly PendingQueue _pendingBossHeroTeleports = new(PendingInteractionLimit); + private readonly PendingQueue _pendingInterDoorEvents = new(PendingInteractionLimit); + private readonly PendingQueue _pendingInterElevatorEvents = new(PendingInteractionLimit); + private readonly PendingQueue _pendingInterElevatorStateEvents = new(PendingInteractionLimit); + private readonly PendingQueue _pendingInterPressurePlateEvents = new(PendingInteractionLimit); + private readonly PendingQueue _pendingInterTreasureChestEvents = new(PendingInteractionLimit); + private readonly PendingQueue _pendingInterVineLadderEvents = new(PendingInteractionLimit); + private readonly PendingQueue _pendingInterTeleportEvents = new(PendingInteractionLimit); + private readonly PendingQueue _pendingInterBreakableGroundEvents = new(PendingInteractionLimit); + private readonly PendingQueue _pendingBossRuneUpdateCells = new(PendingInteractionLimit); + private readonly PendingQueue _pendingInterPortalEvents = new(PendingInteractionLimit); + private int _primaryRemoteId; +} diff --git a/server/server.NetNode.Protocol.Dispatch.cs b/server/server.NetNode.Protocol.Dispatch.cs new file mode 100644 index 0000000..5028912 --- /dev/null +++ b/server/server.NetNode.Protocol.Dispatch.cs @@ -0,0 +1,114 @@ +using System; +using DeadCellsMultiplayerMod; + +public sealed partial class NetNode +{ + private enum FastPathRole + { + Host, + Client + } + + private enum FastPathKind + { + LevelSeedRequest, + LevelGraphRequest, + SerializerSync, + LevelSeed, + LevelGraph + } + + private readonly record struct FastPathRegistration( + string Prefix, + FastPathRole Role, + FastPathKind Kind); + + private static readonly FastPathRegistration[] FastPathRegistrations = + [ + new("LSEEDREQ|", FastPathRole.Host, FastPathKind.LevelSeedRequest), + new("LGRAPHREQ|", FastPathRole.Host, FastPathKind.LevelGraphRequest), + new("HXSYNC|", FastPathRole.Client, FastPathKind.SerializerSync), + new("LSEED|", FastPathRole.Client, FastPathKind.LevelSeed), + new("LGRAPH|", FastPathRole.Client, FastPathKind.LevelGraph) + ]; + + private static bool TryGetFastPathRegistration( + string line, + FastPathRole role, + out FastPathRegistration registration) + { + for (var i = 0; i < FastPathRegistrations.Length; i++) + { + var candidate = FastPathRegistrations[i]; + if (candidate.Role == role && line.StartsWith(candidate.Prefix, StringComparison.Ordinal)) + { + registration = candidate; + return true; + } + } + + registration = default; + return false; + } + + private bool TryHandleHostFastPathLine(string line) + { + if (!TryGetFastPathRegistration(line, FastPathRole.Host, out var registration)) + return false; + + switch (registration.Kind) + { + case FastPathKind.LevelSeedRequest: + { + var levelId = ClampProtocolText(line[registration.Prefix.Length..], MaxIdentityFieldChars); + if (!string.IsNullOrWhiteSpace(levelId)) + ResendCachedLevelSeed(levelId); + return true; + } + + case FastPathKind.LevelGraphRequest: + { + var levelId = ClampProtocolText(line[registration.Prefix.Length..], MaxIdentityFieldChars); + if (!string.IsNullOrWhiteSpace(levelId)) + ResendCachedLevelGraph(levelId); + return true; + } + } + + return false; + } + + private bool TryHandleClientFastPathLine(string line) + { + try + { + if (!TryGetFastPathRegistration(line, FastPathRole.Client, out var registration)) + return false; + + var payload = line[registration.Prefix.Length..]; + switch (registration.Kind) + { + case FastPathKind.SerializerSync: + lock (_sync) _hasRemote = true; + GameDataSync.ReceiveSerializerSync(payload); + return true; + + case FastPathKind.LevelSeed: + lock (_sync) _hasRemote = true; + GameDataSync.ReceiveLevelSeed(payload); + return true; + + case FastPathKind.LevelGraph: + lock (_sync) _hasRemote = true; + GameDataSync.ReceiveLevelGraph(payload); + return true; + } + } + catch (Exception ex) + { + _log.Warning("[NetNode] Client fast-path line handling failed: {msg}", ex.Message); + } + + return false; + } +} diff --git a/server/server.NetNode.Protocol.Incoming.cs b/server/server.NetNode.Protocol.Incoming.cs index 08cda3d..2c72a0d 100644 --- a/server/server.NetNode.Protocol.Incoming.cs +++ b/server/server.NetNode.Protocol.Incoming.cs @@ -3,6 +3,8 @@ using DeadCellsMultiplayerMod; using DeadCellsMultiplayerMod.Interaction; using DeadCellsMultiplayerMod.AdvancedCoop; +using DeadCellsMultiplayerMod.Mobs.MobsSynchronization; +using DeadCellsMultiplayerMod.Network; public sealed partial class NetNode { @@ -44,6 +46,26 @@ private static void AppendBoundedLocked(List target, IReadOnlyList item target.Add(items[i]); } + private static void AppendBoundedLocked(PendingQueue target, IReadOnlyList items, int maxCount) + { + target.AppendBounded(items, maxCount); + } + + private void AppendLatestMobMovesLocked(IReadOnlyList moves) + { + if (moves == null || moves.Count == 0) + return; + + for (var i = 0; i < moves.Count; i++) + { + var move = moves[i]; + // Movement is disposable presentation traffic. If the consumer is stalled, keeping a + // complete history is actively harmful: the game thread would spend time applying + // positions that are already obsolete. Start a fresh latest-only window at the bound. + _pendingMobMoves.Upsert((move.Generation, move.Index), move); + } + } + private static void AddBoundedLocked(List target, T item, int maxCount) { if (maxCount <= 0) @@ -53,6 +75,11 @@ private static void AddBoundedLocked(List target, T item, int maxCount) target.Add(item); } + private static void AddBoundedLocked(PendingQueue target, T item, int maxCount) + { + target.AddBounded(item, maxCount); + } + /// /// Lines that can be fully handled on the network thread, without hopping to the game thread. /// @@ -82,61 +109,12 @@ private bool TryHandleFastPathLine(string line, int? senderId) return true; if (_role == NetRole.Host) - { - if (line.StartsWith("LSEEDREQ|", StringComparison.Ordinal)) - { - var levelId = ClampProtocolText(line["LSEEDREQ|".Length..], MaxIdentityFieldChars); - if (!string.IsNullOrWhiteSpace(levelId)) - ResendCachedLevelSeed(levelId); - return true; - } - - if (line.StartsWith("LGRAPHREQ|", StringComparison.Ordinal)) - { - var levelId = ClampProtocolText(line["LGRAPHREQ|".Length..], MaxIdentityFieldChars); - if (!string.IsNullOrWhiteSpace(levelId)) - ResendCachedLevelGraph(levelId); - return true; - } - - return false; - } + return TryHandleHostFastPathLine(line); if (_role != NetRole.Client) return false; - try - { - if (line.StartsWith("HXSYNC|", StringComparison.Ordinal)) - { - var payload = line["HXSYNC|".Length..]; - lock (_sync) _hasRemote = true; - GameDataSync.ReceiveSerializerSync(payload); - return true; - } - - if (line.StartsWith("LSEED|", StringComparison.Ordinal)) - { - var payload = line["LSEED|".Length..]; - lock (_sync) _hasRemote = true; - GameDataSync.ReceiveLevelSeed(payload); - return true; - } - - if (line.StartsWith("LGRAPH|", StringComparison.Ordinal)) - { - var payload = line["LGRAPH|".Length..]; - lock (_sync) _hasRemote = true; - GameDataSync.ReceiveLevelGraph(payload); - return true; - } - } - catch (Exception ex) - { - _log.Warning("[NetNode] Fast-path line handling failed: {msg}", ex.Message); - } - - return false; + return TryHandleClientFastPathLine(line); } private bool TryHandleMobTrafficFastPath(string line, int? senderId) @@ -156,6 +134,8 @@ private bool TryHandleMobTrafficFastPath(string line, int? senderId) return false; NetNodeMobTrafficStats.RecordFastPathLine(); + NetTrafficDiagnostics.RecordReceived(line.Length); + NetTrafficDiagnostics.TryFlush(_log, _role.ToString()); return true; } catch (Exception ex) @@ -180,6 +160,7 @@ private bool TryHandleMobTrafficLineCore(string line, int? senderId, bool forceS var parsedStates = new List(); if (MobWireBinary.TryParseMobStatesBase64(payload, parsedStates)) { + MobSyncTrace.RecordWireReceive("state", parsedStates.Count, line.Length); lock (_sync) { // MOBSTATE packets are often split into multiple wire lines when a level has @@ -203,6 +184,7 @@ private bool TryHandleMobTrafficLineCore(string line, int? senderId, bool forceS { var payload = line["MOBSTATE|".Length..]; var parsedStates = ParseMobStatesPayload(payload); + MobSyncTrace.RecordWireReceive("state", parsedStates.Count, line.Length); lock (_sync) { // MOBSTATE packets can be chunked. Appending on the client is required so a @@ -242,7 +224,8 @@ private bool TryHandleMobTrafficLineCore(string line, int? senderId, bool forceS { if (parsedMoves.Count > 0) { - AppendBoundedLocked(_pendingMobMoves, parsedMoves, PendingMobMoveLimit); + MobSyncTrace.RecordWireReceive("move", parsedMoves.Count, line.Length); + AppendLatestMobMovesLocked(parsedMoves); _hasRemote = true; } } diff --git a/server/server.NetNode.Protocol.Types.cs b/server/server.NetNode.Protocol.Types.cs new file mode 100644 index 0000000..6824292 --- /dev/null +++ b/server/server.NetNode.Protocol.Types.cs @@ -0,0 +1,422 @@ +using System; +using System.Collections.Generic; + +public sealed partial class NetNode +{ + public readonly struct RemoteSnapshot + { + public readonly int Id; + public readonly double X; + public readonly double Y; + public readonly int Dir; + public readonly string? LevelId; + public readonly string? RoomLevelId; + public readonly int? RoomId; + public readonly bool HasRoom; + public readonly string? Anim; + public readonly int? AnimQueue; + public readonly bool? AnimG; + public readonly bool HasAnim; + public readonly string? Username; + public readonly string? HeadAnim; + public readonly bool HasHeadAnim; + + public RemoteSnapshot( + int id, + double x, + double y, + int dir, + string? levelId, + string? roomLevelId, + int? roomId, + bool hasRoom, + string? anim, + int? animQueue, + bool? animG, + bool hasAnim, + string? username, + string? headAnim, + bool hasHeadAnim) + { + Id = id; + X = x; + Y = y; + Dir = dir; + LevelId = levelId; + RoomLevelId = roomLevelId; + RoomId = roomId; + HasRoom = hasRoom; + Anim = anim; + AnimQueue = animQueue; + AnimG = animG; + HasAnim = hasAnim; + Username = username; + HeadAnim = headAnim; + HasHeadAnim = hasHeadAnim; + } + } + + public readonly struct RemoteWeaponSnapshot + { + public readonly int Id; + public readonly string? Kind; + public readonly int Slot; + public readonly int PermanentId; + public readonly int? Ammo; + + public RemoteWeaponSnapshot(int id, string? kind, int slot, int permanentId, int? ammo) + { + Id = id; + Kind = kind; + Slot = slot; + PermanentId = permanentId; + Ammo = ammo; + } + } + + public readonly struct RemoteAttack + { + public readonly int Id; + public readonly string? Kind; + public readonly int Slot; + public readonly int PermanentId; + public readonly int? Ammo; + public readonly RemoteAttackAction Action; + + public RemoteAttack(int id, string? kind, int slot, int permanentId, int? ammo, RemoteAttackAction action) + { + Id = id; + Kind = kind; + Slot = slot; + PermanentId = permanentId; + Ammo = ammo; + Action = action; + } + } + + public readonly struct RemoteHpSnapshot + { + public readonly int Id; + public readonly int Life; + public readonly int MaxLife; + public readonly int Lif; + public readonly int BonusLife; + public readonly int Recover; + public readonly string? Username; + + public RemoteHpSnapshot(int id, int life, int maxLife, int lif, int bonusLife, int recover, string? username) + { + Id = id; + Life = life; + MaxLife = maxLife; + Lif = lif; + BonusLife = bonusLife; + Recover = recover; + Username = username; + } + } + + public readonly struct RemoteUserSnapshot + { + public readonly int Id; + public readonly string? Username; + + public RemoteUserSnapshot(int id, string? username) + { + Id = id; + Username = username; + } + } + + public readonly struct MobStateSnapshot + { + public readonly int Index; + public readonly int Generation; + public readonly double X; + public readonly double Y; + public readonly int Dir; + public readonly int Life; + public readonly int MaxLife; + public readonly string AnimPayload; + public readonly string Type; + public readonly string StatePayload; + public readonly double Time; + public readonly double Dx; + public readonly double Dy; + + public MobStateSnapshot(int index, double x, double y, int dir, int life, int maxLife, string animPayload, string type, string statePayload = "", int generation = 0, double time = 0.0, double dx = 0.0, double dy = 0.0) + { + Index = index; + Generation = generation; + X = x; + Y = y; + Dir = dir; + Life = life; + MaxLife = maxLife; + AnimPayload = animPayload ?? string.Empty; + Type = type ?? string.Empty; + StatePayload = statePayload ?? string.Empty; + Time = time; + Dx = dx; + Dy = dy; + } + } + + public readonly struct MobMoveSnapshot + { + public readonly int Index; + public readonly int Generation; + public readonly double X; + public readonly double Y; + public readonly int Dir; + public readonly string AnimPayload; + public readonly double Time; + public readonly double Dx; + public readonly double Dy; + + public MobMoveSnapshot(int index, double x, double y, int dir, string animPayload, int generation = 0, double time = 0.0, double dx = 0.0, double dy = 0.0) + { + Index = index; + Generation = generation; + X = x; + Y = y; + Dir = dir; + AnimPayload = animPayload ?? string.Empty; + Time = time; + Dx = dx; + Dy = dy; + } + } + + public readonly struct MobHit + { + public readonly int UserId; + public readonly int MobIndex; + public readonly int Generation; + public readonly int Hp; + public readonly double X; + public readonly double Y; + public readonly string Type; + public readonly double DamageHint; + + public MobHit(int userId, int mobIndex, int hp, double x, double y, string type = "", int generation = 0, double damageHint = 0.0) + { + UserId = userId; + MobIndex = mobIndex; + Generation = generation; + Hp = hp; + X = x; + Y = y; + Type = type ?? string.Empty; + DamageHint = double.IsFinite(damageHint) && damageHint > 0.0 ? damageHint : 0.0; + } + } + + public readonly struct MobDie + { + public readonly int UserId; + public readonly int MobIndex; + public readonly int Generation; + public readonly double X; + public readonly double Y; + public readonly string Type; + + public MobDie(int userId, int mobIndex, double x, double y, int generation = 0, string type = "") + { + UserId = userId; + MobIndex = mobIndex; + X = x; + Y = y; + Generation = generation; + Type = type ?? string.Empty; + } + } + + public readonly struct MobRegistryEntry + { + public readonly int NetId; + public readonly int Generation; + public readonly string Type; + public readonly double X; + public readonly double Y; + + public MobRegistryEntry(int netId, int generation, string type, double x, double y) + { + NetId = netId; + Generation = generation; + Type = type ?? string.Empty; + X = x; + Y = y; + } + } + + public readonly struct MobAttack + { + public readonly int Index; + public readonly int Generation; + public readonly string SkillId; + public readonly bool RequiresTargetInArea; + public readonly int? Data; + public readonly double X; + public readonly double Y; + public readonly int TargetUserId; + public readonly int Dir; + public readonly double BlockSeconds; + public readonly double ForcedDirSeconds; + public readonly string Type; + public readonly int AttackSeq; + + public MobAttack(int index, string skillId, bool requiresTargetInArea, int? data, double x, double y, int targetUserId, int dir = 0, double blockSeconds = 0, double forcedDirSeconds = 0, string type = "", int generation = 0, int attackSeq = 0) + { + Index = index; + Generation = generation; + SkillId = skillId ?? string.Empty; + RequiresTargetInArea = requiresTargetInArea; + Data = data; + X = x; + Y = y; + TargetUserId = targetUserId; + Dir = dir; + BlockSeconds = blockSeconds; + ForcedDirSeconds = forcedDirSeconds; + Type = type ?? string.Empty; + AttackSeq = attackSeq; + } + } + + public readonly struct MobEventUpdate + { + public readonly int Index; + public readonly int Generation; + public readonly double X; + public readonly double Y; + public readonly int Dir; + public readonly IReadOnlyList Events; + public readonly string Type; + + public MobEventUpdate(int index, double x, double y, int dir, IReadOnlyList events, string type = "", int generation = 0) + { + Index = index; + Generation = generation; + X = x; + Y = y; + Dir = dir; + Events = events ?? Array.Empty(); + Type = type ?? string.Empty; + } + } + + public readonly struct MobDraw + { + public readonly int UserId; + public readonly int MobIndex; + public readonly int Generation; + public readonly bool IsOutOfGame; + public readonly bool IsOnScreen; + + public MobDraw(int userId, int mobIndex, bool isOutOfGame, bool isOnScreen, int generation = 0) + { + UserId = userId; + MobIndex = mobIndex; + Generation = generation; + IsOutOfGame = isOutOfGame; + IsOnScreen = isOnScreen; + } + } + + public readonly struct ExitReadyState + { + public readonly int UserId; + public readonly int DoorCx; + public readonly int DoorCy; + public readonly bool Pressed; + public readonly bool InsideCircle; + public readonly bool IsOutOfGame; + public readonly bool IsOnScreen; + public readonly string LevelId; + + public ExitReadyState(int userId, int doorCx, int doorCy, bool pressed, bool insideCircle, bool isOutOfGame, bool isOnScreen, string? levelId = null) + { + UserId = userId; + DoorCx = doorCx; + DoorCy = doorCy; + Pressed = pressed; + InsideCircle = insideCircle; + IsOutOfGame = isOutOfGame; + IsOnScreen = isOnScreen; + LevelId = levelId ?? string.Empty; + } + } + + public readonly struct PlayerDownState + { + public readonly int UserId; + public readonly bool IsDowned; + public readonly double X; + public readonly double Y; + public readonly string LevelId; + public readonly bool HasHeadPosition; + public readonly double HeadX; + public readonly double HeadY; + public readonly bool HasHeadAnim; + public readonly string? HeadAnim; + + public PlayerDownState(int userId, bool isDowned, double x, double y, string levelId, bool hasHeadPosition = false, double headX = 0, double headY = 0, bool hasHeadAnim = false, string? headAnim = null) + { + UserId = userId; + IsDowned = isDowned; + X = x; + Y = y; + LevelId = levelId ?? string.Empty; + HasHeadPosition = hasHeadPosition; + HeadX = headX; + HeadY = headY; + HasHeadAnim = hasHeadAnim; + HeadAnim = hasHeadAnim ? (headAnim ?? string.Empty) : null; + } + } + + public readonly struct HostSpawnAnchor + { + public readonly int Cx; + public readonly int Cy; + public readonly string LevelId; + + public HostSpawnAnchor(int cx, int cy, string? levelId) + { + Cx = cx; + Cy = cy; + LevelId = levelId ?? string.Empty; + } + } + + public readonly struct ExitTransitionCommit + { + public readonly long Sequence; + public readonly int DoorCx; + public readonly int DoorCy; + public readonly string FromLevelId; + public readonly string DestinationLevelId; + + public ExitTransitionCommit(long sequence, int doorCx, int doorCy, string? fromLevelId, string? destinationLevelId) + { + Sequence = sequence; + DoorCx = doorCx; + DoorCy = doorCy; + FromLevelId = fromLevelId ?? string.Empty; + DestinationLevelId = destinationLevelId ?? string.Empty; + } + } + + public readonly struct PlayerReviveRequest + { + public readonly int ReviverId; + public readonly int TargetId; + + public PlayerReviveRequest(int reviverId, int targetId) + { + ReviverId = reviverId; + TargetId = targetId; + } + } +} diff --git a/server/server.NetNode.RemotePlayers.cs b/server/server.NetNode.RemotePlayers.cs new file mode 100644 index 0000000..1eaa337 --- /dev/null +++ b/server/server.NetNode.RemotePlayers.cs @@ -0,0 +1,45 @@ +public sealed partial class NetNode +{ + /// Network-layer state for one remote player, separate from its in-world GhostKing. + private sealed class RemotePlayerState + { + public int Id { get; } + public double X; + public double Y; + public int Dir = 1; + public bool HasPosition; + public long LastPositionSequence; + public bool HasRemote; + public string? LevelId; + public string? RoomLevelId; + public int? RoomId; + public bool HasRoom; + public string? Anim; + public int? AnimQueue; + public bool? AnimG; + public bool HasAnim; + public int Life; + public int MaxLife; + public int Lif; + public int BonusLife; + public int Recover; + public string? Username; + public bool Ready; + public string? CoopId; + public bool HasContinueSave; + public string? Skin; + public string? Head; + public string HeadAnim = string.Empty; + public bool HasHeadAnim; + public string? WeaponKind; + public int WeaponSlot; + public int WeaponPermanentId; + public int WeaponAmmo = int.MinValue; + public bool HasWeaponUpdate; + + public RemotePlayerState(int id) + { + Id = id; + } + } +} diff --git a/server/server.NetNode.SendPublic.cs b/server/server.NetNode.SendPublic.cs index c7f6896..d25e7bd 100644 --- a/server/server.NetNode.SendPublic.cs +++ b/server/server.NetNode.SendPublic.cs @@ -1,5 +1,6 @@ using System.Globalization; using DeadCellsMultiplayerMod; +using DeadCellsMultiplayerMod.Mobs.MobsSynchronization; using DeadCellsMultiplayerMod.PortableCore; public sealed partial class NetNode @@ -843,11 +844,13 @@ public void SendMobStates(IReadOnlyList states) bin != null) { var line = "MOBSTATE2|" + Convert.ToBase64String(bin) + "\n"; + MobSyncTrace.RecordWireSend("state", states.Count, line.Length); _ = SendLineSafe(line); return; } var textLine = MobWireCodec.BuildMobStatesLine(states); + MobSyncTrace.RecordWireSend("state", states.Count, textLine.Length); _ = SendLineSafe(textLine); } @@ -861,6 +864,7 @@ public void SendMobMoves(IReadOnlyList moves) return; var line = MobWireCodec.BuildMobMovesLine(moves); + MobSyncTrace.RecordWireSend("move", moves.Count, line.Length); _ = SendLineSafe(line); } diff --git a/server/server.NetNode.SendRouting.cs b/server/server.NetNode.SendRouting.cs index 47bdfb4..e993863 100644 --- a/server/server.NetNode.SendRouting.cs +++ b/server/server.NetNode.SendRouting.cs @@ -1,11 +1,9 @@ using System.Net.Sockets; -using System.Text; using Steamworks; +using DeadCellsMultiplayerMod.Network; public sealed partial class NetNode { - private static byte[] Utf8ProtocolBytes(string line) => Encoding.UTF8.GetBytes(line); - private void CloseClientConnection() { try { _stream?.Close(); } catch { } @@ -145,10 +143,22 @@ public Task SendMobWireLine(string line) private Task SendLineSafe(string line) { - if (_role == NetRole.Host) + var transport = ReadTransportSnapshot(); + if (!transport.HasConnection) + return Task.CompletedTask; + + if (IsDroppableTcpRealtimeLine(line) && !_realtimePacketBudget.TryConsume(line.Length)) + { + NetTrafficDiagnostics.RecordBudgetDrop(); + return Task.CompletedTask; + } + + NetTrafficDiagnostics.TryFlush(_log, _role.ToString()); + + if (transport.Role == NetRole.Host) return BroadcastLineSafe(line); - if (_useSteamTransport && _steamBridge != null) + if (transport.Kind == DeadCellsMultiplayerMod.Network.NetTransportKind.Steam && _steamBridge != null) return SendLineToSteamBridgeSafe(_steamHostId.m_SteamID, line, ResolveSteamSendType(line), GetSteamOutgoingChannel()); return SendLineToStreamSafe(_stream, _sendLock, line); @@ -168,12 +178,14 @@ private async Task BroadcastLineSafe(string line) if (steamSnapshot.Count == 0) return; var sendType = ResolveSteamSendType(line); var channel = SteamP2PChannelHostToClient; - var bytes = Utf8ProtocolBytes(line); - if (bytes.Length > MaxProtocolLineChars) + var encoded = ProtocolWire.Encode(line, MaxProtocolLineChars); + if (!encoded.Success) { - _log.Warning("[NetNode] Rejected oversized Steam broadcast ({Length} bytes)", bytes.Length); + _log.Warning("[NetNode] Rejected Steam broadcast: {Error}", encoded.Error); return; } + var bytes = encoded.Bytes; + NetTrafficDiagnostics.RecordSent(bytes.Length); foreach (var client in steamSnapshot) { _steamBridge.TrySend(client.SteamId.m_SteamID, sendType, channel, bytes, out _); @@ -197,12 +209,12 @@ private async Task BroadcastLineSafe(string line) private async Task SendKnownUsersToSteamClientSafe(SteamClientConnection connection) { - List snapshot; + List snapshot; lock (_sync) { if (_remotes.Count == 0) return; - snapshot = new List(_remotes.Values); + snapshot = new List(_remotes.Values); } foreach (var state in snapshot) @@ -234,12 +246,14 @@ private async Task SendLineToStreamSafe(NetworkStream? stream, SemaphoreSlim? se if (stream == null || sendLock == null || _disposed || string.IsNullOrEmpty(line)) return; - var bytes = Utf8ProtocolBytes(line); - if (bytes.Length > MaxProtocolLineChars) + var encoded = ProtocolWire.Encode(line, MaxProtocolLineChars); + if (!encoded.Success) { - _log.Warning("[NetNode] Rejected oversized TCP protocol line ({Length} bytes)", bytes.Length); + _log.Warning("[NetNode] Rejected TCP protocol line: {Error}", encoded.Error); return; } + var bytes = encoded.Bytes; + NetTrafficDiagnostics.RecordSent(bytes.Length); var realtime = IsDroppableTcpRealtimeLine(line); var token = _cts?.Token ?? CancellationToken.None; @@ -269,6 +283,7 @@ private async Task SendLineToStreamSafe(NetworkStream? stream, SemaphoreSlim? se catch (ObjectDisposedException) { } catch (Exception ex) { + NetTrafficDiagnostics.RecordSendError(); _log.Warning("[NetNode] send error: {msg}", ex.Message); } finally @@ -284,12 +299,14 @@ private Task SendLineToSteamClientSafe(SteamClientConnection client, string line { if (_steamBridge == null) return Task.CompletedTask; - var bytes = Utf8ProtocolBytes(line); - if (bytes.Length > MaxProtocolLineChars) + var encoded = ProtocolWire.Encode(line, MaxProtocolLineChars); + if (!encoded.Success) { - _log.Warning("[NetNode] Steam payload too large for {SteamId}: {PayloadSize} bytes", client.SteamId.m_SteamID, bytes.Length); + _log.Warning("[NetNode] Rejected Steam payload for {SteamId}: {Error}", client.SteamId.m_SteamID, encoded.Error); return Task.CompletedTask; } + var bytes = encoded.Bytes; + NetTrafficDiagnostics.RecordSent(bytes.Length); var st = sendType ?? ResolveSteamSendType(line); if (!_steamBridge.TrySend(client.SteamId.m_SteamID, st, SteamP2PChannelHostToClient, bytes, out var err)) _log.Warning("[NetNode] Steam send failed to {SteamId}: {Error}", client.SteamId.m_SteamID, err); @@ -301,16 +318,15 @@ private Task SendLineToSteamBridgeSafe(ulong steamId, string line, EP2PSend send if (_steamBridge == null || steamId == 0UL) return Task.CompletedTask; - var bytes = Utf8ProtocolBytes(line); - if (bytes.Length > MaxProtocolLineChars || bytes.Length > SteamMaxPacketSizeBytes) + if (!ProtocolWire.TryEncode(line, (int)Math.Min((uint)MaxProtocolLineChars, SteamMaxPacketSizeBytes), out var bytes)) { _log.Warning( - "[NetNode] Steam payload too large for {SteamId}: {PayloadSize} bytes (protocol limit {Limit} bytes)", + "[NetNode] Steam payload too large for {SteamId} (protocol limit {Limit} bytes)", steamId, - bytes.Length, MaxProtocolLineChars); return Task.CompletedTask; } + NetTrafficDiagnostics.RecordSent(bytes.Length); if (!_steamBridge.TrySend(steamId, sendType, channel, bytes, out var err)) { diff --git a/server/server.NetNode.Steam.cs b/server/server.NetNode.Steam.cs index 0c5fb92..c0f2ae9 100644 --- a/server/server.NetNode.Steam.cs +++ b/server/server.NetNode.Steam.cs @@ -871,21 +871,31 @@ private void CleanupHostSteamClient(SteamClientConnection sender) lock (_sync) { - RemoveRemoteLocked(sender.AssignedId); - _pendingAttacks.RemoveAll(a => a.Id == sender.AssignedId); - _pendingMobHits.RemoveAll(h => h.UserId == sender.AssignedId); - _pendingMobDies.RemoveAll(d => d.UserId == sender.AssignedId); - _pendingExitReadyStates.RemoveAll(s => s.UserId == sender.AssignedId); - _pendingPlayerDownStates.RemoveAll(s => s.UserId == sender.AssignedId); - _pendingPlayerReviveRequests.RemoveAll(s => s.ReviverId == sender.AssignedId || s.TargetId == sender.AssignedId); + RemovePendingPeerStateLocked(sender.AssignedId); _hasRemote = hasClients; } - if (wasConnected && !hasClients) - MainThreadPump.EnqueueCriticalMainThreadCoalesced("net:remote-disconnected", () => + if (wasConnected) + { + if (!hasClients) { - if (IsCurrentNetworkSession()) - LobbySession.NotifyRemoteDisconnected(_role); - }); + MainThreadPump.EnqueueCriticalMainThreadCoalesced("net:remote-disconnected", () => + { + if (IsCurrentNetworkSession()) + LobbySession.NotifyRemoteDisconnected(_role); + }); + } + else + { + // A connected client left but others remain. Reconcile the departed client's ghost + // slot and purge its mob interest (Phase 17) without the full lobby reset, which is + // only correct when the last client leaves the session. + MainThreadPump.EnqueueCriticalMainThreadCoalesced("net:remote-left", () => + { + if (IsCurrentNetworkSession()) + ModEntry.Instance?.HandleNetworkDisconnectGhostCleanup(_role); + }); + } + } } } diff --git a/server/server.NetNode.TransportTypes.cs b/server/server.NetNode.TransportTypes.cs new file mode 100644 index 0000000..8004b31 --- /dev/null +++ b/server/server.NetNode.TransportTypes.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using Steamworks; + +public sealed partial class NetNode +{ + private sealed class ClientConnection : IDisposable + { + public TcpClient Client { get; } + public NetworkStream Stream { get; } + public SemaphoreSlim SendLock { get; } = new(1, 1); + public int AssignedId { get; } + public EndPoint? RemoteEndPoint => Client.Client?.RemoteEndPoint; + private int _handshakeComplete; + public bool HandshakeComplete => Volatile.Read(ref _handshakeComplete) != 0; + + public ClientConnection(TcpClient client, int assignedId) + { + Client = client; + Stream = client.GetStream(); + AssignedId = assignedId; + } + + public bool TryCompleteHandshake() => Interlocked.Exchange(ref _handshakeComplete, 1) == 0; + + public void Dispose() + { + try { Stream.Close(); } catch { } + try { Client.Close(); } catch { } + try { SendLock.Dispose(); } catch { } + } + } + + private sealed class SteamClientConnection : IDisposable + { + public CSteamID SteamId { get; } + public SemaphoreSlim SendLock { get; } = new(1, 1); + public int AssignedId { get; } + private int _handshakeComplete; + public bool HandshakeComplete => Volatile.Read(ref _handshakeComplete) != 0; + private readonly object _initialStateSync = new(); + private DateTime _lastInitialStateSentUtc = DateTime.MinValue; + private long _lastPacketReceivedTicks; + + public SteamClientConnection(CSteamID steamId, int assignedId) + { + SteamId = steamId; + AssignedId = assignedId; + _lastPacketReceivedTicks = Stopwatch.GetTimestamp(); + } + + public long LastPacketReceivedTicks => Interlocked.Read(ref _lastPacketReceivedTicks); + public void MarkPacketReceived() => Interlocked.Exchange(ref _lastPacketReceivedTicks, Stopwatch.GetTimestamp()); + + public bool TryCompleteHandshake() => Interlocked.Exchange(ref _handshakeComplete, 1) == 0; + + public bool TryReserveInitialStateSend(TimeSpan minInterval, bool force = false) + { + var now = DateTime.UtcNow; + lock (_initialStateSync) + { + if (!force && + _lastInitialStateSentUtc != DateTime.MinValue && + now - _lastInitialStateSentUtc < minInterval) + { + return false; + } + + _lastInitialStateSentUtc = now; + return true; + } + } + + public void Dispose() + { + try { SendLock.Dispose(); } catch { } + } + } +} diff --git a/server/server.Tcp.cs b/server/server.Tcp.cs index b8a0552..bc91102 100644 --- a/server/server.Tcp.cs +++ b/server/server.Tcp.cs @@ -174,6 +174,8 @@ private async Task AcceptLoop(CancellationToken ct) string? cachedGeneratePayload; string? cachedCustomGameDataPayload; string? cachedRuneProgressPayload; + string? cachedHeroSkin; + string? cachedHeroHeadSkin; string? cachedCoopId; bool cachedHasContinueSave; double? cachedMobsHpMult; @@ -198,6 +200,8 @@ private async Task AcceptLoop(CancellationToken ct) cachedGeneratePayload = _cachedHostGeneratePayload; cachedCustomGameDataPayload = _cachedHostCustomGameDataPayload; cachedRuneProgressPayload = _cachedHostRuneProgressPayload; + cachedHeroSkin = _cachedHostHeroSkin; + cachedHeroHeadSkin = _cachedHostHeroHeadSkin; cachedCoopId = _cachedHostCoopId; cachedHasContinueSave = _cachedHostHasContinueSave; cachedMobsHpMult = _cachedHostMobsHpMult; @@ -250,6 +254,11 @@ await SendLineToClientSafe( if (!string.IsNullOrWhiteSpace(cachedRunExecutePayload)) await SendLineToClientSafe(connection, $"{RunLaunchWireCodec.ExecuteTag}|{cachedRunExecutePayload}\n").ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(cachedHeroSkin)) + await SendLineToClientSafe(connection, BuildTaggedLine("SKIN", 1, cachedHeroSkin)).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(cachedHeroHeadSkin)) + await SendLineToClientSafe(connection, BuildTaggedLine("HEAD", 1, cachedHeroHeadSkin)).ConfigureAwait(false); + if (cachedMobsHpMult.HasValue && cachedBossesHpMult.HasValue) await SendLineToClientSafe(connection, $"HPMULT|{cachedMobsHpMult.Value.ToString(CultureInfo.InvariantCulture)}|{cachedBossesHpMult.Value.ToString(CultureInfo.InvariantCulture)}\n").ConfigureAwait(false); @@ -446,17 +455,11 @@ private void CleanupHostClient(ClientConnection sender) lock (_sync) { - RemoveRemoteLocked(sender.AssignedId); - _pendingAttacks.RemoveAll(a => a.Id == sender.AssignedId); - _pendingMobHits.RemoveAll(h => h.UserId == sender.AssignedId); - _pendingMobDies.RemoveAll(d => d.UserId == sender.AssignedId); - _pendingExitReadyStates.RemoveAll(s => s.UserId == sender.AssignedId); - _pendingPlayerDownStates.RemoveAll(s => s.UserId == sender.AssignedId); - _pendingPlayerReviveRequests.RemoveAll(s => s.ReviverId == sender.AssignedId || s.TargetId == sender.AssignedId); + RemovePendingPeerStateLocked(sender.AssignedId); _hasRemote = hasClients; } - if (wasConnected && !hasClients) + if (wasConnected) { bool stillNoCompletedClients; lock (_clientsLock) @@ -464,11 +467,24 @@ private void CleanupHostClient(ClientConnection sender) stillNoCompletedClients = CountCompletedHostClientsLocked() == 0; } if (stillNoCompletedClients) + { MainThreadPump.EnqueueCriticalMainThreadCoalesced("net:remote-disconnected", () => { if (IsCurrentNetworkSession()) LobbySession.NotifyRemoteDisconnected(_role); }); + } + else + { + // A connected client left but others remain. Reconcile the departed client's ghost + // slot and purge its mob interest (Phase 17) without the full lobby reset, which is + // only correct when the last client leaves the session. + MainThreadPump.EnqueueCriticalMainThreadCoalesced("net:remote-left", () => + { + if (IsCurrentNetworkSession()) + ModEntry.Instance?.HandleNetworkDisconnectGhostCleanup(_role); + }); + } } } @@ -479,12 +495,12 @@ private Task SendLineToClientSafe(ClientConnection client, string line) private async Task SendKnownUsersToClientSafe(ClientConnection connection) { - List snapshot; + List snapshot; lock (_sync) { if (_remotes.Count == 0) return; - snapshot = new List(_remotes.Values); + snapshot = new List(_remotes.Values); } foreach (var state in snapshot) @@ -498,4 +514,4 @@ private async Task SendKnownUsersToClientSafe(ClientConnection connection) 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 e73dac4..762dbb9 100644 --- a/server/server.cs +++ b/server/server.cs @@ -1,9 +1,9 @@ -using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.Text; using DeadCellsMultiplayerMod; -using DeadCellsMultiplayerMod.Interaction; +using DeadCellsMultiplayerMod.Network; +using DeadCellsMultiplayerMod.Tools; using Serilog; using Steamworks; @@ -14,589 +14,8 @@ public sealed partial class NetNode : IDisposable { private readonly ILogger _log; private readonly NetRole _role; - - private sealed class ClientConnection : IDisposable - { - public TcpClient Client { get; } - public NetworkStream Stream { get; } - public SemaphoreSlim SendLock { get; } = new(1, 1); - public int AssignedId { get; } - public EndPoint? RemoteEndPoint => Client.Client?.RemoteEndPoint; - private int _handshakeComplete; - public bool HandshakeComplete => Volatile.Read(ref _handshakeComplete) != 0; - - public ClientConnection(TcpClient client, int assignedId) - { - Client = client; - Stream = client.GetStream(); - AssignedId = assignedId; - } - - public bool TryCompleteHandshake() => Interlocked.Exchange(ref _handshakeComplete, 1) == 0; - - public void Dispose() - { - try { Stream.Close(); } catch { } - try { Client.Close(); } catch { } - try { SendLock.Dispose(); } catch { } - } - } - - private sealed class SteamClientConnection : IDisposable - { - public CSteamID SteamId { get; } - public SemaphoreSlim SendLock { get; } = new(1, 1); - public int AssignedId { get; } - private int _handshakeComplete; - public bool HandshakeComplete => Volatile.Read(ref _handshakeComplete) != 0; - private readonly object _initialStateSync = new(); - private DateTime _lastInitialStateSentUtc = DateTime.MinValue; - private long _lastPacketReceivedTicks; - - public SteamClientConnection(CSteamID steamId, int assignedId) - { - SteamId = steamId; - AssignedId = assignedId; - _lastPacketReceivedTicks = Stopwatch.GetTimestamp(); - } - - public long LastPacketReceivedTicks => Interlocked.Read(ref _lastPacketReceivedTicks); - public void MarkPacketReceived() => Interlocked.Exchange(ref _lastPacketReceivedTicks, Stopwatch.GetTimestamp()); - - public bool TryCompleteHandshake() => Interlocked.Exchange(ref _handshakeComplete, 1) == 0; - - public bool TryReserveInitialStateSend(TimeSpan minInterval, bool force = false) - { - var now = DateTime.UtcNow; - lock (_initialStateSync) - { - if (!force && - _lastInitialStateSentUtc != DateTime.MinValue && - now - _lastInitialStateSentUtc < minInterval) - { - return false; - } - - _lastInitialStateSentUtc = now; - return true; - } - } - - public void Dispose() - { - try { SendLock.Dispose(); } catch { } - } - } - - private sealed class RemoteState - { - public int Id { get; } - public double X; - public double Y; - public int Dir = 1; - public bool HasPosition; - public long LastPositionSequence; - public bool HasRemote; - public string? LevelId; - public string? RoomLevelId; - public int? RoomId; - public bool HasRoom; - public string? Anim; - public int? AnimQueue; - public bool? AnimG; - public bool HasAnim; - public int Life; - public int MaxLife; - public int Lif; - public int BonusLife; - public int Recover; - public string? Username; - public bool Ready; - public string? CoopId; - public bool HasContinueSave; - public string? Skin; - public string? Head; - - public string HeadAnim; - public bool HasHeadAnim; - - public string? WeaponKind; - public int WeaponSlot; - public int WeaponPermanentId; - public int WeaponAmmo = int.MinValue; - public bool HasWeaponUpdate; - - public RemoteState(int id) - { - Id = id; - HeadAnim = string.Empty; - } - } - - public readonly struct RemoteSnapshot - { - public readonly int Id; - public readonly double X; - public readonly double Y; - public readonly int Dir; - public readonly string? LevelId; - public readonly string? RoomLevelId; - public readonly int? RoomId; - public readonly bool HasRoom; - public readonly string? Anim; - public readonly int? AnimQueue; - public readonly bool? AnimG; - public readonly bool HasAnim; - public readonly string? Username; - public readonly string? HeadAnim; - public readonly bool HasHeadAnim; - - public RemoteSnapshot( - int id, - double x, - double y, - int dir, - string? levelId, - string? roomLevelId, - int? roomId, - bool hasRoom, - string? anim, - int? animQueue, - bool? animG, - bool hasAnim, - string? username, - string? headAnim, - bool hasHeadAnim) - { - Id = id; - X = x; - Y = y; - Dir = dir; - LevelId = levelId; - RoomLevelId = roomLevelId; - RoomId = roomId; - HasRoom = hasRoom; - Anim = anim; - AnimQueue = animQueue; - AnimG = animG; - HasAnim = hasAnim; - Username = username; - HeadAnim = headAnim; - HasHeadAnim = hasHeadAnim; - } - } - - public readonly struct RemoteWeaponSnapshot - { - public readonly int Id; - public readonly string? Kind; - public readonly int Slot; - public readonly int PermanentId; - public readonly int? Ammo; - - public RemoteWeaponSnapshot(int id, string? kind, int slot, int permanentId, int? ammo) - { - Id = id; - Kind = kind; - Slot = slot; - PermanentId = permanentId; - Ammo = ammo; - } - } - - public readonly struct RemoteAttack - { - public readonly int Id; - public readonly string? Kind; - public readonly int Slot; - public readonly int PermanentId; - public readonly int? Ammo; - public readonly RemoteAttackAction Action; - - public RemoteAttack(int id, string? kind, int slot, int permanentId, int? ammo, RemoteAttackAction action) - { - Id = id; - Kind = kind; - Slot = slot; - PermanentId = permanentId; - Ammo = ammo; - Action = action; - } - } - - public readonly struct RemoteHpSnapshot - { - public readonly int Id; - public readonly int Life; - public readonly int MaxLife; - public readonly int Lif; - public readonly int BonusLife; - public readonly int Recover; - public readonly string? Username; - - public RemoteHpSnapshot(int id, int life, int maxLife, int lif, int bonusLife, int recover, string? username) - { - Id = id; - Life = life; - MaxLife = maxLife; - Lif = lif; - BonusLife = bonusLife; - Recover = recover; - Username = username; - } - } - - public readonly struct RemoteUserSnapshot - { - public readonly int Id; - public readonly string? Username; - - public RemoteUserSnapshot(int id, string? username) - { - Id = id; - Username = username; - } - } - - public readonly struct MobStateSnapshot - { - public readonly int Index; - public readonly int Generation; - public readonly double X; - public readonly double Y; - public readonly int Dir; - public readonly int Life; - public readonly int MaxLife; - public readonly string AnimPayload; - public readonly string Type; - public readonly string StatePayload; - public readonly double Time; - public readonly double Dx; - public readonly double Dy; - - public MobStateSnapshot(int index, double x, double y, int dir, int life, int maxLife, string animPayload, string type, string statePayload = "", int generation = 0, double time = 0.0, double dx = 0.0, double dy = 0.0) - { - Index = index; - Generation = generation; - X = x; - Y = y; - Dir = dir; - Life = life; - MaxLife = maxLife; - AnimPayload = animPayload ?? string.Empty; - Type = type ?? string.Empty; - StatePayload = statePayload ?? string.Empty; - Time = time; - Dx = dx; - Dy = dy; - } - } - - public readonly struct MobMoveSnapshot - { - public readonly int Index; - public readonly int Generation; - public readonly double X; - public readonly double Y; - public readonly int Dir; - public readonly string AnimPayload; - public readonly double Time; - public readonly double Dx; - public readonly double Dy; - - public MobMoveSnapshot(int index, double x, double y, int dir, string animPayload, int generation = 0, double time = 0.0, double dx = 0.0, double dy = 0.0) - { - Index = index; - Generation = generation; - X = x; - Y = y; - Dir = dir; - AnimPayload = animPayload ?? string.Empty; - Time = time; - Dx = dx; - Dy = dy; - } - } - - public readonly struct MobHit - { - public readonly int UserId; - public readonly int MobIndex; - public readonly int Generation; - public readonly int Hp; - public readonly double X; - public readonly double Y; - /// Mob type signature from MOBEVENT row (for host hit routing when syncId was rebound to a nearby same-type mob). - public readonly string Type; - /// Best-effort client damage intent. Used only when local client state rejected the hit and HP did not decrease. - public readonly double DamageHint; - - public MobHit(int userId, int mobIndex, int hp, double x, double y, string type = "", int generation = 0, double damageHint = 0.0) - { - UserId = userId; - MobIndex = mobIndex; - Generation = generation; - Hp = hp; - X = x; - Y = y; - Type = type ?? string.Empty; - DamageHint = double.IsFinite(damageHint) && damageHint > 0.0 ? damageHint : 0.0; - } - } - - public readonly struct MobDie - { - public readonly int UserId; - public readonly int MobIndex; - public readonly int Generation; - public readonly double X; - public readonly double Y; - /// Optional MOBEVENT type signature used to recover a rebuilt boss mapping. - public readonly string Type; - - public MobDie(int userId, int mobIndex, double x, double y, int generation = 0, string type = "") - { - UserId = userId; - MobIndex = mobIndex; - Generation = generation; - X = x; - Y = y; - Type = type ?? string.Empty; - } - } - - /// - /// Host-authored spawn table entry. NetId is host-owned identity; Type+X+Y are bind hints for clients. - /// - public readonly struct MobRegistryEntry - { - public readonly int NetId; - public readonly int Generation; - public readonly string Type; - public readonly double X; - public readonly double Y; - - public MobRegistryEntry(int netId, int generation, string type, double x, double y) - { - NetId = netId; - Generation = generation; - Type = type ?? string.Empty; - X = x; - Y = y; - } - } - - public readonly struct MobAttack - { - public readonly int Index; - public readonly int Generation; - public readonly string SkillId; - public readonly bool RequiresTargetInArea; - public readonly int? Data; - public readonly double X; - public readonly double Y; - public readonly int TargetUserId; - public readonly int Dir; - /// Block client simulation for this many seconds. From event; 0 = use legacy lookup. - public readonly double BlockSeconds; - /// Force facing dir for this many seconds. From event; 0 = use legacy. - public readonly double ForcedDirSeconds; - /// Mob type for rebind when syncId mapping is missing. From MOBEVENT. - public readonly string Type; - /// - /// Phase 3: host-assigned per-boss monotonic attack sequence (0 = none / non-boss). Lets the - /// client drop replayed or out-of-order boss attacks deterministically via a high-water mark. - /// - public readonly int AttackSeq; - - public MobAttack(int index, string skillId, bool requiresTargetInArea, int? data, double x, double y, int targetUserId, int dir = 0, double blockSeconds = 0, double forcedDirSeconds = 0, string type = "", int generation = 0, int attackSeq = 0) - { - Index = index; - Generation = generation; - SkillId = skillId ?? string.Empty; - RequiresTargetInArea = requiresTargetInArea; - Data = data; - X = x; - Y = y; - TargetUserId = targetUserId; - Dir = dir; - BlockSeconds = blockSeconds; - ForcedDirSeconds = forcedDirSeconds; - Type = type ?? string.Empty; - AttackSeq = attackSeq; - } - } - - /// Event-based mob update: x, y, dir + events (attack, hit, die, oldSkill). Sent when something changes, not repeatedly. - public readonly struct MobEventUpdate - { - public readonly int Index; - public readonly int Generation; - public readonly double X; - public readonly double Y; - public readonly int Dir; - public readonly IReadOnlyList Events; - /// Mob type for rebind when syncId mapping is missing. Optional. - public readonly string Type; - - public MobEventUpdate(int index, double x, double y, int dir, IReadOnlyList events, string type = "", int generation = 0) - { - Index = index; - Generation = generation; - X = x; - Y = y; - Dir = dir; - Events = events ?? Array.Empty(); - Type = type ?? string.Empty; - } - } - - public readonly struct MobDraw - { - public readonly int UserId; - public readonly int MobIndex; - public readonly int Generation; - public readonly bool IsOutOfGame; - public readonly bool IsOnScreen; - - public MobDraw(int userId, int mobIndex, bool isOutOfGame, bool isOnScreen, int generation = 0) - { - UserId = userId; - MobIndex = mobIndex; - Generation = generation; - IsOutOfGame = isOutOfGame; - IsOnScreen = isOnScreen; - } - } - - public readonly struct ExitReadyState - { - public readonly int UserId; - public readonly int DoorCx; - public readonly int DoorCy; - public readonly bool Pressed; - public readonly bool InsideCircle; - public readonly bool IsOutOfGame; - public readonly bool IsOnScreen; - /// - /// Level this readiness belongs to. Door keys are level-local grid coordinates, so without - /// it a peer standing on an exit at the same cx/cy in a DIFFERENT biome satisfies the - /// rendezvous for a level it already left — an old-level packet changing new-level state. - /// Empty means "peer predates this field"; treated as matching for compatibility. - /// - public readonly string LevelId; - - public ExitReadyState(int userId, int doorCx, int doorCy, bool pressed, bool insideCircle, bool isOutOfGame, bool isOnScreen, string? levelId = null) - { - UserId = userId; - DoorCx = doorCx; - DoorCy = doorCy; - Pressed = pressed; - InsideCircle = insideCircle; - IsOutOfGame = isOutOfGame; - IsOnScreen = isOnScreen; - LevelId = levelId ?? string.Empty; - } - } - - public readonly struct PlayerDownState - { - public readonly int UserId; - public readonly bool IsDowned; - public readonly double X; - public readonly double Y; - public readonly string LevelId; - public readonly bool HasHeadPosition; - public readonly double HeadX; - public readonly double HeadY; - public readonly bool HasHeadAnim; - public readonly string? HeadAnim; - - public PlayerDownState(int userId, bool isDowned, double x, double y, string levelId, bool hasHeadPosition = false, double headX = 0, double headY = 0, bool hasHeadAnim = false, string? headAnim = null) - { - UserId = userId; - IsDowned = isDowned; - X = x; - Y = y; - LevelId = levelId ?? string.Empty; - HasHeadPosition = hasHeadPosition; - HeadX = headX; - HeadY = headY; - HasHeadAnim = hasHeadAnim; - HeadAnim = hasHeadAnim ? (headAnim ?? string.Empty) : null; - } - } - - /// - /// Host-approved safe spawn for a player joining an already-running level. - /// - /// - /// The cell is simply where the host's hero is standing right now. That is the strongest - /// possible validity proof available and it needs no collision heuristics: a live hero is - /// occupying it, so it is inside the level, inside a room, not solid, and reachable. The - /// joining client still re-validates it against its OWN level data before using it, which - /// doubles as a world-agreement check — if the client's map has no room at the host's cell, the - /// two peers did not generate the same world and the client keeps its native entrance instead - /// of teleporting into nothing. - /// - public readonly struct HostSpawnAnchor - { - public readonly int Cx; - public readonly int Cy; - public readonly string LevelId; - - public HostSpawnAnchor(int cx, int cy, string? levelId) - { - Cx = cx; - Cy = cy; - LevelId = levelId ?? string.Empty; - } - } - - /// - /// Host-authored decision to perform one level transition. - /// - /// - /// The exit used to be a purely mutual rendezvous: whichever peer noticed "everyone is ready" - /// first went through, and so did the other, independently. That has no transaction identity, - /// so a duplicate or replayed readiness burst could start a second transition, and nothing - /// could distinguish "this trigger belongs to the level I am in now" from a stale one. - /// - /// This makes the host the single authority for WHEN a transition starts and WHICH door it - /// starts from, and gives the decision a monotonic sequence so duplicates and stale triggers - /// are rejected by identity rather than by timing. The destination is still generated natively - /// by each peer from the synchronized level graph; is carried - /// so a divergence is detected and reported instead of silently splitting the party. - /// - public readonly struct ExitTransitionCommit - { - public readonly long Sequence; - public readonly int DoorCx; - public readonly int DoorCy; - public readonly string FromLevelId; - public readonly string DestinationLevelId; - - public ExitTransitionCommit(long sequence, int doorCx, int doorCy, string? fromLevelId, string? destinationLevelId) - { - Sequence = sequence; - DoorCx = doorCx; - DoorCy = doorCy; - FromLevelId = fromLevelId ?? string.Empty; - DestinationLevelId = destinationLevelId ?? string.Empty; - } - } - - public readonly struct PlayerReviveRequest - { - public readonly int ReviverId; - public readonly int TargetId; - - public PlayerReviveRequest(int reviverId, int targetId) - { - ReviverId = reviverId; - TargetId = targetId; - } - } + private readonly NetPacketBudget _realtimePacketBudget = new(96 * 1024, 16.0); + private readonly LifecycleTracker _lifecycle = new("NetNode"); private TcpListener? _listener; // host private TcpClient? _client; // client @@ -633,6 +52,17 @@ public PlayerReviveRequest(int reviverId, int targetId) public int id => ID; + internal NetTransportSnapshot ReadTransportSnapshot() + { + return new NetTransportSnapshot( + _useSteamTransport ? NetTransportKind.Steam : NetTransportKind.Tcp, + _role, + HasAnyConnection(), + _disposed); + } + + internal LifecycleSnapshot ReadLifecycleSnapshot() => _lifecycle.Snapshot; + private static readonly int[] ClientIds = { 2, 3, 4 }; public static int MaxClientSlots => ClientIds.Length; public static int ConnectedClientCount @@ -681,39 +111,25 @@ private bool IsSupersededNetworkSession() return _disposed || (active != null && !ReferenceEquals(active, this)); } + // Ownership: _clients/_steamClients hold the transport connection objects for this session + // (mutually exclusive per _useSteamTransport). _steamClientIdsBySteam is the reverse lookup + // from steam id -> assigned client id (needed for packet routing on the steam transport). + // _remotes is the network-layer RECEIVE buffer: one RemotePlayerState per remote id, written only + // on the network thread under _sync from decoded incoming lines, and consumed on the main + // thread through pooled snapshot builders. Its lifetime is exactly this NetNode session: + // it is built up by GetOrCreateRemoteLocked, emptied by RemoveRemoteLocked on disconnect, + // and wholesale-cleared in CleanupClient()/Dispose(). It intentionally does NOT own render + // state; the ModEntry.clients[] slot arrays are a separate main-thread projection of the + // same players (slot-keyed, normalized skin/head, applied-state for diffing and GhostKing + // recreation). Keep-both: the two hold different projections with different lifetimes + // (session-scoped lock-guarded raw field vs main-thread applied visuals) and different + // consumers: _remotes feeds forwarding, late-join catch-up, and snapshot building, while + // ModEntry.clients[] feeds the in-world GhostKing pipeline. private readonly object _clientsLock = new(); private readonly Dictionary _clients = new(); private readonly Dictionary _steamClients = new(); private readonly Dictionary _steamClientIdsBySteam = new(); - private readonly Dictionary _remotes = new(); - private List _pendingAttacks = new(); - private List _pendingMobStates = new(); - private List _pendingMobMoves = new(); - private List _pendingMobHits = new(); - private List _pendingMobDies = new(); - private List _pendingMobAttacks = new(); - private List _pendingMobDraws = new(); - private List _pendingMobRegistry = new(); - private List _pendingExitReadyStates = new(); - private List _pendingExitTransitionCommits = new(); - /// Latest host spawn anchor (single value, newest wins). Guarded by _sync. - private HostSpawnAnchor? _latestHostSpawnAnchor; - private List _pendingPlayerDownStates = new(); - private List _pendingPlayerReviveRequests = new(); - private List _pendingBossCineLevelIds = new(); - private List _pendingBossHeroTeleports = new(); - private List _pendingInterDoorEvents = new(); - private List _pendingInterElevatorEvents = new(); - private List _pendingInterElevatorStateEvents = new(); - private List _pendingInterPressurePlateEvents = new(); - private List _pendingInterTreasureChestEvents = new(); - private List _pendingInterVineLadderEvents = new(); - private List _pendingInterTeleportEvents = new(); - private List _pendingInterBreakableGroundEvents = new(); - private List _pendingBossRuneUpdateCells = new(); - private List _pendingInterPortalEvents = new(); - private int _primaryRemoteId; - + private readonly Dictionary _remotes = new(); private readonly IPEndPoint _bindEp; // host bind private readonly IPEndPoint _destEp; // client connect @@ -854,6 +270,8 @@ private NetNode(ILogger log, NetRole role, IPEndPoint ep) StartClient(); ID = 0; } + + _lifecycle.Start(); } private readonly int _steamHostPort; @@ -885,5 +303,7 @@ private NetNode( ID = 0; StartSteamClient(); } + + _lifecycle.Start(); } }