diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..6b04dee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,57 @@ +--- +name: Bug report +about: Report a bug in DeadCellsCoopPlus +title: "[Bug] " +labels: bug +assignees: "" +--- + +## Bug description + +Describe the bug clearly. + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + +What should have happened? + +## Actual behavior + +What actually happened? + +## Host or client? + +- [ ] Host +- [ ] Client +- [ ] Both / not sure + +## Bug type + +- [ ] Crash +- [ ] Desync +- [ ] Revive/downed player +- [ ] Enemy issue +- [ ] Rune/progression issue +- [ ] Invisible object +- [ ] Networking issue +- [ ] Other + +## Game/mod information + +- Dead Cells version: +- DeadCellsCoopPlus version: +- Original mod version, if known: +- Number of players: + +## Logs/screenshots + +Paste logs or add screenshots here. + +## Extra information + +Anything else that might help. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b835626 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature request +about: Suggest an improvement for DeadCellsCoopPlus +title: "[Feature] " +labels: enhancement +assignees: "" +--- + +## Feature description + +Describe the feature or improvement. + +## Why should this be added? + +Explain the problem this solves or why it would improve multiplayer. + +## Suggested behavior + +Describe how it should work. + +## Alternatives + +Have you tried another solution? + +## Extra information + +Add screenshots, examples, or notes if useful. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ce70437 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,44 @@ +# Pull Request + +## Summary + +Describe what this pull request changes. + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation +- [ ] Refactor +- [ ] Performance improvement +- [ ] Other + +## Related issue + +Fixes # + +## What was changed? + +- + +## Testing + +How did you test this? + +- [ ] Tested as host +- [ ] Tested as client +- [ ] Tested with 2 players +- [ ] Tested revive/downed system +- [ ] Tested enemy/rune progression +- [ ] Not tested in-game yet + +## Known issues + +List any problems or unfinished parts. + +## Checklist + +- [ ] My change is focused and not mixed with unrelated edits +- [ ] I kept credit to the original project +- [ ] I updated documentation if needed +- [ ] I checked that the project still builds, if possible diff --git a/.gitignore b/.gitignore index 9377506..2c7dcb6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,9 @@ obj/ bin/ tools_tmp/ __decomp_gp/ -logs/ \ No newline at end of file +logs/ +.vs/ +*.user +*.suo +last_error.txt +log_*.log diff --git a/ADVANCED_COOP_NOTES.md b/ADVANCED_COOP_NOTES.md new file mode 100644 index 0000000..ab84590 --- /dev/null +++ b/ADVANCED_COOP_NOTES.md @@ -0,0 +1,40 @@ +# Advanced Co-op Hardening Notes — v0.8.0 + +This build goes back to the Vaiser-style base instead of the clean-room experiment, but it hardens the parts that were repeatedly causing crashes/desync. + +## Main changes + +- Disabled dangerous debug item/perk injection during `HeroInit`. +- Disabled old debug hotkeys that spawned mobs and wrote cooldown maps during normal play. +- Added `CoopAdvancedHardening`, a lightweight runtime layer for: + - automatic lobby heartbeat packets, + - automatic room-status refresh in the title menu, + - permanent rune/progression unlock sync, + - HUD connection status messages. +- Added Steam invite overlay support from the host room menu. +- Made the main co-op menu shorter and cleaner: + - Host room + - Join room + - Save slot + - Steam friends lobby + - IP / VPN lobby +- Added room status lines for same-lobby visibility. +- Added an exit teleport failsafe. If one player is at an exit/portal and another player gets stuck, the stuck player is pulled to the exit after a delay. +- Hardened mob death handling so clients do not ignore host-authoritative death packets for mobs that already have `life == 0` but never despawned. This targets the “elite stays on client with no HP bar” bug. +- Added broad permanent progression sync for rune-like/progression-like permanent item IDs. + +## Notes + +This is source only. Build it locally with DCCM/MDK. + +Recommended test order: + +1. Remove older broken mod copies from `coremod/mods`. +2. Build v0.8.0. +3. Host Steam friends lobby. +4. Invite via the new Steam invite overlay button. +5. Confirm the room menu says same lobby. +6. Start run. +7. Test elite rune rooms, Ossuary transitions, and boss doors/cinematics. + +Send the full console log if a crash still happens. The most important lines are `[NetMod]`, `[CoopAdvanced]`, `[MobsSync]`, `[ExitSync]`, and Hashlink exception stacks. diff --git a/AdvancedCoop/CoopAdvancedHardening.cs b/AdvancedCoop/CoopAdvancedHardening.cs new file mode 100644 index 0000000..67a30af --- /dev/null +++ b/AdvancedCoop/CoopAdvancedHardening.cs @@ -0,0 +1,323 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using dc; +using dc.en; +using dc.hl.types; +using dc.pr; +using dc.tool; +using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI; +using HaxeProxy.Runtime; +using ModCore.Events; +using ModCore.Events.Interfaces.Game; +using ModCore.Events.Interfaces.Game.Hero; +using ModCore.Utilities; +using Serilog; + +namespace DeadCellsMultiplayerMod.AdvancedCoop; + +/// +/// Stability and progression layer built on top of the original multiplayer base. +/// It deliberately avoids constructing fake heroes/items during HeroInit. +/// +public sealed class CoopAdvancedHardening : + IEventReceiver, + IOnAdvancedModuleInitializing, + IOnFrameUpdate, + IOnHeroUpdate +{ + private static readonly object Sync = new(); + private static readonly HashSet PendingPermanentItems = new(StringComparer.OrdinalIgnoreCase); + private static ILogger? _log; + private static long _nextLobbyHeartbeatTicks; + private static long _nextProgressSyncTicks; + private static long _nextHudStatusTicks; + private static string _lastSentProgress = string.Empty; + private static string _lastAppliedProgress = string.Empty; + private static int _lastKnownRemoteCount = -1; + private static bool _wasConnected; + + private const double LobbyHeartbeatSeconds = 0.50; + private const double ProgressSyncSeconds = 1.50; + private const double HudStatusSeconds = 3.00; + + public CoopAdvancedHardening(ModEntry entry) + { + _log = entry.Logger; + EventSystem.AddReceiver(this); + } + + void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) + { + _log = entry.Logger; + entry.Logger.Information("\x1b[32m[[CoopAdvancedHardening] Initializing advanced co-op hardening...]\x1b[0m "); + } + + void IOnFrameUpdate.OnFrameUpdate(double dt) + { + var net = GameMenu.NetRef; + if (net == null || !net.IsAlive) + { + _wasConnected = false; + return; + } + + var now = Stopwatch.GetTimestamp(); + if (_nextLobbyHeartbeatTicks == 0 || now >= _nextLobbyHeartbeatTicks) + { + _nextLobbyHeartbeatTicks = now + SecondsToTicks(LobbyHeartbeatSeconds); + SendLobbyHeartbeat(net); + GameMenu.RefreshRoomStatusMenuIfVisible(); + } + + if (_nextProgressSyncTicks == 0 || now >= _nextProgressSyncTicks) + { + _nextProgressSyncTicks = now + SecondsToTicks(ProgressSyncSeconds); + SendPermanentProgress(net); + ApplyPendingPermanentProgress(); + } + + if (_nextHudStatusTicks == 0 || now >= _nextHudStatusTicks) + { + _nextHudStatusTicks = now + SecondsToTicks(HudStatusSeconds); + PushConnectionHudStatus(net); + } + } + + void IOnHeroUpdate.OnHeroUpdate(double dt) + { + ApplyPendingPermanentProgress(); + } + + private static long SecondsToTicks(double seconds) => (long)(Stopwatch.Frequency * seconds); + + private static void SendLobbyHeartbeat(NetNode net) + { + try + { + var level = ModEntry.me?._level?.map?.id?.ToString() ?? ModEntry.Instance?.levelId ?? string.Empty; + var seed = GameMenu.TryGetKnownSeed(out var knownSeed) ? knownSeed : 0; + net.SendLobbyState(GameMenu.Username, level, seed, GetLocalPermanentProgressSignature()); + } + catch (Exception ex) + { + _log?.Warning("[CoopAdvanced] Lobby heartbeat failed: {Message}", ex.Message); + } + } + + private static void SendPermanentProgress(NetNode net) + { + try + { + var payload = BuildLocalPermanentProgressPayload(); + if (string.IsNullOrWhiteSpace(payload)) + return; + if (string.Equals(payload, _lastSentProgress, StringComparison.Ordinal)) + return; + + _lastSentProgress = payload; + net.SendRuneProgress(payload); + } + catch (Exception ex) + { + _log?.Warning("[CoopAdvanced] Progress sync send failed: {Message}", ex.Message); + } + } + + private static void PushConnectionHudStatus(NetNode net) + { + try + { + var connected = net.HasRemote; + var remoteCount = net.IsHost ? NetNode.ConnectedClientCount : (connected ? 1 : 0); + if (connected == _wasConnected && remoteCount == _lastKnownRemoteCount) + return; + + _wasConnected = connected; + _lastKnownRemoteCount = remoteCount; + if (connected) + MultiplayerUI.PushSystemMessage(net.IsHost ? $"Co-op: {remoteCount} friend(s) connected" : "Co-op: connected to host", 4.0, 1.0); + else + MultiplayerUI.PushSystemMessage(net.IsHost ? "Co-op: lobby open, waiting for friend" : "Co-op: waiting for host", 4.0, 1.0); + } + catch + { + } + } + + public static void ReceiveLobbyState(string payload) + { + if (string.IsNullOrWhiteSpace(payload)) + return; + + // Current build uses this mainly as a live-room heartbeat. The menu/connection UI already reads NetNode.HasRemote; + // receiving this packet marks the remote alive in NetNode. Keep parsing intentionally loose for forward compatibility. + try + { + var parts = payload.Split('|'); + var nameIndex = parts.Length >= 2 ? 1 : 0; + if (parts.Length > nameIndex && !string.IsNullOrWhiteSpace(parts[nameIndex])) + GameMenu.ReceiveRemoteUsername(parts[nameIndex].Trim()); + } + catch (Exception ex) + { + _log?.Warning("[CoopAdvanced] Lobby state parse failed: {Message}", ex.Message); + } + } + + public static void ReceiveRuneProgress(string payload) + { + if (string.IsNullOrWhiteSpace(payload)) + return; + + lock (Sync) + { + foreach (var raw in payload.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var id = SanitizePermanentItemId(raw); + if (string.IsNullOrWhiteSpace(id)) + continue; + if (!IsProgressPermanentItem(id)) + continue; + PendingPermanentItems.Add(id); + } + } + } + + private static void ApplyPendingPermanentProgress() + { + string[] pending; + lock (Sync) + { + if (PendingPermanentItems.Count == 0) + return; + pending = PendingPermanentItems.ToArray(); + PendingPermanentItems.Clear(); + } + + var user = GetUser(); + if (user == null) + return; + + try + { + var meta = user.itemMeta ?? new ItemMetaManager(user); + meta.itemProgress ??= (ArrayObj)ArrayUtils.CreateDyn().array; + meta.permanentItems ??= (ArrayObj)ArrayUtils.CreateDyn().array; + user.itemMeta = meta; + + var added = 0; + foreach (var id in pending) + { + if (string.IsNullOrWhiteSpace(id)) + continue; + var hx = id.AsHaxeString(); + if (meta.hasPermanentItem(hx)) + continue; + if (meta.addPermanentItem(hx)) + added++; + } + + if (added > 0) + { + var sig = string.Join(",", pending.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)); + if (!string.Equals(sig, _lastAppliedProgress, StringComparison.Ordinal)) + { + _lastAppliedProgress = sig; + MultiplayerUI.PushSystemMessage($"Co-op progression synced: +{added} unlock(s)", 6.0, 1.5); + _log?.Information("[CoopAdvanced] Applied {Count} synced permanent unlocks", added); + } + } + } + catch (Exception ex) + { + _log?.Warning("[CoopAdvanced] Applying permanent progress failed: {Message}", ex.Message); + } + } + + private static string BuildLocalPermanentProgressPayload() + { + var ids = GetLocalPermanentProgressIds(); + return ids.Count == 0 ? string.Empty : string.Join(",", ids.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)); + } + + private static string GetLocalPermanentProgressSignature() + { + var ids = GetLocalPermanentProgressIds(); + return ids.Count.ToString(CultureInfo.InvariantCulture); + } + + private static List GetLocalPermanentProgressIds() + { + var result = new List(); + var user = GetUser(); + var arr = user?.itemMeta?.permanentItems; + if (arr == null) + return result; + + try + { + for (int i = 0; i < arr.length; i++) + { + var id = SanitizePermanentItemId(arr.getDyn(i)?.ToString() ?? string.Empty); + if (string.IsNullOrWhiteSpace(id)) + continue; + if (!IsProgressPermanentItem(id)) + continue; + if (!result.Any(existing => string.Equals(existing, id, StringComparison.OrdinalIgnoreCase))) + result.Add(id); + } + } + catch + { + } + + return result; + } + + private static User? GetUser() + { + try { if (ModEntry.me?._level?.game?.user != null) return ModEntry.me._level.game.user; } catch { } + try { if (ModEntry.Instance?.game?.user != null) return ModEntry.Instance.game.user; } catch { } + try { if (dc.pr.Game.Class.ME?.user != null) return dc.pr.Game.Class.ME.user; } catch { } + try { if (dc.Main.Class.ME?.user != null) return dc.Main.Class.ME.user; } catch { } + return null; + } + + private static string SanitizePermanentItemId(string id) + { + if (string.IsNullOrWhiteSpace(id)) + return string.Empty; + return id.Trim().Replace("|", "/").Replace(",", ";").Replace("\r", string.Empty).Replace("\n", string.Empty); + } + + private static bool IsProgressPermanentItem(string id) + { + if (string.IsNullOrWhiteSpace(id)) + return false; + + // Mobility/rune/progression unlocks. Kept broad because Dead Cells internal IDs vary by version/DLC. + var lower = id.ToLowerInvariant(); + return lower.Contains("rune") || + lower.Contains("key") || + lower.Contains("teleport") || + lower.Contains("spider") || + lower.Contains("belier") || + lower.Contains("ram") || + lower.Contains("gardener") || + lower.Contains("vine") || + lower.Contains("wall") || + lower.Contains("challenger") || + lower.Contains("homunculus") || + lower.Contains("explokey") || + lower.Contains("pokebomb") || + lower.Contains("mirror") || + lower.Contains("backpack") || + lower.Contains("armory") || + lower.Contains("recycling") || + lower.Contains("shopcategor"); + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..17042ef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,81 @@ +# Changelog + +## v0.8.38 - Sublevel exit dive-combat guard + +- Prevents stale dive-attack hits after returning from reward-room sublevels. +- Drains queued remote combat packets before and after sublevel activation. +- Adds a short post-room-change combat replay grace period. +- Suppresses dive area-hit resolution in no-combat `T_*` transition/passages. +- Temporarily removes invalid sprite targets from dive hit resolution in combat biomes. +- Keeps the v0.8.36 transition, reward-door, MobSync, Party HUD, and menu-color fixes. + +# v0.8.37 Blue multiplayer menu label + +- Changes the main-menu **Play Multiplayer** text from white to a soft blue (`#7FD4FF`). +- Leaves all other menu entries and gameplay behavior unchanged. +- Keeps every transition, KingSkin, ZDoor, MobSync, Steam, revive, and Party HUD fix from v0.8.36. + +# v0.8.36 ZDoor sublevel render guard + +- Keeps the v0.8.35 KingSkin fix for normal biome transitions. +- Arms the KingSkin render guard before `ZDoor.onActivate`, which is used by the timed and no-hit reward-room doors. +- Keeps remote KingSkin sprites detached through `Level.resume -> Level.onActivation -> Level.initRender -> LevelDisp.render`. +- Disposes the detached remote ghost shell only after native sublevel rendering completes, then recreates it from the next network snapshot in the active room. +- Adds an eight-second safety timeout so a door that does not transition cannot leave the remote player hidden permanently. + +# v0.8.35 KingSkin build fix + +- Added the missing `ModCore.Utilities` namespace import required by the `AsHaxeString()` extension in `ModEntry.KingSkinRenderSafety.cs`. +- No runtime behavior changes from v0.8.34. + +# v0.8.34 KingSkin render guard + +- Targets the repeated `Game.loadMainLevel -> Boot.tryRender -> Null access .groupName` fatal at the multiplayer `GhostKing : KingSkin` render objects. +- Validates the remote KingSkin body, body clones, and custom head sprites after graphics initialization and during normal frames. +- Repairs missing animation groups through `AnimManager.play("idle")` or `HSprite.set(...)` instead of writing an empty group name into every registered vanilla entity. +- Hides and detaches all remote KingSkin render nodes immediately before a level transition, without destroying their gameplay entities during the fade. +- Blocks remote skin/head recreation while the old level is transitioning so network callbacks cannot reattach a new KingSkin sprite during `loadMainLevel`. +- Keeps the v0.8.33 vanilla `Level.init`, advanced MobSync fixes, new Party HUD, and stable v0.8.2 exit behavior. +- Adds `[NetMod][KingSkinGuard]` diagnostics showing the animation group before/after repair and whether transition detachment ran. + +# v0.8.33 Dead Cells v35.9 vanilla Level.init compatibility + +- Stops replacing the complete game `Level.init` method. +- Uses the current vanilla level initializer from the June 2026 game build. +- Removes all active manual `Boot.tryRender()` calls from the level initialization path. +- Keeps v0.8.32 advanced MobSync fixes, Party HUD, and v0.8.2 exit behavior. +- Adds compatibility logging for every initialized level. + +# v0.8.32 stable v0.8.2 transition base + MobSync hardening + safe new Party HUD + +- Keeps v0.8.2 `LevelExitSync.cs`, level loading, ghost lifecycle, and ModEntry transition behavior unchanged. +- Ports the later MobSync fixes: trusted sync-id hit distance, client culled-mob wake/replay gates, deferred client deaths, ghost-mob despawn echo, teleport-skill replay blacklist, and duplicate-death protection. +- Includes the newer bronze/name/segmented-percent Party HUD appearance. +- Rebuilds that HUD on a vanilla-managed outer `FlowBox` with an inner absolute-position panel, avoiding the loose root object that could survive HUD disposal. +- Delays custom party-label creation for 1.5 seconds after HUD initialization so cached HP cannot create font-backed labels during `Game.loadMainLevel`. +- The later mob-sync transition-quiesce code remains dormant because the stable v0.8.2 transition path never arms it. + +## v0.8.2 - Dynamic Contains build fix + +- Fixed C# dynamic dispatch compile error in `AdvancedCoop/CoopAdvancedHardening.cs`. +- Replaced `List.Contains(id, StringComparer.OrdinalIgnoreCase)` with an explicit `Any(...)` + `string.Equals(...)` check. +- No gameplay/network changes from v0.8.1. + +## 0.8.0 - Advanced stable co-op hardening + +- Returned to the Vaiser-style base instead of the clean-room prototype. +- Disabled HeroInit debug perk/item injection that caused `InventItem/commonProps` crashes. +- Disabled dangerous debug hotkeys and cooldown-map writes during normal gameplay. +- Added automatic lobby heartbeat packets and automatic room-status refresh. +- Added Steam friend invite overlay button to host room UI. +- Simplified main menu co-op flow and room status lines. +- Added permanent rune/progression sync using `RUNEPROG` packets. +- Hardened host-authoritative mob death handling to clean up life=0 ghost mobs/elites. +- Added exit teleport failsafe for portals/exits/Ossuary-style transitions. +- Added x64/win-x64 project settings. + + +## v0.8.1 - User namespace build fix + +- Fixed `User` namespace compile error in `AdvancedCoop/CoopAdvancedHardening.cs` by importing the generated `dc.User` type namespace. +- No gameplay/network changes from v0.8.0. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7b21bd8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,37 @@ +# Code of Conduct + +## Our pledge + +We want DeadCellsCoopPlus to be a friendly and useful community project. + +Everyone is welcome to contribute, report bugs, suggest fixes, and help test the mod. + +## Expected behavior + +Please: + +- Be respectful +- Give useful feedback +- Stay on topic +- Be patient with new contributors +- Credit other people's work +- Report bugs clearly + +## Unacceptable behavior + +Please do not: + +- Harass or insult others +- Spam issues or pull requests +- Steal credit for other people's work +- Post malicious code +- Start personal arguments +- Demand fixes aggressively + +## Enforcement + +Maintainers may remove comments, close issues, reject pull requests, or block users who repeatedly break these rules. + +## Attribution + +This code of conduct is written for this community project and may be updated as the project grows. diff --git a/COMPATIBILITY_NOTES_v0.8.33.md b/COMPATIBILITY_NOTES_v0.8.33.md new file mode 100644 index 0000000..6966418 --- /dev/null +++ b/COMPATIBILITY_NOTES_v0.8.33.md @@ -0,0 +1,28 @@ +# v0.8.33 Dead Cells v35.9 compatibility test + +## Main change + +The mod no longer replaces the full `dc.pr.Level.init` routine. It now calls the current game's original `Level.init` implementation and lets MobSync rebuild its registry from `entitiesPostCreate`. + +The legacy copied initializer remains in the source for comparison but is never hooked or executed. + +## Why + +Dead Cells received a PC stability patch on 15 June 2026. The current executable identifies its source build as `client_20260616_101646`. The previous multiplayer initializer manually reconstructed hundreds of internal fields and called `Boot.tryRender()` repeatedly. That approach is brittle across game updates and matches the observed `Boot.tryRender -> Null access .groupName` transition fatal. + +## Preserved features + +- Advanced MobSync fixes from v0.8.32 +- New Party HUD from v0.8.32 +- v0.8.2 exit/door behavior +- Steam/TCP hosting and joining +- Revive and other merged systems + +## Expected log + +```text +[NetMod] Source build: v0.8.33-vanilla-level-init-compat +[NetMod][Compat] using vanilla Level.init for level=PrisonStart +``` + +The old custom initializer should not execute. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34566a0..161d22f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,9 +3,9 @@ English • [Русский](CONTRIBUTING_ru.md) -# Contributing to DeadCellsMultiplayerMod +# Contributing to DeadCellsCoopPlus -Thank you for your interest in improving this mod. This document describes how to build the project and what we expect from contributions. +Thank you for your interest in improving DeadCellsCoopPlus. This document describes how to build the project and what we expect from contributions. ## Prerequisites diff --git a/CONTRIBUTING_ru.md b/CONTRIBUTING_ru.md index 4ac0c1a..811ccba 100644 --- a/CONTRIBUTING_ru.md +++ b/CONTRIBUTING_ru.md @@ -1,4 +1,4 @@ -# Участие в разработке DeadCellsMultiplayerMod +# Участие в разработке DeadCellsCoopPlus Спасибо за интерес к улучшению мода. Здесь описано, как собрать проект и чего мы ожидаем от изменений в коде. diff --git a/DeadCellsMultiplayerMod.csproj b/DeadCellsMultiplayerMod.csproj index 9c33305..3080916 100644 --- a/DeadCellsMultiplayerMod.csproj +++ b/DeadCellsMultiplayerMod.csproj @@ -8,9 +8,14 @@ Windows $(NoWarn);CA1416;CS0414;CS9107 - 0.3.3 + 0.8.38 mod DeadCellsMultiplayerMod.ModEntry + + x64 + win-x64 + false + DeadCellsMultiplayerMod true $(DCCM_MDK_ROOT)\tools\Steamworks.NET.dll diff --git a/Ghost/GhostKing.cs b/Ghost/GhostKing.cs index 346d507..640117d 100644 --- a/Ghost/GhostKing.cs +++ b/Ghost/GhostKing.cs @@ -1061,7 +1061,7 @@ public override void initGfx() // Scarf initScarf(); - + ModEntry.EnsureGhostKingRenderSafe(this, "GhostKing.initGfx", detachForTransition: false); } private bool TryRetargetCurrentSprite(SpriteLib heroLib, dc.String group, Texture normalMap) @@ -1099,6 +1099,7 @@ public void ApplyRemoteSkin(string? skin) { this.disposeGfx(); this.initGfx(); + ModEntry.EnsureGhostKingRenderSafe(this, "GhostKing.ApplyRemoteSkin", detachForTransition: false); } catch { @@ -1107,6 +1108,7 @@ public void ApplyRemoteSkin(string? skin) { this.disposeGfx(); this.initGfx(); + ModEntry.EnsureGhostKingRenderSafe(this, "GhostKing.ApplyRemoteSkin.fallback", detachForTransition: false); } catch { diff --git a/Ghost/KingDiveAttackHooksBridge.cs b/Ghost/KingDiveAttackHooksBridge.cs index a8f3c1f..a363f3c 100644 --- a/Ghost/KingDiveAttackHooksBridge.cs +++ b/Ghost/KingDiveAttackHooksBridge.cs @@ -20,6 +20,9 @@ public partial class ModEntry private const double LocalDiveStartRepeatBlockSeconds = 0.04; private const double LocalDiveLandRepeatBlockSeconds = 0.04; private const double DiveInfoRescanMinSeconds = 0.2; + private const double PostRoomDiveGuardSeconds = 1.25; + + private static long s_lastNoCombatDiveSuppressLogTicks; private long _lastLocalDiveStartSendTicks; private long _lastLocalDiveLandSendTicks; @@ -58,9 +61,10 @@ private void Hook_DiveAttack_onOwnerLand(Hook_DiveAttack.orig_onOwnerLand orig, return; var wasDiving = IsDiveReallyActive(self); + bool executed; try { - ExecuteDiveAttackLand(orig, self, high, hero!); + executed = ExecuteDiveAttackLand(orig, self, high, hero!); } catch (Exception ex) { @@ -69,6 +73,9 @@ private void Hook_DiveAttack_onOwnerLand(Hook_DiveAttack.orig_onOwnerLand orig, return; } + if (!executed) + return; + try { NotifyLocalDiveAttackLandedFromHooks(self, high, wasDiving); @@ -145,7 +152,7 @@ private static bool IsDiveAttackHookContextValid(DiveAttack? self, out Hero? her return true; } - private static void ExecuteDiveAttackLand(Hook_DiveAttack.orig_onOwnerLand orig, DiveAttack self, double high, Hero hero) + private static bool ExecuteDiveAttackLand(Hook_DiveAttack.orig_onOwnerLand orig, DiveAttack self, double high, Hero hero) { Level? level; try @@ -158,16 +165,56 @@ private static void ExecuteDiveAttackLand(Hook_DiveAttack.orig_onOwnerLand orig, } if (level == null) - return; + return false; + + // Transition/passages (T_*) are no-combat staging levels. A sublevel return can leave + // stale Mob collision entries alive even though there are no legitimate combat targets. + // Let the dive animation end, but do not run vanilla area-hit resolution there. + if (IsNoCombatTransitionLevel(level, out var levelId)) + { + LogSuppressedNoCombatDive(levelId); + try { self.end(); } catch { } + return false; + } // Vanilla applyHit/applyAttackResult reads spr.groupName; null crashes the HL runtime (HashlinkError). if (!IsHeroSpriteGroupReadyForCombat(hero)) { try { self.end(); } catch { } + return false; + } + + WithSanitizedDiveTargets(level, () => orig(self, high)); + return true; + } + + private static bool IsNoCombatTransitionLevel(Level level, out string levelId) + { + levelId = string.Empty; + try + { + levelId = level.map?.id?.ToString() ?? string.Empty; + } + catch + { + } + + return levelId.StartsWith("T_", StringComparison.Ordinal); + } + + private static void LogSuppressedNoCombatDive(string levelId) + { + var now = Stopwatch.GetTimestamp(); + if (s_lastNoCombatDiveSuppressLogTicks != 0 && + Stopwatch.GetElapsedTime(s_lastNoCombatDiveSuppressLogTicks, now).TotalSeconds < 1.0) + { return; } - WithSanitizedQuadElements(level, () => orig(self, high)); + s_lastNoCombatDiveSuppressLogTicks = now; + Instance?.Logger.Information( + "[NetMod][DiveGuard] suppressed dive hit in no-combat transition level={LevelId}", + levelId); } /// @@ -192,6 +239,68 @@ private static bool IsHeroSpriteGroupReadyForCombat(Hero? hero) } } + private readonly struct DiveTargetableSnapshot + { + public readonly dc.Entity Entity; + public readonly bool WasTargetable; + + public DiveTargetableSnapshot(dc.Entity entity, bool wasTargetable) + { + Entity = entity; + WasTargetable = wasTargetable; + } + } + + private static void WithSanitizedDiveTargets(Level level, Action action) + { + if (action == null) + return; + + var disabled = new List(); + try + { + ArrayObj? entities = null; + try { entities = level.entities; } catch { } + if (entities != null) + { + for (var i = 0; i < entities.length; i++) + { + dc.Entity? entity = null; + try { entity = entities.getDyn(i) as dc.Entity; } catch { } + if (entity == null || IsEntityQuadHitSafe(entity)) + continue; + + try + { + var wasTargetable = entity._targetable; + entity._targetable = false; + disabled.Add(new DiveTargetableSnapshot(entity, wasTargetable)); + } + catch + { + } + } + } + + WithSanitizedQuadElements(level, action); + } + finally + { + for (var i = disabled.Count - 1; i >= 0; i--) + { + var snapshot = disabled[i]; + try + { + if (!snapshot.Entity.destroyed) + snapshot.Entity._targetable = snapshot.WasTargetable; + } + catch + { + } + } + } + } + private static void WithSanitizedQuadElements(Level level, Action action) { if (action == null) @@ -302,7 +411,8 @@ private bool IsLocalDiveNetGuardActive() internal void MarkDiveNetGuardAfterSpawnOrRoomChange() { - _localDiveNetGuardUntilTicks = Stopwatch.GetTimestamp() + (long)(Stopwatch.Frequency * 0.55); + _localDiveNetGuardUntilTicks = Stopwatch.GetTimestamp() + + (long)(Stopwatch.Frequency * PostRoomDiveGuardSeconds); } private void NotifyLocalDiveAttackStartedFromHooks(DiveAttack? self) @@ -744,6 +854,12 @@ private bool TryHandleRemoteDiveAttack(NetNode.RemoteAttack attack, int localId) if (!IsRemoteDiveAttackKind(attack.Kind)) return false; + // Drop stale dive land packets while a room/sublevel is changing and during the + // short post-activation grace window. Full-level changes already drain these queues; + // sublevel activation needs the same protection. + if (IsRemoteKingTransitionActive || IsLocalDiveNetGuardActive()) + return true; + var remoteIsDiving = attack.PermanentId != 0; if (!remoteIsDiving) return true; diff --git a/KINGSKIN_CRASH_NOTES_v0.8.34.md b/KINGSKIN_CRASH_NOTES_v0.8.34.md new file mode 100644 index 0000000..75358d5 --- /dev/null +++ b/KINGSKIN_CRASH_NOTES_v0.8.34.md @@ -0,0 +1,25 @@ +# v0.8.34 KingSkin transition crash notes + +The recurring fatal is: + +``` +Game.loadMainLevel -> Boot.tryRender -> Null access .groupName +``` + +The multiplayer remote player is implemented as `GhostKing : KingSkin`. On the current Dead Cells build, one of the KingSkin-owned `HSprite` objects can reach the renderer with a null animation `groupName`, especially while the old level remains visible during the loading fade. + +This build does not globally assign `string.Empty` to partially initialized vanilla sprites. Instead it: + +1. Validates only multiplayer KingSkin sprites. +2. Attempts to restore the valid `idle` animation through the normal sprite APIs. +3. Detaches the old remote KingSkin render nodes before the transition begins. +4. Prevents network skin/head updates from recreating those render nodes until the local hero has entered the next level. + +Expected log lines: + +``` +[NetMod] Source build: v0.8.34-kingskin-render-guard +[NetMod][KingSkinGuard] reason=exit-activate:... detached=True ... +``` + +If a null group is detected before the transition, the same diagnostic reports `bodyGroupBefore=` and the resulting group after repair. diff --git a/MERGE_NOTES_v0.8.32.md b/MERGE_NOTES_v0.8.32.md new file mode 100644 index 0000000..d433612 --- /dev/null +++ b/MERGE_NOTES_v0.8.32.md @@ -0,0 +1,24 @@ +# v0.8.32 merge notes + +This source intentionally uses the v0.8.2 rollback as the base. + +## Kept from v0.8.2 +- `UI/LevelExitSync.cs` unchanged. +- `ModEntry/ModEntry.cs` behavior unchanged except for a build-identification log line. +- Original level/door loading and ghost lifecycle. + +## Ported from later stabilization builds +- MobSync client culled-mob safety gates. +- Deferred client mob death execution. +- Ghost-mob despawn echo. +- Teleport replay blacklist. +- More tolerant sync-id hit position validation. +- Duplicate network death protection. + +## Party HUD +The newer bronze/segmented Party HUD is included, but its outer owner is a vanilla-managed `FlowBox`. The custom drawing surface is only an inner child, so HUD disposal owns the complete tree. Plate creation is delayed for 1.5 seconds after `HUD.initHero`. + +## Test identity +The game log must contain: + +`[NetMod] Source build: v0.8.32-stable-v082-mobsync-partyhud` diff --git a/Mobs/Levelinit.cs b/Mobs/Levelinit.cs index fbe7033..e40137a 100644 --- a/Mobs/Levelinit.cs +++ b/Mobs/Levelinit.cs @@ -58,7 +58,20 @@ private void Levelinit_OnDispose(Hook_Level.orig_onDispose orig, Level self) + // Compatibility path for Dead Cells v35.9+ (June 2026). + // Do not replace the game's complete Level.init implementation: the game now + // owns render/UI initialization details that this legacy copied routine cannot + // safely reproduce. MobSync attaches later through entitiesPostCreate. private void Levelinit_Main(Hook_Level.orig_init orig, Level self) + { + ModEntry.Instance?.Logger.Information( + "[NetMod][Compat] using vanilla Level.init for level={LevelId}", + self.map?.id?.ToString() ?? string.Empty); + orig(self); + } + + // Kept only as reference for older game builds. It is no longer hooked. + private void LegacyLevelinit_Main(Hook_Level.orig_init orig, Level self) { initprocess(self); diff --git a/Mobs/MonsterSynchronization.Attacks.cs b/Mobs/MonsterSynchronization.Attacks.cs index 0a29785..f404833 100644 --- a/Mobs/MonsterSynchronization.Attacks.cs +++ b/Mobs/MonsterSynchronization.Attacks.cs @@ -319,6 +319,8 @@ private static bool RebuildMobArray(Level? level) generationAfterRebuild, s_levelIdentityToken); + ClearSyncQuiesceAfterRebuild(); + for (int i = 0; i < s_batchMobsScratch.Count; i++) QueueInitialMobSync(s_batchMobsScratch[i]); @@ -519,6 +521,7 @@ private static void ResetMobTrackingStateLocked() IdToMob.Clear(); MobToId.Clear(); nextRuntimeSyncId = 0; + s_pendingCulledMobDeaths.Clear(); clientMobTargets.Clear(); clientCachedAttackTargetByMob.Clear(); clientQueuedOldSkillMarkers.Clear(); @@ -783,6 +786,12 @@ private static void ValidateTrackedIntegrityLocked(string reason) MobSyncTrace.LogInvariantViolation(reason, $"IdToMob mismatch syncId={syncId} localIndex={i}"); } + // The first pass intentionally records every tracked mob/id. Start fresh before + // validating IdToMob itself; otherwise every valid dictionary entry is reported as a + // duplicate merely because it was already seen through trackedMobs. + s_validationSeenMobsScratch.Clear(); + s_validationSeenSyncIdsScratch.Clear(); + foreach (var pair in IdToMob) { var syncId = pair.Key; diff --git a/Mobs/MonsterSynchronization.ClientApply.cs b/Mobs/MonsterSynchronization.ClientApply.cs index e82ef5c..1842110 100644 --- a/Mobs/MonsterSynchronization.ClientApply.cs +++ b/Mobs/MonsterSynchronization.ClientApply.cs @@ -153,6 +153,88 @@ private static bool ShouldProcessClientVisualState(Mob mob) return false; } + /// + /// Client-side: mark the mob dead and defer the vanilla death to the mob's OWN update + /// cycle ( in postUpdate). The original mod never + /// calls onDie() from a network apply; a death half-executed outside the mob's update is + /// corrupted state that the level-transition render trips on (Null access .groupName with + /// every mod scene object provably removed). Deferral keeps the ghost-mob cleanup while + /// restoring vanilla death timing. Returns true when deferred (always, for valid mobs). + /// + private static bool TryDeferCulledClientMobDeath(Mob mob) + { + if (mob == null) + return false; + if (IsHost(GameMenu.NetRef)) + return false; + + try { mob.life = 0; } catch { } + lock (Sync) + { + s_pendingCulledMobDeaths.Add(mob); + } + return true; + } + + /// + /// Called from Hook_Mob_postUpdate (client branch). A mob reaching postUpdate is being + /// simulated by vanilla, so its state is initialized and the deferred death is now safe. + /// Returns true when this mob's deferred death was executed this frame. + /// + private static bool TryRunPendingCulledMobDeath(Mob mob) + { + if (mob == null) + return false; + + lock (Sync) + { + if (s_pendingCulledMobDeaths.Count == 0 || !s_pendingCulledMobDeaths.Contains(mob)) + return false; + } + + // Reaching postUpdate is not proof of initialization if vanilla also ticks culled + // mobs; require the mob to actually be awake before running the vanilla death. + if (IsMobCulledLocally(mob)) + return false; + + lock (Sync) + { + s_pendingCulledMobDeaths.Remove(mob); + } + + try + { + if (mob.destroyed) + return true; + + RunWithSuppressedMobDieSend(() => + { + mob.life = 0; + mob.onDie(); + }); + + var animManager = GetMobAnimManager(mob); + if (animManager?.stack != null) + { + while (animManager.stack.length > 0) + animManager.stack.pop(); + } + } + catch + { + try + { + mob.isOutOfGame = true; + mob.isOnScreen = false; + } + catch + { + } + } + + return true; + } + private static void ApplyAuthoritativeLifeState(Mob mob, int targetLife, int targetMaxLife) { if (mob == null) @@ -168,7 +250,14 @@ private static void ApplyAuthoritativeLifeState(Mob mob, int targetLife, int tar clampedLife = 0; if (mob.life == clampedLife) + { + // Host may report life=0 after the local client already lost the HP bar but never ran + // the death/despawn branch. Force that branch once for non-boss mobs so rune elites and + // normal mobs do not stay as invisible/unkillable ghosts. + if (clampedLife <= 0 && !BossSyncHelpers.IsBossMob(mob)) + ForceNonBossAuthoritativeDeath(mob); return; + } var wasAlive = mob.life > 0; mob.life = clampedLife; @@ -178,6 +267,9 @@ private static void ApplyAuthoritativeLifeState(Mob mob, int targetLife, int tar if (BossSyncHelpers.IsBossMob(mob)) return; + if (TryDeferCulledClientMobDeath(mob)) + return; + try { if (!mob.destroyed) @@ -202,6 +294,46 @@ private static void ApplyAuthoritativeLifeState(Mob mob, int targetLife, int tar } } + + private static void ForceNonBossAuthoritativeDeath(Mob mob) + { + if (mob == null) + return; + + try + { + if (mob.destroyed) + return; + } + catch + { + } + + if (TryDeferCulledClientMobDeath(mob)) + return; + + try + { + TryWakeMobForForcedSimulation(mob); + RunWithSuppressedMobDieSend(() => + { + mob.life = 0; + mob.onDie(); + }); + } + catch + { + try + { + mob.isOutOfGame = true; + mob.isOnScreen = false; + } + catch + { + } + } + } + private static void ApplyClientAnimationStateBeforeUpdate(Mob self) { ClientMobState target; diff --git a/Mobs/MonsterSynchronization.ClientReceive.cs b/Mobs/MonsterSynchronization.ClientReceive.cs index 94c91de..850b13b 100644 --- a/Mobs/MonsterSynchronization.ClientReceive.cs +++ b/Mobs/MonsterSynchronization.ClientReceive.cs @@ -29,6 +29,8 @@ private static void ConsumeIncomingHostMobStates(NetNode net) try { MobSyncTrace.LogRecvStates("hostStatesFromHost", states); + if (IsSyncQuiescedForTransition()) + return; ApplyIncomingHostMobStates(states); } finally @@ -45,6 +47,8 @@ private static void ConsumeIncomingHostMobMoves(NetNode net) try { MobSyncTrace.LogRecvMoves("hostMovesFromHost", moves); + if (IsSyncQuiescedForTransition()) + return; ApplyIncomingHostMobMoves(moves); } finally @@ -61,6 +65,8 @@ private static void ConsumeIncomingClientMobStates(NetNode net) try { MobSyncTrace.LogRecvStates("clientAffectFromClient", states); + if (IsSyncQuiescedForTransition()) + return; ApplyIncomingClientMobStatesOnHost(states); } finally @@ -264,6 +270,12 @@ private static void ApplyAuthoritativeAffectState(int mobSyncId, Mob mob, string if (!TryDecodeStatePayloadFromWire(wirePayload, out var safePayload)) return; + // Affects run vanilla calls (setAffectS etc.) on the mob; never do that on a mob + // culled locally on a client (same .cx hazard class as culled deaths/attacks). + // Checked BEFORE the dedupe cache so the payload re-applies once the mob wakes. + if (!IsHost(GameMenu.NetRef) && IsMobCulledLocally(mob)) + return; + lock (Sync) { if (clientLastAppliedHostAffectPayloadBySyncId.TryGetValue(mobSyncId, out var lastApplied) && @@ -424,6 +436,8 @@ private static void ConsumeIncomingHostMobAttacks(NetNode net) try { MobSyncTrace.LogRecvAttacks("hostAttacksFromHost", attacks); + if (IsSyncQuiescedForTransition()) + return; ApplyIncomingHostMobAttacks(attacks); } finally @@ -467,6 +481,8 @@ private static void ConsumeIncomingMobDraws(NetNode net) try { MobSyncTrace.LogRecvDraws("clientDrawsFromClient", draws); + if (IsSyncQuiescedForTransition()) + return; ApplyIncomingMobDraws(draws); } finally @@ -539,6 +555,31 @@ private static void ProcessClientMobAttackIntent(Mob mob, ClientMobAttackIntent var skillId = intent.SkillId; var traceRoute = ResolveClientAttackRouteForTrace(skillId); _ = TryGetMobSyncId(mob, out var traceSyncId); + + // Never replay remote mob attacks on mobs that are culled locally: contact/skill + // replays run vanilla combat code on a never-initialized mob (same hazard class as + // the culled-death .cx fatal). A locally-culled mob is far from the local hero, so + // the replay is off-screen and its target is out of reach here anyway; position/life + // still sync via state snapshots. + if (!IsHost(GameMenu.NetRef) && IsMobCulledLocally(mob)) + { + MobSyncTrace.LogClientAttackRoute("skipped_culled_" + traceRoute, traceSyncId, skillId); + return; + } + + // Never replay teleport-class skills (e.g. Mage360 aggrTeleport). Their vanilla + // implementation defers post-arrival logic that reads the target's grid coords + // (.cx) frames after prepare() returns - outside any try/catch we can place - and + // on replayed copies the target reference is not guaranteed across the wind-up. + // Confirmed via trace: route=oldSkillPrepare skillId=@oldprep:aggrTeleport logged + // immediately before the Null access .cx fatal. The teleport's position change + // still arrives via move snapshots; only the local wind-up VFX is dropped. + if (skillId.IndexOf("teleport", StringComparison.OrdinalIgnoreCase) >= 0) + { + MobSyncTrace.LogClientAttackRoute("skipped_teleport_" + traceRoute, traceSyncId, skillId); + return; + } + MobSyncTrace.LogClientAttackRoute(traceRoute, traceSyncId, skillId); if (string.Equals(skillId, ContactAttackPacketSkillId, StringComparison.Ordinal)) @@ -980,7 +1021,7 @@ private static bool ShouldSkipClientOldSkillExecuteFromMarker(Mob mob, string in { clientQueuedOldSkillMarkers.Remove(mob); // Marker is only meaningful if the mob is still actively queued/charging this skill - // (e.g. client AI fired it and host event would be a duplicate). + // (e.g. client-side behavior fired it and the host event would be a duplicate). // If the skill is not queued/charging, the marker is stale from our own replay // and the incoming host event is a fresh attack — do not skip. if (IsQueuedOrChargingOldSkillId(mob, incomingSkillId)) @@ -1280,6 +1321,12 @@ private static void ConsumeIncomingMobHits(NetNode net) MobSyncTrace.LogRecvHits(net.IsHost ? "hitsOnHost" : "hitsOnClient", s_mobHitMergeScratch); + if (IsSyncQuiescedForTransition()) + { + s_mobHitMergeScratch.Clear(); + return; + } + ApplyIncomingMobHits(s_mobHitMergeScratch, 0, s_mobHitMergeScratch.Count, false); } @@ -1296,6 +1343,9 @@ private static void ConsumeIncomingMobDies(NetNode net) if (net.IsHost) return; + if (IsSyncQuiescedForTransition()) + return; + ApplyIncomingMobDies(dies); } finally @@ -1339,7 +1389,10 @@ private static void ApplyIncomingMobDies(IReadOnlyList dies) continue; } - if (!isBoss && life <= 0) + // Hardening: do not ignore dead-but-not-destroyed mobs. This was a common + // source of client-side ghost elites: life already reached 0, HP bar disappeared, + // but onDie/despawn never ran locally. Let the authoritative MOBDIE packet finish cleanup. + if (!isBoss && life <= 0 && mob.destroyed) continue; if (s_dieVictimDedupScratch.Add(mob)) @@ -1357,6 +1410,18 @@ private static void ApplyIncomingMobDies(IReadOnlyList dies) if (mob == null) continue; + // Non-boss (client): DEFER to the mob's own update cycle instead of re-running + // onDie synchronously. The hardening that processed dead-but-not-destroyed mobs + // here re-killed mobs that had already died cleanly (host MOBDIE arriving after a + // local kill), corrupting their death state - the source of the level-transition + // render fatal. The deferred flush skips mobs that finish destroying themselves + // and only completes genuinely stuck ghosts. + if (!IsHost(GameMenu.NetRef) && !BossSyncHelpers.IsBossMob(mob)) + { + TryDeferCulledClientMobDeath(mob); + continue; + } + TryWakeMobForForcedSimulation(mob); try { @@ -1449,6 +1514,8 @@ private static void ApplyIncomingMobHits(IReadOnlyList hits, int LogRejectedPacketGeneration(isHost ? "mobHitOnHost" : "mobHitOnClient", rejectedCount, rejectedGeneration); + FlushGhostDespawnEchoes(net, isHost); + for (int i = 0; i < s_pendingMobHitAppliesScratch.Count; i++) { var update = s_pendingMobHitAppliesScratch[i]; @@ -1634,11 +1701,39 @@ private static bool ShouldReplayIncomingHitWithoutLifeDelta(Mob mob) return string.Equals(runtimeClass, "Mushroom", StringComparison.OrdinalIgnoreCase); } + /// + /// True when this mob is culled on THIS machine (far from the local hero, vanilla is not + /// simulating it). Client-side, such mobs must not be pushed into vanilla simulation: + /// their behavior state was never proximity-initialized, and forcing them awake makes vanilla + /// update null-deref a few frames later (Null access .cx while fighting far from the + /// host). Returns false on any read failure so callers fall back to existing behavior. + /// + private static bool IsMobCulledLocally(Mob mob) + { + if (mob == null) + return false; + + try + { + return mob.isOutOfGame && !mob.isOnScreen; + } + catch + { + return false; + } + } + private static void TryReplayIncomingSpecialHitReaction(Mob mob) { if (mob == null) return; + // Cosmetic replay only: skip it on clients for mobs culled locally. Running vanilla + // hit resolution on a sleeping, never-initialized mob is hazardous, and the reaction + // is off-screen anyway. The authoritative life still arrives via state snapshots. + if (!IsHost(GameMenu.NetRef) && IsMobCulledLocally(mob)) + return; + try { RunWithSuppressedMobHitSend(() => @@ -1672,6 +1767,12 @@ private static void TryWakeMobForForcedSimulation(Mob mob) if (mob == null) return; + // The wake is required on the authoritative HOST (it must simulate mobs that remote + // players are fighting). On clients it only served cosmetic hit/death replays; waking + // a locally culled mob there runs vanilla behavior logic on uninitialized state and crashes. + if (!IsHost(GameMenu.NetRef) && IsMobCulledLocally(mob)) + return; + PromoteMobToSyncVisibleState(mob); } @@ -1723,9 +1824,99 @@ private static bool ShouldSendHostContactPacket(Mob mob, Entity? target) registryMob != null ? BuildMobStateTypeSignature(registryMob) : string.Empty, registryMob == null ? "missing_sync_id" : "type_mismatch"); + // Only the missing_sync_id case feeds the ghost despawn echo: the host tracks NO mob + // at this syncId in the current generation, so the client's same-generation mob at + // this syncId can only be a stale ghost. (type_mismatch means the host DOES have a + // mob there — echoing a death then could kill a legitimate mob.) + if (registryMob == null) + RecordGhostHitMissLocked(hit); + return null; } + /// + /// Called under Sync. Counts missing_sync_id hits per syncId; once the same syncId + /// has missed at least times spanning at least + /// , queues a life=0 state echo (rate-limited per + /// syncId). Late hits on freshly killed mobs produce only 1-2 misses and never trigger. + /// + private static void RecordGhostHitMissLocked(NetNode.MobHit hit) + { + if (s_ghostHitMissGeneration != hit.Generation) + { + s_ghostHitMissBySyncId.Clear(); + s_ghostHitMissGeneration = hit.Generation; + } + + var now = System.Diagnostics.Stopwatch.GetTimestamp(); + if (!s_ghostHitMissBySyncId.TryGetValue(hit.MobIndex, out var record)) + { + record = new GhostHitMissRecord { FirstMissTicks = now }; + s_ghostHitMissBySyncId[hit.MobIndex] = record; + } + + record.Count++; + if (record.Count < GhostHitMissMinCount) + return; + if (now - record.FirstMissTicks < (long)(System.Diagnostics.Stopwatch.Frequency * GhostHitMissMinSeconds)) + return; + if (record.LastEchoTicks != 0 && + now - record.LastEchoTicks < (long)(System.Diagnostics.Stopwatch.Frequency * GhostHitEchoMinIntervalSeconds)) + return; + + record.LastEchoTicks = now; + s_ghostDespawnEchoScratch.Add(new NetNode.MobStateSnapshot( + hit.MobIndex, + hit.X, + hit.Y, + 0, + 0, + 0, + string.Empty, + hit.Type ?? string.Empty, + string.Empty, + hit.Generation)); + } + + /// + /// Sends queued ghost despawn echoes. Host only; discards silently on client role. + /// + private static void FlushGhostDespawnEchoes(NetNode? net, bool isHost) + { + List? toSend = null; + lock (Sync) + { + if (s_ghostDespawnEchoScratch.Count == 0) + return; + + if (!isHost || net == null || !net.IsAlive) + { + s_ghostDespawnEchoScratch.Clear(); + return; + } + + toSend = new List(s_ghostDespawnEchoScratch); + s_ghostDespawnEchoScratch.Clear(); + } + + for (int i = 0; i < toSend.Count; i++) + { + Log.Warning( + "[MobSync] ghost mob despawn echo syncId={SyncId} type={Type}", + toSend[i].Index, + toSend[i].Type); + } + + try + { + net!.SendMobStates(toSend); + } + catch (Exception ex) + { + Log.Warning(ex, "[MobsSync] Ghost despawn echo send failed"); + } + } + private static bool MobHitRegistryTypeMatchesLocked(Mob? registryMob, NetNode.MobHit hit) { if (registryMob == null) @@ -1767,8 +1958,26 @@ private static bool MobHitRegistryStillTrustworthyLocked(Mob mob, NetNode.MobHit if (mob == null) return false; - return MobHitQuantizedPositionCloseEnoughLocked(mob, hit) || - MobHitQuantizedFallbackPositionMatchesLocked(mob, hit); + if (MobHitQuantizedPositionCloseEnoughLocked(mob, hit) || + MobHitQuantizedFallbackPositionMatchesLocked(mob, hit)) + return true; + + // Old code required exact quantized coordinates for client damage reports. That caused + // valid hits to be rejected as position_mismatch once the same mob drifted even slightly + // between host/client. If the sync id and type already match, accept the hit within a + // generous same-room distance and let the normal host mob-state packets correct the drift. + try + { + var dx = GetWorldX(mob) - hit.X; + var dy = GetWorldY(mob) - hit.Y; + if (double.IsFinite(dx) && double.IsFinite(dy)) + return (dx * dx + dy * dy) <= (MobHitTrustedSyncIdDistancePx * MobHitTrustedSyncIdDistancePx); + } + catch + { + } + + return false; } private static Mob? ResolveMobFromDieLocked(NetNode.MobDie die) diff --git a/Mobs/MonsterSynchronization.Constants.cs b/Mobs/MonsterSynchronization.Constants.cs index aa9eb6a..ab78241 100644 --- a/Mobs/MonsterSynchronization.Constants.cs +++ b/Mobs/MonsterSynchronization.Constants.cs @@ -12,6 +12,8 @@ public partial class MobsSynchronization private const double HostMobStateMidPositionEpsilon = 1.20; private const double HostMobStateDormantPositionEpsilon = 6.00; private const double MobFallbackMinimumScoreGap = 4.0; + /// Client/host mobs can drift while fighting. Direct sync-id hits are allowed within this many pixels instead of requiring exact coordinates. + private const double MobHitTrustedSyncIdDistancePx = 24.0 * 32.0; private const double ClientAiAuthorityLockDurationSeconds = 99999.0; private const double AuthoritativeAffectPresenceSeconds = 99999.0; private const double PixelsPerCase = 24.0; diff --git a/Mobs/MonsterSynchronization.DirtyQueue.cs b/Mobs/MonsterSynchronization.DirtyQueue.cs index fa6fa6d..7f55ab4 100644 --- a/Mobs/MonsterSynchronization.DirtyQueue.cs +++ b/Mobs/MonsterSynchronization.DirtyQueue.cs @@ -329,6 +329,9 @@ private static bool TryDequeuePendingClientDirtyMob(out Mob? mob, out int syncId private static void FlushHostDirtyMobQueue(NetNode net) { + if (IsSyncQuiescedForTransition()) + return; + if (!IsHost(net)) return; @@ -500,6 +503,9 @@ private static bool TryBuildHostDirtySnapshotForQueue( private static void FlushClientDirtyMobQueue(NetNode net) { + if (IsSyncQuiescedForTransition()) + return; + if (!IsClient(net)) return; diff --git a/Mobs/MonsterSynchronization.cs b/Mobs/MonsterSynchronization.cs index 3a94190..796d411 100644 --- a/Mobs/MonsterSynchronization.cs +++ b/Mobs/MonsterSynchronization.cs @@ -72,9 +72,70 @@ public partial class MobsSynchronization : private static readonly List s_dieVictimsScratch = new(); private static readonly HashSet s_dieVictimDedupScratch = new(ReferenceEqualityComparer.Instance); private static readonly HashSet s_usedTrackedMobsScratch = new(ReferenceEqualityComparer.Instance); + + // Client-side deaths deferred because the mob is culled locally (far from the local hero, + // vanilla never proximity-initialized it). Running vanilla onDie() on such a mob leaves a + // half-started death sequence that vanilla update null-derefs a frame later (the + // "Null access .cx" fatal when the other player kills a mob far away). The real death runs + // in Hook_Mob_postUpdate once vanilla itself starts simulating the mob. Guarded by Sync. + private static readonly HashSet s_pendingCulledMobDeaths = new(ReferenceEqualityComparer.Instance); + + // Full mob-sync quiescence during level transitions: from door activation until the new + // level's registry commit, NOTHING is applied to mobs and nothing is sent. The v0.8.25 + // crash log showed 3 seconds of move/state application to the old level's 75 mobs between + // the door activation and the mid-load render fatal - mutating mobs the transition is + // dismantling, behind a fading screen where none of it is visible anyway. + private static long s_syncQuiescedUntilTicks; + + internal static void QuiesceForLevelTransition() + { + s_syncQuiescedUntilTicks = System.Diagnostics.Stopwatch.GetTimestamp() + + (long)(System.Diagnostics.Stopwatch.Frequency * 8.0); + Log.Information("[MobSync] quiesced for level transition"); + } + + private static bool IsSyncQuiescedForTransition() + { + if (s_syncQuiescedUntilTicks == 0) + return false; + if (System.Diagnostics.Stopwatch.GetTimestamp() >= s_syncQuiescedUntilTicks) + { + s_syncQuiescedUntilTicks = 0; + Log.Information("[MobSync] quiesce window expired (timeout)"); + return false; + } + return true; + } + + private static void ClearSyncQuiesceAfterRebuild() + { + if (s_syncQuiescedUntilTicks == 0) + return; + s_syncQuiescedUntilTicks = 0; + Log.Information("[MobSync] resumed after rebuild commit"); + } private static int suppressMobDieSendDepth; private static int suppressMobHitSendDepth; + // Ghost-mob despawn echo (host only). Tracks repeated missing_sync_id hits per syncId — + // a client persistently hitting a mob the host no longer tracks means the client has an + // unkillable ghost. After enough misses over enough time the host echoes an authoritative + // life=0 state for that syncId so the client's existing forced-death path cleans it up. + // All access guarded by Sync. + private sealed class GhostHitMissRecord + { + public int Count; + public long FirstMissTicks; + public long LastEchoTicks; + } + + private static readonly Dictionary s_ghostHitMissBySyncId = new(); + private static readonly List s_ghostDespawnEchoScratch = new(); + private static int s_ghostHitMissGeneration; + private const int GhostHitMissMinCount = 3; + private const double GhostHitMissMinSeconds = 2.0; + private const double GhostHitEchoMinIntervalSeconds = 2.0; + private static Level? currentLevel; private static bool s_levelIdentityReady; private static int s_levelIdentityGeneration; @@ -774,20 +835,11 @@ private static void Hook_Level_registerEntity(Hook_Level.orig_registerEntity ori { orig(self, clid); - // Prevent Null access .groupName crashes in vanilla applyAttackResult (e.g. dive attacks - // hitting entities whose sprite group hasn't been assigned). Native entity init should - // always set groupName, but edge cases (spawn, level transition, remote ghosts) can leave - // it null. A fallback groupName avoids the HashlinkError without hunting every code path. - if (clid is dc.Entity ety) - { - try - { - var spr = ety.spr; - if (spr != null && spr.groupName == null) - spr.groupName = string.Empty.AsHaxeString(); - } - catch { } - } + // Do not write an empty groupName into every vanilla entity. The current game build + // can legitimately register sprites before their animation group is assigned. Only + // validate the multiplayer-created KingSkin, and repair it through the animation API. + if (clid is DeadCellsMultiplayerMod.Ghost.GhostBase.GhostKing registeredKing) + ModEntry.EnsureGhostKingRenderSafe(registeredKing, "Level.registerEntity", detachForTransition: false); if (clid is not Mob mob) return; @@ -932,6 +984,9 @@ private void Hook_Mob_postUpdate(Hook_Mob.orig_postUpdate orig, Mob self) orig(self); if (IsClient(net) && IsSyncMob(self)) { + if (TryRunPendingCulledMobDeath(self)) + return; + ObserveClientMobForDirtyQueue(self); ApplyClientAnimationStateBeforeUpdate(self); TryRepairClientMobAttackTarget(self); diff --git a/ModEntry/ModEntry.DebugHero.cs b/ModEntry/ModEntry.DebugHero.cs index 82588b4..ee75d96 100644 --- a/ModEntry/ModEntry.DebugHero.cs +++ b/ModEntry/ModEntry.DebugHero.cs @@ -44,12 +44,19 @@ private void ApplyDebugHeroRuntimeOptions() hero.noDamageDuringBossBattle = false; } - TryApplyDebugStartPerk(hero); - TryApplyDebugExplorerRune(hero); + // Stability hardening: never inject perks/items/runes during HeroInit. + // The original debug path constructed tool.InventItem before item commonProps were ready and + // caused the Hashlink crash: Null access .commonProps. + // Real rune/progression sync is handled by CoopAdvancedHardening after the run is alive. + // TryApplyDebugStartPerk(hero); + // TryApplyDebugExplorerRune(hero); } private void TryApplyDebugStartPerk(Hero hero) { + // Disabled intentionally. Do not construct InventItem from debug code. + return; +#pragma warning disable CS0162 if (hero == null) return; @@ -83,6 +90,7 @@ private void TryApplyDebugStartPerk(Hero hero) _debugPerkAppliedHero = hero; _debugPerkAppliedId = perkId; _nextDebugPerkApplyTick = 0; +#pragma warning restore CS0162 } private void TryApplyDebugExplorerRune(Hero hero) diff --git a/ModEntry/ModEntry.GhostSync.cs b/ModEntry/ModEntry.GhostSync.cs index 7b2590f..2502bda 100644 --- a/ModEntry/ModEntry.GhostSync.cs +++ b/ModEntry/ModEntry.GhostSync.cs @@ -379,7 +379,7 @@ internal static void SetClientSkin(int remoteId, string? skin) clientSkins[index] = cleaned; var client = clients[index]; - if (client != null) + if (client != null && !IsRemoteKingTransitionActive) { if (!string.Equals(prev, cleaned, StringComparison.Ordinal) || client.spr == null) client.ApplyRemoteSkin(cleaned); @@ -405,7 +405,8 @@ internal static void SetClientHeadSkin(int remoteId, string? skin) if (client != null) client.RemoteHeadSkinId = cleaned; - if (!string.Equals(prev, cleaned, StringComparison.Ordinal) || client?.head == null) + if (!IsRemoteKingTransitionActive && + (!string.Equals(prev, cleaned, StringComparison.Ordinal) || client?.head == null)) instance.ScheduleGhostHeadRecreate(index, immediate: true); } @@ -416,6 +417,9 @@ private static string NormalizeSkin(string? skin, string defaultSkin) private void RecreateClientHead(int slot) { + if (IsRemoteKingTransitionActive) + return; + var hitchStart = RuntimeHitchWatch.Start(); if (slot < 0 || slot >= clients.Length) return; @@ -749,6 +753,10 @@ private void CancelPendingClientDispose(int slot) private GhostKing? EnsureClientKingSlot(int slot) { + var existingDuringTransition = slot >= 0 && slot < clients.Length ? clients[slot] : null; + if (IsRemoteKingTransitionActive) + return existingDuringTransition; + var hitchStart = RuntimeHitchWatch.Start(); if (slot < 0 || slot >= clients.Length) return null; @@ -899,6 +907,13 @@ private void ReceiveGhostAttacks() var net = _net; if (net == null || me == null) return; + if (IsRemoteKingTransitionActive || IsLocalDiveNetGuardActive()) + { + if (net.TryConsumeRemoteAttacks(out var guardedAttacks)) + NetNode.ReleaseConsumedList(guardedAttacks); + return; + } + if (!net.TryConsumeRemoteAttacks(out var attacks)) return; diff --git a/ModEntry/ModEntry.KingSkinRenderSafety.cs b/ModEntry/ModEntry.KingSkinRenderSafety.cs new file mode 100644 index 0000000..6daaf9f --- /dev/null +++ b/ModEntry/ModEntry.KingSkinRenderSafety.cs @@ -0,0 +1,382 @@ +using System; +using System.Diagnostics; +using dc; +using dc.en; +using dc.hl.types; +using dc.libs.heaps.slib; +using dc.pr; +using DeadCellsMultiplayerMod.Ghost.GhostBase; +using Hashlink.Virtuals; +using HaxeProxy.Runtime; +using ModCore.Utilities; + +namespace DeadCellsMultiplayerMod +{ + public partial class ModEntry + { + private static bool s_remoteKingRenderDetachedForTransition; + private static bool s_subLevelRenderGuardArmed; + private static long s_subLevelRenderGuardStartedTicks; + private static string s_subLevelRenderGuardReason = string.Empty; + private static long s_lastKingSkinGuardLogTicks; + private const double KingSkinGuardLogIntervalSeconds = 1.0; + private const double SubLevelRenderGuardTimeoutSeconds = 8.0; + + internal static bool IsRemoteKingTransitionActive => s_remoteKingRenderDetachedForTransition; + + internal static void CheckRemoteKingRenderSafety(string reason) + { + if (s_remoteKingRenderDetachedForTransition) + return; + + GuardRemoteKingSprites(reason, detachForTransition: false); + } + + internal static void PrepareRemoteKingsForLevelTransition(string reason) + { + if (s_remoteKingRenderDetachedForTransition) + return; + + s_remoteKingRenderDetachedForTransition = true; + GuardRemoteKingSprites(reason, detachForTransition: true); + } + + internal static void FinishRemoteKingLevelTransition() + { + s_remoteKingRenderDetachedForTransition = false; + } + + internal static void PrepareRemoteKingsForSubLevelTransition(string reason) + { + s_subLevelRenderGuardArmed = true; + s_subLevelRenderGuardStartedTicks = Stopwatch.GetTimestamp(); + s_subLevelRenderGuardReason = string.IsNullOrWhiteSpace(reason) + ? "sublevel-transition" + : reason; + + var instance = Instance; + instance?.DrainRemoteCombatQueuesAfterLevelChange(); + instance?.MarkDiveNetGuardAfterSpawnOrRoomChange(); + PrepareRemoteKingsForLevelTransition(s_subLevelRenderGuardReason); + instance?.Logger.Information( + "[NetMod][SubLevelGuard] armed reason={Reason}", + s_subLevelRenderGuardReason); + } + + internal static void CancelRemoteKingSubLevelTransition(string reason) + { + var instance = Instance; + s_subLevelRenderGuardArmed = false; + s_subLevelRenderGuardStartedTicks = 0; + s_subLevelRenderGuardReason = string.Empty; + FinishRemoteKingLevelTransition(); + instance?.Logger.Warning( + "[NetMod][SubLevelGuard] cancelled reason={Reason}", + reason); + } + + private void Hook_Level_onActivation_SubLevelRenderGuard( + Hook_Level.orig_onActivation orig, + Level self) + { + var wasArmed = s_subLevelRenderGuardArmed; + if (!wasArmed) + { + orig(self); + return; + } + + var targetLevelId = ""; + try + { + targetLevelId = self?.map?.id?.ToString() ?? ""; + } + catch + { + } + + Logger.Information( + "[NetMod][SubLevelGuard] activating target={Target} reason={Reason}", + targetLevelId, + s_subLevelRenderGuardReason); + + // The remote KingSkin must stay detached for the entire native + // Level.resume -> Level.onActivation -> Level.initRender sequence. + // Calling the original first is intentional: LevelDisp.render invokes + // Boot.tryRender inside this call. Re-attaching before it returns brings + // back the null groupName crash on timed/no-hit reward rooms. + orig(self); + + CompleteRemoteKingSubLevelTransitionGuard( + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"level-onActivation:{targetLevelId}")); + } + + private void TickRemoteKingSubLevelTransitionGuard() + { + if (!s_subLevelRenderGuardArmed || s_subLevelRenderGuardStartedTicks == 0) + return; + + if (Stopwatch.GetElapsedTime(s_subLevelRenderGuardStartedTicks).TotalSeconds < + SubLevelRenderGuardTimeoutSeconds) + { + return; + } + + CompleteRemoteKingSubLevelTransitionGuard("timeout"); + } + + private void CompleteRemoteKingSubLevelTransitionGuard(string completionReason) + { + if (!s_subLevelRenderGuardArmed) + return; + + var armedReason = s_subLevelRenderGuardReason; + s_subLevelRenderGuardArmed = false; + s_subLevelRenderGuardStartedTicks = 0; + s_subLevelRenderGuardReason = string.Empty; + + // The detached HSprite nodes cannot safely be re-parented because the + // active level display changed. Dispose the old ghost shell only after + // native rendering completed; the next remote snapshot recreates it in + // the currently active parent/sublevel. + for (var slot = 0; slot < clients.Length; slot++) + DisposeClientSlot(slot, clearIdentity: false); + + FinishRemoteKingLevelTransition(); + DrainRemoteCombatQueuesAfterLevelChange(); + MarkDiveNetGuardAfterSpawnOrRoomChange(); + SendCurrentRoomTarget(force: true); + GameMenu.EnqueueMainThreadCoalesced("ghost:receive-coords", ReceiveGhostCoords); + + Logger.Information( + "[NetMod][SubLevelGuard] completed reason={CompletionReason} armedBy={ArmedReason}", + completionReason, + armedReason); + } + + internal static bool EnsureGhostKingRenderSafe(GhostKing? king, string reason, bool detachForTransition) + { + return GuardSingleKingSprite(king, -1, reason, detachForTransition); + } + + private static void GuardRemoteKingSprites(string reason, bool detachForTransition) + { + for (var slot = 0; slot < clients.Length; slot++) + { + var king = clients[slot]; + if (king == null) + continue; + + GuardSingleKingSprite(king, slot, reason, detachForTransition); + } + } + + private static bool GuardSingleKingSprite(GhostKing? king, int slot, string reason, bool detachForTransition) + { + if (king == null) + return true; + + var bodyOk = EnsureSpriteAnimationGroup(king.spr, "idle", out var bodyBefore, out var bodyAfter); + var invalidCloneCount = 0; + var repairedCloneCount = 0; + + var clones = king.spriteClones; + if (clones != null) + { + for (var i = 0; i < clones.length; i++) + { + virtual_e_followHead_notActualClone_offX_offY_scaleBonus_? cloneInfo = null; + try { cloneInfo = clones.array[i] as virtual_e_followHead_notActualClone_offX_offY_scaleBonus_; } catch { } + var clone = cloneInfo?.e; + if (clone == null) + continue; + + if (!HasValidAnimationGroup(clone)) + invalidCloneCount++; + + if (EnsureSpriteAnimationGroup(clone, "idle", out _, out _)) + repairedCloneCount++; + else + HideAndDetachSprite(clone, detach: true); + } + } + + var head = king.head; + var headFrontOk = EnsureSpriteAnimationGroup(head?.customHeadSpr, "idle", out _, out _); + var headBackOk = EnsureSpriteAnimationGroup(head?.customBackSpr, "idle", out _, out _); + + if (!bodyOk) + HideAndDetachSprite(king.spr, detach: true); + + if (detachForTransition) + { + try { king.visible = false; } catch { } + HideAndDetachSprite(king.spr, detach: true); + HideAndDetachSprite(head?.customHeadSpr, detach: true); + HideAndDetachSprite(head?.customBackSpr, detach: true); + + if (clones != null) + { + for (var i = 0; i < clones.length; i++) + { + virtual_e_followHead_notActualClone_offX_offY_scaleBonus_? cloneInfo = null; + try { cloneInfo = clones.array[i] as virtual_e_followHead_notActualClone_offX_offY_scaleBonus_; } catch { } + HideAndDetachSprite(cloneInfo?.e, detach: true); + } + } + } + + if (!bodyOk || !headFrontOk || !headBackOk || invalidCloneCount > 0 || detachForTransition) + { + LogKingSkinGuard( + slot, + reason, + detachForTransition, + bodyOk, + bodyBefore, + bodyAfter, + invalidCloneCount, + repairedCloneCount, + headFrontOk, + headBackOk); + } + + return bodyOk && headFrontOk && headBackOk && invalidCloneCount == 0; + } + + private static bool EnsureSpriteAnimationGroup(HSprite? sprite, string fallbackGroup, out string before, out string after) + { + before = ReadGroupName(sprite); + after = before; + if (sprite == null) + return true; + + if (HasValidAnimationGroup(sprite)) + return true; + + try + { + var anim = sprite._animManager; + if (anim != null) + anim.play(fallbackGroup.AsHaxeString(), null, null).loop(null); + } + catch + { + } + + after = ReadGroupName(sprite); + if (HasValidAnimationGroup(sprite)) + return true; + + try + { + var lib = sprite.lib; + if (lib != null) + { + var startFrame = 0; + var stopAllAnimations = true; + sprite.set( + lib, + fallbackGroup.AsHaxeString(), + Ref.From(ref startFrame), + Ref.From(ref stopAllAnimations)); + } + } + catch + { + } + + after = ReadGroupName(sprite); + return HasValidAnimationGroup(sprite); + } + + private static bool HasValidAnimationGroup(HSprite? sprite) + { + if (sprite == null) + return true; + + try + { + var group = sprite.groupName; + return group != null && !string.IsNullOrWhiteSpace(group.ToString()); + } + catch + { + return false; + } + } + + private static string ReadGroupName(HSprite? sprite) + { + if (sprite == null) + return ""; + + try + { + return sprite.groupName?.ToString() ?? ""; + } + catch + { + return ""; + } + } + + private static void HideAndDetachSprite(HSprite? sprite, bool detach) + { + if (sprite == null) + return; + + try { sprite.set_visible(false); } catch { } + if (!detach) + return; + + try + { + var parent = sprite.parent; + if (parent != null) + parent.removeChild(sprite); + } + catch + { + } + } + + private static void LogKingSkinGuard( + int slot, + string reason, + bool detached, + bool bodyOk, + string bodyBefore, + string bodyAfter, + int invalidCloneCount, + int repairedCloneCount, + bool headFrontOk, + bool headBackOk) + { + var instance = Instance; + if (instance == null) + return; + + var now = Stopwatch.GetTimestamp(); + if (!detached && s_lastKingSkinGuardLogTicks != 0 && + Stopwatch.GetElapsedTime(s_lastKingSkinGuardLogTicks, now).TotalSeconds < KingSkinGuardLogIntervalSeconds) + return; + + s_lastKingSkinGuardLogTicks = now; + instance.Logger.Warning( + "[NetMod][KingSkinGuard] reason={Reason} slot={Slot} detached={Detached} bodyOk={BodyOk} bodyGroupBefore={Before} bodyGroupAfter={After} invalidClones={InvalidClones} repairedClones={RepairedClones} headFrontOk={HeadFrontOk} headBackOk={HeadBackOk}", + reason, + slot, + detached, + bodyOk, + bodyBefore, + bodyAfter, + invalidCloneCount, + repairedCloneCount, + headFrontOk, + headBackOk); + } + } +} diff --git a/ModEntry/ModEntry.cs b/ModEntry/ModEntry.cs index 7d3e1ab..f5ceadc 100644 --- a/ModEntry/ModEntry.cs +++ b/ModEntry/ModEntry.cs @@ -40,6 +40,7 @@ using dc.en.inter.door; using DeadCellsMultiplayerMod.Interaction; using DeadCellsMultiplayerMod.UI; +using DeadCellsMultiplayerMod.AdvancedCoop; namespace DeadCellsMultiplayerMod @@ -378,6 +379,8 @@ public override void Initialize() "ConnectionUI", () => ConnectionUI.Initialize(this)); + _ = new CoopAdvancedHardening(this); + GameMenu.Initialize(Logger); s_steamOverlayCallbackPending = true; s_steamOverlayCallbackRetryCount = 0; @@ -404,9 +407,11 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) s_hooksInstalled = true; entry.Logger.Information("\x1b[32m[[ModEntry] Initializing ModEntry...]\x1b[0m "); + entry.Logger.Information("[NetMod] Source build: v0.8.38-sublevel-dive-combat-guard"); Hook_Game.init += Hook_gameinit; Hook_Hero.wakeup += hook_hero_wakeup; Hook_Hero.onLevelChanged += hook_level_changed; + Hook_Level.onActivation += Hook_Level_onActivation_SubLevelRenderGuard; Hook_User.newGame += GameDataSync.user_hook_new_game; Hook_User.unserialize += Hook_User_unserialize; Hook_Game.onDispose += Hook_Game_onDispose; @@ -501,13 +506,36 @@ private void Hook_HeroHead_initCustomHead(Hook_HeroHead.orig_initCustomHead orig private void Hook_ZDoor_onActivate(Hook_ZDoor.orig_onActivate orig, ZDoor self, Hero lp, bool mob) { - orig(self, lp, mob); - - if (_netRole != NetRole.None && + var localMultiplayerActivation = + _netRole != NetRole.None && _net != null && + _net.IsAlive && me != null && lp != null && - ReferenceEquals(lp, me)) + ReferenceEquals(lp, me); + + if (localMultiplayerActivation) + { + var doorKey = self == null + ? "unknown" + : string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"{self.cx}:{self.cy}"); + PrepareRemoteKingsForSubLevelTransition($"zdoor-activate:{doorKey}"); + } + + try + { + orig(self, lp, mob); + } + catch + { + if (localMultiplayerActivation) + CancelRemoteKingSubLevelTransition("zdoor-orig-threw"); + throw; + } + + if (localMultiplayerActivation) { SendCurrentRoomTarget(force: true); GameMenu.EnqueueMainThreadCoalesced("ghost:receive-coords", ReceiveGhostCoords); @@ -721,6 +749,7 @@ private void hook_boot_update(Hook_Boot.orig_update orig, Boot self, double dt) GameMenu.HandleTextInputClipboardShortcuts(); _ghost?.UpdateLabels(); ProcessCameraSpectateInput(); + TickRemoteKingSubLevelTransitionGuard(); } @@ -861,6 +890,7 @@ public void hook_level_changed(Hook_Hero.orig_onLevelChanged orig, Hero self, Le SendCurrentRoomTarget(force: true); _net?.ClearMobSyncQueues(); EnsureHeroVisibilityAfterRoomChange(me); + FinishRemoteKingLevelTransition(); if (_netRole == NetRole.None) return; var net = _net; var localId = net?.id ?? 0; @@ -915,6 +945,7 @@ public void OnFrameUpdate(double dt) var hitchStart = RuntimeHitchWatch.Start(); PumpSteamCallbacksForOverlay(); GameMenu.ProcessMainThreadQueue(); + CheckRemoteKingRenderSafety("frame"); GameMenu.TickMenu(dt); DetectAndSendBossCine(); ApplyReceivedBossHeroTeleport(); diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..75686b2 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,12 @@ +# Notice + +DeadCellsCoopPlus is an independent, community-maintained fork of the original **Dead Cells Multiplayer Mod**. + +Original project and author: + +- Vaiser / `vaiserYT` +- https://github.com/vaiserYT/DeadCellsMultiplayerMod + +This fork focuses on multiplayer stability, synchronization improvements, transition crash fixes, Steam connectivity, revive support, and quality-of-life improvements while preserving full credit to the original project. + +The project continues under the original MIT License. See `LICENSE`. diff --git a/README.md b/README.md index e041686..3030e71 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,131 @@ -
+# DeadCellsCoopPlus — Stable Co-op v0.8.38 -English • [Русский](README_ru.md) - -
-

