";
+ }
+ }
+
+ 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";