Dead Cells Multiplayer Mod

+DeadCellsCoopPlus is a community-maintained fork of Vaiser’s original **Dead Cells Multiplayer Mod**, built with the **Dead Cells Core Modding API (DCCM)**. -**DeadCellsMultiplayerMod** is a **multiplayer / co-op mod for Dead Cells**, built using the **Dead Cells Core Modding API (DCCM)**. +This release keeps the stable v0.8.36 co-op fixes and gives the main-menu **Play Multiplayer** entry a soft blue highlight. -The mod adds **co-op / multiplayer gameplay** via a **local or virtual network**: -one player hosts a server, another connects — and both players can **play through levels together in real time**. +See `CHANGELOG.md` for the complete version history and `SUBLEVEL_REWARD_DOOR_NOTES_v0.8.36.md` for the latest transition fix. --- -## 🎮 Features +
-- ✅ Real-time synchronization between two players -- ✅ Local TCP or Steam P2P multiplayer -- ✅ Host / Client architecture -- ✅ Automatic game start for connected clients -- ✅ Camera spectate — cycle between players with `,` / `.` keys or gamepad -- ✅ Boss HP scaling and boss rune sync -- ✅ Client mob attack synchronization and interruption -- ✅ Ghost weapon, head, and cosmetic sync -- ✅ Death/revive handling and restart sync -- ✅ Level graph reload sync (boss cell doors, level transitions) -- ✅ Multiplayer save slots +English • [Русский](README_ru.md) ---- +
-## ⭐ Support the Project +## Latest release highlights + +- Fixed normal biome transition crashes caused by invalid remote-player render state. +- Fixed timed and no-hit reward-room crashes through `ZDoor` sublevel transitions. +- Uses the current vanilla `Level.init` path for better compatibility with newer Dead Cells builds. +- Includes hardened host-authoritative enemy synchronization and cleanup. +- Includes the newer Party HUD with player name, segmented health, and percentage display. +- Supports Steam P2P and direct TCP hosting/joining. + +## Features + +- Real-time synchronization between two players +- Local TCP or Steam P2P multiplayer +- Host/client architecture +- Automatic game start for connected clients +- Camera spectate controls with keyboard or gamepad +- Boss HP scaling and boss-rune synchronization +- Enemy movement, damage, attack, death, and despawn synchronization +- Remote weapon, head, skin, and cosmetic synchronization +- Death, downed-state, revive, and restart handling +- Level generation and transition synchronization +- Timed/no-hit reward-room transition protection +- Multiplayer save slots +- Party HUD for the remote player + +## Requirements -If you find this project interesting: -- ⭐ Star the repository -- 🍴 Fork the project and experiment +- **Dead Cells (PC)** +- **Dead Cells Core Modding API (DCCM)** +- Steam, a local network, or a compatible virtual LAN for online TCP play -Every bit of feedback helps improve multiplayer support for **Dead Cells**. +## Installation ---- +### 1. Install DCCM -## 🧰 Requirements +For the Steam version of Dead Cells, follow the official DCCM installation guide: -- **Dead Cells (PC)** -- **Dead Cells Core Modding API (DCCM)** -- Local network, Steam, or virtual LAN software (for online play) +https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/ ---- +### 2. Install the mod -## 📦 Installation +Copy the built mod folder into: -### 1️⃣ Install Dead Cells Core Modding API (DCCM) +```text +Dead Cells/ +└── coremod/ + └── mods/ + └── DeadCellsMultiplayerMod/ +``` -If you are using the **Steam version** of the game, follow the official installation guide: +The installed folder should contain the compiled DLL, `modinfo.json`, and any required resource files produced by the project build. -👉 [https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/](https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/) +### 3. Start through DCCM -This method will automatically install and keep DCCM up to date. +Launch Dead Cells through DCCM. Configuration files are generated automatically on first launch. -### 2️⃣ Install DeadCellsMultiplayerMod +## How to play -If you are using the **Steam version** of the game: -1. Open [https://steamcommunity.com/sharedfiles/filedetails/?id=3657857836](https://steamcommunity.com/sharedfiles/filedetails/?id=3657857836) -2. Install the mod in one click. +1. Start Dead Cells through DCCM on both computers. +2. Open **Play Multiplayer**. +3. The host chooses a Steam lobby or direct TCP host. +4. The second player joins through Steam, lobby code, or the host address. +5. Start a run after both players are connected. -If you are using a **non-Steam version** of Dead Cells (DCCM required): -1. Navigate to your **DCCM directory** -2. Create a folder named `mods` (if it doesn't exist) -3. Extract the **DeadCellsMultiplayerMod** folder into the `mods` directory +Both players should use the same mod build. For v0.8.38, the game log should contain: -Example: -``` -Your game path/ - └──coremod/ - └── mods/ - └── DeadCellsMultiplayerMod/ +```text +[NetMod] Source build: v0.8.38-sublevel-dive-combat-guard ``` -### 3️⃣ Run the game via DCCM +## v0.8.38 regression test checklist -Start **Dead Cells** using **DCCM**. -On the first launch, required configuration files will be generated automatically. +- Prisoners’ Quarters to the passage area +- Passage forge and mutation area +- Timed reward door +- No-hit reward door +- Reward-room exit +- Passage to the next main biome ---- - -## 🕹️ How to Play (Multiplayer) - -1. Launch the game via **DCCM** -2. Click **Play Multiplayer** -3. Choose **Host** or **Join** -4. Enter **IP address** and **port** (TCP) or connect via Steam -5. When the host starts the game, the client will automatically join the session +## Development status -🌐 **For online play**, use one of the following: -- Hamachi -- Radmin VPN -- ZeroTier -- Steam P2P (built-in) +- [x] Second-player remote character +- [x] World and level generation synchronization +- [x] Enemy and boss synchronization +- [x] Boss HP scaling and boss-rune synchronization +- [x] Death, revive, and restart synchronization +- [x] Weapon, head, skin, and cosmetic synchronization +- [x] Main biome transition crash protection +- [x] Timed/no-hit reward-room transition protection +- [x] Multiplayer saves and continue support +- [x] Camera spectate mode +- [x] Steam P2P connectivity +- [ ] Broader custom-mode support ---- +## Reporting bugs -## 🧪 Development Status / TODO - -- [x] Second player ghost -- [x] World data synchronization -- [x] Ghost animations -- [x] Level generation sync -- [x] Enemy synchronization -- [x] Boss synchronization, HP scaling, boss rune sync -- [x] Death handling and restart sync -- [x] Player ghost weapon, head, and cosmetic sync -- [x] Level graph reload (boss cells, transitions) -- [x] Multiplayer save slots and continue -- [x] Camera spectate mode -- [ ] Custom mode -- [x] Steam P2P connectivity +Include both players’ logs whenever possible: ---- +- `last_error.txt` +- the latest DCCM game log +- exact reproduction steps +- whether the crash happened on host, client, or both +- the source-build line printed during startup -## 📜 Credits +## Credits -- **Dead Cells Core Modding API (DCCM)** - https://github.com/dead-cells-core-modding/core +- **Original project and core multiplayer implementation:** Vaiser / `vaiserYT` + - https://github.com/vaiserYT/DeadCellsMultiplayerMod +- **Dead Cells Core Modding API:** + - https://github.com/dead-cells-core-modding/core +- Community contributors and testers who helped reproduce crashes, verify synchronization, and test transitions. ---- +## License - +This project continues under the original MIT License. See `LICENSE` and `NOTICE.md`. diff --git a/README_ru.md b/README_ru.md index 5ee83e9..57d21c0 100644 --- a/README_ru.md +++ b/README_ru.md @@ -1,123 +1,105 @@ -

Dead Cells Multiplayer Mod

+# DeadCellsCoopPlus — стабильный кооператив v0.8.38 -**DeadCellsMultiplayerMod** — это **мод для совместной игры / мультиплеера в Dead Cells**, собранный на **Dead Cells Core Modding API (DCCM)**. +DeadCellsCoopPlus — это поддерживаемый сообществом форк оригинального **Dead Cells Multiplayer Mod** от Vaiser, созданный с помощью **Dead Cells Core Modding API (DCCM)**. -Мод добавляет **кооператив / мультиплеер** через **локальную или виртуальную сеть**: -один игрок поднимает сервер, второй подключается — оба **проходят уровни вместе в реальном времени**. +Версия v0.8.38 сохраняет все исправления стабильности v0.8.36, голубой пункт **Play Multiplayer** и добавляет защиту от устаревших dive-атак после выхода из дополнительных комнат. ---- - -## 🎮 Возможности - -- ✅ Синхронизация между двумя игроками в реальном времени -- ✅ Локальный TCP или Steam P2P мультиплеер -- ✅ Архитектура хост / клиент -- ✅ Автоматический старт игры для подключённого клиента -- ✅ Камера-спектатор — переключение между игроками клавишами `,` / `.` или геймпадом -- ✅ Синхронизация множителей HP боссов и руны босса -- ✅ Синхронизация атак мобов клиента и их прерывание -- ✅ Синхронизация оружия, головы и косметики призрака -- ✅ Обработка смерти/возрождения и рестарта -- ✅ Синхронизация перезагрузки графа уровня (босс-клетки, переходы) -- ✅ Слоты сохранения мультиплеера +Полная история изменений находится в `CHANGELOG.md`, а подробности последнего исправления переходов — в `SUBLEVEL_REWARD_DOOR_NOTES_v0.8.36.md`. --- -## ⭐ Поддержка проекта +
-Если проект вам интересен: -- ⭐ Поставьте звезду репозиторию -- 🍴 Сделайте форк и экспериментируйте +[English](README.md) • Русский -Любая обратная связь помогает улучшить мультиплеер для **Dead Cells**. +
---- +## Основные изменения v0.8.38 -## 🧰 Требования +- Пункт **Play Multiplayer** в главном меню теперь выделен мягким голубым цветом. +- Исправлены падения при обычных переходах между биомами. +- Исправлены падения при входе в комнаты награды за время и прохождение без урона. +- Используется актуальный стандартный путь `Level.init` игры для лучшей совместимости. +- Улучшена синхронизация врагов, урона, смертей и удаления зависших противников. +- Добавлен обновлённый Party HUD с именем, сегментированной полосой здоровья и процентами. +- Поддерживаются Steam P2P и прямое TCP-подключение. -- **Dead Cells (PC)** -- **Dead Cells Core Modding API (DCCM)** -- Локальная сеть, Steam или виртуальная LAN (для игры по интернету) +## Возможности ---- +- Синхронизация двух игроков в реальном времени +- Локальный TCP или Steam P2P +- Архитектура хост/клиент +- Автоматический запуск игры для подключённого клиента +- Режим наблюдения за игроками +- Синхронизация HP боссов и рун боссов +- Синхронизация движения, атак, урона, смерти и удаления врагов +- Синхронизация оружия, головы, скина и косметики второго игрока +- Система смерти, падения, возрождения и рестарта +- Синхронизация генерации и переходов уровней +- Защита переходов в комнаты награды +- Слоты сохранения для мультиплеера +- Party HUD для второго игрока -## 📦 Установка +## Требования -### 1️⃣ Установите Dead Cells Core Modding API (DCCM) +- **Dead Cells (PC)** +- **Dead Cells Core Modding API (DCCM)** +- Steam, локальная сеть или виртуальная LAN для TCP-подключения -Если у вас **Steam-версия** игры, следуйте официальной инструкции: +## Установка -👉 [https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/](https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/) +### 1. Установите DCCM -Так DCCM установится и будет обновляться автоматически. +Официальная инструкция для Steam-версии: -### 2️⃣ Установите DeadCellsMultiplayerMod +https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/ -Если у вас **Steam-версия** игры: -1. Откройте [https://steamcommunity.com/sharedfiles/filedetails/?id=3657857836](https://steamcommunity.com/sharedfiles/filedetails/?id=3657857836) -2. Установите мод в один клик. +### 2. Установите мод -Если у вас **не-Steam версия** Dead Cells (требуется DCCM): -1. Перейдите в **каталог DCCM** -2. Создайте папку `mods` (если её нет) -3. Распакуйте папку **DeadCellsMultiplayerMod** в каталог `mods` +Скопируйте собранную папку мода в: -Пример: -``` -Путь к игре/ - └──coremod/ +```text +Dead Cells/ +└── coremod/ └── mods/ └── DeadCellsMultiplayerMod/ ``` -### 3️⃣ Запуск игры через DCCM +### 3. Запустите игру через DCCM -Запустите **Dead Cells** через **DCCM**. -При первом запуске нужные конфигурационные файлы создадутся автоматически. +Оба игрока должны использовать одинаковую версию мода. Для v0.8.38 в логе должна быть строка: ---- - -## 🕹️ Как играть (мультиплеер) +```text +[NetMod] Source build: v0.8.38-sublevel-dive-combat-guard +``` -1. Запустите игру через **DCCM** -2. Нажмите **Play Multiplayer** -3. Выберите **Host** или **Join** -4. Введите **IP-адрес** и **порт** (TCP) или подключитесь через Steam -5. Когда хост начнёт игру, клиент автоматически подключится к сессии +## Чек-лист проверки v0.8.38 -🌐 **Для игры по интернету** можно использовать: -- Hamachi -- Radmin VPN -- ZeroTier -- Steam P2P (встроено) +- Prisoners’ Quarters → переходная безопасная зона +- Кузница и выбор мутаций +- Комната награды за время +- Комната награды за прохождение без урона +- Выход из комнаты награды +- Переход в следующий основной биом ---- +## Сообщение об ошибках -## 🧪 Статус разработки / TODO - -- [x] Второй игрок-призрак -- [x] Синхронизация данных мира -- [x] Анимации призрака -- [x] Синхронизация генерации уровня -- [x] Синхронизация врагов -- [x] Синхронизация боссов, множителей HP, руны босса -- [x] Обработка смерти и рестарт -- [x] Синхронизация оружия, головы и косметики призрака -- [x] Синхронизация загрузки графа уровня (босс-клетки, переходы) -- [x] Слоты сохранения мультиплеера и продолжение -- [x] Камера-спектатор -- [ ] Кастомный режим -- [x] Steam P2P подключение +По возможности приложите логи обоих игроков: ---- +- `last_error.txt` +- последний лог DCCM +- точные шаги воспроизведения +- где произошла ошибка: у хоста, клиента или у обоих +- строку версии сборки из начала лога -## 📜 Благодарности +## Благодарности -- **Dead Cells Core Modding API (DCCM)** - https://github.com/dead-cells-core-modding/core +- **Оригинальный проект и основная реализация мультиплеера:** Vaiser / `vaiserYT` + - https://github.com/vaiserYT/DeadCellsMultiplayerMod +- **Dead Cells Core Modding API:** + - https://github.com/dead-cells-core-modding/core +- Участники сообщества и тестировщики, помогавшие находить ошибки и проверять исправления. ---- +## Лицензия - +Проект продолжает использовать оригинальную лицензию MIT. См. `LICENSE` и `NOTICE.md`. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..07b1dc6 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Security Policy + +## Supported versions + +DeadCellsCoopPlus is a community-maintained mod project. Security fixes will generally target the newest public release. + +| Version | Supported | +| --- | --- | +| Latest release | Yes | +| Older releases | Limited | + +## Reporting a vulnerability + +Please do not publicly post security issues that could harm users. + +Instead, open a private report if GitHub security advisories are enabled, or contact the repository maintainer directly. + +Include: + +- A clear description of the issue +- Steps to reproduce +- Possible impact +- Suggested fix, if known + +## Scope + +Security issues may include: + +- Unsafe networking behavior +- File write/delete issues +- Remote crash exploits +- Anything that could allow unwanted code execution +- Anything that could expose user information + +Game bugs, desyncs, crashes, and normal gameplay issues should be reported as regular GitHub issues instead. diff --git a/SUBLEVEL_DIVE_COMBAT_NOTES_v0.8.38.md b/SUBLEVEL_DIVE_COMBAT_NOTES_v0.8.38.md new file mode 100644 index 0000000..eb8be45 --- /dev/null +++ b/SUBLEVEL_DIVE_COMBAT_NOTES_v0.8.38.md @@ -0,0 +1,13 @@ +# v0.8.38 sublevel dive-combat guard + +The v0.8.37 crash log showed that the `ZDoor` sublevel activation completed successfully. +Ten seconds later, `DiveAttack.onOwnerLand` reached `Mob.applyAttackResult` on an entity whose sprite animation group was null. + +This build keeps the working render guard and adds combat-state isolation around sublevel changes: + +- remote combat queues are drained before and after sublevel activation; +- remote dive replay is dropped during the transition and short post-activation grace period; +- dive damage is suppressed in no-combat `T_*` transition/passages; +- invalid entities are made non-targetable only while a dive hit is resolved in normal combat levels. + +The visual landing/end path remains available; only unsafe area-hit resolution is skipped. diff --git a/SUBLEVEL_REWARD_DOOR_NOTES_v0.8.36.md b/SUBLEVEL_REWARD_DOOR_NOTES_v0.8.36.md new file mode 100644 index 0000000..89af519 --- /dev/null +++ b/SUBLEVEL_REWARD_DOOR_NOTES_v0.8.36.md @@ -0,0 +1,23 @@ +# v0.8.36 timed/no-hit reward-room crash fix + +The v0.8.35 log proves the normal biome transition succeeded: the host loaded `T_PurpleGarden` and continued running. The later fatal is a separate sublevel activation path: + +``` +Game.activateSubLevel +Level.resume +Level.onActivation +Level.initRender +LevelDisp.render +Boot.tryRender +Null access .groupName +``` + +Timed and no-hit reward-room entrances use `ZDoor`. v0.8.35 only armed the KingSkin guard for the normal exit synchronization hooks, so no guard line appeared when the reward door was activated. v0.8.36 arms the guard before `ZDoor.onActivate` and leaves the remote KingSkin detached until native `Level.onActivation` has returned. + +Expected log lines: + +``` +[NetMod][SubLevelGuard] armed reason=zdoor-activate:... +[NetMod][SubLevelGuard] activating target=... +[NetMod][SubLevelGuard] completed reason=level-onActivation:... +``` diff --git a/SteamP2P/SteamConnect.cs b/SteamP2P/SteamConnect.cs index ca3fe91..aa4c20b 100644 --- a/SteamP2P/SteamConnect.cs +++ b/SteamP2P/SteamConnect.cs @@ -1339,6 +1339,28 @@ private static int NormalizePort(int port) return port; } + internal static bool TryOpenInviteOverlay(ulong lobbyId, out string error) + { + error = string.Empty; + if (lobbyId == 0UL) + { + error = "No Steam lobby is active yet"; + return false; + } + + try + { + SteamFriends.ActivateGameOverlayInviteDialog(new CSteamID(lobbyId)); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + internal static string ResolveBestHostIp() { try diff --git a/UI/GameMenu.Connection.cs b/UI/GameMenu.Connection.cs index 458b9c4..eec26c3 100644 --- a/UI/GameMenu.Connection.cs +++ b/UI/GameMenu.Connection.cs @@ -934,12 +934,17 @@ private static dc.String MakeHLString(string value) return value.AsHaxeString(); } - private static void AddMenuButton(TitleScreen screen, string label, Action onClick, string? help = null) + private static void AddMenuButton( + TitleScreen screen, + string label, + Action onClick, + string? help = null, + int textColor = 0xFFFFFF) { var cb = new HlAction(onClick); var labelStr = MakeHLString(label); var helpStr = MakeHLString(help ?? string.Empty); - int colorVal = 0xFFFFFF; + int colorVal = textColor; var color = Ref.From(ref colorVal); screen.addMenu(labelStr, cb, helpStr, null, color); } diff --git a/UI/GameMenu.cs b/UI/GameMenu.cs index 64741eb..f916dcd 100644 --- a/UI/GameMenu.cs +++ b/UI/GameMenu.cs @@ -32,6 +32,7 @@ internal static partial class GameMenu private static bool _addMenuHookRegistered; private static bool _mainMenuButtonAdded; private static bool _addingMultiplayerButton; + private const int MultiplayerMainMenuTextColor = 0x7FD4FF; // soft blue private static WeakReference? _titleScreenRef; private static string _mpIp = "127.0.0.1"; private static int _mpPort = 1234; @@ -49,6 +50,8 @@ private enum ConnectionTransport private static bool _steamJoinLobbyResolvePending; private static ulong? _pendingOverlayJoinLobbyId; private static bool _waitingForHost; + private static int _roomStatusMenuKind; // 0 none, 1 host, 2 client + private static DateTime _lastRoomStatusAutoRefresh = DateTime.MinValue; internal const int ClientConnectMaxAttempts = 3; private static int _clientConnectAttempt; private static bool _clientConnecting; @@ -306,6 +309,26 @@ public static bool TryGetHostRunSeed(out int seed) return false; } + public static bool TryGetKnownSeed(out int seed) + { + lock (Sync) + { + if (_serverSeed.HasValue) + { + seed = _serverSeed.Value; + return true; + } + if (_remoteSeed.HasValue) + { + seed = _remoteSeed.Value; + return true; + } + } + + seed = 0; + return false; + } + public static void ReceiveHostRunSeed(int seed) { int? previousSeed = null; @@ -625,30 +648,38 @@ private static void NotifyLevelDescReceived() private static void ShowMultiplayerMenu(TitleScreen screen) { + _roomStatusMenuKind = 0; screen.clearMenu(); - AddMenuButton(screen, GetText.Instance.GetString("Host game"), () => ShowHostTransportMenu(screen), GetText.Instance.GetString("Create a multiplayer session")); - AddMenuButton(screen, GetText.Instance.GetString("Join game"), () => ShowJoinTransportMenu(screen), GetText.Instance.GetString("Connect to an existing host")); + AddInfoLine(screen, GetText.Instance.GetString("Co-op"), 0xFFE48A); + AddMenuButton(screen, GetText.Instance.GetString("Host room"), () => ShowHostTransportMenu(screen), GetText.Instance.GetString("Create a Steam or IP/VPN room")); + AddMenuButton(screen, GetText.Instance.GetString("Join room"), () => ShowJoinTransportMenu(screen), GetText.Instance.GetString("Join with Steam invite/lobby code or IP")); + AddMenuButton(screen, GetMultiplayerSaveButtonLabel(), () => OpenMultiplayerSlotMenu(screen), Localize("Choose multiplayer save slot")); AddMenuButton(screen, GetText.Instance.GetString("Back"), () => screen.mainMenu(), GetText.Instance.GetString("Return to main menu")); } private static void ShowHostTransportMenu(TitleScreen screen) { + _roomStatusMenuKind = 0; screen.clearMenu(); - AddMenuButton(screen, GetText.Instance.GetString("LAN"), () => ShowLanConnectionMenu(screen, NetRole.Host), GetText.Instance.GetString("Use direct IP/port hosting")); - AddMenuButton(screen, GetText.Instance.GetString("Steam"), () => NativeStartSteamHost(screen), GetText.Instance.GetString("Create Steam lobby and start immediately")); + AddInfoLine(screen, GetText.Instance.GetString("Host room"), 0xFFE48A); + AddMenuButton(screen, GetText.Instance.GetString("Steam friends lobby"), () => NativeStartSteamHost(screen), GetText.Instance.GetString("Create Steam lobby and invite friends")); + AddMenuButton(screen, GetText.Instance.GetString("IP / VPN lobby"), () => ShowLanConnectionMenu(screen, NetRole.Host), GetText.Instance.GetString("Hamachi, Radmin, ZeroTier, LAN or port forward")); AddMenuButton(screen, GetText.Instance.GetString("Back"), () => ShowMultiplayerMenu(screen), GetText.Instance.GetString("Back to multiplayer menu")); } private static void ShowJoinTransportMenu(TitleScreen screen) { + _roomStatusMenuKind = 0; screen.clearMenu(); - AddMenuButton(screen, GetText.Instance.GetString("LAN"), () => ShowLanConnectionMenu(screen, NetRole.Client), GetText.Instance.GetString("Connect by IP/port")); - AddMenuButton(screen, GetText.Instance.GetString("Steam"), () => NativeStartSteamJoin(screen), GetText.Instance.GetString("Connect by Steam lobby id/code from clipboard")); + AddInfoLine(screen, GetText.Instance.GetString("Join room"), 0xFFE48A); + AddMenuButton(screen, GetText.Instance.GetString("Join Steam invite/code"), () => NativeStartSteamJoin(screen), GetText.Instance.GetString("Use lobby code from clipboard or accepted Steam invite")); + AddMenuButton(screen, GetText.Instance.GetString("Join IP / VPN"), () => ShowLanConnectionMenu(screen, NetRole.Client), GetText.Instance.GetString("Connect by Hamachi/Radmin/ZeroTier/IP")); AddMenuButton(screen, GetText.Instance.GetString("Back"), () => ShowMultiplayerMenu(screen), GetText.Instance.GetString("Back to multiplayer menu")); } private static void ShowLanConnectionMenu(TitleScreen screen, NetRole role) { + _roomStatusMenuKind = 0; _menuSelection = role; _menuTransport = ConnectionTransport.Lan; if (role == NetRole.Client) @@ -715,22 +746,36 @@ private static void ShowLanConnectionMenu(TitleScreen screen, NetRole role) private static void ShowHostStatusMenu(TitleScreen screen) { + _roomStatusMenuKind = 1; screen.clearMenu(); - AddMenuButton(screen, GetText.Instance.GetString("Play"), () => StartHostRun(screen), GetText.Instance.GetString("Launch game")); + AddInfoLine(screen, BuildRoomSummaryLine(), 0xFFE48A); + AddInfoLine(screen, BuildFriendSummaryLine(), NetRef != null && NetRef.HasRemote ? 0xA6FF8A : 0xE0E0E0); + AddMenuButton(screen, GetText.Instance.GetString("Start run for everyone"), () => StartHostRun(screen), GetText.Instance.GetString("Launch the synced co-op run")); + AddMenuButton(screen, GetText.Instance.GetString("Refresh room"), () => ShowHostStatusMenu(screen), GetText.Instance.GetString("Refresh lobby status")); AddMenuButton(screen, GetMultiplayerSaveButtonLabel(), () => OpenMultiplayerSlotMenu(screen), Localize("Choose multiplayer save slot")); - AddMenuButton(screen, GetText.Instance.GetString("Back"), () => + if (_menuTransport == ConnectionTransport.Steam) + { + AddMenuButton(screen, GetText.Instance.GetString("Invite Steam friends"), () => OpenSteamInviteOverlayFromMenu(screen), GetText.Instance.GetString("Open Steam friend invite overlay")); + AddMenuButton(screen, GetText.Instance.GetString("Copy Steam room code"), () => { TryCopySteamLobbyCodeFromUi(); ShowHostStatusMenu(screen); }, GetText.Instance.GetString("Copy lobby code for friend")); + } + AddMenuButton(screen, GetText.Instance.GetString("Stop hosting"), () => { StopNetworkFromMenu(); SetRole(NetRole.None); _menuSelection = NetRole.None; ShowMultiplayerMenu(screen); screen.ShouldAutoHideConnectionUI(false); - }, GetText.Instance.GetString("Back to host setup")); + }, GetText.Instance.GetString("Close room and go back")); } private static void ShowClientWaitingMenu(TitleScreen screen) { + _roomStatusMenuKind = 2; screen.clearMenu(); + AddInfoLine(screen, BuildRoomSummaryLine(), 0xFFE48A); + AddInfoLine(screen, BuildFriendSummaryLine(), NetRef != null && NetRef.HasRemote ? 0xA6FF8A : 0xE0E0E0); + AddInfoLine(screen, GetText.Instance.GetString("Waiting for host to start..."), 0xE0E0E0); + AddMenuButton(screen, GetText.Instance.GetString("Refresh room"), () => ShowClientWaitingMenu(screen), GetText.Instance.GetString("Refresh lobby status")); AddMenuButton(screen, GetText.Instance.GetString("Disconnect"), () => { StopNetworkFromMenu(); @@ -744,6 +789,66 @@ private static void ShowClientWaitingMenu(TitleScreen screen) AddMenuButton(screen, GetMultiplayerSaveButtonLabel(), () => OpenMultiplayerSlotMenu(screen), Localize("Choose multiplayer save slot")); } + + + public static void RefreshRoomStatusMenuIfVisible() + { + if (_roomStatusMenuKind == 0) + return; + if ((DateTime.UtcNow - _lastRoomStatusAutoRefresh).TotalSeconds < 1.0) + return; + _lastRoomStatusAutoRefresh = DateTime.UtcNow; + + EnqueueMainThreadCoalesced("ui:auto-refresh-room-status", () => + { + var screen = GetTitleScreen(); + if (screen == null) + return; + if (_roomStatusMenuKind == 1) + ShowHostStatusMenu(screen); + else if (_roomStatusMenuKind == 2) + ShowClientWaitingMenu(screen); + }); + } + + + private static void OpenSteamInviteOverlayFromMenu(TitleScreen screen) + { + if (_steamLobbyId == 0UL) + { + AddInfoLine(screen, GetText.Instance.GetString("No Steam room yet."), 0xFF9090); + return; + } + if (!SteamConnect.TryOpenInviteOverlay(_steamLobbyId, out var error)) + _log?.Warning("[NetMod][Steam] Invite overlay failed: {Error}", error); + ShowHostStatusMenu(screen); + } + + private static string BuildRoomSummaryLine() + { + var transport = _menuTransport == ConnectionTransport.Steam ? "Steam" : "IP/VPN"; + var role = _role == NetRole.Host ? "Host" : _role == NetRole.Client ? "Client" : _menuSelection == NetRole.Host ? "Host" : _menuSelection == NetRole.Client ? "Client" : "Room"; + var code = _menuTransport == ConnectionTransport.Steam ? GetSteamLobbyCodeForUi() : $"{_mpIp}:{_mpPort}"; + if (string.IsNullOrWhiteSpace(code)) + code = _menuTransport == ConnectionTransport.Steam ? "creating..." : $"{_mpIp}:{_mpPort}"; + return $"{transport} {role} | {code}"; + } + + private static string BuildFriendSummaryLine() + { + var net = NetRef; + if (net == null || !net.IsAlive) + return "Not connected"; + if (!net.HasRemote) + return net.IsHost ? "Waiting for friend..." : "Connecting to host..."; + var name = string.IsNullOrWhiteSpace(_remoteUsername) || string.Equals(_remoteUsername, "guest", StringComparison.OrdinalIgnoreCase) + ? "friend" + : _remoteUsername.Trim(); + if (net.IsHost) + return $"Same lobby: yes | Friend: {name}"; + return $"Same lobby: yes | Host: {name}"; + } + private static void ShowConnectionErrorPopup(TitleScreen screen, string title, string details, Action onOk) { screen.clearMenu(); diff --git a/UI/GameMenuHooks.cs b/UI/GameMenuHooks.cs index 5e3fb94..91f2673 100644 --- a/UI/GameMenuHooks.cs +++ b/UI/GameMenuHooks.cs @@ -45,7 +45,7 @@ private static void MainMenuHook(Hook_TitleScreen.orig_mainMenu orig, TitleScree { var label = GetText.Instance.GetString("Play multiplayer"); var help = GetText.Instance.GetString("Host or join a multiplayer session"); - AddMenuButton(self, label, () => ShowMultiplayerMenu(self), help); + AddMenuButton(self, label, () => ShowMultiplayerMenu(self), help, MultiplayerMainMenuTextColor); _mainMenuButtonAdded = true; } ProcessPendingOverlayJoinRequest(self); @@ -96,7 +96,7 @@ private static virtual_cb_help_inter_isEnable_t_ AddMenuHook( { var mpLabel = GetText.Instance.GetString("Play multiplayer"); var mpHelp = GetText.Instance.GetString("Host or join a multiplayer session"); - AddMenuButton(self, mpLabel, () => ShowMultiplayerMenu(self), mpHelp); + AddMenuButton(self, mpLabel, () => ShowMultiplayerMenu(self), mpHelp, MultiplayerMainMenuTextColor); _mainMenuButtonAdded = true; } finally { _addingMultiplayerButton = false; } diff --git a/UI/LevelExitSync.cs b/UI/LevelExitSync.cs index 0ee4247..e109fb3 100644 --- a/UI/LevelExitSync.cs +++ b/UI/LevelExitSync.cs @@ -87,6 +87,9 @@ private sealed class PlayerExitState private bool _suppressDoorActivateHook; private string _transitionDoorKey = string.Empty; private bool _timerPausedByExit; + private string _exitFailsafeDoorKey = string.Empty; + private long _exitFailsafeStartedTicks; + private const double ExitFailsafeTeleportSeconds = 8.0; /// Exit/portal/boss-door entities only — avoids scanning level.entities every hero frame. private readonly List _exitTargetCandidates = new(); @@ -173,6 +176,10 @@ private void Hook_Level_unregisterEntity(Hook_Level.orig_unregisterEntity orig, private void Hook_Level_onDispose(Hook_Level.orig_onDispose orig, Level self) { + var localLevel = ModEntry.me?._level; + if (localLevel != null && ReferenceEquals(localLevel, self)) + ModEntry.PrepareRemoteKingsForLevelTransition("level-onDispose-current"); + if (ReferenceEquals(_exitCandidatesLevel, self)) { _exitCandidatesLevel = null; @@ -298,6 +305,7 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) UpdateLocalPlayerState(net, forceSend: false); ApplyLocalTimerPause(_localPressed && _localInsideCircle); RefreshDoorVisuals(net); + TryExitTeleportFailsafe(hero, currentLevel, net); if (_localPressed && _localInsideCircle && @@ -324,6 +332,75 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) UpdateExitPointer(net); } + + private void TryExitTeleportFailsafe(Hero hero, Level? level, NetNode net) + { + if (hero == null || level == null || net == null || !net.IsAlive) + return; + if (_localPressed && _localInsideCircle) + { + _exitFailsafeDoorKey = string.Empty; + _exitFailsafeStartedTicks = 0; + return; + } + + string candidateKey = string.Empty; + foreach (var state in _playerStates.Values) + { + if (state == null || state.UserId <= 0) + continue; + if (!state.Pressed || !state.InsideCircle || string.IsNullOrWhiteSpace(state.DoorKey)) + continue; + if (IsPlayerDownedForExit(state.UserId, net.id)) + continue; + candidateKey = state.DoorKey; + break; + } + + if (string.IsNullOrWhiteSpace(candidateKey)) + { + _exitFailsafeDoorKey = string.Empty; + _exitFailsafeStartedTicks = 0; + return; + } + + var target = FindExitTargetByDoorKey(level, candidateKey); + if (target == null) + return; + + if (!string.Equals(_exitFailsafeDoorKey, candidateKey, StringComparison.Ordinal)) + { + _exitFailsafeDoorKey = candidateKey; + _exitFailsafeStartedTicks = Stopwatch.GetTimestamp(); + return; + } + + var elapsed = Stopwatch.GetElapsedTime(_exitFailsafeStartedTicks).TotalSeconds; + if (elapsed < ExitFailsafeTeleportSeconds) + return; + + try + { + var x = GetEntityX(target); + var y = GetEntityY(target); + hero.cancelVelocities(); + hero.setPosPixel(x, y); + _localDoorKey = candidateKey; + _localDoorCx = target.cx; + _localDoorCy = target.cy; + _localInsideCircle = true; + _localPressed = true; + UpdateLocalPlayerState(net, forceSend: true); + MultiplayerUI.PushSystemMessage(FormatLocalized("Exit failsafe: pulled you to {0}", ResolveExitDestinationName(target)), 6.0, 1.5); + _exitFailsafeStartedTicks = Stopwatch.GetTimestamp(); + MarkExitUiStateDirty(); + } + catch (Exception ex) + { + _log.Warning("[ExitSync] Exit teleport failsafe failed: {Message}", ex.Message); + } + } + private void TriggerExitTransition(Entity target, Hero hero, Action? origActivate) { if (target == null || hero == null) @@ -333,6 +410,10 @@ private void TriggerExitTransition(Entity target, Hero hero, Action? origActivat ModEntry.ApplyLocalDownedExitPenaltyIfNeeded(); _transitionDoorKey = BuildDoorKey(target.cx, target.cy); + ModEntry.PrepareRemoteKingsForLevelTransition( + string.Create( + CultureInfo.InvariantCulture, + $"exit-activate:{target.GetType().Name}:{_transitionDoorKey}")); _suppressDoorActivateHook = true; try { diff --git a/UI/MultiplayerUI.cs b/UI/MultiplayerUI.cs index 9efe2a6..3ef1ad0 100644 --- a/UI/MultiplayerUI.cs +++ b/UI/MultiplayerUI.cs @@ -30,16 +30,19 @@ private sealed class LifeSlot public int SlotIndex { get; } public dc.ui.hud.LifeBar LifeBar { get; } public FlowBox Container { get; } - public FlowBox LabelBox { get; } - public dc.h2d.Text? LabelText { get; set; } + public dc.h2d.Object LabelBox { get; } + public Graphics? ChipGraphics { get; set; } + public dc.ui.Text? LabelText { get; set; } + public dc.ui.Text? StatusText { get; set; } public string? LastLabel { get; set; } + public string? LastStatus { get; set; } public int LastLife { get; set; } = int.MinValue; public int LastMaxLife { get; set; } = int.MinValue; public int LastLif { get; set; } = int.MinValue; public int LastBonusLife { get; set; } = int.MinValue; public int LastRecover { get; set; } = int.MinValue; - public LifeSlot(int slotIndex, dc.ui.hud.LifeBar lifeBar, FlowBox container, FlowBox labelBox) + public LifeSlot(int slotIndex, dc.ui.hud.LifeBar lifeBar, FlowBox container, dc.h2d.Object labelBox) { SlotIndex = slotIndex; LifeBar = lifeBar; @@ -58,6 +61,7 @@ public LifeSlot(int slotIndex, dc.ui.hud.LifeBar lifeBar, FlowBox container, Flo private LifeSlot?[] _slots = System.Array.Empty(); private bool[] _slotActive = System.Array.Empty(); private HUD? _hud; + private long _hudReadyAfterTicks; private int lastLife = 0; private int lastMaxLife = 0; @@ -65,6 +69,23 @@ public LifeSlot(int slotIndex, dc.ui.hud.LifeBar lifeBar, FlowBox container, Flo private static MultiplayerUI? _instance; + // Party plate layout (panel-local units), matching the mockup: bronze name plate on the + // left, long dark bar plate with a segmented HP fill extending to the right. + private const double PartyChipX = 18.0; + private const double PartyChipY = 42.0; + private const double PartyChipGapY = 52.0; + private const double PlateTotalW = 300.0; + private const double NamePlateW = 82.0; + private const double NamePlateH = 42.0; + private const double BarPlateX = 76.0; + private const double BarPlateH = 26.0; + private const double BarPlateY = (NamePlateH - BarPlateH) / 2.0; + private const double PartyBarX = BarPlateX + 7.0; + private const double PartyBarY = BarPlateY + 7.0; + private const double PartyBarW = PlateTotalW - 7.0 - PartyBarX; + private const double PartyBarH = BarPlateH - 14.0; + private const int PartyBarSegments = 6; + public MultiplayerUI(ModEntry Entry, int slotIndex = 0) { @@ -82,15 +103,23 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) Hook_Hero.updateLifeBar += Hook_Hero_kinglifupdate; } - private void Hook_HUD_initking(Hook_HUD.orig_initHero orig, HUD self) { + // The custom plate is owned by a vanilla FlowBox, so old HUD disposal owns its + // render lifecycle. Drop our references before initializing the replacement HUD. + try { ClearSlots(); } catch { } + orig(self); _hud = self; int slotCount = NetNode.MaxClientSlots; _slots = new LifeSlot?[slotCount]; _slotActive = new bool[slotCount]; + + // Remote HP is already cached during a level transition. Do not create font-backed + // custom labels from inside Game.loadMainLevel; wait until the new HUD has settled. + _hudReadyAfterTicks = System.Diagnostics.Stopwatch.GetTimestamp() + + (long)(System.Diagnostics.Stopwatch.Frequency * 1.5); } public bool CanUseJumpHit() { @@ -107,6 +136,10 @@ public bool CanUseJumpHit() } public void Debugkeys() { + // Disabled for stability. The old debug hotkeys spawned mobs and wrote Dead Cells cooldown maps + // during normal play, which can corrupt Hashlink runtime state. + return; +#pragma warning disable CS0162 if (Key.Class.isPressed(97))//num1 { @@ -151,6 +184,7 @@ public void Debugkeys() { return; } +#pragma warning restore CS0162 } private void Hook_Hero_kinglifupdate(Hook_Hero.orig_updateLifeBar orig, Hero self) @@ -234,15 +268,23 @@ public void KingLifeUpdate(Hero self) if (slotIndex < 0 || slotIndex >= _slots.Length) continue; + // Position/name packets can create a remote snapshot before the first HP packet arrives. + // Do not render a broken 0 / 0 party frame; wait for real HP data. + if (remote.MaxLife <= 0) + continue; + var slot = _slots[slotIndex]; if (slot == null) { + if (System.Diagnostics.Stopwatch.GetTimestamp() < _hudReadyAfterTicks) + continue; var hud = _hud; if (hud == null) continue; var lifeBar = new dc.ui.hud.LifeBar(new LifeBarColorMode.Normal(), null); slot = initkingLife(hud, slotIndex, lifeBar); _slots[slotIndex] = slot; + Log.Information("[NetMod][PartyHUD] created plate slot={Slot}", slotIndex); } var displayName = ModEntry.GetClientLabel(slotIndex); @@ -273,97 +315,270 @@ private LifeSlot initkingLife(HUD self, int slotIndex, dc.ui.hud.LifeBar kinglif { this.toplib = self.topRightFlowT; - var displayName = ModEntry.GetClientLabel(slotIndex); - dc.String remoteUsername = displayName.AsHaxeString(); - double wh = remoteUsername.length + 2; - double hh = 1.5; - bool logo = true; + // The OUTER owner is a vanilla-managed FlowBox. The inner panel is a plain Object so + // its graphics/text keep exact positions, but it cannot outlive the owning HUD. + FlowBox owner = FlowBox.Class.createBoxValidation( + null, Ref.Null, Ref.Null, Ref.Null, null); + owner.isVertical = false; + owner.box.alpha = 0; + owner.set_horizontalAlign(new FlowAlign.Middle()); + owner.set_verticalAlign(new FlowAlign.Middle()); - FlowBox flowBox = FlowBox.Class.createBoxValidation(null, Ref.Null, Ref.Null, Ref.Null, null); - flowBox.isVertical = false; - flowBox.box.alpha = 0; + var panel = new dc.h2d.Object(null); + owner.addChild(panel); + var chip = new Graphics(panel); + chip.visible = true; - flowBox.set_horizontalAlign(new FlowAlign.Middle()); - flowBox.set_verticalAlign(new FlowAlign.Middle()); + var displayName = ModEntry.GetClientLabel(slotIndex); + if (string.IsNullOrWhiteSpace(displayName)) + displayName = "Guest"; + displayName = FitDisplayName(displayName); - FlowBox uibox = FlowBox.Class.createBoxValidation(null, Ref.From(ref wh), Ref.From(ref hh), Ref.From(ref logo), null); - dc.h2d.Text text_h2d = Assets.Class.makeText(remoteUsername, dc.ui.Text.Class.COLORS.get("WO".AsHaxeString()), false, uibox); - text_h2d.textColor = 16766720; + dc.ui.Text nameText = Assets.Class.makeText( + displayName.AsHaxeString(), + dc.ui.Text.Class.COLORS.get("WO".AsHaxeString()), + false, + panel); + nameText.y = 11; + nameText.textColor = 0xF2D98C; + nameText.customScale = 0.8; + nameText.onResize(); + CenterText(nameText, displayName, 0.0, NamePlateW); + + dc.ui.Text statusText = Assets.Class.makeText( + "0%".AsHaxeString(), + dc.ui.Text.Class.COLORS.get("WO".AsHaxeString()), + false, + panel); + statusText.y = BarPlateY + 5.0; + statusText.textColor = 0xF4F8FF; + statusText.customScale = 0.6; + statusText.onResize(); + CenterText(statusText, "0%", PartyBarX, PartyBarW); - flowBox.addChild(kinglifeui); - flowBox.addChild(uibox); + try { kinglifeui.visible = false; } catch { } - this.toplib.addChild(flowBox); + this.toplib.addChild(owner); this.toplib.isVertical = true; this.toplib.set_verticalAlign(new FlowAlign.Top()); this.toplib.set_horizontalAlign(new FlowAlign.Right()); - var geth = Viewport.Class.NATIVE_HEIGHT; - var getw = Viewport.Class.NATIVE_WIDTH; - double pixelScale = self.get_pixelScale.Invoke(); + var slot = new LifeSlot(slotIndex, kinglifeui, owner, panel) + { + ChipGraphics = chip, + LabelText = nameText, + StatusText = statusText, + LastLabel = displayName, + LastStatus = "0%" + }; + DrawPartyChip(slot, 0, 1, 0); + return slot; + } - int rightMargin = (int)(5 * pixelScale); - int topMargin = (int)(5 * pixelScale); - int w = (int)(100 * pixelScale); - int h = (int)(10 * pixelScale); - int labelHeight = (int)(hh * pixelScale); - int labelBarGap = (int)(2 * pixelScale); - int slotGap = (int)(6 * pixelScale); + /// Centers a dc.ui.Text horizontally inside [regionX, regionX + regionW]. + private static void CenterText(dc.ui.Text? text, string label, double regionX, double regionW) + { + if (text == null) + return; - kinglifeui.setSize(w, h); - kinglifeui.get_pixelScale = self.get_pixelScale; - kinglifeui.enableText(); + double textWidth; + try + { + textWidth = text.textWidth * text.scaleX; + } + catch + { + textWidth = label.Length * 10.0; + } - int horizontalSpacing = (int)(5 * pixelScale); + if (textWidth <= 0) + textWidth = label.Length * 10.0; - //horizontalContainer.horizontalSpacing = horizontalSpacing; - var slot = new LifeSlot(slotIndex, kinglifeui, flowBox, uibox) - { - LabelText = text_h2d, - LastLabel = displayName - }; - return slot; + text.x = System.Math.Max(regionX + 4.0, regionX + (regionW - textWidth) * 0.5); + } + + /// The name plate is small; trim long Steam names so they stay inside it. + private static string FitDisplayName(string displayName) + { + const int maxChars = 9; + if (string.IsNullOrWhiteSpace(displayName)) + return "Guest"; + displayName = displayName.Trim(); + return displayName.Length <= maxChars ? displayName : displayName[..(maxChars - 1)] + "…"; } private static void UpdateSlotLabel(LifeSlot slot, string displayName) { + if (string.IsNullOrWhiteSpace(displayName)) + displayName = "Guest"; + displayName = FitDisplayName(displayName); + if (slot.LabelText != null && slot.LastLabel != displayName) { - slot.LabelText.text = displayName.AsHaxeString(); + slot.LabelText.set_text(displayName.AsHaxeString()); + slot.LabelText.onResize(); + CenterText(slot.LabelText, displayName, 0.0, NamePlateW); slot.LastLabel = displayName; } } - private static void UpdateLifeBar(LifeSlot slot, int max, int maxLife, int lif, int bonusLife, int recover) + private static void UpdateLifeBar(LifeSlot slot, int life, int maxLife, int lif, int bonusLife, int recover) { - if (slot.LastLife == max && - slot.LastMaxLife == maxLife && - slot.LastLif == lif && + var safeMaxLife = System.Math.Max(1, maxLife); + var safeLife = System.Math.Max(0, System.Math.Min(life, safeMaxLife)); + var safeLif = System.Math.Max(0, System.Math.Min(lif <= 0 ? safeLife : lif, safeMaxLife)); + var percent = safeMaxLife <= 0 ? 0 : (int)System.Math.Round((safeLife * 100.0) / safeMaxLife); + var status = $"{percent}%"; + + if (slot.LastLife == safeLife && + slot.LastMaxLife == safeMaxLife && + slot.LastLif == safeLif && slot.LastBonusLife == bonusLife && - slot.LastRecover == recover) + slot.LastRecover == recover && + string.Equals(slot.LastStatus, status, StringComparison.Ordinal)) { return; } - var lifeBar = slot.LifeBar; - lifeBar.init(max, maxLife); - lifeBar.curState.life = (double)lif; - lifeBar.curState.bonusLife = (double)bonusLife; - lifeBar.curState.recover = (double)recover; - slot.LastLife = max; - slot.LastMaxLife = maxLife; - slot.LastLif = lif; + DrawPartyChip(slot, safeLife, safeMaxLife, percent); + + if (slot.StatusText != null && !string.Equals(slot.LastStatus, status, StringComparison.Ordinal)) + { + slot.StatusText.set_text(status.AsHaxeString()); + slot.StatusText.textColor = percent <= 25 ? 0xFF9B8A : percent <= 50 ? 0xFFE0A6 : 0xF4F8FF; + slot.StatusText.onResize(); + CenterText(slot.StatusText, status, PartyBarX, PartyBarW); + slot.LastStatus = status; + } + + slot.LastLife = safeLife; + slot.LastMaxLife = safeMaxLife; + slot.LastLif = safeLif; slot.LastBonusLife = bonusLife; slot.LastRecover = recover; } + private static void DrawPartyChip(LifeSlot slot, int life, int maxLife, int percent) + { + var g = slot.ChipGraphics; + if (g == null) + return; + + try + { + g.clear(); + + double fullAlpha = 1.0; + int shadowColor = 0x000000; + + // --- Soft drop shadow under both plates. --- + double shadowAlpha = 0.38; + g.beginFill(Ref.From(ref shadowColor), Ref.From(ref shadowAlpha)); + g.drawRect(3.0, BarPlateY + 3.0, PlateTotalW, BarPlateH); + g.drawRect(3.0, 3.0, NamePlateW, NamePlateH); + g.endFill(); + + // --- Bar plate (drawn first so the name plate overlaps its left edge). --- + // Thin gold/bronze outline around a near-black panel, like the mockup. + int barOutline = 0xC98A4B; + g.beginFill(Ref.From(ref barOutline), Ref.From(ref fullAlpha)); + g.drawRect(BarPlateX, BarPlateY, PlateTotalW - BarPlateX, BarPlateH); + g.endFill(); + + int barPanel = 0x14161F; + g.beginFill(Ref.From(ref barPanel), Ref.From(ref fullAlpha)); + g.drawRect(BarPlateX + 2.0, BarPlateY + 2.0, PlateTotalW - BarPlateX - 4.0, BarPlateH - 4.0); + g.endFill(); + + // HP bar shell (dark inset). + int barBorder = 0x07090F; + g.beginFill(Ref.From(ref barBorder), Ref.From(ref fullAlpha)); + g.drawRect(PartyBarX - 2.0, PartyBarY - 2.0, PartyBarW + 4.0, PartyBarH + 4.0); + g.endFill(); + + int barBackColor = 0x11202A; + g.beginFill(Ref.From(ref barBackColor), Ref.From(ref fullAlpha)); + g.drawRect(PartyBarX, PartyBarY, PartyBarW, PartyBarH); + g.endFill(); + + // HP fill with pixel-art highlight/shade bands. + var safeMax = System.Math.Max(1, maxLife); + var safeLife = System.Math.Max(0, System.Math.Min(life, safeMax)); + var fillW = PartyBarW * safeLife / safeMax; + + if (fillW > 0) + { + int fillColor = percent <= 25 ? 0xC94040 : percent <= 50 ? 0xD89036 : 0x4CBB5E; + int fillHighlight = percent <= 25 ? 0xE87B6E : percent <= 50 ? 0xF0BC6E : 0x7FDD82; + int fillShade = percent <= 25 ? 0x8C2A2A : percent <= 50 ? 0x9C6420 : 0x2E8F45; + + g.beginFill(Ref.From(ref fillColor), Ref.From(ref fullAlpha)); + g.drawRect(PartyBarX, PartyBarY, fillW, PartyBarH); + g.endFill(); + + g.beginFill(Ref.From(ref fillHighlight), Ref.From(ref fullAlpha)); + g.drawRect(PartyBarX, PartyBarY, fillW, 2.0); + g.endFill(); + + g.beginFill(Ref.From(ref fillShade), Ref.From(ref fullAlpha)); + g.drawRect(PartyBarX, PartyBarY + PartyBarH - 2.0, fillW, 2.0); + g.endFill(); + } + + // Segment dividers across the whole bar (visible over the fill, near-invisible + // over the dark empty part), like the notched bar in the mockup. + for (int i = 1; i < PartyBarSegments; i++) + { + var dividerX = PartyBarX + PartyBarW * i / PartyBarSegments - 1.0; + g.beginFill(Ref.From(ref barBorder), Ref.From(ref fullAlpha)); + g.drawRect(dividerX, PartyBarY, 2.0, PartyBarH); + g.endFill(); + } + + // --- Name plate (bronze frame, chamfered pixel-art corners, navy inner). --- + int plateDark = 0x3A1B12; + g.beginFill(Ref.From(ref plateDark), Ref.From(ref fullAlpha)); + g.drawRect(4.0, 0.0, NamePlateW - 8.0, NamePlateH); + g.drawRect(0.0, 4.0, NamePlateW, NamePlateH - 8.0); + g.drawRect(2.0, 2.0, NamePlateW - 4.0, NamePlateH - 4.0); + g.endFill(); + + int plateBronze = 0xA44E32; + g.beginFill(Ref.From(ref plateBronze), Ref.From(ref fullAlpha)); + g.drawRect(6.0, 2.0, NamePlateW - 12.0, NamePlateH - 4.0); + g.drawRect(2.0, 6.0, NamePlateW - 4.0, NamePlateH - 12.0); + g.drawRect(4.0, 4.0, NamePlateW - 8.0, NamePlateH - 8.0); + g.endFill(); + + // Lighter bronze top edge for that lit-from-above look. + int plateBronzeLight = 0xC66B42; + g.beginFill(Ref.From(ref plateBronzeLight), Ref.From(ref fullAlpha)); + g.drawRect(6.0, 2.0, NamePlateW - 12.0, 2.0); + g.endFill(); + + // Navy inner window. + int plateInnerEdge = 0x2A3A5E; + g.beginFill(Ref.From(ref plateInnerEdge), Ref.From(ref fullAlpha)); + g.drawRect(7.0, 7.0, NamePlateW - 14.0, NamePlateH - 14.0); + g.endFill(); + + int plateInner = 0x1A2340; + g.beginFill(Ref.From(ref plateInner), Ref.From(ref fullAlpha)); + g.drawRect(8.0, 8.0, NamePlateW - 16.0, NamePlateH - 16.0); + g.endFill(); + } + catch + { + } + } private void ClearSlots() { if (_slots.Length == 0) return; + var removed = 0; for (int i = 0; i < _slots.Length; i++) { var slot = _slots[i]; @@ -373,10 +588,14 @@ private void ClearSlots() { toplib?.removeChild(slot.Container); slot.Container.remove(); + removed++; } catch { } _slots[i] = null; } + + if (removed > 0) + Log.Information("[NetMod][PartyHUD] cleared {Count} plate(s)", removed); } private void RemoveInactiveSlots() diff --git a/server/server.NetNode.Protocol.Incoming.cs b/server/server.NetNode.Protocol.Incoming.cs index 8f018e3..ec6fdce 100644 --- a/server/server.NetNode.Protocol.Incoming.cs +++ b/server/server.NetNode.Protocol.Incoming.cs @@ -2,6 +2,7 @@ using System.Text; using DeadCellsMultiplayerMod; using DeadCellsMultiplayerMod.Interaction; +using DeadCellsMultiplayerMod.AdvancedCoop; public sealed partial class NetNode { @@ -221,6 +222,27 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) return true; } + + if (line.StartsWith("LOBBYSTATE|", StringComparison.OrdinalIgnoreCase)) + { + var payload = line["LOBBYSTATE|".Length..]; + lock (_sync) _hasRemote = true; + CoopAdvancedHardening.ReceiveLobbyState(payload); + if (_role == NetRole.Host && senderId.HasValue) + forwardLine = line.EndsWith("\n", StringComparison.Ordinal) ? line : line + "\n"; + return true; + } + + if (line.StartsWith("RUNEPROG|", StringComparison.OrdinalIgnoreCase)) + { + var payload = line["RUNEPROG|".Length..]; + lock (_sync) _hasRemote = true; + CoopAdvancedHardening.ReceiveRuneProgress(payload); + if (_role == NetRole.Host && senderId.HasValue) + forwardLine = line.EndsWith("\n", StringComparison.Ordinal) ? line : line + "\n"; + return true; + } + if (line.StartsWith("HPMULT|")) { var payload = line["HPMULT|".Length..]; diff --git a/server/server.NetNode.SendPublic.cs b/server/server.NetNode.SendPublic.cs index 8ffdcd1..2a1caaa 100644 --- a/server/server.NetNode.SendPublic.cs +++ b/server/server.NetNode.SendPublic.cs @@ -722,6 +722,30 @@ public void SendInterPortal(double x, double y, string action) SendRaw($"INTERPORTAL|{action}|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}"); } + + public void SendLobbyState(string username, string levelId, int seed, string progressSignature) + { + if (!HasAnyConnection()) + return; + + var safeUser = (username ?? "guest").Replace("|", "/").Replace("\r", string.Empty).Replace("\n", string.Empty); + var safeLevel = (levelId ?? string.Empty).Replace("|", "/").Replace("\r", string.Empty).Replace("\n", string.Empty); + var safeProgress = (progressSignature ?? string.Empty).Replace("|", "/").Replace("\r", string.Empty).Replace("\n", string.Empty); + var idPart = ID > 0 ? ID.ToString(CultureInfo.InvariantCulture) : "0"; + SendRaw($"LOBBYSTATE|{idPart}|{safeUser}|{safeLevel}|{seed.ToString(CultureInfo.InvariantCulture)}|{safeProgress}"); + } + + public void SendRuneProgress(string csvPermanentIds) + { + if (!HasAnyConnection()) + return; + if (string.IsNullOrWhiteSpace(csvPermanentIds)) + return; + + var safe = csvPermanentIds.Replace("|", "/").Replace("\r", string.Empty).Replace("\n", string.Empty); + SendRaw($"RUNEPROG|{safe}"); + } + private void SendRaw(string payload) { var line = payload.EndsWith('\n') ? payload : payload + "\n";