From 9b81e80eee2754b6cb2b926fff39293404bc5eea Mon Sep 17 00:00:00 2001 From: realMiksy Date: Sun, 28 Jun 2026 14:59:19 +0300 Subject: [PATCH 1/5] Upload current mod source Added the current multiplayer mod source code, documentation, and project files. --- .github/ISSUE_TEMPLATE/bug_report.md | 57 + .github/ISSUE_TEMPLATE/feature_request.md | 27 + .github/pull_request_template.md | 44 + CHANGELOG.md | 22 + CODE_OF_CONDUCT.md | 37 + CONTRIBUTING.md | 89 +- CONTRIBUTING_ru.md | 61 - FakeDeath/FakeDeath.cs | 1296 +++++++++++++++--- Ghost/KingDiveAttackHooksBridge.cs | 70 +- Ghost/KingWeapon/KingWeaponHooksBridge.cs | 8 + GhostCine/GhostDead/DeadBase.cs | 83 +- GhostCine/GhostDead/RemoteDownedCorpse.cs | 186 +-- Interaction/InterSyncTypes.cs | 44 + Interaction/InteractionSync.cs | 422 +++++- LevelSync.cs | 68 + Mobs/MobWireCodec.cs | 7 + Mobs/MonsterSynchronization.Attacks.cs | 29 + Mobs/MonsterSynchronization.ClientApply.cs | 13 +- Mobs/MonsterSynchronization.ClientReceive.cs | 316 ++++- Mobs/MonsterSynchronization.Constants.cs | 7 + Mobs/MonsterSynchronization.DirtyQueue.cs | 29 + Mobs/MonsterSynchronization.ZeroHpCleanup.cs | 59 + Mobs/MonsterSynchronization.cs | 83 +- ModEntry/ModEntry.BossCine.cs | 268 +++- ModEntry/ModEntry.DebugHero.cs | 48 +- ModEntry/ModEntry.GhostSync.cs | 259 ++-- ModEntry/ModEntry.NetworkMenu.cs | 3 + ModEntry/ModEntry.StabilityGuards.cs | 19 + ModEntry/ModEntry.Steam.cs | 73 +- ModEntry/ModEntry.cs | 169 +-- NOTICE.md | 10 + README.md | 145 +- README_ru.md | 123 -- SECURITY.md | 35 + SteamP2P/SteamConnect.cs | 63 +- UI/GameMenu.ReviveInput.cs | 293 +++- UI/GameMenu.cs | 57 +- UI/GameMenuHooks.cs | 6 +- UI/LevelExitSync.cs | 325 ++++- UI/StuckRecoveryFailsafe.cs | 261 ++++ WorldSync/WorldObjectSync.cs | 474 +++++++ server/server.NetNode.Cleanup.cs | 2 + server/server.NetNode.Consume.cs | 16 + server/server.NetNode.Dispose.cs | 15 + server/server.NetNode.Parse.cs | 60 +- server/server.NetNode.Protocol.Incoming.cs | 56 +- server/server.NetNode.SendPublic.cs | 53 +- server/server.NetNode.Steam.cs | 18 +- server/server.cs | 15 + 49 files changed, 4711 insertions(+), 1212 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md delete mode 100644 CONTRIBUTING_ru.md create mode 100644 Mobs/MonsterSynchronization.ZeroHpCleanup.cs create mode 100644 ModEntry/ModEntry.StabilityGuards.cs create mode 100644 NOTICE.md delete mode 100644 README_ru.md create mode 100644 SECURITY.md create mode 100644 UI/StuckRecoveryFailsafe.cs create mode 100644 WorldSync/WorldObjectSync.cs 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..38c5c45 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +## v0.4.0 - Initial Community Fork + +### Added +- Community-maintained fork +- Project documentation +- Bug tracking +- Development roadmap + +### Fixed +- Improved synchronization +- World object synchronization +- Revive synchronization improvements +- Elite synchronization improvements +- Rune progression improvements +- Multiplayer stability improvements + +### Changed +- Repository renamed to DeadCellsCoopPlus +- Documentation rewritten +- Project organization improved 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/CONTRIBUTING.md b/CONTRIBUTING.md index 34566a0..4a0142b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,65 +1,66 @@ -
+# Contributing to DeadCellsCoopPlus -English • [Русский](CONTRIBUTING_ru.md) - -
-# Contributing to DeadCellsMultiplayerMod +Thanks for helping improve DeadCellsCoopPlus. -Thank you for your interest in improving this mod. This document describes how to build the project and what we expect from contributions. +This project is a community-maintained fork of the original Dead Cells Multiplayer Mod. The main goal is to improve multiplayer stability, synchronization, revive behavior, rune progression, and general co-op quality of life. -## Prerequisites +## Before contributing -- **Windows** (the project targets `net10.0` with `SupportedOSPlatform` Windows). -- **.NET SDK** compatible with **.NET 10** (see `TargetFramework` in `DeadCellsMultiplayerMod.csproj`). -- **Dead Cells** with **DCCM (Dead Cells Core Modding API)** installed for local testing. -- Optional: **`DCCM_MDK_ROOT`** environment variable pointing at your DCCM MDK/tools folder if you need Steamworks references (`Steamworks.NET`, `steam_api64.dll`) resolved via the paths in the project file. +Please make sure your change fits one of these goals: -## Build +- Fixing multiplayer bugs +- Improving synchronization +- Improving revive/downed-player behavior +- Fixing elite/rune progression issues +- Improving stability +- Improving documentation +- Adding safe quality-of-life features -From the repository root: +## How to contribute + +1. Fork the repository. +2. Create a new branch for your change. -```powershell -dotnet build -c Release +```bash +git checkout -b fix/revive-sync ``` -Output artifacts are produced under `bin/Release/net10.0/` (and the packaged mod layout as configured by the DCCM MDK targets). +3. Make your changes. +4. Test the mod in-game if possible. +5. Commit your changes with a clear message. -For iterative development with automatic install into your DCCM layout, use **Debug** configuration (`AutoInstallMod` is enabled for Debug in the csproj). +```bash +git commit -m "Fix revive body falling through floor" +``` -## Project layout (high level) +6. Push your branch. +7. Open a pull request. -- `ModEntry.cs` — mod entry point and lifecycle. -- `Mobs/` — mob synchronization, wire codecs, tracing. -- `Ghost/` — remote player ghost and related hooks. -- `UI/` — in-game UI. -- `Resourcefile/lang/` — localization (`.po` / `.pot`). -- `server/` — networking (`NetNode`, wire protocol). +## Pull request guidelines -## How to contribute -1. **Open an issue** or discuss a **small, scoped** change before large refactors. -2. **Fork** the repository and create a **branch** focused on one feature or fix. -3. **Keep pull requests focused** — avoid unrelated formatting, renames, or drive-by cleanups in files you are not changing for the task. -4. **Match existing style** — naming, patterns, and comment density should match surrounding code. -5. **Build** in Release before submitting; fix any new compiler warnings relevant to your change. -6. **Describe** what changed and **why** in the PR description (plain language, complete sentences). +Please include: -## Code review expectations +- What the change does +- What bug it fixes +- How you tested it +- Any known problems -- Changes should be **minimal** and **directly related** to the stated goal. -- Do **not** delete unrelated comments or rewrite large sections without need. -- Prefer **one clear code path** over many special cases when possible. -- New user-facing strings belong in **localization** (`Resourcefile/lang/`) when applicable. +## Bug reports -## Testing +When reporting bugs, include: -There is no automated test suite in this repository. For gameplay changes: +- Dead Cells version +- Mod version +- Host or client side +- Steps to reproduce +- What you expected to happen +- What actually happened +- Logs or screenshots if possible -- Run the game **through DCCM** with the mod loaded. -- For multiplayer, verify **host** and **client** behavior when you touch sync or networking code. +## Code style -## Questions +Try to keep changes small and focused. Avoid large rewrites unless they are necessary. -For DCCM installation and API documentation, see the official DCCM docs and GitHub: +## Credits -- [DCCM install (Steam Workshop)](https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/) -- [dead-cells-core-modding/core](https://github.com/dead-cells-core-modding/core) +Please respect the original project and keep credit to the original author. diff --git a/CONTRIBUTING_ru.md b/CONTRIBUTING_ru.md deleted file mode 100644 index 4ac0c1a..0000000 --- a/CONTRIBUTING_ru.md +++ /dev/null @@ -1,61 +0,0 @@ -# Участие в разработке DeadCellsMultiplayerMod - -Спасибо за интерес к улучшению мода. Здесь описано, как собрать проект и чего мы ожидаем от изменений в коде. - -## Требования - -- **Windows** (в проекте указано `SupportedOSPlatform` Windows, целевой фреймворк — `net10.0`). -- **.NET SDK**, совместимый с **.NET 10** (см. `TargetFramework` в `DeadCellsMultiplayerMod.csproj`). -- **Dead Cells** с установленным **DCCM (Dead Cells Core Modding API)** для локальной проверки. -- По желанию: переменная окружения **`DCCM_MDK_ROOT`** — путь к папке MDK/tools DCCM, если нужно подтянуть ссылки на **Steamworks.NET** и `steam_api64.dll` согласно настройкам в `.csproj`. - -## Сборка - -Из корня репозитория: - -```powershell -dotnet build -c Release -``` - -Артефакты сборки попадают в `bin/Release/net10.0/` (и в упакованный вид мода — согласно целям DCCM MDK). - -Для частой разработки с автоматической установкой в окружение DCCM используйте конфигурацию **Debug** (в `.csproj` для Debug включён `AutoInstallMod`). - -## Структура проекта (кратко) - -- `ModEntry.cs` — точка входа мода и жизненный цикл. -- `Mobs/` — синхронизация мобов, кодеки, трассировка. -- `Ghost/` — призрак удалённого игрока и связанные хуки. -- `UI/` — UI настроек в игре. -- `Resourcefile/lang/` — локализация (`.po` / `.pot`). -- `server/` — сеть (`NetNode`, протокол). - -## Как вносить вклад - -1. **Создайте issue** или заранее обсудите **небольшой по объёму** рефакторинг. -2. Сделайте **fork** репозитория и **ветку** под одну задачу или исправление. -3. **Держите PR сфокусированным** — без несвязанного форматирования, переименований и «попутной» уборки в чужих файлах. -4. **Соблюдайте стиль** окружающего кода — имена, паттерны, плотность комментариев. -5. Перед отправкой **соберите** проект в Release; устраните новые предупреждения компилятора, относящиеся к вашим правкам. -6. В описании PR **опишите**, что изменилось и **зачем** (понятным языком, полными предложениями). - -## Ожидания при ревью - -- Изменения должны быть **минимальными** и **по делу** задачи. -- **Не** удаляйте чужие комментарии и **не** переписывайте большие куски без необходимости. -- По возможности лучше **один понятный путь** в коде, чем множество особых случаев. -- Новые строки для пользователя — в **локализацию** (`Resourcefile/lang/`), где это уместно. - -## Тестирование - -В репозитории нет автоматических тестов. Для геймплейных изменений: - -- Запускайте игру **через DCCM** с загруженным модом. -- Для мультиплеера проверяйте поведение **хоста** и **клиента**, если менялись синхронизация или сеть. - -## Вопросы - -По установке DCCM и документации API см. официальные материалы: - -- [Установка DCCM (Steam Workshop)](https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/) -- [dead-cells-core-modding/core](https://github.com/dead-cells-core-modding/core) diff --git a/FakeDeath/FakeDeath.cs b/FakeDeath/FakeDeath.cs index e15a1ad..4650936 100644 --- a/FakeDeath/FakeDeath.cs +++ b/FakeDeath/FakeDeath.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Diagnostics; +using dc; using dc.en; using dc.tool.atk; using dc.tool.mainSkills; @@ -26,11 +27,56 @@ public partial class ModEntry private double _localDownedAnchorY; private const double DownedCorpseMaxDriftPx = 96.0; private const double DownedCorpseMaxDriftSq = DownedCorpseMaxDriftPx * DownedCorpseMaxDriftPx; + private bool _hasLocalReviveSafePosition; + private double _localReviveSafeX; + private double _localReviveSafeY; + private string _localReviveSafeLevelId = string.Empty; + private long _localReviveSafeTicks; + private bool _hasLocalReviveTeleporterPosition; + private double _localReviveTeleporterX; + private double _localReviveTeleporterY; + private string _localReviveTeleporterLevelId = string.Empty; + private long _localReviveTeleporterTicks; + private readonly List _localReviveSafeHistory = new(); + private const double ReviveSafePositionMaxAgeSeconds = 20.0; + private const double ReviveTeleporterMaxAgeSeconds = 900.0; + private const double ReviveSafeHistoryMinAgeSeconds = 1.25; + private const double ReviveSafeHistoryMaxAgeSeconds = 12.0; + private const int ReviveSafeHistoryMaxEntries = 36; + private const double DownedVoidRescueDropPx = 160.0; + private const double DownedUnsafeRescueCheckIntervalSeconds = 0.20; + private const double DownedSafeRescueLockSeconds = 6.0; + private const double DownedPermanentAnchorRefreshSeconds = 2.0; + private const double DownedParkedHeroYOffsetPx = 8.0; + private bool _localDownedHeroGravityWasCaptured; + private bool _localDownedHeroHadGravity; + private bool _localDownedHeroVisibilityWasCaptured; + private bool _localDownedHeroWasVisible; + private long _nextDownedSafeRescueCheckTicks; + private long _downedSafeRescueLockUntilTicks; + private double _downedSafeRescueLockX; + private double _downedSafeRescueLockY; private readonly HashSet _scratchRemoteActiveIds = new(); private readonly HashSet _scratchActiveCorpseIds = new(); private readonly List _scratchStaleRemoteIds = new(); private readonly List _scratchStaleCorpseIds = new(); + private readonly struct ReviveSafeAnchor + { + public readonly double X; + public readonly double Y; + public readonly string LevelId; + public readonly long Ticks; + + public ReviveSafeAnchor(double x, double y, string levelId, long ticks) + { + X = x; + Y = y; + LevelId = levelId ?? string.Empty; + Ticks = ticks; + } + } + private void Hook_Hero_onHeroDie(Hook_Hero.orig_onHeroDie orig, Hero self) { if (IsDebugImmortalLocalHero(self)) @@ -158,7 +204,26 @@ private void Hook_Hero_checkCursedWeaponHit(Hook_Hero.orig_checkCursedWeaponHit if (_localFakeDead) return; - orig(self, a); + // v5.8: cursed deaths are unsafe in vanilla multiplayer because the vanilla death + // flow can start controller/animation feedback cleanup before our fake-death hooks + // see it. If the hero appears cursed, go directly to fake death and never let the + // vanilla cursed-death path run. + if (IsHeroLikelyCursed(self)) + { + EnterLocalFakeDeath(self, net); + return; + } + + try + { + orig(self, a); + } + catch (Exception ex) + { + Logger.Warning(ex, "[NetMod][FakeDeath] Suppressed cursed death exception and entered fake death"); + EnterLocalFakeDeath(self, net); + return; + } if (_localFakeDead) return; @@ -174,6 +239,90 @@ private void Hook_Hero_checkCursedWeaponHit(Hook_Hero.orig_checkCursedWeaponHit orig(self, a); } + private static bool IsHeroLikelyCursed(Hero self) + { + if (self == null) + return false; + + try + { + var t = self.GetType(); + const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic; + + foreach (var f in t.GetFields(flags)) + { + if (!LooksLikeCurseMemberName(f.Name)) + continue; + if (MemberValueMeansCurseActive(f.GetValue(self))) + return true; + } + + foreach (var p in t.GetProperties(flags)) + { + if (!p.CanRead || !LooksLikeCurseMemberName(p.Name)) + continue; + if (p.GetIndexParameters().Length != 0) + continue; + if (MemberValueMeansCurseActive(p.GetValue(self))) + return true; + } + } + catch + { + } + + return false; + } + + private static bool LooksLikeCurseMemberName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return false; + return name.IndexOf("curse", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("cursed", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static bool MemberValueMeansCurseActive(object? value) + { + try + { + switch (value) + { + case null: + return false; + case bool b: + return b; + case byte v: + return v > 0; + case sbyte v: + return v > 0; + case short v: + return v > 0; + case ushort v: + return v > 0; + case int v: + return v > 0; + case uint v: + return v > 0; + case long v: + return v > 0; + case ulong v: + return v > 0; + case float v: + return v > 0; + case double v: + return v > 0; + } + } + catch + { + } + + return false; + } + private bool ShouldEnterFakeDeathFromEarlyDeathHook(Hero self, NetNode net) { if (self == null || net == null) @@ -369,6 +518,8 @@ private void UpdateFakeDeathFlow(double dt) return; } + TrackLocalReviveSafePosition(); + ContinueReviveRequestBurst(net); UpdateReviveHintsByProximity(); ProcessReviveHold(net); } @@ -401,6 +552,8 @@ private void ConsumeRemoteDownedStates(NetNode net) } _remoteDowned.Remove(state.UserId); + if (_reviveBurstTargetId == state.UserId) + ResetReviveBurst(); _downedAnnouncements.Remove(state.UserId); DisposeRemoteDownedCine(state.UserId); continue; @@ -500,9 +653,9 @@ private void ConsumeReviveRequests(NetNode net) if (req.TargetId != localId) continue; - if (_localDeadCine == null || !_localDeadCine.IsHomunculusNearCorpse(ReviveHomunculusBodyMaxDistancePx)) - continue; - + // v6.0: trust the reviver-side proximity/flask check. The downed player's + // local DeadBase/homunculus object can be missing or desynced after boss/DLC + // transitions, which made valid revive holds do nothing. ReviveLocalPlayer(net); return; } @@ -720,12 +873,169 @@ private bool HasAliveRemoteTeammate(NetNode net) return false; } + private void CaptureLocalDownedStats(Hero hero) + { + if (hero == null) + return; + + _localDownedStatsCaptured = true; + try { _localDownedSavedMaxLife = System.Math.Max(_localDownedSavedMaxLife, hero.maxLife); } catch { } + try { _localDownedSavedBrutalityTier = System.Math.Max(_localDownedSavedBrutalityTier, hero.brutalityTier); } catch { } + try { _localDownedSavedSurvivalTier = System.Math.Max(_localDownedSavedSurvivalTier, hero.survivalTier); } catch { } + try { _localDownedSavedTacticTier = System.Math.Max(_localDownedSavedTacticTier, hero.tacticTier); } catch { } + try { _localDownedSavedBonusLife = System.Math.Max(_localDownedSavedBonusLife, (int)System.Math.Round((double)hero.bonusLife)); } catch { } + + try + { + var data = hero._level?.game?.data; + if (data != null) + { + try { _localDownedSavedBrutalityTier = System.Math.Max(_localDownedSavedBrutalityTier, data.brutalityTier); } catch { } + try { _localDownedSavedSurvivalTier = System.Math.Max(_localDownedSavedSurvivalTier, data.survivalTier); } catch { } + try { _localDownedSavedTacticTier = System.Math.Max(_localDownedSavedTacticTier, data.tacticTier); } catch { } + } + } + catch { } + } + + private void RestoreLocalDownedStats(Hero hero) + { + if (hero == null || !_localDownedStatsCaptured) + return; + + try + { + if (_localDownedSavedMaxLife > 0 && hero.maxLife < _localDownedSavedMaxLife) + hero.maxLife = _localDownedSavedMaxLife; + } + catch { } + + try + { + if (_localDownedSavedBonusLife > 0 && hero.bonusLife < _localDownedSavedBonusLife) + hero.bonusLife = _localDownedSavedBonusLife; + } + catch { } + + try + { + if (_localDownedSavedBrutalityTier > 0 && hero.brutalityTier < _localDownedSavedBrutalityTier) + hero.brutalityTier = _localDownedSavedBrutalityTier; + if (_localDownedSavedSurvivalTier > 0 && hero.survivalTier < _localDownedSavedSurvivalTier) + hero.survivalTier = _localDownedSavedSurvivalTier; + if (_localDownedSavedTacticTier > 0 && hero.tacticTier < _localDownedSavedTacticTier) + hero.tacticTier = _localDownedSavedTacticTier; + } + catch { } + + try + { + var data = hero._level?.game?.data; + if (data != null) + { + if (_localDownedSavedBrutalityTier > 0 && data.brutalityTier < _localDownedSavedBrutalityTier) + data.brutalityTier = _localDownedSavedBrutalityTier; + if (_localDownedSavedSurvivalTier > 0 && data.survivalTier < _localDownedSavedSurvivalTier) + data.survivalTier = _localDownedSavedSurvivalTier; + if (_localDownedSavedTacticTier > 0 && data.tacticTier < _localDownedSavedTacticTier) + data.tacticTier = _localDownedSavedTacticTier; + } + } + catch { } + + try + { + if (_localDownedSavedMaxLife > 0 && hero.maxLife < _localDownedSavedMaxLife) + hero.maxLife = _localDownedSavedMaxLife; + } + catch { } + } + + private void ClearLocalDownedStatSnapshot() + { + _localDownedStatsCaptured = false; + _localDownedSavedMaxLife = 0; + _localDownedSavedBrutalityTier = 0; + _localDownedSavedSurvivalTier = 0; + _localDownedSavedTacticTier = 0; + _localDownedSavedBonusLife = 0; + } + + private static void SetLocalGameTimerPausedForRevive(bool paused) + { + // v6.3.5: do not freeze Dead Cells global game time while fake-dead. + // Using data.stopGameTime for revive pause can soft-lock the client after spike/void + // rescue because the local game keeps its cinematic/control state frozen. Keep the + // world running and only make sure older paused states are cleared. + try + { + var data = dc.pr.Game.Class.ME?.data; + if (data != null && data.stopGameTime) + data.stopGameTime = false; + } + catch { } + } + + private void ResetReviveBurst() + { + _reviveBurstTargetId = 0; + _reviveBurstUntilTicks = 0; + _nextReviveBurstSendTicks = 0; + } + + private void StartReviveRequestBurst(NetNode net, int targetId) + { + if (net == null || targetId <= 0) + return; + + var now = Stopwatch.GetTimestamp(); + _reviveBurstTargetId = targetId; + _reviveBurstUntilTicks = now + (long)(Stopwatch.Frequency * ReviveRequestBurstSeconds); + _nextReviveBurstSendTicks = 0; + SendReviveRequestBurstTick(net, now); + } + + private void ContinueReviveRequestBurst(NetNode net) + { + if (net == null || _reviveBurstTargetId <= 0) + return; + + var now = Stopwatch.GetTimestamp(); + if (_reviveBurstUntilTicks == 0 || now >= _reviveBurstUntilTicks) + { + ResetReviveBurst(); + return; + } + + if (!_remoteDowned.ContainsKey(_reviveBurstTargetId)) + { + ResetReviveBurst(); + return; + } + + SendReviveRequestBurstTick(net, now); + } + + private void SendReviveRequestBurstTick(NetNode net, long now) + { + if (_reviveBurstTargetId <= 0) + return; + if (_nextReviveBurstSendTicks != 0 && now < _nextReviveBurstSendTicks) + return; + + try { net.SendPlayerReviveRequest(_reviveBurstTargetId); } catch { } + _nextReviveBurstSendTicks = now + (long)(Stopwatch.Frequency * ReviveRequestBurstIntervalSeconds); + } + private void EnterLocalFakeDeath(Hero hero, NetNode net) { if (hero == null) return; ResetAllDownedGameOverState(); + ClearLocalDownedStatSnapshot(); + CaptureLocalDownedStats(hero); + SetLocalGameTimerPausedForRevive(true); _localFakeDead = true; _localExitPenaltyApplied = false; _localFakeDeadStartedTicks = Stopwatch.GetTimestamp(); @@ -756,6 +1066,7 @@ private void EnterLocalFakeDeath(Hero hero, NetNode net) _localDownedY = sprY; _localHeldX = _localDownedX; _localHeldY = _localDownedY; + RescueLocalDownedPositionIfUnsafe(hero, "enter_fake_death", force: true); _localDownedAnchorX = _localDownedX; _localDownedAnchorY = _localDownedY; _hasLocalDownedAnchor = true; @@ -775,8 +1086,7 @@ private void EnterLocalFakeDeath(Hero hero, NetNode net) try { hero.cancelVelocities(); } catch { } try { hero.lockControlsS(10.0); } catch { } try { hero.cancelSkillControlLock(); } catch { } - SnapHeroToDownedPosition(hero, _localDownedX, _localDownedY, clampToGround: false); - StartLocalDeadCine(hero); + ForceParkLocalDownedHero(hero, clampToGround: true); SendLocalDownedState(net, isDowned: true, force: true); } @@ -786,6 +1096,8 @@ private void MaintainLocalFakeDeath(NetNode net) if (!_localFakeDead || me == null) return; + SetLocalGameTimerPausedForRevive(true); + try { if (me.life <= 0) @@ -795,8 +1107,8 @@ private void MaintainLocalFakeDeath(NetNode net) { } - if (_localDeadCine == null) - StartLocalDeadCine(me); + // v6.4.6: no local death cinematic/corpse while downed. The hidden hero is parked + // at one safe anchor; this prevents the vanilla death body from falling through the map. if (!HasAliveRemoteTeammate(net)) { @@ -820,13 +1132,13 @@ private void MaintainLocalFakeDeath(NetNode net) try { me.cancelSkillControlLock(); } catch { } try { me._targetable = false; } catch { } - var cine = _localDeadCine; - if (cine != null && cine.TryGetCorpsePixelPosition(out var corpseX, out var corpseY)) - { - TryUpdateDownedPositionFromCorpse(corpseX, corpseY); - } + // v6.4.7: once fake-dead, keep one authoritative anchor until revive/reset. + // Do not let any corpse/hero physics update become the new revive position. + if (!MaintainDownedSafeRescueLock()) + RescueLocalDownedPositionIfUnsafe(me, "maintain_fake_death", force: false); + ReassertLocalDownedAnchor(me); - SnapHeroToDownedPosition(me, _localHeldX, _localHeldY, clampToGround: false); + ForceParkLocalDownedHero(me, clampToGround: true); SendLocalDownedState(net, isDowned: true, force: false); } @@ -847,6 +1159,163 @@ private void MaintainPostRevivePositionLock() SnapHeroToDownedPosition(me, _postReviveLockX, _postReviveLockY); } + private void ForceParkLocalDownedHero(Hero hero, bool clampToGround = true) + { + if (hero == null) + return; + + CaptureLocalDownedHeroRuntimeState(hero); + + try + { + if (hero.life <= 0) + hero.life = 1; + } + catch { } + + try { hero._targetable = false; } catch { } + try { hero.visible = false; } catch { } + TrySetHeroHeadVisible(hero, false); + + try { hero.dx = 0; } catch { } + try { hero.dy = 0; } catch { } + try { hero.bdx = 0; } catch { } + try { hero.bdy = 0; } catch { } + try { hero.hasGravity = false; } catch { } + try { hero.cancelVelocities(); } catch { } + try { hero.cancelSkillControlLock(); } catch { } + try { hero.lockControlsS(0.35); } catch { } + + var x = _localHeldX; + var y = _localHeldY - DownedParkedHeroYOffsetPx; + try { hero.setPosPixel(x, y); } catch { } + try { ForceSetHeroCaseFromPixel(hero, x, y); } catch { } + if (clampToGround) + SnapHeroToDownedPosition(hero, x, y, clampToGround: true); + + try { hero.cancelVelocities(); } catch { } + try { hero.dx = 0; } catch { } + try { hero.dy = 0; } catch { } + try { hero.bdx = 0; } catch { } + try { hero.bdy = 0; } catch { } + } + + private void CaptureLocalDownedHeroRuntimeState(Hero hero) + { + if (hero == null) + return; + + if (!_localDownedHeroGravityWasCaptured) + { + try { _localDownedHeroHadGravity = hero.hasGravity; } + catch { _localDownedHeroHadGravity = true; } + _localDownedHeroGravityWasCaptured = true; + } + + if (!_localDownedHeroVisibilityWasCaptured) + { + try { _localDownedHeroWasVisible = hero.visible; } + catch { _localDownedHeroWasVisible = true; } + _localDownedHeroVisibilityWasCaptured = true; + } + } + + private void RestoreLocalDownedHeroRuntimeState(Hero? hero) + { + if (hero != null) + { + try { hero.hasGravity = _localDownedHeroGravityWasCaptured ? _localDownedHeroHadGravity : true; } catch { } + try { hero.visible = _localDownedHeroVisibilityWasCaptured ? _localDownedHeroWasVisible : true; } catch { } + TrySetHeroHeadVisible(hero, true); + try { hero._targetable = true; } catch { } + } + + _localDownedHeroGravityWasCaptured = false; + _localDownedHeroHadGravity = true; + _localDownedHeroVisibilityWasCaptured = false; + _localDownedHeroWasVisible = true; + } + + private static void TrySetHeroHeadVisible(Hero hero, bool visible) + { + if (hero == null) + return; + + try + { + var head = hero.heroHead; + if (head == null) + return; + + try { head.customHeadSpr?.set_visible(visible); } catch { } + try { head.customBackSpr?.set_visible(visible); } catch { } + try { head.headNormalSb?.set_visible(visible); } catch { } + try { head.headAddSb?.set_visible(visible); } catch { } + try { head.eye?.set_visible(visible); } catch { } + // Leave headBlack untouched; sprite visibility is enough and avoids changing + // custom-head state after revive. + } + catch + { + } + } + + private void ReassertLocalDownedAnchor(Hero hero) + { + if (hero == null || !_localFakeDead) + return; + + if (!_hasLocalDownedAnchor) + { + _localDownedAnchorX = _localHeldX; + _localDownedAnchorY = _localHeldY; + _hasLocalDownedAnchor = double.IsFinite(_localDownedAnchorX) && double.IsFinite(_localDownedAnchorY); + } + + if (!_hasLocalDownedAnchor) + return; + + _localDownedX = _localDownedAnchorX; + _localDownedY = _localDownedAnchorY; + _localHeldX = _localDownedAnchorX; + _localHeldY = _localDownedAnchorY; + _downedSafeRescueLockX = _localDownedAnchorX; + _downedSafeRescueLockY = _localDownedAnchorY; + if (_downedSafeRescueLockUntilTicks == 0) + _downedSafeRescueLockUntilTicks = Stopwatch.GetTimestamp() + (long)(Stopwatch.Frequency * DownedPermanentAnchorRefreshSeconds); + + try { hero.cancelVelocities(); } catch { } + try { hero.dx = 0; } catch { } + try { hero.dy = 0; } catch { } + try { hero.bdx = 0; } catch { } + try { hero.bdy = 0; } catch { } + try { hero.hasGravity = false; } catch { } + try { hero._targetable = false; } catch { } + try { hero.visible = false; } catch { } + TrySetHeroHeadVisible(hero, false); + try { hero.setPosPixel(_localDownedAnchorX, _localDownedAnchorY - DownedParkedHeroYOffsetPx); } catch { } + try { ForceSetHeroCaseFromPixel(hero, _localDownedAnchorX, _localDownedAnchorY - DownedParkedHeroYOffsetPx); } catch { } + } + + private static void ForceSetHeroCaseFromPixel(Hero hero, double x, double y) + { + if (hero == null || !double.IsFinite(x) || !double.IsFinite(y)) + return; + + try + { + var cx = (int)System.Math.Floor(x / 24.0); + var cy = (int)System.Math.Floor(y / 24.0); + var xr = (x / 24.0) - cx; + var yr = (y / 24.0) - cy; + if (double.IsFinite(xr) && double.IsFinite(yr)) + hero.setPosCase(cx, cy, xr, yr); + } + catch + { + } + } + private static void SnapHeroToDownedPosition(Hero hero, double x, double y, bool clampToGround = true) { if (hero == null) @@ -884,6 +1353,9 @@ private void ReviveLocalPlayer(NetNode net) ResetAllDownedGameOverState(); var hero = me; + RestoreLocalDownedStats(hero); + SetLocalGameTimerPausedForRevive(false); + RestoreLocalDownedHeroRuntimeState(hero); _localFakeDead = false; _localExitPenaltyApplied = false; _localFakeDeadStartedTicks = 0; @@ -912,10 +1384,13 @@ private void ReviveLocalPlayer(NetNode net) _localHeldX = _postReviveLockX; _localHeldY = _postReviveLockY; - try { hero.cancelVelocities(); } catch { } - try { hero.cancelSkillControlLock(); } catch { } - try { hero.unlockControls(); } catch { } - try { hero._targetable = true; } catch { } + if (IsHeroRuntimeSafeForControlUnlock(hero)) + { + try { hero.cancelVelocities(); } catch { } + try { hero.cancelSkillControlLock(); } catch { } + try { hero.unlockControls(); } catch { } + try { hero._targetable = true; } catch { } + } try { @@ -933,92 +1408,28 @@ private void ReviveLocalPlayer(NetNode net) try { hero.fullHeal(); } catch { } } + try { net.SendHP(hero.life, hero.maxLife, hero.life, hero.bonusLife, hero.radius); } catch { } SendLocalDownedState(net, isDowned: false, force: true); + ClearLocalDownedStatSnapshot(); } private void ApplyLocalDownedExitPenaltyIfNeededCore() { - if (!_localFakeDead || _localExitPenaltyApplied || me == null) - return; - + // v6.3: no stat/HP penalty while downed. The old penalty removed + // Brutality/Survival/TacticUp items during auto-follow/exit failsafe, + // which made revived players drop back to base HP after being carried + // through doors or boss exits. _localExitPenaltyApplied = true; - var hero = me; - - try { hero.spdComboKills = 0; } catch { } - try { hero.perfectKillsCount = 0; } catch { } - try { hero.goldCombo = 0; } catch { } + } - try - { - var data = hero._level?.game?.data; - if (data != null) - { - data.killCount = 0; - data.corruptedHealingKillCount = 0; - } - } - catch + private void ProcessReviveHold(NetNode net) + { + if (me == null || _remoteDowned.Count == 0) { - } - - try - { - bool noStats = true; - hero.tryToSubstractMoney(int.MaxValue, Ref.From(ref noStats)); - } - catch - { - try - { - var data = hero._level?.game?.data; - if (data != null) - data.money = 0; - hero.hudSetMoney(0); - } - catch - { - } - } - - try - { - var inventory = hero.inventory; - if (inventory != null) - { - inventory.removeAll("BrutalityUp".AsHaxeString()); - inventory.removeAll("SurvivalUp".AsHaxeString()); - inventory.removeAll("TacticUp".AsHaxeString()); - } - } - catch - { - } - - try { hero.computeTiers(); } catch { } - - try - { - var data = hero._level?.game?.data; - if (data != null) - { - data.money = 0; - data.brutalityTier = hero.brutalityTier; - data.survivalTier = hero.survivalTier; - data.tacticTier = hero.tacticTier; - } - } - catch - { - } - } - - private void ProcessReviveHold(NetNode net) - { - if (me == null || _remoteDowned.Count == 0) - { - ResetReviveHold(); - ClearReviveHints(); - return; + ResetReviveHold(); + ResetReviveBurst(); + ClearReviveHints(); + return; } var isHoldPressed = GameMenu.IsReviveHoldInputDown(me); @@ -1062,8 +1473,9 @@ private void ProcessReviveHold(NetNode net) return; } - net.SendPlayerReviveRequest(nearest.UserId); + StartReviveRequestBurst(net, nearest.UserId); _nextReviveAttemptTicks = now + (long)(Stopwatch.Frequency * ReviveAttemptCooldownSeconds); + try { MultiplayerUI.PushSystemMessage(FormatLocalized("Reviving player..."), 2.0, 0.3); } catch { } ResetReviveHold(); ClearReviveHints(); } @@ -1115,15 +1527,8 @@ private void UpdateReviveHintsByProximity() if (distSq > ReviveUseDistancePx * ReviveUseDistancePx) continue; - if (state.HasHeadPosition) - { - var hdx = state.HeadX - state.X; - var hdy = state.HeadY - state.Y; - var headBodyDistSq = hdx * hdx + hdy * hdy; - var maxHeadBodySq = ReviveHomunculusBodyMaxDistancePx * ReviveHomunculusBodyMaxDistancePx * 16.0; - if (headBodyDistSq > maxHeadBodySq) - continue; - } + // v6.0: do not reject revive because the downed player's homunculus/head + // position is stale. The body position is the authoritative revive target. if (distSq < bestDistSq) { @@ -1227,21 +1632,9 @@ private void SendLocalDownedState(NetNode net, bool isDowned, bool force) double? headX = null; double? headY = null; string? headAnim = null; - if (isDowned && _localDeadCine != null && _localDeadCine.TryGetHomunculusPixelPosition(out var hx, out var hy)) - { - headX = hx; - headY = hy; - _localDeadCine.TryGetHomunculusAnim(out headAnim); - } var now = Stopwatch.GetTimestamp(); var resend = (long)(Stopwatch.Frequency * DownedStateResendSeconds); - if (isDowned && headX.HasValue && headY.HasValue) - { - var fastResend = (long)(Stopwatch.Frequency * DownedHeadStateResendSeconds); - if (fastResend > 0 && (resend <= 0 || fastResend < resend)) - resend = fastResend; - } if (!force && _nextDownedStateSendTicks != 0 && now < _nextDownedStateSendTicks) return; @@ -1275,20 +1668,10 @@ private string GetCurrentLevelId() private void StartLocalDeadCine(Hero hero) { - if (hero == null) - return; - - if (_localDeadCine != null) - return; - - try - { - _localDeadCine = new DeadBase(hero, ModEntry.GetPrimaryClient()); - } - catch - { - _localDeadCine = null; - } + // v6.4.6: intentionally disabled. Even with HeroDeadCorpse creation disabled in + // DeadBase, entering the vanilla ghost-death cinematic can still leave a physical + // body/target anchor that falls through floors and can trigger duplicated drops. + _localDeadCine = null; } private void StopLocalDeadCine() @@ -1310,6 +1693,7 @@ private void ResetFakeDeathState( { ResetAllDownedGameOverState(); var wasFakeDead = _localFakeDead; + RestoreLocalDownedHeroRuntimeState(me); _localFakeDead = false; _localExitPenaltyApplied = false; _localFakeDeadStartedTicks = 0; @@ -1324,9 +1708,16 @@ private void ResetFakeDeathState( _postReviveLockUntilTicks = 0; _postReviveLockX = 0; _postReviveLockY = 0; + _downedSafeRescueLockUntilTicks = 0; + _downedSafeRescueLockX = 0; + _downedSafeRescueLockY = 0; + SetLocalGameTimerPausedForRevive(false); + ResetReviveBurst(); + ClearLocalDownedStatSnapshot(); _hasLocalDownedAnchor = false; _localDownedAnchorX = 0; _localDownedAnchorY = 0; + _nextDownedSafeRescueCheckTicks = 0; ResetReviveHold(); ClearReviveHints(); if (clearRemoteDownedTracking) @@ -1343,11 +1734,11 @@ private void ResetFakeDeathState( } } - if (unlockLocalHero && me != null) + if (unlockLocalHero && IsHeroRuntimeSafeForControlUnlock(me)) { - try { me.cancelSkillControlLock(); } catch { } - try { me.unlockControls(); } catch { } - try { me._targetable = true; } catch { } + try { me!.cancelSkillControlLock(); } catch { } + try { me!.unlockControls(); } catch { } + try { me!._targetable = true; } catch { } } if (sendNetworkUpState && wasFakeDead && _net != null && _netRole != NetRole.None) @@ -1370,9 +1761,6 @@ private void HandleAllPlayersDowned(NetNode net) { } - if (_localDeadCine == null) - StartLocalDeadCine(me); - var now = Stopwatch.GetTimestamp(); if (!_allDownedGameOverShown) { @@ -1386,12 +1774,8 @@ private void HandleAllPlayersDowned(NetNode net) try { me.cancelSkillControlLock(); } catch { } try { me._targetable = false; } catch { } - var cine = _localDeadCine; - if (cine != null && cine.TryGetCorpsePixelPosition(out var corpseX, out var corpseY)) - { - TryUpdateDownedPositionFromCorpse(corpseX, corpseY); - } - SnapHeroToDownedPosition(me, _localHeldX, _localHeldY, clampToGround: false); + RescueLocalDownedPositionIfUnsafe(me, "all_downed_fake_death", force: false); + ForceParkLocalDownedHero(me, clampToGround: true); SendLocalDownedState(net, isDowned: true, force: false); if (_allDownedRestartQueued || _netRole != NetRole.Host) @@ -1400,8 +1784,13 @@ private void HandleAllPlayersDowned(NetNode net) if (_allDownedRestartAtTicks != 0 && now < _allDownedRestartAtTicks) return; + // v5.9: do not auto-call launchGame/newGame while both players are in the + // fake-death/game-over state. The current DCCM/Hashlink build can hit an + // AccessViolation in User.newGame/GC during that transition. Stop the session + // cleanly and let the host start a fresh multiplayer run from the menu instead. _allDownedRestartQueued = true; - GameMenu.QueueHostRestartFromDeath("all_players_downed"); + try { MultiplayerUI.PushSystemMessage("Both players are down. Multiplayer stopped safely; start a new run from the menu."); } catch { } + try { StopNetworkFromMenu(); } catch { } } private void ShowAllDownedGameOverLogo() @@ -1437,6 +1826,28 @@ private void ShowAllDownedGameOverLogo() } } + private static bool IsHeroRuntimeSafeForControlUnlock(Hero? hero) + { + if (hero == null) + return false; + + try + { + if (hero.destroyed || hero._level == null || hero._level.destroyed) + return false; + if (hero._level.game == null || hero._level.game.destroyed) + return false; + if (dc.pr.Game.Class.ME == null) + return false; + } + catch + { + return false; + } + + return true; + } + private static string Localize(string message) { return GetText.Instance.GetString(message); @@ -1462,30 +1873,591 @@ private void ResetAllDownedGameOverState() _allDownedRestartAtTicks = 0; } - private bool TryUpdateDownedPositionFromCorpse(double corpseX, double corpseY) + private void TrackLocalReviveSafePosition() { - if (!double.IsFinite(corpseX) || !double.IsFinite(corpseY)) + var hero = me; + if (hero == null || _localFakeDead) + return; + + if (!TryGetSafeReviveAnchor(hero, out var x, out var y)) + return; + + _hasLocalReviveSafePosition = true; + _localReviveSafeX = x; + _localReviveSafeY = y; + _localReviveSafeLevelId = GetCurrentLevelId(); + _localReviveSafeTicks = Stopwatch.GetTimestamp(); + RecordLocalReviveSafeHistory(x, y, _localReviveSafeLevelId, _localReviveSafeTicks); + } + + private bool RescueLocalDownedPositionIfUnsafe(Hero hero, string reason, bool force, double? proposedX = null, double? proposedY = null) + { + if (hero == null) return false; - if (!_hasLocalDownedAnchor) + var now = Stopwatch.GetTimestamp(); + if (!force && _nextDownedSafeRescueCheckTicks != 0 && now < _nextDownedSafeRescueCheckTicks) + return false; + _nextDownedSafeRescueCheckTicks = now + (long)(Stopwatch.Frequency * DownedUnsafeRescueCheckIntervalSeconds); + + var checkX = proposedX ?? _localHeldX; + var checkY = proposedY ?? _localHeldY; + if (!ShouldUseSavedSafeDownedPosition(hero, checkX, checkY, force)) + return false; + + if (!TryGetBestDownedRescuePoint(hero, out var safeX, out var safeY, out var source)) + return false; + + _localDownedX = safeX; + _localDownedY = safeY; + _localHeldX = safeX; + _localHeldY = safeY; + _localDownedAnchorX = safeX; + _localDownedAnchorY = safeY; + _hasLocalDownedAnchor = true; + _downedSafeRescueLockX = safeX; + _downedSafeRescueLockY = safeY; + _downedSafeRescueLockUntilTicks = Stopwatch.GetTimestamp() + (long)(Stopwatch.Frequency * DownedSafeRescueLockSeconds); + _nextDownedStateSendTicks = 0; + + StabilizeLocalDownedAfterSafeRescue(hero, reason); + + try { - _localDownedAnchorX = _localDownedX; - _localDownedAnchorY = _localDownedY; - _hasLocalDownedAnchor = true; + Logger.Information("[NetMod][Revive] Moved downed body to safe revive point reason={Reason} source={Source} x={X:0.0} y={Y:0.0}", reason, source, safeX, safeY); } + catch + { + } + + return true; + } + + private void StabilizeLocalDownedAfterSafeRescue(Hero hero, string reason) + { + if (hero == null) + return; + + // The safe-position rescue can happen while the player is dying in spikes/void and + // while vanilla death/hazard states still hold velocities or cinematic locks. Clear + // those transient states without reviving the player; they remain fake-dead/downed. + try { hero.cancelVelocities(); } catch { } + try { hero.cancelSkillControlLock(); } catch { } + try { hero.lockControlsS(0.25); } catch { } + try { hero._targetable = false; } catch { } + try + { + var data = dc.pr.Game.Class.ME?.data; + if (data != null && data.stopGameTime) + data.stopGameTime = false; + } + catch { } + + ForceParkLocalDownedHero(hero, clampToGround: true); + } - var dx = corpseX - _localDownedAnchorX; - var dy = corpseY - _localDownedAnchorY; - var distSq = dx * dx + dy * dy; - if (distSq > DownedCorpseMaxDriftSq) + private bool MaintainDownedSafeRescueLock() + { + if (!_localFakeDead || me == null || _downedSafeRescueLockUntilTicks == 0) return false; - _localDownedX = corpseX; - _localDownedY = corpseY; - _localHeldX = _localDownedX; - _localHeldY = _localDownedY; - _localDownedAnchorX = corpseX; - _localDownedAnchorY = corpseY; + var now = Stopwatch.GetTimestamp(); + // v6.4.7: do not expire the safe anchor while the player is downed. The previous + // 6 second expiry let vanilla falling/corpse physics become authoritative again, + // which caused the marker to sink through floors and trigger duplicate drops. + if (now >= _downedSafeRescueLockUntilTicks) + _downedSafeRescueLockUntilTicks = now + (long)(Stopwatch.Frequency * DownedPermanentAnchorRefreshSeconds); + + _localDownedX = _downedSafeRescueLockX; + _localDownedY = _downedSafeRescueLockY; + _localHeldX = _downedSafeRescueLockX; + _localHeldY = _downedSafeRescueLockY; + _localDownedAnchorX = _downedSafeRescueLockX; + _localDownedAnchorY = _downedSafeRescueLockY; + _hasLocalDownedAnchor = true; + + try { me.cancelVelocities(); } catch { } + try { me.lockControlsS(0.25); } catch { } + try { me._targetable = false; } catch { } + ForceParkLocalDownedHero(me, clampToGround: true); + return true; + } + + private void TrySnapLocalDeadCineToHeldPosition() + { + // v6.4.6: local death cinematic is disabled. The hidden hero is parked directly. + } + + private bool ShouldUseSavedSafeDownedPosition(Hero hero, double x, double y, bool force) + { + if (hero == null) + return false; + if (!double.IsFinite(x) || !double.IsFinite(y)) + return true; + if (force && !IsPixelPositionReviveAccessible(hero, x, y)) + return true; + + if (_hasLocalReviveSafePosition) + { + if (IsLocalReviveSafePositionFresh() && IsLocalReviveSafePositionForCurrentLevel()) + { + if (y > _localReviveSafeY + DownedVoidRescueDropPx) + return true; + } + } + + if (!IsPixelPositionReviveAccessible(hero, x, y)) + return true; + + return false; + } + + private bool TryGetBestDownedRescuePoint(Hero hero, out double x, out double y, out string source) + { + // v6.3.6: Prefer the last actually visited teleporter/fast-travel point. + // Trap floors such as spikes can look like valid ground, so the most recent + // generic safe-ground sample is not always safe enough for revive placement. + if (TryGetLastVisitedTeleporterRevivePoint(hero, out x, out y)) + { + source = "last_teleporter"; + return true; + } + + // If there is no teleporter yet, use an older safe-ground history point instead + // of the latest frame. This avoids saving the exact spike/trap tile during the + // death frame and moving the downed body back into the hazard. + if (TryGetAgedLocalReviveSafePosition(hero, out x, out y)) + { + source = "aged_safe_position"; + return true; + } + + if (TryGetNearestRemotePlayerPixel(hero, out x, out y)) + { + y -= LocalReviveBodyYOffsetPx; + source = "teammate_position"; + return true; + } + + if (TryGetSafeReviveAnchor(hero, out x, out y)) + { + source = "current_ground"; + return true; + } + + source = string.Empty; + x = 0; + y = 0; + return false; + } + + public void RememberLocalReviveTeleporterPosition(double x, double y) + { + if (!double.IsFinite(x) || !double.IsFinite(y)) + return; + + var hero = me; + if (hero == null) + return; + + try + { + if (hero._level == null || hero._level.destroyed) + return; + } + catch + { + return; + } + + // Put the revive body slightly above the teleporter platform so it does not sink + // into the teleporter entity or nearby hazard floor. + _hasLocalReviveTeleporterPosition = true; + _localReviveTeleporterX = x; + _localReviveTeleporterY = y - LocalReviveBodyYOffsetPx; + _localReviveTeleporterLevelId = GetCurrentLevelId(); + _localReviveTeleporterTicks = Stopwatch.GetTimestamp(); + RecordLocalReviveSafeHistory(_localReviveTeleporterX, _localReviveTeleporterY, _localReviveTeleporterLevelId, _localReviveTeleporterTicks); + } + + private void RecordLocalReviveSafeHistory(double x, double y, string levelId, long ticks) + { + if (!double.IsFinite(x) || !double.IsFinite(y) || ticks <= 0) + return; + + if (_localReviveSafeHistory.Count > 0) + { + var last = _localReviveSafeHistory[_localReviveSafeHistory.Count - 1]; + var dx = last.X - x; + var dy = last.Y - y; + if (dx * dx + dy * dy < 48.0 * 48.0 && + ticks - last.Ticks < (long)(Stopwatch.Frequency * 0.5)) + { + return; + } + } + + _localReviveSafeHistory.Add(new ReviveSafeAnchor(x, y, levelId, ticks)); + if (_localReviveSafeHistory.Count > ReviveSafeHistoryMaxEntries) + _localReviveSafeHistory.RemoveRange(0, _localReviveSafeHistory.Count - ReviveSafeHistoryMaxEntries); + } + + private bool TryGetLastVisitedTeleporterRevivePoint(Hero hero, out double x, out double y) + { + x = 0; + y = 0; + if (hero == null || !_hasLocalReviveTeleporterPosition || _localReviveTeleporterTicks == 0) + return false; + + var now = Stopwatch.GetTimestamp(); + if (now - _localReviveTeleporterTicks > (long)(Stopwatch.Frequency * ReviveTeleporterMaxAgeSeconds)) + return false; + if (!IsAnchorLevelCurrent(_localReviveTeleporterLevelId)) + return false; + if (!IsPixelPositionReviveAccessible(hero, _localReviveTeleporterX, _localReviveTeleporterY)) + return false; + + x = _localReviveTeleporterX; + y = _localReviveTeleporterY; + return true; + } + + private bool TryGetAgedLocalReviveSafePosition(Hero hero, out double x, out double y) + { + x = 0; + y = 0; + if (hero == null) + return false; + + var now = Stopwatch.GetTimestamp(); + var minAge = (long)(Stopwatch.Frequency * ReviveSafeHistoryMinAgeSeconds); + var maxAge = (long)(Stopwatch.Frequency * ReviveSafeHistoryMaxAgeSeconds); + + for (var i = _localReviveSafeHistory.Count - 1; i >= 0; i--) + { + var anchor = _localReviveSafeHistory[i]; + var age = now - anchor.Ticks; + if (age < minAge) + continue; + if (age > maxAge) + break; + if (!IsAnchorLevelCurrent(anchor.LevelId)) + continue; + if (!IsPixelPositionReviveAccessible(hero, anchor.X, anchor.Y)) + continue; + + x = anchor.X; + y = anchor.Y; + return true; + } + + // Backwards-compatible fallback for old sessions that have no history yet. + // Only use it if it is not from the immediate death frame. + if (_hasLocalReviveSafePosition && IsLocalReviveSafePositionFresh() && IsLocalReviveSafePositionForCurrentLevel()) + { + var age = now - _localReviveSafeTicks; + if (age >= minAge && IsPixelPositionReviveAccessible(hero, _localReviveSafeX, _localReviveSafeY)) + { + x = _localReviveSafeX; + y = _localReviveSafeY; + return true; + } + } + + return false; + } + + private bool IsAnchorLevelCurrent(string levelId) + { + var current = GetCurrentLevelId(); + if (string.IsNullOrWhiteSpace(levelId) || string.IsNullOrWhiteSpace(current)) + return true; + return string.Equals(levelId, current, StringComparison.Ordinal); + } + + private bool IsLocalReviveSafePositionFresh() + { + if (!_hasLocalReviveSafePosition || _localReviveSafeTicks == 0) + return false; + var ageTicks = Stopwatch.GetTimestamp() - _localReviveSafeTicks; + return ageTicks >= 0 && ageTicks <= (long)(Stopwatch.Frequency * ReviveSafePositionMaxAgeSeconds); + } + + private bool IsLocalReviveSafePositionForCurrentLevel() + { + var current = GetCurrentLevelId(); + if (string.IsNullOrWhiteSpace(_localReviveSafeLevelId) || string.IsNullOrWhiteSpace(current)) + return true; + return string.Equals(_localReviveSafeLevelId, current, StringComparison.Ordinal); + } + + private bool TryGetSafeReviveAnchor(Hero hero, out double x, out double y) + { + x = 0; + y = 0; + if (hero == null) + return false; + + try + { + if (hero.destroyed || hero._level == null || hero._level.destroyed || hero.spr == null) + return false; + if (!double.IsFinite(hero.spr.x) || !double.IsFinite(hero.spr.y)) + return false; + if (!IsPixelPositionReviveAccessible(hero, hero.spr.x, hero.spr.y)) + return false; + + x = hero.spr.x; + y = hero.spr.y; + return true; + } + catch + { + return false; + } + } + + private bool IsPixelPositionReviveAccessible(Hero hero, double pixelX, double pixelY) + { + if (hero == null || !double.IsFinite(pixelX) || !double.IsFinite(pixelY)) + return false; + + try + { + var level = hero._level; + var map = level?.map; + if (level == null || level.destroyed || map == null) + return false; + + var tx = pixelX / 24.0; + var ty = pixelY / 24.0; + var cx = (int)System.Math.Floor(tx); + var cy = (int)System.Math.Floor(ty); + var xr = tx - cx; + var yr = ty - cy; + if (!double.IsFinite(xr) || !double.IsFinite(yr)) + return false; + + var probeXr = xr; + var probeYr = yr; + var groundYr = map.getGroundYr(cx, cy, Ref.From(ref probeXr), Ref.From(ref probeYr)); + if (!double.IsFinite(groundYr)) + return false; + + // getGroundYr returns the closest floor fractional Y for this cell. If the body is + // many cells below the found ground, it is probably falling into void/out-of-bounds. + if (yr > groundYr + 2.25) + return false; + + // Spike/trap floors can still look like valid ground to getGroundYr. Reject obvious + // nearby hazard entities so the downed body does not get anchored back into the trap + // that killed the player. + if (IsPixelNearLikelyReviveHazard(hero, pixelX, pixelY)) + return false; + + return true; + } + catch + { + return false; + } + } + + private bool IsPixelNearLikelyReviveHazard(Hero hero, double pixelX, double pixelY) + { + if (hero == null || !double.IsFinite(pixelX) || !double.IsFinite(pixelY)) + return true; + + try + { + var level = hero._level; + var elements = level?.listCurrentQuadElements; + if (level == null || level.destroyed || elements == null) + return false; + + const double hazardRadiusPx = 72.0; + const double hazardRadiusSq = hazardRadiusPx * hazardRadiusPx; + + for (var i = 0; i < elements.length; i++) + { + object? raw; + try { raw = elements.getDyn(i); } catch { continue; } + if (raw is not dc.Entity entity) + continue; + + if (!IsLikelyReviveHazardEntity(entity)) + continue; + + if (!TryGetEntityPixelPosition(entity, out var ex, out var ey)) + continue; + + var dx = ex - pixelX; + var dy = ey - pixelY; + if (dx * dx + dy * dy <= hazardRadiusSq) + return true; + } + } + catch + { + } + + return false; + } + + private static bool IsLikelyReviveHazardEntity(dc.Entity entity) + { + if (entity == null) + return false; + + string name; + try { name = entity.GetType().Name ?? string.Empty; } catch { name = string.Empty; } + if (string.IsNullOrWhiteSpace(name)) + return false; + + return name.IndexOf("Spike", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Trap", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Saw", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Blade", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Lava", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Acid", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Poison", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Crusher", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Thorn", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static bool TryGetEntityPixelPosition(dc.Entity entity, out double x, out double y) + { + x = 0; + y = 0; + if (entity == null) + return false; + + try + { + var spr = entity.spr; + if (spr != null && double.IsFinite(spr.x) && double.IsFinite(spr.y)) + { + x = spr.x; + y = spr.y; + return true; + } + } + catch + { + } + + try + { + x = (entity.cx + entity.xr) * 24.0; + y = (entity.cy + entity.yr) * 24.0; + return double.IsFinite(x) && double.IsFinite(y); + } + catch + { + return false; + } + } + + private bool TryGetNearestRemotePlayerPixel(Hero hero, out double x, out double y) + { + x = 0; + y = 0; + if (hero == null) + return false; + + var bestDist = double.MaxValue; + var found = false; + var localX = hero.spr?.x ?? 0; + var localY = hero.spr?.y ?? 0; + var localLevelId = GetCurrentLevelId(); + + for (int i = 0; i < clients.Length; i++) + { + var client = clients[i]; + if (client == null) + continue; + + try + { + if (client.destroyed || client.spr == null) + continue; + } + catch + { + continue; + } + + var remoteId = clientIds[i]; + if (remoteId > 0 && _remoteDowned.ContainsKey(remoteId)) + continue; + + double rx; + double ry; + try + { + rx = client.spr.x; + ry = client.spr.y; + } + catch + { + continue; + } + + if (!double.IsFinite(rx) || !double.IsFinite(ry)) + continue; + if (!string.IsNullOrWhiteSpace(localLevelId) && client._level?.map?.id != null) + { + var remoteLevel = client._level.map.id.ToString(); + if (!string.IsNullOrWhiteSpace(remoteLevel) && !string.Equals(remoteLevel, localLevelId, StringComparison.Ordinal)) + continue; + } + + var dx = rx - localX; + var dy = ry - localY; + var dist = dx * dx + dy * dy; + if (dist >= bestDist) + continue; + + bestDist = dist; + x = rx; + y = ry; + found = true; + } + + return found; + } + + private bool TryUpdateDownedPositionFromCorpse(double corpseX, double corpseY) + { + // v6.4.7: never let a corpse/body position update become authoritative for revive. + // Dead Cells can still produce or move a death body/anchor even when the mod tries to + // hide/disable it. Accepting those coordinates caused the downed marker to fall through + // floors, bounce between spikes and teleporters, and repeatedly trigger cell/blueprint + // drops. The authoritative position is now only _localHeldX/_localHeldY. + if (!_localFakeDead) + return false; + + if (me != null) + { + if (_hasLocalDownedAnchor) + { + _localDownedX = _localDownedAnchorX; + _localDownedY = _localDownedAnchorY; + _localHeldX = _localDownedAnchorX; + _localHeldY = _localDownedAnchorY; + _downedSafeRescueLockX = _localDownedAnchorX; + _downedSafeRescueLockY = _localDownedAnchorY; + _downedSafeRescueLockUntilTicks = Stopwatch.GetTimestamp() + (long)(Stopwatch.Frequency * DownedPermanentAnchorRefreshSeconds); + } + else + { + RescueLocalDownedPositionIfUnsafe(me, "corpse_position_ignored", force: true); + } + + ReassertLocalDownedAnchor(me); + _nextDownedStateSendTicks = 0; + } + return true; } diff --git a/Ghost/KingDiveAttackHooksBridge.cs b/Ghost/KingDiveAttackHooksBridge.cs index a8f3c1f..84c356f 100644 --- a/Ghost/KingDiveAttackHooksBridge.cs +++ b/Ghost/KingDiveAttackHooksBridge.cs @@ -203,7 +203,7 @@ private static void WithSanitizedQuadElements(Level level, Action action) try { original = level.listCurrentQuadElements; - if (!TrySanitizeQuadElements(original, out sanitized)) + if (!TrySanitizeQuadElements(level, original, out sanitized)) { action(); return; @@ -229,7 +229,7 @@ private static void WithSanitizedQuadElements(Level level, Action action) } } - private static bool TrySanitizeQuadElements(ArrayObj? source, out ArrayObj? sanitized) + private static bool TrySanitizeQuadElements(Level level, ArrayObj? source, out ArrayObj? sanitized) { sanitized = null; if (source == null) @@ -252,7 +252,7 @@ private static bool TrySanitizeQuadElements(ArrayObj? source, out ArrayObj? sani if (entry is dc.Entity entity) { - if (IsEntityQuadHitSafe(entity)) + if (IsEntityQuadHitSafe(level, entity)) { arr.array.pushDyn(entity); continue; @@ -273,13 +273,71 @@ private static bool TrySanitizeQuadElements(ArrayObj? source, out ArrayObj? sani } /// - /// Skip entities whose sprite/group metadata would crash vanilla hit resolution (applyAttackResult). + /// Skip entities whose position, level, life or sprite metadata would crash vanilla hit resolution + /// (applyAttackResult). DLC rooms can leave stale/half-destroyed mobs in + /// listCurrentQuadElements; vanilla dive landing later dereferences fields such as + /// cx on those entries and raises Hashlink Null access .cx. /// - private static bool IsEntityQuadHitSafe(dc.Entity entity) + private static bool IsEntityQuadHitSafe(Level level, dc.Entity entity) { - if (entity == null) + if (entity == null || level == null) return false; + try + { + if (entity.destroyed) + return false; + } + catch + { + return false; + } + + try + { + if (entity._level == null) + return false; + } + catch + { + return false; + } + + try + { + _ = entity.cx; + _ = entity.cy; + _ = entity.xr; + _ = entity.yr; + } + catch + { + return false; + } + + try + { + if (!entity._targetable) + return false; + } + catch + { + return false; + } + + if (entity is Mob mob) + { + try + { + if (mob.life <= 0 || mob.destroyed || mob._level == null) + return false; + } + catch + { + return false; + } + } + try { var spr = entity.spr; diff --git a/Ghost/KingWeapon/KingWeaponHooksBridge.cs b/Ghost/KingWeapon/KingWeaponHooksBridge.cs index 64d3c7b..2d223c6 100644 --- a/Ghost/KingWeapon/KingWeaponHooksBridge.cs +++ b/Ghost/KingWeapon/KingWeaponHooksBridge.cs @@ -64,6 +64,8 @@ internal void NotifyInventoryReplaceFromKingWeaponHooks(Hook_Inventory.orig_repl internal void NotifyLocalWeaponPrepareFromKingWeaponHooks(Weapon self) { + if (RemoteWeaponVisualSyncDisabled()) + return; if(_netRole == NetRole.None || self == null || me == null) return; @@ -105,6 +107,8 @@ internal void NotifyLocalWeaponPrepareFromKingWeaponHooks(Weapon self) internal void NotifyLocalShieldHoldingPulseFromKingWeaponHooks(BaseShield self, double ratio) { + if (RemoteWeaponVisualSyncDisabled()) + return; if(_netRole == NetRole.None || self == null || me == null) return; @@ -133,6 +137,8 @@ internal void NotifyLocalShieldHoldingPulseFromKingWeaponHooks(BaseShield self, internal void NotifyLocalWeaponInterruptFromKingWeaponHooks(Weapon self) { + if (RemoteWeaponVisualSyncDisabled()) + return; if(_netRole == NetRole.None || self == null || me == null) return; @@ -174,6 +180,8 @@ internal void NotifyLocalBowShotFromKingWeaponHooks(BaseBow self) internal void NotifyLocalAmmoChangedFromKingWeaponHooks(InventItem? item) { + if (RemoteWeaponVisualSyncDisabled()) + return; if(_netRole == NetRole.None || item == null || me == null) return; diff --git a/GhostCine/GhostDead/DeadBase.cs b/GhostCine/GhostDead/DeadBase.cs index 2995c3c..c47005f 100644 --- a/GhostCine/GhostDead/DeadBase.cs +++ b/GhostCine/GhostDead/DeadBase.cs @@ -78,9 +78,10 @@ public override void onDispose() private void EnsureCorpse() { - var corpse = _corpse; - if (corpse == null || corpse.destroyed) - CreateCorpse(); + // v6.4.5: corpse-less fake-death. Do not create HeroDeadCorpse here. + // The vanilla corpse entity can run lethal-fall/drop logic and can duplicate cells or + // blueprints when a downed player is repeatedly snapped out of spikes/void. + DisposeCorpse(); } private void EnsureHomunculus() @@ -91,31 +92,9 @@ private void EnsureHomunculus() private void CreateCorpse() { + // v6.4.5: disabled physical HeroDeadCorpse creation for multiplayer fake-death. DisposeCorpse(); DisposeHomunculus(); - - if (!IsSafeToCreateCorpse()) - return; - - try - { - var corpse = new HeroDeadCorpse(this, _hero); - corpse.init(); - _corpse = corpse; - _lethalFallStarted = false; - _hasBossArenaCorpseAnchor = false; - _bossArenaCorpseAnchorX = 0; - _bossArenaCorpseAnchorY = 0; - _bossArenaCorpsePushApplied = false; - _bossArenaCorpsePushStartedTicks = 0; - TrySnapCorpseToHeroAnchor(corpse); - TryApplyBossArenaCorpsePush(corpse); - EnsureLethalFallStarted(); - } - catch - { - _corpse = null; - } } private bool IsSafeToCreateCorpse() @@ -141,13 +120,8 @@ private bool IsSafeToCreateCorpse() private void EnsureCorpseFalling() { - var corpse = _corpse; - if (corpse == null || corpse.destroyed) - return; - - KeepCorpseActive(corpse); - KeepBossArenaCorpseAnchored(corpse); - EnsureLethalFallStarted(); + // v6.4.5: no physical corpse simulation while fake-dead. + DisposeCorpse(); } private void EnsureLethalFallStarted() @@ -413,40 +387,53 @@ private static void KeepCorpseActive(HeroDeadCorpse corpse) try { corpse.onOutOfGameChange(); } catch { } } + public void SnapCorpseToPixel(double x, double y, bool clampToGround = true) + { + // v6.4.5: the local downed marker is the hidden hero anchor, not a physics corpse. + if (_hero == null || _hero.destroyed) + return; + + try { _hero.cancelVelocities(); } catch { } + try { _hero.setPosPixel(x, y); } catch { } + } + public bool TryGetCorpsePixelPosition(out double x, out double y) { x = 0; y = 0; - var corpse = _corpse; - if (corpse == null || corpse.destroyed) + var hero = _hero; + if (hero == null || hero.destroyed) return false; try { - // Use physics-driven target coordinates so hero follows corpse reliably - // even when sprite position is temporarily unavailable or delayed. - x = corpse.get_targetSprPosX(); - y = corpse.get_targetSprPosY(); - return true; + x = hero.get_targetSprPosX(); + y = hero.get_targetSprPosY(); + return double.IsFinite(x) && double.IsFinite(y); } catch { } - var sprite = corpse.spr; - if (sprite != null) + try + { + if (hero.spr != null) + { + x = hero.spr.x; + y = hero.spr.y; + return double.IsFinite(x) && double.IsFinite(y); + } + } + catch { - x = sprite.x; - y = sprite.y; - return true; } try { - x = (corpse.cx + corpse.xr) * 24.0; - y = (corpse.cy + corpse.yr) * 24.0; - return true; + x = (hero.cx + hero.xr) * 24.0; + y = (hero.cy + hero.yr) * 24.0; + return double.IsFinite(x) && double.IsFinite(y); } catch { diff --git a/GhostCine/GhostDead/RemoteDownedCorpse.cs b/GhostCine/GhostDead/RemoteDownedCorpse.cs index 26aaa10..fa99d63 100644 --- a/GhostCine/GhostDead/RemoteDownedCorpse.cs +++ b/GhostCine/GhostDead/RemoteDownedCorpse.cs @@ -15,6 +15,8 @@ public sealed class RemoteDownedCorpse : dc.GameCinematic private Homunculus? _homunculus; private bool _hadGhostVisibleState; private bool _ghostWasVisible; + private bool _hadGhostGravityState; + private bool _ghostHadGravity; private bool _hadTemplateHeroVisibleState; private bool _templateHeroWasVisible; private bool _hadTemplateHeroHeadBlackState; @@ -45,6 +47,7 @@ public RemoteDownedCorpse(Hero templateHero, GhostKing ghost, double x, double y cancellable = false; SuppressCineEffects(); CaptureGhostVisibility(); + CaptureGhostRuntime(); CaptureTemplateHeroVisibility(); HideGhost(); CreateCorpse(); @@ -147,6 +150,7 @@ public override void onDispose() RestoreCineState(); DisposeCorpse(); DisposeHomunculus(); + RestoreGhostRuntime(); RestoreGhostVisibility(); RestoreTemplateHeroVisibility(); EnsureViewportTracksTemplateHero(immediate: true); @@ -154,94 +158,26 @@ public override void onDispose() private void EnsureCorpse() { - var corpse = _corpse; - if (corpse == null || corpse.destroyed) - { - CreateCorpse(); - return; - } - - KeepCorpseActive(corpse); + // v6.4.5: remote downed marker is ghost-only. Never create or simulate a + // HeroDeadCorpse because it can trigger Dead Cells drop/out-of-game logic. + DisposeCorpse(); ApplyTargetToCorpse(forceStartFall: false); - EnsureLethalFallStarted(); - ApplyTargetToHomunculus(); - EnsureCorpsePointer(); } private void CreateCorpse() { + // v6.4.5: disabled physical remote corpse creation to prevent cells/blueprints + // from being created by spectator-side downed visuals. DisposeCorpse(); DisposeHomunculus(); - - if (!IsSafeToCreateCorpse()) - return; - - try - { - var corpse = CreateCorpseWithoutDrops(); - if (corpse == null) - return; - - _corpse = corpse; - _lethalFallStarted = false; - ApplyTargetToCorpse(forceStartFall: true); - ApplyTargetToHomunculus(); - ApplyInteractionLabel(); - EnsureCorpsePointer(); - EnsureTemplateHeroVisible(); - } - catch - { - _corpse = null; - } + ApplyTargetToCorpse(forceStartFall: false); + EnsureTemplateHeroVisible(); } private HeroDeadCorpse? CreateCorpseWithoutDrops() { - var hero = _templateHero; - if (hero == null) - return null; - - var originalCells = 0; - var capturedCells = false; - var originalBlueprints = hero.blueprints; - try - { - originalCells = hero.cells; - capturedCells = true; - hero.cells = 0; - hero.blueprints = (dc.hl.types.ArrayObj)ArrayUtils.CreateDyn().array; - } - catch - { - } - - try - { - var corpse = new HeroDeadCorpse(this, hero); - corpse.init(); - corpse.cells = 0; - return corpse; - } - finally - { - try - { - if (capturedCells) - hero.cells = originalCells; - } - catch - { - } - - try - { - hero.blueprints = originalBlueprints; - } - catch - { - } - } + // v6.4.5: physical corpses are disabled in multiplayer revive visuals. + return null; } private bool IsSafeToCreateCorpse() @@ -268,17 +204,19 @@ private bool IsSafeToCreateCorpse() private void ApplyTargetToCorpse(bool forceStartFall) { - var corpse = _corpse; - if (corpse == null || corpse.destroyed || !_hasTarget) + if (!_hasTarget || _ghost == null) return; - try { corpse.dir = _targetDir; } catch { } - if (!_lethalFallStarted || IsCorpseStabilized(corpse)) - { - SafeSnapCorpse(corpse, _targetX, _targetY); - } - if (forceStartFall) - EnsureLethalFallStarted(); + try { _ghost.visible = true; } catch { } + try { _ghost._targetable = false; } catch { } + try { _ghost.hasGravity = false; } catch { } + try { _ghost.cancelVelocities(); } catch { } + try { _ghost.dx = 0; } catch { } + try { _ghost.dy = 0; } catch { } + try { _ghost.bdx = 0; } catch { } + try { _ghost.bdy = 0; } catch { } + try { _ghost.dir = _targetDir; } catch { } + try { _ghost.setPosPixel(_targetX, _targetY - 40.0); } catch { } } private static void SafeSnapCorpse(HeroDeadCorpse corpse, double x, double y) @@ -485,47 +423,26 @@ private void RestoreCineState() private void HideGhost() { - try { _ghost.visible = false; } catch { } + // v6.4.5: do not hide the remote player while downed. The ghost itself is now the + // safe revive marker, because a physical HeroDeadCorpse can duplicate drops. + try { _ghost.visible = true; } catch { } + try { _ghost._targetable = false; } catch { } + try { _ghost.hasGravity = false; } catch { } + try { _ghost.cancelVelocities(); } catch { } + try { _ghost.dx = 0; } catch { } + try { _ghost.dy = 0; } catch { } + try { _ghost.bdx = 0; } catch { } + try { _ghost.bdy = 0; } catch { } + if (_hasTarget) + { + try { _ghost.setPosPixel(_targetX, _targetY - 40.0); } catch { } + } } private void EnsureCorpsePointer() { - var corpse = _corpse; - if (corpse == null || corpse.destroyed) - { - ClearCorpsePointer(); - return; - } - - if (_corpsePointer != null) - { - try - { - if (_corpsePointer.destroyed) - { - _corpsePointer = null; - } - else - { - _corpsePointer.e = corpse; - return; - } - } - catch - { - _corpsePointer = null; - } - } - - try - { - _corpsePointer = new Pointer(corpse, "".AsHaxeString(), 99999.0, CorpseMarkerColor); - PointerFxHelper.SuppressPointerFx(_corpsePointer, PointerFxSuppressionKey); - } - catch - { - _corpsePointer = null; - } + // v6.4.5: no corpse pointer when physical corpse is disabled. + ClearCorpsePointer(); } private void ClearCorpsePointer() @@ -614,6 +531,27 @@ private void CaptureGhostVisibility() _hadGhostVisibleState = true; } + private void CaptureGhostRuntime() + { + if (_hadGhostGravityState || _ghost == null) + return; + + try { _ghostHadGravity = _ghost.hasGravity; } + catch { _ghostHadGravity = true; } + _hadGhostGravityState = true; + } + + private void RestoreGhostRuntime() + { + if (_ghost == null || _ghost.destroyed) + return; + + try { _ghost.hasGravity = _hadGhostGravityState ? _ghostHadGravity : true; } catch { } + try { _ghost.cancelVelocities(); } catch { } + _hadGhostGravityState = false; + _ghostHadGravity = true; + } + private void CaptureTemplateHeroVisibility() { if (_hadTemplateHeroVisibleState || _templateHero == null) @@ -717,8 +655,6 @@ private void DisposeCorpse() catch { } - - try { corpse.dispose(); } catch { } } private void DisposeHomunculus() diff --git a/Interaction/InterSyncTypes.cs b/Interaction/InterSyncTypes.cs index bfe660f..a785ab1 100644 --- a/Interaction/InterSyncTypes.cs +++ b/Interaction/InterSyncTypes.cs @@ -120,6 +120,21 @@ public InterBossRuneUpdateCellsEvent(double x, double y, bool add) } } + +public readonly struct InterGenericActivateEvent +{ + public readonly double X; + public readonly double Y; + public readonly string TypeName; + + public InterGenericActivateEvent(double x, double y, string typeName) + { + X = x; + Y = y; + TypeName = typeName ?? string.Empty; + } +} + public readonly struct InterPortalEvent { public readonly double X; @@ -133,3 +148,32 @@ public InterPortalEvent(double x, double y, string action) Action = action ?? string.Empty; } } + +[System.Flags] +public enum WorldObjectSyncFlags +{ + None = 0, + Consumed = 1, + Opened = 2, + Broken = 4, + Hidden = 8, + Important = 16 +} + +public readonly struct WorldObjectState +{ + public readonly string LevelId; + public readonly string TypeName; + public readonly double X; + public readonly double Y; + public readonly int Flags; + + public WorldObjectState(string levelId, string typeName, double x, double y, int flags) + { + LevelId = levelId ?? string.Empty; + TypeName = typeName ?? string.Empty; + X = x; + Y = y; + Flags = flags; + } +} diff --git a/Interaction/InteractionSync.cs b/Interaction/InteractionSync.cs index 7ed03b3..3bc693e 100644 --- a/Interaction/InteractionSync.cs +++ b/Interaction/InteractionSync.cs @@ -9,6 +9,7 @@ using ModCore.Events; using ModCore.Events.Interfaces.Game.Hero; using Serilog; +using System.Diagnostics; using System.Reflection; namespace DeadCellsMultiplayerMod.Interaction; @@ -25,6 +26,7 @@ private sealed class LevelInteractionCache public readonly List VineLadders = new(); public readonly List Teleports = new(); public readonly List Portals = new(); + public readonly List GenericInteractives = new(); public readonly List PressurePlates = new(); public readonly List TreasureChests = new(); public readonly List SwitchBossRunes = new(); @@ -39,6 +41,7 @@ public void Clear() VineLadders.Clear(); Teleports.Clear(); Portals.Clear(); + GenericInteractives.Clear(); PressurePlates.Clear(); TreasureChests.Clear(); SwitchBossRunes.Clear(); @@ -57,6 +60,7 @@ public void Clear() private const double SwitchBossRunePosTolerance = 32.0; private const double ElevatorPosTolerance = 48.0; private const double PortalPosTolerance = 48.0; + private const double GenericInteractPosTolerance = 72.0; private const double TileSizePx = 24.0; private const double DoorProximityRadiusPx = 100.0; private static readonly double DoorProximityRadiusSq = DoorProximityRadiusPx * DoorProximityRadiusPx; @@ -74,14 +78,18 @@ public void Clear() private static Level? _cachedInteractionLevel; private bool _applyingRemoteDoorEvents; private bool _applyingRemoteChestEvents; - private bool _applyingRemotePressurePlateEvents; private bool _applyingRemoteVineLadderEvents; private bool _applyingRemoteTeleportEvents; private bool _applyingRemoteBreakableGroundEvents; private bool _applyingRemotePortalEvents; + private bool _applyingRemoteGenericActivateEvents; private bool _applyingRemoteElevatorEvents; /// Throttle elevator INTERELEV sends — onStep can fire every frame while riding. private readonly Dictionary _elevatorLastInterSendTickMs = new(); + private readonly Dictionary _recentLocalInteractionSends = new(StringComparer.Ordinal); + private readonly Dictionary _recentRemoteInteractionApplies = new(StringComparer.Ordinal); + private const double OneShotInteractionSendDedupeSeconds = 0.75; + private const double OneShotInteractionApplyDedupeSeconds = 2.0; public InteractionSync(ModEntry entry) { @@ -106,6 +114,10 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) Hook_Teleport.open += Hook_Teleport_open; Hook_Portal.show += Hook_Portal_show; Hook_Portal.close += Hook_Portal_close; + // v5.1: Do not hook Hook_Interactive.onActivate directly. + // Some DCCM/GameProxy builds expose Hook_Interactive without orig_onActivate, + // which caused CS0426: type name orig_onActivate does not exist. + // Specific interaction replay hooks and F8 stuck recovery remain enabled. Hook_Hero.breakBreakableGround += Hook_Hero_breakBreakableGround; Hook_SwitchBossRune.canBeActivated += Hook_SwitchBossRune_canBeActivated; Hook_SwitchBossRune.close += Hook_SwitchBossRune_close; @@ -113,6 +125,13 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) } + // v5.1 compile-fix note: + // The generic Interactive.onActivate hook was intentionally removed because the generated + // hook delegate name is not stable across the current DCCM/GameProxy builds. The v5 code + // referenced Hook_Interactive.orig_onActivate, but your build exposes Hook_Interactive + // without that nested type. This keeps the stable/specific interaction sync hooks active + // while avoiding a compile break. + private bool Hook_SwitchBossRune_canBeActivated(Hook_SwitchBossRune.orig_canBeActivated orig, SwitchBossRune self, Hero by) { var net = GameMenu.NetRef; @@ -153,6 +172,7 @@ private void Hook_SwitchBossRune_close(Hook_SwitchBossRune.orig_close orig, Swit var (x, y) = GetEntityPixelPos(self); for (var i = 0; i < count; i++) net.SendInterBossRuneUpdateCells(x, y, add); + QueueReliableInteractionReplay("bossrune", x, y, string.Empty, add); } } catch (Exception ex) @@ -173,6 +193,7 @@ private void Hook_SwitchBossRune_updateCells(Hook_SwitchBossRune.orig_updateCell { var (x, y) = GetEntityPixelPos(self); net.SendInterBossRuneUpdateCells(x, y, add); + QueueReliableInteractionReplay("bossrune", x, y, string.Empty, add); var user = self?._level?.game?.user ?? dc.Main.Class.ME?.user; if (user != null) GameDataSync.SendBossRune(user, net); @@ -223,6 +244,9 @@ private void Hook_Door_onDie(Hook_Door.orig_onDie orig, Door self) private void TrySendDoorEvent(Door self, string action) { + if (string.Equals(action, "close", StringComparison.OrdinalIgnoreCase)) + return; // v5.6: never network-close doors; this fought pressure plates and caused flicker/stuck closed doors. + if (_applyingRemoteDoorEvents) return; var net = GameMenu.NetRef; @@ -232,6 +256,8 @@ private void TrySendDoorEvent(Door self, string action) { var (x, y) = GetEntityPixelPos(self); var broken = action == "die" || SafeRead(() => self.broken, false); + if (!ShouldSendOneShotInteraction("door", x, y, action)) + return; net!.SendInterDoor(net.id, x, y, action, broken); } catch (Exception ex) @@ -255,6 +281,8 @@ private void Hook_Elevator_onStep(Hook_Elevator.orig_onStep orig, Elevator self) _elevatorLastInterSendTickMs[self] = now; var (x, y) = GetElevatorStableAnchor(self); + if (!ShouldSendOneShotInteraction("elevator", x, y, string.Empty)) + return; GameMenu.NetRef!.SendInterElevator(x, y); } catch (Exception ex) @@ -294,9 +322,9 @@ private void Hook_PressurePlate_trigger(Hook_PressurePlate.orig_trigger orig, Pr private void TrySendPressurePlateEvent(PressurePlate self) { - if (_applyingRemotePressurePlateEvents) - return; - TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterPressurePlate(x, y), "PressurePlate"); + // v5.6: pressure plates are stateful/toggle-like. Network replay/echo made them + // repeatedly open/close doors, so leave pressure plates to local vanilla simulation. + return; } private void Hook_TreasureChest_open(Hook_TreasureChest.orig_open orig, TreasureChest self, Hero by) @@ -308,7 +336,10 @@ private void Hook_TreasureChest_open(Hook_TreasureChest.orig_open orig, Treasure private void TrySendTreasureChestEvent(TreasureChest self) { - TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterTreasureChest(x, y), "TreasureChest"); + TrySendInteractEvent(self, (x, y) => + { + GameMenu.NetRef!.SendInterTreasureChest(x, y); + }, "TreasureChest", "chest", string.Empty); } private void Hook_VineLadder_activate(Hook_VineLadder.orig_activate orig, VineLadder self) @@ -321,12 +352,16 @@ private void TrySendVineLadderEvent(VineLadder self) { if (_applyingRemoteVineLadderEvents) return; - TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterVineLadder(x, y), "VineLadder"); + TrySendInteractEvent(self, (x, y) => + { + GameMenu.NetRef!.SendInterVineLadder(x, y); + }, "VineLadder", "vine", string.Empty); } private void Hook_Teleport_open(Hook_Teleport.orig_open orig, Teleport self) { orig(self); + TryRememberLocalTeleporterReviveAnchor(self); TrySendTeleportEvent(self); } @@ -340,6 +375,8 @@ private void Hook_Hero_breakBreakableGround(Hook_Hero.orig_breakBreakableGround return; try { + if (!ShouldSendOneShotInteraction("break", x, y, string.Empty)) + return; net!.SendInterBreakableGround(x, y); } catch (Exception ex) @@ -348,11 +385,54 @@ private void Hook_Hero_breakBreakableGround(Hook_Hero.orig_breakBreakableGround } } + private void TryRememberLocalTeleporterReviveAnchor(Teleport self) + { + if (_applyingRemoteTeleportEvents || self == null) + return; + + var hero = ModEntry.me; + if (hero == null) + return; + + try + { + if (hero._level == null || self._level == null || !ReferenceEquals(hero._level, self._level)) + return; + } + catch + { + return; + } + + var (x, y) = GetEntityPixelPos(self); + if (!double.IsFinite(x) || !double.IsFinite(y) || (x == 0 && y == 0)) + return; + + try + { + var hx = hero.spr?.x ?? ((hero.cx + hero.xr) * TileSizePx); + var hy = hero.spr?.y ?? ((hero.cy + hero.yr) * TileSizePx); + var dx = hx - x; + var dy = hy - y; + if (dx * dx + dy * dy > 220.0 * 220.0) + return; + } + catch + { + return; + } + + try { ModEntry.Instance?.RememberLocalReviveTeleporterPosition(x, y); } catch { } + } + private void TrySendTeleportEvent(Teleport self) { if (_applyingRemoteTeleportEvents) return; - TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterTeleport(x, y), "Teleport"); + TrySendInteractEvent(self, (x, y) => + { + GameMenu.NetRef!.SendInterTeleport(x, y); + }, "Teleport", "teleport", string.Empty); } private void Hook_Portal_show(Hook_Portal.orig_show orig, Portal self) @@ -376,6 +456,8 @@ private void TrySendPortalEvent(Portal self, string action) try { var (x, y) = GetEntityPixelPos(self); + if (!ShouldSendOneShotInteraction("portal", x, y, action)) + return; GameMenu.NetRef!.SendInterPortal(x, y, action); } catch (Exception ex) @@ -384,6 +466,62 @@ private void TrySendPortalEvent(Portal self, string action) } } + private static bool ShouldSyncGenericInteractive(Interactive? interactive, Hero? by) + { + if (interactive == null || by == null || ModEntry.me == null || !ReferenceEquals(by, ModEntry.me)) + return false; + if (!ShouldAllowGenericInteractiveApply(interactive)) + return false; + + return true; + } + + private static bool ShouldAllowGenericInteractiveApply(Interactive? interactive) + { + if (interactive == null) + return false; + + if (interactive is Door || interactive is Exit || interactive is Portal || + interactive is TreasureChest || interactive is VineLadder || interactive is Teleport || interactive is SwitchBossRune) + { + return false; + } + + try + { + var typeName = interactive.GetType().Name ?? string.Empty; + if (typeName.IndexOf("ZDoor", StringComparison.OrdinalIgnoreCase) >= 0 || + typeName.IndexOf("BossRushDoor", StringComparison.OrdinalIgnoreCase) >= 0) + { + return false; + } + } + catch + { + } + + if (SafeRead(() => interactive.destroyed, false)) + return false; + if (SafeRead(() => interactive.isOutOfGame, false)) + return false; + + return true; + } + + private static string GetStableInteractiveTypeName(Interactive? interactive) + { + if (interactive == null) + return string.Empty; + try + { + return interactive.GetType().Name ?? string.Empty; + } + catch + { + return string.Empty; + } + } + private static (double x, double y) GetEntityPixelPos(Entity e) { if (e?.spr == null) @@ -407,13 +545,19 @@ private static T SafeRead(Func fn, T fallback) private static bool IsNetReadyForSend(NetNode? net) => net != null && net.IsAlive && net.id > 0; - private bool TrySendInteractEvent(Entity entity, Action send, string logContext) + private bool TrySendInteractEvent(Entity entity, Action send, string logContext, string? dedupeKind = null, string? dedupeAction = null) { if (!IsNetReadyForSend(GameMenu.NetRef)) return false; try { var (x, y) = GetEntityPixelPos(entity); + if (!string.IsNullOrWhiteSpace(dedupeKind) && + !ShouldSendOneShotInteraction(dedupeKind!, x, y, dedupeAction ?? string.Empty)) + { + return false; + } + send(x, y); return true; } @@ -424,12 +568,64 @@ private bool TrySendInteractEvent(Entity entity, Action send, st } } + private bool ShouldSendOneShotInteraction(string kind, double x, double y, string action) + { + return ShouldAllowOneShot(_recentLocalInteractionSends, kind, x, y, action, OneShotInteractionSendDedupeSeconds); + } + + private bool ShouldApplyOneShotInteraction(string kind, double x, double y, string action) + { + return ShouldAllowOneShot(_recentRemoteInteractionApplies, kind, x, y, action, OneShotInteractionApplyDedupeSeconds); + } + + private static bool ShouldAllowOneShot(Dictionary map, string kind, double x, double y, string action, double seconds) + { + var now = StopwatchTicks(); + if (map.Count > 256) + PruneOneShotMap(map, now); + + var key = MakeInteractionKey(kind, x, y, action); + if (map.TryGetValue(key, out var last) && + now - last < (long)(System.Diagnostics.Stopwatch.Frequency * seconds)) + { + return false; + } + + map[key] = now; + return true; + } + + private static long StopwatchTicks() => System.Diagnostics.Stopwatch.GetTimestamp(); + + private static void PruneOneShotMap(Dictionary map, long now) + { + var cutoff = now - (long)(System.Diagnostics.Stopwatch.Frequency * 8.0); + var stale = new List(); + foreach (var kv in map) + { + if (kv.Value < cutoff) + stale.Add(kv.Key); + } + + for (var i = 0; i < stale.Count; i++) + map.Remove(stale[i]); + } + + private static string MakeInteractionKey(string kind, double x, double y, string action) + { + var qx = (int)System.Math.Round(x / 8.0); + var qy = (int)System.Math.Round(y / 8.0); + return string.Concat(kind, ":", action ?? string.Empty, ":", qx.ToString(System.Globalization.CultureInfo.InvariantCulture), ":", qy.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + void IOnHeroUpdate.OnHeroUpdate(double dt) { var net = GameMenu.NetRef; if (net == null || !net.IsAlive) return; + // v5.6: reliable replay disabled; it could repeatedly toggle stateful DLC doors/vines/plates. + if (net.TryConsumeInterDoorEvents(out var doorEvents)) { ApplyRemoteDoorEvents(doorEvents); @@ -465,8 +661,12 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) ApplyRemotePortalEvents(portalEvents); } - if (net.IsHost) - CheckAndCloseDoorsWhenNoOneNearby(); + if (net.TryConsumeInterGenericActivateEvents(out var genericEvents)) + { + ApplyRemoteGenericActivateEvents(genericEvents); + } + + // v5.6: do not run custom auto-close; it can fight pressure plates/DLC doors. if (net.TryConsumeInterBreakableGroundEvents(out var breakableGroundEvents)) { ApplyRemoteBreakableGroundEvents(breakableGroundEvents); @@ -478,6 +678,13 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) } } + private void QueueReliableInteractionReplay(string kind, double x, double y, string action, bool flag) + { + // v5.6 rollback: do not replay stateful interactions. Replaying open/trigger/activate + // packets every few frames caused doors, pressure plates and vines to flicker/toggle. + // Keep this method as a no-op so all call sites remain harmless and compile-safe. + } + private void CheckAndCloseDoorsWhenNoOneNearby() { var level = ModEntry.me?._level; @@ -633,6 +840,9 @@ private void ApplyRemoteDoorEvents(List events) if (door == null) continue; + if (!ShouldApplyOneShotInteraction("door", ev.X, ev.Y, ev.Action)) + continue; + try { switch (ev.Action) @@ -641,18 +851,7 @@ private void ApplyRemoteDoorEvents(List events) door.open(300, null, null); break; case "close": - if (SafeRead(() => door.broken, false)) - break; - _openedDoors.Remove(door); - try - { - int delayMs = DoorCloseDelayMs; - door.close(Ref.From(ref delayMs)); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] close failed (door may be broken)"); - } + // v5.6: ignore remote close events. Local vanilla logic/plates should own close state. break; case "damage": if (ev.Broken) @@ -716,7 +915,7 @@ private void ApplyRemoteElevatorEvents(List events) private void ApplyRemoteVineLadderEvents(List events) { var level = ModEntry.me?._level; - if (level?.entities == null || events == null || events.Count == 0) + if (level == null || events == null || events.Count == 0) return; _applyingRemoteVineLadderEvents = true; @@ -724,13 +923,16 @@ private void ApplyRemoteVineLadderEvents(List events) { foreach (var ev in events) { - var vineLadder = FindVineLadderByPos(level, ev.X, ev.Y); - if (vineLadder == null) + if (!ShouldApplyOneShotInteraction("vine", ev.X, ev.Y, string.Empty)) + continue; + + var vine = FindVineLadderByPos(level, ev.X, ev.Y); + if (vine == null) continue; try { - vineLadder.activate(); + vine.activate(); } catch (Exception ex) { @@ -755,6 +957,9 @@ private void ApplyRemotePortalEvents(List events) { foreach (var ev in events) { + if (!ShouldApplyOneShotInteraction("portal", ev.X, ev.Y, ev.Action)) + continue; + var portal = FindPortalByPos(level, ev.X, ev.Y); if (portal == null) continue; @@ -778,6 +983,44 @@ private void ApplyRemotePortalEvents(List events) } } + private void ApplyRemoteGenericActivateEvents(List events) + { + var level = ModEntry.me?._level; + var localHero = ModEntry.me; + if (level == null || localHero == null || events == null || events.Count == 0) + return; + + _applyingRemoteGenericActivateEvents = true; + try + { + foreach (var ev in events) + { + if (!ShouldApplyOneShotInteraction("generic", ev.X, ev.Y, ev.TypeName)) + continue; + + var target = FindGenericInteractiveByPos(level, ev.X, ev.Y, ev.TypeName); + if (target == null) + { + _log.Warning("[InteractionSync] No generic interactive found type={Type} x={X} y={Y}", ev.TypeName, ev.X, ev.Y); + continue; + } + + try + { + target.onActivate(localHero, false); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Apply generic interactive failed type={Type} x={X} y={Y}", ev.TypeName, ev.X, ev.Y); + } + } + } + finally + { + _applyingRemoteGenericActivateEvents = false; + } + } + private void ApplyRemoteTeleportEvents(List events) { var level = ModEntry.me?._level; @@ -789,6 +1032,9 @@ private void ApplyRemoteTeleportEvents(List events) { foreach (var ev in events) { + if (!ShouldApplyOneShotInteraction("teleport", ev.X, ev.Y, string.Empty)) + continue; + var teleport = FindTeleportByPos(level, ev.X, ev.Y); if (teleport == null) { @@ -835,6 +1081,8 @@ private void ApplyRemoteBreakableGroundEvents(List ev } if (alreadyNearby) continue; + if (!ShouldApplyOneShotInteraction("break", ev.X, ev.Y, string.Empty)) + continue; var cx = (int)System.Math.Round(ev.X); var cy = (int)System.Math.Round(ev.Y); @@ -858,37 +1106,8 @@ private void ApplyRemoteBreakableGroundEvents(List ev private void ApplyRemotePressurePlateEvents(List events) { - var level = ModEntry.me?._level; - if (level?.entities == null || events == null || events.Count == 0) - return; - - var localHero = ModEntry.me as Entity; - if (localHero == null) - return; - - _applyingRemotePressurePlateEvents = true; - try - { - foreach (var ev in events) - { - var plate = FindPressurePlateByPos(level, ev.X, ev.Y); - if (plate == null) - continue; - - try - { - plate.trigger(localHero); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Apply pressure plate event failed x={X} y={Y}", ev.X, ev.Y); - } - } - } - finally - { - _applyingRemotePressurePlateEvents = false; - } + // v5.6: ignore remote pressure plate events to prevent plate/door toggle loops. + return; } private static Door? FindDoorByPos(Level level, double x, double y) @@ -1090,6 +1309,55 @@ private static int GetTriggerArrayLength(object? triggers) return FindInteractByPos(level, x, y, PlatePosTolerance); } + private static Interactive? FindGenericInteractiveByPos(Level level, double x, double y, string typeName) + { + var candidates = GetInteractionCandidates(level); + if (candidates == null || candidates.Count == 0) + return null; + + Interactive? nearestSameType = null; + Interactive? nearestAnyType = null; + var nearestSameTypeSq = GenericInteractPosTolerance * GenericInteractPosTolerance; + var nearestAnyTypeSq = GenericInteractPosTolerance * GenericInteractPosTolerance; + var wanted = string.IsNullOrWhiteSpace(typeName) ? string.Empty : typeName.Trim(); + + for (var i = 0; i < candidates.Count; i++) + { + var e = candidates[i]; + if (e?.spr == null || !ShouldAllowGenericInteractiveApply(e)) + continue; + + try + { + var dx = e.spr.x - x; + var dy = e.spr.y - y; + var dSq = dx * dx + dy * dy; + if (dSq < nearestAnyTypeSq) + { + nearestAnyTypeSq = dSq; + nearestAnyType = e; + } + + if (!string.IsNullOrWhiteSpace(wanted) && + !string.Equals(GetStableInteractiveTypeName(e), wanted, StringComparison.Ordinal)) + { + continue; + } + + if (dSq < nearestSameTypeSq) + { + nearestSameTypeSq = dSq; + nearestSameType = e; + } + } + catch + { + } + } + + return nearestSameType ?? nearestAnyType; + } + private Teleport? FindTeleportByPos(Level level, double x, double y) { var byPos = FindInteractByPos(level, x, y, TeleportPosTolerance); @@ -1138,6 +1406,9 @@ private void ApplyRemoteTreasureChestEvents(List events { foreach (var ev in events) { + if (!ShouldApplyOneShotInteraction("chest", ev.X, ev.Y, string.Empty)) + continue; + var chest = FindTreasureChestByPos(level, ev.X, ev.Y); if (chest == null) continue; @@ -1172,6 +1443,12 @@ private void ApplyRemoteTreasureChestEvents(List events private static T? FindInteractByPos(Level level, double x, double y, double tolerance = PosTolerance) where T : Entity { var candidates = GetInteractionCandidates(level); + if (candidates == null || candidates.Count == 0) + { + RebuildInteractionCache(level); + candidates = GetInteractionCandidates(level); + } + if (candidates == null || candidates.Count == 0) return null; @@ -1195,6 +1472,32 @@ private void ApplyRemoteTreasureChestEvents(List events } } + // Level interaction entities can appear after our first cache pass (DLC doors, vines, + // teleports). Refresh once before giving up so one-shot visual sync is less likely to miss. + RebuildInteractionCache(level); + candidates = GetInteractionCandidates(level); + if (candidates != null && candidates.Count > 0) + { + for (var i = 0; i < candidates.Count; i++) + { + var e = candidates[i]; + if (e == null) + continue; + try + { + if (e.spr != null && + System.Math.Abs(e.spr.x - x) < tolerance && + System.Math.Abs(e.spr.y - y) < tolerance) + { + return e; + } + } + catch + { + } + } + } + return null; } @@ -1245,6 +1548,9 @@ private static void RebuildInteractionCache(Level? level) case SwitchBossRune switchBossRune: CachedInteractionLevelData.SwitchBossRunes.Add(switchBossRune); break; + case Interactive interactive when ShouldAllowGenericInteractiveApply(interactive): + CachedInteractionLevelData.GenericInteractives.Add(interactive); + break; } } } @@ -1281,6 +1587,8 @@ private static void RebuildInteractionCache(Level? level) return (IReadOnlyList)(object)cache.Teleports; if (typeof(T) == typeof(Portal)) return (IReadOnlyList)(object)cache.Portals; + if (typeof(T) == typeof(Interactive)) + return (IReadOnlyList)(object)cache.GenericInteractives; if (typeof(T) == typeof(PressurePlate)) return (IReadOnlyList)(object)cache.PressurePlates; if (typeof(T) == typeof(TreasureChest)) diff --git a/LevelSync.cs b/LevelSync.cs index 2b4611e..276e08c 100644 --- a/LevelSync.cs +++ b/LevelSync.cs @@ -245,6 +245,12 @@ private static void TryTriggerLevelGraphReload(string graphLevelId, string paylo if (!string.Equals(currentLevelId, graphLevelId, StringComparison.Ordinal)) return; + if (IsClientLevelReloadUnsafe(hero, level, graphLevelId, out var unsafeReason)) + { + _log?.Information("[NetMod] Skipping level-graph reload for {LevelId}: {Reason}", graphLevelId, unsafeReason); + return; + } + lock (_levelGraphLock) { if (!_remoteLevelGraphs.ContainsKey(graphLevelId)) @@ -271,6 +277,62 @@ private static void TryTriggerLevelGraphReload(string graphLevelId, string paylo offsetCy); } + private static bool IsClientLevelReloadUnsafe(Hero hero, Level level, string graphLevelId, out string reason) + { + reason = string.Empty; + + try + { + if (hero == null || hero.destroyed) + { + reason = "hero unavailable"; + return true; + } + + if (level == null || level.destroyed || level.map == null) + { + reason = "level unavailable"; + return true; + } + } + catch + { + reason = "hero/level validity check failed"; + return true; + } + + try + { + var game = dc.pr.Game.Class.ME; + var cine = game?.curCine; + if (cine != null && !cine.destroyed) + { + reason = "cinematic active"; + return true; + } + } + catch + { + } + + try + { + var room = TryGetRoomAt(level, hero.cx, hero.cy); + if (room == null) + { + reason = "hero is not in a valid room"; + return true; + } + } + catch + { + reason = "hero room check failed"; + return true; + } + + return false; + } + private static bool TryBeginLevelGraphReload(string levelId, string payload) { var now = Environment.TickCount64; @@ -315,6 +377,12 @@ private static void TryTriggerBossRuneReload(string graphLevelId) if (!string.Equals(currentLevelId, graphLevelId, StringComparison.Ordinal)) return; + if (IsClientLevelReloadUnsafe(hero, level, graphLevelId, out var unsafeReason)) + { + _log?.Information("[NetMod] Skipping boss-rune reload for {LevelId}: {Reason}", graphLevelId, unsafeReason); + return; + } + if (!TryGetRemoteBossRune(out var remoteBossRune)) return; diff --git a/Mobs/MobWireCodec.cs b/Mobs/MobWireCodec.cs index 5daadb8..90b16c8 100644 --- a/Mobs/MobWireCodec.cs +++ b/Mobs/MobWireCodec.cs @@ -190,6 +190,13 @@ public static string BuildMobDrawLine(IReadOnlyList draws) public static string BuildMobDieLine(NetNode.MobDie die) { + if (!string.IsNullOrWhiteSpace(die.Type)) + { + return string.Create( + CultureInfo.InvariantCulture, + $"MOBDIE|{die.UserId}|{die.MobIndex}|{die.X}|{die.Y}|{die.Generation}|{die.Type}\n"); + } + return string.Create( CultureInfo.InvariantCulture, $"MOBDIE|{die.UserId}|{die.MobIndex}|{die.X}|{die.Y}|{die.Generation}\n"); diff --git a/Mobs/MonsterSynchronization.Attacks.cs b/Mobs/MonsterSynchronization.Attacks.cs index 0a29785..ba50b69 100644 --- a/Mobs/MonsterSynchronization.Attacks.cs +++ b/Mobs/MonsterSynchronization.Attacks.cs @@ -1003,6 +1003,20 @@ private static bool TryGetMobSyncId(Mob mob, out int syncId) return unresolvedMob; } + if (TryResolveNearestTypedMobLocked( + state.Index, + state.Type, + state.X, + state.Y, + MobStateTypedRebindMaxDistancePx, + reservedMobs, + out var nearestTypedStateMob) && nearestTypedStateMob != null) + { + TryRebindTrackedMobSyncIdLocked(nearestTypedStateMob, state.Index); + MobSyncTrace.LogBindSyncId("state_nearest_typed", state.Index, state.Type ?? string.Empty, state.X, state.Y); + return nearestTypedStateMob; + } + if (candidateCount > 1) { MobSyncTrace.LogAmbiguousMatchRejected( @@ -1155,6 +1169,21 @@ private static void QuantizeWorldPositionToPixelsInt32(double x, double y, out i if (string.IsNullOrWhiteSpace(expectedType)) return null; + if (TryResolveNearestTypedMobLocked( + attack.Index, + expectedType, + attack.X, + attack.Y, + MobHitTypedRebindMaxDistancePx, + null, + out var nearestTypedAttackMob) && nearestTypedAttackMob != null) + { + TryRebindTrackedMobSyncIdLocked(nearestTypedAttackMob, attack.Index); + hostMobTypeBySyncId[attack.Index] = expectedType; + MobSyncTrace.LogBindSyncId("attack_nearest_typed", attack.Index, expectedType ?? string.Empty, attack.X, attack.Y); + return nearestTypedAttackMob; + } + if (!string.IsNullOrWhiteSpace(attack.Type)) hostMobTypeBySyncId[attack.Index] = attack.Type; return null; diff --git a/Mobs/MonsterSynchronization.ClientApply.cs b/Mobs/MonsterSynchronization.ClientApply.cs index e82ef5c..397534a 100644 --- a/Mobs/MonsterSynchronization.ClientApply.cs +++ b/Mobs/MonsterSynchronization.ClientApply.cs @@ -168,7 +168,11 @@ private static void ApplyAuthoritativeLifeState(Mob mob, int targetLife, int tar clampedLife = 0; if (mob.life == clampedLife) + { + if (clampedLife <= 0 && !BossSyncHelpers.IsBossMob(mob)) + TryFinalizeNonBossZeroLifeMob(mob, "authoritative_life_already_zero"); return; + } var wasAlive = mob.life > 0; mob.life = clampedLife; @@ -180,14 +184,7 @@ private static void ApplyAuthoritativeLifeState(Mob mob, int targetLife, int tar try { - if (!mob.destroyed) - { - RunWithSuppressedMobDieSend(() => - { - mob.life = 0; - mob.onDie(); - }); - } + TryFinalizeNonBossZeroLifeMob(mob, "authoritative_life_new_zero"); var animManager = GetMobAnimManager(mob); if (animManager?.stack != null) diff --git a/Mobs/MonsterSynchronization.ClientReceive.cs b/Mobs/MonsterSynchronization.ClientReceive.cs index 94c91de..1f7d045 100644 --- a/Mobs/MonsterSynchronization.ClientReceive.cs +++ b/Mobs/MonsterSynchronization.ClientReceive.cs @@ -1292,10 +1292,9 @@ private static void ConsumeIncomingMobDies(NetNode net) { MobSyncTrace.LogRecvDies(net.IsHost ? "diesOnHost" : "diesOnClient", dies); - // Host is authoritative for mob death. Ignore remote client die packets. - if (net.IsHost) - return; - + // Host remains authoritative, but client-side lethal hits are accepted as validated + // kill requests so remote players can finish normal mobs without waiting for the host + // to land the final hit. ResolveMobFromDieLocked still checks generation/type/position. ApplyIncomingMobDies(dies); } finally @@ -1340,7 +1339,10 @@ private static void ApplyIncomingMobDies(IReadOnlyList dies) } if (!isBoss && life <= 0) - continue; + { + // v6.4.5: do not ignore an already-zero non-boss entity. + // It may be the exact stuck empty-HP mob that still needs onDie finalization. + } if (s_dieVictimDedupScratch.Add(mob)) s_dieVictimsScratch.Add(mob); @@ -1362,11 +1364,18 @@ private static void ApplyIncomingMobDies(IReadOnlyList dies) { RunWithAuthoritativeClientBossDie(mob, () => { - RunWithSuppressedMobDieSend(() => + if (BossSyncHelpers.IsBossMob(mob)) + { + RunWithSuppressedMobDieSend(() => + { + mob.life = 0; + mob.onDie(); + }); + } + else { - mob.life = 0; - mob.onDie(); - }); + TryFinalizeNonBossZeroLifeMob(mob, "incoming_die_packet"); + } }); } catch @@ -1468,6 +1477,24 @@ private static void ApplyIncomingMobHits(IReadOnlyList hits, int if (mob == null) continue; + // v5.6: client safe-combat mode. Do not run intermediate remote hit reactions + // on clients; charged/heavy weapon paths can crash client feedback cleanup. + // Lethal/death packets still apply so progression and rune unlocks continue. + if (!isHost && !update.ForceDie) + { + try + { + if (update.TargetMaxLife > 0 && mob.maxLife != update.TargetMaxLife) + mob.maxLife = update.TargetMaxLife; + if (update.TargetLife >= 0 && mob.life > 0) + mob.life = System.Math.Clamp(update.TargetLife, 1, mob.maxLife > 0 ? mob.maxLife : update.TargetLife); + } + catch + { + } + continue; + } + if (isHost) TryWakeMobForForcedSimulation(mob); @@ -1531,7 +1558,8 @@ private static void ApplyIncomingMobHits(IReadOnlyList hits, int var hitEv = $"hit|{appliedLife.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; if (TryGetCurrentLevelIdentityToken(out var identityToken)) { - var evUpdate = new NetNode.MobEventUpdate(update.SyncId, sx, sy, dir, SingleEvent(hitEv), generation: identityToken); + var mobType = BuildMobStateTypeSignature(mob); + var evUpdate = new NetNode.MobEventUpdate(update.SyncId, sx, sy, dir, SingleEvent(hitEv), mobType, identityToken); MobSyncTrace.LogSendMobEvents(MobSyncNetRoleForTrace(net), SingleUpdate(evUpdate)); net.SendMobEvents(SingleUpdate(evUpdate)); } @@ -1548,39 +1576,16 @@ private static void TryApplyHostBossFinishingHit(Mob mob, int targetMaxLife) try { - var damage = System.Math.Max(1.0, targetMaxLife * 8.0); - var attackUtils = AttackUtils.Class; - var createFromHeroAndHit = attackUtils?.createFromHeroAndHit; - if (createFromHeroAndHit != null) - { - _ = createFromHeroAndHit(null, damage, null, mob); - if (TryFinalizeHostMobDeath(mob)) - return; - } - - var createFromHero = attackUtils?.createFromHero; - var hit = attackUtils?.hit; - if (createFromHero != null && hit != null) - { - var attack = createFromHero(null, damage, null); - if (attack != null) - { - hit(attack, mob); - if (TryFinalizeHostMobDeath(mob)) - return; - } - } - - if (TryFinalizeHostMobDeath(mob)) - return; - - // Last-resort force; some bosses need explicit life zero before onDie branching. + // Do not synthesize an AttackUtils hit with a null hero. Some native/Haxe attack + // cleanup paths dereference controller feedback and can crash with + // "Null access .stopPoweredFeedback". Directly finalize the authoritative host + // death instead; the death packet then drives clients. mob.life = 0; TryFinalizeHostMobDeath(mob); } catch (Exception ex) { - Log.Warning(ex, "[MobsSync] Host boss finishing hit replay failed"); + Log.Warning(ex, "[MobsSync] Host boss finishing death failed"); } } @@ -1639,32 +1644,10 @@ private static void TryReplayIncomingSpecialHitReaction(Mob mob) if (mob == null) return; - try - { - RunWithSuppressedMobHitSend(() => - { - var attackUtils = AttackUtils.Class; - var createFromHeroAndHit = attackUtils?.createFromHeroAndHit; - if (createFromHeroAndHit != null) - { - _ = createFromHeroAndHit(null, 1.0, null, mob); - return; - } - - var createFromHero = attackUtils?.createFromHero; - var hit = attackUtils?.hit; - if (createFromHero == null || hit == null) - return; - - var attack = createFromHero(null, 1.0, null); - if (attack != null) - hit(attack, mob); - }); - } - catch (Exception ex) - { - Log.Warning(ex, "[MobsSync] Special incoming mob hit replay failed"); - } + // Keep this intentionally conservative. Replaying an AttackUtils hit without a real + // local hero can enter native powered-feedback cleanup and crash the game. The + // authoritative life/state sync is enough for multiplayer correctness. + TryWakeMobForForcedSimulation(mob); } private static void TryWakeMobForForcedSimulation(Mob mob) @@ -1702,6 +1685,11 @@ private static bool ShouldSendHostContactPacket(Mob mob, Entity? target) if (registryMob != null && typeMatchesRegistry) { + // The log that was sent with the bug report shows many same-sync-id / same-type + // hits being dropped only because the client and host positions differed by a + // quantized pixel. That is too strict for multiplayer and can prevent the host's + // lethal hit/death confirmation from ever applying on the other player. Treat the + // sync id + type as authoritative; use the position mismatch as a resync warning. if (!MobHitRegistryStillTrustworthyLocked(registryMob, hit)) { MobSyncTrace.LogIncomingMappingMismatch( @@ -1709,29 +1697,108 @@ private static bool ShouldSendHostContactPacket(Mob mob, Entity? target) hit.MobIndex, hit.Type ?? string.Empty, BuildMobStateTypeSignature(registryMob), - "position_mismatch"); - return null; + "position_mismatch_accepted"); } return registryMob; } + var mismatchReason = registryMob == null ? "missing_sync_id" : "type_mismatch"; MobSyncTrace.LogIncomingMappingMismatch( "hit", hit.MobIndex, hit.Type ?? string.Empty, registryMob != null ? BuildMobStateTypeSignature(registryMob) : string.Empty, - registryMob == null ? "missing_sync_id" : "type_mismatch"); + mismatchReason); + + if (!string.IsNullOrWhiteSpace(hit.Type) && + TryResolveNearestTypedMobLocked( + hit.MobIndex, + hit.Type, + hit.X, + hit.Y, + MobHitTypedRebindMaxDistancePx, + null, + out var nearestTypedHitMob) && nearestTypedHitMob != null) + { + TryRebindTrackedMobSyncIdLocked(nearestTypedHitMob, hit.MobIndex); + MobSyncTrace.LogBindSyncId("hit_nearest_typed", hit.MobIndex, hit.Type ?? string.Empty, hit.X, hit.Y); + return nearestTypedHitMob; + } return null; } + private static bool TryResolveNearestTypedMobLocked( + int syncId, + string? expectedType, + double x, + double y, + double maxDistancePx, + HashSet? reservedMobs, + out Mob? selected) + { + selected = null; + if (string.IsNullOrWhiteSpace(expectedType) || trackedMobs.Count == 0) + return false; + + Mob? best = null; + var bestDistanceSq = double.MaxValue; + var maxDistanceSq = maxDistancePx * maxDistancePx; + + for (int i = 0; i < trackedMobs.Count; i++) + { + var candidate = trackedMobs[i]; + if (candidate == null) + continue; + if (reservedMobs != null && reservedMobs.Contains(candidate)) + continue; + if (!IsSyncMob(candidate)) + continue; + + try + { + if (candidate.destroyed || candidate._level == null) + continue; + if (!DoesLevelMatchCurrentIdentityLocked(candidate._level)) + continue; + } + catch + { + continue; + } + + if (!DoesMobMatchStateType(candidate, expectedType)) + continue; + + var dx = GetWorldX(candidate) - x; + var dy = GetWorldY(candidate) - y; + var distanceSq = dx * dx + dy * dy; + if (distanceSq > maxDistanceSq || distanceSq >= bestDistanceSq) + continue; + + bestDistanceSq = distanceSq; + best = candidate; + } + + selected = best; + return selected != null; + } + private static bool MobHitRegistryTypeMatchesLocked(Mob? registryMob, NetNode.MobHit hit) { if (registryMob == null) return false; + + // Some old/combined MOBHIT packets arrive without a mob type. Treating an empty type + // as fully authoritative caused clients to apply hits to the wrong local mob after + // sync-id drift, which is especially dangerous in Fractured Shrines/Tumulus where + // JavelinSnake/Comboter updates were followed by Null access .cx crashes. Empty-type + // hits are now only accepted when the sync id also points to a mob at the same + // quantized position; death packets still have their separate nearest-mob fallback. if (string.IsNullOrWhiteSpace(hit.Type)) - return true; + return MobHitRegistryStillTrustworthyLocked(registryMob, hit); + return DoesMobMatchStateType(registryMob, hit.Type); } @@ -1775,10 +1842,121 @@ private static bool MobHitRegistryStillTrustworthyLocked(Mob mob, NetNode.MobHit { lock (Sync) { - return ResolveMobBySyncIdLocked(die.MobIndex); + var mob = ResolveMobBySyncIdLocked(die.MobIndex); + if (mob != null) + { + if (string.IsNullOrWhiteSpace(die.Type) || DoesMobMatchStateType(mob, die.Type)) + return mob; + + MobSyncTrace.LogIncomingMappingMismatch( + "die", + die.MobIndex, + die.Type ?? string.Empty, + BuildMobStateTypeSignature(mob), + "type_mismatch"); + InvalidateTrackedSyncCacheLocked(die.MobIndex, "die_type_mismatch"); + } + + // Death packets are progression-critical for elite/rune mobs, but killing the + // wrong local entity can corrupt native mob AI and crash with Null access .cx + // (Clock Tower/Ninja and Fractured Shrines were both hitting this path). v6.2 + // allows nearest fallback only when the packet carries a matching mob type, or + // when an old untyped packet is extremely close to the local mob. + mob = ResolveNearestMobFromDieLocked(die); + if (mob != null) + { + TryRebindTrackedMobSyncIdLocked(mob, die.MobIndex); + MobSyncTrace.LogIncomingMappingMismatch( + "die", + die.MobIndex, + die.Type ?? string.Empty, + BuildMobStateTypeSignature(mob), + "missing_sync_id_fallback_nearest"); + return mob; + } + + MobSyncTrace.LogIncomingMappingMismatch( + "die", + die.MobIndex, + die.Type ?? string.Empty, + string.Empty, + "missing_sync_id_no_fallback"); + + return null; } } + private static Mob? ResolveNearestMobFromDieLocked(NetNode.MobDie die) + { + Mob? best = null; + var bestDistanceSq = double.MaxValue; + var hasTypedDie = !string.IsNullOrWhiteSpace(die.Type); + var firstPassMaxDistance = hasTypedDie ? MobDieFallbackMaxDistancePx : MobDieUntypedFallbackMaxDistancePx; + var maxDistanceSq = firstPassMaxDistance * firstPassMaxDistance; + + for (int i = 0; i < trackedMobs.Count; i++) + TryConsiderMobDieFallbackCandidateLocked(trackedMobs[i], die, maxDistanceSq, ref best, ref bestDistanceSq); + + if (best != null) + return best; + + // If the sync-id map is stale, the victim may no longer be in trackedMobs even though + // the current level still owns it. Search live level entities too. This is deliberately + // a second pass with a wider radius: death packets are authoritative and should not leave + // no-HP/no-damage ghosts behind, but we still prefer the normal tracked-map resolution. + var secondPassMaxDistance = hasTypedDie ? MobDieFallbackExtendedMaxDistancePx : MobDieUntypedFallbackExtendedMaxDistancePx; + maxDistanceSq = secondPassMaxDistance * secondPassMaxDistance; + try + { + var entities = currentLevel?.entities; + if (entities != null) + { + for (int i = 0; i < entities.length; i++) + TryConsiderMobDieFallbackCandidateLocked(entities.getDyn(i) as Mob, die, maxDistanceSq, ref best, ref bestDistanceSq); + } + } + catch + { + } + + return best; + } + + private static void TryConsiderMobDieFallbackCandidateLocked( + Mob? candidate, + NetNode.MobDie die, + double maxDistanceSq, + ref Mob? best, + ref double bestDistanceSq) + { + if (candidate == null || !IsSyncMob(candidate)) + return; + + try + { + if (candidate.destroyed || candidate._level == null) + return; + if (!DoesLevelMatchCurrentIdentityLocked(candidate._level)) + return; + } + catch + { + return; + } + + if (!string.IsNullOrWhiteSpace(die.Type) && !DoesMobMatchStateType(candidate, die.Type)) + return; + + var dx = GetWorldX(candidate) - die.X; + var dy = GetWorldY(candidate) - die.Y; + var distanceSq = dx * dx + dy * dy; + if (distanceSq > maxDistanceSq || distanceSq >= bestDistanceSq) + return; + + bestDistanceSq = distanceSq; + best = candidate; + } + private static Mob? ResolveMobBySyncIdLocked(int mobIndex) { var mob = ResolveTrackedMobBySyncIdLocked(mobIndex); diff --git a/Mobs/MonsterSynchronization.Constants.cs b/Mobs/MonsterSynchronization.Constants.cs index aa9eb6a..2e9a357 100644 --- a/Mobs/MonsterSynchronization.Constants.cs +++ b/Mobs/MonsterSynchronization.Constants.cs @@ -12,8 +12,15 @@ public partial class MobsSynchronization private const double HostMobStateMidPositionEpsilon = 1.20; private const double HostMobStateDormantPositionEpsilon = 6.00; private const double MobFallbackMinimumScoreGap = 4.0; + private const double MobDieFallbackMaxDistancePx = 192.0; + private const double MobDieFallbackExtendedMaxDistancePx = 384.0; + private const double MobDieUntypedFallbackMaxDistancePx = 48.0; + private const double MobDieUntypedFallbackExtendedMaxDistancePx = 96.0; + private const double MobHitTypedRebindMaxDistancePx = 256.0; + private const double MobStateTypedRebindMaxDistancePx = 384.0; private const double ClientAiAuthorityLockDurationSeconds = 99999.0; private const double AuthoritativeAffectPresenceSeconds = 99999.0; + private const double HostFullMobResyncIntervalSeconds = 2.5; private const double PixelsPerCase = 24.0; private const double ClientNetworkAttackMinActiveFrames = 20.0; private const string ContactAttackPacketSkillId = "@contact"; diff --git a/Mobs/MonsterSynchronization.DirtyQueue.cs b/Mobs/MonsterSynchronization.DirtyQueue.cs index fa6fa6d..d62a2dc 100644 --- a/Mobs/MonsterSynchronization.DirtyQueue.cs +++ b/Mobs/MonsterSynchronization.DirtyQueue.cs @@ -326,6 +326,35 @@ private static bool TryDequeuePendingClientDirtyMob(out Mob? mob, out int syncId } private static int s_pendingHostBatchCount; + private static long s_nextHostFullMobResyncTick; + + private static void QueueHostFullMobResyncIfDue(NetNode net) + { + if (!IsHost(net)) + return; + + var now = System.Diagnostics.Stopwatch.GetTimestamp(); + if (s_nextHostFullMobResyncTick != 0 && now < s_nextHostFullMobResyncTick) + return; + + s_nextHostFullMobResyncTick = now + (long)(System.Diagnostics.Stopwatch.Frequency * HostFullMobResyncIntervalSeconds); + + lock (Sync) + { + PruneInvalidTrackedMobsLocked(); + for (int i = 0; i < trackedMobs.Count; i++) + { + var mob = trackedMobs[i]; + if (mob == null || !IsSyncMob(mob)) + continue; + + if (!MobToId.TryGetValue(mob, out var syncId) || syncId < 0) + continue; + + EnqueueHostMobDirtyLocked(syncId, HostMobDirtyFlags.State | HostMobDirtyFlags.ForceState); + } + } + } private static void FlushHostDirtyMobQueue(NetNode net) { diff --git a/Mobs/MonsterSynchronization.ZeroHpCleanup.cs b/Mobs/MonsterSynchronization.ZeroHpCleanup.cs new file mode 100644 index 0000000..5f35632 --- /dev/null +++ b/Mobs/MonsterSynchronization.ZeroHpCleanup.cs @@ -0,0 +1,59 @@ +using dc.en; +using DeadCellsMultiplayerMod.Mobs.Bosses; + +namespace DeadCellsMultiplayerMod.Mobs.MobsSynchronization +{ + public partial class MobsSynchronization + { + /// + /// v6.4.5: finalize non-boss mobs that reached zero HP but were left as live entities. + /// This can happen after client lethal-hit requests, revive-state network floods, or stale + /// host snapshots where the HP bar reaches zero before vanilla onDie finishes. + /// + private static void TryFinalizeNonBossZeroLifeMob(Mob? mob, string context) + { + if (mob == null) + return; + + try + { + if (mob.destroyed) + return; + if (BossSyncHelpers.IsBossMob(mob)) + return; + if (mob.life > 0) + return; + } + catch + { + return; + } + + try + { + RunWithSuppressedMobDieSend(() => + { + try { mob.life = 0; } catch { } + try { mob.onDie(); } catch { } + }); + } + catch + { + } + + try { mob._targetable = false; } catch { } + try { mob.isOutOfGame = true; } catch { } + + try + { + lock (Sync) + { + RemoveTrackedMobLocked(mob); + } + } + catch + { + } + } + } +} diff --git a/Mobs/MonsterSynchronization.cs b/Mobs/MonsterSynchronization.cs index 3a94190..71592d5 100644 --- a/Mobs/MonsterSynchronization.cs +++ b/Mobs/MonsterSynchronization.cs @@ -371,6 +371,7 @@ void IOnFrameUpdate.OnFrameUpdate(double dt) RuntimeHitchWatch.LogSlow(modEntry.Logger, "MobsSynchronization.HostConsume", consumeMs, BuildRuntimeQueueDetails()); var flushStart = RuntimeHitchWatch.Start(); + QueueHostFullMobResyncIfDue(net); FlushHostDirtyMobQueue(net); var flushMs = RuntimeHitchWatch.GetElapsedMilliseconds(flushStart); if (flushMs >= RuntimeHitchWatch.MobSyncFlushSlowThresholdMs) @@ -932,6 +933,8 @@ private void Hook_Mob_postUpdate(Hook_Mob.orig_postUpdate orig, Mob self) orig(self); if (IsClient(net) && IsSyncMob(self)) { + TryFinalizeNonBossZeroLifeMob(self, "client_post_update_zero_hp"); + try { if (self.destroyed) return; } catch { return; } ObserveClientMobForDirtyQueue(self); ApplyClientAnimationStateBeforeUpdate(self); TryRepairClientMobAttackTarget(self); @@ -943,6 +946,8 @@ private void Hook_Mob_postUpdate(Hook_Mob.orig_postUpdate orig, Mob self) orig(self); if (IsSyncMob(self)) { + TryFinalizeNonBossZeroLifeMob(self, "host_post_update_zero_hp"); + try { if (self.destroyed) return; } catch { return; } ObserveHostMobForDirtyQueue(self); TryAssignHostAttackTarget(self); } @@ -967,9 +972,12 @@ private static void Hook_Mob_onDie(Hook_Mob.orig_onDie orig, Mob self) dieNet = GameMenu.NetRef; isClient = IsClient(dieNet); - // Client is not authoritative for mob death; wait for host die/hit confirmation. + // Client is not authoritative for mob death, but it must still send a reliable + // kill request. Otherwise the client's local mob can sit at an empty HP bar until + // the host also hits it. The host validates sync id + type + position before applying. if (isClient && IsSyncMob(self)) { + TrySendClientMobKillRequest(self, dieNet); try { if (self.life <= 0) @@ -1004,7 +1012,8 @@ private static void Hook_Mob_onDie(Hook_Mob.orig_onDie orig, Mob self) { if (TryGetCurrentLevelIdentityToken(out var identityToken)) { - var update = new NetNode.MobEventUpdate(dieSyncId, dieX, dieY, 0, SingleEvent("die"), generation: identityToken); + var dieType = BuildMobStateTypeSignature(self); + var update = new NetNode.MobEventUpdate(dieSyncId, dieX, dieY, 0, SingleEvent("die"), dieType, identityToken); MobSyncTrace.LogSendMobEvents(MobSyncNetRoleForTrace(dieNet), SingleUpdate(update)); dieNet.SendMobEvents(SingleUpdate(update)); } @@ -1016,6 +1025,29 @@ private static void Hook_Mob_onDie(Hook_Mob.orig_onDie orig, Mob self) } } + private static void TrySendClientMobKillRequest(Mob self, NetNode? net) + { + if (self == null || net == null || !net.IsAlive || !IsClient(net)) + return; + if (!IsSyncMob(self)) + return; + if (!TryGetMobSyncId(self, out var syncId) || syncId < 0) + return; + + try + { + var x = GetSyncX(self); + var y = GetSyncY(self); + var type = BuildMobStateTypeSignature(self); + var generation = TryGetCurrentLevelIdentityToken(out var token) ? token : 0; + net.SendMobDie(syncId, x, y, type, generation); + clientLastReportedMobLife[self] = 0; + } + catch + { + } + } + private static void RunWithSuppressedMobDieSend(Action action) { if (action == null) @@ -1048,6 +1080,25 @@ private static void RunWithSuppressedMobHitSend(Action action) } } + private static bool IsRecoverableMobDamageCrash(Exception ex) + { + if (ex == null) + return false; + + var text = ex.ToString() ?? string.Empty; + if (!text.Contains("Null access", StringComparison.OrdinalIgnoreCase)) + return false; + + return text.Contains("Mob.onDamage", StringComparison.OrdinalIgnoreCase) || + text.Contains("Mob.applyAttackResult", StringComparison.OrdinalIgnoreCase) || + text.Contains("AttackUtils.hit", StringComparison.OrdinalIgnoreCase) || + text.Contains("AttackTargetImpl.applyHit", StringComparison.OrdinalIgnoreCase) || + text.Contains("DiveAttack.onOwnerLand", StringComparison.OrdinalIgnoreCase) || + text.Contains("Null access .commonProps", StringComparison.OrdinalIgnoreCase) || + text.Contains("Null access .cx", StringComparison.OrdinalIgnoreCase) || + text.Contains("Null access .lockAiS", StringComparison.OrdinalIgnoreCase); + } + private void Hook_Mob_onDamage(Hook_Mob.orig_onDamage orig, Mob self, AttackData i) { var preDamageLife = GetMobLifeOrFallback(self, 0); @@ -1060,7 +1111,33 @@ private void Hook_Mob_onDamage(Hook_Mob.orig_onDamage orig, Mob self, AttackData preSyncOk = TryGetMobSyncId(self, out cachedMobSyncId); } - orig(self, i); + try + { + orig(self, i); + } + catch (Exception ex) when (IsRecoverableMobDamageCrash(ex)) + { + // If vanilla Dead Cells tries to damage a half-disposed/desynced mob, keep + // the multiplayer run alive. This can happen after a stale sync id or DLC + // room transition leaves an invalid mob in the hit list. + Log.Warning(ex, "[MobSync] Suppressed recoverable Mob.onDamage crash and removed mob from sync tracking"); + try + { + if (self != null) + { + lock (Sync) + { + RemoveTrackedMobLocked(self); + } + + try { self._targetable = false; } catch { } + } + } + catch + { + } + return; + } try { diff --git a/ModEntry/ModEntry.BossCine.cs b/ModEntry/ModEntry.BossCine.cs index 9ca9a01..0a328e2 100644 --- a/ModEntry/ModEntry.BossCine.cs +++ b/ModEntry/ModEntry.BossCine.cs @@ -907,10 +907,10 @@ private void SuppressRemoteBossDeathCineIfNeeded() return; var typeName = cine.GetType().Name ?? string.Empty; - if (!BossDeathCineTypeNames.Contains(typeName)) + if (!IsBossDeathCinematicName(typeName)) return; - SuppressRemoteBossDeathCineState(cine); + ObserveRemoteBossDeathCineState(cine); } catch { @@ -919,26 +919,274 @@ private void SuppressRemoteBossDeathCineIfNeeded() private bool ShouldSuppressRemoteBossDeathCineConstruction() { - return _netRole == NetRole.Client && _net != null && _net.IsAlive; + // v6.0: do not suppress vanilla boss-death cinematics anymore. Suppressing them + // prevented the client from seeing/receiving boss reward-room state and could leave + // the player frozen until the exit failsafe moved them forward. Keep the hooks + // installed for compatibility, but let vanilla run and recover controls afterwards. + return false; + } + + private static bool IsBossDeathCinematicName(string? typeName) + { + if (string.IsNullOrWhiteSpace(typeName)) + return false; + + var trimmed = typeName.Trim(); + if (BossIntroCineTypeNames.Contains(trimmed) || + trimmed.StartsWith("Enter", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("Start", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("Meet", StringComparison.OrdinalIgnoreCase)) + return false; + + if (BossDeathCineTypeNames.Contains(trimmed)) + return true; + + // Covers generated DLC/vanilla names not present in this proxy build, such as + // Concierge/TimeKeeper/Scarecrow/MamaTick-style death cinematics, without catching + // HeroDeath because those are filtered before this method is called. + return typeName.IndexOf("Death", StringComparison.OrdinalIgnoreCase) >= 0 || + typeName.IndexOf("Defeat", StringComparison.OrdinalIgnoreCase) >= 0 || + typeName.IndexOf("Kill", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private void ObserveRemoteBossDeathCineState(dc.GameCinematic? cine) + { + // Do not destroy/dispose the cine here: vanilla needs it to spawn boss rewards, unlock + // doors, and finish reward-room state. We only schedule an unstuck pass after a short + // grace delay, so the cinematic/reward setup has time to run first. + MarkBossVictoryRecoveryWindow("boss_death_cine_observed", 24.0, 3.0); + } + + private void TryScheduleBossVictoryRecoveryFromBossDeath() + { + if (_netRole != NetRole.Client || _net == null || !_net.IsAlive) + return; + + var currentLevelId = GetCurrentLevelId(); + if (!IsBossLevel(currentLevelId)) + return; + + if (_bossVictoryRecoveryUntilTicks != 0) + return; + + var hero = me ?? ModCore.Modules.Game.Instance?.HeroInstance; + var level = hero?._level; + if (hero == null || level == null) + return; + + if (!string.Equals(_bossDeathPollLevelId, currentLevelId, StringComparison.OrdinalIgnoreCase)) + { + _bossDeathPollLevelId = currentLevelId; + _bossDeathPollSawLiveBoss = false; + _bossDeathPollGoneSinceTicks = 0; + } + + if (TryGetCurrentBossDeathCinematicName(out _)) + { + MarkBossVictoryRecoveryWindow("boss_death_cine_polling_fallback", 45.0, 2.0); + return; + } + + var hasBossObject = false; + var bossAlive = false; + try + { + var boss = level.boss; + if (boss != null) + { + hasBossObject = true; + bossAlive = !boss.destroyed && boss.life > 0; + } + } + catch + { + } + + if (bossAlive) + { + _bossDeathPollSawLiveBoss = true; + _bossDeathPollGoneSinceTicks = 0; + return; + } + + if (!_bossDeathPollSawLiveBoss) + return; + + var now = Stopwatch.GetTimestamp(); + if (_bossDeathPollGoneSinceTicks == 0) + { + _bossDeathPollGoneSinceTicks = now; + return; + } + + if (now - _bossDeathPollGoneSinceTicks < (long)(Stopwatch.Frequency * (hasBossObject ? 0.25 : 0.75))) + return; + + MarkBossVictoryRecoveryWindow("boss_dead_polling_fallback", 45.0, 2.0); + } + + private bool TryGetCurrentBossDeathCinematicName(out string typeName) + { + typeName = string.Empty; + try + { + var game = dc.pr.Game.Class.ME; + var cine = game?.curCine; + if (cine == null || cine.destroyed) + return false; + + if (cine is DeadBase || cine is RemoteDownedCorpse) + return false; + if (cine is HeroDeath || cine is HeroDeathBase || cine is HeroDeathContinue || + cine is HeroDeathRespawn || cine is HeroDeathDLCP) + return false; + + typeName = cine.GetType().Name ?? string.Empty; + return IsBossDeathCinematicName(typeName); + } + catch + { + return false; + } } private void SuppressRemoteBossDeathCineState(dc.GameCinematic? cine) { + // Kept as a compatibility shim for existing hook methods. In v6.0 this no longer + // suppresses the cine; it observes it and lets vanilla reward logic continue. + ObserveRemoteBossDeathCineState(cine); + } + + private void MarkBossVictoryRecoveryWindow(string reason, double seconds) + { + MarkBossVictoryRecoveryWindow(reason, seconds, 0.0); + } + + private void MarkBossVictoryRecoveryWindow(string reason, double seconds, double delaySeconds) + { + var now = Stopwatch.GetTimestamp(); + var durationTicks = (long)(Stopwatch.Frequency * System.Math.Max(1.0, seconds)); + var delayTicks = (long)(Stopwatch.Frequency * System.Math.Max(0.0, delaySeconds)); + _bossVictoryRecoveryUntilTicks = now + durationTicks; + _bossVictoryRecoveryUnlockAfterTicks = now + delayTicks; + _nextBossVictoryRecoveryTick = now + delayTicks; + _bossVictoryRecoveryReason = string.IsNullOrWhiteSpace(reason) ? "boss_victory" : reason.Trim(); + } + + private void MaintainBossVictoryUnstuckRecovery() + { + if (_bossVictoryRecoveryUntilTicks == 0) + return; + + var now = Stopwatch.GetTimestamp(); + if (now > _bossVictoryRecoveryUntilTicks) + { + _bossVictoryRecoveryUntilTicks = 0; + _bossVictoryRecoveryUnlockAfterTicks = 0; + _nextBossVictoryRecoveryTick = 0; + _bossVictoryRecoveryReason = string.Empty; + return; + } + + if (_bossVictoryRecoveryUnlockAfterTicks != 0 && now < _bossVictoryRecoveryUnlockAfterTicks) + return; + + if (now < _nextBossVictoryRecoveryTick) + return; + + _nextBossVictoryRecoveryTick = now + (long)(Stopwatch.Frequency * 0.35); + RecoverLocalHeroAfterBossVictory(string.IsNullOrWhiteSpace(_bossVictoryRecoveryReason) + ? "boss_victory_recovery_window" + : _bossVictoryRecoveryReason); + } + + private bool ShouldAllowBossVictoryRecoveryAtLevel(string? currentLevelId) + { + if (IsBossLevel(currentLevelId)) + return true; + + // Boss death often moves the client into a reward/transition room before controls are + // fully released. Do not cancel an active boss-recovery window just because the level + // id changed from e.g. Bridge/Throne to a T_* transition area. + return _bossVictoryRecoveryUntilTicks != 0; + } + + private void RecoverLocalHeroAfterBossVictory(string reason) + { + if (_netRole == NetRole.None || _net == null || !_net.IsAlive) + return; + + var hero = me ?? ModCore.Modules.Game.Instance?.HeroInstance; + if (hero == null) + return; + + var currentLevelId = GetCurrentLevelId(); + if (!ShouldAllowBossVictoryRecoveryAtLevel(currentLevelId)) + return; + try { var game = dc.pr.Game.Class.ME; - if (game != null && cine != null && ReferenceEquals(game.curCine, cine)) - game.curCine = null; + var cine = game?.curCine; + if (cine is HeroDeath || cine is HeroDeathBase || cine is HeroDeathContinue || + cine is HeroDeathRespawn || cine is HeroDeathDLCP) + { + game!.curCine = null; + try { cine.destroy(); } catch { } + try { cine.disposeImmediately(); } catch { } + } + } + catch + { + } + + if (_localFakeDead) + { + ResetFakeDeathState( + unlockLocalHero: true, + sendNetworkUpState: true, + clearRemoteDownedTracking: false, + clearDownedAnnouncements: false); + } + + try + { + if (hero.life <= 0) + hero.life = 1; } catch { } - try { cine?.destroy(); } catch { } - try { cine?.disposeImmediately(); } catch { } - try { me?.cancelSkillControlLock(); } catch { } - try { me?.unlockControls(); } catch { } - EnsureHeroVisibilityAfterRoomChange(me); + try { StopLocalDeadCine(); } catch { } + if (IsHeroRuntimeSafeForControlUnlock(hero)) + { + try { hero.cancelVelocities(); } catch { } + try { hero.cancelSkillControlLock(); } catch { } + try { hero.unlockControls(); } catch { } + try { hero._targetable = true; } catch { } + } + + try + { + var data = hero._level?.game?.data; + if (data != null) + data.stopGameTime = false; + } + catch + { + } + + _postReviveLockUntilTicks = 0; + _nextDownedStateSendTicks = 0; + + try + { + Logger.Information("[BossSync] Applied local boss-victory unstuck recovery reason={Reason} level={LevelId}", reason, currentLevelId); + } + catch + { + } } private void Hook__BeholderDeath__constructor__(Hook__BeholderDeath.orig___constructor__ orig, BeholderDeath e, Beholder boss) diff --git a/ModEntry/ModEntry.DebugHero.cs b/ModEntry/ModEntry.DebugHero.cs index 82588b4..f7f385a 100644 --- a/ModEntry/ModEntry.DebugHero.cs +++ b/ModEntry/ModEntry.DebugHero.cs @@ -48,41 +48,31 @@ private void ApplyDebugHeroRuntimeOptions() TryApplyDebugExplorerRune(hero); } + private static bool s_debugStartPerkDisabledNoticeLogged; + private void TryApplyDebugStartPerk(Hero hero) { - if (hero == null) - return; + // Stability v5.5: disable the optional debug start-perk injection completely. + // + // The uploaded crash logs show repeated Hashlink crashes from + // new InventItem(new InventItemKind.Perk("P_Necromancy")) while Dead Cells is + // still initializing / while combat is running. Even when caught in C#, that + // Hashlink exception can poison the HL runtime and later crash the run with + // Null access .commonProps. This feature is only a debug convenience, so the + // safest multiplayer behavior is to never create InventItem perks from the + // mod at runtime. Runes/minimap debug options are left intact below. + _debugPerkAppliedHero = null; + _debugPerkAppliedId = string.Empty; + _nextDebugPerkApplyTick = 0; var configuredPerkId = MultiplayerSettingsStorage.DebugStartPerkId; - if (string.IsNullOrWhiteSpace(configuredPerkId) || - string.Equals(configuredPerkId, MultiplayerSettingsStorage.NoStartPerkValue, StringComparison.OrdinalIgnoreCase)) - { - _debugPerkAppliedHero = null; - _debugPerkAppliedId = string.Empty; - _nextDebugPerkApplyTick = 0; - return; - } - - var perkId = configuredPerkId.Trim(); - if (ReferenceEquals(_debugPerkAppliedHero, hero) && - string.Equals(_debugPerkAppliedId, perkId, StringComparison.Ordinal)) + if (!s_debugStartPerkDisabledNoticeLogged && + !string.IsNullOrWhiteSpace(configuredPerkId) && + !string.Equals(configuredPerkId, MultiplayerSettingsStorage.NoStartPerkValue, StringComparison.OrdinalIgnoreCase)) { - return; + s_debugStartPerkDisabledNoticeLogged = true; + Logger.Warning("[NetMod][Stability] Debug start perk {PerkId} is disabled in v5.5 to prevent InventItem.commonProps crashes.", configuredPerkId.Trim()); } - - var now = Stopwatch.GetTimestamp(); - if (_nextDebugPerkApplyTick != 0 && now < _nextDebugPerkApplyTick) - return; - - var item = new InventItem(new InventItemKind.Perk(perkId.AsHaxeString())); - hero.applyItemPickEffect(hero, item); - - if (string.Equals(perkId, "P_Yolo", StringComparison.OrdinalIgnoreCase)) - hero.tryToApplyYoloPerk(); - - _debugPerkAppliedHero = hero; - _debugPerkAppliedId = perkId; - _nextDebugPerkApplyTick = 0; } private void TryApplyDebugExplorerRune(Hero hero) diff --git a/ModEntry/ModEntry.GhostSync.cs b/ModEntry/ModEntry.GhostSync.cs index 7b2590f..bbfed94 100644 --- a/ModEntry/ModEntry.GhostSync.cs +++ b/ModEntry/ModEntry.GhostSync.cs @@ -348,6 +348,9 @@ private void SendHeroCoords() public static double[] rLastX = new double[NetNode.MaxClientSlots]; public static double[] rLastY = new double[NetNode.MaxClientSlots]; + public static double[] EmergencyLastRemoteX = new double[NetNode.MaxClientSlots]; + public static double[] EmergencyLastRemoteY = new double[NetNode.MaxClientSlots]; + public static long[] EmergencyLastRemoteTicks = new long[NetNode.MaxClientSlots]; internal static bool TryGetClientIndex(int localId, int remoteId, out int index) { @@ -496,6 +499,9 @@ private void ReceiveGhostCoords() remotePlayerId = remote.Id; clientIds[index] = remote.Id; + EmergencyLastRemoteX[index] = remote.X; + EmergencyLastRemoteY[index] = remote.Y; + EmergencyLastRemoteTicks[index] = Stopwatch.GetTimestamp(); ProcessRemoteDoorMarker(remote); if (!ShouldKeepRemoteKingVisibleInRoom(remote, localLevelId)) { @@ -678,12 +684,31 @@ private bool ShouldKeepRemoteKingVisibleInRoom(NetNode.RemoteSnapshot remote, st if (!string.Equals(remoteContextLevelId, localContextLevelId, StringComparison.Ordinal)) return false; + // Same main level should keep the remote visible even if a stale door/room marker is + // temporarily different after forced exit assist or teleporter transitions. Only use + // the room-token hide when the local player is actually inside a sublevel/branch. + if (!IsLocalHeroInSubLevel()) + return true; + if (remote.RoomId.Value != localBranchToken) return false; return true; } + private static bool IsLocalHeroInSubLevel() + { + try + { + var level = me?._level ?? ModCore.Modules.Game.Instance?.HeroInstance?._level; + return level != null && level.isSubLevel; + } + catch + { + return false; + } + } + private void ProcessRemoteDoorMarker(NetNode.RemoteSnapshot remote) { if (!remote.HasRoom || @@ -823,6 +848,8 @@ private void DisposeClientSlot(int slot, bool clearIdentity) clientLastDownedOffsets[slot] = false; rLastX[slot] = 0; rLastY[slot] = 0; + // Do not clear EmergencyLastRemoteX/Y here. F8 recovery needs the last known + // network position even when the visible ghost was hidden by a room/sublevel mismatch. if (!clearIdentity) return; @@ -839,46 +866,15 @@ private void DisposeClientSlot(int slot, bool clearIdentity) private void ReceiveGhostWeapons() { - var hitchStart = RuntimeHitchWatch.Start(); + // v5.7: stability mode. Do not create/equip the remote player's real weapons on + // the ghost. Heavy/charged weapons such as Flint can start vanilla powered-feedback + // cleanup on the receiving client, where the ghost has no real local controller, + // causing Hashlink "Null access .stopPoweredFeedback" crashes. var net = _net; - if (net == null || me == null) return; - - if (!net.TryConsumeRemoteWeaponSnapshots(out var updates)) - return; - - try - { - var applied = 0; - - foreach (var update in updates) - { - var updateStart = RuntimeHitchWatch.Start(); - ApplyRemoteWeaponUpdate(update.Id, update.Kind, update.Slot, update.PermanentId, update.Ammo); - applied++; - LogGhostRuntimeStepIfSlow( - "ModEntry.ReceiveGhostWeapons.ApplyRemoteWeaponUpdate", - updateStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"remoteId={update.Id} slot={update.Slot} permanentId={update.PermanentId} ammo={(update.Ammo.HasValue ? update.Ammo.Value : -1)}")); - } + if (net == null) return; - var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); - if (hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) - { - RuntimeHitchWatch.LogSlow( - Logger, - "ModEntry.ReceiveGhostWeapons", - hitchMs, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"updates={updates.Count} applied={applied}")); - } - } - finally - { + if (net.TryConsumeRemoteWeaponSnapshots(out var updates)) NetNode.ReleaseConsumedList(updates); - } } private void DrainRemoteCombatQueuesAfterLevelChange() @@ -895,108 +891,20 @@ private void DrainRemoteCombatQueuesAfterLevelChange() private void ReceiveGhostAttacks() { - var hitchStart = RuntimeHitchWatch.Start(); + // v5.7: stability mode. Drain remote attack packets but do not replay them on the + // ghost weapon manager. The host's mob HP/death packets still drive progression; + // this only removes client-side visual attack simulation that crashes with Flint. var net = _net; - if (net == null || me == null) return; - - if (!net.TryConsumeRemoteAttacks(out var attacks)) - return; - - try - { - var localId = net.id; - var diveHandled = 0; - var queuedAttacks = 0; - foreach (var attack in attacks) - { - var attackStart = RuntimeHitchWatch.Start(); - if (TryHandleRemoteDiveAttack(attack, localId)) - { - diveHandled++; - LogGhostRuntimeStepIfSlow( - "ModEntry.ReceiveGhostAttacks.Remote", - attackStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"remoteId={attack.Id} slot={attack.Slot} dive=1 action={attack.Action}")); - continue; - } - - if (attack.Slot < 0 && - (string.IsNullOrWhiteSpace(attack.Kind) || - attack.Kind.StartsWith("__", StringComparison.Ordinal))) - { - continue; - } - - ApplyRemoteWeaponUpdate(attack.Id, attack.Kind, attack.Slot, attack.PermanentId, attack.Ammo); - if (!TryGetClientIndex(localId, attack.Id, out var index)) - continue; - - var client = clients[index]; - if (client?.kingWeaponsManager == null) continue; - if (attack.Action == RemoteAttackAction.Interrupt) - client.kingWeaponsManager.queueInterrupt(attack.Slot); - else - client.kingWeaponsManager.queueAttack(attack.Slot); - - queuedAttacks++; - LogGhostRuntimeStepIfSlow( - "ModEntry.ReceiveGhostAttacks.Remote", - attackStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"remoteId={attack.Id} slot={attack.Slot} dive=0 action={attack.Action} kind={attack.Kind ?? string.Empty}")); - } + if (net == null) return; - var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); - if (hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) - { - RuntimeHitchWatch.LogSlow( - Logger, - "ModEntry.ReceiveGhostAttacks", - hitchMs, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"attacks={attacks.Count} diveHandled={diveHandled} queuedAttacks={queuedAttacks}")); - } - } - finally - { + if (net.TryConsumeRemoteAttacks(out var attacks)) NetNode.ReleaseConsumedList(attacks); - } } private void UpdateGhostWeapons() { - var hitchStart = RuntimeHitchWatch.Start(); - var activeManagers = 0; - for (int i = 0; i < clients.Length; i++) - { - var client = clients[i]; - if (client?.kingWeaponsManager == null) continue; - activeManagers++; - var managerStart = RuntimeHitchWatch.Start(); - client.kingWeaponsManager.update(); - LogGhostRuntimeStepIfSlow( - "ModEntry.UpdateGhostWeapons.Manager", - managerStart, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"slot={i} remoteId={clientIds[i]} shield={(client.kingWeaponsManager.IsShieldActive ? 1 : 0)}")); - } - - var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); - if (hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) - { - RuntimeHitchWatch.LogSlow( - Logger, - "ModEntry.UpdateGhostWeapons", - hitchMs, - string.Create( - System.Globalization.CultureInfo.InvariantCulture, - $"activeManagers={activeManagers} clients={clients.Length}")); - } + // v5.7: stability mode. Do not tick remote ghost weapon managers. Their internal + // weapon feedback state is not safe for non-local ghosts in the current DCCM build. } private static int CountPendingClientHeadRecreate() @@ -1024,58 +932,62 @@ private void PlayGhostAnim(GhostKing client, string anim, int? queueAnim, bool? { if (client?.spr?._animManager == null) return; if (string.IsNullOrWhiteSpace(anim)) return; - var shieldActive = client.kingWeaponsManager != null && client.kingWeaponsManager.IsShieldActive; - if (shieldActive && ShouldLoopRemoteAnim(anim)) - { - return; - } - - if (anim.IndexOf("hold", StringComparison.OrdinalIgnoreCase) >= 0 || - anim.IndexOf("shield", StringComparison.OrdinalIgnoreCase) >= 0 || - anim.IndexOf("parry", StringComparison.OrdinalIgnoreCase) >= 0 || - anim.IndexOf("block", StringComparison.OrdinalIgnoreCase) >= 0) - return; + if (!IsSafeNetworkHeroAnim(anim)) return; var animManager = client.spr._animManager; var current = client.spr.groupName; - if(current != null && string.Equals(current.ToString(), anim, StringComparison.Ordinal)) + if (current != null && string.Equals(current.ToString(), anim, StringComparison.Ordinal)) return; - if (ShouldLoopRemoteAnim(anim)) + try { - if (!shieldActive) + if (ShouldLoopRemoteAnim(anim)) { client.removeAllAffects(96); client.removeAllAffects(98); client.removeAllAffects(99); + animManager.play(anim.AsHaxeString(), null, null).loop(null); + return; } - animManager.play(anim.AsHaxeString(), null, null).loop(null); - return; + + // Visual-only: play the remote hero animation, but do not replay the weapon/action + // object itself. This is what keeps Flint and powered-feedback weapons stable. + animManager.play(anim.AsHaxeString(), queueAnim, g).stopOnLastFrame(Ref.Null); + } + catch (Exception ex) + { + Logger.Warning(ex, "[GhostSync] Remote visual animation failed anim={Anim}", anim); } - animManager.play(anim.AsHaxeString(), queueAnim, g).stopOnLastFrame(Ref.Null); } private static bool ShouldLoopRemoteAnim(string anim) { - if(string.IsNullOrWhiteSpace(anim)) return false; + if (string.IsNullOrWhiteSpace(anim)) return false; var a = anim.Trim(); - // Don't ever force-loop weapon/hold-ish states; those should be driven by weapon replication. - if(IsAttackAnim(a)) return false; - if(a.IndexOf("guard", StringComparison.OrdinalIgnoreCase) >= 0) return false; - if(a.IndexOf("defend", StringComparison.OrdinalIgnoreCase) >= 0) return false; + // v5.9: loop only true locomotion/idle animations. One-shot actions such as + // attacks, bow shots, healing, scroll pickup, talking, teleporter use, ladders, + // exits, ground-pound and doors must play once; looping them made movement/level + // transitions look wrong. + if (IsAttackAnim(a)) return false; + if (a.IndexOf("heal", StringComparison.OrdinalIgnoreCase) >= 0) return false; + if (a.IndexOf("potion", StringComparison.OrdinalIgnoreCase) >= 0) return false; + if (a.IndexOf("scroll", StringComparison.OrdinalIgnoreCase) >= 0) return false; + if (a.IndexOf("talk", StringComparison.OrdinalIgnoreCase) >= 0) return false; + if (a.IndexOf("teleport", StringComparison.OrdinalIgnoreCase) >= 0) return false; + if (a.IndexOf("door", StringComparison.OrdinalIgnoreCase) >= 0) return false; + if (a.IndexOf("ground", StringComparison.OrdinalIgnoreCase) >= 0) return false; + if (a.IndexOf("bound", StringComparison.OrdinalIgnoreCase) >= 0) return false; + if (a.IndexOf("ladder", StringComparison.OrdinalIgnoreCase) >= 0) return false; + if (a.IndexOf("climb", StringComparison.OrdinalIgnoreCase) >= 0) return false; + if (a.IndexOf("stair", StringComparison.OrdinalIgnoreCase) >= 0) return false; + if (a.IndexOf("exit", StringComparison.OrdinalIgnoreCase) >= 0) return false; if (a.StartsWith("idle", StringComparison.OrdinalIgnoreCase)) return true; if (a.StartsWith("run", StringComparison.OrdinalIgnoreCase)) return true; if (a.StartsWith("walk", StringComparison.OrdinalIgnoreCase)) return true; if (a.IndexOf("move", StringComparison.OrdinalIgnoreCase) >= 0) return true; - if (a.IndexOf("jump", StringComparison.OrdinalIgnoreCase) >= 0) return true; if (a.IndexOf("fall", StringComparison.OrdinalIgnoreCase) >= 0) return true; - if (a.IndexOf("land", StringComparison.OrdinalIgnoreCase) >= 0) return true; - if (a.IndexOf("climb", StringComparison.OrdinalIgnoreCase) >= 0) return true; - if (a.IndexOf("ladder", StringComparison.OrdinalIgnoreCase) >= 0) return true; - if (a.IndexOf("crouch", StringComparison.OrdinalIgnoreCase) >= 0) return true; - if (a.IndexOf("volte", StringComparison.OrdinalIgnoreCase) >= 0) return true; if (a.IndexOf("remain", StringComparison.OrdinalIgnoreCase) >= 0) return true; return false; @@ -1085,9 +997,18 @@ private void PlayGhostHeadAnim(GhostKing client, string anim) { if (client == null || client?.head == null || client?.head?.customHeadSpr._animManager == null) return; if (string.IsNullOrWhiteSpace(anim)) return; - var animManager = client.head.customHeadSpr._animManager; - animManager.play(anim.AsHaxeString(), null, null).loop(null); - animManager.genSpeed = 0.4; + if (!IsSafeNetworkHeroAnim(anim)) return; + + try + { + var animManager = client.head.customHeadSpr._animManager; + animManager.play(anim.AsHaxeString(), null, null).loop(null); + animManager.genSpeed = 0.4; + } + catch (Exception ex) + { + Logger.Warning(ex, "[GhostSync] Remote head animation failed anim={Anim}", anim); + } } private void SendHeroAnim(string anim, int? queueAnim, bool? g, bool force = false) @@ -1129,6 +1050,7 @@ private void SendEquippedWeapons(Inventory inv) private void SendInventoryWeapon(InventItem item, int slot) { + if (RemoteWeaponVisualSyncDisabled()) return; if (_netRole == NetRole.None) return; if (item == null) return; if (!TryGetWeaponKindId(item, out var kindId)) return; @@ -1150,6 +1072,12 @@ private static bool TryGetWeaponKindId(InventItem item, out string? kindId) return false; } + private static bool RemoteWeaponVisualSyncDisabled() + { + // Method instead of a const so the compiler does not mark the fallback body unreachable. + return true; + } + private static int? GetWeaponAmmoForSync(InventItem? item) { if(item == null) @@ -1183,6 +1111,11 @@ private bool IsLocalInventory(Inventory self) private void ApplyRemoteWeaponUpdate(int remoteId, string? kindId, int slot, int permanentId, int? ammo = null) { + // v5.7: disabled for stability. Creating/equipping remote InventItem weapons on + // ghost heroes can trigger Hashlink powered-feedback cleanup crashes with Flint. + if (RemoteWeaponVisualSyncDisabled()) + return; + var hitchStart = RuntimeHitchWatch.Start(); if (string.IsNullOrWhiteSpace(kindId)) return; var net = _net; diff --git a/ModEntry/ModEntry.NetworkMenu.cs b/ModEntry/ModEntry.NetworkMenu.cs index df97974..5aae0b3 100644 --- a/ModEntry/ModEntry.NetworkMenu.cs +++ b/ModEntry/ModEntry.NetworkMenu.cs @@ -39,6 +39,7 @@ public void StartSteamClientFromMenu(ulong hostSteamId) private void StartHostCore(Action createHost) { + s_isDisposing = false; _net?.Dispose(); ResetNetworkState(); createHost(); @@ -59,6 +60,7 @@ private void StartHostWithEndpoint(IPEndPoint ep) private void StartClientCore(Action createClient) { + s_isDisposing = false; _net?.Dispose(); var main = dc.Main.Class.ME; if (main?.user != null) @@ -101,6 +103,7 @@ private void StartClientWithSteamTransport(ulong hostSteamId) public void StopNetworkFromMenu() { + StopSteamCallbackPumpTimer(); var roleBeforeStop = _netRole; if (roleBeforeStop == NetRole.Client) { diff --git a/ModEntry/ModEntry.StabilityGuards.cs b/ModEntry/ModEntry.StabilityGuards.cs new file mode 100644 index 0000000..f3f430f --- /dev/null +++ b/ModEntry/ModEntry.StabilityGuards.cs @@ -0,0 +1,19 @@ +using dc.en.inter; + +namespace DeadCellsMultiplayerMod +{ + public partial class ModEntry + { + // v5.6 rollback: v5.5's live level-quad mutation was too aggressive and could + // destabilize Hashlink GC/native state. Keep these methods as safe no-ops so older + // ModEntry.cs call sites compile, but do not mutate level entity/quad lists. + private void RunFrameStabilityGuards() + { + } + + private void Hook_HiddenTrigger_fixedUpdate(Hook_HiddenTrigger.orig_fixedUpdate orig, HiddenTrigger self) + { + orig?.Invoke(self); + } + } +} diff --git a/ModEntry/ModEntry.Steam.cs b/ModEntry/ModEntry.Steam.cs index 66881b4..3343af8 100644 --- a/ModEntry/ModEntry.Steam.cs +++ b/ModEntry/ModEntry.Steam.cs @@ -62,46 +62,56 @@ private static void OnGameLobbyJoinRequested(GameLobbyJoinRequested_t data) WriteOverlayJoinDiagnostic("GameLobbyJoinRequested_t", data.m_steamIDLobby.m_SteamID.ToString()); Instance?.Logger.Information("[NetMod][Steam] GameLobbyJoinRequested_t callback fired"); var lobbyId = data.m_steamIDLobby.m_SteamID; - if (lobbyId == 0UL) + var friendSteamId = data.m_steamIDFriend.m_SteamID; + if (lobbyId == 0UL && friendSteamId == 0UL) return; - Instance?.Logger.Information("[NetMod][Steam] Overlay lobby join requested lobbyId={LobbyId}", lobbyId); - EnqueueAndProcessOverlayJoin(lobbyId, "GameLobbyJoinRequested_t"); + Instance?.Logger.Information("[NetMod][Steam] Overlay lobby join requested lobbyId={LobbyId} friendSteamId={FriendSteamId}", lobbyId, friendSteamId); + EnqueueAndProcessOverlayJoin(lobbyId, "GameLobbyJoinRequested_t", friendSteamId); } private static void OnGameRichPresenceJoinRequested(GameRichPresenceJoinRequested_t data) { var connect = data.m_rgchConnect ?? string.Empty; + var friendSteamId = data.m_steamIDFriend.m_SteamID; WriteOverlayJoinDiagnostic("GameRichPresenceJoinRequested_t", connect); - Instance?.Logger.Information("[NetMod][Steam] GameRichPresenceJoinRequested_t callback fired"); + Instance?.Logger.Information("[NetMod][Steam] GameRichPresenceJoinRequested_t callback fired friendSteamId={FriendSteamId}", friendSteamId); if (string.IsNullOrWhiteSpace(connect)) { - Instance?.Logger.Information("[NetMod][Steam] Rich Presence join requested but connect string is empty (host may not have set Rich Presence)"); + if (friendSteamId != 0UL) + { + Instance?.Logger.Information("[NetMod][Steam] Rich Presence join had empty connect string; falling back to direct friend Steam P2P join friendSteamId={FriendSteamId}", friendSteamId); + EnqueueAndProcessOverlayJoin(0UL, "GameRichPresenceJoinRequested_t:direct-friend-fallback", friendSteamId); + } + else + { + Instance?.Logger.Information("[NetMod][Steam] Rich Presence join requested but connect string and friend Steam ID are empty"); + } return; } - Instance?.Logger.Information("[NetMod][Steam] Overlay Rich Presence join requested connect={Connect}", connect); + Instance?.Logger.Information("[NetMod][Steam] Overlay Rich Presence join requested connect={Connect} friendSteamId={FriendSteamId}", connect, friendSteamId); var lobbyId = TryParseLobbyIdFromConnectString(connect); - if (lobbyId == 0UL) + if (lobbyId == 0UL && friendSteamId == 0UL) { - Instance?.Logger.Warning("[NetMod][Steam] Could not parse lobby ID from connect string: {Connect}", connect); + Instance?.Logger.Warning("[NetMod][Steam] Could not parse lobby ID from connect string and no friend fallback was available: {Connect}", connect); return; } - EnqueueAndProcessOverlayJoin(lobbyId, "GameRichPresenceJoinRequested_t"); + EnqueueAndProcessOverlayJoin(lobbyId, "GameRichPresenceJoinRequested_t", friendSteamId); } - private static void EnqueueAndProcessOverlayJoin(ulong lobbyId, string source) + private static void EnqueueAndProcessOverlayJoin(ulong lobbyId, string source, ulong fallbackHostSteamId = 0UL) { var nowTicks = Environment.TickCount64; if (lobbyId == s_lastOverlayJoinLobbyId && nowTicks - s_lastOverlayJoinTicks < SteamOverlayJoinDedupMs) { - Instance?.Logger.Debug("[NetMod][Steam] Ignoring duplicate overlay join request lobbyId={LobbyId} source={Source}", lobbyId, source); + Instance?.Logger.Debug("[NetMod][Steam] Ignoring duplicate overlay join request lobbyId={LobbyId} fallbackHostSteamId={FallbackHostSteamId} source={Source}", lobbyId, fallbackHostSteamId, source); return; } s_lastOverlayJoinLobbyId = lobbyId; s_lastOverlayJoinTicks = nowTicks; - Instance?.Logger.Information("[NetMod][Steam] Queueing overlay join request lobbyId={LobbyId} source={Source}", lobbyId, source); - GameMenu.EnqueueMainThreadCoalesced("steam:overlay-join", () => GameMenu.HandleSteamOverlayJoinRequest(lobbyId)); + Instance?.Logger.Information("[NetMod][Steam] Queueing overlay join request lobbyId={LobbyId} fallbackHostSteamId={FallbackHostSteamId} source={Source}", lobbyId, fallbackHostSteamId, source); + GameMenu.EnqueueMainThreadCoalesced("steam:overlay-join", () => GameMenu.HandleSteamOverlayJoinRequest(lobbyId, fallbackHostSteamId)); } private static ulong TryParseLobbyIdFromConnectString(string connect) @@ -122,7 +132,15 @@ private static ulong TryParseLobbyIdFromConnectString(string connect) private static void TryRunSteamCallbacks() { - SteamAPI.RunCallbacks(); + try + { + if (!s_isDisposing) + SteamAPI.RunCallbacks(); + } + catch (Exception ex) + { + Instance?.Logger.Debug(ex, "[NetMod][Steam] Steam callback pump ignored failure"); + } } /// @@ -226,14 +244,37 @@ private static void TryPollSteamOverlayJoinFromLaunchData() /// private static void StartSteamCallbackPumpTimer() { - if (s_steamCallbackPumpTimer != null) + if (s_steamCallbackPumpTimer != null || s_isDisposing) return; s_steamCallbackPumpTimer = new Timer( - _ => SteamAPI.RunCallbacks(), + _ => TryRunSteamCallbacks(), null, TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(100)); Instance?.Logger.Debug("[NetMod] Steam callback pump timer started"); } + + private static void StopSteamCallbackPumpTimer() + { + var timer = Interlocked.Exchange(ref s_steamCallbackPumpTimer, null); + if (timer == null) + return; + + try + { + timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + catch + { + } + + try + { + timer.Dispose(); + } + catch + { + } + } } } diff --git a/ModEntry/ModEntry.cs b/ModEntry/ModEntry.cs index 7d3e1ab..f5da2ec 100644 --- a/ModEntry/ModEntry.cs +++ b/ModEntry/ModEntry.cs @@ -33,6 +33,7 @@ using DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI; using DeadCellsMultiplayerMod.MultiplayerModUI.LevelExit; using DeadCellsMultiplayerMod.MultiplayerModUI.Connection; +using DeadCellsMultiplayerMod.WorldSync; using DeadCellsMultiplayerMod.Tools.ModLang; using DeadCellsMultiplayerMod.Tools; using DeadCellsMultiplayerMod.KingHead; @@ -59,6 +60,7 @@ public partial class ModEntry(ModInfo info) : ModBase(info), private static IDisposable? s_steamRichPresenceJoinCallback; private static bool s_steamOverlayCallbackPending; private static Timer? s_steamCallbackPumpTimer; + private static bool s_isDisposing; private static int s_steamOverlayCallbackRetryCount; private static bool s_steamApiReady; private static string s_lastSteamLaunchCommand = string.Empty; @@ -143,16 +145,27 @@ public partial class ModEntry(ModInfo info) : ModBase(info), private double _postReviveLockY; private int _reviveHoldTargetId; private long _reviveHoldStartedTicks; - private const double ReviveUseDistancePx = 48.0; + private int _reviveBurstTargetId; + private long _reviveBurstUntilTicks; + private long _nextReviveBurstSendTicks; + private bool _localDownedStatsCaptured; + private int _localDownedSavedMaxLife; + private int _localDownedSavedBrutalityTier; + private int _localDownedSavedSurvivalTier; + private int _localDownedSavedTacticTier; + private int _localDownedSavedBonusLife; + private const double ReviveUseDistancePx = 192.0; private const double ReviveAttemptCooldownSeconds = 0.2; - private const double ReviveHoldSeconds = 0.7; + private const double ReviveHoldSeconds = 0.45; + private const double ReviveRequestBurstSeconds = 1.25; + private const double ReviveRequestBurstIntervalSeconds = 0.18; private const double ReviveHomunculusBodyMaxDistancePx = 64.0; - private const double DownedStateResendSeconds = 0.4; + private const double DownedStateResendSeconds = 0.25; private const double DownedHeadStateResendSeconds = 1.0 / 30.0; private const double DownedGhostBodyYOffsetPx = 40.0; private const double LocalReviveBodyYOffsetPx = 0.5; - private const double PostRevivePositionLockSeconds = 0.0; - private const string ReviveHintText = "Hold to revive."; + private const double PostRevivePositionLockSeconds = 0.15; + private const string ReviveHintText = "Hold R / controller action to revive."; private string _lastDoorMarkerLevelId = string.Empty; private int _lastDoorMarkerToken = int.MinValue; private string _localLastDoorMarkerLevelId = string.Empty; @@ -211,6 +224,7 @@ internal static bool IsBossLevel(string? levelId) { "BeholderDeath", "GiantDeath", "GiantDeath4", "KillKingCinem", "KillQueenCinem", "QueenDefeated", "KillDookuBeastCinem", "FakeKillDooku", "RichterDeath", + "ConciergeDeath", "TimeKeeperDeath", "GardenerDeath", "ScarecrowDeath", "MamaTickDeath", "EndCollectorPreSmash", "SmashCinem", "EndCollectorPostSmash", "EndCollectorPostSmashKS" }; private static readonly HashSet BossIntroCineTypeNames = new(StringComparer.Ordinal) @@ -233,6 +247,13 @@ internal static bool IsBossLevel(string? levelId) private const double BossHeroTeleportEchoSuppressSeconds = 1.5; private int _suppressBossCineSendDepth; private long _suppressBossTriggerNetSendUntilTick; + private long _bossVictoryRecoveryUntilTicks; + private long _bossVictoryRecoveryUnlockAfterTicks; + private long _nextBossVictoryRecoveryTick; + private string _bossVictoryRecoveryReason = string.Empty; + private string _bossDeathPollLevelId = string.Empty; + private bool _bossDeathPollSawLiveBoss; + private long _bossDeathPollGoneSinceTicks; void IOnAfterLoadingCDB.OnAfterLoadingCDB(dc._Data_ cdb) @@ -373,6 +394,14 @@ public override void Initialize() DebugModuleId.InteractionSync, "InteractionSync", () => _ = new InteractionSync(this)); + + // v6.4.4: re-enable WorldObjectSync in visibility-safe / host-authoritative mode. + // It no longer reads sprite culling as "hidden" and never forces spr.visible=false + // or alpha=0, which prevents the v6.4 invisible synced-object regression. + _ = new WorldObjectSync(this); + + _ = new StuckRecoveryFailsafe(this); + InitializeOptionalModule( DebugModuleId.ConnectionUI, "ConnectionUI", @@ -426,12 +455,12 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) Hook_Hero.canBeHitBy += Hook_Hero_canBeHitBy; Hook_Game.hasCinematic += Hook_Game_hasCinematic; Hook_ZDoor.onActivate += Hook_ZDoor_onActivate; - Hook_BossRushDoor.initGfx += Hook_BossRushDoor_initGfx; Hook_Hero.applySkin += Hook_Hero_applySkin; Hook_HeroHead.initCustomHead += Hook_HeroHead_initCustomHead; Hook_DiveAttack.onStart += Hook_DiveAttack_onStart; Hook_DiveAttack.onOwnerLand += Hook_DiveAttack_onOwnerLand; Hook_HiddenTrigger.trigger += Hook_HiddenTrigger_trigger; + // v5.6: do not hook HiddenTrigger.fixedUpdate; v5.5's trigger crashguard was too invasive. Hook__HeroDeath.__constructor__ += Hook__HeroDeath__constructor__; Hook__HeroDeathBase.__constructor__ += Hook__HeroDeathBase__constructor__; Hook__HeroDeathContinue.__constructor__ += Hook__HeroDeathContinue__constructor__; @@ -514,92 +543,6 @@ private void Hook_ZDoor_onActivate(Hook_ZDoor.orig_onActivate orig, ZDoor self, } } - /// - /// Client often lacks Boss Rush door animation frames until assets settle; HL throws "Unknown frame: bossRushDoor*". - /// Only swallow those — rethrow everything else so unrelated bugs are not masked (and to avoid odd door state). - /// - private static bool IsBossRushDoorMissingFrameException(Exception ex) - { - for (var cur = ex; cur != null; cur = cur.InnerException) - { - var msg = cur.Message; - if (string.IsNullOrWhiteSpace(msg)) - continue; - if (msg.IndexOf("Unknown frame", StringComparison.OrdinalIgnoreCase) < 0) - continue; - if (msg.IndexOf("bossRushDoor", StringComparison.OrdinalIgnoreCase) < 0) - continue; - return true; - } - - return false; - } - - /// - /// At most one deferred orig(self) per door instance. - /// - private static readonly ConditionalWeakTable s_bossRushDoorGfxDeferredPending = new(); - - /// - /// Client-only: missing boss-rush door anim frames. Do not call for - /// BossRushZone from this hook — it can run during level/entity init and corrupt unrelated HL state (casts such as - /// tool.CPoint vs level.LevelMap). - /// - private void Hook_BossRushDoor_initGfx(Hook_BossRushDoor.orig_initGfx orig, BossRushDoor self) - { - try - { - orig(self); - } - catch (Exception ex) - { - if (_netRole != NetRole.Client || self == null || !IsBossRushDoorMissingFrameException(ex)) - throw; - - string? bossRushType = null; - try { bossRushType = self.bossRushType?.ToString(); } catch { } - - Logger.Warning("[NetMod] BossRushDoor.initGfx failed on client level={LevelId}: type={Type} ({Msg})", - levelId, - bossRushType ?? "null", - ex.Message); - - if (s_bossRushDoorGfxDeferredPending.TryGetValue(self, out _)) - { - Logger.Warning("[NetMod] BossRushDoor.initGfx second sync failure before deferred retry; clearing spr level={LevelId}", levelId); - try { self.spr = null; } catch (Exception ex2) { Logger.Warning(ex2, "[NetMod] BossRushDoor spr=null failed"); } - return; - } - - s_bossRushDoorGfxDeferredPending.Add(self, new object()); - var localOrig = orig; - var localSelf = self; - GameMenu.EnqueueMainThread(() => - { - try - { - localOrig(localSelf); - } - catch (Exception ex2) - { - if (_netRole != NetRole.Client || !IsBossRushDoorMissingFrameException(ex2)) - { - Logger.Warning(ex2, "[NetMod] BossRushDoor.initGfx deferred retry unexpected error"); - return; - } - - string? t = null; - try { t = localSelf.bossRushType?.ToString(); } catch { } - Logger.Warning("[NetMod] BossRushDoor.initGfx deferred retry still missing frames level={LevelId}: type={Type} ({Msg})", - levelId, - t ?? "null", - ex2.Message); - try { localSelf.spr = null; } catch (Exception ex3) { Logger.Warning(ex3, "[NetMod] BossRushDoor spr=null failed"); } - } - }); - } - } - private void Hook_HiddenTrigger_trigger(Hook_HiddenTrigger.orig_trigger orig, HiddenTrigger self, Entity dh) { var senderLevelId = string.IsNullOrWhiteSpace(levelId) ? string.Empty : levelId.Trim(); @@ -667,6 +610,9 @@ private void Hook_User_unserialize(Hook_User.orig_unserialize orig, User self, d private void Hook_Game_onDispose(Hook_Game.orig_onDispose orig, dc.pr.Game self) { + s_isDisposing = true; + StopSteamCallbackPumpTimer(); + if (_netRole == NetRole.Client) { var user = self?.user; @@ -719,6 +665,7 @@ private void hook_boot_update(Hook_Boot.orig_update orig, Boot self, double dt) PumpSteamCallbacksForOverlay(); GameMenu.ProcessMainThreadQueue(); GameMenu.HandleTextInputClipboardShortcuts(); + // v5.6: frame stability guards disabled; they mutated live Hashlink structures. _ghost?.UpdateLabels(); ProcessCameraSpectateInput(); } @@ -809,12 +756,14 @@ private AnimManager Hook_AnimManager_play(Hook_AnimManager.orig_play orig, AnimM if (me != null && me?.spr?._animManager != null && ReferenceEquals(self, me.spr._animManager)) { if (!DeadCellsMultiplayerMod.Ghost.KingWeaponSupport.IsInKingContext && - !IsAttackAnim(play)) + IsSafeNetworkHeroAnim(play)) SendHeroAnim(play, queueAnim, g); } if(me != null && me.heroHead.customHeadSpr != null && ReferenceEquals(self, me.heroHead.customHeadSpr._animManager)) { - SendHeadAnim(play); + // v5.7: apply the same attack/weapon filter to head animation packets. + if (IsSafeNetworkHeroAnim(play)) + SendHeadAnim(play); } return orig(self, plays, queueAnim, g); @@ -841,6 +790,21 @@ private static bool IsAttackAnim(string anim) return false; } + + private static bool IsSafeNetworkHeroAnim(string anim) + { + if (string.IsNullOrWhiteSpace(anim)) return false; + + // v5.9: visual-only animation sync. Remote weapon objects/projectiles stay disabled + // for stability, but the ghost body/head may play the same vanilla animation name. + // This restores visible attacks, bows, healing, scrolls, teleports, talking, ladders, + // exits, and other one-shot player animations without constructing a remote weapon. + var a = anim.Trim(); + if (a.Length == 0) return false; + if (a.IndexOf("stopPoweredFeedback", StringComparison.OrdinalIgnoreCase) >= 0) return false; + return true; + } + public void hook_level_changed(Hook_Hero.orig_onLevelChanged orig, Hero self, Level oldLevel) { kingInitialized = false; @@ -851,6 +815,21 @@ public void hook_level_changed(Hook_Hero.orig_onLevelChanged orig, Hero self, Le _appliedBossHeroTeleportLevels.Clear(); _lastBossCineSentLevelId = null; _lastBossCineSentTick = 0; + var oldLevelIdForBossRecovery = string.Empty; + try { oldLevelIdForBossRecovery = oldLevel?.map?.id?.ToString()?.Trim() ?? string.Empty; } catch { } + var preserveBossVictoryRecovery = + _bossVictoryRecoveryUntilTicks != 0 && + IsBossLevel(oldLevelIdForBossRecovery); + if (!preserveBossVictoryRecovery) + { + _bossVictoryRecoveryUntilTicks = 0; + _bossVictoryRecoveryUnlockAfterTicks = 0; + _nextBossVictoryRecoveryTick = 0; + _bossVictoryRecoveryReason = string.Empty; + } + _bossDeathPollLevelId = string.Empty; + _bossDeathPollSawLiveBoss = false; + _bossDeathPollGoneSinceTicks = 0; ResetFakeDeathState(unlockLocalHero: true, sendNetworkUpState: false, clearRemoteDownedTracking: false, clearDownedAnnouncements: false); me = self; me._targetable = true; @@ -920,6 +899,8 @@ public void OnFrameUpdate(double dt) ApplyReceivedBossHeroTeleport(); ApplyReceivedBossCine(); SuppressRemoteBossDeathCineIfNeeded(); + TryScheduleBossVictoryRecoveryFromBossDeath(); + MaintainBossVictoryUnstuckRecovery(); var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); if (hitchMs >= RuntimeHitchWatch.ModFrameSlowThresholdMs) diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..9a1741f --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,10 @@ +# Notice + +DeadCellsCoopPlus is an independent community-maintained fork of the original **Dead Cells Multiplayer Mod**. + +Original repository: +https://github.com/vaiserYT/DeadCellsMultiplayerMod + +This project continues under the original MIT License. + +DeadCellsCoopPlus focuses on multiplayer bug fixes, synchronization improvements, and quality-of-life features while preserving full credit to the original author. diff --git a/README.md b/README.md index e041686..ef2f2f6 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,37 @@ -
+# DeadCellsCoopPlus -English • [Русский](README_ru.md) - -
-

Dead Cells Multiplayer Mod

+A community-maintained fork of the original **Dead Cells Multiplayer Mod** focused on stability, synchronization, bug fixes, and quality-of-life improvements. -**DeadCellsMultiplayerMod** is a **multiplayer / co-op mod for Dead Cells**, built using the **Dead Cells Core Modding API (DCCM)**. +## About -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**. +DeadCellsCoopPlus aims to improve the multiplayer experience while remaining compatible with the original mod wherever possible. ---- +This project is based on the original work by **vaiserYT** and continues development through community contributions. -## 🎮 Features +## Current Improvements -- ✅ 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 +### Multiplayer +- Improved synchronization +- World object synchronization improvements +- Multiplayer stability improvements +- General networking fixes ---- +### Bug Fixes +- Revive system improvements +- Body synchronization fixes +- Elite enemy synchronization improvements +- Rune progression fixes +- Invisible synchronized object fixes +- General desynchronization fixes -## ⭐ Support the Project +### Planned Features +- Better revive system +- Late join support +- Improved disconnect recovery +- Better object ownership +- Additional multiplayer QoL improvements -If you find this project interesting: -- ⭐ Star the repository -- 🍴 Fork the project and experiment +## Credits -Every bit of feedback helps improve multiplayer support for **Dead Cells**. - ---- - -## 🧰 Requirements - -- **Dead Cells (PC)** -- **Dead Cells Core Modding API (DCCM)** -- Local network, Steam, or virtual LAN software (for online play) - ---- - -## 📦 Installation - -### 1️⃣ Install Dead Cells Core Modding API (DCCM) - -If you are using the **Steam version** of the game, follow the official installation guide: - -👉 [https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/](https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/) - -This method will automatically install and keep DCCM up to date. - -### 2️⃣ Install DeadCellsMultiplayerMod - -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. - -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 - -Example: -``` -Your game path/ - └──coremod/ - └── mods/ - └── DeadCellsMultiplayerMod/ -``` - -### 3️⃣ Run the game via DCCM - -Start **Dead Cells** using **DCCM**. -On the first launch, required configuration files will be generated automatically. - ---- - -## 🕹️ 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 - -🌐 **For online play**, use one of the following: -- Hamachi -- Radmin VPN -- ZeroTier -- Steam P2P (built-in) - ---- - -## 🧪 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 - ---- - -## 📜 Credits - -- **Dead Cells Core Modding API (DCCM)** - https://github.com/dead-cells-core-modding/core - ---- - - +Original project by vaiserYT. +This project remains under the original MIT License. diff --git a/README_ru.md b/README_ru.md deleted file mode 100644 index 5ee83e9..0000000 --- a/README_ru.md +++ /dev/null @@ -1,123 +0,0 @@ -

Dead Cells Multiplayer Mod

- -**DeadCellsMultiplayerMod** — это **мод для совместной игры / мультиплеера в Dead Cells**, собранный на **Dead Cells Core Modding API (DCCM)**. - -Мод добавляет **кооператив / мультиплеер** через **локальную или виртуальную сеть**: -один игрок поднимает сервер, второй подключается — оба **проходят уровни вместе в реальном времени**. - ---- - -## 🎮 Возможности - -- ✅ Синхронизация между двумя игроками в реальном времени -- ✅ Локальный TCP или Steam P2P мультиплеер -- ✅ Архитектура хост / клиент -- ✅ Автоматический старт игры для подключённого клиента -- ✅ Камера-спектатор — переключение между игроками клавишами `,` / `.` или геймпадом -- ✅ Синхронизация множителей HP боссов и руны босса -- ✅ Синхронизация атак мобов клиента и их прерывание -- ✅ Синхронизация оружия, головы и косметики призрака -- ✅ Обработка смерти/возрождения и рестарта -- ✅ Синхронизация перезагрузки графа уровня (босс-клетки, переходы) -- ✅ Слоты сохранения мультиплеера - ---- - -## ⭐ Поддержка проекта - -Если проект вам интересен: -- ⭐ Поставьте звезду репозиторию -- 🍴 Сделайте форк и экспериментируйте - -Любая обратная связь помогает улучшить мультиплеер для **Dead Cells**. - ---- - -## 🧰 Требования - -- **Dead Cells (PC)** -- **Dead Cells Core Modding API (DCCM)** -- Локальная сеть, Steam или виртуальная LAN (для игры по интернету) - ---- - -## 📦 Установка - -### 1️⃣ Установите Dead Cells Core Modding API (DCCM) - -Если у вас **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/) - -Так DCCM установится и будет обновляться автоматически. - -### 2️⃣ Установите DeadCellsMultiplayerMod - -Если у вас **Steam-версия** игры: -1. Откройте [https://steamcommunity.com/sharedfiles/filedetails/?id=3657857836](https://steamcommunity.com/sharedfiles/filedetails/?id=3657857836) -2. Установите мод в один клик. - -Если у вас **не-Steam версия** Dead Cells (требуется DCCM): -1. Перейдите в **каталог DCCM** -2. Создайте папку `mods` (если её нет) -3. Распакуйте папку **DeadCellsMultiplayerMod** в каталог `mods` - -Пример: -``` -Путь к игре/ - └──coremod/ - └── mods/ - └── DeadCellsMultiplayerMod/ -``` - -### 3️⃣ Запуск игры через DCCM - -Запустите **Dead Cells** через **DCCM**. -При первом запуске нужные конфигурационные файлы создадутся автоматически. - ---- - -## 🕹️ Как играть (мультиплеер) - -1. Запустите игру через **DCCM** -2. Нажмите **Play Multiplayer** -3. Выберите **Host** или **Join** -4. Введите **IP-адрес** и **порт** (TCP) или подключитесь через Steam -5. Когда хост начнёт игру, клиент автоматически подключится к сессии - -🌐 **Для игры по интернету** можно использовать: -- Hamachi -- Radmin VPN -- ZeroTier -- Steam P2P (встроено) - ---- - -## 🧪 Статус разработки / TODO - -- [x] Второй игрок-призрак -- [x] Синхронизация данных мира -- [x] Анимации призрака -- [x] Синхронизация генерации уровня -- [x] Синхронизация врагов -- [x] Синхронизация боссов, множителей HP, руны босса -- [x] Обработка смерти и рестарт -- [x] Синхронизация оружия, головы и косметики призрака -- [x] Синхронизация загрузки графа уровня (босс-клетки, переходы) -- [x] Слоты сохранения мультиплеера и продолжение -- [x] Камера-спектатор -- [ ] Кастомный режим -- [x] Steam P2P подключение - ---- - -## 📜 Благодарности - -- **Dead Cells Core Modding API (DCCM)** - https://github.com/dead-cells-core-modding/core - ---- - - 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/SteamP2P/SteamConnect.cs b/SteamP2P/SteamConnect.cs index ca3fe91..84559a7 100644 --- a/SteamP2P/SteamConnect.cs +++ b/SteamP2P/SteamConnect.cs @@ -418,7 +418,12 @@ private static bool TryParseLobbyInput(string? text, out ulong lobbyId, out stri if (codeMatch.Success) { lobbyCode = NormalizeLobbyCode(codeMatch.Value); - lobbyId = 0; + // DCCM lobby codes are not random: they are the Steam lobby id encoded in base36 + // with a dc prefix. Decode locally first so joining does not depend on Steam's + // public lobby-list search, which can lag or fail to return cross-region/private + // lobbies even when the lobby id itself is valid. + if (TryDecodeLobbyCodeToLobbyId(lobbyCode, out var decodedLobbyId)) + lobbyId = decodedLobbyId; return true; } @@ -436,6 +441,46 @@ private static string NormalizeLobbyCode(string? rawCode) return normalized; } + private static bool TryDecodeLobbyCodeToLobbyId(string? rawCode, out ulong lobbyId) + { + lobbyId = 0UL; + + var normalized = NormalizeLobbyCode(rawCode); + if (string.IsNullOrWhiteSpace(normalized) || + !normalized.StartsWith(LobbyCodePrefix, StringComparison.Ordinal) || + normalized.Length <= LobbyCodePrefix.Length) + { + return false; + } + + var value = 0UL; + for (var i = LobbyCodePrefix.Length; i < normalized.Length; i++) + { + var ch = normalized[i]; + int digit; + if (ch >= '0' && ch <= '9') + digit = ch - '0'; + else if (ch >= 'a' && ch <= 'z') + digit = 10 + ch - 'a'; + else + return false; + + if (digit < 0 || digit >= 36) + return false; + + if (value > (ulong.MaxValue - (ulong)digit) / 36UL) + return false; + + value = (value * 36UL) + (ulong)digit; + } + + if (value == 0UL) + return false; + + lobbyId = value; + return true; + } + private static bool TryRunHostWorker(WorkerRequest request, out WorkerResponse response) { response = new WorkerResponse @@ -1033,8 +1078,14 @@ private static WorkerResponse ExecuteJoin(WorkerRequest request) if (!string.IsNullOrWhiteSpace(requestedCode)) { - if (TryResolveLobbyIdByCode(requestedCode, out var resolvedLobbyId, out _)) + if (TryDecodeLobbyCodeToLobbyId(requestedCode, out var decodedLobbyId)) + { + targetLobbyId = decodedLobbyId; + } + else if (TryResolveLobbyIdByCode(requestedCode, out var resolvedLobbyId, out _)) + { targetLobbyId = resolvedLobbyId; + } } if (targetLobbyId == 0) @@ -1044,7 +1095,7 @@ private static WorkerResponse ExecuteJoin(WorkerRequest request) Success = false, Error = string.IsNullOrWhiteSpace(requestedCode) ? "Steam lobby id is invalid" - : "Steam lobby code does not exist" + : "Steam lobby code is invalid or could not be decoded" }; } @@ -1223,6 +1274,12 @@ private static bool TryResolveLobbyIdByCode(string lobbyCode, out ulong lobbyId, return false; } + if (TryDecodeLobbyCodeToLobbyId(normalizedCode, out var decodedLobbyId)) + { + lobbyId = decodedLobbyId; + return true; + } + SteamMatchmaking.AddRequestLobbyListStringFilter( ModMarkerLobbyKey, ModMarkerLobbyValue, diff --git a/UI/GameMenu.ReviveInput.cs b/UI/GameMenu.ReviveInput.cs index dcde68a..3452209 100644 --- a/UI/GameMenu.ReviveInput.cs +++ b/UI/GameMenu.ReviveInput.cs @@ -1,3 +1,4 @@ +using System; using System.Runtime.InteropServices; using dc.en; using dc.hl.types; @@ -5,14 +6,22 @@ using dc.pr; using dc.tool; using dc.ui; +using DeadCellsMultiplayerMod.UI; namespace DeadCellsMultiplayerMod; internal static partial class GameMenu { private const int ReviveInteractKeyCode = 82; // R (keyboard) + private const int ReviveEmergencyKeyCode = 118; // F7 fallback (keyboard) + private const int ReviveControllerRightShoulderPadCode = 5; // common Xbox RB / PlayStation R1 in hxd pad order + private const int ReviveControllerRightTriggerPadCode = 7; // older fallback for controllers that expose right shoulder as trigger + private const double ReviveControllerLatchSeconds = 1.10; + private static double _controllerReviveLatchUntilSeconds; + private static readonly int[] ReviveControllerRightSidePadCandidates = { 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }; + private static readonly int[] ReviveControllerFallbackPadCandidates = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23 }; - /// Hold-to-revive: keyboard R plus gamepad face buttons / primary-secondary (same binding resolution as menus). + /// Hold-to-revive: dedicated R/F7 or controller action/RB input so doors, NPCs, elevators, and other interactables cannot steal the revive. internal static bool IsReviveHoldInputDown(Hero? hero) { if (hero == null) @@ -20,7 +29,7 @@ internal static bool IsReviveHoldInputDown(Hero? hero) try { - if (dc.hxd.Key.Class.isDown(ReviveInteractKeyCode)) + if (dc.hxd.Key.Class.isDown(ReviveInteractKeyCode) || dc.hxd.Key.Class.isDown(ReviveEmergencyKeyCode)) return true; } catch @@ -30,72 +39,138 @@ internal static bool IsReviveHoldInputDown(Hero? hero) #pragma warning disable CS8602 try { - if (hero.controller is not ControllerAccess access) - return false; - if (access.manualLock) - return false; + Controller? controller = null; + if (hero.controller is ControllerAccess access) + { + try { controller = access.parent; } catch { } + } - var controller = access.parent; - if (controller == null || controller.isLocked) + if (controller == null) + controller = TryGetControllerFromHeroReflection(hero); + + if (controller == null) return false; - var b = controller.get_bindings(); + // v6.4.7: revive is allowed to read controller buttons even if Dead Cells has the + // controller briefly locked by a door/object/cinematic. Those locks were exactly why + // RB/R1 held beside a downed player did nothing. + var nowSeconds = GetCurrentUnixTimeSeconds(); + if (_controllerReviveLatchUntilSeconds > nowSeconds) + return true; - bool PadHeld(ArrayBytes_Int? bind) + try { - if (bind == null) - return false; - try + var b = controller.get_bindings(); + + bool PadHeld(ArrayBytes_Int? bind) { - for (var i = 0; i < bind.length; i++) + if (bind == null) + return false; + try { - var code = Marshal.ReadInt32(bind.bytes, i << 2); - if (code < 0) - continue; - if (controller.padIsPressed(code)) - return true; + for (var i = 0; i < bind.length; i++) + { + var code = Marshal.ReadInt32(bind.bytes, i << 2); + if (code < 0) + continue; + if (IsControllerPadDownOrPressed(controller, code)) + return LatchControllerReviveInput(); + } } - } - catch - { + catch + { + } + + return false; } - return false; + if (PadHeld(b.padA)) + return true; + if (PadHeld(b.padB)) + return true; + if (PadHeld(b.padC)) + return true; + } + catch + { } - bool KeyHeld(ArrayBytes_Int? bind) + bool PadObjectHeld(object? bindingObject) { - if (bind == null) + if (bindingObject == null) return false; - try + + if (bindingObject is ArrayBytes_Int bytes) + return IsAnyArrayBytesPadHeld(controller, bytes); + + if (bindingObject is ArrayObj arrayObj) { - for (var i = 0; i < bind.length; i++) + try + { + for (var i = 0; i < arrayObj.length; i++) + { + var raw = arrayObj.getDyn(i); + if (raw is not int code || code < 0) + continue; + if (IsControllerPadDownOrPressed(controller, code)) + return LatchControllerReviveInput(); + } + } + catch { - var code = Marshal.ReadInt32(bind.bytes, i << 2); - if (code < 0) - continue; - if (dc.hxd.Key.Class.isDown(code)) - return true; } - } - catch - { } return false; } - if (PadHeld(b.padA)) + if (IsControllerReviveShoulderHeld(controller)) + return true; + + var gamepadOptions = dc.Main.Class.ME?.options?.get_gamepad(); + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "interact", true))) + return true; + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "action", true))) + return true; + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "use", true))) + return true; + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "ui_select", true))) + return true; + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "ui_confirm", true))) + return true; + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "submit", true))) return true; - if (PadHeld(b.padB)) + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "ui_next", true))) return true; - if (PadHeld(b.padC)) + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "ui_page_next", true))) return true; - if (KeyHeld(b.primary)) + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "next", true))) return true; - if (KeyHeld(b.secondary)) + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "rightShoulder", true))) return true; - if (KeyHeld(b.third)) + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "right_shoulder", true))) + return true; + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "rightBumper", true))) + return true; + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "right_bumper", true))) + return true; + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "rb", true))) + return true; + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "r1", true))) + return true; + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "rightTrigger", true))) + return true; + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "right_trigger", true))) + return true; + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "rt", true))) + return true; + if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "r2", true))) + return true; + + // Last fallback: DCCM/Hashlink builds can expose controller buttons with different + // numeric codes. Only while the revive system is polling, scan common face/shoulder/ + // trigger pad codes so RB/R1 works even if the binding name is missing. + if (IsAnyLikelyControllerPadHeld(controller)) return true; } catch @@ -105,4 +180,140 @@ bool KeyHeld(ArrayBytes_Int? bind) return false; } + + private static Controller? TryGetControllerFromHeroReflection(Hero hero) + { + if (hero == null) + return null; + + try + { + var rawController = TitleScreenReflection.GetMemberValue(hero, "controller", true); + if (rawController is ControllerAccess access) + return access.parent; + if (rawController is Controller controller) + return controller; + + var parent = TitleScreenReflection.GetMemberValue(rawController, "parent", true); + if (parent is Controller reflectedController) + return reflectedController; + } + catch + { + } + + return null; + } + + private static bool IsAnyArrayBytesPadHeld(Controller controller, ArrayBytes_Int bind) + { + if (controller == null || bind == null) + return false; + + try + { + for (var i = 0; i < bind.length; i++) + { + var code = Marshal.ReadInt32(bind.bytes, i << 2); + if (code >= 0 && IsControllerPadDownOrPressed(controller, code)) + return LatchControllerReviveInput(); + } + } + catch + { + } + + return false; + } + + private static bool IsAnyLikelyControllerPadHeld(Controller controller) + { + if (controller == null) + return false; + + foreach (var code in ReviveControllerFallbackPadCandidates) + { + if (IsControllerPadDownOrPressed(controller, code)) + return LatchControllerReviveInput(); + } + + return false; + } + + private static bool IsControllerReviveShoulderHeld(Controller controller) + { + if (controller == null) + return false; + + foreach (var code in ReviveControllerRightSidePadCandidates) + { + if (IsControllerPadDownOrPressed(controller, code)) + return LatchControllerReviveInput(); + } + + // Keep the named constants too so future readers can see the intended mapping. + if (IsControllerPadDownOrPressed(controller, ReviveControllerRightShoulderPadCode)) + return LatchControllerReviveInput(); + if (IsControllerPadDownOrPressed(controller, ReviveControllerRightTriggerPadCode)) + return LatchControllerReviveInput(); + + return false; + } + + private static bool IsControllerPadDownOrPressed(Controller controller, int code) + { + if (controller == null || code < 0) + return false; + + // Prefer hold/down methods when the current GameProxy exposes them. + if (TryInvokeControllerButtonMethod(controller, "padIsDown", code)) + return true; + if (TryInvokeControllerButtonMethod(controller, "padIsHeld", code)) + return true; + if (TryInvokeControllerButtonMethod(controller, "padDown", code)) + return true; + if (TryInvokeControllerButtonMethod(controller, "isDown", code)) + return true; + if (TryInvokeControllerButtonMethod(controller, "buttonIsDown", code)) + return true; + if (TryInvokeControllerButtonMethod(controller, "buttonDown", code)) + return true; + + try + { + if (controller.padIsPressed(code)) + return true; + } + catch + { + } + + return false; + } + + private static bool TryInvokeControllerButtonMethod(Controller controller, string methodName, int code) + { + if (controller == null || string.IsNullOrWhiteSpace(methodName)) + return false; + + try + { + var method = controller.GetType().GetMethod(methodName, new[] { typeof(int) }); + if (method == null) + return false; + + var value = method.Invoke(controller, new object[] { code }); + return value is bool b && b; + } + catch + { + return false; + } + } + + private static bool LatchControllerReviveInput() + { + _controllerReviveLatchUntilSeconds = GetCurrentUnixTimeSeconds() + ReviveControllerLatchSeconds; + return true; + } } diff --git a/UI/GameMenu.cs b/UI/GameMenu.cs index 64741eb..a5e2810 100644 --- a/UI/GameMenu.cs +++ b/UI/GameMenu.cs @@ -48,6 +48,7 @@ private enum ConnectionTransport private static ulong _steamHostSteamId; private static bool _steamJoinLobbyResolvePending; private static ulong? _pendingOverlayJoinLobbyId; + private static ulong _pendingOverlayJoinHostSteamId; private static bool _waitingForHost; internal const int ClientConnectMaxAttempts = 3; private static int _clientConnectAttempt; @@ -800,6 +801,17 @@ private static void SharedStartSteamHost(Action showErro _steamLobbyCode = SteamConnect.BuildLobbyCodeFromLobbyId(_steamLobbyId); ConnectionUI.NotifyConnectionsChanged(); _log?.Information("[NetMod][Steam] Host lobby ready: id={LobbyId} code={LobbyCode}", _steamLobbyId, _steamLobbyCode); + try + { + if (NetRef?.TrySetSteamHostRichPresence(_steamLobbyId) == true) + _log?.Information("[NetMod][Steam] Host rich presence join data published for lobbyId={LobbyId}", _steamLobbyId); + else + _log?.Warning("[NetMod][Steam] Host rich presence join data could not be published; Steam invites may still work through lobby fallback"); + } + catch (Exception ex) + { + _log?.Warning("[NetMod][Steam] Host rich presence publish failed: {Message}", ex.Message); + } var copied = SteamConnect.TryCopyLobbyCodeToClipboard(_steamLobbyCode) || SteamConnect.TryCopyLobbyIdToClipboard(lobby.LobbyId); @@ -855,8 +867,11 @@ private static void SharedApplySteamJoinResult(bool ok, SteamConnect.JoinLobbyRe { StopNetworkFromMenu(); _log?.Warning("[NetMod][SteamWorkerError] {Error}", join.Error); + var details = string.IsNullOrWhiteSpace(join.Error) + ? GetText.Instance.GetString("Steam join failed. Check console logs.") + : string.Concat(GetText.Instance.GetString("Steam join failed. Check console logs."), "\n", join.Error); showError(GetText.Instance.GetString("Steam join failed"), - GetText.Instance.GetString("Steam join failed. Check console logs."), + details, showTransport); return; } @@ -898,17 +913,18 @@ private static void SharedApplySteamJoinResult(bool ok, SteamConnect.JoinLobbyRe showStatus(); } - internal static void HandleSteamOverlayJoinRequest(ulong lobbyId) + internal static void HandleSteamOverlayJoinRequest(ulong lobbyId, ulong fallbackHostSteamId = 0UL) { var screen = GetTitleScreen(); if (screen == null) { _pendingOverlayJoinLobbyId = lobbyId; - _log?.Information("[NetMod][Steam] Overlay join request queued: not at main menu (lobbyId={LobbyId})", lobbyId); + _pendingOverlayJoinHostSteamId = fallbackHostSteamId; + _log?.Information("[NetMod][Steam] Overlay join request queued: not at main menu (lobbyId={LobbyId} fallbackHostSteamId={FallbackHostSteamId})", lobbyId, fallbackHostSteamId); return; } - _log?.Information("[NetMod][Steam] Overlay join starting: lobbyId={LobbyId} screen=ok", lobbyId); + _log?.Information("[NetMod][Steam] Overlay join starting: lobbyId={LobbyId} fallbackHostSteamId={FallbackHostSteamId} screen=ok", lobbyId, fallbackHostSteamId); _menuSelection = NetRole.Client; _menuTransport = ConnectionTransport.Steam; @@ -926,8 +942,37 @@ internal static void HandleSteamOverlayJoinRequest(ulong lobbyId) ConnectionUI.NotifyConnectionsChanged(); _ = Task.Run(() => { - _log?.Information("[NetMod][Steam] Overlay join resolving lobby (lobbyId={LobbyId})", lobbyId); - var ok = SteamConnect.TryResolveJoinEndpointFromLobbyId(lobbyId, out var join); + _log?.Information("[NetMod][Steam] Overlay join resolving lobby (lobbyId={LobbyId} fallbackHostSteamId={FallbackHostSteamId})", lobbyId, fallbackHostSteamId); + SteamConnect.JoinLobbyResult join; + var ok = false; + if (lobbyId != 0UL) + { + ok = SteamConnect.TryResolveJoinEndpointFromLobbyId(lobbyId, out join); + } + else + { + join = new SteamConnect.JoinLobbyResult + { + Success = false, + LobbyId = 0UL, + HostSteamId = 0UL, + Endpoint = null, + Error = "Steam overlay did not provide a lobby id" + }; + } + if (!ok && fallbackHostSteamId != 0UL) + { + _log?.Warning("[NetMod][Steam] Overlay lobby resolution failed for lobbyId={LobbyId}; falling back to direct friend Steam P2P hostSteamId={HostSteamId}. Error={Error}", lobbyId, fallbackHostSteamId, join.Error ?? string.Empty); + join = new SteamConnect.JoinLobbyResult + { + Success = true, + LobbyId = lobbyId, + HostSteamId = fallbackHostSteamId, + Endpoint = null, + Error = string.Empty + }; + ok = true; + } EnqueueMainThread(() => ApplySteamJoinResult(screen, ok, join, fromOverlay: true)); }); } diff --git a/UI/GameMenuHooks.cs b/UI/GameMenuHooks.cs index 5e3fb94..9e93e8a 100644 --- a/UI/GameMenuHooks.cs +++ b/UI/GameMenuHooks.cs @@ -55,9 +55,11 @@ private static void ProcessPendingOverlayJoinRequest(TitleScreen screen) { if (_pendingOverlayJoinLobbyId is not { } lobbyId) return; + var fallbackHostSteamId = _pendingOverlayJoinHostSteamId; _pendingOverlayJoinLobbyId = null; - _log?.Information("[NetMod][Steam] Processing queued overlay join request (lobbyId={LobbyId})", lobbyId); - HandleSteamOverlayJoinRequest(lobbyId); + _pendingOverlayJoinHostSteamId = 0UL; + _log?.Information("[NetMod][Steam] Processing queued overlay join request (lobbyId={LobbyId} fallbackHostSteamId={FallbackHostSteamId})", lobbyId, fallbackHostSteamId); + HandleSteamOverlayJoinRequest(lobbyId, fallbackHostSteamId); } private static void TryDisconnectWhenReturningToMainMenu() diff --git a/UI/LevelExitSync.cs b/UI/LevelExitSync.cs index 0ee4247..4db3086 100644 --- a/UI/LevelExitSync.cs +++ b/UI/LevelExitSync.cs @@ -57,6 +57,12 @@ private sealed class PlayerExitState private const double CircleAlphaIdle = 0.10; private const double CircleAlphaActive = 0.22; private const int PointerFxSuppressionKey = 188743680; + private const double ForceExitCountdownSeconds = 15.0; + private const double BossForceExitCountdownSeconds = 45.0; + private const double ForceExitNoticeIntervalSeconds = 5.0; + private const double RemoteExitAssistSeconds = 2.0; + private const double RemoteExitAssistRadiusPx = 260.0; + private static readonly double RemoteExitAssistRadiusSq = RemoteExitAssistRadiusPx * RemoteExitAssistRadiusPx; private readonly ILogger _log; @@ -87,6 +93,13 @@ private sealed class PlayerExitState private bool _suppressDoorActivateHook; private string _transitionDoorKey = string.Empty; private bool _timerPausedByExit; + private string _forceExitDoorKey = string.Empty; + private long _forceExitCountdownStartedTick; + private long _nextForceExitNoticeTick; + private bool _forceExitTriggered; + private string _remoteExitAssistDoorKey = string.Empty; + private long _remoteExitAssistStartedTick; + private bool _remoteExitAssistTriggered; /// Exit/portal/boss-door entities only — avoids scanning level.entities every hero frame. private readonly List _exitTargetCandidates = new(); @@ -124,7 +137,6 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) Hook_Exit.postUpdate += Hook_Exit_postUpdate; Hook_Exit.onActivate += Hook_Exit_onActivate; Hook_Portal.onActivate += Hook_Portal_onActivate; - Hook_BossRushDoor.onActivate += Hook_BossRushDoor_onActivate; Hook_Level.registerEntity += Hook_Level_registerEntity; Hook_Level.unregisterEntity += Hook_Level_unregisterEntity; Hook_Level.onDispose += Hook_Level_onDispose; @@ -150,15 +162,6 @@ private void Hook_Portal_onActivate(Hook_Portal.orig_onActivate orig, Portal sel target => SafeRead(() => target.visible, false)); } - private void Hook_BossRushDoor_onActivate(Hook_BossRushDoor.orig_onActivate orig, BossRushDoor self, Hero by, bool cine) - { - HandleExitTargetActivate( - self, - by, - () => orig(self, by, cine), - target => !SafeRead(() => target.locked, true)); - } - private void Hook_Level_registerEntity(Hook_Level.orig_registerEntity orig, Level self, Entity clid) { orig(self, clid); @@ -298,6 +301,8 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) UpdateLocalPlayerState(net, forceSend: false); ApplyLocalTimerPause(_localPressed && _localInsideCircle); RefreshDoorVisuals(net); + UpdateForceExitCountdown(net, currentLevel, hero, nearestTarget); + UpdateRemoteExitAssist(net, currentLevel, hero, nearestTarget); if (_localPressed && _localInsideCircle && @@ -324,6 +329,241 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) UpdateExitPointer(net); } + private void UpdateForceExitCountdown(NetNode net, Level? currentLevel, Hero hero, Entity? nearestTarget) + { + if (net == null || !net.IsAlive || hero == null) + { + ResetForceExitCountdown(showCancelMessage: false); + return; + } + + var doorKey = ResolveWatchedDoorKey(net); + if (string.IsNullOrWhiteSpace(doorKey)) + { + ResetForceExitCountdown(showCancelMessage: false); + return; + } + + if (AreAllPlayersReadyForDoor(doorKey, net)) + { + ResetForceExitCountdown(showCancelMessage: false); + return; + } + + if (!HasAnyPlayerReadyForDoor(doorKey, net.id)) + { + ResetForceExitCountdown(showCancelMessage: false); + return; + } + + var target = FindExitTargetByDoorKey(currentLevel, doorKey); + if (target == null && nearestTarget != null && string.Equals(BuildDoorKey(nearestTarget.cx, nearestTarget.cy), doorKey, StringComparison.Ordinal)) + target = nearestTarget; + + if (target == null || !IsAvailableExitTarget(target)) + { + ResetForceExitCountdown(showCancelMessage: false); + return; + } + + var countdownSeconds = ResolveForceExitCountdownSeconds(currentLevel); + var now = Stopwatch.GetTimestamp(); + if (!string.Equals(_forceExitDoorKey, doorKey, StringComparison.Ordinal) || _forceExitCountdownStartedTick == 0) + { + _forceExitDoorKey = doorKey; + _forceExitCountdownStartedTick = now; + _nextForceExitNoticeTick = now; + _forceExitTriggered = false; + PushForceExitCountdownMessage(net, target, countdownSeconds); + _nextForceExitNoticeTick = now + (long)(Stopwatch.Frequency * ForceExitNoticeIntervalSeconds); + return; + } + + var elapsedSeconds = (now - _forceExitCountdownStartedTick) / (double)Stopwatch.Frequency; + var remainingSeconds = countdownSeconds - elapsedSeconds; + if (!_forceExitTriggered && remainingSeconds > 0 && now >= _nextForceExitNoticeTick) + { + PushForceExitCountdownMessage(net, target, remainingSeconds); + _nextForceExitNoticeTick = now + (long)(Stopwatch.Frequency * ForceExitNoticeIntervalSeconds); + } + + if (_forceExitTriggered || elapsedSeconds < countdownSeconds) + return; + + _forceExitTriggered = true; + MultiplayerUI.PushSystemMessage(FormatLocalized("Exit failsafe activated. Moving everyone to {0}.", ResolveExitDestinationName(target))); + ApplyLocalTimerPause(false); + TriggerExitTransition(target, hero, null); + } + + private static double ResolveForceExitCountdownSeconds(Level? currentLevel) + { + try + { + var levelId = currentLevel?.map?.id?.ToString(); + if (DeadCellsMultiplayerMod.ModEntry.IsBossLevel(levelId)) + return BossForceExitCountdownSeconds; + } + catch + { + } + + return ForceExitCountdownSeconds; + } + + private void UpdateRemoteExitAssist(NetNode net, Level? currentLevel, Hero hero, Entity? nearestTarget) + { + if (net == null || !net.IsAlive || hero == null || ModEntry.IsLocalPlayerDowned()) + { + ResetRemoteExitAssist(); + return; + } + + if (_localPressed && _localInsideCircle) + { + ResetRemoteExitAssist(); + return; + } + + var doorKey = ResolveWatchedDoorKey(net); + if (string.IsNullOrWhiteSpace(doorKey) || !HasAnyPlayerReadyForDoor(doorKey, net.id)) + { + ResetRemoteExitAssist(); + return; + } + + var target = FindExitTargetByDoorKey(currentLevel, doorKey); + if (target == null && nearestTarget != null && string.Equals(BuildDoorKey(nearestTarget.cx, nearestTarget.cy), doorKey, StringComparison.Ordinal)) + target = nearestTarget; + if (target == null || !IsAvailableExitTarget(target)) + { + ResetRemoteExitAssist(); + return; + } + + if (!IsHeroNearOrStuckAtExit(hero, target)) + { + ResetRemoteExitAssist(); + return; + } + + var now = Stopwatch.GetTimestamp(); + if (!string.Equals(_remoteExitAssistDoorKey, doorKey, StringComparison.Ordinal) || _remoteExitAssistStartedTick == 0) + { + _remoteExitAssistDoorKey = doorKey; + _remoteExitAssistStartedTick = now; + _remoteExitAssistTriggered = false; + try { MultiplayerUI.PushSystemMessage(Localize("Exit assist: teammate entered the exit. Pulling you in.")); } catch { } + } + + _localDoorKey = doorKey; + _localDoorCx = target.cx; + _localDoorCy = target.cy; + _localDoorOutOfGame = SafeRead(() => target.isOutOfGame, false); + _localDoorOnScreen = SafeRead(() => target.isOnScreen, false); + _localInsideCircle = true; + _localPressed = true; + UpdateLocalPlayerState(net, forceSend: true); + + var elapsedSeconds = (now - _remoteExitAssistStartedTick) / (double)Stopwatch.Frequency; + var allReady = AreAllPlayersReadyForDoor(doorKey, net); + + // Do not let a client jump into the next level before the host has had time to receive + // this ready packet. That early local transition was a major cause of boss/level desync. + if (net.IsHost && allReady) + { + ApplyLocalTimerPause(false); + TriggerExitTransition(target, hero, null); + return; + } + + if (_remoteExitAssistTriggered || elapsedSeconds < RemoteExitAssistSeconds || !allReady) + return; + + _remoteExitAssistTriggered = true; + ApplyLocalTimerPause(false); + TriggerExitTransition(target, hero, null); + } + + private void ResetRemoteExitAssist() + { + _remoteExitAssistDoorKey = string.Empty; + _remoteExitAssistStartedTick = 0; + _remoteExitAssistTriggered = false; + } + + private static bool IsHeroNearOrStuckAtExit(Hero hero, Entity target) + { + if (hero == null || target == null) + return false; + + try + { + var heroX = hero.spr?.x ?? ((hero.cx + hero.xr) * 24.0); + var heroY = hero.spr?.y ?? ((hero.cy + hero.yr) * 24.0); + var targetX = target.spr?.x ?? ((target.cx + target.xr) * 24.0); + var targetY = target.spr?.y ?? ((target.cy + target.yr) * 24.0); + if (!double.IsFinite(heroX) || !double.IsFinite(heroY) || !double.IsFinite(targetX) || !double.IsFinite(targetY)) + return false; + + var dx = heroX - targetX; + var dy = heroY - targetY; + if (dx * dx + dy * dy <= RemoteExitAssistRadiusSq) + return true; + + // Some exits/transition doors trap the client just below the activation trigger + // when the other player starts the transition. Treat that as "close enough" and + // pull the client through instead of leaving them wedged under the trigger. + return System.Math.Abs(dx) <= 160.0 && dy >= -48.0 && dy <= 420.0; + } + catch + { + return false; + } + } + + private void ResetForceExitCountdown(bool showCancelMessage) + { + if (showCancelMessage && _forceExitCountdownStartedTick != 0) + MultiplayerUI.PushSystemMessage(Localize("Exit failsafe cancelled.")); + + _forceExitDoorKey = string.Empty; + _forceExitCountdownStartedTick = 0; + _nextForceExitNoticeTick = 0; + _forceExitTriggered = false; + ResetRemoteExitAssist(); + } + + private bool HasAnyPlayerReadyForDoor(string doorKey, int localId) + { + if (string.IsNullOrWhiteSpace(doorKey)) + return false; + + foreach (var state in _playerStates.Values) + { + if (state.UserId <= 0) + continue; + if (IsPlayerDownedForExit(state.UserId, localId)) + continue; + if (!state.Pressed || !state.InsideCircle) + continue; + if (string.Equals(state.DoorKey, doorKey, StringComparison.Ordinal)) + return true; + } + + return false; + } + + private void PushForceExitCountdownMessage(NetNode net, Entity target, double secondsRemaining) + { + var seconds = (int)System.Math.Ceiling(System.Math.Max(1.0, secondsRemaining)); + var destination = ResolveExitDestinationName(target); + MultiplayerUI.PushSystemMessage(FormatLocalized( + "Exit failsafe: not everyone is ready. Forcing travel to {0} in {1}s.", + destination, + seconds)); + } + private void TriggerExitTransition(Entity target, Hero hero, Action? origActivate) { if (target == null || hero == null) @@ -625,9 +865,9 @@ private static string ResolveExitDestinationName(Entity? target) return mapId.Trim(); } - if (target is BossRushDoor bossDoor) + if (IsTypeName(target, "BossRushDoor")) { - var type = SafeRead(() => bossDoor.bossRushType?.ToString() ?? string.Empty, string.Empty); + var type = SafeRead(() => ReadDynamicMember(target, "bossRushType"), string.Empty); if (!string.IsNullOrWhiteSpace(type)) return type.Trim(); return Localize("Boss Rush"); @@ -768,7 +1008,7 @@ private static bool TryParseDoorKey(string key, out int cx, out int cy) private static bool IsSupportedExitTarget(Entity? entity) { - return entity is Exit || entity is Portal || entity is BossRushDoor; + return entity is Exit || entity is Portal || IsTypeName(entity, "BossRushDoor"); } private static bool IsAvailableExitTarget(Entity? entity) @@ -777,7 +1017,7 @@ private static bool IsAvailableExitTarget(Entity? entity) return false; if (!SafeRead(() => entity!.visible, true)) return false; - if (entity is BossRushDoor bossDoor && SafeRead(() => bossDoor.locked, false)) + if (IsTypeName(entity, "BossRushDoor") && SafeRead(() => ReadDynamicBool(entity, "locked"), false)) return false; return true; } @@ -1279,6 +1519,11 @@ private void ResetLevelState(Level? newLevel) _hasCachedDownedSignature = false; _cachedDownedSignature = 0; _exitPointerDoorKey = string.Empty; + _forceExitDoorKey = string.Empty; + _forceExitCountdownStartedTick = 0; + _nextForceExitNoticeTick = 0; + _forceExitTriggered = false; + ResetRemoteExitAssist(); _staleDoorVisualKeys.Clear(); foreach (var key in _doorVisuals.Keys) @@ -1394,6 +1639,58 @@ private static T SafeRead(Func getter, T fallback) try { return getter(); } catch { return fallback; } } + private static bool IsTypeName(object? value, string typeName) + { + if (value == null || string.IsNullOrWhiteSpace(typeName)) + return false; + + try + { + var type = value.GetType(); + while (type != null) + { + if (string.Equals(type.Name, typeName, StringComparison.Ordinal) || + string.Equals(type.FullName, typeName, StringComparison.Ordinal) || + type.FullName?.EndsWith("." + typeName, StringComparison.Ordinal) == true) + return true; + type = type.BaseType; + } + } + catch + { + } + + return false; + } + + private static string ReadDynamicMember(object? value, string memberName) + { + if (value == null || string.IsNullOrWhiteSpace(memberName)) + return string.Empty; + + try + { + var type = value.GetType(); + var prop = type.GetProperty(memberName); + if (prop != null) + return prop.GetValue(value)?.ToString() ?? string.Empty; + var field = type.GetField(memberName); + if (field != null) + return field.GetValue(value)?.ToString() ?? string.Empty; + } + catch + { + } + + return string.Empty; + } + + private static bool ReadDynamicBool(object? value, string memberName) + { + var raw = ReadDynamicMember(value, memberName); + return bool.TryParse(raw, out var parsed) && parsed; + } + private void MarkExitUiStateDirty() { _readyStateCacheDirty = true; diff --git a/UI/StuckRecoveryFailsafe.cs b/UI/StuckRecoveryFailsafe.cs new file mode 100644 index 0000000..d1c61f4 --- /dev/null +++ b/UI/StuckRecoveryFailsafe.cs @@ -0,0 +1,261 @@ +using dc; +using dc.en; +using dc.pr; +using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using DeadCellsMultiplayerMod.Ghost.GhostBase; +using DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI; +using ModCore.Events; +using ModCore.Events.Interfaces.Game.Hero; +using Serilog; + +namespace DeadCellsMultiplayerMod.UI; + +/// +/// Manual recovery for multiplayer softlocks: press F8 to teleport your local hero to the nearest +/// remote player ghost. This is intentionally local-only; the normal position sync then tells the +/// other side where you moved. +/// +public sealed class StuckRecoveryFailsafe : + IEventReceiver, + IOnAdvancedModuleInitializing, + IOnHeroUpdate +{ + private const int EmergencyTeleportKeyCode = 119; // F8 on hxd.Key/DOM-style key codes. + private const double EmergencyTeleportCooldownSeconds = 2.0; + private const double EmergencyTeleportYOffsetPx = 16.0; + private const double MinDistanceForInfoMessagePx = 280.0; + + private readonly ILogger _log; + private long _nextAllowedTeleportTick; + private long _nextInfoMessageTick; + + public StuckRecoveryFailsafe(ModEntry entry) + { + _log = entry.Logger; + EventSystem.AddReceiver(this); + } + + void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) + { + entry.Logger.Information("\x1b[32m[[StuckRecoveryFailsafe] Initializing stuck recovery failsafe...]\x1b[0m "); + } + + void IOnHeroUpdate.OnHeroUpdate(double dt) + { + var net = GameMenu.NetRef; + var hero = ModEntry.me; + if (net == null || !net.IsAlive || net.id <= 0 || hero == null || hero._level == null) + return; + + if (!IsEmergencyTeleportPressed()) + return; + + var now = System.Diagnostics.Stopwatch.GetTimestamp(); + if (now < _nextAllowedTeleportTick) + { + MaybePushInfo("Emergency teleport is cooling down."); + return; + } + + var hasVisibleTarget = TryFindNearestRemoteHero(hero, out var target, out var distanceSq); + var hasRawTarget = false; + double rawX = 0; + double rawY = 0; + int rawDir = SafeRead(() => hero.dir, 0); + if (!hasVisibleTarget) + hasRawTarget = TryFindLastNetworkRemotePosition(hero, out rawX, out rawY, out distanceSq); + + if (!hasVisibleTarget && !hasRawTarget) + { + MaybePushInfo("No remote player position found to teleport to."); + return; + } + + try + { + var x = hasVisibleTarget ? GetEntityX(target!) : rawX; + var y = (hasVisibleTarget ? GetEntityY(target!) : rawY) - EmergencyTeleportYOffsetPx; + var dir = hasVisibleTarget ? SafeRead(() => target!.dir, rawDir) : rawDir; + + RecoverLocalHeroControl(hero); + hero.setPosPixel(x, y); + hero.dir = dir; + RecoverLocalHeroControl(hero); + try { net.TickSend(x, y, dir); } catch { } + + _nextAllowedTeleportTick = now + (long)(System.Diagnostics.Stopwatch.Frequency * EmergencyTeleportCooldownSeconds); + MultiplayerUI.PushSystemMessage("Emergency teleport used: moved to the other player."); + _log.Information("[StuckRecovery] Emergency teleport applied distance={Distance}", System.Math.Sqrt(distanceSq)); + } + catch (Exception ex) + { + _log.Warning(ex, "[StuckRecovery] Emergency teleport failed"); + MaybePushInfo("Emergency teleport failed. Try again after moving/camera settling."); + } + } + + private bool IsEmergencyTeleportPressed() + { + try + { + return dc.hxd.Key.Class.isPressed(EmergencyTeleportKeyCode); + } + catch + { + return false; + } + } + + private bool TryFindNearestRemoteHero(Hero localHero, out GhostKing? target, out double distanceSq) + { + target = null; + distanceSq = double.MaxValue; + var level = localHero._level; + var lx = GetEntityX(localHero); + var ly = GetEntityY(localHero); + + // Prefer a visible ghost on the same Level object, then any visible ghost, then finally + // the last raw network coordinate even if the visible ghost was hidden/disposed by a + // room/sublevel mismatch. This makes F8 work at any distance and in more DLC/transition + // softlocks. + TryFindNearestRemoteHeroPass(level, lx, ly, requireSameLevel: true, ref target, ref distanceSq); + if (target == null) + TryFindNearestRemoteHeroPass(level, lx, ly, requireSameLevel: false, ref target, ref distanceSq); + + if (target != null) + { + if (distanceSq < MinDistanceForInfoMessagePx * MinDistanceForInfoMessagePx) + _log.Debug("[StuckRecovery] Emergency teleport used while players were already close distance={Distance}", System.Math.Sqrt(distanceSq)); + return true; + } + + return false; + } + + private static void TryFindNearestRemoteHeroPass(Level? level, double lx, double ly, bool requireSameLevel, ref GhostKing? target, ref double distanceSq) + { + for (var i = 0; i < ModEntry.clients.Length; i++) + { + var remote = ModEntry.clients[i]; + if (remote == null) + continue; + if (requireSameLevel && !ReferenceEquals(remote._level, level)) + continue; + if (SafeRead(() => remote.destroyed, true)) + continue; + if (SafeRead(() => remote.isOutOfGame, false)) + continue; + + var rx = GetEntityX(remote); + var ry = GetEntityY(remote); + if (System.Math.Abs(rx) < 0.001 && System.Math.Abs(ry) < 0.001) + continue; + + var dx = rx - lx; + var dy = ry - ly; + var dSq = dx * dx + dy * dy; + if (dSq < distanceSq) + { + target = remote; + distanceSq = dSq; + } + } + } + + private static bool TryFindLastNetworkRemotePosition(Hero localHero, out double x, out double y, out double distanceSq) + { + x = 0; + y = 0; + distanceSq = double.MaxValue; + var now = System.Diagnostics.Stopwatch.GetTimestamp(); + var maxAgeTicks = System.Diagnostics.Stopwatch.Frequency * 30L; + var lx = GetEntityX(localHero); + var ly = GetEntityY(localHero); + + for (var i = 0; i < ModEntry.EmergencyLastRemoteX.Length && i < ModEntry.EmergencyLastRemoteY.Length && i < ModEntry.EmergencyLastRemoteTicks.Length; i++) + { + var tick = ModEntry.EmergencyLastRemoteTicks[i]; + if (tick <= 0 || now - tick > maxAgeTicks) + continue; + + var rx = ModEntry.EmergencyLastRemoteX[i]; + var ry = ModEntry.EmergencyLastRemoteY[i]; + if (System.Math.Abs(rx) < 0.001 && System.Math.Abs(ry) < 0.001) + continue; + + var dx = rx - lx; + var dy = ry - ly; + var dSq = dx * dx + dy * dy; + if (dSq < distanceSq) + { + distanceSq = dSq; + x = rx; + y = ry; + } + } + + return distanceSq < double.MaxValue; + } + + private static void RecoverLocalHeroControl(Hero hero) + { + try { hero.cancelVelocities(); } catch { } + try { hero.cancelSkillControlLock(); } catch { } + try { hero.unlockControls(); } catch { } + try { hero._targetable = true; } catch { } + try + { + if (hero.life <= 0) + hero.life = 1; + } + catch { } + + try + { + var data = hero._level?.game?.data; + if (data != null) + data.stopGameTime = false; + } + catch { } + } + + private void MaybePushInfo(string message) + { + var now = System.Diagnostics.Stopwatch.GetTimestamp(); + if (now < _nextInfoMessageTick) + return; + + _nextInfoMessageTick = now + System.Diagnostics.Stopwatch.Frequency; + MultiplayerUI.PushSystemMessage(message); + } + + private static double GetEntityX(Entity e) + { + try + { + if (e.spr != null) + return e.spr.x; + } + catch { } + + try { return (e.cx + e.xr) * 24.0; } catch { return 0.0; } + } + + private static double GetEntityY(Entity e) + { + try + { + if (e.spr != null) + return e.spr.y; + } + catch { } + + try { return (e.cy + e.yr) * 24.0; } catch { return 0.0; } + } + + private static T SafeRead(Func read, T fallback) + { + try { return read(); } + catch { return fallback; } + } +} diff --git a/WorldSync/WorldObjectSync.cs b/WorldSync/WorldObjectSync.cs new file mode 100644 index 0000000..9e15481 --- /dev/null +++ b/WorldSync/WorldObjectSync.cs @@ -0,0 +1,474 @@ +using dc; +using dc.en; +using dc.en.inter; +using dc.pr; +using DeadCellsMultiplayerMod; +using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using DeadCellsMultiplayerMod.Interaction; +using ModCore.Events; +using ModCore.Events.Interfaces.Game.Hero; +using Serilog; +using System.Diagnostics; +using System.Globalization; +using System.Reflection; + +namespace DeadCellsMultiplayerMod.WorldSync; + +/// +/// Visibility-safe host/world-object state sync. +/// +/// v6.4.4 deliberately avoids direct sprite hiding/alpha changes. The previous broad v6.4 +/// scanner could read temporary culling/invisible sprite state as a real terminal state and then +/// made matching objects invisible on the other client. This version only lets the host broadcast +/// conservative terminal flags for specific object families, and the receiver only writes gameplay +/// state booleans. It never forces spr.visible=false or alpha=0. +/// +public sealed class WorldObjectSync : + IEventReceiver, + IOnAdvancedModuleInitializing, + IOnHeroUpdate +{ + private const double TileSizePx = 24.0; + private const double ScanIntervalSeconds = 1.85; + private const double HostCorrectionIntervalSeconds = 7.50; + private const int MaxObjectsPerScan = 48; + private const double MatchRadiusPx = 40.0; + private const double MatchRadiusSq = MatchRadiusPx * MatchRadiusPx; + + private readonly ILogger _log; + private long _nextScanTick; + private long _nextHostCorrectionTick; + private string _lastLevelId = string.Empty; + private readonly Dictionary _lastSentFlags = new(StringComparer.Ordinal); + private readonly Dictionary _recentApplied = new(StringComparer.Ordinal); + + private static readonly string[] InterestingTypeTokens = + { + "item", "loot", "weapon", "skill", "scroll", "blueprint", "key", "rune", + "secret", "legendary", "cursed", "chest", "reward", "treasure", + "altar", "breakable", "wall", "shrine", "protector", "guardian", + "challenge", "slab", "pedestal", "amulet" + }; + + private static readonly string[] BadTypeTokens = + { + "hero", "mob", "zombie", "grenader", "archer", "runner", "bat", "dasher", + "shielder", "shieldbearer", "bullet", "projectile", "grenade", "ammo", "pet", "familiar", + "fx", "particle", "decal", "cine", "camera", "ui", "hud", "minimap", "ghost" + }; + + public WorldObjectSync(ModEntry entry) + { + _log = entry.Logger; + EventSystem.AddReceiver(this); + } + + void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) + { + entry.Logger.Information("\x1b[32m[[WorldObjectSync] Initializing v6.4.4 visibility-safe host world-object sync...]\x1b[0m "); + } + + void IOnHeroUpdate.OnHeroUpdate(double dt) + { + var net = GameMenu.NetRef; + var hero = ModEntry.me; + var level = hero?._level; + if (net == null || !net.IsAlive || hero == null || level == null) + return; + + var levelId = TryGetCurrentLevelId(hero); + if (string.IsNullOrWhiteSpace(levelId)) + return; + + if (!string.Equals(_lastLevelId, levelId, StringComparison.Ordinal)) + { + _lastLevelId = levelId; + _lastSentFlags.Clear(); + _recentApplied.Clear(); + _nextScanTick = 0; + _nextHostCorrectionTick = 0; + } + + // Host-authoritative: clients apply host state, but clients do not broad-scan and send + // their own world object states. This prevents one client's temporary culling/hidden state + // from making every matching object invisible for the whole run. + if (!net.IsHost) + ApplyPendingWorldObjectStates(net, level, levelId); + + if (!net.IsHost) + return; + + var now = Stopwatch.GetTimestamp(); + if (now < _nextScanTick) + return; + + _nextScanTick = now + SecondsToTicks(ScanIntervalSeconds); + var forceCorrection = now >= _nextHostCorrectionTick; + if (forceCorrection) + _nextHostCorrectionTick = now + SecondsToTicks(HostCorrectionIntervalSeconds); + + ScanAndSendChangedWorldObjectStates(net, hero, level, levelId, forceCorrection); + } + + private void ScanAndSendChangedWorldObjectStates(NetNode net, Hero hero, Level level, string levelId, bool forceCorrection) + { + var entities = SafeRead(() => level.entities, null); + if (entities == null) + return; + + var sent = 0; + for (var i = 0; i < entities.length && sent < MaxObjectsPerScan; i++) + { + Entity? e = null; + try { e = entities.getDyn(i) as Entity; } catch { } + if (e == null || ReferenceEquals(e, hero)) + continue; + + var typeName = GetStableTypeName(e); + if (!ShouldTrackWorldObject(typeName)) + continue; + + var flags = ReadWorldObjectFlags(e, typeName); + if (flags == 0) + continue; + + var (x, y) = GetEntityPixelPos(e); + if (!IsUsefulPos(x, y)) + continue; + + var key = BuildStableKey(levelId, typeName, x, y); + if (!forceCorrection && _lastSentFlags.TryGetValue(key, out var last) && last == flags) + continue; + + _lastSentFlags[key] = flags; + net.SendWorldObjectState(levelId, typeName, x, y, flags); + sent++; + } + } + + private void ApplyPendingWorldObjectStates(NetNode net, Level level, string localLevelId) + { + if (!net.TryConsumeWorldObjectStates(out var states) || states == null || states.Count == 0) + return; + + foreach (var state in states) + { + if (string.IsNullOrWhiteSpace(state.LevelId) || + !string.Equals(state.LevelId, localLevelId, StringComparison.Ordinal)) + { + continue; + } + + var safeFlags = StripUnsafeFlags(state.Flags); + if ((safeFlags & (int)WorldObjectSyncFlags.Important) == 0) + continue; + + if (!ShouldTrackWorldObject(state.TypeName)) + continue; + + var key = BuildStableKey(state.LevelId, state.TypeName, state.X, state.Y); + var now = Stopwatch.GetTimestamp(); + if (_recentApplied.TryGetValue(key, out var last) && now - last < SecondsToTicks(0.85)) + continue; + _recentApplied[key] = now; + + var local = FindMatchingWorldObject(level, state); + if (local == null) + continue; + + try + { + ApplyWorldObjectFlags(local, state.TypeName, safeFlags); + } + catch (Exception ex) + { + _log.Debug(ex, "[WorldObjectSync] Apply failed type={Type} x={X} y={Y} flags={Flags}", state.TypeName, state.X, state.Y, safeFlags); + } + } + } + + private static Entity? FindMatchingWorldObject(Level level, WorldObjectState state) + { + var entities = SafeRead(() => level.entities, null); + if (entities == null) + return null; + + Entity? nearestSameType = null; + Entity? nearestCompatible = null; + var nearestSameSq = MatchRadiusSq; + var nearestCompatibleSq = MatchRadiusSq * 0.36; + var wantedType = state.TypeName ?? string.Empty; + + for (var i = 0; i < entities.length; i++) + { + Entity? e = null; + try { e = entities.getDyn(i) as Entity; } catch { } + if (e == null) + continue; + + var typeName = GetStableTypeName(e); + if (!ShouldTrackWorldObject(typeName)) + continue; + + var (x, y) = GetEntityPixelPos(e); + if (!IsUsefulPos(x, y)) + continue; + + var dx = x - state.X; + var dy = y - state.Y; + var dSq = dx * dx + dy * dy; + if (dSq > MatchRadiusSq) + continue; + + if (string.Equals(typeName, wantedType, StringComparison.Ordinal)) + { + if (dSq < nearestSameSq) + { + nearestSameSq = dSq; + nearestSameType = e; + } + } + else if (dSq < nearestCompatibleSq && AreWorldTypesCompatible(typeName, wantedType)) + { + nearestCompatibleSq = dSq; + nearestCompatible = e; + } + } + + return nearestSameType ?? nearestCompatible; + } + + private static bool AreWorldTypesCompatible(string a, string b) + { + if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) + return false; + a = a.ToLowerInvariant(); + b = b.ToLowerInvariant(); + + if ((a.Contains("chest") && b.Contains("chest")) || + (a.Contains("scroll") && b.Contains("scroll")) || + (a.Contains("item") && b.Contains("item")) || + (a.Contains("loot") && b.Contains("loot")) || + (a.Contains("weapon") && b.Contains("weapon")) || + (a.Contains("skill") && b.Contains("skill")) || + (a.Contains("blueprint") && b.Contains("blueprint")) || + (a.Contains("key") && b.Contains("key")) || + (a.Contains("secret") && b.Contains("secret")) || + (a.Contains("breakable") && b.Contains("breakable")) || + (a.Contains("legendary") && b.Contains("legendary")) || + (a.Contains("reward") && b.Contains("reward"))) + { + return true; + } + + return false; + } + + private static void ApplyWorldObjectFlags(Entity e, string typeName, int flags) + { + flags = StripUnsafeFlags(flags); + if ((flags & (int)WorldObjectSyncFlags.Important) == 0) + return; + + if ((flags & (int)WorldObjectSyncFlags.Opened) != 0 && IsOpenableWorldObjectType(typeName)) + { + SetBoolMember(e, "opened", true); + SetBoolMember(e, "isOpen", true); + SetBoolMember(e, "open", true); + SetBoolMember(e, "activated", true); + SetBoolMember(e, "used", true); + SetBoolMember(e, "done", true); + } + + if ((flags & (int)WorldObjectSyncFlags.Broken) != 0 && IsBreakableWorldObjectType(typeName)) + { + SetBoolMember(e, "broken", true); + } + + if ((flags & (int)WorldObjectSyncFlags.Consumed) != 0 && IsConsumableWorldObjectType(typeName)) + { + SetBoolMember(e, "picked", true); + SetBoolMember(e, "pickedUp", true); + SetBoolMember(e, "collected", true); + SetBoolMember(e, "taken", true); + SetBoolMember(e, "looted", true); + SetBoolMember(e, "used", true); + SetBoolMember(e, "isOutOfGame", true); + SetBoolMember(e, "outOfGame", true); + } + } + + private static int ReadWorldObjectFlags(Entity e, string typeName) + { + var flags = 0; + + if (IsConsumableWorldObjectType(typeName) && + (BoolMember(e, "picked") || BoolMember(e, "pickedUp") || BoolMember(e, "collected") || + BoolMember(e, "taken") || BoolMember(e, "looted") || BoolMember(e, "consumed"))) + { + flags |= (int)WorldObjectSyncFlags.Consumed; + } + + if (IsOpenableWorldObjectType(typeName) && + (BoolMember(e, "opened") || BoolMember(e, "isOpen") || BoolMember(e, "open") || + BoolMember(e, "activated") || BoolMember(e, "used") || BoolMember(e, "done"))) + { + flags |= (int)WorldObjectSyncFlags.Opened; + } + + if (IsBreakableWorldObjectType(typeName) && + (BoolMember(e, "broken") || BoolMember(e, "destroyed"))) + { + flags |= (int)WorldObjectSyncFlags.Broken; + } + + // Do not derive Hidden from sprite visibility/alpha. Dead Cells hides/culls many valid + // objects temporarily, and syncing that state made items/secrets/chests vanish remotely. + flags = StripUnsafeFlags(flags); + + if (flags != 0) + flags |= (int)WorldObjectSyncFlags.Important; + + return flags; + } + + private static int StripUnsafeFlags(int flags) + { + // Hidden was too broad for Dead Cells' renderer/culling state. Keep the enum/protocol for + // compatibility with older packets, but ignore it in v6.4.4 runtime logic. + flags &= ~(int)WorldObjectSyncFlags.Hidden; + if ((flags & ((int)WorldObjectSyncFlags.Consumed | (int)WorldObjectSyncFlags.Opened | (int)WorldObjectSyncFlags.Broken)) == 0) + flags &= ~(int)WorldObjectSyncFlags.Important; + return flags; + } + + private static bool ShouldTrackWorldObject(string typeName) + { + if (string.IsNullOrWhiteSpace(typeName)) + return false; + + var lower = typeName.ToLowerInvariant(); + foreach (var bad in BadTypeTokens) + { + if (lower.Contains(bad)) + return false; + } + + foreach (var token in InterestingTypeTokens) + { + if (lower.Contains(token)) + return true; + } + + return false; + } + + private static bool IsConsumableWorldObjectType(string typeName) + { + var lower = (typeName ?? string.Empty).ToLowerInvariant(); + return lower.Contains("item") || lower.Contains("loot") || lower.Contains("weapon") || + lower.Contains("skill") || lower.Contains("scroll") || lower.Contains("blueprint") || + lower.Contains("key") || lower.Contains("rune") || lower.Contains("reward") || + lower.Contains("treasure") || lower.Contains("amulet"); + } + + private static bool IsOpenableWorldObjectType(string typeName) + { + var lower = (typeName ?? string.Empty).ToLowerInvariant(); + return lower.Contains("chest") || lower.Contains("cursed") || lower.Contains("reward") || + lower.Contains("treasure") || lower.Contains("secret") || lower.Contains("legendary") || + lower.Contains("altar") || lower.Contains("shrine") || lower.Contains("pedestal") || + lower.Contains("slab") || lower.Contains("challenge"); + } + + private static bool IsBreakableWorldObjectType(string typeName) + { + var lower = (typeName ?? string.Empty).ToLowerInvariant(); + return lower.Contains("breakable") || lower.Contains("wall") || lower.Contains("secret"); + } + + private static bool BoolMember(object obj, string name) + { + try + { + var t = obj.GetType(); + var prop = t.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase); + if (prop != null && prop.PropertyType == typeof(bool)) + return (bool)(prop.GetValue(obj) ?? false); + var field = t.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase); + if (field != null && field.FieldType == typeof(bool)) + return (bool)(field.GetValue(obj) ?? false); + } + catch { } + return false; + } + + private static void SetBoolMember(object obj, string name, bool value) + { + try + { + var t = obj.GetType(); + var prop = t.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase); + if (prop != null && prop.CanWrite && prop.PropertyType == typeof(bool)) + { + prop.SetValue(obj, value); + return; + } + var field = t.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase); + if (field != null && field.FieldType == typeof(bool)) + field.SetValue(obj, value); + } + catch { } + } + + private static (double x, double y) GetEntityPixelPos(Entity e) + { + try + { + if (e.spr != null) + return (e.spr.x, e.spr.y); + } + catch { } + + try { return ((e.cx + e.xr) * TileSizePx, (e.cy + e.yr) * TileSizePx); } + catch { return (0, 0); } + } + + private static bool IsUsefulPos(double x, double y) + { + return double.IsFinite(x) && double.IsFinite(y) && (System.Math.Abs(x) > 0.01 || System.Math.Abs(y) > 0.01); + } + + private static string GetStableTypeName(object obj) + { + try { return obj.GetType().Name ?? string.Empty; } + catch { return string.Empty; } + } + + private static string TryGetCurrentLevelId(Hero hero) + { + try + { + var levelId = hero._level?.map?.id?.ToString(); + if (!string.IsNullOrWhiteSpace(levelId)) + return levelId.Trim(); + } + catch { } + return string.Empty; + } + + private static string BuildStableKey(string levelId, string typeName, double x, double y) + { + var qx = (int)System.Math.Round(x / 12.0); + var qy = (int)System.Math.Round(y / 12.0); + return $"{levelId}|{typeName}|{qx}|{qy}"; + } + + private static T? SafeRead(Func fn, T? fallback) + { + try { return fn(); } + catch { return fallback; } + } + + private static long SecondsToTicks(double seconds) => (long)(Stopwatch.Frequency * seconds); +} diff --git a/server/server.NetNode.Cleanup.cs b/server/server.NetNode.Cleanup.cs index 998e177..461aa3a 100644 --- a/server/server.NetNode.Cleanup.cs +++ b/server/server.NetNode.Cleanup.cs @@ -34,6 +34,8 @@ private void CleanupClient() _pendingInterBreakableGroundEvents.Clear(); _pendingBossRuneUpdateCells.Clear(); _pendingInterPortalEvents.Clear(); + _pendingInterGenericActivateEvents.Clear(); + _pendingWorldObjectStates.Clear(); } if (_useSteamTransport) { diff --git a/server/server.NetNode.Consume.cs b/server/server.NetNode.Consume.cs index 143dad7..d821704 100644 --- a/server/server.NetNode.Consume.cs +++ b/server/server.NetNode.Consume.cs @@ -345,6 +345,22 @@ public bool TryConsumeInterPortalEvents(out List events) } } + public bool TryConsumeInterGenericActivateEvents(out List events) + { + lock (_sync) + { + return TryConsumePendingListLocked(ref _pendingInterGenericActivateEvents, out events); + } + } + + public bool TryConsumeWorldObjectStates(out List states) + { + lock (_sync) + { + return TryConsumePendingListLocked(ref _pendingWorldObjectStates, out states); + } + } + public bool TryGetRemoteHpSnapshots(out List snapshot) { lock (_sync) diff --git a/server/server.NetNode.Dispose.cs b/server/server.NetNode.Dispose.cs index 79d3013..e1e605e 100644 --- a/server/server.NetNode.Dispose.cs +++ b/server/server.NetNode.Dispose.cs @@ -55,6 +55,10 @@ public void Dispose() try { _client?.Close(); } catch { } try { _listener?.Stop(); } catch { } try { _steamTransportTask?.Wait(400); } catch { } + if (_useSteamTransport && _role == NetRole.Host) + { + try { TryClearSteamRichPresence(); } catch { } + } GameDataSync.Seed = 0; lock (_hostCacheSync) { @@ -90,6 +94,17 @@ public void Dispose() _pendingBossHeroTeleports.Clear(); _pendingPlayerDownStates.Clear(); _pendingPlayerReviveRequests.Clear(); + _pendingInterDoorEvents.Clear(); + _pendingInterElevatorEvents.Clear(); + _pendingInterPressurePlateEvents.Clear(); + _pendingInterTreasureChestEvents.Clear(); + _pendingInterVineLadderEvents.Clear(); + _pendingInterTeleportEvents.Clear(); + _pendingInterBreakableGroundEvents.Clear(); + _pendingBossRuneUpdateCells.Clear(); + _pendingInterPortalEvents.Clear(); + _pendingInterGenericActivateEvents.Clear(); + _pendingWorldObjectStates.Clear(); } _stream = null; _client = null; _listener = null; try { _sendLock.Dispose(); } catch { } diff --git a/server/server.NetNode.Parse.cs b/server/server.NetNode.Parse.cs index b57abe6..09ab2a3 100644 --- a/server/server.NetNode.Parse.cs +++ b/server/server.NetNode.Parse.cs @@ -389,10 +389,16 @@ private static bool TryParseMobDiePayload(string payload, int? senderId, bool fo return false; var generation = 0; - if (parts.Length > 4) - int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out generation); + var typeIndex = 4; + if (parts.Length > 4 && + int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedGeneration)) + { + generation = parsedGeneration; + typeIndex = 5; + } - die = new MobDie(parsedUserId, mobIndex, x, y, generation); + var type = parts.Length > typeIndex ? parts[typeIndex] : string.Empty; + die = new MobDie(parsedUserId, mobIndex, x, y, type, generation); return true; } @@ -895,6 +901,26 @@ private static bool TryParseBossHeroTeleportPayload(string payload, int? senderI return true; } + private static bool TryParseInterGenericActivatePayload(string payload, out InterGenericActivateEvent ev) + { + ev = default; + if (string.IsNullOrWhiteSpace(payload)) + return false; + + var parts = payload.Split('|'); + if (parts.Length < 2) + return false; + + if (!double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) + return false; + if (!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) + return false; + + var typeName = parts.Length >= 3 ? parts[2].Trim() : string.Empty; + ev = new InterGenericActivateEvent(x, y, typeName); + return true; + } + private static bool TryParseInterBreakableGroundPayload(string payload, out InterBreakableGroundEvent ev) { ev = default; @@ -937,6 +963,34 @@ private static bool TryParseInterPortalPayload(string payload, out InterPortalEv return true; } + private static bool TryParseWorldObjectStatePayload(string payload, out WorldObjectState state) + { + state = default; + if (string.IsNullOrWhiteSpace(payload)) + return false; + + var parts = payload.Split('|'); + if (parts.Length < 5) + return false; + + var levelId = (parts[0] ?? string.Empty).Trim(); + var typeName = (parts[1] ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(levelId) || string.IsNullOrWhiteSpace(typeName)) + return false; + + if (!double.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) + return false; + if (!double.TryParse(parts[3], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) + return false; + if (!int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var flags)) + return false; + if (flags <= 0) + return false; + + state = new WorldObjectState(levelId, typeName, x, y, flags); + return true; + } + private static bool TryParsePositionLine(string line, int? senderId, out int remoteId, out double rx, out double ry, out int dir, out bool hasDir) { remoteId = 0; diff --git a/server/server.NetNode.Protocol.Incoming.cs b/server/server.NetNode.Protocol.Incoming.cs index 8f018e3..b6a35cc 100644 --- a/server/server.NetNode.Protocol.Incoming.cs +++ b/server/server.NetNode.Protocol.Incoming.cs @@ -871,6 +871,30 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) return true; } + if (line.StartsWith("INTERGENACT|", StringComparison.OrdinalIgnoreCase)) + { + var payload = line["INTERGENACT|".Length..]; + if (TryParseInterGenericActivatePayload(payload, out var ev)) + { + lock (_sync) + { + _pendingInterGenericActivateEvents.Add(ev); + _hasRemote = true; + } + + if (_role == NetRole.Host && senderId.HasValue) + { + var safeType = (ev.TypeName ?? string.Empty) + .Replace("|", "/", StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal) + .Trim(); + forwardLine = $"INTERGENACT|{ev.X.ToString(CultureInfo.InvariantCulture)}|{ev.Y.ToString(CultureInfo.InvariantCulture)}|{safeType}\n"; + } + } + return true; + } + if (line.StartsWith("INTERBREAK|", StringComparison.OrdinalIgnoreCase)) { var payload = line["INTERBREAK|".Length..]; @@ -905,6 +929,36 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) return true; } + if (line.StartsWith("WORLDOBJ|", StringComparison.OrdinalIgnoreCase)) + { + var payload = line["WORLDOBJ|".Length..]; + if (TryParseWorldObjectStatePayload(payload, out var state)) + { + lock (_sync) + { + _pendingWorldObjectStates.Add(state); + _hasRemote = true; + } + + if (_role == NetRole.Host && senderId.HasValue) + { + var safeLevel = (state.LevelId ?? string.Empty) + .Replace("|", "/", StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal) + .Trim(); + var safeType = (state.TypeName ?? string.Empty) + .Replace("|", "/", StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal) + .Trim(); + forwardLine = + $"WORLDOBJ|{safeLevel}|{safeType}|{state.X.ToString(CultureInfo.InvariantCulture)}|{state.Y.ToString(CultureInfo.InvariantCulture)}|{state.Flags.ToString(CultureInfo.InvariantCulture)}\n"; + } + } + return true; + } + if (line.StartsWith("MOBEVENT|", StringComparison.OrdinalIgnoreCase)) { var payload = line["MOBEVENT|".Length..]; @@ -937,7 +991,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) } else if (ev == "die") { - var die = new MobDie(effectiveUserId, u.Index, u.X, u.Y, u.Generation); + var die = new MobDie(effectiveUserId, u.Index, u.X, u.Y, u.Type, u.Generation); _pendingMobDies.Add(die); if (_role == NetRole.Host && senderId.HasValue) hasDieToForward = true; diff --git a/server/server.NetNode.SendPublic.cs b/server/server.NetNode.SendPublic.cs index 8ffdcd1..6b8c476 100644 --- a/server/server.NetNode.SendPublic.cs +++ b/server/server.NetNode.SendPublic.cs @@ -547,6 +547,11 @@ public void SendMobHit(int mobIndex, int hp, double x, double y, int generation } public void SendMobDie(int mobIndex, double x, double y, int generation = 0) + { + SendMobDie(mobIndex, x, y, string.Empty, generation); + } + + public void SendMobDie(int mobIndex, double x, double y, string type, int generation = 0) { if (_role != NetRole.Client && _role != NetRole.Host) return; @@ -555,9 +560,10 @@ public void SendMobDie(int mobIndex, double x, double y, int generation = 0) if (ID <= 0) return; - var payload = string.Create( - CultureInfo.InvariantCulture, - $"MOBDIE|{ID}|{mobIndex}|{x}|{y}|{generation}"); + var safeType = type ?? string.Empty; + var payload = string.IsNullOrWhiteSpace(safeType) + ? string.Create(CultureInfo.InvariantCulture, $"MOBDIE|{ID}|{mobIndex}|{x}|{y}|{generation}") + : string.Create(CultureInfo.InvariantCulture, $"MOBDIE|{ID}|{mobIndex}|{x}|{y}|{generation}|{safeType}"); SendRaw(payload); } @@ -722,6 +728,47 @@ public void SendInterPortal(double x, double y, string action) SendRaw($"INTERPORTAL|{action}|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}"); } + public void SendInterGenericActivate(double x, double y, string typeName) + { + if (!HasAnyConnection()) + return; + if (ID <= 0) + return; + + var safeType = (typeName ?? string.Empty) + .Replace("|", "/", StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal) + .Trim(); + + SendRaw($"INTERGENACT|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}|{safeType}"); + } + + public void SendWorldObjectState(string levelId, string typeName, double x, double y, int flags) + { + if (!HasAnyConnection()) + return; + if (ID <= 0) + return; + if (string.IsNullOrWhiteSpace(levelId) || string.IsNullOrWhiteSpace(typeName) || flags <= 0) + return; + + var safeLevel = (levelId ?? string.Empty) + .Replace("|", "/", StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal) + .Trim(); + var safeType = (typeName ?? string.Empty) + .Replace("|", "/", StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal) + .Trim(); + if (string.IsNullOrWhiteSpace(safeLevel) || string.IsNullOrWhiteSpace(safeType)) + return; + + SendRaw($"WORLDOBJ|{safeLevel}|{safeType}|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}|{flags.ToString(CultureInfo.InvariantCulture)}"); + } + private void SendRaw(string payload) { var line = payload.EndsWith('\n') ? payload : payload + "\n"; diff --git a/server/server.NetNode.Steam.cs b/server/server.NetNode.Steam.cs index b2e9684..d5ce72a 100644 --- a/server/server.NetNode.Steam.cs +++ b/server/server.NetNode.Steam.cs @@ -12,14 +12,26 @@ internal bool TrySetSteamHostRichPresence(ulong lobbyId) return false; var connect = lobbyId == 0UL ? string.Empty : $"+connect_lobby {lobbyId}"; + + var success = true; if (!_steamBridge.TrySetRichPresence("connect", connect, out var error)) { if (!string.IsNullOrWhiteSpace(error)) - _log.Warning("[NetNode] Steam worker set rich presence failed: {Error}", error); - return false; + _log.Warning("[NetNode] Steam worker set rich presence connect failed: {Error}", error); + success = false; } - return true; + // These keys make Steam friend-list joins more reliable. Some clients receive a + // GameRichPresenceJoinRequested_t callback instead of a lobby callback, and that + // callback needs the connect string above. The group keys also help Steam associate + // the invite with the active lobby instead of only showing generic Dead Cells. + var group = lobbyId == 0UL ? string.Empty : lobbyId.ToString(CultureInfo.InvariantCulture); + if (!_steamBridge.TrySetRichPresence("steam_player_group", group, out error) && !string.IsNullOrWhiteSpace(error)) + _log.Warning("[NetNode] Steam worker set rich presence group failed: {Error}", error); + if (!_steamBridge.TrySetRichPresence("steam_player_group_size", _connectedClientCount <= 0 ? "1" : (_connectedClientCount + 1).ToString(CultureInfo.InvariantCulture), out error) && !string.IsNullOrWhiteSpace(error)) + _log.Warning("[NetNode] Steam worker set rich presence group size failed: {Error}", error); + + return success; } internal bool TryClearSteamRichPresence() diff --git a/server/server.cs b/server/server.cs index 0c3b0d0..a6ff67d 100644 --- a/server/server.cs +++ b/server/server.cs @@ -359,6 +359,8 @@ public readonly struct MobDie public readonly int Generation; public readonly double X; public readonly double Y; + /// Optional mob type signature. v6.2 uses this to avoid killing/rebinding the wrong mob after sync-id drift. + public readonly string Type; public MobDie(int userId, int mobIndex, double x, double y, int generation = 0) { @@ -367,6 +369,17 @@ public MobDie(int userId, int mobIndex, double x, double y, int generation = 0) Generation = generation; X = x; Y = y; + Type = string.Empty; + } + + public MobDie(int userId, int mobIndex, double x, double y, string type, int generation = 0) + { + UserId = userId; + MobIndex = mobIndex; + Generation = generation; + X = x; + Y = y; + Type = type ?? string.Empty; } } @@ -580,6 +593,8 @@ private static bool TryTakeNextUnusedClientId(out int assignedId) private List _pendingInterBreakableGroundEvents = new(); private List _pendingBossRuneUpdateCells = new(); private List _pendingInterPortalEvents = new(); + private List _pendingInterGenericActivateEvents = new(); + private List _pendingWorldObjectStates = new(); private int _primaryRemoteId; private readonly IPEndPoint _bindEp; // host bind From 736afc58f6004106c737a79780b992dc0d06b105 Mon Sep 17 00:00:00 2001 From: realMiksy Date: Tue, 7 Jul 2026 22:07:43 +0300 Subject: [PATCH 2/5] Commit to fix/DeadCellsCoopPlusv0.8.2 bug & crash fixes --- ADVANCED_COOP_NOTES.md | 40 + AdvancedCoop/CoopAdvancedHardening.cs | 323 +++++ CHANGELOG.md | 85 +- CODE_OF_CONDUCT.md | 37 - COMPATIBILITY_NOTES_v0.8.33.md | 28 + CONTRIBUTING.md | 89 +- CONTRIBUTING_ru.md | 61 + DeadCellsMultiplayerMod.csproj | 7 +- FakeDeath/FakeDeath.cs | 1320 +++--------------- Ghost/GhostKing.cs | 4 +- Ghost/KingDiveAttackHooksBridge.cs | 70 +- Ghost/KingWeapon/KingWeaponHooksBridge.cs | 8 - GhostCine/GhostDead/DeadBase.cs | 83 +- GhostCine/GhostDead/RemoteDownedCorpse.cs | 186 ++- Interaction/InterSyncTypes.cs | 44 - Interaction/InteractionSync.cs | 422 +----- KINGSKIN_CRASH_NOTES_v0.8.34.md | 25 + LevelSync.cs | 68 - MERGE_NOTES_v0.8.32.md | 24 + Mobs/Levelinit.cs | 13 + Mobs/MobWireCodec.cs | 7 - Mobs/MonsterSynchronization.Attacks.cs | 38 +- Mobs/MonsterSynchronization.ClientApply.cs | 139 +- Mobs/MonsterSynchronization.ClientReceive.cs | 491 ++++--- Mobs/MonsterSynchronization.Constants.cs | 9 +- Mobs/MonsterSynchronization.DirtyQueue.cs | 33 +- Mobs/MonsterSynchronization.ZeroHpCleanup.cs | 59 - Mobs/MonsterSynchronization.cs | 166 +-- ModEntry/ModEntry.BossCine.cs | 268 +--- ModEntry/ModEntry.DebugHero.cs | 60 +- ModEntry/ModEntry.GhostSync.cs | 271 ++-- ModEntry/ModEntry.KingSkinRenderSafety.cs | 377 +++++ ModEntry/ModEntry.NetworkMenu.cs | 3 - ModEntry/ModEntry.StabilityGuards.cs | 19 - ModEntry/ModEntry.Steam.cs | 73 +- ModEntry/ModEntry.cs | 208 +-- NOTICE.md | 10 - README.md | 157 ++- README_ru.md | 123 ++ SECURITY.md | 35 - SUBLEVEL_REWARD_DOOR_NOTES_v0.8.36.md | 23 + SteamP2P/SteamConnect.cs | 85 +- UI/GameMenu.ReviveInput.cs | 293 +--- UI/GameMenu.cs | 179 ++- UI/GameMenuHooks.cs | 6 +- UI/LevelExitSync.cs | 346 +---- UI/MultiplayerUI.cs | 329 ++++- UI/StuckRecoveryFailsafe.cs | 261 ---- WorldSync/WorldObjectSync.cs | 474 ------- server/server.NetNode.Cleanup.cs | 2 - server/server.NetNode.Consume.cs | 16 - server/server.NetNode.Dispose.cs | 15 - server/server.NetNode.Parse.cs | 60 +- server/server.NetNode.Protocol.Incoming.cs | 78 +- server/server.NetNode.SendPublic.cs | 51 +- server/server.NetNode.Steam.cs | 18 +- server/server.cs | 15 - 57 files changed, 3117 insertions(+), 4617 deletions(-) create mode 100644 ADVANCED_COOP_NOTES.md create mode 100644 AdvancedCoop/CoopAdvancedHardening.cs delete mode 100644 CODE_OF_CONDUCT.md create mode 100644 COMPATIBILITY_NOTES_v0.8.33.md create mode 100644 CONTRIBUTING_ru.md create mode 100644 KINGSKIN_CRASH_NOTES_v0.8.34.md create mode 100644 MERGE_NOTES_v0.8.32.md delete mode 100644 Mobs/MonsterSynchronization.ZeroHpCleanup.cs create mode 100644 ModEntry/ModEntry.KingSkinRenderSafety.cs delete mode 100644 ModEntry/ModEntry.StabilityGuards.cs delete mode 100644 NOTICE.md create mode 100644 README_ru.md delete mode 100644 SECURITY.md create mode 100644 SUBLEVEL_REWARD_DOOR_NOTES_v0.8.36.md delete mode 100644 UI/StuckRecoveryFailsafe.cs delete mode 100644 WorldSync/WorldObjectSync.cs 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 index 38c5c45..fa8fbd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,67 @@ +# 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`, Claude 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 Claude 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 + Claude MobSync + safe new Party HUD + +- Keeps v0.8.2 `LevelExitSync.cs`, level loading, ghost lifecycle, and ModEntry transition behavior unchanged. +- Ports the later Claude 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. + # Changelog -## v0.4.0 - Initial Community Fork - -### Added -- Community-maintained fork -- Project documentation -- Bug tracking -- Development roadmap - -### Fixed -- Improved synchronization -- World object synchronization -- Revive synchronization improvements -- Elite synchronization improvements -- Rune progression improvements -- Multiplayer stability improvements - -### Changed -- Repository renamed to DeadCellsCoopPlus -- Documentation rewritten -- Project organization improved + +## 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 deleted file mode 100644 index 7b21bd8..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,37 +0,0 @@ -# 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..8d356a5 --- /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 + +- Claude 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 4a0142b..34566a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,66 +1,65 @@ -# Contributing to DeadCellsCoopPlus +
-Thanks for helping improve DeadCellsCoopPlus. +English • [Русский](CONTRIBUTING_ru.md) + +
+# Contributing to DeadCellsMultiplayerMod -This project is a community-maintained fork of the original Dead Cells Multiplayer Mod. The main goal is to improve multiplayer stability, synchronization, revive behavior, rune progression, and general co-op quality of life. +Thank you for your interest in improving this mod. This document describes how to build the project and what we expect from contributions. -## Before contributing +## Prerequisites -Please make sure your change fits one of these goals: +- **Windows** (the project targets `net10.0` with `SupportedOSPlatform` Windows). +- **.NET SDK** compatible with **.NET 10** (see `TargetFramework` in `DeadCellsMultiplayerMod.csproj`). +- **Dead Cells** with **DCCM (Dead Cells Core Modding API)** installed for local testing. +- Optional: **`DCCM_MDK_ROOT`** environment variable pointing at your DCCM MDK/tools folder if you need Steamworks references (`Steamworks.NET`, `steam_api64.dll`) resolved via the paths in the project file. -- Fixing multiplayer bugs -- Improving synchronization -- Improving revive/downed-player behavior -- Fixing elite/rune progression issues -- Improving stability -- Improving documentation -- Adding safe quality-of-life features +## Build -## How to contribute - -1. Fork the repository. -2. Create a new branch for your change. +From the repository root: -```bash -git checkout -b fix/revive-sync +```powershell +dotnet build -c Release ``` -3. Make your changes. -4. Test the mod in-game if possible. -5. Commit your changes with a clear message. +Output artifacts are produced under `bin/Release/net10.0/` (and the packaged mod layout as configured by the DCCM MDK targets). -```bash -git commit -m "Fix revive body falling through floor" -``` +For iterative development with automatic install into your DCCM layout, use **Debug** configuration (`AutoInstallMod` is enabled for Debug in the csproj). -6. Push your branch. -7. Open a pull request. +## Project layout (high level) -## Pull request guidelines +- `ModEntry.cs` — mod entry point and lifecycle. +- `Mobs/` — mob synchronization, wire codecs, tracing. +- `Ghost/` — remote player ghost and related hooks. +- `UI/` — in-game UI. +- `Resourcefile/lang/` — localization (`.po` / `.pot`). +- `server/` — networking (`NetNode`, wire protocol). -Please include: +## How to contribute +1. **Open an issue** or discuss a **small, scoped** change before large refactors. +2. **Fork** the repository and create a **branch** focused on one feature or fix. +3. **Keep pull requests focused** — avoid unrelated formatting, renames, or drive-by cleanups in files you are not changing for the task. +4. **Match existing style** — naming, patterns, and comment density should match surrounding code. +5. **Build** in Release before submitting; fix any new compiler warnings relevant to your change. +6. **Describe** what changed and **why** in the PR description (plain language, complete sentences). -- What the change does -- What bug it fixes -- How you tested it -- Any known problems +## Code review expectations -## Bug reports +- Changes should be **minimal** and **directly related** to the stated goal. +- Do **not** delete unrelated comments or rewrite large sections without need. +- Prefer **one clear code path** over many special cases when possible. +- New user-facing strings belong in **localization** (`Resourcefile/lang/`) when applicable. -When reporting bugs, include: +## Testing -- Dead Cells version -- Mod version -- Host or client side -- Steps to reproduce -- What you expected to happen -- What actually happened -- Logs or screenshots if possible +There is no automated test suite in this repository. For gameplay changes: -## Code style +- Run the game **through DCCM** with the mod loaded. +- For multiplayer, verify **host** and **client** behavior when you touch sync or networking code. -Try to keep changes small and focused. Avoid large rewrites unless they are necessary. +## Questions -## Credits +For DCCM installation and API documentation, see the official DCCM docs and GitHub: -Please respect the original project and keep credit to the original author. +- [DCCM install (Steam Workshop)](https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/) +- [dead-cells-core-modding/core](https://github.com/dead-cells-core-modding/core) diff --git a/CONTRIBUTING_ru.md b/CONTRIBUTING_ru.md new file mode 100644 index 0000000..4ac0c1a --- /dev/null +++ b/CONTRIBUTING_ru.md @@ -0,0 +1,61 @@ +# Участие в разработке DeadCellsMultiplayerMod + +Спасибо за интерес к улучшению мода. Здесь описано, как собрать проект и чего мы ожидаем от изменений в коде. + +## Требования + +- **Windows** (в проекте указано `SupportedOSPlatform` Windows, целевой фреймворк — `net10.0`). +- **.NET SDK**, совместимый с **.NET 10** (см. `TargetFramework` в `DeadCellsMultiplayerMod.csproj`). +- **Dead Cells** с установленным **DCCM (Dead Cells Core Modding API)** для локальной проверки. +- По желанию: переменная окружения **`DCCM_MDK_ROOT`** — путь к папке MDK/tools DCCM, если нужно подтянуть ссылки на **Steamworks.NET** и `steam_api64.dll` согласно настройкам в `.csproj`. + +## Сборка + +Из корня репозитория: + +```powershell +dotnet build -c Release +``` + +Артефакты сборки попадают в `bin/Release/net10.0/` (и в упакованный вид мода — согласно целям DCCM MDK). + +Для частой разработки с автоматической установкой в окружение DCCM используйте конфигурацию **Debug** (в `.csproj` для Debug включён `AutoInstallMod`). + +## Структура проекта (кратко) + +- `ModEntry.cs` — точка входа мода и жизненный цикл. +- `Mobs/` — синхронизация мобов, кодеки, трассировка. +- `Ghost/` — призрак удалённого игрока и связанные хуки. +- `UI/` — UI настроек в игре. +- `Resourcefile/lang/` — локализация (`.po` / `.pot`). +- `server/` — сеть (`NetNode`, протокол). + +## Как вносить вклад + +1. **Создайте issue** или заранее обсудите **небольшой по объёму** рефакторинг. +2. Сделайте **fork** репозитория и **ветку** под одну задачу или исправление. +3. **Держите PR сфокусированным** — без несвязанного форматирования, переименований и «попутной» уборки в чужих файлах. +4. **Соблюдайте стиль** окружающего кода — имена, паттерны, плотность комментариев. +5. Перед отправкой **соберите** проект в Release; устраните новые предупреждения компилятора, относящиеся к вашим правкам. +6. В описании PR **опишите**, что изменилось и **зачем** (понятным языком, полными предложениями). + +## Ожидания при ревью + +- Изменения должны быть **минимальными** и **по делу** задачи. +- **Не** удаляйте чужие комментарии и **не** переписывайте большие куски без необходимости. +- По возможности лучше **один понятный путь** в коде, чем множество особых случаев. +- Новые строки для пользователя — в **локализацию** (`Resourcefile/lang/`), где это уместно. + +## Тестирование + +В репозитории нет автоматических тестов. Для геймплейных изменений: + +- Запускайте игру **через DCCM** с загруженным модом. +- Для мультиплеера проверяйте поведение **хоста** и **клиента**, если менялись синхронизация или сеть. + +## Вопросы + +По установке DCCM и документации API см. официальные материалы: + +- [Установка DCCM (Steam Workshop)](https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/) +- [dead-cells-core-modding/core](https://github.com/dead-cells-core-modding/core) diff --git a/DeadCellsMultiplayerMod.csproj b/DeadCellsMultiplayerMod.csproj index 9c33305..ecd26e3 100644 --- a/DeadCellsMultiplayerMod.csproj +++ b/DeadCellsMultiplayerMod.csproj @@ -8,9 +8,14 @@ Windows $(NoWarn);CA1416;CS0414;CS9107 - 0.3.3 + 0.8.36 mod DeadCellsMultiplayerMod.ModEntry + + x64 + win-x64 + false + DeadCellsMultiplayerMod true $(DCCM_MDK_ROOT)\tools\Steamworks.NET.dll diff --git a/FakeDeath/FakeDeath.cs b/FakeDeath/FakeDeath.cs index 4650936..e15a1ad 100644 --- a/FakeDeath/FakeDeath.cs +++ b/FakeDeath/FakeDeath.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Globalization; using System.Diagnostics; -using dc; using dc.en; using dc.tool.atk; using dc.tool.mainSkills; @@ -27,56 +26,11 @@ public partial class ModEntry private double _localDownedAnchorY; private const double DownedCorpseMaxDriftPx = 96.0; private const double DownedCorpseMaxDriftSq = DownedCorpseMaxDriftPx * DownedCorpseMaxDriftPx; - private bool _hasLocalReviveSafePosition; - private double _localReviveSafeX; - private double _localReviveSafeY; - private string _localReviveSafeLevelId = string.Empty; - private long _localReviveSafeTicks; - private bool _hasLocalReviveTeleporterPosition; - private double _localReviveTeleporterX; - private double _localReviveTeleporterY; - private string _localReviveTeleporterLevelId = string.Empty; - private long _localReviveTeleporterTicks; - private readonly List _localReviveSafeHistory = new(); - private const double ReviveSafePositionMaxAgeSeconds = 20.0; - private const double ReviveTeleporterMaxAgeSeconds = 900.0; - private const double ReviveSafeHistoryMinAgeSeconds = 1.25; - private const double ReviveSafeHistoryMaxAgeSeconds = 12.0; - private const int ReviveSafeHistoryMaxEntries = 36; - private const double DownedVoidRescueDropPx = 160.0; - private const double DownedUnsafeRescueCheckIntervalSeconds = 0.20; - private const double DownedSafeRescueLockSeconds = 6.0; - private const double DownedPermanentAnchorRefreshSeconds = 2.0; - private const double DownedParkedHeroYOffsetPx = 8.0; - private bool _localDownedHeroGravityWasCaptured; - private bool _localDownedHeroHadGravity; - private bool _localDownedHeroVisibilityWasCaptured; - private bool _localDownedHeroWasVisible; - private long _nextDownedSafeRescueCheckTicks; - private long _downedSafeRescueLockUntilTicks; - private double _downedSafeRescueLockX; - private double _downedSafeRescueLockY; private readonly HashSet _scratchRemoteActiveIds = new(); private readonly HashSet _scratchActiveCorpseIds = new(); private readonly List _scratchStaleRemoteIds = new(); private readonly List _scratchStaleCorpseIds = new(); - private readonly struct ReviveSafeAnchor - { - public readonly double X; - public readonly double Y; - public readonly string LevelId; - public readonly long Ticks; - - public ReviveSafeAnchor(double x, double y, string levelId, long ticks) - { - X = x; - Y = y; - LevelId = levelId ?? string.Empty; - Ticks = ticks; - } - } - private void Hook_Hero_onHeroDie(Hook_Hero.orig_onHeroDie orig, Hero self) { if (IsDebugImmortalLocalHero(self)) @@ -204,26 +158,7 @@ private void Hook_Hero_checkCursedWeaponHit(Hook_Hero.orig_checkCursedWeaponHit if (_localFakeDead) return; - // v5.8: cursed deaths are unsafe in vanilla multiplayer because the vanilla death - // flow can start controller/animation feedback cleanup before our fake-death hooks - // see it. If the hero appears cursed, go directly to fake death and never let the - // vanilla cursed-death path run. - if (IsHeroLikelyCursed(self)) - { - EnterLocalFakeDeath(self, net); - return; - } - - try - { - orig(self, a); - } - catch (Exception ex) - { - Logger.Warning(ex, "[NetMod][FakeDeath] Suppressed cursed death exception and entered fake death"); - EnterLocalFakeDeath(self, net); - return; - } + orig(self, a); if (_localFakeDead) return; @@ -239,90 +174,6 @@ private void Hook_Hero_checkCursedWeaponHit(Hook_Hero.orig_checkCursedWeaponHit orig(self, a); } - private static bool IsHeroLikelyCursed(Hero self) - { - if (self == null) - return false; - - try - { - var t = self.GetType(); - const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Instance | - System.Reflection.BindingFlags.Public | - System.Reflection.BindingFlags.NonPublic; - - foreach (var f in t.GetFields(flags)) - { - if (!LooksLikeCurseMemberName(f.Name)) - continue; - if (MemberValueMeansCurseActive(f.GetValue(self))) - return true; - } - - foreach (var p in t.GetProperties(flags)) - { - if (!p.CanRead || !LooksLikeCurseMemberName(p.Name)) - continue; - if (p.GetIndexParameters().Length != 0) - continue; - if (MemberValueMeansCurseActive(p.GetValue(self))) - return true; - } - } - catch - { - } - - return false; - } - - private static bool LooksLikeCurseMemberName(string? name) - { - if (string.IsNullOrWhiteSpace(name)) - return false; - return name.IndexOf("curse", StringComparison.OrdinalIgnoreCase) >= 0 || - name.IndexOf("cursed", StringComparison.OrdinalIgnoreCase) >= 0; - } - - private static bool MemberValueMeansCurseActive(object? value) - { - try - { - switch (value) - { - case null: - return false; - case bool b: - return b; - case byte v: - return v > 0; - case sbyte v: - return v > 0; - case short v: - return v > 0; - case ushort v: - return v > 0; - case int v: - return v > 0; - case uint v: - return v > 0; - case long v: - return v > 0; - case ulong v: - return v > 0; - case float v: - return v > 0; - case double v: - return v > 0; - } - } - catch - { - } - - return false; - } - private bool ShouldEnterFakeDeathFromEarlyDeathHook(Hero self, NetNode net) { if (self == null || net == null) @@ -518,8 +369,6 @@ private void UpdateFakeDeathFlow(double dt) return; } - TrackLocalReviveSafePosition(); - ContinueReviveRequestBurst(net); UpdateReviveHintsByProximity(); ProcessReviveHold(net); } @@ -552,8 +401,6 @@ private void ConsumeRemoteDownedStates(NetNode net) } _remoteDowned.Remove(state.UserId); - if (_reviveBurstTargetId == state.UserId) - ResetReviveBurst(); _downedAnnouncements.Remove(state.UserId); DisposeRemoteDownedCine(state.UserId); continue; @@ -653,9 +500,9 @@ private void ConsumeReviveRequests(NetNode net) if (req.TargetId != localId) continue; - // v6.0: trust the reviver-side proximity/flask check. The downed player's - // local DeadBase/homunculus object can be missing or desynced after boss/DLC - // transitions, which made valid revive holds do nothing. + if (_localDeadCine == null || !_localDeadCine.IsHomunculusNearCorpse(ReviveHomunculusBodyMaxDistancePx)) + continue; + ReviveLocalPlayer(net); return; } @@ -873,169 +720,12 @@ private bool HasAliveRemoteTeammate(NetNode net) return false; } - private void CaptureLocalDownedStats(Hero hero) - { - if (hero == null) - return; - - _localDownedStatsCaptured = true; - try { _localDownedSavedMaxLife = System.Math.Max(_localDownedSavedMaxLife, hero.maxLife); } catch { } - try { _localDownedSavedBrutalityTier = System.Math.Max(_localDownedSavedBrutalityTier, hero.brutalityTier); } catch { } - try { _localDownedSavedSurvivalTier = System.Math.Max(_localDownedSavedSurvivalTier, hero.survivalTier); } catch { } - try { _localDownedSavedTacticTier = System.Math.Max(_localDownedSavedTacticTier, hero.tacticTier); } catch { } - try { _localDownedSavedBonusLife = System.Math.Max(_localDownedSavedBonusLife, (int)System.Math.Round((double)hero.bonusLife)); } catch { } - - try - { - var data = hero._level?.game?.data; - if (data != null) - { - try { _localDownedSavedBrutalityTier = System.Math.Max(_localDownedSavedBrutalityTier, data.brutalityTier); } catch { } - try { _localDownedSavedSurvivalTier = System.Math.Max(_localDownedSavedSurvivalTier, data.survivalTier); } catch { } - try { _localDownedSavedTacticTier = System.Math.Max(_localDownedSavedTacticTier, data.tacticTier); } catch { } - } - } - catch { } - } - - private void RestoreLocalDownedStats(Hero hero) - { - if (hero == null || !_localDownedStatsCaptured) - return; - - try - { - if (_localDownedSavedMaxLife > 0 && hero.maxLife < _localDownedSavedMaxLife) - hero.maxLife = _localDownedSavedMaxLife; - } - catch { } - - try - { - if (_localDownedSavedBonusLife > 0 && hero.bonusLife < _localDownedSavedBonusLife) - hero.bonusLife = _localDownedSavedBonusLife; - } - catch { } - - try - { - if (_localDownedSavedBrutalityTier > 0 && hero.brutalityTier < _localDownedSavedBrutalityTier) - hero.brutalityTier = _localDownedSavedBrutalityTier; - if (_localDownedSavedSurvivalTier > 0 && hero.survivalTier < _localDownedSavedSurvivalTier) - hero.survivalTier = _localDownedSavedSurvivalTier; - if (_localDownedSavedTacticTier > 0 && hero.tacticTier < _localDownedSavedTacticTier) - hero.tacticTier = _localDownedSavedTacticTier; - } - catch { } - - try - { - var data = hero._level?.game?.data; - if (data != null) - { - if (_localDownedSavedBrutalityTier > 0 && data.brutalityTier < _localDownedSavedBrutalityTier) - data.brutalityTier = _localDownedSavedBrutalityTier; - if (_localDownedSavedSurvivalTier > 0 && data.survivalTier < _localDownedSavedSurvivalTier) - data.survivalTier = _localDownedSavedSurvivalTier; - if (_localDownedSavedTacticTier > 0 && data.tacticTier < _localDownedSavedTacticTier) - data.tacticTier = _localDownedSavedTacticTier; - } - } - catch { } - - try - { - if (_localDownedSavedMaxLife > 0 && hero.maxLife < _localDownedSavedMaxLife) - hero.maxLife = _localDownedSavedMaxLife; - } - catch { } - } - - private void ClearLocalDownedStatSnapshot() - { - _localDownedStatsCaptured = false; - _localDownedSavedMaxLife = 0; - _localDownedSavedBrutalityTier = 0; - _localDownedSavedSurvivalTier = 0; - _localDownedSavedTacticTier = 0; - _localDownedSavedBonusLife = 0; - } - - private static void SetLocalGameTimerPausedForRevive(bool paused) - { - // v6.3.5: do not freeze Dead Cells global game time while fake-dead. - // Using data.stopGameTime for revive pause can soft-lock the client after spike/void - // rescue because the local game keeps its cinematic/control state frozen. Keep the - // world running and only make sure older paused states are cleared. - try - { - var data = dc.pr.Game.Class.ME?.data; - if (data != null && data.stopGameTime) - data.stopGameTime = false; - } - catch { } - } - - private void ResetReviveBurst() - { - _reviveBurstTargetId = 0; - _reviveBurstUntilTicks = 0; - _nextReviveBurstSendTicks = 0; - } - - private void StartReviveRequestBurst(NetNode net, int targetId) - { - if (net == null || targetId <= 0) - return; - - var now = Stopwatch.GetTimestamp(); - _reviveBurstTargetId = targetId; - _reviveBurstUntilTicks = now + (long)(Stopwatch.Frequency * ReviveRequestBurstSeconds); - _nextReviveBurstSendTicks = 0; - SendReviveRequestBurstTick(net, now); - } - - private void ContinueReviveRequestBurst(NetNode net) - { - if (net == null || _reviveBurstTargetId <= 0) - return; - - var now = Stopwatch.GetTimestamp(); - if (_reviveBurstUntilTicks == 0 || now >= _reviveBurstUntilTicks) - { - ResetReviveBurst(); - return; - } - - if (!_remoteDowned.ContainsKey(_reviveBurstTargetId)) - { - ResetReviveBurst(); - return; - } - - SendReviveRequestBurstTick(net, now); - } - - private void SendReviveRequestBurstTick(NetNode net, long now) - { - if (_reviveBurstTargetId <= 0) - return; - if (_nextReviveBurstSendTicks != 0 && now < _nextReviveBurstSendTicks) - return; - - try { net.SendPlayerReviveRequest(_reviveBurstTargetId); } catch { } - _nextReviveBurstSendTicks = now + (long)(Stopwatch.Frequency * ReviveRequestBurstIntervalSeconds); - } - private void EnterLocalFakeDeath(Hero hero, NetNode net) { if (hero == null) return; ResetAllDownedGameOverState(); - ClearLocalDownedStatSnapshot(); - CaptureLocalDownedStats(hero); - SetLocalGameTimerPausedForRevive(true); _localFakeDead = true; _localExitPenaltyApplied = false; _localFakeDeadStartedTicks = Stopwatch.GetTimestamp(); @@ -1066,7 +756,6 @@ private void EnterLocalFakeDeath(Hero hero, NetNode net) _localDownedY = sprY; _localHeldX = _localDownedX; _localHeldY = _localDownedY; - RescueLocalDownedPositionIfUnsafe(hero, "enter_fake_death", force: true); _localDownedAnchorX = _localDownedX; _localDownedAnchorY = _localDownedY; _hasLocalDownedAnchor = true; @@ -1086,7 +775,8 @@ private void EnterLocalFakeDeath(Hero hero, NetNode net) try { hero.cancelVelocities(); } catch { } try { hero.lockControlsS(10.0); } catch { } try { hero.cancelSkillControlLock(); } catch { } - ForceParkLocalDownedHero(hero, clampToGround: true); + SnapHeroToDownedPosition(hero, _localDownedX, _localDownedY, clampToGround: false); + StartLocalDeadCine(hero); SendLocalDownedState(net, isDowned: true, force: true); } @@ -1096,8 +786,6 @@ private void MaintainLocalFakeDeath(NetNode net) if (!_localFakeDead || me == null) return; - SetLocalGameTimerPausedForRevive(true); - try { if (me.life <= 0) @@ -1107,8 +795,8 @@ private void MaintainLocalFakeDeath(NetNode net) { } - // v6.4.6: no local death cinematic/corpse while downed. The hidden hero is parked - // at one safe anchor; this prevents the vanilla death body from falling through the map. + if (_localDeadCine == null) + StartLocalDeadCine(me); if (!HasAliveRemoteTeammate(net)) { @@ -1132,13 +820,13 @@ private void MaintainLocalFakeDeath(NetNode net) try { me.cancelSkillControlLock(); } catch { } try { me._targetable = false; } catch { } - // v6.4.7: once fake-dead, keep one authoritative anchor until revive/reset. - // Do not let any corpse/hero physics update become the new revive position. - if (!MaintainDownedSafeRescueLock()) - RescueLocalDownedPositionIfUnsafe(me, "maintain_fake_death", force: false); - ReassertLocalDownedAnchor(me); + var cine = _localDeadCine; + if (cine != null && cine.TryGetCorpsePixelPosition(out var corpseX, out var corpseY)) + { + TryUpdateDownedPositionFromCorpse(corpseX, corpseY); + } - ForceParkLocalDownedHero(me, clampToGround: true); + SnapHeroToDownedPosition(me, _localHeldX, _localHeldY, clampToGround: false); SendLocalDownedState(net, isDowned: true, force: false); } @@ -1159,163 +847,6 @@ private void MaintainPostRevivePositionLock() SnapHeroToDownedPosition(me, _postReviveLockX, _postReviveLockY); } - private void ForceParkLocalDownedHero(Hero hero, bool clampToGround = true) - { - if (hero == null) - return; - - CaptureLocalDownedHeroRuntimeState(hero); - - try - { - if (hero.life <= 0) - hero.life = 1; - } - catch { } - - try { hero._targetable = false; } catch { } - try { hero.visible = false; } catch { } - TrySetHeroHeadVisible(hero, false); - - try { hero.dx = 0; } catch { } - try { hero.dy = 0; } catch { } - try { hero.bdx = 0; } catch { } - try { hero.bdy = 0; } catch { } - try { hero.hasGravity = false; } catch { } - try { hero.cancelVelocities(); } catch { } - try { hero.cancelSkillControlLock(); } catch { } - try { hero.lockControlsS(0.35); } catch { } - - var x = _localHeldX; - var y = _localHeldY - DownedParkedHeroYOffsetPx; - try { hero.setPosPixel(x, y); } catch { } - try { ForceSetHeroCaseFromPixel(hero, x, y); } catch { } - if (clampToGround) - SnapHeroToDownedPosition(hero, x, y, clampToGround: true); - - try { hero.cancelVelocities(); } catch { } - try { hero.dx = 0; } catch { } - try { hero.dy = 0; } catch { } - try { hero.bdx = 0; } catch { } - try { hero.bdy = 0; } catch { } - } - - private void CaptureLocalDownedHeroRuntimeState(Hero hero) - { - if (hero == null) - return; - - if (!_localDownedHeroGravityWasCaptured) - { - try { _localDownedHeroHadGravity = hero.hasGravity; } - catch { _localDownedHeroHadGravity = true; } - _localDownedHeroGravityWasCaptured = true; - } - - if (!_localDownedHeroVisibilityWasCaptured) - { - try { _localDownedHeroWasVisible = hero.visible; } - catch { _localDownedHeroWasVisible = true; } - _localDownedHeroVisibilityWasCaptured = true; - } - } - - private void RestoreLocalDownedHeroRuntimeState(Hero? hero) - { - if (hero != null) - { - try { hero.hasGravity = _localDownedHeroGravityWasCaptured ? _localDownedHeroHadGravity : true; } catch { } - try { hero.visible = _localDownedHeroVisibilityWasCaptured ? _localDownedHeroWasVisible : true; } catch { } - TrySetHeroHeadVisible(hero, true); - try { hero._targetable = true; } catch { } - } - - _localDownedHeroGravityWasCaptured = false; - _localDownedHeroHadGravity = true; - _localDownedHeroVisibilityWasCaptured = false; - _localDownedHeroWasVisible = true; - } - - private static void TrySetHeroHeadVisible(Hero hero, bool visible) - { - if (hero == null) - return; - - try - { - var head = hero.heroHead; - if (head == null) - return; - - try { head.customHeadSpr?.set_visible(visible); } catch { } - try { head.customBackSpr?.set_visible(visible); } catch { } - try { head.headNormalSb?.set_visible(visible); } catch { } - try { head.headAddSb?.set_visible(visible); } catch { } - try { head.eye?.set_visible(visible); } catch { } - // Leave headBlack untouched; sprite visibility is enough and avoids changing - // custom-head state after revive. - } - catch - { - } - } - - private void ReassertLocalDownedAnchor(Hero hero) - { - if (hero == null || !_localFakeDead) - return; - - if (!_hasLocalDownedAnchor) - { - _localDownedAnchorX = _localHeldX; - _localDownedAnchorY = _localHeldY; - _hasLocalDownedAnchor = double.IsFinite(_localDownedAnchorX) && double.IsFinite(_localDownedAnchorY); - } - - if (!_hasLocalDownedAnchor) - return; - - _localDownedX = _localDownedAnchorX; - _localDownedY = _localDownedAnchorY; - _localHeldX = _localDownedAnchorX; - _localHeldY = _localDownedAnchorY; - _downedSafeRescueLockX = _localDownedAnchorX; - _downedSafeRescueLockY = _localDownedAnchorY; - if (_downedSafeRescueLockUntilTicks == 0) - _downedSafeRescueLockUntilTicks = Stopwatch.GetTimestamp() + (long)(Stopwatch.Frequency * DownedPermanentAnchorRefreshSeconds); - - try { hero.cancelVelocities(); } catch { } - try { hero.dx = 0; } catch { } - try { hero.dy = 0; } catch { } - try { hero.bdx = 0; } catch { } - try { hero.bdy = 0; } catch { } - try { hero.hasGravity = false; } catch { } - try { hero._targetable = false; } catch { } - try { hero.visible = false; } catch { } - TrySetHeroHeadVisible(hero, false); - try { hero.setPosPixel(_localDownedAnchorX, _localDownedAnchorY - DownedParkedHeroYOffsetPx); } catch { } - try { ForceSetHeroCaseFromPixel(hero, _localDownedAnchorX, _localDownedAnchorY - DownedParkedHeroYOffsetPx); } catch { } - } - - private static void ForceSetHeroCaseFromPixel(Hero hero, double x, double y) - { - if (hero == null || !double.IsFinite(x) || !double.IsFinite(y)) - return; - - try - { - var cx = (int)System.Math.Floor(x / 24.0); - var cy = (int)System.Math.Floor(y / 24.0); - var xr = (x / 24.0) - cx; - var yr = (y / 24.0) - cy; - if (double.IsFinite(xr) && double.IsFinite(yr)) - hero.setPosCase(cx, cy, xr, yr); - } - catch - { - } - } - private static void SnapHeroToDownedPosition(Hero hero, double x, double y, bool clampToGround = true) { if (hero == null) @@ -1353,9 +884,6 @@ private void ReviveLocalPlayer(NetNode net) ResetAllDownedGameOverState(); var hero = me; - RestoreLocalDownedStats(hero); - SetLocalGameTimerPausedForRevive(false); - RestoreLocalDownedHeroRuntimeState(hero); _localFakeDead = false; _localExitPenaltyApplied = false; _localFakeDeadStartedTicks = 0; @@ -1384,13 +912,10 @@ private void ReviveLocalPlayer(NetNode net) _localHeldX = _postReviveLockX; _localHeldY = _postReviveLockY; - if (IsHeroRuntimeSafeForControlUnlock(hero)) - { - try { hero.cancelVelocities(); } catch { } - try { hero.cancelSkillControlLock(); } catch { } - try { hero.unlockControls(); } catch { } - try { hero._targetable = true; } catch { } - } + try { hero.cancelVelocities(); } catch { } + try { hero.cancelSkillControlLock(); } catch { } + try { hero.unlockControls(); } catch { } + try { hero._targetable = true; } catch { } try { @@ -1408,42 +933,106 @@ private void ReviveLocalPlayer(NetNode net) try { hero.fullHeal(); } catch { } } - try { net.SendHP(hero.life, hero.maxLife, hero.life, hero.bonusLife, hero.radius); } catch { } SendLocalDownedState(net, isDowned: false, force: true); - ClearLocalDownedStatSnapshot(); } private void ApplyLocalDownedExitPenaltyIfNeededCore() { - // v6.3: no stat/HP penalty while downed. The old penalty removed - // Brutality/Survival/TacticUp items during auto-follow/exit failsafe, - // which made revived players drop back to base HP after being carried - // through doors or boss exits. - _localExitPenaltyApplied = true; - } - - private void ProcessReviveHold(NetNode net) - { - if (me == null || _remoteDowned.Count == 0) - { - ResetReviveHold(); - ResetReviveBurst(); - ClearReviveHints(); + if (!_localFakeDead || _localExitPenaltyApplied || me == null) return; - } - var isHoldPressed = GameMenu.IsReviveHoldInputDown(me); + _localExitPenaltyApplied = true; + var hero = me; - if (!isHoldPressed) - { - ResetReviveHold(); - return; - } + try { hero.spdComboKills = 0; } catch { } + try { hero.perfectKillsCount = 0; } catch { } + try { hero.goldCombo = 0; } catch { } - var nearest = FindNearestReviveTarget(); - if (nearest == null) + try { - ResetReviveHold(); + var data = hero._level?.game?.data; + if (data != null) + { + data.killCount = 0; + data.corruptedHealingKillCount = 0; + } + } + catch + { + } + + try + { + bool noStats = true; + hero.tryToSubstractMoney(int.MaxValue, Ref.From(ref noStats)); + } + catch + { + try + { + var data = hero._level?.game?.data; + if (data != null) + data.money = 0; + hero.hudSetMoney(0); + } + catch + { + } + } + + try + { + var inventory = hero.inventory; + if (inventory != null) + { + inventory.removeAll("BrutalityUp".AsHaxeString()); + inventory.removeAll("SurvivalUp".AsHaxeString()); + inventory.removeAll("TacticUp".AsHaxeString()); + } + } + catch + { + } + + try { hero.computeTiers(); } catch { } + + try + { + var data = hero._level?.game?.data; + if (data != null) + { + data.money = 0; + data.brutalityTier = hero.brutalityTier; + data.survivalTier = hero.survivalTier; + data.tacticTier = hero.tacticTier; + } + } + catch + { + } + } + + private void ProcessReviveHold(NetNode net) + { + if (me == null || _remoteDowned.Count == 0) + { + ResetReviveHold(); + ClearReviveHints(); + return; + } + + var isHoldPressed = GameMenu.IsReviveHoldInputDown(me); + + if (!isHoldPressed) + { + ResetReviveHold(); + return; + } + + var nearest = FindNearestReviveTarget(); + if (nearest == null) + { + ResetReviveHold(); return; } @@ -1473,9 +1062,8 @@ private void ProcessReviveHold(NetNode net) return; } - StartReviveRequestBurst(net, nearest.UserId); + net.SendPlayerReviveRequest(nearest.UserId); _nextReviveAttemptTicks = now + (long)(Stopwatch.Frequency * ReviveAttemptCooldownSeconds); - try { MultiplayerUI.PushSystemMessage(FormatLocalized("Reviving player..."), 2.0, 0.3); } catch { } ResetReviveHold(); ClearReviveHints(); } @@ -1527,8 +1115,15 @@ private void UpdateReviveHintsByProximity() if (distSq > ReviveUseDistancePx * ReviveUseDistancePx) continue; - // v6.0: do not reject revive because the downed player's homunculus/head - // position is stale. The body position is the authoritative revive target. + if (state.HasHeadPosition) + { + var hdx = state.HeadX - state.X; + var hdy = state.HeadY - state.Y; + var headBodyDistSq = hdx * hdx + hdy * hdy; + var maxHeadBodySq = ReviveHomunculusBodyMaxDistancePx * ReviveHomunculusBodyMaxDistancePx * 16.0; + if (headBodyDistSq > maxHeadBodySq) + continue; + } if (distSq < bestDistSq) { @@ -1632,9 +1227,21 @@ private void SendLocalDownedState(NetNode net, bool isDowned, bool force) double? headX = null; double? headY = null; string? headAnim = null; + if (isDowned && _localDeadCine != null && _localDeadCine.TryGetHomunculusPixelPosition(out var hx, out var hy)) + { + headX = hx; + headY = hy; + _localDeadCine.TryGetHomunculusAnim(out headAnim); + } var now = Stopwatch.GetTimestamp(); var resend = (long)(Stopwatch.Frequency * DownedStateResendSeconds); + if (isDowned && headX.HasValue && headY.HasValue) + { + var fastResend = (long)(Stopwatch.Frequency * DownedHeadStateResendSeconds); + if (fastResend > 0 && (resend <= 0 || fastResend < resend)) + resend = fastResend; + } if (!force && _nextDownedStateSendTicks != 0 && now < _nextDownedStateSendTicks) return; @@ -1668,10 +1275,20 @@ private string GetCurrentLevelId() private void StartLocalDeadCine(Hero hero) { - // v6.4.6: intentionally disabled. Even with HeroDeadCorpse creation disabled in - // DeadBase, entering the vanilla ghost-death cinematic can still leave a physical - // body/target anchor that falls through floors and can trigger duplicated drops. - _localDeadCine = null; + if (hero == null) + return; + + if (_localDeadCine != null) + return; + + try + { + _localDeadCine = new DeadBase(hero, ModEntry.GetPrimaryClient()); + } + catch + { + _localDeadCine = null; + } } private void StopLocalDeadCine() @@ -1693,7 +1310,6 @@ private void ResetFakeDeathState( { ResetAllDownedGameOverState(); var wasFakeDead = _localFakeDead; - RestoreLocalDownedHeroRuntimeState(me); _localFakeDead = false; _localExitPenaltyApplied = false; _localFakeDeadStartedTicks = 0; @@ -1708,16 +1324,9 @@ private void ResetFakeDeathState( _postReviveLockUntilTicks = 0; _postReviveLockX = 0; _postReviveLockY = 0; - _downedSafeRescueLockUntilTicks = 0; - _downedSafeRescueLockX = 0; - _downedSafeRescueLockY = 0; - SetLocalGameTimerPausedForRevive(false); - ResetReviveBurst(); - ClearLocalDownedStatSnapshot(); _hasLocalDownedAnchor = false; _localDownedAnchorX = 0; _localDownedAnchorY = 0; - _nextDownedSafeRescueCheckTicks = 0; ResetReviveHold(); ClearReviveHints(); if (clearRemoteDownedTracking) @@ -1734,11 +1343,11 @@ private void ResetFakeDeathState( } } - if (unlockLocalHero && IsHeroRuntimeSafeForControlUnlock(me)) + if (unlockLocalHero && me != null) { - try { me!.cancelSkillControlLock(); } catch { } - try { me!.unlockControls(); } catch { } - try { me!._targetable = true; } catch { } + try { me.cancelSkillControlLock(); } catch { } + try { me.unlockControls(); } catch { } + try { me._targetable = true; } catch { } } if (sendNetworkUpState && wasFakeDead && _net != null && _netRole != NetRole.None) @@ -1761,6 +1370,9 @@ private void HandleAllPlayersDowned(NetNode net) { } + if (_localDeadCine == null) + StartLocalDeadCine(me); + var now = Stopwatch.GetTimestamp(); if (!_allDownedGameOverShown) { @@ -1774,8 +1386,12 @@ private void HandleAllPlayersDowned(NetNode net) try { me.cancelSkillControlLock(); } catch { } try { me._targetable = false; } catch { } - RescueLocalDownedPositionIfUnsafe(me, "all_downed_fake_death", force: false); - ForceParkLocalDownedHero(me, clampToGround: true); + var cine = _localDeadCine; + if (cine != null && cine.TryGetCorpsePixelPosition(out var corpseX, out var corpseY)) + { + TryUpdateDownedPositionFromCorpse(corpseX, corpseY); + } + SnapHeroToDownedPosition(me, _localHeldX, _localHeldY, clampToGround: false); SendLocalDownedState(net, isDowned: true, force: false); if (_allDownedRestartQueued || _netRole != NetRole.Host) @@ -1784,13 +1400,8 @@ private void HandleAllPlayersDowned(NetNode net) if (_allDownedRestartAtTicks != 0 && now < _allDownedRestartAtTicks) return; - // v5.9: do not auto-call launchGame/newGame while both players are in the - // fake-death/game-over state. The current DCCM/Hashlink build can hit an - // AccessViolation in User.newGame/GC during that transition. Stop the session - // cleanly and let the host start a fresh multiplayer run from the menu instead. _allDownedRestartQueued = true; - try { MultiplayerUI.PushSystemMessage("Both players are down. Multiplayer stopped safely; start a new run from the menu."); } catch { } - try { StopNetworkFromMenu(); } catch { } + GameMenu.QueueHostRestartFromDeath("all_players_downed"); } private void ShowAllDownedGameOverLogo() @@ -1826,28 +1437,6 @@ private void ShowAllDownedGameOverLogo() } } - private static bool IsHeroRuntimeSafeForControlUnlock(Hero? hero) - { - if (hero == null) - return false; - - try - { - if (hero.destroyed || hero._level == null || hero._level.destroyed) - return false; - if (hero._level.game == null || hero._level.game.destroyed) - return false; - if (dc.pr.Game.Class.ME == null) - return false; - } - catch - { - return false; - } - - return true; - } - private static string Localize(string message) { return GetText.Instance.GetString(message); @@ -1873,591 +1462,30 @@ private void ResetAllDownedGameOverState() _allDownedRestartAtTicks = 0; } - private void TrackLocalReviveSafePosition() - { - var hero = me; - if (hero == null || _localFakeDead) - return; - - if (!TryGetSafeReviveAnchor(hero, out var x, out var y)) - return; - - _hasLocalReviveSafePosition = true; - _localReviveSafeX = x; - _localReviveSafeY = y; - _localReviveSafeLevelId = GetCurrentLevelId(); - _localReviveSafeTicks = Stopwatch.GetTimestamp(); - RecordLocalReviveSafeHistory(x, y, _localReviveSafeLevelId, _localReviveSafeTicks); - } - - private bool RescueLocalDownedPositionIfUnsafe(Hero hero, string reason, bool force, double? proposedX = null, double? proposedY = null) - { - if (hero == null) - return false; - - var now = Stopwatch.GetTimestamp(); - if (!force && _nextDownedSafeRescueCheckTicks != 0 && now < _nextDownedSafeRescueCheckTicks) - return false; - _nextDownedSafeRescueCheckTicks = now + (long)(Stopwatch.Frequency * DownedUnsafeRescueCheckIntervalSeconds); - - var checkX = proposedX ?? _localHeldX; - var checkY = proposedY ?? _localHeldY; - if (!ShouldUseSavedSafeDownedPosition(hero, checkX, checkY, force)) - return false; - - if (!TryGetBestDownedRescuePoint(hero, out var safeX, out var safeY, out var source)) - return false; - - _localDownedX = safeX; - _localDownedY = safeY; - _localHeldX = safeX; - _localHeldY = safeY; - _localDownedAnchorX = safeX; - _localDownedAnchorY = safeY; - _hasLocalDownedAnchor = true; - _downedSafeRescueLockX = safeX; - _downedSafeRescueLockY = safeY; - _downedSafeRescueLockUntilTicks = Stopwatch.GetTimestamp() + (long)(Stopwatch.Frequency * DownedSafeRescueLockSeconds); - _nextDownedStateSendTicks = 0; - - StabilizeLocalDownedAfterSafeRescue(hero, reason); - - try - { - Logger.Information("[NetMod][Revive] Moved downed body to safe revive point reason={Reason} source={Source} x={X:0.0} y={Y:0.0}", reason, source, safeX, safeY); - } - catch - { - } - - return true; - } - - private void StabilizeLocalDownedAfterSafeRescue(Hero hero, string reason) - { - if (hero == null) - return; - - // The safe-position rescue can happen while the player is dying in spikes/void and - // while vanilla death/hazard states still hold velocities or cinematic locks. Clear - // those transient states without reviving the player; they remain fake-dead/downed. - try { hero.cancelVelocities(); } catch { } - try { hero.cancelSkillControlLock(); } catch { } - try { hero.lockControlsS(0.25); } catch { } - try { hero._targetable = false; } catch { } - try - { - var data = dc.pr.Game.Class.ME?.data; - if (data != null && data.stopGameTime) - data.stopGameTime = false; - } - catch { } - - ForceParkLocalDownedHero(hero, clampToGround: true); - } - - private bool MaintainDownedSafeRescueLock() - { - if (!_localFakeDead || me == null || _downedSafeRescueLockUntilTicks == 0) - return false; - - var now = Stopwatch.GetTimestamp(); - // v6.4.7: do not expire the safe anchor while the player is downed. The previous - // 6 second expiry let vanilla falling/corpse physics become authoritative again, - // which caused the marker to sink through floors and trigger duplicate drops. - if (now >= _downedSafeRescueLockUntilTicks) - _downedSafeRescueLockUntilTicks = now + (long)(Stopwatch.Frequency * DownedPermanentAnchorRefreshSeconds); - - _localDownedX = _downedSafeRescueLockX; - _localDownedY = _downedSafeRescueLockY; - _localHeldX = _downedSafeRescueLockX; - _localHeldY = _downedSafeRescueLockY; - _localDownedAnchorX = _downedSafeRescueLockX; - _localDownedAnchorY = _downedSafeRescueLockY; - _hasLocalDownedAnchor = true; - - try { me.cancelVelocities(); } catch { } - try { me.lockControlsS(0.25); } catch { } - try { me._targetable = false; } catch { } - ForceParkLocalDownedHero(me, clampToGround: true); - return true; - } - - private void TrySnapLocalDeadCineToHeldPosition() - { - // v6.4.6: local death cinematic is disabled. The hidden hero is parked directly. - } - - private bool ShouldUseSavedSafeDownedPosition(Hero hero, double x, double y, bool force) - { - if (hero == null) - return false; - if (!double.IsFinite(x) || !double.IsFinite(y)) - return true; - if (force && !IsPixelPositionReviveAccessible(hero, x, y)) - return true; - - if (_hasLocalReviveSafePosition) - { - if (IsLocalReviveSafePositionFresh() && IsLocalReviveSafePositionForCurrentLevel()) - { - if (y > _localReviveSafeY + DownedVoidRescueDropPx) - return true; - } - } - - if (!IsPixelPositionReviveAccessible(hero, x, y)) - return true; - - return false; - } - - private bool TryGetBestDownedRescuePoint(Hero hero, out double x, out double y, out string source) - { - // v6.3.6: Prefer the last actually visited teleporter/fast-travel point. - // Trap floors such as spikes can look like valid ground, so the most recent - // generic safe-ground sample is not always safe enough for revive placement. - if (TryGetLastVisitedTeleporterRevivePoint(hero, out x, out y)) - { - source = "last_teleporter"; - return true; - } - - // If there is no teleporter yet, use an older safe-ground history point instead - // of the latest frame. This avoids saving the exact spike/trap tile during the - // death frame and moving the downed body back into the hazard. - if (TryGetAgedLocalReviveSafePosition(hero, out x, out y)) - { - source = "aged_safe_position"; - return true; - } - - if (TryGetNearestRemotePlayerPixel(hero, out x, out y)) - { - y -= LocalReviveBodyYOffsetPx; - source = "teammate_position"; - return true; - } - - if (TryGetSafeReviveAnchor(hero, out x, out y)) - { - source = "current_ground"; - return true; - } - - source = string.Empty; - x = 0; - y = 0; - return false; - } - - public void RememberLocalReviveTeleporterPosition(double x, double y) - { - if (!double.IsFinite(x) || !double.IsFinite(y)) - return; - - var hero = me; - if (hero == null) - return; - - try - { - if (hero._level == null || hero._level.destroyed) - return; - } - catch - { - return; - } - - // Put the revive body slightly above the teleporter platform so it does not sink - // into the teleporter entity or nearby hazard floor. - _hasLocalReviveTeleporterPosition = true; - _localReviveTeleporterX = x; - _localReviveTeleporterY = y - LocalReviveBodyYOffsetPx; - _localReviveTeleporterLevelId = GetCurrentLevelId(); - _localReviveTeleporterTicks = Stopwatch.GetTimestamp(); - RecordLocalReviveSafeHistory(_localReviveTeleporterX, _localReviveTeleporterY, _localReviveTeleporterLevelId, _localReviveTeleporterTicks); - } - - private void RecordLocalReviveSafeHistory(double x, double y, string levelId, long ticks) - { - if (!double.IsFinite(x) || !double.IsFinite(y) || ticks <= 0) - return; - - if (_localReviveSafeHistory.Count > 0) - { - var last = _localReviveSafeHistory[_localReviveSafeHistory.Count - 1]; - var dx = last.X - x; - var dy = last.Y - y; - if (dx * dx + dy * dy < 48.0 * 48.0 && - ticks - last.Ticks < (long)(Stopwatch.Frequency * 0.5)) - { - return; - } - } - - _localReviveSafeHistory.Add(new ReviveSafeAnchor(x, y, levelId, ticks)); - if (_localReviveSafeHistory.Count > ReviveSafeHistoryMaxEntries) - _localReviveSafeHistory.RemoveRange(0, _localReviveSafeHistory.Count - ReviveSafeHistoryMaxEntries); - } - - private bool TryGetLastVisitedTeleporterRevivePoint(Hero hero, out double x, out double y) - { - x = 0; - y = 0; - if (hero == null || !_hasLocalReviveTeleporterPosition || _localReviveTeleporterTicks == 0) - return false; - - var now = Stopwatch.GetTimestamp(); - if (now - _localReviveTeleporterTicks > (long)(Stopwatch.Frequency * ReviveTeleporterMaxAgeSeconds)) - return false; - if (!IsAnchorLevelCurrent(_localReviveTeleporterLevelId)) - return false; - if (!IsPixelPositionReviveAccessible(hero, _localReviveTeleporterX, _localReviveTeleporterY)) - return false; - - x = _localReviveTeleporterX; - y = _localReviveTeleporterY; - return true; - } - - private bool TryGetAgedLocalReviveSafePosition(Hero hero, out double x, out double y) - { - x = 0; - y = 0; - if (hero == null) - return false; - - var now = Stopwatch.GetTimestamp(); - var minAge = (long)(Stopwatch.Frequency * ReviveSafeHistoryMinAgeSeconds); - var maxAge = (long)(Stopwatch.Frequency * ReviveSafeHistoryMaxAgeSeconds); - - for (var i = _localReviveSafeHistory.Count - 1; i >= 0; i--) - { - var anchor = _localReviveSafeHistory[i]; - var age = now - anchor.Ticks; - if (age < minAge) - continue; - if (age > maxAge) - break; - if (!IsAnchorLevelCurrent(anchor.LevelId)) - continue; - if (!IsPixelPositionReviveAccessible(hero, anchor.X, anchor.Y)) - continue; - - x = anchor.X; - y = anchor.Y; - return true; - } - - // Backwards-compatible fallback for old sessions that have no history yet. - // Only use it if it is not from the immediate death frame. - if (_hasLocalReviveSafePosition && IsLocalReviveSafePositionFresh() && IsLocalReviveSafePositionForCurrentLevel()) - { - var age = now - _localReviveSafeTicks; - if (age >= minAge && IsPixelPositionReviveAccessible(hero, _localReviveSafeX, _localReviveSafeY)) - { - x = _localReviveSafeX; - y = _localReviveSafeY; - return true; - } - } - - return false; - } - - private bool IsAnchorLevelCurrent(string levelId) - { - var current = GetCurrentLevelId(); - if (string.IsNullOrWhiteSpace(levelId) || string.IsNullOrWhiteSpace(current)) - return true; - return string.Equals(levelId, current, StringComparison.Ordinal); - } - - private bool IsLocalReviveSafePositionFresh() - { - if (!_hasLocalReviveSafePosition || _localReviveSafeTicks == 0) - return false; - var ageTicks = Stopwatch.GetTimestamp() - _localReviveSafeTicks; - return ageTicks >= 0 && ageTicks <= (long)(Stopwatch.Frequency * ReviveSafePositionMaxAgeSeconds); - } - - private bool IsLocalReviveSafePositionForCurrentLevel() - { - var current = GetCurrentLevelId(); - if (string.IsNullOrWhiteSpace(_localReviveSafeLevelId) || string.IsNullOrWhiteSpace(current)) - return true; - return string.Equals(_localReviveSafeLevelId, current, StringComparison.Ordinal); - } - - private bool TryGetSafeReviveAnchor(Hero hero, out double x, out double y) - { - x = 0; - y = 0; - if (hero == null) - return false; - - try - { - if (hero.destroyed || hero._level == null || hero._level.destroyed || hero.spr == null) - return false; - if (!double.IsFinite(hero.spr.x) || !double.IsFinite(hero.spr.y)) - return false; - if (!IsPixelPositionReviveAccessible(hero, hero.spr.x, hero.spr.y)) - return false; - - x = hero.spr.x; - y = hero.spr.y; - return true; - } - catch - { - return false; - } - } - - private bool IsPixelPositionReviveAccessible(Hero hero, double pixelX, double pixelY) - { - if (hero == null || !double.IsFinite(pixelX) || !double.IsFinite(pixelY)) - return false; - - try - { - var level = hero._level; - var map = level?.map; - if (level == null || level.destroyed || map == null) - return false; - - var tx = pixelX / 24.0; - var ty = pixelY / 24.0; - var cx = (int)System.Math.Floor(tx); - var cy = (int)System.Math.Floor(ty); - var xr = tx - cx; - var yr = ty - cy; - if (!double.IsFinite(xr) || !double.IsFinite(yr)) - return false; - - var probeXr = xr; - var probeYr = yr; - var groundYr = map.getGroundYr(cx, cy, Ref.From(ref probeXr), Ref.From(ref probeYr)); - if (!double.IsFinite(groundYr)) - return false; - - // getGroundYr returns the closest floor fractional Y for this cell. If the body is - // many cells below the found ground, it is probably falling into void/out-of-bounds. - if (yr > groundYr + 2.25) - return false; - - // Spike/trap floors can still look like valid ground to getGroundYr. Reject obvious - // nearby hazard entities so the downed body does not get anchored back into the trap - // that killed the player. - if (IsPixelNearLikelyReviveHazard(hero, pixelX, pixelY)) - return false; - - return true; - } - catch - { - return false; - } - } - - private bool IsPixelNearLikelyReviveHazard(Hero hero, double pixelX, double pixelY) - { - if (hero == null || !double.IsFinite(pixelX) || !double.IsFinite(pixelY)) - return true; - - try - { - var level = hero._level; - var elements = level?.listCurrentQuadElements; - if (level == null || level.destroyed || elements == null) - return false; - - const double hazardRadiusPx = 72.0; - const double hazardRadiusSq = hazardRadiusPx * hazardRadiusPx; - - for (var i = 0; i < elements.length; i++) - { - object? raw; - try { raw = elements.getDyn(i); } catch { continue; } - if (raw is not dc.Entity entity) - continue; - - if (!IsLikelyReviveHazardEntity(entity)) - continue; - - if (!TryGetEntityPixelPosition(entity, out var ex, out var ey)) - continue; - - var dx = ex - pixelX; - var dy = ey - pixelY; - if (dx * dx + dy * dy <= hazardRadiusSq) - return true; - } - } - catch - { - } - - return false; - } - - private static bool IsLikelyReviveHazardEntity(dc.Entity entity) - { - if (entity == null) - return false; - - string name; - try { name = entity.GetType().Name ?? string.Empty; } catch { name = string.Empty; } - if (string.IsNullOrWhiteSpace(name)) - return false; - - return name.IndexOf("Spike", StringComparison.OrdinalIgnoreCase) >= 0 || - name.IndexOf("Trap", StringComparison.OrdinalIgnoreCase) >= 0 || - name.IndexOf("Saw", StringComparison.OrdinalIgnoreCase) >= 0 || - name.IndexOf("Blade", StringComparison.OrdinalIgnoreCase) >= 0 || - name.IndexOf("Lava", StringComparison.OrdinalIgnoreCase) >= 0 || - name.IndexOf("Acid", StringComparison.OrdinalIgnoreCase) >= 0 || - name.IndexOf("Poison", StringComparison.OrdinalIgnoreCase) >= 0 || - name.IndexOf("Crusher", StringComparison.OrdinalIgnoreCase) >= 0 || - name.IndexOf("Thorn", StringComparison.OrdinalIgnoreCase) >= 0; - } - - private static bool TryGetEntityPixelPosition(dc.Entity entity, out double x, out double y) - { - x = 0; - y = 0; - if (entity == null) - return false; - - try - { - var spr = entity.spr; - if (spr != null && double.IsFinite(spr.x) && double.IsFinite(spr.y)) - { - x = spr.x; - y = spr.y; - return true; - } - } - catch - { - } - - try - { - x = (entity.cx + entity.xr) * 24.0; - y = (entity.cy + entity.yr) * 24.0; - return double.IsFinite(x) && double.IsFinite(y); - } - catch - { - return false; - } - } - - private bool TryGetNearestRemotePlayerPixel(Hero hero, out double x, out double y) + private bool TryUpdateDownedPositionFromCorpse(double corpseX, double corpseY) { - x = 0; - y = 0; - if (hero == null) + if (!double.IsFinite(corpseX) || !double.IsFinite(corpseY)) return false; - var bestDist = double.MaxValue; - var found = false; - var localX = hero.spr?.x ?? 0; - var localY = hero.spr?.y ?? 0; - var localLevelId = GetCurrentLevelId(); - - for (int i = 0; i < clients.Length; i++) + if (!_hasLocalDownedAnchor) { - var client = clients[i]; - if (client == null) - continue; - - try - { - if (client.destroyed || client.spr == null) - continue; - } - catch - { - continue; - } - - var remoteId = clientIds[i]; - if (remoteId > 0 && _remoteDowned.ContainsKey(remoteId)) - continue; - - double rx; - double ry; - try - { - rx = client.spr.x; - ry = client.spr.y; - } - catch - { - continue; - } - - if (!double.IsFinite(rx) || !double.IsFinite(ry)) - continue; - if (!string.IsNullOrWhiteSpace(localLevelId) && client._level?.map?.id != null) - { - var remoteLevel = client._level.map.id.ToString(); - if (!string.IsNullOrWhiteSpace(remoteLevel) && !string.Equals(remoteLevel, localLevelId, StringComparison.Ordinal)) - continue; - } - - var dx = rx - localX; - var dy = ry - localY; - var dist = dx * dx + dy * dy; - if (dist >= bestDist) - continue; - - bestDist = dist; - x = rx; - y = ry; - found = true; + _localDownedAnchorX = _localDownedX; + _localDownedAnchorY = _localDownedY; + _hasLocalDownedAnchor = true; } - return found; - } - - private bool TryUpdateDownedPositionFromCorpse(double corpseX, double corpseY) - { - // v6.4.7: never let a corpse/body position update become authoritative for revive. - // Dead Cells can still produce or move a death body/anchor even when the mod tries to - // hide/disable it. Accepting those coordinates caused the downed marker to fall through - // floors, bounce between spikes and teleporters, and repeatedly trigger cell/blueprint - // drops. The authoritative position is now only _localHeldX/_localHeldY. - if (!_localFakeDead) + var dx = corpseX - _localDownedAnchorX; + var dy = corpseY - _localDownedAnchorY; + var distSq = dx * dx + dy * dy; + if (distSq > DownedCorpseMaxDriftSq) return false; - if (me != null) - { - if (_hasLocalDownedAnchor) - { - _localDownedX = _localDownedAnchorX; - _localDownedY = _localDownedAnchorY; - _localHeldX = _localDownedAnchorX; - _localHeldY = _localDownedAnchorY; - _downedSafeRescueLockX = _localDownedAnchorX; - _downedSafeRescueLockY = _localDownedAnchorY; - _downedSafeRescueLockUntilTicks = Stopwatch.GetTimestamp() + (long)(Stopwatch.Frequency * DownedPermanentAnchorRefreshSeconds); - } - else - { - RescueLocalDownedPositionIfUnsafe(me, "corpse_position_ignored", force: true); - } - - ReassertLocalDownedAnchor(me); - _nextDownedStateSendTicks = 0; - } - + _localDownedX = corpseX; + _localDownedY = corpseY; + _localHeldX = _localDownedX; + _localHeldY = _localDownedY; + _localDownedAnchorX = corpseX; + _localDownedAnchorY = corpseY; return true; } 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 84c356f..a8f3c1f 100644 --- a/Ghost/KingDiveAttackHooksBridge.cs +++ b/Ghost/KingDiveAttackHooksBridge.cs @@ -203,7 +203,7 @@ private static void WithSanitizedQuadElements(Level level, Action action) try { original = level.listCurrentQuadElements; - if (!TrySanitizeQuadElements(level, original, out sanitized)) + if (!TrySanitizeQuadElements(original, out sanitized)) { action(); return; @@ -229,7 +229,7 @@ private static void WithSanitizedQuadElements(Level level, Action action) } } - private static bool TrySanitizeQuadElements(Level level, ArrayObj? source, out ArrayObj? sanitized) + private static bool TrySanitizeQuadElements(ArrayObj? source, out ArrayObj? sanitized) { sanitized = null; if (source == null) @@ -252,7 +252,7 @@ private static bool TrySanitizeQuadElements(Level level, ArrayObj? source, out A if (entry is dc.Entity entity) { - if (IsEntityQuadHitSafe(level, entity)) + if (IsEntityQuadHitSafe(entity)) { arr.array.pushDyn(entity); continue; @@ -273,71 +273,13 @@ private static bool TrySanitizeQuadElements(Level level, ArrayObj? source, out A } /// - /// Skip entities whose position, level, life or sprite metadata would crash vanilla hit resolution - /// (applyAttackResult). DLC rooms can leave stale/half-destroyed mobs in - /// listCurrentQuadElements; vanilla dive landing later dereferences fields such as - /// cx on those entries and raises Hashlink Null access .cx. + /// Skip entities whose sprite/group metadata would crash vanilla hit resolution (applyAttackResult). /// - private static bool IsEntityQuadHitSafe(Level level, dc.Entity entity) + private static bool IsEntityQuadHitSafe(dc.Entity entity) { - if (entity == null || level == null) + if (entity == null) return false; - try - { - if (entity.destroyed) - return false; - } - catch - { - return false; - } - - try - { - if (entity._level == null) - return false; - } - catch - { - return false; - } - - try - { - _ = entity.cx; - _ = entity.cy; - _ = entity.xr; - _ = entity.yr; - } - catch - { - return false; - } - - try - { - if (!entity._targetable) - return false; - } - catch - { - return false; - } - - if (entity is Mob mob) - { - try - { - if (mob.life <= 0 || mob.destroyed || mob._level == null) - return false; - } - catch - { - return false; - } - } - try { var spr = entity.spr; diff --git a/Ghost/KingWeapon/KingWeaponHooksBridge.cs b/Ghost/KingWeapon/KingWeaponHooksBridge.cs index 2d223c6..64d3c7b 100644 --- a/Ghost/KingWeapon/KingWeaponHooksBridge.cs +++ b/Ghost/KingWeapon/KingWeaponHooksBridge.cs @@ -64,8 +64,6 @@ internal void NotifyInventoryReplaceFromKingWeaponHooks(Hook_Inventory.orig_repl internal void NotifyLocalWeaponPrepareFromKingWeaponHooks(Weapon self) { - if (RemoteWeaponVisualSyncDisabled()) - return; if(_netRole == NetRole.None || self == null || me == null) return; @@ -107,8 +105,6 @@ internal void NotifyLocalWeaponPrepareFromKingWeaponHooks(Weapon self) internal void NotifyLocalShieldHoldingPulseFromKingWeaponHooks(BaseShield self, double ratio) { - if (RemoteWeaponVisualSyncDisabled()) - return; if(_netRole == NetRole.None || self == null || me == null) return; @@ -137,8 +133,6 @@ internal void NotifyLocalShieldHoldingPulseFromKingWeaponHooks(BaseShield self, internal void NotifyLocalWeaponInterruptFromKingWeaponHooks(Weapon self) { - if (RemoteWeaponVisualSyncDisabled()) - return; if(_netRole == NetRole.None || self == null || me == null) return; @@ -180,8 +174,6 @@ internal void NotifyLocalBowShotFromKingWeaponHooks(BaseBow self) internal void NotifyLocalAmmoChangedFromKingWeaponHooks(InventItem? item) { - if (RemoteWeaponVisualSyncDisabled()) - return; if(_netRole == NetRole.None || item == null || me == null) return; diff --git a/GhostCine/GhostDead/DeadBase.cs b/GhostCine/GhostDead/DeadBase.cs index c47005f..2995c3c 100644 --- a/GhostCine/GhostDead/DeadBase.cs +++ b/GhostCine/GhostDead/DeadBase.cs @@ -78,10 +78,9 @@ public override void onDispose() private void EnsureCorpse() { - // v6.4.5: corpse-less fake-death. Do not create HeroDeadCorpse here. - // The vanilla corpse entity can run lethal-fall/drop logic and can duplicate cells or - // blueprints when a downed player is repeatedly snapped out of spikes/void. - DisposeCorpse(); + var corpse = _corpse; + if (corpse == null || corpse.destroyed) + CreateCorpse(); } private void EnsureHomunculus() @@ -92,9 +91,31 @@ private void EnsureHomunculus() private void CreateCorpse() { - // v6.4.5: disabled physical HeroDeadCorpse creation for multiplayer fake-death. DisposeCorpse(); DisposeHomunculus(); + + if (!IsSafeToCreateCorpse()) + return; + + try + { + var corpse = new HeroDeadCorpse(this, _hero); + corpse.init(); + _corpse = corpse; + _lethalFallStarted = false; + _hasBossArenaCorpseAnchor = false; + _bossArenaCorpseAnchorX = 0; + _bossArenaCorpseAnchorY = 0; + _bossArenaCorpsePushApplied = false; + _bossArenaCorpsePushStartedTicks = 0; + TrySnapCorpseToHeroAnchor(corpse); + TryApplyBossArenaCorpsePush(corpse); + EnsureLethalFallStarted(); + } + catch + { + _corpse = null; + } } private bool IsSafeToCreateCorpse() @@ -120,8 +141,13 @@ private bool IsSafeToCreateCorpse() private void EnsureCorpseFalling() { - // v6.4.5: no physical corpse simulation while fake-dead. - DisposeCorpse(); + var corpse = _corpse; + if (corpse == null || corpse.destroyed) + return; + + KeepCorpseActive(corpse); + KeepBossArenaCorpseAnchored(corpse); + EnsureLethalFallStarted(); } private void EnsureLethalFallStarted() @@ -387,53 +413,40 @@ private static void KeepCorpseActive(HeroDeadCorpse corpse) try { corpse.onOutOfGameChange(); } catch { } } - public void SnapCorpseToPixel(double x, double y, bool clampToGround = true) - { - // v6.4.5: the local downed marker is the hidden hero anchor, not a physics corpse. - if (_hero == null || _hero.destroyed) - return; - - try { _hero.cancelVelocities(); } catch { } - try { _hero.setPosPixel(x, y); } catch { } - } - public bool TryGetCorpsePixelPosition(out double x, out double y) { x = 0; y = 0; - var hero = _hero; - if (hero == null || hero.destroyed) + var corpse = _corpse; + if (corpse == null || corpse.destroyed) return false; try { - x = hero.get_targetSprPosX(); - y = hero.get_targetSprPosY(); - return double.IsFinite(x) && double.IsFinite(y); + // Use physics-driven target coordinates so hero follows corpse reliably + // even when sprite position is temporarily unavailable or delayed. + x = corpse.get_targetSprPosX(); + y = corpse.get_targetSprPosY(); + return true; } catch { } - try - { - if (hero.spr != null) - { - x = hero.spr.x; - y = hero.spr.y; - return double.IsFinite(x) && double.IsFinite(y); - } - } - catch + var sprite = corpse.spr; + if (sprite != null) { + x = sprite.x; + y = sprite.y; + return true; } try { - x = (hero.cx + hero.xr) * 24.0; - y = (hero.cy + hero.yr) * 24.0; - return double.IsFinite(x) && double.IsFinite(y); + x = (corpse.cx + corpse.xr) * 24.0; + y = (corpse.cy + corpse.yr) * 24.0; + return true; } catch { diff --git a/GhostCine/GhostDead/RemoteDownedCorpse.cs b/GhostCine/GhostDead/RemoteDownedCorpse.cs index fa99d63..26aaa10 100644 --- a/GhostCine/GhostDead/RemoteDownedCorpse.cs +++ b/GhostCine/GhostDead/RemoteDownedCorpse.cs @@ -15,8 +15,6 @@ public sealed class RemoteDownedCorpse : dc.GameCinematic private Homunculus? _homunculus; private bool _hadGhostVisibleState; private bool _ghostWasVisible; - private bool _hadGhostGravityState; - private bool _ghostHadGravity; private bool _hadTemplateHeroVisibleState; private bool _templateHeroWasVisible; private bool _hadTemplateHeroHeadBlackState; @@ -47,7 +45,6 @@ public RemoteDownedCorpse(Hero templateHero, GhostKing ghost, double x, double y cancellable = false; SuppressCineEffects(); CaptureGhostVisibility(); - CaptureGhostRuntime(); CaptureTemplateHeroVisibility(); HideGhost(); CreateCorpse(); @@ -150,7 +147,6 @@ public override void onDispose() RestoreCineState(); DisposeCorpse(); DisposeHomunculus(); - RestoreGhostRuntime(); RestoreGhostVisibility(); RestoreTemplateHeroVisibility(); EnsureViewportTracksTemplateHero(immediate: true); @@ -158,26 +154,94 @@ public override void onDispose() private void EnsureCorpse() { - // v6.4.5: remote downed marker is ghost-only. Never create or simulate a - // HeroDeadCorpse because it can trigger Dead Cells drop/out-of-game logic. - DisposeCorpse(); + var corpse = _corpse; + if (corpse == null || corpse.destroyed) + { + CreateCorpse(); + return; + } + + KeepCorpseActive(corpse); ApplyTargetToCorpse(forceStartFall: false); + EnsureLethalFallStarted(); + ApplyTargetToHomunculus(); + EnsureCorpsePointer(); } private void CreateCorpse() { - // v6.4.5: disabled physical remote corpse creation to prevent cells/blueprints - // from being created by spectator-side downed visuals. DisposeCorpse(); DisposeHomunculus(); - ApplyTargetToCorpse(forceStartFall: false); - EnsureTemplateHeroVisible(); + + if (!IsSafeToCreateCorpse()) + return; + + try + { + var corpse = CreateCorpseWithoutDrops(); + if (corpse == null) + return; + + _corpse = corpse; + _lethalFallStarted = false; + ApplyTargetToCorpse(forceStartFall: true); + ApplyTargetToHomunculus(); + ApplyInteractionLabel(); + EnsureCorpsePointer(); + EnsureTemplateHeroVisible(); + } + catch + { + _corpse = null; + } } private HeroDeadCorpse? CreateCorpseWithoutDrops() { - // v6.4.5: physical corpses are disabled in multiplayer revive visuals. - return null; + var hero = _templateHero; + if (hero == null) + return null; + + var originalCells = 0; + var capturedCells = false; + var originalBlueprints = hero.blueprints; + try + { + originalCells = hero.cells; + capturedCells = true; + hero.cells = 0; + hero.blueprints = (dc.hl.types.ArrayObj)ArrayUtils.CreateDyn().array; + } + catch + { + } + + try + { + var corpse = new HeroDeadCorpse(this, hero); + corpse.init(); + corpse.cells = 0; + return corpse; + } + finally + { + try + { + if (capturedCells) + hero.cells = originalCells; + } + catch + { + } + + try + { + hero.blueprints = originalBlueprints; + } + catch + { + } + } } private bool IsSafeToCreateCorpse() @@ -204,19 +268,17 @@ private bool IsSafeToCreateCorpse() private void ApplyTargetToCorpse(bool forceStartFall) { - if (!_hasTarget || _ghost == null) + var corpse = _corpse; + if (corpse == null || corpse.destroyed || !_hasTarget) return; - try { _ghost.visible = true; } catch { } - try { _ghost._targetable = false; } catch { } - try { _ghost.hasGravity = false; } catch { } - try { _ghost.cancelVelocities(); } catch { } - try { _ghost.dx = 0; } catch { } - try { _ghost.dy = 0; } catch { } - try { _ghost.bdx = 0; } catch { } - try { _ghost.bdy = 0; } catch { } - try { _ghost.dir = _targetDir; } catch { } - try { _ghost.setPosPixel(_targetX, _targetY - 40.0); } catch { } + try { corpse.dir = _targetDir; } catch { } + if (!_lethalFallStarted || IsCorpseStabilized(corpse)) + { + SafeSnapCorpse(corpse, _targetX, _targetY); + } + if (forceStartFall) + EnsureLethalFallStarted(); } private static void SafeSnapCorpse(HeroDeadCorpse corpse, double x, double y) @@ -423,26 +485,47 @@ private void RestoreCineState() private void HideGhost() { - // v6.4.5: do not hide the remote player while downed. The ghost itself is now the - // safe revive marker, because a physical HeroDeadCorpse can duplicate drops. - try { _ghost.visible = true; } catch { } - try { _ghost._targetable = false; } catch { } - try { _ghost.hasGravity = false; } catch { } - try { _ghost.cancelVelocities(); } catch { } - try { _ghost.dx = 0; } catch { } - try { _ghost.dy = 0; } catch { } - try { _ghost.bdx = 0; } catch { } - try { _ghost.bdy = 0; } catch { } - if (_hasTarget) - { - try { _ghost.setPosPixel(_targetX, _targetY - 40.0); } catch { } - } + try { _ghost.visible = false; } catch { } } private void EnsureCorpsePointer() { - // v6.4.5: no corpse pointer when physical corpse is disabled. - ClearCorpsePointer(); + var corpse = _corpse; + if (corpse == null || corpse.destroyed) + { + ClearCorpsePointer(); + return; + } + + if (_corpsePointer != null) + { + try + { + if (_corpsePointer.destroyed) + { + _corpsePointer = null; + } + else + { + _corpsePointer.e = corpse; + return; + } + } + catch + { + _corpsePointer = null; + } + } + + try + { + _corpsePointer = new Pointer(corpse, "".AsHaxeString(), 99999.0, CorpseMarkerColor); + PointerFxHelper.SuppressPointerFx(_corpsePointer, PointerFxSuppressionKey); + } + catch + { + _corpsePointer = null; + } } private void ClearCorpsePointer() @@ -531,27 +614,6 @@ private void CaptureGhostVisibility() _hadGhostVisibleState = true; } - private void CaptureGhostRuntime() - { - if (_hadGhostGravityState || _ghost == null) - return; - - try { _ghostHadGravity = _ghost.hasGravity; } - catch { _ghostHadGravity = true; } - _hadGhostGravityState = true; - } - - private void RestoreGhostRuntime() - { - if (_ghost == null || _ghost.destroyed) - return; - - try { _ghost.hasGravity = _hadGhostGravityState ? _ghostHadGravity : true; } catch { } - try { _ghost.cancelVelocities(); } catch { } - _hadGhostGravityState = false; - _ghostHadGravity = true; - } - private void CaptureTemplateHeroVisibility() { if (_hadTemplateHeroVisibleState || _templateHero == null) @@ -655,6 +717,8 @@ private void DisposeCorpse() catch { } + + try { corpse.dispose(); } catch { } } private void DisposeHomunculus() diff --git a/Interaction/InterSyncTypes.cs b/Interaction/InterSyncTypes.cs index a785ab1..bfe660f 100644 --- a/Interaction/InterSyncTypes.cs +++ b/Interaction/InterSyncTypes.cs @@ -120,21 +120,6 @@ public InterBossRuneUpdateCellsEvent(double x, double y, bool add) } } - -public readonly struct InterGenericActivateEvent -{ - public readonly double X; - public readonly double Y; - public readonly string TypeName; - - public InterGenericActivateEvent(double x, double y, string typeName) - { - X = x; - Y = y; - TypeName = typeName ?? string.Empty; - } -} - public readonly struct InterPortalEvent { public readonly double X; @@ -148,32 +133,3 @@ public InterPortalEvent(double x, double y, string action) Action = action ?? string.Empty; } } - -[System.Flags] -public enum WorldObjectSyncFlags -{ - None = 0, - Consumed = 1, - Opened = 2, - Broken = 4, - Hidden = 8, - Important = 16 -} - -public readonly struct WorldObjectState -{ - public readonly string LevelId; - public readonly string TypeName; - public readonly double X; - public readonly double Y; - public readonly int Flags; - - public WorldObjectState(string levelId, string typeName, double x, double y, int flags) - { - LevelId = levelId ?? string.Empty; - TypeName = typeName ?? string.Empty; - X = x; - Y = y; - Flags = flags; - } -} diff --git a/Interaction/InteractionSync.cs b/Interaction/InteractionSync.cs index 3bc693e..7ed03b3 100644 --- a/Interaction/InteractionSync.cs +++ b/Interaction/InteractionSync.cs @@ -9,7 +9,6 @@ using ModCore.Events; using ModCore.Events.Interfaces.Game.Hero; using Serilog; -using System.Diagnostics; using System.Reflection; namespace DeadCellsMultiplayerMod.Interaction; @@ -26,7 +25,6 @@ private sealed class LevelInteractionCache public readonly List VineLadders = new(); public readonly List Teleports = new(); public readonly List Portals = new(); - public readonly List GenericInteractives = new(); public readonly List PressurePlates = new(); public readonly List TreasureChests = new(); public readonly List SwitchBossRunes = new(); @@ -41,7 +39,6 @@ public void Clear() VineLadders.Clear(); Teleports.Clear(); Portals.Clear(); - GenericInteractives.Clear(); PressurePlates.Clear(); TreasureChests.Clear(); SwitchBossRunes.Clear(); @@ -60,7 +57,6 @@ public void Clear() private const double SwitchBossRunePosTolerance = 32.0; private const double ElevatorPosTolerance = 48.0; private const double PortalPosTolerance = 48.0; - private const double GenericInteractPosTolerance = 72.0; private const double TileSizePx = 24.0; private const double DoorProximityRadiusPx = 100.0; private static readonly double DoorProximityRadiusSq = DoorProximityRadiusPx * DoorProximityRadiusPx; @@ -78,18 +74,14 @@ public void Clear() private static Level? _cachedInteractionLevel; private bool _applyingRemoteDoorEvents; private bool _applyingRemoteChestEvents; + private bool _applyingRemotePressurePlateEvents; private bool _applyingRemoteVineLadderEvents; private bool _applyingRemoteTeleportEvents; private bool _applyingRemoteBreakableGroundEvents; private bool _applyingRemotePortalEvents; - private bool _applyingRemoteGenericActivateEvents; private bool _applyingRemoteElevatorEvents; /// Throttle elevator INTERELEV sends — onStep can fire every frame while riding. private readonly Dictionary _elevatorLastInterSendTickMs = new(); - private readonly Dictionary _recentLocalInteractionSends = new(StringComparer.Ordinal); - private readonly Dictionary _recentRemoteInteractionApplies = new(StringComparer.Ordinal); - private const double OneShotInteractionSendDedupeSeconds = 0.75; - private const double OneShotInteractionApplyDedupeSeconds = 2.0; public InteractionSync(ModEntry entry) { @@ -114,10 +106,6 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) Hook_Teleport.open += Hook_Teleport_open; Hook_Portal.show += Hook_Portal_show; Hook_Portal.close += Hook_Portal_close; - // v5.1: Do not hook Hook_Interactive.onActivate directly. - // Some DCCM/GameProxy builds expose Hook_Interactive without orig_onActivate, - // which caused CS0426: type name orig_onActivate does not exist. - // Specific interaction replay hooks and F8 stuck recovery remain enabled. Hook_Hero.breakBreakableGround += Hook_Hero_breakBreakableGround; Hook_SwitchBossRune.canBeActivated += Hook_SwitchBossRune_canBeActivated; Hook_SwitchBossRune.close += Hook_SwitchBossRune_close; @@ -125,13 +113,6 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) } - // v5.1 compile-fix note: - // The generic Interactive.onActivate hook was intentionally removed because the generated - // hook delegate name is not stable across the current DCCM/GameProxy builds. The v5 code - // referenced Hook_Interactive.orig_onActivate, but your build exposes Hook_Interactive - // without that nested type. This keeps the stable/specific interaction sync hooks active - // while avoiding a compile break. - private bool Hook_SwitchBossRune_canBeActivated(Hook_SwitchBossRune.orig_canBeActivated orig, SwitchBossRune self, Hero by) { var net = GameMenu.NetRef; @@ -172,7 +153,6 @@ private void Hook_SwitchBossRune_close(Hook_SwitchBossRune.orig_close orig, Swit var (x, y) = GetEntityPixelPos(self); for (var i = 0; i < count; i++) net.SendInterBossRuneUpdateCells(x, y, add); - QueueReliableInteractionReplay("bossrune", x, y, string.Empty, add); } } catch (Exception ex) @@ -193,7 +173,6 @@ private void Hook_SwitchBossRune_updateCells(Hook_SwitchBossRune.orig_updateCell { var (x, y) = GetEntityPixelPos(self); net.SendInterBossRuneUpdateCells(x, y, add); - QueueReliableInteractionReplay("bossrune", x, y, string.Empty, add); var user = self?._level?.game?.user ?? dc.Main.Class.ME?.user; if (user != null) GameDataSync.SendBossRune(user, net); @@ -244,9 +223,6 @@ private void Hook_Door_onDie(Hook_Door.orig_onDie orig, Door self) private void TrySendDoorEvent(Door self, string action) { - if (string.Equals(action, "close", StringComparison.OrdinalIgnoreCase)) - return; // v5.6: never network-close doors; this fought pressure plates and caused flicker/stuck closed doors. - if (_applyingRemoteDoorEvents) return; var net = GameMenu.NetRef; @@ -256,8 +232,6 @@ private void TrySendDoorEvent(Door self, string action) { var (x, y) = GetEntityPixelPos(self); var broken = action == "die" || SafeRead(() => self.broken, false); - if (!ShouldSendOneShotInteraction("door", x, y, action)) - return; net!.SendInterDoor(net.id, x, y, action, broken); } catch (Exception ex) @@ -281,8 +255,6 @@ private void Hook_Elevator_onStep(Hook_Elevator.orig_onStep orig, Elevator self) _elevatorLastInterSendTickMs[self] = now; var (x, y) = GetElevatorStableAnchor(self); - if (!ShouldSendOneShotInteraction("elevator", x, y, string.Empty)) - return; GameMenu.NetRef!.SendInterElevator(x, y); } catch (Exception ex) @@ -322,9 +294,9 @@ private void Hook_PressurePlate_trigger(Hook_PressurePlate.orig_trigger orig, Pr private void TrySendPressurePlateEvent(PressurePlate self) { - // v5.6: pressure plates are stateful/toggle-like. Network replay/echo made them - // repeatedly open/close doors, so leave pressure plates to local vanilla simulation. - return; + if (_applyingRemotePressurePlateEvents) + return; + TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterPressurePlate(x, y), "PressurePlate"); } private void Hook_TreasureChest_open(Hook_TreasureChest.orig_open orig, TreasureChest self, Hero by) @@ -336,10 +308,7 @@ private void Hook_TreasureChest_open(Hook_TreasureChest.orig_open orig, Treasure private void TrySendTreasureChestEvent(TreasureChest self) { - TrySendInteractEvent(self, (x, y) => - { - GameMenu.NetRef!.SendInterTreasureChest(x, y); - }, "TreasureChest", "chest", string.Empty); + TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterTreasureChest(x, y), "TreasureChest"); } private void Hook_VineLadder_activate(Hook_VineLadder.orig_activate orig, VineLadder self) @@ -352,16 +321,12 @@ private void TrySendVineLadderEvent(VineLadder self) { if (_applyingRemoteVineLadderEvents) return; - TrySendInteractEvent(self, (x, y) => - { - GameMenu.NetRef!.SendInterVineLadder(x, y); - }, "VineLadder", "vine", string.Empty); + TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterVineLadder(x, y), "VineLadder"); } private void Hook_Teleport_open(Hook_Teleport.orig_open orig, Teleport self) { orig(self); - TryRememberLocalTeleporterReviveAnchor(self); TrySendTeleportEvent(self); } @@ -375,8 +340,6 @@ private void Hook_Hero_breakBreakableGround(Hook_Hero.orig_breakBreakableGround return; try { - if (!ShouldSendOneShotInteraction("break", x, y, string.Empty)) - return; net!.SendInterBreakableGround(x, y); } catch (Exception ex) @@ -385,54 +348,11 @@ private void Hook_Hero_breakBreakableGround(Hook_Hero.orig_breakBreakableGround } } - private void TryRememberLocalTeleporterReviveAnchor(Teleport self) - { - if (_applyingRemoteTeleportEvents || self == null) - return; - - var hero = ModEntry.me; - if (hero == null) - return; - - try - { - if (hero._level == null || self._level == null || !ReferenceEquals(hero._level, self._level)) - return; - } - catch - { - return; - } - - var (x, y) = GetEntityPixelPos(self); - if (!double.IsFinite(x) || !double.IsFinite(y) || (x == 0 && y == 0)) - return; - - try - { - var hx = hero.spr?.x ?? ((hero.cx + hero.xr) * TileSizePx); - var hy = hero.spr?.y ?? ((hero.cy + hero.yr) * TileSizePx); - var dx = hx - x; - var dy = hy - y; - if (dx * dx + dy * dy > 220.0 * 220.0) - return; - } - catch - { - return; - } - - try { ModEntry.Instance?.RememberLocalReviveTeleporterPosition(x, y); } catch { } - } - private void TrySendTeleportEvent(Teleport self) { if (_applyingRemoteTeleportEvents) return; - TrySendInteractEvent(self, (x, y) => - { - GameMenu.NetRef!.SendInterTeleport(x, y); - }, "Teleport", "teleport", string.Empty); + TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterTeleport(x, y), "Teleport"); } private void Hook_Portal_show(Hook_Portal.orig_show orig, Portal self) @@ -456,8 +376,6 @@ private void TrySendPortalEvent(Portal self, string action) try { var (x, y) = GetEntityPixelPos(self); - if (!ShouldSendOneShotInteraction("portal", x, y, action)) - return; GameMenu.NetRef!.SendInterPortal(x, y, action); } catch (Exception ex) @@ -466,62 +384,6 @@ private void TrySendPortalEvent(Portal self, string action) } } - private static bool ShouldSyncGenericInteractive(Interactive? interactive, Hero? by) - { - if (interactive == null || by == null || ModEntry.me == null || !ReferenceEquals(by, ModEntry.me)) - return false; - if (!ShouldAllowGenericInteractiveApply(interactive)) - return false; - - return true; - } - - private static bool ShouldAllowGenericInteractiveApply(Interactive? interactive) - { - if (interactive == null) - return false; - - if (interactive is Door || interactive is Exit || interactive is Portal || - interactive is TreasureChest || interactive is VineLadder || interactive is Teleport || interactive is SwitchBossRune) - { - return false; - } - - try - { - var typeName = interactive.GetType().Name ?? string.Empty; - if (typeName.IndexOf("ZDoor", StringComparison.OrdinalIgnoreCase) >= 0 || - typeName.IndexOf("BossRushDoor", StringComparison.OrdinalIgnoreCase) >= 0) - { - return false; - } - } - catch - { - } - - if (SafeRead(() => interactive.destroyed, false)) - return false; - if (SafeRead(() => interactive.isOutOfGame, false)) - return false; - - return true; - } - - private static string GetStableInteractiveTypeName(Interactive? interactive) - { - if (interactive == null) - return string.Empty; - try - { - return interactive.GetType().Name ?? string.Empty; - } - catch - { - return string.Empty; - } - } - private static (double x, double y) GetEntityPixelPos(Entity e) { if (e?.spr == null) @@ -545,19 +407,13 @@ private static T SafeRead(Func fn, T fallback) private static bool IsNetReadyForSend(NetNode? net) => net != null && net.IsAlive && net.id > 0; - private bool TrySendInteractEvent(Entity entity, Action send, string logContext, string? dedupeKind = null, string? dedupeAction = null) + private bool TrySendInteractEvent(Entity entity, Action send, string logContext) { if (!IsNetReadyForSend(GameMenu.NetRef)) return false; try { var (x, y) = GetEntityPixelPos(entity); - if (!string.IsNullOrWhiteSpace(dedupeKind) && - !ShouldSendOneShotInteraction(dedupeKind!, x, y, dedupeAction ?? string.Empty)) - { - return false; - } - send(x, y); return true; } @@ -568,64 +424,12 @@ private bool TrySendInteractEvent(Entity entity, Action send, st } } - private bool ShouldSendOneShotInteraction(string kind, double x, double y, string action) - { - return ShouldAllowOneShot(_recentLocalInteractionSends, kind, x, y, action, OneShotInteractionSendDedupeSeconds); - } - - private bool ShouldApplyOneShotInteraction(string kind, double x, double y, string action) - { - return ShouldAllowOneShot(_recentRemoteInteractionApplies, kind, x, y, action, OneShotInteractionApplyDedupeSeconds); - } - - private static bool ShouldAllowOneShot(Dictionary map, string kind, double x, double y, string action, double seconds) - { - var now = StopwatchTicks(); - if (map.Count > 256) - PruneOneShotMap(map, now); - - var key = MakeInteractionKey(kind, x, y, action); - if (map.TryGetValue(key, out var last) && - now - last < (long)(System.Diagnostics.Stopwatch.Frequency * seconds)) - { - return false; - } - - map[key] = now; - return true; - } - - private static long StopwatchTicks() => System.Diagnostics.Stopwatch.GetTimestamp(); - - private static void PruneOneShotMap(Dictionary map, long now) - { - var cutoff = now - (long)(System.Diagnostics.Stopwatch.Frequency * 8.0); - var stale = new List(); - foreach (var kv in map) - { - if (kv.Value < cutoff) - stale.Add(kv.Key); - } - - for (var i = 0; i < stale.Count; i++) - map.Remove(stale[i]); - } - - private static string MakeInteractionKey(string kind, double x, double y, string action) - { - var qx = (int)System.Math.Round(x / 8.0); - var qy = (int)System.Math.Round(y / 8.0); - return string.Concat(kind, ":", action ?? string.Empty, ":", qx.ToString(System.Globalization.CultureInfo.InvariantCulture), ":", qy.ToString(System.Globalization.CultureInfo.InvariantCulture)); - } - void IOnHeroUpdate.OnHeroUpdate(double dt) { var net = GameMenu.NetRef; if (net == null || !net.IsAlive) return; - // v5.6: reliable replay disabled; it could repeatedly toggle stateful DLC doors/vines/plates. - if (net.TryConsumeInterDoorEvents(out var doorEvents)) { ApplyRemoteDoorEvents(doorEvents); @@ -661,12 +465,8 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) ApplyRemotePortalEvents(portalEvents); } - if (net.TryConsumeInterGenericActivateEvents(out var genericEvents)) - { - ApplyRemoteGenericActivateEvents(genericEvents); - } - - // v5.6: do not run custom auto-close; it can fight pressure plates/DLC doors. + if (net.IsHost) + CheckAndCloseDoorsWhenNoOneNearby(); if (net.TryConsumeInterBreakableGroundEvents(out var breakableGroundEvents)) { ApplyRemoteBreakableGroundEvents(breakableGroundEvents); @@ -678,13 +478,6 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) } } - private void QueueReliableInteractionReplay(string kind, double x, double y, string action, bool flag) - { - // v5.6 rollback: do not replay stateful interactions. Replaying open/trigger/activate - // packets every few frames caused doors, pressure plates and vines to flicker/toggle. - // Keep this method as a no-op so all call sites remain harmless and compile-safe. - } - private void CheckAndCloseDoorsWhenNoOneNearby() { var level = ModEntry.me?._level; @@ -840,9 +633,6 @@ private void ApplyRemoteDoorEvents(List events) if (door == null) continue; - if (!ShouldApplyOneShotInteraction("door", ev.X, ev.Y, ev.Action)) - continue; - try { switch (ev.Action) @@ -851,7 +641,18 @@ private void ApplyRemoteDoorEvents(List events) door.open(300, null, null); break; case "close": - // v5.6: ignore remote close events. Local vanilla logic/plates should own close state. + if (SafeRead(() => door.broken, false)) + break; + _openedDoors.Remove(door); + try + { + int delayMs = DoorCloseDelayMs; + door.close(Ref.From(ref delayMs)); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] close failed (door may be broken)"); + } break; case "damage": if (ev.Broken) @@ -915,7 +716,7 @@ private void ApplyRemoteElevatorEvents(List events) private void ApplyRemoteVineLadderEvents(List events) { var level = ModEntry.me?._level; - if (level == null || events == null || events.Count == 0) + if (level?.entities == null || events == null || events.Count == 0) return; _applyingRemoteVineLadderEvents = true; @@ -923,16 +724,13 @@ private void ApplyRemoteVineLadderEvents(List events) { foreach (var ev in events) { - if (!ShouldApplyOneShotInteraction("vine", ev.X, ev.Y, string.Empty)) - continue; - - var vine = FindVineLadderByPos(level, ev.X, ev.Y); - if (vine == null) + var vineLadder = FindVineLadderByPos(level, ev.X, ev.Y); + if (vineLadder == null) continue; try { - vine.activate(); + vineLadder.activate(); } catch (Exception ex) { @@ -957,9 +755,6 @@ private void ApplyRemotePortalEvents(List events) { foreach (var ev in events) { - if (!ShouldApplyOneShotInteraction("portal", ev.X, ev.Y, ev.Action)) - continue; - var portal = FindPortalByPos(level, ev.X, ev.Y); if (portal == null) continue; @@ -983,44 +778,6 @@ private void ApplyRemotePortalEvents(List events) } } - private void ApplyRemoteGenericActivateEvents(List events) - { - var level = ModEntry.me?._level; - var localHero = ModEntry.me; - if (level == null || localHero == null || events == null || events.Count == 0) - return; - - _applyingRemoteGenericActivateEvents = true; - try - { - foreach (var ev in events) - { - if (!ShouldApplyOneShotInteraction("generic", ev.X, ev.Y, ev.TypeName)) - continue; - - var target = FindGenericInteractiveByPos(level, ev.X, ev.Y, ev.TypeName); - if (target == null) - { - _log.Warning("[InteractionSync] No generic interactive found type={Type} x={X} y={Y}", ev.TypeName, ev.X, ev.Y); - continue; - } - - try - { - target.onActivate(localHero, false); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Apply generic interactive failed type={Type} x={X} y={Y}", ev.TypeName, ev.X, ev.Y); - } - } - } - finally - { - _applyingRemoteGenericActivateEvents = false; - } - } - private void ApplyRemoteTeleportEvents(List events) { var level = ModEntry.me?._level; @@ -1032,9 +789,6 @@ private void ApplyRemoteTeleportEvents(List events) { foreach (var ev in events) { - if (!ShouldApplyOneShotInteraction("teleport", ev.X, ev.Y, string.Empty)) - continue; - var teleport = FindTeleportByPos(level, ev.X, ev.Y); if (teleport == null) { @@ -1081,8 +835,6 @@ private void ApplyRemoteBreakableGroundEvents(List ev } if (alreadyNearby) continue; - if (!ShouldApplyOneShotInteraction("break", ev.X, ev.Y, string.Empty)) - continue; var cx = (int)System.Math.Round(ev.X); var cy = (int)System.Math.Round(ev.Y); @@ -1106,8 +858,37 @@ private void ApplyRemoteBreakableGroundEvents(List ev private void ApplyRemotePressurePlateEvents(List events) { - // v5.6: ignore remote pressure plate events to prevent plate/door toggle loops. - return; + var level = ModEntry.me?._level; + if (level?.entities == null || events == null || events.Count == 0) + return; + + var localHero = ModEntry.me as Entity; + if (localHero == null) + return; + + _applyingRemotePressurePlateEvents = true; + try + { + foreach (var ev in events) + { + var plate = FindPressurePlateByPos(level, ev.X, ev.Y); + if (plate == null) + continue; + + try + { + plate.trigger(localHero); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Apply pressure plate event failed x={X} y={Y}", ev.X, ev.Y); + } + } + } + finally + { + _applyingRemotePressurePlateEvents = false; + } } private static Door? FindDoorByPos(Level level, double x, double y) @@ -1309,55 +1090,6 @@ private static int GetTriggerArrayLength(object? triggers) return FindInteractByPos(level, x, y, PlatePosTolerance); } - private static Interactive? FindGenericInteractiveByPos(Level level, double x, double y, string typeName) - { - var candidates = GetInteractionCandidates(level); - if (candidates == null || candidates.Count == 0) - return null; - - Interactive? nearestSameType = null; - Interactive? nearestAnyType = null; - var nearestSameTypeSq = GenericInteractPosTolerance * GenericInteractPosTolerance; - var nearestAnyTypeSq = GenericInteractPosTolerance * GenericInteractPosTolerance; - var wanted = string.IsNullOrWhiteSpace(typeName) ? string.Empty : typeName.Trim(); - - for (var i = 0; i < candidates.Count; i++) - { - var e = candidates[i]; - if (e?.spr == null || !ShouldAllowGenericInteractiveApply(e)) - continue; - - try - { - var dx = e.spr.x - x; - var dy = e.spr.y - y; - var dSq = dx * dx + dy * dy; - if (dSq < nearestAnyTypeSq) - { - nearestAnyTypeSq = dSq; - nearestAnyType = e; - } - - if (!string.IsNullOrWhiteSpace(wanted) && - !string.Equals(GetStableInteractiveTypeName(e), wanted, StringComparison.Ordinal)) - { - continue; - } - - if (dSq < nearestSameTypeSq) - { - nearestSameTypeSq = dSq; - nearestSameType = e; - } - } - catch - { - } - } - - return nearestSameType ?? nearestAnyType; - } - private Teleport? FindTeleportByPos(Level level, double x, double y) { var byPos = FindInteractByPos(level, x, y, TeleportPosTolerance); @@ -1406,9 +1138,6 @@ private void ApplyRemoteTreasureChestEvents(List events { foreach (var ev in events) { - if (!ShouldApplyOneShotInteraction("chest", ev.X, ev.Y, string.Empty)) - continue; - var chest = FindTreasureChestByPos(level, ev.X, ev.Y); if (chest == null) continue; @@ -1443,12 +1172,6 @@ private void ApplyRemoteTreasureChestEvents(List events private static T? FindInteractByPos(Level level, double x, double y, double tolerance = PosTolerance) where T : Entity { var candidates = GetInteractionCandidates(level); - if (candidates == null || candidates.Count == 0) - { - RebuildInteractionCache(level); - candidates = GetInteractionCandidates(level); - } - if (candidates == null || candidates.Count == 0) return null; @@ -1472,32 +1195,6 @@ private void ApplyRemoteTreasureChestEvents(List events } } - // Level interaction entities can appear after our first cache pass (DLC doors, vines, - // teleports). Refresh once before giving up so one-shot visual sync is less likely to miss. - RebuildInteractionCache(level); - candidates = GetInteractionCandidates(level); - if (candidates != null && candidates.Count > 0) - { - for (var i = 0; i < candidates.Count; i++) - { - var e = candidates[i]; - if (e == null) - continue; - try - { - if (e.spr != null && - System.Math.Abs(e.spr.x - x) < tolerance && - System.Math.Abs(e.spr.y - y) < tolerance) - { - return e; - } - } - catch - { - } - } - } - return null; } @@ -1548,9 +1245,6 @@ private static void RebuildInteractionCache(Level? level) case SwitchBossRune switchBossRune: CachedInteractionLevelData.SwitchBossRunes.Add(switchBossRune); break; - case Interactive interactive when ShouldAllowGenericInteractiveApply(interactive): - CachedInteractionLevelData.GenericInteractives.Add(interactive); - break; } } } @@ -1587,8 +1281,6 @@ private static void RebuildInteractionCache(Level? level) return (IReadOnlyList)(object)cache.Teleports; if (typeof(T) == typeof(Portal)) return (IReadOnlyList)(object)cache.Portals; - if (typeof(T) == typeof(Interactive)) - return (IReadOnlyList)(object)cache.GenericInteractives; if (typeof(T) == typeof(PressurePlate)) return (IReadOnlyList)(object)cache.PressurePlates; if (typeof(T) == typeof(TreasureChest)) 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/LevelSync.cs b/LevelSync.cs index 276e08c..2b4611e 100644 --- a/LevelSync.cs +++ b/LevelSync.cs @@ -245,12 +245,6 @@ private static void TryTriggerLevelGraphReload(string graphLevelId, string paylo if (!string.Equals(currentLevelId, graphLevelId, StringComparison.Ordinal)) return; - if (IsClientLevelReloadUnsafe(hero, level, graphLevelId, out var unsafeReason)) - { - _log?.Information("[NetMod] Skipping level-graph reload for {LevelId}: {Reason}", graphLevelId, unsafeReason); - return; - } - lock (_levelGraphLock) { if (!_remoteLevelGraphs.ContainsKey(graphLevelId)) @@ -277,62 +271,6 @@ private static void TryTriggerLevelGraphReload(string graphLevelId, string paylo offsetCy); } - private static bool IsClientLevelReloadUnsafe(Hero hero, Level level, string graphLevelId, out string reason) - { - reason = string.Empty; - - try - { - if (hero == null || hero.destroyed) - { - reason = "hero unavailable"; - return true; - } - - if (level == null || level.destroyed || level.map == null) - { - reason = "level unavailable"; - return true; - } - } - catch - { - reason = "hero/level validity check failed"; - return true; - } - - try - { - var game = dc.pr.Game.Class.ME; - var cine = game?.curCine; - if (cine != null && !cine.destroyed) - { - reason = "cinematic active"; - return true; - } - } - catch - { - } - - try - { - var room = TryGetRoomAt(level, hero.cx, hero.cy); - if (room == null) - { - reason = "hero is not in a valid room"; - return true; - } - } - catch - { - reason = "hero room check failed"; - return true; - } - - return false; - } - private static bool TryBeginLevelGraphReload(string levelId, string payload) { var now = Environment.TickCount64; @@ -377,12 +315,6 @@ private static void TryTriggerBossRuneReload(string graphLevelId) if (!string.Equals(currentLevelId, graphLevelId, StringComparison.Ordinal)) return; - if (IsClientLevelReloadUnsafe(hero, level, graphLevelId, out var unsafeReason)) - { - _log?.Information("[NetMod] Skipping boss-rune reload for {LevelId}: {Reason}", graphLevelId, unsafeReason); - return; - } - if (!TryGetRemoteBossRune(out var remoteBossRune)) return; diff --git a/MERGE_NOTES_v0.8.32.md b/MERGE_NOTES_v0.8.32.md new file mode 100644 index 0000000..90a1b99 --- /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 Claude 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/MobWireCodec.cs b/Mobs/MobWireCodec.cs index 90b16c8..5daadb8 100644 --- a/Mobs/MobWireCodec.cs +++ b/Mobs/MobWireCodec.cs @@ -190,13 +190,6 @@ public static string BuildMobDrawLine(IReadOnlyList draws) public static string BuildMobDieLine(NetNode.MobDie die) { - if (!string.IsNullOrWhiteSpace(die.Type)) - { - return string.Create( - CultureInfo.InvariantCulture, - $"MOBDIE|{die.UserId}|{die.MobIndex}|{die.X}|{die.Y}|{die.Generation}|{die.Type}\n"); - } - return string.Create( CultureInfo.InvariantCulture, $"MOBDIE|{die.UserId}|{die.MobIndex}|{die.X}|{die.Y}|{die.Generation}\n"); diff --git a/Mobs/MonsterSynchronization.Attacks.cs b/Mobs/MonsterSynchronization.Attacks.cs index ba50b69..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; @@ -1003,20 +1012,6 @@ private static bool TryGetMobSyncId(Mob mob, out int syncId) return unresolvedMob; } - if (TryResolveNearestTypedMobLocked( - state.Index, - state.Type, - state.X, - state.Y, - MobStateTypedRebindMaxDistancePx, - reservedMobs, - out var nearestTypedStateMob) && nearestTypedStateMob != null) - { - TryRebindTrackedMobSyncIdLocked(nearestTypedStateMob, state.Index); - MobSyncTrace.LogBindSyncId("state_nearest_typed", state.Index, state.Type ?? string.Empty, state.X, state.Y); - return nearestTypedStateMob; - } - if (candidateCount > 1) { MobSyncTrace.LogAmbiguousMatchRejected( @@ -1169,21 +1164,6 @@ private static void QuantizeWorldPositionToPixelsInt32(double x, double y, out i if (string.IsNullOrWhiteSpace(expectedType)) return null; - if (TryResolveNearestTypedMobLocked( - attack.Index, - expectedType, - attack.X, - attack.Y, - MobHitTypedRebindMaxDistancePx, - null, - out var nearestTypedAttackMob) && nearestTypedAttackMob != null) - { - TryRebindTrackedMobSyncIdLocked(nearestTypedAttackMob, attack.Index); - hostMobTypeBySyncId[attack.Index] = expectedType; - MobSyncTrace.LogBindSyncId("attack_nearest_typed", attack.Index, expectedType ?? string.Empty, attack.X, attack.Y); - return nearestTypedAttackMob; - } - if (!string.IsNullOrWhiteSpace(attack.Type)) hostMobTypeBySyncId[attack.Index] = attack.Type; return null; diff --git a/Mobs/MonsterSynchronization.ClientApply.cs b/Mobs/MonsterSynchronization.ClientApply.cs index 397534a..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) @@ -169,8 +251,11 @@ private static void ApplyAuthoritativeLifeState(Mob mob, int targetLife, int tar 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)) - TryFinalizeNonBossZeroLifeMob(mob, "authoritative_life_already_zero"); + ForceNonBossAuthoritativeDeath(mob); return; } @@ -182,9 +267,19 @@ private static void ApplyAuthoritativeLifeState(Mob mob, int targetLife, int tar if (BossSyncHelpers.IsBossMob(mob)) return; + if (TryDeferCulledClientMobDeath(mob)) + return; + try { - TryFinalizeNonBossZeroLifeMob(mob, "authoritative_life_new_zero"); + if (!mob.destroyed) + { + RunWithSuppressedMobDieSend(() => + { + mob.life = 0; + mob.onDie(); + }); + } var animManager = GetMobAnimManager(mob); if (animManager?.stack != null) @@ -199,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 1f7d045..3598a95 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)) @@ -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); } @@ -1292,9 +1339,13 @@ private static void ConsumeIncomingMobDies(NetNode net) { MobSyncTrace.LogRecvDies(net.IsHost ? "diesOnHost" : "diesOnClient", dies); - // Host remains authoritative, but client-side lethal hits are accepted as validated - // kill requests so remote players can finish normal mobs without waiting for the host - // to land the final hit. ResolveMobFromDieLocked still checks generation/type/position. + // Host is authoritative for mob death. Ignore remote client die packets. + if (net.IsHost) + return; + + if (IsSyncQuiescedForTransition()) + return; + ApplyIncomingMobDies(dies); } finally @@ -1338,11 +1389,11 @@ private static void ApplyIncomingMobDies(IReadOnlyList dies) continue; } - if (!isBoss && life <= 0) - { - // v6.4.5: do not ignore an already-zero non-boss entity. - // It may be the exact stuck empty-HP mob that still needs onDie finalization. - } + // 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)) s_dieVictimsScratch.Add(mob); @@ -1359,23 +1410,28 @@ 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 { RunWithAuthoritativeClientBossDie(mob, () => { - if (BossSyncHelpers.IsBossMob(mob)) - { - RunWithSuppressedMobDieSend(() => - { - mob.life = 0; - mob.onDie(); - }); - } - else + RunWithSuppressedMobDieSend(() => { - TryFinalizeNonBossZeroLifeMob(mob, "incoming_die_packet"); - } + mob.life = 0; + mob.onDie(); + }); }); } catch @@ -1458,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]; @@ -1477,24 +1535,6 @@ private static void ApplyIncomingMobHits(IReadOnlyList hits, int if (mob == null) continue; - // v5.6: client safe-combat mode. Do not run intermediate remote hit reactions - // on clients; charged/heavy weapon paths can crash client feedback cleanup. - // Lethal/death packets still apply so progression and rune unlocks continue. - if (!isHost && !update.ForceDie) - { - try - { - if (update.TargetMaxLife > 0 && mob.maxLife != update.TargetMaxLife) - mob.maxLife = update.TargetMaxLife; - if (update.TargetLife >= 0 && mob.life > 0) - mob.life = System.Math.Clamp(update.TargetLife, 1, mob.maxLife > 0 ? mob.maxLife : update.TargetLife); - } - catch - { - } - continue; - } - if (isHost) TryWakeMobForForcedSimulation(mob); @@ -1558,8 +1598,7 @@ private static void ApplyIncomingMobHits(IReadOnlyList hits, int var hitEv = $"hit|{appliedLife.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; if (TryGetCurrentLevelIdentityToken(out var identityToken)) { - var mobType = BuildMobStateTypeSignature(mob); - var evUpdate = new NetNode.MobEventUpdate(update.SyncId, sx, sy, dir, SingleEvent(hitEv), mobType, identityToken); + var evUpdate = new NetNode.MobEventUpdate(update.SyncId, sx, sy, dir, SingleEvent(hitEv), generation: identityToken); MobSyncTrace.LogSendMobEvents(MobSyncNetRoleForTrace(net), SingleUpdate(evUpdate)); net.SendMobEvents(SingleUpdate(evUpdate)); } @@ -1576,16 +1615,39 @@ private static void TryApplyHostBossFinishingHit(Mob mob, int targetMaxLife) try { - // Do not synthesize an AttackUtils hit with a null hero. Some native/Haxe attack - // cleanup paths dereference controller feedback and can crash with - // "Null access .stopPoweredFeedback". Directly finalize the authoritative host - // death instead; the death packet then drives clients. + var damage = System.Math.Max(1.0, targetMaxLife * 8.0); + var attackUtils = AttackUtils.Class; + var createFromHeroAndHit = attackUtils?.createFromHeroAndHit; + if (createFromHeroAndHit != null) + { + _ = createFromHeroAndHit(null, damage, null, mob); + if (TryFinalizeHostMobDeath(mob)) + return; + } + + var createFromHero = attackUtils?.createFromHero; + var hit = attackUtils?.hit; + if (createFromHero != null && hit != null) + { + var attack = createFromHero(null, damage, null); + if (attack != null) + { + hit(attack, mob); + if (TryFinalizeHostMobDeath(mob)) + return; + } + } + + if (TryFinalizeHostMobDeath(mob)) + return; + + // Last-resort force; some bosses need explicit life zero before onDie branching. mob.life = 0; TryFinalizeHostMobDeath(mob); } catch (Exception ex) { - Log.Warning(ex, "[MobsSync] Host boss finishing death failed"); + Log.Warning(ex, "[MobsSync] Host boss finishing hit replay failed"); } } @@ -1639,15 +1701,65 @@ 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 AI 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; - // Keep this intentionally conservative. Replaying an AttackUtils hit without a real - // local hero can enter native powered-feedback cleanup and crash the game. The - // authoritative life/state sync is enough for multiplayer correctness. - TryWakeMobForForcedSimulation(mob); + // 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(() => + { + var attackUtils = AttackUtils.Class; + var createFromHeroAndHit = attackUtils?.createFromHeroAndHit; + if (createFromHeroAndHit != null) + { + _ = createFromHeroAndHit(null, 1.0, null, mob); + return; + } + + var createFromHero = attackUtils?.createFromHero; + var hit = attackUtils?.hit; + if (createFromHero == null || hit == null) + return; + + var attack = createFromHero(null, 1.0, null); + if (attack != null) + hit(attack, mob); + }); + } + catch (Exception ex) + { + Log.Warning(ex, "[MobsSync] Special incoming mob hit replay failed"); + } } private static void TryWakeMobForForcedSimulation(Mob mob) @@ -1655,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 AI on uninitialized state and crashes. + if (!IsHost(GameMenu.NetRef) && IsMobCulledLocally(mob)) + return; + PromoteMobToSyncVisibleState(mob); } @@ -1685,11 +1803,6 @@ private static bool ShouldSendHostContactPacket(Mob mob, Entity? target) if (registryMob != null && typeMatchesRegistry) { - // The log that was sent with the bug report shows many same-sync-id / same-type - // hits being dropped only because the client and host positions differed by a - // quantized pixel. That is too strict for multiplayer and can prevent the host's - // lethal hit/death confirmation from ever applying on the other player. Treat the - // sync id + type as authoritative; use the position mismatch as a resync warning. if (!MobHitRegistryStillTrustworthyLocked(registryMob, hit)) { MobSyncTrace.LogIncomingMappingMismatch( @@ -1697,108 +1810,119 @@ private static bool ShouldSendHostContactPacket(Mob mob, Entity? target) hit.MobIndex, hit.Type ?? string.Empty, BuildMobStateTypeSignature(registryMob), - "position_mismatch_accepted"); + "position_mismatch"); + return null; } return registryMob; } - var mismatchReason = registryMob == null ? "missing_sync_id" : "type_mismatch"; MobSyncTrace.LogIncomingMappingMismatch( "hit", hit.MobIndex, hit.Type ?? string.Empty, registryMob != null ? BuildMobStateTypeSignature(registryMob) : string.Empty, - mismatchReason); + registryMob == null ? "missing_sync_id" : "type_mismatch"); - if (!string.IsNullOrWhiteSpace(hit.Type) && - TryResolveNearestTypedMobLocked( - hit.MobIndex, - hit.Type, - hit.X, - hit.Y, - MobHitTypedRebindMaxDistancePx, - null, - out var nearestTypedHitMob) && nearestTypedHitMob != null) - { - TryRebindTrackedMobSyncIdLocked(nearestTypedHitMob, hit.MobIndex); - MobSyncTrace.LogBindSyncId("hit_nearest_typed", hit.MobIndex, hit.Type ?? string.Empty, hit.X, hit.Y); - return nearestTypedHitMob; - } + // 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; } - private static bool TryResolveNearestTypedMobLocked( - int syncId, - string? expectedType, - double x, - double y, - double maxDistancePx, - HashSet? reservedMobs, - out Mob? selected) + /// + /// 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) { - selected = null; - if (string.IsNullOrWhiteSpace(expectedType) || trackedMobs.Count == 0) - return false; + if (s_ghostHitMissGeneration != hit.Generation) + { + s_ghostHitMissBySyncId.Clear(); + s_ghostHitMissGeneration = hit.Generation; + } - Mob? best = null; - var bestDistanceSq = double.MaxValue; - var maxDistanceSq = maxDistancePx * maxDistancePx; + 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; + } - for (int i = 0; i < trackedMobs.Count; i++) + 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) { - var candidate = trackedMobs[i]; - if (candidate == null) - continue; - if (reservedMobs != null && reservedMobs.Contains(candidate)) - continue; - if (!IsSyncMob(candidate)) - continue; + if (s_ghostDespawnEchoScratch.Count == 0) + return; - try + if (!isHost || net == null || !net.IsAlive) { - if (candidate.destroyed || candidate._level == null) - continue; - if (!DoesLevelMatchCurrentIdentityLocked(candidate._level)) - continue; - } - catch - { - continue; + s_ghostDespawnEchoScratch.Clear(); + return; } - if (!DoesMobMatchStateType(candidate, expectedType)) - continue; - - var dx = GetWorldX(candidate) - x; - var dy = GetWorldY(candidate) - y; - var distanceSq = dx * dx + dy * dy; - if (distanceSq > maxDistanceSq || distanceSq >= bestDistanceSq) - continue; + toSend = new List(s_ghostDespawnEchoScratch); + s_ghostDespawnEchoScratch.Clear(); + } - bestDistanceSq = distanceSq; - best = candidate; + 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); } - selected = best; - return selected != null; + 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) return false; - - // Some old/combined MOBHIT packets arrive without a mob type. Treating an empty type - // as fully authoritative caused clients to apply hits to the wrong local mob after - // sync-id drift, which is especially dangerous in Fractured Shrines/Tumulus where - // JavelinSnake/Comboter updates were followed by Null access .cx crashes. Empty-type - // hits are now only accepted when the sync id also points to a mob at the same - // quantized position; death packets still have their separate nearest-mob fallback. if (string.IsNullOrWhiteSpace(hit.Type)) - return MobHitRegistryStillTrustworthyLocked(registryMob, hit); - + return true; return DoesMobMatchStateType(registryMob, hit.Type); } @@ -1834,127 +1958,34 @@ private static bool MobHitRegistryStillTrustworthyLocked(Mob mob, NetNode.MobHit if (mob == null) return false; - return MobHitQuantizedPositionCloseEnoughLocked(mob, hit) || - MobHitQuantizedFallbackPositionMatchesLocked(mob, hit); - } - - private static Mob? ResolveMobFromDieLocked(NetNode.MobDie die) - { - lock (Sync) - { - var mob = ResolveMobBySyncIdLocked(die.MobIndex); - if (mob != null) - { - if (string.IsNullOrWhiteSpace(die.Type) || DoesMobMatchStateType(mob, die.Type)) - return mob; - - MobSyncTrace.LogIncomingMappingMismatch( - "die", - die.MobIndex, - die.Type ?? string.Empty, - BuildMobStateTypeSignature(mob), - "type_mismatch"); - InvalidateTrackedSyncCacheLocked(die.MobIndex, "die_type_mismatch"); - } - - // Death packets are progression-critical for elite/rune mobs, but killing the - // wrong local entity can corrupt native mob AI and crash with Null access .cx - // (Clock Tower/Ninja and Fractured Shrines were both hitting this path). v6.2 - // allows nearest fallback only when the packet carries a matching mob type, or - // when an old untyped packet is extremely close to the local mob. - mob = ResolveNearestMobFromDieLocked(die); - if (mob != null) - { - TryRebindTrackedMobSyncIdLocked(mob, die.MobIndex); - MobSyncTrace.LogIncomingMappingMismatch( - "die", - die.MobIndex, - die.Type ?? string.Empty, - BuildMobStateTypeSignature(mob), - "missing_sync_id_fallback_nearest"); - return mob; - } - - MobSyncTrace.LogIncomingMappingMismatch( - "die", - die.MobIndex, - die.Type ?? string.Empty, - string.Empty, - "missing_sync_id_no_fallback"); - - return null; - } - } - - private static Mob? ResolveNearestMobFromDieLocked(NetNode.MobDie die) - { - Mob? best = null; - var bestDistanceSq = double.MaxValue; - var hasTypedDie = !string.IsNullOrWhiteSpace(die.Type); - var firstPassMaxDistance = hasTypedDie ? MobDieFallbackMaxDistancePx : MobDieUntypedFallbackMaxDistancePx; - var maxDistanceSq = firstPassMaxDistance * firstPassMaxDistance; - - for (int i = 0; i < trackedMobs.Count; i++) - TryConsiderMobDieFallbackCandidateLocked(trackedMobs[i], die, maxDistanceSq, ref best, ref bestDistanceSq); - - if (best != null) - return best; + if (MobHitQuantizedPositionCloseEnoughLocked(mob, hit) || + MobHitQuantizedFallbackPositionMatchesLocked(mob, hit)) + return true; - // If the sync-id map is stale, the victim may no longer be in trackedMobs even though - // the current level still owns it. Search live level entities too. This is deliberately - // a second pass with a wider radius: death packets are authoritative and should not leave - // no-HP/no-damage ghosts behind, but we still prefer the normal tracked-map resolution. - var secondPassMaxDistance = hasTypedDie ? MobDieFallbackExtendedMaxDistancePx : MobDieUntypedFallbackExtendedMaxDistancePx; - maxDistanceSq = secondPassMaxDistance * secondPassMaxDistance; + // 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 entities = currentLevel?.entities; - if (entities != null) - { - for (int i = 0; i < entities.length; i++) - TryConsiderMobDieFallbackCandidateLocked(entities.getDyn(i) as Mob, die, maxDistanceSq, ref best, ref bestDistanceSq); - } + 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 best; + return false; } - private static void TryConsiderMobDieFallbackCandidateLocked( - Mob? candidate, - NetNode.MobDie die, - double maxDistanceSq, - ref Mob? best, - ref double bestDistanceSq) + private static Mob? ResolveMobFromDieLocked(NetNode.MobDie die) { - if (candidate == null || !IsSyncMob(candidate)) - return; - - try - { - if (candidate.destroyed || candidate._level == null) - return; - if (!DoesLevelMatchCurrentIdentityLocked(candidate._level)) - return; - } - catch + lock (Sync) { - return; + return ResolveMobBySyncIdLocked(die.MobIndex); } - - if (!string.IsNullOrWhiteSpace(die.Type) && !DoesMobMatchStateType(candidate, die.Type)) - return; - - var dx = GetWorldX(candidate) - die.X; - var dy = GetWorldY(candidate) - die.Y; - var distanceSq = dx * dx + dy * dy; - if (distanceSq > maxDistanceSq || distanceSq >= bestDistanceSq) - return; - - bestDistanceSq = distanceSq; - best = candidate; } private static Mob? ResolveMobBySyncIdLocked(int mobIndex) diff --git a/Mobs/MonsterSynchronization.Constants.cs b/Mobs/MonsterSynchronization.Constants.cs index 2e9a357..ab78241 100644 --- a/Mobs/MonsterSynchronization.Constants.cs +++ b/Mobs/MonsterSynchronization.Constants.cs @@ -12,15 +12,10 @@ public partial class MobsSynchronization private const double HostMobStateMidPositionEpsilon = 1.20; private const double HostMobStateDormantPositionEpsilon = 6.00; private const double MobFallbackMinimumScoreGap = 4.0; - private const double MobDieFallbackMaxDistancePx = 192.0; - private const double MobDieFallbackExtendedMaxDistancePx = 384.0; - private const double MobDieUntypedFallbackMaxDistancePx = 48.0; - private const double MobDieUntypedFallbackExtendedMaxDistancePx = 96.0; - private const double MobHitTypedRebindMaxDistancePx = 256.0; - private const double MobStateTypedRebindMaxDistancePx = 384.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 HostFullMobResyncIntervalSeconds = 2.5; private const double PixelsPerCase = 24.0; private const double ClientNetworkAttackMinActiveFrames = 20.0; private const string ContactAttackPacketSkillId = "@contact"; diff --git a/Mobs/MonsterSynchronization.DirtyQueue.cs b/Mobs/MonsterSynchronization.DirtyQueue.cs index d62a2dc..7f55ab4 100644 --- a/Mobs/MonsterSynchronization.DirtyQueue.cs +++ b/Mobs/MonsterSynchronization.DirtyQueue.cs @@ -326,38 +326,12 @@ private static bool TryDequeuePendingClientDirtyMob(out Mob? mob, out int syncId } private static int s_pendingHostBatchCount; - private static long s_nextHostFullMobResyncTick; - private static void QueueHostFullMobResyncIfDue(NetNode net) + private static void FlushHostDirtyMobQueue(NetNode net) { - if (!IsHost(net)) + if (IsSyncQuiescedForTransition()) return; - var now = System.Diagnostics.Stopwatch.GetTimestamp(); - if (s_nextHostFullMobResyncTick != 0 && now < s_nextHostFullMobResyncTick) - return; - - s_nextHostFullMobResyncTick = now + (long)(System.Diagnostics.Stopwatch.Frequency * HostFullMobResyncIntervalSeconds); - - lock (Sync) - { - PruneInvalidTrackedMobsLocked(); - for (int i = 0; i < trackedMobs.Count; i++) - { - var mob = trackedMobs[i]; - if (mob == null || !IsSyncMob(mob)) - continue; - - if (!MobToId.TryGetValue(mob, out var syncId) || syncId < 0) - continue; - - EnqueueHostMobDirtyLocked(syncId, HostMobDirtyFlags.State | HostMobDirtyFlags.ForceState); - } - } - } - - private static void FlushHostDirtyMobQueue(NetNode net) - { if (!IsHost(net)) return; @@ -529,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.ZeroHpCleanup.cs b/Mobs/MonsterSynchronization.ZeroHpCleanup.cs deleted file mode 100644 index 5f35632..0000000 --- a/Mobs/MonsterSynchronization.ZeroHpCleanup.cs +++ /dev/null @@ -1,59 +0,0 @@ -using dc.en; -using DeadCellsMultiplayerMod.Mobs.Bosses; - -namespace DeadCellsMultiplayerMod.Mobs.MobsSynchronization -{ - public partial class MobsSynchronization - { - /// - /// v6.4.5: finalize non-boss mobs that reached zero HP but were left as live entities. - /// This can happen after client lethal-hit requests, revive-state network floods, or stale - /// host snapshots where the HP bar reaches zero before vanilla onDie finishes. - /// - private static void TryFinalizeNonBossZeroLifeMob(Mob? mob, string context) - { - if (mob == null) - return; - - try - { - if (mob.destroyed) - return; - if (BossSyncHelpers.IsBossMob(mob)) - return; - if (mob.life > 0) - return; - } - catch - { - return; - } - - try - { - RunWithSuppressedMobDieSend(() => - { - try { mob.life = 0; } catch { } - try { mob.onDie(); } catch { } - }); - } - catch - { - } - - try { mob._targetable = false; } catch { } - try { mob.isOutOfGame = true; } catch { } - - try - { - lock (Sync) - { - RemoveTrackedMobLocked(mob); - } - } - catch - { - } - } - } -} diff --git a/Mobs/MonsterSynchronization.cs b/Mobs/MonsterSynchronization.cs index 71592d5..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; @@ -371,7 +432,6 @@ void IOnFrameUpdate.OnFrameUpdate(double dt) RuntimeHitchWatch.LogSlow(modEntry.Logger, "MobsSynchronization.HostConsume", consumeMs, BuildRuntimeQueueDetails()); var flushStart = RuntimeHitchWatch.Start(); - QueueHostFullMobResyncIfDue(net); FlushHostDirtyMobQueue(net); var flushMs = RuntimeHitchWatch.GetElapsedMilliseconds(flushStart); if (flushMs >= RuntimeHitchWatch.MobSyncFlushSlowThresholdMs) @@ -775,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; @@ -933,8 +984,9 @@ private void Hook_Mob_postUpdate(Hook_Mob.orig_postUpdate orig, Mob self) orig(self); if (IsClient(net) && IsSyncMob(self)) { - TryFinalizeNonBossZeroLifeMob(self, "client_post_update_zero_hp"); - try { if (self.destroyed) return; } catch { return; } + if (TryRunPendingCulledMobDeath(self)) + return; + ObserveClientMobForDirtyQueue(self); ApplyClientAnimationStateBeforeUpdate(self); TryRepairClientMobAttackTarget(self); @@ -946,8 +998,6 @@ private void Hook_Mob_postUpdate(Hook_Mob.orig_postUpdate orig, Mob self) orig(self); if (IsSyncMob(self)) { - TryFinalizeNonBossZeroLifeMob(self, "host_post_update_zero_hp"); - try { if (self.destroyed) return; } catch { return; } ObserveHostMobForDirtyQueue(self); TryAssignHostAttackTarget(self); } @@ -972,12 +1022,9 @@ private static void Hook_Mob_onDie(Hook_Mob.orig_onDie orig, Mob self) dieNet = GameMenu.NetRef; isClient = IsClient(dieNet); - // Client is not authoritative for mob death, but it must still send a reliable - // kill request. Otherwise the client's local mob can sit at an empty HP bar until - // the host also hits it. The host validates sync id + type + position before applying. + // Client is not authoritative for mob death; wait for host die/hit confirmation. if (isClient && IsSyncMob(self)) { - TrySendClientMobKillRequest(self, dieNet); try { if (self.life <= 0) @@ -1012,8 +1059,7 @@ private static void Hook_Mob_onDie(Hook_Mob.orig_onDie orig, Mob self) { if (TryGetCurrentLevelIdentityToken(out var identityToken)) { - var dieType = BuildMobStateTypeSignature(self); - var update = new NetNode.MobEventUpdate(dieSyncId, dieX, dieY, 0, SingleEvent("die"), dieType, identityToken); + var update = new NetNode.MobEventUpdate(dieSyncId, dieX, dieY, 0, SingleEvent("die"), generation: identityToken); MobSyncTrace.LogSendMobEvents(MobSyncNetRoleForTrace(dieNet), SingleUpdate(update)); dieNet.SendMobEvents(SingleUpdate(update)); } @@ -1025,29 +1071,6 @@ private static void Hook_Mob_onDie(Hook_Mob.orig_onDie orig, Mob self) } } - private static void TrySendClientMobKillRequest(Mob self, NetNode? net) - { - if (self == null || net == null || !net.IsAlive || !IsClient(net)) - return; - if (!IsSyncMob(self)) - return; - if (!TryGetMobSyncId(self, out var syncId) || syncId < 0) - return; - - try - { - var x = GetSyncX(self); - var y = GetSyncY(self); - var type = BuildMobStateTypeSignature(self); - var generation = TryGetCurrentLevelIdentityToken(out var token) ? token : 0; - net.SendMobDie(syncId, x, y, type, generation); - clientLastReportedMobLife[self] = 0; - } - catch - { - } - } - private static void RunWithSuppressedMobDieSend(Action action) { if (action == null) @@ -1080,25 +1103,6 @@ private static void RunWithSuppressedMobHitSend(Action action) } } - private static bool IsRecoverableMobDamageCrash(Exception ex) - { - if (ex == null) - return false; - - var text = ex.ToString() ?? string.Empty; - if (!text.Contains("Null access", StringComparison.OrdinalIgnoreCase)) - return false; - - return text.Contains("Mob.onDamage", StringComparison.OrdinalIgnoreCase) || - text.Contains("Mob.applyAttackResult", StringComparison.OrdinalIgnoreCase) || - text.Contains("AttackUtils.hit", StringComparison.OrdinalIgnoreCase) || - text.Contains("AttackTargetImpl.applyHit", StringComparison.OrdinalIgnoreCase) || - text.Contains("DiveAttack.onOwnerLand", StringComparison.OrdinalIgnoreCase) || - text.Contains("Null access .commonProps", StringComparison.OrdinalIgnoreCase) || - text.Contains("Null access .cx", StringComparison.OrdinalIgnoreCase) || - text.Contains("Null access .lockAiS", StringComparison.OrdinalIgnoreCase); - } - private void Hook_Mob_onDamage(Hook_Mob.orig_onDamage orig, Mob self, AttackData i) { var preDamageLife = GetMobLifeOrFallback(self, 0); @@ -1111,33 +1115,7 @@ private void Hook_Mob_onDamage(Hook_Mob.orig_onDamage orig, Mob self, AttackData preSyncOk = TryGetMobSyncId(self, out cachedMobSyncId); } - try - { - orig(self, i); - } - catch (Exception ex) when (IsRecoverableMobDamageCrash(ex)) - { - // If vanilla Dead Cells tries to damage a half-disposed/desynced mob, keep - // the multiplayer run alive. This can happen after a stale sync id or DLC - // room transition leaves an invalid mob in the hit list. - Log.Warning(ex, "[MobSync] Suppressed recoverable Mob.onDamage crash and removed mob from sync tracking"); - try - { - if (self != null) - { - lock (Sync) - { - RemoveTrackedMobLocked(self); - } - - try { self._targetable = false; } catch { } - } - } - catch - { - } - return; - } + orig(self, i); try { diff --git a/ModEntry/ModEntry.BossCine.cs b/ModEntry/ModEntry.BossCine.cs index 0a328e2..9ca9a01 100644 --- a/ModEntry/ModEntry.BossCine.cs +++ b/ModEntry/ModEntry.BossCine.cs @@ -907,10 +907,10 @@ private void SuppressRemoteBossDeathCineIfNeeded() return; var typeName = cine.GetType().Name ?? string.Empty; - if (!IsBossDeathCinematicName(typeName)) + if (!BossDeathCineTypeNames.Contains(typeName)) return; - ObserveRemoteBossDeathCineState(cine); + SuppressRemoteBossDeathCineState(cine); } catch { @@ -919,274 +919,26 @@ private void SuppressRemoteBossDeathCineIfNeeded() private bool ShouldSuppressRemoteBossDeathCineConstruction() { - // v6.0: do not suppress vanilla boss-death cinematics anymore. Suppressing them - // prevented the client from seeing/receiving boss reward-room state and could leave - // the player frozen until the exit failsafe moved them forward. Keep the hooks - // installed for compatibility, but let vanilla run and recover controls afterwards. - return false; - } - - private static bool IsBossDeathCinematicName(string? typeName) - { - if (string.IsNullOrWhiteSpace(typeName)) - return false; - - var trimmed = typeName.Trim(); - if (BossIntroCineTypeNames.Contains(trimmed) || - trimmed.StartsWith("Enter", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("Start", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("Meet", StringComparison.OrdinalIgnoreCase)) - return false; - - if (BossDeathCineTypeNames.Contains(trimmed)) - return true; - - // Covers generated DLC/vanilla names not present in this proxy build, such as - // Concierge/TimeKeeper/Scarecrow/MamaTick-style death cinematics, without catching - // HeroDeath because those are filtered before this method is called. - return typeName.IndexOf("Death", StringComparison.OrdinalIgnoreCase) >= 0 || - typeName.IndexOf("Defeat", StringComparison.OrdinalIgnoreCase) >= 0 || - typeName.IndexOf("Kill", StringComparison.OrdinalIgnoreCase) >= 0; - } - - private void ObserveRemoteBossDeathCineState(dc.GameCinematic? cine) - { - // Do not destroy/dispose the cine here: vanilla needs it to spawn boss rewards, unlock - // doors, and finish reward-room state. We only schedule an unstuck pass after a short - // grace delay, so the cinematic/reward setup has time to run first. - MarkBossVictoryRecoveryWindow("boss_death_cine_observed", 24.0, 3.0); - } - - private void TryScheduleBossVictoryRecoveryFromBossDeath() - { - if (_netRole != NetRole.Client || _net == null || !_net.IsAlive) - return; - - var currentLevelId = GetCurrentLevelId(); - if (!IsBossLevel(currentLevelId)) - return; - - if (_bossVictoryRecoveryUntilTicks != 0) - return; - - var hero = me ?? ModCore.Modules.Game.Instance?.HeroInstance; - var level = hero?._level; - if (hero == null || level == null) - return; - - if (!string.Equals(_bossDeathPollLevelId, currentLevelId, StringComparison.OrdinalIgnoreCase)) - { - _bossDeathPollLevelId = currentLevelId; - _bossDeathPollSawLiveBoss = false; - _bossDeathPollGoneSinceTicks = 0; - } - - if (TryGetCurrentBossDeathCinematicName(out _)) - { - MarkBossVictoryRecoveryWindow("boss_death_cine_polling_fallback", 45.0, 2.0); - return; - } - - var hasBossObject = false; - var bossAlive = false; - try - { - var boss = level.boss; - if (boss != null) - { - hasBossObject = true; - bossAlive = !boss.destroyed && boss.life > 0; - } - } - catch - { - } - - if (bossAlive) - { - _bossDeathPollSawLiveBoss = true; - _bossDeathPollGoneSinceTicks = 0; - return; - } - - if (!_bossDeathPollSawLiveBoss) - return; - - var now = Stopwatch.GetTimestamp(); - if (_bossDeathPollGoneSinceTicks == 0) - { - _bossDeathPollGoneSinceTicks = now; - return; - } - - if (now - _bossDeathPollGoneSinceTicks < (long)(Stopwatch.Frequency * (hasBossObject ? 0.25 : 0.75))) - return; - - MarkBossVictoryRecoveryWindow("boss_dead_polling_fallback", 45.0, 2.0); - } - - private bool TryGetCurrentBossDeathCinematicName(out string typeName) - { - typeName = string.Empty; - try - { - var game = dc.pr.Game.Class.ME; - var cine = game?.curCine; - if (cine == null || cine.destroyed) - return false; - - if (cine is DeadBase || cine is RemoteDownedCorpse) - return false; - if (cine is HeroDeath || cine is HeroDeathBase || cine is HeroDeathContinue || - cine is HeroDeathRespawn || cine is HeroDeathDLCP) - return false; - - typeName = cine.GetType().Name ?? string.Empty; - return IsBossDeathCinematicName(typeName); - } - catch - { - return false; - } + return _netRole == NetRole.Client && _net != null && _net.IsAlive; } private void SuppressRemoteBossDeathCineState(dc.GameCinematic? cine) { - // Kept as a compatibility shim for existing hook methods. In v6.0 this no longer - // suppresses the cine; it observes it and lets vanilla reward logic continue. - ObserveRemoteBossDeathCineState(cine); - } - - private void MarkBossVictoryRecoveryWindow(string reason, double seconds) - { - MarkBossVictoryRecoveryWindow(reason, seconds, 0.0); - } - - private void MarkBossVictoryRecoveryWindow(string reason, double seconds, double delaySeconds) - { - var now = Stopwatch.GetTimestamp(); - var durationTicks = (long)(Stopwatch.Frequency * System.Math.Max(1.0, seconds)); - var delayTicks = (long)(Stopwatch.Frequency * System.Math.Max(0.0, delaySeconds)); - _bossVictoryRecoveryUntilTicks = now + durationTicks; - _bossVictoryRecoveryUnlockAfterTicks = now + delayTicks; - _nextBossVictoryRecoveryTick = now + delayTicks; - _bossVictoryRecoveryReason = string.IsNullOrWhiteSpace(reason) ? "boss_victory" : reason.Trim(); - } - - private void MaintainBossVictoryUnstuckRecovery() - { - if (_bossVictoryRecoveryUntilTicks == 0) - return; - - var now = Stopwatch.GetTimestamp(); - if (now > _bossVictoryRecoveryUntilTicks) - { - _bossVictoryRecoveryUntilTicks = 0; - _bossVictoryRecoveryUnlockAfterTicks = 0; - _nextBossVictoryRecoveryTick = 0; - _bossVictoryRecoveryReason = string.Empty; - return; - } - - if (_bossVictoryRecoveryUnlockAfterTicks != 0 && now < _bossVictoryRecoveryUnlockAfterTicks) - return; - - if (now < _nextBossVictoryRecoveryTick) - return; - - _nextBossVictoryRecoveryTick = now + (long)(Stopwatch.Frequency * 0.35); - RecoverLocalHeroAfterBossVictory(string.IsNullOrWhiteSpace(_bossVictoryRecoveryReason) - ? "boss_victory_recovery_window" - : _bossVictoryRecoveryReason); - } - - private bool ShouldAllowBossVictoryRecoveryAtLevel(string? currentLevelId) - { - if (IsBossLevel(currentLevelId)) - return true; - - // Boss death often moves the client into a reward/transition room before controls are - // fully released. Do not cancel an active boss-recovery window just because the level - // id changed from e.g. Bridge/Throne to a T_* transition area. - return _bossVictoryRecoveryUntilTicks != 0; - } - - private void RecoverLocalHeroAfterBossVictory(string reason) - { - if (_netRole == NetRole.None || _net == null || !_net.IsAlive) - return; - - var hero = me ?? ModCore.Modules.Game.Instance?.HeroInstance; - if (hero == null) - return; - - var currentLevelId = GetCurrentLevelId(); - if (!ShouldAllowBossVictoryRecoveryAtLevel(currentLevelId)) - return; - try { var game = dc.pr.Game.Class.ME; - var cine = game?.curCine; - if (cine is HeroDeath || cine is HeroDeathBase || cine is HeroDeathContinue || - cine is HeroDeathRespawn || cine is HeroDeathDLCP) - { - game!.curCine = null; - try { cine.destroy(); } catch { } - try { cine.disposeImmediately(); } catch { } - } - } - catch - { - } - - if (_localFakeDead) - { - ResetFakeDeathState( - unlockLocalHero: true, - sendNetworkUpState: true, - clearRemoteDownedTracking: false, - clearDownedAnnouncements: false); - } - - try - { - if (hero.life <= 0) - hero.life = 1; + if (game != null && cine != null && ReferenceEquals(game.curCine, cine)) + game.curCine = null; } catch { } - try { StopLocalDeadCine(); } catch { } - if (IsHeroRuntimeSafeForControlUnlock(hero)) - { - try { hero.cancelVelocities(); } catch { } - try { hero.cancelSkillControlLock(); } catch { } - try { hero.unlockControls(); } catch { } - try { hero._targetable = true; } catch { } - } - - try - { - var data = hero._level?.game?.data; - if (data != null) - data.stopGameTime = false; - } - catch - { - } - - _postReviveLockUntilTicks = 0; - _nextDownedStateSendTicks = 0; - - try - { - Logger.Information("[BossSync] Applied local boss-victory unstuck recovery reason={Reason} level={LevelId}", reason, currentLevelId); - } - catch - { - } + try { cine?.destroy(); } catch { } + try { cine?.disposeImmediately(); } catch { } + try { me?.cancelSkillControlLock(); } catch { } + try { me?.unlockControls(); } catch { } + EnsureHeroVisibilityAfterRoomChange(me); } private void Hook__BeholderDeath__constructor__(Hook__BeholderDeath.orig___constructor__ orig, BeholderDeath e, Beholder boss) diff --git a/ModEntry/ModEntry.DebugHero.cs b/ModEntry/ModEntry.DebugHero.cs index f7f385a..ee75d96 100644 --- a/ModEntry/ModEntry.DebugHero.cs +++ b/ModEntry/ModEntry.DebugHero.cs @@ -44,35 +44,53 @@ 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 static bool s_debugStartPerkDisabledNoticeLogged; - private void TryApplyDebugStartPerk(Hero hero) { - // Stability v5.5: disable the optional debug start-perk injection completely. - // - // The uploaded crash logs show repeated Hashlink crashes from - // new InventItem(new InventItemKind.Perk("P_Necromancy")) while Dead Cells is - // still initializing / while combat is running. Even when caught in C#, that - // Hashlink exception can poison the HL runtime and later crash the run with - // Null access .commonProps. This feature is only a debug convenience, so the - // safest multiplayer behavior is to never create InventItem perks from the - // mod at runtime. Runes/minimap debug options are left intact below. - _debugPerkAppliedHero = null; - _debugPerkAppliedId = string.Empty; - _nextDebugPerkApplyTick = 0; + // Disabled intentionally. Do not construct InventItem from debug code. + return; +#pragma warning disable CS0162 + if (hero == null) + return; var configuredPerkId = MultiplayerSettingsStorage.DebugStartPerkId; - if (!s_debugStartPerkDisabledNoticeLogged && - !string.IsNullOrWhiteSpace(configuredPerkId) && - !string.Equals(configuredPerkId, MultiplayerSettingsStorage.NoStartPerkValue, StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrWhiteSpace(configuredPerkId) || + string.Equals(configuredPerkId, MultiplayerSettingsStorage.NoStartPerkValue, StringComparison.OrdinalIgnoreCase)) + { + _debugPerkAppliedHero = null; + _debugPerkAppliedId = string.Empty; + _nextDebugPerkApplyTick = 0; + return; + } + + var perkId = configuredPerkId.Trim(); + if (ReferenceEquals(_debugPerkAppliedHero, hero) && + string.Equals(_debugPerkAppliedId, perkId, StringComparison.Ordinal)) { - s_debugStartPerkDisabledNoticeLogged = true; - Logger.Warning("[NetMod][Stability] Debug start perk {PerkId} is disabled in v5.5 to prevent InventItem.commonProps crashes.", configuredPerkId.Trim()); + return; } + + var now = Stopwatch.GetTimestamp(); + if (_nextDebugPerkApplyTick != 0 && now < _nextDebugPerkApplyTick) + return; + + var item = new InventItem(new InventItemKind.Perk(perkId.AsHaxeString())); + hero.applyItemPickEffect(hero, item); + + if (string.Equals(perkId, "P_Yolo", StringComparison.OrdinalIgnoreCase)) + hero.tryToApplyYoloPerk(); + + _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 bbfed94..058fed2 100644 --- a/ModEntry/ModEntry.GhostSync.cs +++ b/ModEntry/ModEntry.GhostSync.cs @@ -348,9 +348,6 @@ private void SendHeroCoords() public static double[] rLastX = new double[NetNode.MaxClientSlots]; public static double[] rLastY = new double[NetNode.MaxClientSlots]; - public static double[] EmergencyLastRemoteX = new double[NetNode.MaxClientSlots]; - public static double[] EmergencyLastRemoteY = new double[NetNode.MaxClientSlots]; - public static long[] EmergencyLastRemoteTicks = new long[NetNode.MaxClientSlots]; internal static bool TryGetClientIndex(int localId, int remoteId, out int index) { @@ -382,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); @@ -408,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); } @@ -419,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; @@ -499,9 +500,6 @@ private void ReceiveGhostCoords() remotePlayerId = remote.Id; clientIds[index] = remote.Id; - EmergencyLastRemoteX[index] = remote.X; - EmergencyLastRemoteY[index] = remote.Y; - EmergencyLastRemoteTicks[index] = Stopwatch.GetTimestamp(); ProcessRemoteDoorMarker(remote); if (!ShouldKeepRemoteKingVisibleInRoom(remote, localLevelId)) { @@ -684,31 +682,12 @@ private bool ShouldKeepRemoteKingVisibleInRoom(NetNode.RemoteSnapshot remote, st if (!string.Equals(remoteContextLevelId, localContextLevelId, StringComparison.Ordinal)) return false; - // Same main level should keep the remote visible even if a stale door/room marker is - // temporarily different after forced exit assist or teleporter transitions. Only use - // the room-token hide when the local player is actually inside a sublevel/branch. - if (!IsLocalHeroInSubLevel()) - return true; - if (remote.RoomId.Value != localBranchToken) return false; return true; } - private static bool IsLocalHeroInSubLevel() - { - try - { - var level = me?._level ?? ModCore.Modules.Game.Instance?.HeroInstance?._level; - return level != null && level.isSubLevel; - } - catch - { - return false; - } - } - private void ProcessRemoteDoorMarker(NetNode.RemoteSnapshot remote) { if (!remote.HasRoom || @@ -774,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; @@ -848,8 +831,6 @@ private void DisposeClientSlot(int slot, bool clearIdentity) clientLastDownedOffsets[slot] = false; rLastX[slot] = 0; rLastY[slot] = 0; - // Do not clear EmergencyLastRemoteX/Y here. F8 recovery needs the last known - // network position even when the visible ghost was hidden by a room/sublevel mismatch. if (!clearIdentity) return; @@ -866,15 +847,46 @@ private void DisposeClientSlot(int slot, bool clearIdentity) private void ReceiveGhostWeapons() { - // v5.7: stability mode. Do not create/equip the remote player's real weapons on - // the ghost. Heavy/charged weapons such as Flint can start vanilla powered-feedback - // cleanup on the receiving client, where the ghost has no real local controller, - // causing Hashlink "Null access .stopPoweredFeedback" crashes. + var hitchStart = RuntimeHitchWatch.Start(); var net = _net; - if (net == null) return; + if (net == null || me == null) return; + + if (!net.TryConsumeRemoteWeaponSnapshots(out var updates)) + return; + + try + { + var applied = 0; + + foreach (var update in updates) + { + var updateStart = RuntimeHitchWatch.Start(); + ApplyRemoteWeaponUpdate(update.Id, update.Kind, update.Slot, update.PermanentId, update.Ammo); + applied++; + LogGhostRuntimeStepIfSlow( + "ModEntry.ReceiveGhostWeapons.ApplyRemoteWeaponUpdate", + updateStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"remoteId={update.Id} slot={update.Slot} permanentId={update.PermanentId} ammo={(update.Ammo.HasValue ? update.Ammo.Value : -1)}")); + } - if (net.TryConsumeRemoteWeaponSnapshots(out var updates)) + var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); + if (hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) + { + RuntimeHitchWatch.LogSlow( + Logger, + "ModEntry.ReceiveGhostWeapons", + hitchMs, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"updates={updates.Count} applied={applied}")); + } + } + finally + { NetNode.ReleaseConsumedList(updates); + } } private void DrainRemoteCombatQueuesAfterLevelChange() @@ -891,20 +903,108 @@ private void DrainRemoteCombatQueuesAfterLevelChange() private void ReceiveGhostAttacks() { - // v5.7: stability mode. Drain remote attack packets but do not replay them on the - // ghost weapon manager. The host's mob HP/death packets still drive progression; - // this only removes client-side visual attack simulation that crashes with Flint. + var hitchStart = RuntimeHitchWatch.Start(); var net = _net; - if (net == null) return; + if (net == null || me == null) return; - if (net.TryConsumeRemoteAttacks(out var attacks)) + if (!net.TryConsumeRemoteAttacks(out var attacks)) + return; + + try + { + var localId = net.id; + var diveHandled = 0; + var queuedAttacks = 0; + foreach (var attack in attacks) + { + var attackStart = RuntimeHitchWatch.Start(); + if (TryHandleRemoteDiveAttack(attack, localId)) + { + diveHandled++; + LogGhostRuntimeStepIfSlow( + "ModEntry.ReceiveGhostAttacks.Remote", + attackStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"remoteId={attack.Id} slot={attack.Slot} dive=1 action={attack.Action}")); + continue; + } + + if (attack.Slot < 0 && + (string.IsNullOrWhiteSpace(attack.Kind) || + attack.Kind.StartsWith("__", StringComparison.Ordinal))) + { + continue; + } + + ApplyRemoteWeaponUpdate(attack.Id, attack.Kind, attack.Slot, attack.PermanentId, attack.Ammo); + if (!TryGetClientIndex(localId, attack.Id, out var index)) + continue; + + var client = clients[index]; + if (client?.kingWeaponsManager == null) continue; + if (attack.Action == RemoteAttackAction.Interrupt) + client.kingWeaponsManager.queueInterrupt(attack.Slot); + else + client.kingWeaponsManager.queueAttack(attack.Slot); + + queuedAttacks++; + LogGhostRuntimeStepIfSlow( + "ModEntry.ReceiveGhostAttacks.Remote", + attackStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"remoteId={attack.Id} slot={attack.Slot} dive=0 action={attack.Action} kind={attack.Kind ?? string.Empty}")); + } + + var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); + if (hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) + { + RuntimeHitchWatch.LogSlow( + Logger, + "ModEntry.ReceiveGhostAttacks", + hitchMs, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"attacks={attacks.Count} diveHandled={diveHandled} queuedAttacks={queuedAttacks}")); + } + } + finally + { NetNode.ReleaseConsumedList(attacks); + } } private void UpdateGhostWeapons() { - // v5.7: stability mode. Do not tick remote ghost weapon managers. Their internal - // weapon feedback state is not safe for non-local ghosts in the current DCCM build. + var hitchStart = RuntimeHitchWatch.Start(); + var activeManagers = 0; + for (int i = 0; i < clients.Length; i++) + { + var client = clients[i]; + if (client?.kingWeaponsManager == null) continue; + activeManagers++; + var managerStart = RuntimeHitchWatch.Start(); + client.kingWeaponsManager.update(); + LogGhostRuntimeStepIfSlow( + "ModEntry.UpdateGhostWeapons.Manager", + managerStart, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"slot={i} remoteId={clientIds[i]} shield={(client.kingWeaponsManager.IsShieldActive ? 1 : 0)}")); + } + + var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); + if (hitchMs >= RuntimeHitchWatch.GhostRuntimeSlowThresholdMs) + { + RuntimeHitchWatch.LogSlow( + Logger, + "ModEntry.UpdateGhostWeapons", + hitchMs, + string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"activeManagers={activeManagers} clients={clients.Length}")); + } } private static int CountPendingClientHeadRecreate() @@ -932,62 +1032,58 @@ private void PlayGhostAnim(GhostKing client, string anim, int? queueAnim, bool? { if (client?.spr?._animManager == null) return; if (string.IsNullOrWhiteSpace(anim)) return; - if (!IsSafeNetworkHeroAnim(anim)) return; + var shieldActive = client.kingWeaponsManager != null && client.kingWeaponsManager.IsShieldActive; + if (shieldActive && ShouldLoopRemoteAnim(anim)) + { + return; + } + + if (anim.IndexOf("hold", StringComparison.OrdinalIgnoreCase) >= 0 || + anim.IndexOf("shield", StringComparison.OrdinalIgnoreCase) >= 0 || + anim.IndexOf("parry", StringComparison.OrdinalIgnoreCase) >= 0 || + anim.IndexOf("block", StringComparison.OrdinalIgnoreCase) >= 0) + return; var animManager = client.spr._animManager; var current = client.spr.groupName; - if (current != null && string.Equals(current.ToString(), anim, StringComparison.Ordinal)) + if(current != null && string.Equals(current.ToString(), anim, StringComparison.Ordinal)) return; - try + if (ShouldLoopRemoteAnim(anim)) { - if (ShouldLoopRemoteAnim(anim)) + if (!shieldActive) { client.removeAllAffects(96); client.removeAllAffects(98); client.removeAllAffects(99); - animManager.play(anim.AsHaxeString(), null, null).loop(null); - return; } - - // Visual-only: play the remote hero animation, but do not replay the weapon/action - // object itself. This is what keeps Flint and powered-feedback weapons stable. - animManager.play(anim.AsHaxeString(), queueAnim, g).stopOnLastFrame(Ref.Null); - } - catch (Exception ex) - { - Logger.Warning(ex, "[GhostSync] Remote visual animation failed anim={Anim}", anim); + animManager.play(anim.AsHaxeString(), null, null).loop(null); + return; } + animManager.play(anim.AsHaxeString(), queueAnim, g).stopOnLastFrame(Ref.Null); } private static bool ShouldLoopRemoteAnim(string anim) { - if (string.IsNullOrWhiteSpace(anim)) return false; + if(string.IsNullOrWhiteSpace(anim)) return false; var a = anim.Trim(); - // v5.9: loop only true locomotion/idle animations. One-shot actions such as - // attacks, bow shots, healing, scroll pickup, talking, teleporter use, ladders, - // exits, ground-pound and doors must play once; looping them made movement/level - // transitions look wrong. - if (IsAttackAnim(a)) return false; - if (a.IndexOf("heal", StringComparison.OrdinalIgnoreCase) >= 0) return false; - if (a.IndexOf("potion", StringComparison.OrdinalIgnoreCase) >= 0) return false; - if (a.IndexOf("scroll", StringComparison.OrdinalIgnoreCase) >= 0) return false; - if (a.IndexOf("talk", StringComparison.OrdinalIgnoreCase) >= 0) return false; - if (a.IndexOf("teleport", StringComparison.OrdinalIgnoreCase) >= 0) return false; - if (a.IndexOf("door", StringComparison.OrdinalIgnoreCase) >= 0) return false; - if (a.IndexOf("ground", StringComparison.OrdinalIgnoreCase) >= 0) return false; - if (a.IndexOf("bound", StringComparison.OrdinalIgnoreCase) >= 0) return false; - if (a.IndexOf("ladder", StringComparison.OrdinalIgnoreCase) >= 0) return false; - if (a.IndexOf("climb", StringComparison.OrdinalIgnoreCase) >= 0) return false; - if (a.IndexOf("stair", StringComparison.OrdinalIgnoreCase) >= 0) return false; - if (a.IndexOf("exit", StringComparison.OrdinalIgnoreCase) >= 0) return false; + // Don't ever force-loop weapon/hold-ish states; those should be driven by weapon replication. + if(IsAttackAnim(a)) return false; + if(a.IndexOf("guard", StringComparison.OrdinalIgnoreCase) >= 0) return false; + if(a.IndexOf("defend", StringComparison.OrdinalIgnoreCase) >= 0) return false; if (a.StartsWith("idle", StringComparison.OrdinalIgnoreCase)) return true; if (a.StartsWith("run", StringComparison.OrdinalIgnoreCase)) return true; if (a.StartsWith("walk", StringComparison.OrdinalIgnoreCase)) return true; if (a.IndexOf("move", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (a.IndexOf("jump", StringComparison.OrdinalIgnoreCase) >= 0) return true; if (a.IndexOf("fall", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (a.IndexOf("land", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (a.IndexOf("climb", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (a.IndexOf("ladder", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (a.IndexOf("crouch", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (a.IndexOf("volte", StringComparison.OrdinalIgnoreCase) >= 0) return true; if (a.IndexOf("remain", StringComparison.OrdinalIgnoreCase) >= 0) return true; return false; @@ -997,18 +1093,9 @@ private void PlayGhostHeadAnim(GhostKing client, string anim) { if (client == null || client?.head == null || client?.head?.customHeadSpr._animManager == null) return; if (string.IsNullOrWhiteSpace(anim)) return; - if (!IsSafeNetworkHeroAnim(anim)) return; - - try - { - var animManager = client.head.customHeadSpr._animManager; - animManager.play(anim.AsHaxeString(), null, null).loop(null); - animManager.genSpeed = 0.4; - } - catch (Exception ex) - { - Logger.Warning(ex, "[GhostSync] Remote head animation failed anim={Anim}", anim); - } + var animManager = client.head.customHeadSpr._animManager; + animManager.play(anim.AsHaxeString(), null, null).loop(null); + animManager.genSpeed = 0.4; } private void SendHeroAnim(string anim, int? queueAnim, bool? g, bool force = false) @@ -1050,7 +1137,6 @@ private void SendEquippedWeapons(Inventory inv) private void SendInventoryWeapon(InventItem item, int slot) { - if (RemoteWeaponVisualSyncDisabled()) return; if (_netRole == NetRole.None) return; if (item == null) return; if (!TryGetWeaponKindId(item, out var kindId)) return; @@ -1072,12 +1158,6 @@ private static bool TryGetWeaponKindId(InventItem item, out string? kindId) return false; } - private static bool RemoteWeaponVisualSyncDisabled() - { - // Method instead of a const so the compiler does not mark the fallback body unreachable. - return true; - } - private static int? GetWeaponAmmoForSync(InventItem? item) { if(item == null) @@ -1111,11 +1191,6 @@ private bool IsLocalInventory(Inventory self) private void ApplyRemoteWeaponUpdate(int remoteId, string? kindId, int slot, int permanentId, int? ammo = null) { - // v5.7: disabled for stability. Creating/equipping remote InventItem weapons on - // ghost heroes can trigger Hashlink powered-feedback cleanup crashes with Flint. - if (RemoteWeaponVisualSyncDisabled()) - return; - var hitchStart = RuntimeHitchWatch.Start(); if (string.IsNullOrWhiteSpace(kindId)) return; var net = _net; diff --git a/ModEntry/ModEntry.KingSkinRenderSafety.cs b/ModEntry/ModEntry.KingSkinRenderSafety.cs new file mode 100644 index 0000000..f9b2864 --- /dev/null +++ b/ModEntry/ModEntry.KingSkinRenderSafety.cs @@ -0,0 +1,377 @@ +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; + + 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(); + 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.NetworkMenu.cs b/ModEntry/ModEntry.NetworkMenu.cs index 5aae0b3..df97974 100644 --- a/ModEntry/ModEntry.NetworkMenu.cs +++ b/ModEntry/ModEntry.NetworkMenu.cs @@ -39,7 +39,6 @@ public void StartSteamClientFromMenu(ulong hostSteamId) private void StartHostCore(Action createHost) { - s_isDisposing = false; _net?.Dispose(); ResetNetworkState(); createHost(); @@ -60,7 +59,6 @@ private void StartHostWithEndpoint(IPEndPoint ep) private void StartClientCore(Action createClient) { - s_isDisposing = false; _net?.Dispose(); var main = dc.Main.Class.ME; if (main?.user != null) @@ -103,7 +101,6 @@ private void StartClientWithSteamTransport(ulong hostSteamId) public void StopNetworkFromMenu() { - StopSteamCallbackPumpTimer(); var roleBeforeStop = _netRole; if (roleBeforeStop == NetRole.Client) { diff --git a/ModEntry/ModEntry.StabilityGuards.cs b/ModEntry/ModEntry.StabilityGuards.cs deleted file mode 100644 index f3f430f..0000000 --- a/ModEntry/ModEntry.StabilityGuards.cs +++ /dev/null @@ -1,19 +0,0 @@ -using dc.en.inter; - -namespace DeadCellsMultiplayerMod -{ - public partial class ModEntry - { - // v5.6 rollback: v5.5's live level-quad mutation was too aggressive and could - // destabilize Hashlink GC/native state. Keep these methods as safe no-ops so older - // ModEntry.cs call sites compile, but do not mutate level entity/quad lists. - private void RunFrameStabilityGuards() - { - } - - private void Hook_HiddenTrigger_fixedUpdate(Hook_HiddenTrigger.orig_fixedUpdate orig, HiddenTrigger self) - { - orig?.Invoke(self); - } - } -} diff --git a/ModEntry/ModEntry.Steam.cs b/ModEntry/ModEntry.Steam.cs index 3343af8..66881b4 100644 --- a/ModEntry/ModEntry.Steam.cs +++ b/ModEntry/ModEntry.Steam.cs @@ -62,56 +62,46 @@ private static void OnGameLobbyJoinRequested(GameLobbyJoinRequested_t data) WriteOverlayJoinDiagnostic("GameLobbyJoinRequested_t", data.m_steamIDLobby.m_SteamID.ToString()); Instance?.Logger.Information("[NetMod][Steam] GameLobbyJoinRequested_t callback fired"); var lobbyId = data.m_steamIDLobby.m_SteamID; - var friendSteamId = data.m_steamIDFriend.m_SteamID; - if (lobbyId == 0UL && friendSteamId == 0UL) + if (lobbyId == 0UL) return; - Instance?.Logger.Information("[NetMod][Steam] Overlay lobby join requested lobbyId={LobbyId} friendSteamId={FriendSteamId}", lobbyId, friendSteamId); - EnqueueAndProcessOverlayJoin(lobbyId, "GameLobbyJoinRequested_t", friendSteamId); + Instance?.Logger.Information("[NetMod][Steam] Overlay lobby join requested lobbyId={LobbyId}", lobbyId); + EnqueueAndProcessOverlayJoin(lobbyId, "GameLobbyJoinRequested_t"); } private static void OnGameRichPresenceJoinRequested(GameRichPresenceJoinRequested_t data) { var connect = data.m_rgchConnect ?? string.Empty; - var friendSteamId = data.m_steamIDFriend.m_SteamID; WriteOverlayJoinDiagnostic("GameRichPresenceJoinRequested_t", connect); - Instance?.Logger.Information("[NetMod][Steam] GameRichPresenceJoinRequested_t callback fired friendSteamId={FriendSteamId}", friendSteamId); + Instance?.Logger.Information("[NetMod][Steam] GameRichPresenceJoinRequested_t callback fired"); if (string.IsNullOrWhiteSpace(connect)) { - if (friendSteamId != 0UL) - { - Instance?.Logger.Information("[NetMod][Steam] Rich Presence join had empty connect string; falling back to direct friend Steam P2P join friendSteamId={FriendSteamId}", friendSteamId); - EnqueueAndProcessOverlayJoin(0UL, "GameRichPresenceJoinRequested_t:direct-friend-fallback", friendSteamId); - } - else - { - Instance?.Logger.Information("[NetMod][Steam] Rich Presence join requested but connect string and friend Steam ID are empty"); - } + Instance?.Logger.Information("[NetMod][Steam] Rich Presence join requested but connect string is empty (host may not have set Rich Presence)"); return; } - Instance?.Logger.Information("[NetMod][Steam] Overlay Rich Presence join requested connect={Connect} friendSteamId={FriendSteamId}", connect, friendSteamId); + Instance?.Logger.Information("[NetMod][Steam] Overlay Rich Presence join requested connect={Connect}", connect); var lobbyId = TryParseLobbyIdFromConnectString(connect); - if (lobbyId == 0UL && friendSteamId == 0UL) + if (lobbyId == 0UL) { - Instance?.Logger.Warning("[NetMod][Steam] Could not parse lobby ID from connect string and no friend fallback was available: {Connect}", connect); + Instance?.Logger.Warning("[NetMod][Steam] Could not parse lobby ID from connect string: {Connect}", connect); return; } - EnqueueAndProcessOverlayJoin(lobbyId, "GameRichPresenceJoinRequested_t", friendSteamId); + EnqueueAndProcessOverlayJoin(lobbyId, "GameRichPresenceJoinRequested_t"); } - private static void EnqueueAndProcessOverlayJoin(ulong lobbyId, string source, ulong fallbackHostSteamId = 0UL) + private static void EnqueueAndProcessOverlayJoin(ulong lobbyId, string source) { var nowTicks = Environment.TickCount64; if (lobbyId == s_lastOverlayJoinLobbyId && nowTicks - s_lastOverlayJoinTicks < SteamOverlayJoinDedupMs) { - Instance?.Logger.Debug("[NetMod][Steam] Ignoring duplicate overlay join request lobbyId={LobbyId} fallbackHostSteamId={FallbackHostSteamId} source={Source}", lobbyId, fallbackHostSteamId, source); + Instance?.Logger.Debug("[NetMod][Steam] Ignoring duplicate overlay join request lobbyId={LobbyId} source={Source}", lobbyId, source); return; } s_lastOverlayJoinLobbyId = lobbyId; s_lastOverlayJoinTicks = nowTicks; - Instance?.Logger.Information("[NetMod][Steam] Queueing overlay join request lobbyId={LobbyId} fallbackHostSteamId={FallbackHostSteamId} source={Source}", lobbyId, fallbackHostSteamId, source); - GameMenu.EnqueueMainThreadCoalesced("steam:overlay-join", () => GameMenu.HandleSteamOverlayJoinRequest(lobbyId, fallbackHostSteamId)); + Instance?.Logger.Information("[NetMod][Steam] Queueing overlay join request lobbyId={LobbyId} source={Source}", lobbyId, source); + GameMenu.EnqueueMainThreadCoalesced("steam:overlay-join", () => GameMenu.HandleSteamOverlayJoinRequest(lobbyId)); } private static ulong TryParseLobbyIdFromConnectString(string connect) @@ -132,15 +122,7 @@ private static ulong TryParseLobbyIdFromConnectString(string connect) private static void TryRunSteamCallbacks() { - try - { - if (!s_isDisposing) - SteamAPI.RunCallbacks(); - } - catch (Exception ex) - { - Instance?.Logger.Debug(ex, "[NetMod][Steam] Steam callback pump ignored failure"); - } + SteamAPI.RunCallbacks(); } /// @@ -244,37 +226,14 @@ private static void TryPollSteamOverlayJoinFromLaunchData() /// private static void StartSteamCallbackPumpTimer() { - if (s_steamCallbackPumpTimer != null || s_isDisposing) + if (s_steamCallbackPumpTimer != null) return; s_steamCallbackPumpTimer = new Timer( - _ => TryRunSteamCallbacks(), + _ => SteamAPI.RunCallbacks(), null, TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(100)); Instance?.Logger.Debug("[NetMod] Steam callback pump timer started"); } - - private static void StopSteamCallbackPumpTimer() - { - var timer = Interlocked.Exchange(ref s_steamCallbackPumpTimer, null); - if (timer == null) - return; - - try - { - timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - } - catch - { - } - - try - { - timer.Dispose(); - } - catch - { - } - } } } diff --git a/ModEntry/ModEntry.cs b/ModEntry/ModEntry.cs index f5da2ec..bca9460 100644 --- a/ModEntry/ModEntry.cs +++ b/ModEntry/ModEntry.cs @@ -33,7 +33,6 @@ using DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI; using DeadCellsMultiplayerMod.MultiplayerModUI.LevelExit; using DeadCellsMultiplayerMod.MultiplayerModUI.Connection; -using DeadCellsMultiplayerMod.WorldSync; using DeadCellsMultiplayerMod.Tools.ModLang; using DeadCellsMultiplayerMod.Tools; using DeadCellsMultiplayerMod.KingHead; @@ -41,6 +40,7 @@ using dc.en.inter.door; using DeadCellsMultiplayerMod.Interaction; using DeadCellsMultiplayerMod.UI; +using DeadCellsMultiplayerMod.AdvancedCoop; namespace DeadCellsMultiplayerMod @@ -60,7 +60,6 @@ public partial class ModEntry(ModInfo info) : ModBase(info), private static IDisposable? s_steamRichPresenceJoinCallback; private static bool s_steamOverlayCallbackPending; private static Timer? s_steamCallbackPumpTimer; - private static bool s_isDisposing; private static int s_steamOverlayCallbackRetryCount; private static bool s_steamApiReady; private static string s_lastSteamLaunchCommand = string.Empty; @@ -145,27 +144,16 @@ public partial class ModEntry(ModInfo info) : ModBase(info), private double _postReviveLockY; private int _reviveHoldTargetId; private long _reviveHoldStartedTicks; - private int _reviveBurstTargetId; - private long _reviveBurstUntilTicks; - private long _nextReviveBurstSendTicks; - private bool _localDownedStatsCaptured; - private int _localDownedSavedMaxLife; - private int _localDownedSavedBrutalityTier; - private int _localDownedSavedSurvivalTier; - private int _localDownedSavedTacticTier; - private int _localDownedSavedBonusLife; - private const double ReviveUseDistancePx = 192.0; + private const double ReviveUseDistancePx = 48.0; private const double ReviveAttemptCooldownSeconds = 0.2; - private const double ReviveHoldSeconds = 0.45; - private const double ReviveRequestBurstSeconds = 1.25; - private const double ReviveRequestBurstIntervalSeconds = 0.18; + private const double ReviveHoldSeconds = 0.7; private const double ReviveHomunculusBodyMaxDistancePx = 64.0; - private const double DownedStateResendSeconds = 0.25; + private const double DownedStateResendSeconds = 0.4; private const double DownedHeadStateResendSeconds = 1.0 / 30.0; private const double DownedGhostBodyYOffsetPx = 40.0; private const double LocalReviveBodyYOffsetPx = 0.5; - private const double PostRevivePositionLockSeconds = 0.15; - private const string ReviveHintText = "Hold R / controller action to revive."; + private const double PostRevivePositionLockSeconds = 0.0; + private const string ReviveHintText = "Hold to revive."; private string _lastDoorMarkerLevelId = string.Empty; private int _lastDoorMarkerToken = int.MinValue; private string _localLastDoorMarkerLevelId = string.Empty; @@ -224,7 +212,6 @@ internal static bool IsBossLevel(string? levelId) { "BeholderDeath", "GiantDeath", "GiantDeath4", "KillKingCinem", "KillQueenCinem", "QueenDefeated", "KillDookuBeastCinem", "FakeKillDooku", "RichterDeath", - "ConciergeDeath", "TimeKeeperDeath", "GardenerDeath", "ScarecrowDeath", "MamaTickDeath", "EndCollectorPreSmash", "SmashCinem", "EndCollectorPostSmash", "EndCollectorPostSmashKS" }; private static readonly HashSet BossIntroCineTypeNames = new(StringComparer.Ordinal) @@ -247,13 +234,6 @@ internal static bool IsBossLevel(string? levelId) private const double BossHeroTeleportEchoSuppressSeconds = 1.5; private int _suppressBossCineSendDepth; private long _suppressBossTriggerNetSendUntilTick; - private long _bossVictoryRecoveryUntilTicks; - private long _bossVictoryRecoveryUnlockAfterTicks; - private long _nextBossVictoryRecoveryTick; - private string _bossVictoryRecoveryReason = string.Empty; - private string _bossDeathPollLevelId = string.Empty; - private bool _bossDeathPollSawLiveBoss; - private long _bossDeathPollGoneSinceTicks; void IOnAfterLoadingCDB.OnAfterLoadingCDB(dc._Data_ cdb) @@ -394,19 +374,13 @@ public override void Initialize() DebugModuleId.InteractionSync, "InteractionSync", () => _ = new InteractionSync(this)); - - // v6.4.4: re-enable WorldObjectSync in visibility-safe / host-authoritative mode. - // It no longer reads sprite culling as "hidden" and never forces spr.visible=false - // or alpha=0, which prevents the v6.4 invisible synced-object regression. - _ = new WorldObjectSync(this); - - _ = new StuckRecoveryFailsafe(this); - InitializeOptionalModule( DebugModuleId.ConnectionUI, "ConnectionUI", () => ConnectionUI.Initialize(this)); + _ = new CoopAdvancedHardening(this); + GameMenu.Initialize(Logger); s_steamOverlayCallbackPending = true; s_steamOverlayCallbackRetryCount = 0; @@ -433,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.36-zdoor-sublevel-render-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; @@ -455,12 +431,12 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) Hook_Hero.canBeHitBy += Hook_Hero_canBeHitBy; Hook_Game.hasCinematic += Hook_Game_hasCinematic; Hook_ZDoor.onActivate += Hook_ZDoor_onActivate; + Hook_BossRushDoor.initGfx += Hook_BossRushDoor_initGfx; Hook_Hero.applySkin += Hook_Hero_applySkin; Hook_HeroHead.initCustomHead += Hook_HeroHead_initCustomHead; Hook_DiveAttack.onStart += Hook_DiveAttack_onStart; Hook_DiveAttack.onOwnerLand += Hook_DiveAttack_onOwnerLand; Hook_HiddenTrigger.trigger += Hook_HiddenTrigger_trigger; - // v5.6: do not hook HiddenTrigger.fixedUpdate; v5.5's trigger crashguard was too invasive. Hook__HeroDeath.__constructor__ += Hook__HeroDeath__constructor__; Hook__HeroDeathBase.__constructor__ += Hook__HeroDeathBase__constructor__; Hook__HeroDeathContinue.__constructor__ += Hook__HeroDeathContinue__constructor__; @@ -530,19 +506,128 @@ 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); } } + /// + /// Client often lacks Boss Rush door animation frames until assets settle; HL throws "Unknown frame: bossRushDoor*". + /// Only swallow those — rethrow everything else so unrelated bugs are not masked (and to avoid odd door state). + /// + private static bool IsBossRushDoorMissingFrameException(Exception ex) + { + for (var cur = ex; cur != null; cur = cur.InnerException) + { + var msg = cur.Message; + if (string.IsNullOrWhiteSpace(msg)) + continue; + if (msg.IndexOf("Unknown frame", StringComparison.OrdinalIgnoreCase) < 0) + continue; + if (msg.IndexOf("bossRushDoor", StringComparison.OrdinalIgnoreCase) < 0) + continue; + return true; + } + + return false; + } + + /// + /// At most one deferred orig(self) per door instance. + /// + private static readonly ConditionalWeakTable s_bossRushDoorGfxDeferredPending = new(); + + /// + /// Client-only: missing boss-rush door anim frames. Do not call for + /// BossRushZone from this hook — it can run during level/entity init and corrupt unrelated HL state (casts such as + /// tool.CPoint vs level.LevelMap). + /// + private void Hook_BossRushDoor_initGfx(Hook_BossRushDoor.orig_initGfx orig, BossRushDoor self) + { + try + { + orig(self); + } + catch (Exception ex) + { + if (_netRole != NetRole.Client || self == null || !IsBossRushDoorMissingFrameException(ex)) + throw; + + string? bossRushType = null; + try { bossRushType = self.bossRushType?.ToString(); } catch { } + + Logger.Warning("[NetMod] BossRushDoor.initGfx failed on client level={LevelId}: type={Type} ({Msg})", + levelId, + bossRushType ?? "null", + ex.Message); + + if (s_bossRushDoorGfxDeferredPending.TryGetValue(self, out _)) + { + Logger.Warning("[NetMod] BossRushDoor.initGfx second sync failure before deferred retry; clearing spr level={LevelId}", levelId); + try { self.spr = null; } catch (Exception ex2) { Logger.Warning(ex2, "[NetMod] BossRushDoor spr=null failed"); } + return; + } + + s_bossRushDoorGfxDeferredPending.Add(self, new object()); + var localOrig = orig; + var localSelf = self; + GameMenu.EnqueueMainThread(() => + { + try + { + localOrig(localSelf); + } + catch (Exception ex2) + { + if (_netRole != NetRole.Client || !IsBossRushDoorMissingFrameException(ex2)) + { + Logger.Warning(ex2, "[NetMod] BossRushDoor.initGfx deferred retry unexpected error"); + return; + } + + string? t = null; + try { t = localSelf.bossRushType?.ToString(); } catch { } + Logger.Warning("[NetMod] BossRushDoor.initGfx deferred retry still missing frames level={LevelId}: type={Type} ({Msg})", + levelId, + t ?? "null", + ex2.Message); + try { localSelf.spr = null; } catch (Exception ex3) { Logger.Warning(ex3, "[NetMod] BossRushDoor spr=null failed"); } + } + }); + } + } + private void Hook_HiddenTrigger_trigger(Hook_HiddenTrigger.orig_trigger orig, HiddenTrigger self, Entity dh) { var senderLevelId = string.IsNullOrWhiteSpace(levelId) ? string.Empty : levelId.Trim(); @@ -610,9 +695,6 @@ private void Hook_User_unserialize(Hook_User.orig_unserialize orig, User self, d private void Hook_Game_onDispose(Hook_Game.orig_onDispose orig, dc.pr.Game self) { - s_isDisposing = true; - StopSteamCallbackPumpTimer(); - if (_netRole == NetRole.Client) { var user = self?.user; @@ -665,9 +747,9 @@ private void hook_boot_update(Hook_Boot.orig_update orig, Boot self, double dt) PumpSteamCallbacksForOverlay(); GameMenu.ProcessMainThreadQueue(); GameMenu.HandleTextInputClipboardShortcuts(); - // v5.6: frame stability guards disabled; they mutated live Hashlink structures. _ghost?.UpdateLabels(); ProcessCameraSpectateInput(); + TickRemoteKingSubLevelTransitionGuard(); } @@ -756,14 +838,12 @@ private AnimManager Hook_AnimManager_play(Hook_AnimManager.orig_play orig, AnimM if (me != null && me?.spr?._animManager != null && ReferenceEquals(self, me.spr._animManager)) { if (!DeadCellsMultiplayerMod.Ghost.KingWeaponSupport.IsInKingContext && - IsSafeNetworkHeroAnim(play)) + !IsAttackAnim(play)) SendHeroAnim(play, queueAnim, g); } if(me != null && me.heroHead.customHeadSpr != null && ReferenceEquals(self, me.heroHead.customHeadSpr._animManager)) { - // v5.7: apply the same attack/weapon filter to head animation packets. - if (IsSafeNetworkHeroAnim(play)) - SendHeadAnim(play); + SendHeadAnim(play); } return orig(self, plays, queueAnim, g); @@ -790,21 +870,6 @@ private static bool IsAttackAnim(string anim) return false; } - - private static bool IsSafeNetworkHeroAnim(string anim) - { - if (string.IsNullOrWhiteSpace(anim)) return false; - - // v5.9: visual-only animation sync. Remote weapon objects/projectiles stay disabled - // for stability, but the ghost body/head may play the same vanilla animation name. - // This restores visible attacks, bows, healing, scrolls, teleports, talking, ladders, - // exits, and other one-shot player animations without constructing a remote weapon. - var a = anim.Trim(); - if (a.Length == 0) return false; - if (a.IndexOf("stopPoweredFeedback", StringComparison.OrdinalIgnoreCase) >= 0) return false; - return true; - } - public void hook_level_changed(Hook_Hero.orig_onLevelChanged orig, Hero self, Level oldLevel) { kingInitialized = false; @@ -815,21 +880,6 @@ public void hook_level_changed(Hook_Hero.orig_onLevelChanged orig, Hero self, Le _appliedBossHeroTeleportLevels.Clear(); _lastBossCineSentLevelId = null; _lastBossCineSentTick = 0; - var oldLevelIdForBossRecovery = string.Empty; - try { oldLevelIdForBossRecovery = oldLevel?.map?.id?.ToString()?.Trim() ?? string.Empty; } catch { } - var preserveBossVictoryRecovery = - _bossVictoryRecoveryUntilTicks != 0 && - IsBossLevel(oldLevelIdForBossRecovery); - if (!preserveBossVictoryRecovery) - { - _bossVictoryRecoveryUntilTicks = 0; - _bossVictoryRecoveryUnlockAfterTicks = 0; - _nextBossVictoryRecoveryTick = 0; - _bossVictoryRecoveryReason = string.Empty; - } - _bossDeathPollLevelId = string.Empty; - _bossDeathPollSawLiveBoss = false; - _bossDeathPollGoneSinceTicks = 0; ResetFakeDeathState(unlockLocalHero: true, sendNetworkUpState: false, clearRemoteDownedTracking: false, clearDownedAnnouncements: false); me = self; me._targetable = true; @@ -840,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; @@ -894,13 +945,12 @@ public void OnFrameUpdate(double dt) var hitchStart = RuntimeHitchWatch.Start(); PumpSteamCallbacksForOverlay(); GameMenu.ProcessMainThreadQueue(); + CheckRemoteKingRenderSafety("frame"); GameMenu.TickMenu(dt); DetectAndSendBossCine(); ApplyReceivedBossHeroTeleport(); ApplyReceivedBossCine(); SuppressRemoteBossDeathCineIfNeeded(); - TryScheduleBossVictoryRecoveryFromBossDeath(); - MaintainBossVictoryUnstuckRecovery(); var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); if (hitchMs >= RuntimeHitchWatch.ModFrameSlowThresholdMs) diff --git a/NOTICE.md b/NOTICE.md deleted file mode 100644 index 9a1741f..0000000 --- a/NOTICE.md +++ /dev/null @@ -1,10 +0,0 @@ -# Notice - -DeadCellsCoopPlus is an independent community-maintained fork of the original **Dead Cells Multiplayer Mod**. - -Original repository: -https://github.com/vaiserYT/DeadCellsMultiplayerMod - -This project continues under the original MIT License. - -DeadCellsCoopPlus focuses on multiplayer bug fixes, synchronization improvements, and quality-of-life features while preserving full credit to the original author. diff --git a/README.md b/README.md index ef2f2f6..9060278 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,140 @@ -# DeadCellsCoopPlus +# Dead Cells Multiplayer Mod — Advanced Stable Co-op v0.8.2 -A community-maintained fork of the original **Dead Cells Multiplayer Mod** focused on stability, synchronization, bug fixes, and quality-of-life improvements. +This package is a Vaiser-base hardening build focused on fewer crashes, same-world co-op, cleaner UI, Steam friend invites, safer mob/boss sync, rune/progression sync, and exit failsafes. -## About +See `ADVANCED_COOP_NOTES.md` for the exact v0.8.2 changes. -DeadCellsCoopPlus aims to improve the multiplayer experience while remaining compatible with the original mod wherever possible. +--- -This project is based on the original work by **vaiserYT** and continues development through community contributions. +
-## Current Improvements +English • [Русский](README_ru.md) + +
+

Dead Cells Multiplayer Mod

-### Multiplayer -- Improved synchronization -- World object synchronization improvements -- Multiplayer stability improvements -- General networking fixes +**DeadCellsMultiplayerMod** is a **multiplayer / co-op mod for Dead Cells**, built using the **Dead Cells Core Modding API (DCCM)**. -### Bug Fixes -- Revive system improvements -- Body synchronization fixes -- Elite enemy synchronization improvements -- Rune progression fixes -- Invisible synchronized object fixes -- General desynchronization fixes +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**. -### Planned Features -- Better revive system -- Late join support -- Improved disconnect recovery -- Better object ownership -- Additional multiplayer QoL improvements +--- -## Credits +## 🎮 Features -Original project by vaiserYT. -This project remains under the original MIT License. +- ✅ 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 + +--- + +## ⭐ Support the Project + +If you find this project interesting: +- ⭐ Star the repository +- 🍴 Fork the project and experiment + +Every bit of feedback helps improve multiplayer support for **Dead Cells**. + +--- + +## 🧰 Requirements + +- **Dead Cells (PC)** +- **Dead Cells Core Modding API (DCCM)** +- Local network, Steam, or virtual LAN software (for online play) + +--- + +## 📦 Installation + +### 1️⃣ Install Dead Cells Core Modding API (DCCM) + +If you are using the **Steam version** of the game, follow the official installation guide: + +👉 [https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/](https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/) + +This method will automatically install and keep DCCM up to date. + +### 2️⃣ Install DeadCellsMultiplayerMod + +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. + +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 + +Example: +``` +Your game path/ + └──coremod/ + └── mods/ + └── DeadCellsMultiplayerMod/ +``` + +### 3️⃣ Run the game via DCCM + +Start **Dead Cells** using **DCCM**. +On the first launch, required configuration files will be generated automatically. + +--- + +## 🕹️ 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 + +🌐 **For online play**, use one of the following: +- Hamachi +- Radmin VPN +- ZeroTier +- Steam P2P (built-in) + +--- + +## 🧪 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 + +--- + +## 📜 Credits + +- **Dead Cells Core Modding API (DCCM)** + https://github.com/dead-cells-core-modding/core + +--- + + + + +### v0.8.1 build note +Fixed the missing `User` namespace import in the advanced hardening module. diff --git a/README_ru.md b/README_ru.md new file mode 100644 index 0000000..5ee83e9 --- /dev/null +++ b/README_ru.md @@ -0,0 +1,123 @@ +

Dead Cells Multiplayer Mod

+ +**DeadCellsMultiplayerMod** — это **мод для совместной игры / мультиплеера в Dead Cells**, собранный на **Dead Cells Core Modding API (DCCM)**. + +Мод добавляет **кооператив / мультиплеер** через **локальную или виртуальную сеть**: +один игрок поднимает сервер, второй подключается — оба **проходят уровни вместе в реальном времени**. + +--- + +## 🎮 Возможности + +- ✅ Синхронизация между двумя игроками в реальном времени +- ✅ Локальный TCP или Steam P2P мультиплеер +- ✅ Архитектура хост / клиент +- ✅ Автоматический старт игры для подключённого клиента +- ✅ Камера-спектатор — переключение между игроками клавишами `,` / `.` или геймпадом +- ✅ Синхронизация множителей HP боссов и руны босса +- ✅ Синхронизация атак мобов клиента и их прерывание +- ✅ Синхронизация оружия, головы и косметики призрака +- ✅ Обработка смерти/возрождения и рестарта +- ✅ Синхронизация перезагрузки графа уровня (босс-клетки, переходы) +- ✅ Слоты сохранения мультиплеера + +--- + +## ⭐ Поддержка проекта + +Если проект вам интересен: +- ⭐ Поставьте звезду репозиторию +- 🍴 Сделайте форк и экспериментируйте + +Любая обратная связь помогает улучшить мультиплеер для **Dead Cells**. + +--- + +## 🧰 Требования + +- **Dead Cells (PC)** +- **Dead Cells Core Modding API (DCCM)** +- Локальная сеть, Steam или виртуальная LAN (для игры по интернету) + +--- + +## 📦 Установка + +### 1️⃣ Установите Dead Cells Core Modding API (DCCM) + +Если у вас **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/) + +Так DCCM установится и будет обновляться автоматически. + +### 2️⃣ Установите DeadCellsMultiplayerMod + +Если у вас **Steam-версия** игры: +1. Откройте [https://steamcommunity.com/sharedfiles/filedetails/?id=3657857836](https://steamcommunity.com/sharedfiles/filedetails/?id=3657857836) +2. Установите мод в один клик. + +Если у вас **не-Steam версия** Dead Cells (требуется DCCM): +1. Перейдите в **каталог DCCM** +2. Создайте папку `mods` (если её нет) +3. Распакуйте папку **DeadCellsMultiplayerMod** в каталог `mods` + +Пример: +``` +Путь к игре/ + └──coremod/ + └── mods/ + └── DeadCellsMultiplayerMod/ +``` + +### 3️⃣ Запуск игры через DCCM + +Запустите **Dead Cells** через **DCCM**. +При первом запуске нужные конфигурационные файлы создадутся автоматически. + +--- + +## 🕹️ Как играть (мультиплеер) + +1. Запустите игру через **DCCM** +2. Нажмите **Play Multiplayer** +3. Выберите **Host** или **Join** +4. Введите **IP-адрес** и **порт** (TCP) или подключитесь через Steam +5. Когда хост начнёт игру, клиент автоматически подключится к сессии + +🌐 **Для игры по интернету** можно использовать: +- Hamachi +- Radmin VPN +- ZeroTier +- Steam P2P (встроено) + +--- + +## 🧪 Статус разработки / TODO + +- [x] Второй игрок-призрак +- [x] Синхронизация данных мира +- [x] Анимации призрака +- [x] Синхронизация генерации уровня +- [x] Синхронизация врагов +- [x] Синхронизация боссов, множителей HP, руны босса +- [x] Обработка смерти и рестарт +- [x] Синхронизация оружия, головы и косметики призрака +- [x] Синхронизация загрузки графа уровня (босс-клетки, переходы) +- [x] Слоты сохранения мультиплеера и продолжение +- [x] Камера-спектатор +- [ ] Кастомный режим +- [x] Steam P2P подключение + +--- + +## 📜 Благодарности + +- **Dead Cells Core Modding API (DCCM)** + https://github.com/dead-cells-core-modding/core + +--- + + diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 07b1dc6..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,35 +0,0 @@ -# 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_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 84559a7..aa4c20b 100644 --- a/SteamP2P/SteamConnect.cs +++ b/SteamP2P/SteamConnect.cs @@ -418,12 +418,7 @@ private static bool TryParseLobbyInput(string? text, out ulong lobbyId, out stri if (codeMatch.Success) { lobbyCode = NormalizeLobbyCode(codeMatch.Value); - // DCCM lobby codes are not random: they are the Steam lobby id encoded in base36 - // with a dc prefix. Decode locally first so joining does not depend on Steam's - // public lobby-list search, which can lag or fail to return cross-region/private - // lobbies even when the lobby id itself is valid. - if (TryDecodeLobbyCodeToLobbyId(lobbyCode, out var decodedLobbyId)) - lobbyId = decodedLobbyId; + lobbyId = 0; return true; } @@ -441,46 +436,6 @@ private static string NormalizeLobbyCode(string? rawCode) return normalized; } - private static bool TryDecodeLobbyCodeToLobbyId(string? rawCode, out ulong lobbyId) - { - lobbyId = 0UL; - - var normalized = NormalizeLobbyCode(rawCode); - if (string.IsNullOrWhiteSpace(normalized) || - !normalized.StartsWith(LobbyCodePrefix, StringComparison.Ordinal) || - normalized.Length <= LobbyCodePrefix.Length) - { - return false; - } - - var value = 0UL; - for (var i = LobbyCodePrefix.Length; i < normalized.Length; i++) - { - var ch = normalized[i]; - int digit; - if (ch >= '0' && ch <= '9') - digit = ch - '0'; - else if (ch >= 'a' && ch <= 'z') - digit = 10 + ch - 'a'; - else - return false; - - if (digit < 0 || digit >= 36) - return false; - - if (value > (ulong.MaxValue - (ulong)digit) / 36UL) - return false; - - value = (value * 36UL) + (ulong)digit; - } - - if (value == 0UL) - return false; - - lobbyId = value; - return true; - } - private static bool TryRunHostWorker(WorkerRequest request, out WorkerResponse response) { response = new WorkerResponse @@ -1078,14 +1033,8 @@ private static WorkerResponse ExecuteJoin(WorkerRequest request) if (!string.IsNullOrWhiteSpace(requestedCode)) { - if (TryDecodeLobbyCodeToLobbyId(requestedCode, out var decodedLobbyId)) - { - targetLobbyId = decodedLobbyId; - } - else if (TryResolveLobbyIdByCode(requestedCode, out var resolvedLobbyId, out _)) - { + if (TryResolveLobbyIdByCode(requestedCode, out var resolvedLobbyId, out _)) targetLobbyId = resolvedLobbyId; - } } if (targetLobbyId == 0) @@ -1095,7 +1044,7 @@ private static WorkerResponse ExecuteJoin(WorkerRequest request) Success = false, Error = string.IsNullOrWhiteSpace(requestedCode) ? "Steam lobby id is invalid" - : "Steam lobby code is invalid or could not be decoded" + : "Steam lobby code does not exist" }; } @@ -1274,12 +1223,6 @@ private static bool TryResolveLobbyIdByCode(string lobbyCode, out ulong lobbyId, return false; } - if (TryDecodeLobbyCodeToLobbyId(normalizedCode, out var decodedLobbyId)) - { - lobbyId = decodedLobbyId; - return true; - } - SteamMatchmaking.AddRequestLobbyListStringFilter( ModMarkerLobbyKey, ModMarkerLobbyValue, @@ -1396,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.ReviveInput.cs b/UI/GameMenu.ReviveInput.cs index 3452209..dcde68a 100644 --- a/UI/GameMenu.ReviveInput.cs +++ b/UI/GameMenu.ReviveInput.cs @@ -1,4 +1,3 @@ -using System; using System.Runtime.InteropServices; using dc.en; using dc.hl.types; @@ -6,22 +5,14 @@ using dc.pr; using dc.tool; using dc.ui; -using DeadCellsMultiplayerMod.UI; namespace DeadCellsMultiplayerMod; internal static partial class GameMenu { private const int ReviveInteractKeyCode = 82; // R (keyboard) - private const int ReviveEmergencyKeyCode = 118; // F7 fallback (keyboard) - private const int ReviveControllerRightShoulderPadCode = 5; // common Xbox RB / PlayStation R1 in hxd pad order - private const int ReviveControllerRightTriggerPadCode = 7; // older fallback for controllers that expose right shoulder as trigger - private const double ReviveControllerLatchSeconds = 1.10; - private static double _controllerReviveLatchUntilSeconds; - private static readonly int[] ReviveControllerRightSidePadCandidates = { 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }; - private static readonly int[] ReviveControllerFallbackPadCandidates = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23 }; - /// Hold-to-revive: dedicated R/F7 or controller action/RB input so doors, NPCs, elevators, and other interactables cannot steal the revive. + /// Hold-to-revive: keyboard R plus gamepad face buttons / primary-secondary (same binding resolution as menus). internal static bool IsReviveHoldInputDown(Hero? hero) { if (hero == null) @@ -29,7 +20,7 @@ internal static bool IsReviveHoldInputDown(Hero? hero) try { - if (dc.hxd.Key.Class.isDown(ReviveInteractKeyCode) || dc.hxd.Key.Class.isDown(ReviveEmergencyKeyCode)) + if (dc.hxd.Key.Class.isDown(ReviveInteractKeyCode)) return true; } catch @@ -39,138 +30,72 @@ internal static bool IsReviveHoldInputDown(Hero? hero) #pragma warning disable CS8602 try { - Controller? controller = null; - if (hero.controller is ControllerAccess access) - { - try { controller = access.parent; } catch { } - } - - if (controller == null) - controller = TryGetControllerFromHeroReflection(hero); + if (hero.controller is not ControllerAccess access) + return false; + if (access.manualLock) + return false; - if (controller == null) + var controller = access.parent; + if (controller == null || controller.isLocked) return false; - // v6.4.7: revive is allowed to read controller buttons even if Dead Cells has the - // controller briefly locked by a door/object/cinematic. Those locks were exactly why - // RB/R1 held beside a downed player did nothing. - var nowSeconds = GetCurrentUnixTimeSeconds(); - if (_controllerReviveLatchUntilSeconds > nowSeconds) - return true; + var b = controller.get_bindings(); - try + bool PadHeld(ArrayBytes_Int? bind) { - var b = controller.get_bindings(); - - bool PadHeld(ArrayBytes_Int? bind) + if (bind == null) + return false; + try { - if (bind == null) - return false; - try + for (var i = 0; i < bind.length; i++) { - for (var i = 0; i < bind.length; i++) - { - var code = Marshal.ReadInt32(bind.bytes, i << 2); - if (code < 0) - continue; - if (IsControllerPadDownOrPressed(controller, code)) - return LatchControllerReviveInput(); - } + var code = Marshal.ReadInt32(bind.bytes, i << 2); + if (code < 0) + continue; + if (controller.padIsPressed(code)) + return true; } - catch - { - } - - return false; + } + catch + { } - if (PadHeld(b.padA)) - return true; - if (PadHeld(b.padB)) - return true; - if (PadHeld(b.padC)) - return true; - } - catch - { + return false; } - bool PadObjectHeld(object? bindingObject) + bool KeyHeld(ArrayBytes_Int? bind) { - if (bindingObject == null) + if (bind == null) return false; - - if (bindingObject is ArrayBytes_Int bytes) - return IsAnyArrayBytesPadHeld(controller, bytes); - - if (bindingObject is ArrayObj arrayObj) + try { - try - { - for (var i = 0; i < arrayObj.length; i++) - { - var raw = arrayObj.getDyn(i); - if (raw is not int code || code < 0) - continue; - if (IsControllerPadDownOrPressed(controller, code)) - return LatchControllerReviveInput(); - } - } - catch + for (var i = 0; i < bind.length; i++) { + var code = Marshal.ReadInt32(bind.bytes, i << 2); + if (code < 0) + continue; + if (dc.hxd.Key.Class.isDown(code)) + return true; } } + catch + { + } return false; } - if (IsControllerReviveShoulderHeld(controller)) - return true; - - var gamepadOptions = dc.Main.Class.ME?.options?.get_gamepad(); - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "interact", true))) - return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "action", true))) - return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "use", true))) - return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "ui_select", true))) - return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "ui_confirm", true))) - return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "submit", true))) + if (PadHeld(b.padA)) return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "ui_next", true))) + if (PadHeld(b.padB)) return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "ui_page_next", true))) + if (PadHeld(b.padC)) return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "next", true))) + if (KeyHeld(b.primary)) return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "rightShoulder", true))) + if (KeyHeld(b.secondary)) return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "right_shoulder", true))) - return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "rightBumper", true))) - return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "right_bumper", true))) - return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "rb", true))) - return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "r1", true))) - return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "rightTrigger", true))) - return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "right_trigger", true))) - return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "rt", true))) - return true; - if (PadObjectHeld(TitleScreenReflection.GetMemberValue(gamepadOptions, "r2", true))) - return true; - - // Last fallback: DCCM/Hashlink builds can expose controller buttons with different - // numeric codes. Only while the revive system is polling, scan common face/shoulder/ - // trigger pad codes so RB/R1 works even if the binding name is missing. - if (IsAnyLikelyControllerPadHeld(controller)) + if (KeyHeld(b.third)) return true; } catch @@ -180,140 +105,4 @@ bool PadObjectHeld(object? bindingObject) return false; } - - private static Controller? TryGetControllerFromHeroReflection(Hero hero) - { - if (hero == null) - return null; - - try - { - var rawController = TitleScreenReflection.GetMemberValue(hero, "controller", true); - if (rawController is ControllerAccess access) - return access.parent; - if (rawController is Controller controller) - return controller; - - var parent = TitleScreenReflection.GetMemberValue(rawController, "parent", true); - if (parent is Controller reflectedController) - return reflectedController; - } - catch - { - } - - return null; - } - - private static bool IsAnyArrayBytesPadHeld(Controller controller, ArrayBytes_Int bind) - { - if (controller == null || bind == null) - return false; - - try - { - for (var i = 0; i < bind.length; i++) - { - var code = Marshal.ReadInt32(bind.bytes, i << 2); - if (code >= 0 && IsControllerPadDownOrPressed(controller, code)) - return LatchControllerReviveInput(); - } - } - catch - { - } - - return false; - } - - private static bool IsAnyLikelyControllerPadHeld(Controller controller) - { - if (controller == null) - return false; - - foreach (var code in ReviveControllerFallbackPadCandidates) - { - if (IsControllerPadDownOrPressed(controller, code)) - return LatchControllerReviveInput(); - } - - return false; - } - - private static bool IsControllerReviveShoulderHeld(Controller controller) - { - if (controller == null) - return false; - - foreach (var code in ReviveControllerRightSidePadCandidates) - { - if (IsControllerPadDownOrPressed(controller, code)) - return LatchControllerReviveInput(); - } - - // Keep the named constants too so future readers can see the intended mapping. - if (IsControllerPadDownOrPressed(controller, ReviveControllerRightShoulderPadCode)) - return LatchControllerReviveInput(); - if (IsControllerPadDownOrPressed(controller, ReviveControllerRightTriggerPadCode)) - return LatchControllerReviveInput(); - - return false; - } - - private static bool IsControllerPadDownOrPressed(Controller controller, int code) - { - if (controller == null || code < 0) - return false; - - // Prefer hold/down methods when the current GameProxy exposes them. - if (TryInvokeControllerButtonMethod(controller, "padIsDown", code)) - return true; - if (TryInvokeControllerButtonMethod(controller, "padIsHeld", code)) - return true; - if (TryInvokeControllerButtonMethod(controller, "padDown", code)) - return true; - if (TryInvokeControllerButtonMethod(controller, "isDown", code)) - return true; - if (TryInvokeControllerButtonMethod(controller, "buttonIsDown", code)) - return true; - if (TryInvokeControllerButtonMethod(controller, "buttonDown", code)) - return true; - - try - { - if (controller.padIsPressed(code)) - return true; - } - catch - { - } - - return false; - } - - private static bool TryInvokeControllerButtonMethod(Controller controller, string methodName, int code) - { - if (controller == null || string.IsNullOrWhiteSpace(methodName)) - return false; - - try - { - var method = controller.GetType().GetMethod(methodName, new[] { typeof(int) }); - if (method == null) - return false; - - var value = method.Invoke(controller, new object[] { code }); - return value is bool b && b; - } - catch - { - return false; - } - } - - private static bool LatchControllerReviveInput() - { - _controllerReviveLatchUntilSeconds = GetCurrentUnixTimeSeconds() + ReviveControllerLatchSeconds; - return true; - } } diff --git a/UI/GameMenu.cs b/UI/GameMenu.cs index a5e2810..30f593a 100644 --- a/UI/GameMenu.cs +++ b/UI/GameMenu.cs @@ -48,8 +48,9 @@ private enum ConnectionTransport private static ulong _steamHostSteamId; private static bool _steamJoinLobbyResolvePending; private static ulong? _pendingOverlayJoinLobbyId; - private static ulong _pendingOverlayJoinHostSteamId; 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; @@ -307,6 +308,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; @@ -626,30 +647,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) @@ -716,22 +745,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(); @@ -745,6 +788,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(); @@ -801,17 +904,6 @@ private static void SharedStartSteamHost(Action showErro _steamLobbyCode = SteamConnect.BuildLobbyCodeFromLobbyId(_steamLobbyId); ConnectionUI.NotifyConnectionsChanged(); _log?.Information("[NetMod][Steam] Host lobby ready: id={LobbyId} code={LobbyCode}", _steamLobbyId, _steamLobbyCode); - try - { - if (NetRef?.TrySetSteamHostRichPresence(_steamLobbyId) == true) - _log?.Information("[NetMod][Steam] Host rich presence join data published for lobbyId={LobbyId}", _steamLobbyId); - else - _log?.Warning("[NetMod][Steam] Host rich presence join data could not be published; Steam invites may still work through lobby fallback"); - } - catch (Exception ex) - { - _log?.Warning("[NetMod][Steam] Host rich presence publish failed: {Message}", ex.Message); - } var copied = SteamConnect.TryCopyLobbyCodeToClipboard(_steamLobbyCode) || SteamConnect.TryCopyLobbyIdToClipboard(lobby.LobbyId); @@ -867,11 +959,8 @@ private static void SharedApplySteamJoinResult(bool ok, SteamConnect.JoinLobbyRe { StopNetworkFromMenu(); _log?.Warning("[NetMod][SteamWorkerError] {Error}", join.Error); - var details = string.IsNullOrWhiteSpace(join.Error) - ? GetText.Instance.GetString("Steam join failed. Check console logs.") - : string.Concat(GetText.Instance.GetString("Steam join failed. Check console logs."), "\n", join.Error); showError(GetText.Instance.GetString("Steam join failed"), - details, + GetText.Instance.GetString("Steam join failed. Check console logs."), showTransport); return; } @@ -913,18 +1002,17 @@ private static void SharedApplySteamJoinResult(bool ok, SteamConnect.JoinLobbyRe showStatus(); } - internal static void HandleSteamOverlayJoinRequest(ulong lobbyId, ulong fallbackHostSteamId = 0UL) + internal static void HandleSteamOverlayJoinRequest(ulong lobbyId) { var screen = GetTitleScreen(); if (screen == null) { _pendingOverlayJoinLobbyId = lobbyId; - _pendingOverlayJoinHostSteamId = fallbackHostSteamId; - _log?.Information("[NetMod][Steam] Overlay join request queued: not at main menu (lobbyId={LobbyId} fallbackHostSteamId={FallbackHostSteamId})", lobbyId, fallbackHostSteamId); + _log?.Information("[NetMod][Steam] Overlay join request queued: not at main menu (lobbyId={LobbyId})", lobbyId); return; } - _log?.Information("[NetMod][Steam] Overlay join starting: lobbyId={LobbyId} fallbackHostSteamId={FallbackHostSteamId} screen=ok", lobbyId, fallbackHostSteamId); + _log?.Information("[NetMod][Steam] Overlay join starting: lobbyId={LobbyId} screen=ok", lobbyId); _menuSelection = NetRole.Client; _menuTransport = ConnectionTransport.Steam; @@ -942,37 +1030,8 @@ internal static void HandleSteamOverlayJoinRequest(ulong lobbyId, ulong fallback ConnectionUI.NotifyConnectionsChanged(); _ = Task.Run(() => { - _log?.Information("[NetMod][Steam] Overlay join resolving lobby (lobbyId={LobbyId} fallbackHostSteamId={FallbackHostSteamId})", lobbyId, fallbackHostSteamId); - SteamConnect.JoinLobbyResult join; - var ok = false; - if (lobbyId != 0UL) - { - ok = SteamConnect.TryResolveJoinEndpointFromLobbyId(lobbyId, out join); - } - else - { - join = new SteamConnect.JoinLobbyResult - { - Success = false, - LobbyId = 0UL, - HostSteamId = 0UL, - Endpoint = null, - Error = "Steam overlay did not provide a lobby id" - }; - } - if (!ok && fallbackHostSteamId != 0UL) - { - _log?.Warning("[NetMod][Steam] Overlay lobby resolution failed for lobbyId={LobbyId}; falling back to direct friend Steam P2P hostSteamId={HostSteamId}. Error={Error}", lobbyId, fallbackHostSteamId, join.Error ?? string.Empty); - join = new SteamConnect.JoinLobbyResult - { - Success = true, - LobbyId = lobbyId, - HostSteamId = fallbackHostSteamId, - Endpoint = null, - Error = string.Empty - }; - ok = true; - } + _log?.Information("[NetMod][Steam] Overlay join resolving lobby (lobbyId={LobbyId})", lobbyId); + var ok = SteamConnect.TryResolveJoinEndpointFromLobbyId(lobbyId, out var join); EnqueueMainThread(() => ApplySteamJoinResult(screen, ok, join, fromOverlay: true)); }); } diff --git a/UI/GameMenuHooks.cs b/UI/GameMenuHooks.cs index 9e93e8a..5e3fb94 100644 --- a/UI/GameMenuHooks.cs +++ b/UI/GameMenuHooks.cs @@ -55,11 +55,9 @@ private static void ProcessPendingOverlayJoinRequest(TitleScreen screen) { if (_pendingOverlayJoinLobbyId is not { } lobbyId) return; - var fallbackHostSteamId = _pendingOverlayJoinHostSteamId; _pendingOverlayJoinLobbyId = null; - _pendingOverlayJoinHostSteamId = 0UL; - _log?.Information("[NetMod][Steam] Processing queued overlay join request (lobbyId={LobbyId} fallbackHostSteamId={FallbackHostSteamId})", lobbyId, fallbackHostSteamId); - HandleSteamOverlayJoinRequest(lobbyId, fallbackHostSteamId); + _log?.Information("[NetMod][Steam] Processing queued overlay join request (lobbyId={LobbyId})", lobbyId); + HandleSteamOverlayJoinRequest(lobbyId); } private static void TryDisconnectWhenReturningToMainMenu() diff --git a/UI/LevelExitSync.cs b/UI/LevelExitSync.cs index 4db3086..e109fb3 100644 --- a/UI/LevelExitSync.cs +++ b/UI/LevelExitSync.cs @@ -57,12 +57,6 @@ private sealed class PlayerExitState private const double CircleAlphaIdle = 0.10; private const double CircleAlphaActive = 0.22; private const int PointerFxSuppressionKey = 188743680; - private const double ForceExitCountdownSeconds = 15.0; - private const double BossForceExitCountdownSeconds = 45.0; - private const double ForceExitNoticeIntervalSeconds = 5.0; - private const double RemoteExitAssistSeconds = 2.0; - private const double RemoteExitAssistRadiusPx = 260.0; - private static readonly double RemoteExitAssistRadiusSq = RemoteExitAssistRadiusPx * RemoteExitAssistRadiusPx; private readonly ILogger _log; @@ -93,13 +87,9 @@ private sealed class PlayerExitState private bool _suppressDoorActivateHook; private string _transitionDoorKey = string.Empty; private bool _timerPausedByExit; - private string _forceExitDoorKey = string.Empty; - private long _forceExitCountdownStartedTick; - private long _nextForceExitNoticeTick; - private bool _forceExitTriggered; - private string _remoteExitAssistDoorKey = string.Empty; - private long _remoteExitAssistStartedTick; - private bool _remoteExitAssistTriggered; + 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(); @@ -137,6 +127,7 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) Hook_Exit.postUpdate += Hook_Exit_postUpdate; Hook_Exit.onActivate += Hook_Exit_onActivate; Hook_Portal.onActivate += Hook_Portal_onActivate; + Hook_BossRushDoor.onActivate += Hook_BossRushDoor_onActivate; Hook_Level.registerEntity += Hook_Level_registerEntity; Hook_Level.unregisterEntity += Hook_Level_unregisterEntity; Hook_Level.onDispose += Hook_Level_onDispose; @@ -162,6 +153,15 @@ private void Hook_Portal_onActivate(Hook_Portal.orig_onActivate orig, Portal sel target => SafeRead(() => target.visible, false)); } + private void Hook_BossRushDoor_onActivate(Hook_BossRushDoor.orig_onActivate orig, BossRushDoor self, Hero by, bool cine) + { + HandleExitTargetActivate( + self, + by, + () => orig(self, by, cine), + target => !SafeRead(() => target.locked, true)); + } + private void Hook_Level_registerEntity(Hook_Level.orig_registerEntity orig, Level self, Entity clid) { orig(self, clid); @@ -176,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; @@ -301,8 +305,7 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) UpdateLocalPlayerState(net, forceSend: false); ApplyLocalTimerPause(_localPressed && _localInsideCircle); RefreshDoorVisuals(net); - UpdateForceExitCountdown(net, currentLevel, hero, nearestTarget); - UpdateRemoteExitAssist(net, currentLevel, hero, nearestTarget); + TryExitTeleportFailsafe(hero, currentLevel, net); if (_localPressed && _localInsideCircle && @@ -329,241 +332,75 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) UpdateExitPointer(net); } - private void UpdateForceExitCountdown(NetNode net, Level? currentLevel, Hero hero, Entity? nearestTarget) - { - if (net == null || !net.IsAlive || hero == null) - { - ResetForceExitCountdown(showCancelMessage: false); - return; - } - var doorKey = ResolveWatchedDoorKey(net); - if (string.IsNullOrWhiteSpace(doorKey)) - { - ResetForceExitCountdown(showCancelMessage: false); - return; - } - - if (AreAllPlayersReadyForDoor(doorKey, net)) - { - ResetForceExitCountdown(showCancelMessage: false); - return; - } - - if (!HasAnyPlayerReadyForDoor(doorKey, net.id)) - { - ResetForceExitCountdown(showCancelMessage: false); - return; - } - - var target = FindExitTargetByDoorKey(currentLevel, doorKey); - if (target == null && nearestTarget != null && string.Equals(BuildDoorKey(nearestTarget.cx, nearestTarget.cy), doorKey, StringComparison.Ordinal)) - target = nearestTarget; - - if (target == null || !IsAvailableExitTarget(target)) - { - ResetForceExitCountdown(showCancelMessage: false); - return; - } - - var countdownSeconds = ResolveForceExitCountdownSeconds(currentLevel); - var now = Stopwatch.GetTimestamp(); - if (!string.Equals(_forceExitDoorKey, doorKey, StringComparison.Ordinal) || _forceExitCountdownStartedTick == 0) - { - _forceExitDoorKey = doorKey; - _forceExitCountdownStartedTick = now; - _nextForceExitNoticeTick = now; - _forceExitTriggered = false; - PushForceExitCountdownMessage(net, target, countdownSeconds); - _nextForceExitNoticeTick = now + (long)(Stopwatch.Frequency * ForceExitNoticeIntervalSeconds); - return; - } - - var elapsedSeconds = (now - _forceExitCountdownStartedTick) / (double)Stopwatch.Frequency; - var remainingSeconds = countdownSeconds - elapsedSeconds; - if (!_forceExitTriggered && remainingSeconds > 0 && now >= _nextForceExitNoticeTick) - { - PushForceExitCountdownMessage(net, target, remainingSeconds); - _nextForceExitNoticeTick = now + (long)(Stopwatch.Frequency * ForceExitNoticeIntervalSeconds); - } - - if (_forceExitTriggered || elapsedSeconds < countdownSeconds) - return; - - _forceExitTriggered = true; - MultiplayerUI.PushSystemMessage(FormatLocalized("Exit failsafe activated. Moving everyone to {0}.", ResolveExitDestinationName(target))); - ApplyLocalTimerPause(false); - TriggerExitTransition(target, hero, null); - } - - private static double ResolveForceExitCountdownSeconds(Level? currentLevel) - { - try - { - var levelId = currentLevel?.map?.id?.ToString(); - if (DeadCellsMultiplayerMod.ModEntry.IsBossLevel(levelId)) - return BossForceExitCountdownSeconds; - } - catch - { - } - - return ForceExitCountdownSeconds; - } - - private void UpdateRemoteExitAssist(NetNode net, Level? currentLevel, Hero hero, Entity? nearestTarget) + private void TryExitTeleportFailsafe(Hero hero, Level? level, NetNode net) { - if (net == null || !net.IsAlive || hero == null || ModEntry.IsLocalPlayerDowned()) - { - ResetRemoteExitAssist(); + if (hero == null || level == null || net == null || !net.IsAlive) return; - } - if (_localPressed && _localInsideCircle) { - ResetRemoteExitAssist(); + _exitFailsafeDoorKey = string.Empty; + _exitFailsafeStartedTicks = 0; return; } - var doorKey = ResolveWatchedDoorKey(net); - if (string.IsNullOrWhiteSpace(doorKey) || !HasAnyPlayerReadyForDoor(doorKey, net.id)) + string candidateKey = string.Empty; + foreach (var state in _playerStates.Values) { - ResetRemoteExitAssist(); - return; + 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; } - var target = FindExitTargetByDoorKey(currentLevel, doorKey); - if (target == null && nearestTarget != null && string.Equals(BuildDoorKey(nearestTarget.cx, nearestTarget.cy), doorKey, StringComparison.Ordinal)) - target = nearestTarget; - if (target == null || !IsAvailableExitTarget(target)) + if (string.IsNullOrWhiteSpace(candidateKey)) { - ResetRemoteExitAssist(); + _exitFailsafeDoorKey = string.Empty; + _exitFailsafeStartedTicks = 0; return; } - if (!IsHeroNearOrStuckAtExit(hero, target)) - { - ResetRemoteExitAssist(); + var target = FindExitTargetByDoorKey(level, candidateKey); + if (target == null) return; - } - - var now = Stopwatch.GetTimestamp(); - if (!string.Equals(_remoteExitAssistDoorKey, doorKey, StringComparison.Ordinal) || _remoteExitAssistStartedTick == 0) - { - _remoteExitAssistDoorKey = doorKey; - _remoteExitAssistStartedTick = now; - _remoteExitAssistTriggered = false; - try { MultiplayerUI.PushSystemMessage(Localize("Exit assist: teammate entered the exit. Pulling you in.")); } catch { } - } - - _localDoorKey = doorKey; - _localDoorCx = target.cx; - _localDoorCy = target.cy; - _localDoorOutOfGame = SafeRead(() => target.isOutOfGame, false); - _localDoorOnScreen = SafeRead(() => target.isOnScreen, false); - _localInsideCircle = true; - _localPressed = true; - UpdateLocalPlayerState(net, forceSend: true); - - var elapsedSeconds = (now - _remoteExitAssistStartedTick) / (double)Stopwatch.Frequency; - var allReady = AreAllPlayersReadyForDoor(doorKey, net); - // Do not let a client jump into the next level before the host has had time to receive - // this ready packet. That early local transition was a major cause of boss/level desync. - if (net.IsHost && allReady) + if (!string.Equals(_exitFailsafeDoorKey, candidateKey, StringComparison.Ordinal)) { - ApplyLocalTimerPause(false); - TriggerExitTransition(target, hero, null); + _exitFailsafeDoorKey = candidateKey; + _exitFailsafeStartedTicks = Stopwatch.GetTimestamp(); return; } - if (_remoteExitAssistTriggered || elapsedSeconds < RemoteExitAssistSeconds || !allReady) + var elapsed = Stopwatch.GetElapsedTime(_exitFailsafeStartedTicks).TotalSeconds; + if (elapsed < ExitFailsafeTeleportSeconds) return; - _remoteExitAssistTriggered = true; - ApplyLocalTimerPause(false); - TriggerExitTransition(target, hero, null); - } - - private void ResetRemoteExitAssist() - { - _remoteExitAssistDoorKey = string.Empty; - _remoteExitAssistStartedTick = 0; - _remoteExitAssistTriggered = false; - } - - private static bool IsHeroNearOrStuckAtExit(Hero hero, Entity target) - { - if (hero == null || target == null) - return false; - try { - var heroX = hero.spr?.x ?? ((hero.cx + hero.xr) * 24.0); - var heroY = hero.spr?.y ?? ((hero.cy + hero.yr) * 24.0); - var targetX = target.spr?.x ?? ((target.cx + target.xr) * 24.0); - var targetY = target.spr?.y ?? ((target.cy + target.yr) * 24.0); - if (!double.IsFinite(heroX) || !double.IsFinite(heroY) || !double.IsFinite(targetX) || !double.IsFinite(targetY)) - return false; - - var dx = heroX - targetX; - var dy = heroY - targetY; - if (dx * dx + dy * dy <= RemoteExitAssistRadiusSq) - return true; - - // Some exits/transition doors trap the client just below the activation trigger - // when the other player starts the transition. Treat that as "close enough" and - // pull the client through instead of leaving them wedged under the trigger. - return System.Math.Abs(dx) <= 160.0 && dy >= -48.0 && dy <= 420.0; + 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 + catch (Exception ex) { - return false; + _log.Warning("[ExitSync] Exit teleport failsafe failed: {Message}", ex.Message); } } - private void ResetForceExitCountdown(bool showCancelMessage) - { - if (showCancelMessage && _forceExitCountdownStartedTick != 0) - MultiplayerUI.PushSystemMessage(Localize("Exit failsafe cancelled.")); - - _forceExitDoorKey = string.Empty; - _forceExitCountdownStartedTick = 0; - _nextForceExitNoticeTick = 0; - _forceExitTriggered = false; - ResetRemoteExitAssist(); - } - - private bool HasAnyPlayerReadyForDoor(string doorKey, int localId) - { - if (string.IsNullOrWhiteSpace(doorKey)) - return false; - - foreach (var state in _playerStates.Values) - { - if (state.UserId <= 0) - continue; - if (IsPlayerDownedForExit(state.UserId, localId)) - continue; - if (!state.Pressed || !state.InsideCircle) - continue; - if (string.Equals(state.DoorKey, doorKey, StringComparison.Ordinal)) - return true; - } - - return false; - } - - private void PushForceExitCountdownMessage(NetNode net, Entity target, double secondsRemaining) - { - var seconds = (int)System.Math.Ceiling(System.Math.Max(1.0, secondsRemaining)); - var destination = ResolveExitDestinationName(target); - MultiplayerUI.PushSystemMessage(FormatLocalized( - "Exit failsafe: not everyone is ready. Forcing travel to {0} in {1}s.", - destination, - seconds)); - } - private void TriggerExitTransition(Entity target, Hero hero, Action? origActivate) { if (target == null || hero == null) @@ -573,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 { @@ -865,9 +706,9 @@ private static string ResolveExitDestinationName(Entity? target) return mapId.Trim(); } - if (IsTypeName(target, "BossRushDoor")) + if (target is BossRushDoor bossDoor) { - var type = SafeRead(() => ReadDynamicMember(target, "bossRushType"), string.Empty); + var type = SafeRead(() => bossDoor.bossRushType?.ToString() ?? string.Empty, string.Empty); if (!string.IsNullOrWhiteSpace(type)) return type.Trim(); return Localize("Boss Rush"); @@ -1008,7 +849,7 @@ private static bool TryParseDoorKey(string key, out int cx, out int cy) private static bool IsSupportedExitTarget(Entity? entity) { - return entity is Exit || entity is Portal || IsTypeName(entity, "BossRushDoor"); + return entity is Exit || entity is Portal || entity is BossRushDoor; } private static bool IsAvailableExitTarget(Entity? entity) @@ -1017,7 +858,7 @@ private static bool IsAvailableExitTarget(Entity? entity) return false; if (!SafeRead(() => entity!.visible, true)) return false; - if (IsTypeName(entity, "BossRushDoor") && SafeRead(() => ReadDynamicBool(entity, "locked"), false)) + if (entity is BossRushDoor bossDoor && SafeRead(() => bossDoor.locked, false)) return false; return true; } @@ -1519,11 +1360,6 @@ private void ResetLevelState(Level? newLevel) _hasCachedDownedSignature = false; _cachedDownedSignature = 0; _exitPointerDoorKey = string.Empty; - _forceExitDoorKey = string.Empty; - _forceExitCountdownStartedTick = 0; - _nextForceExitNoticeTick = 0; - _forceExitTriggered = false; - ResetRemoteExitAssist(); _staleDoorVisualKeys.Clear(); foreach (var key in _doorVisuals.Keys) @@ -1639,58 +1475,6 @@ private static T SafeRead(Func getter, T fallback) try { return getter(); } catch { return fallback; } } - private static bool IsTypeName(object? value, string typeName) - { - if (value == null || string.IsNullOrWhiteSpace(typeName)) - return false; - - try - { - var type = value.GetType(); - while (type != null) - { - if (string.Equals(type.Name, typeName, StringComparison.Ordinal) || - string.Equals(type.FullName, typeName, StringComparison.Ordinal) || - type.FullName?.EndsWith("." + typeName, StringComparison.Ordinal) == true) - return true; - type = type.BaseType; - } - } - catch - { - } - - return false; - } - - private static string ReadDynamicMember(object? value, string memberName) - { - if (value == null || string.IsNullOrWhiteSpace(memberName)) - return string.Empty; - - try - { - var type = value.GetType(); - var prop = type.GetProperty(memberName); - if (prop != null) - return prop.GetValue(value)?.ToString() ?? string.Empty; - var field = type.GetField(memberName); - if (field != null) - return field.GetValue(value)?.ToString() ?? string.Empty; - } - catch - { - } - - return string.Empty; - } - - private static bool ReadDynamicBool(object? value, string memberName) - { - var raw = ReadDynamicMember(value, memberName); - return bool.TryParse(raw, out var parsed) && parsed; - } - private void MarkExitUiStateDirty() { _readyStateCacheDirty = true; 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/UI/StuckRecoveryFailsafe.cs b/UI/StuckRecoveryFailsafe.cs deleted file mode 100644 index d1c61f4..0000000 --- a/UI/StuckRecoveryFailsafe.cs +++ /dev/null @@ -1,261 +0,0 @@ -using dc; -using dc.en; -using dc.pr; -using DeadCellsMultiplayerMod.Interface.ModuleInitializing; -using DeadCellsMultiplayerMod.Ghost.GhostBase; -using DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI; -using ModCore.Events; -using ModCore.Events.Interfaces.Game.Hero; -using Serilog; - -namespace DeadCellsMultiplayerMod.UI; - -/// -/// Manual recovery for multiplayer softlocks: press F8 to teleport your local hero to the nearest -/// remote player ghost. This is intentionally local-only; the normal position sync then tells the -/// other side where you moved. -/// -public sealed class StuckRecoveryFailsafe : - IEventReceiver, - IOnAdvancedModuleInitializing, - IOnHeroUpdate -{ - private const int EmergencyTeleportKeyCode = 119; // F8 on hxd.Key/DOM-style key codes. - private const double EmergencyTeleportCooldownSeconds = 2.0; - private const double EmergencyTeleportYOffsetPx = 16.0; - private const double MinDistanceForInfoMessagePx = 280.0; - - private readonly ILogger _log; - private long _nextAllowedTeleportTick; - private long _nextInfoMessageTick; - - public StuckRecoveryFailsafe(ModEntry entry) - { - _log = entry.Logger; - EventSystem.AddReceiver(this); - } - - void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) - { - entry.Logger.Information("\x1b[32m[[StuckRecoveryFailsafe] Initializing stuck recovery failsafe...]\x1b[0m "); - } - - void IOnHeroUpdate.OnHeroUpdate(double dt) - { - var net = GameMenu.NetRef; - var hero = ModEntry.me; - if (net == null || !net.IsAlive || net.id <= 0 || hero == null || hero._level == null) - return; - - if (!IsEmergencyTeleportPressed()) - return; - - var now = System.Diagnostics.Stopwatch.GetTimestamp(); - if (now < _nextAllowedTeleportTick) - { - MaybePushInfo("Emergency teleport is cooling down."); - return; - } - - var hasVisibleTarget = TryFindNearestRemoteHero(hero, out var target, out var distanceSq); - var hasRawTarget = false; - double rawX = 0; - double rawY = 0; - int rawDir = SafeRead(() => hero.dir, 0); - if (!hasVisibleTarget) - hasRawTarget = TryFindLastNetworkRemotePosition(hero, out rawX, out rawY, out distanceSq); - - if (!hasVisibleTarget && !hasRawTarget) - { - MaybePushInfo("No remote player position found to teleport to."); - return; - } - - try - { - var x = hasVisibleTarget ? GetEntityX(target!) : rawX; - var y = (hasVisibleTarget ? GetEntityY(target!) : rawY) - EmergencyTeleportYOffsetPx; - var dir = hasVisibleTarget ? SafeRead(() => target!.dir, rawDir) : rawDir; - - RecoverLocalHeroControl(hero); - hero.setPosPixel(x, y); - hero.dir = dir; - RecoverLocalHeroControl(hero); - try { net.TickSend(x, y, dir); } catch { } - - _nextAllowedTeleportTick = now + (long)(System.Diagnostics.Stopwatch.Frequency * EmergencyTeleportCooldownSeconds); - MultiplayerUI.PushSystemMessage("Emergency teleport used: moved to the other player."); - _log.Information("[StuckRecovery] Emergency teleport applied distance={Distance}", System.Math.Sqrt(distanceSq)); - } - catch (Exception ex) - { - _log.Warning(ex, "[StuckRecovery] Emergency teleport failed"); - MaybePushInfo("Emergency teleport failed. Try again after moving/camera settling."); - } - } - - private bool IsEmergencyTeleportPressed() - { - try - { - return dc.hxd.Key.Class.isPressed(EmergencyTeleportKeyCode); - } - catch - { - return false; - } - } - - private bool TryFindNearestRemoteHero(Hero localHero, out GhostKing? target, out double distanceSq) - { - target = null; - distanceSq = double.MaxValue; - var level = localHero._level; - var lx = GetEntityX(localHero); - var ly = GetEntityY(localHero); - - // Prefer a visible ghost on the same Level object, then any visible ghost, then finally - // the last raw network coordinate even if the visible ghost was hidden/disposed by a - // room/sublevel mismatch. This makes F8 work at any distance and in more DLC/transition - // softlocks. - TryFindNearestRemoteHeroPass(level, lx, ly, requireSameLevel: true, ref target, ref distanceSq); - if (target == null) - TryFindNearestRemoteHeroPass(level, lx, ly, requireSameLevel: false, ref target, ref distanceSq); - - if (target != null) - { - if (distanceSq < MinDistanceForInfoMessagePx * MinDistanceForInfoMessagePx) - _log.Debug("[StuckRecovery] Emergency teleport used while players were already close distance={Distance}", System.Math.Sqrt(distanceSq)); - return true; - } - - return false; - } - - private static void TryFindNearestRemoteHeroPass(Level? level, double lx, double ly, bool requireSameLevel, ref GhostKing? target, ref double distanceSq) - { - for (var i = 0; i < ModEntry.clients.Length; i++) - { - var remote = ModEntry.clients[i]; - if (remote == null) - continue; - if (requireSameLevel && !ReferenceEquals(remote._level, level)) - continue; - if (SafeRead(() => remote.destroyed, true)) - continue; - if (SafeRead(() => remote.isOutOfGame, false)) - continue; - - var rx = GetEntityX(remote); - var ry = GetEntityY(remote); - if (System.Math.Abs(rx) < 0.001 && System.Math.Abs(ry) < 0.001) - continue; - - var dx = rx - lx; - var dy = ry - ly; - var dSq = dx * dx + dy * dy; - if (dSq < distanceSq) - { - target = remote; - distanceSq = dSq; - } - } - } - - private static bool TryFindLastNetworkRemotePosition(Hero localHero, out double x, out double y, out double distanceSq) - { - x = 0; - y = 0; - distanceSq = double.MaxValue; - var now = System.Diagnostics.Stopwatch.GetTimestamp(); - var maxAgeTicks = System.Diagnostics.Stopwatch.Frequency * 30L; - var lx = GetEntityX(localHero); - var ly = GetEntityY(localHero); - - for (var i = 0; i < ModEntry.EmergencyLastRemoteX.Length && i < ModEntry.EmergencyLastRemoteY.Length && i < ModEntry.EmergencyLastRemoteTicks.Length; i++) - { - var tick = ModEntry.EmergencyLastRemoteTicks[i]; - if (tick <= 0 || now - tick > maxAgeTicks) - continue; - - var rx = ModEntry.EmergencyLastRemoteX[i]; - var ry = ModEntry.EmergencyLastRemoteY[i]; - if (System.Math.Abs(rx) < 0.001 && System.Math.Abs(ry) < 0.001) - continue; - - var dx = rx - lx; - var dy = ry - ly; - var dSq = dx * dx + dy * dy; - if (dSq < distanceSq) - { - distanceSq = dSq; - x = rx; - y = ry; - } - } - - return distanceSq < double.MaxValue; - } - - private static void RecoverLocalHeroControl(Hero hero) - { - try { hero.cancelVelocities(); } catch { } - try { hero.cancelSkillControlLock(); } catch { } - try { hero.unlockControls(); } catch { } - try { hero._targetable = true; } catch { } - try - { - if (hero.life <= 0) - hero.life = 1; - } - catch { } - - try - { - var data = hero._level?.game?.data; - if (data != null) - data.stopGameTime = false; - } - catch { } - } - - private void MaybePushInfo(string message) - { - var now = System.Diagnostics.Stopwatch.GetTimestamp(); - if (now < _nextInfoMessageTick) - return; - - _nextInfoMessageTick = now + System.Diagnostics.Stopwatch.Frequency; - MultiplayerUI.PushSystemMessage(message); - } - - private static double GetEntityX(Entity e) - { - try - { - if (e.spr != null) - return e.spr.x; - } - catch { } - - try { return (e.cx + e.xr) * 24.0; } catch { return 0.0; } - } - - private static double GetEntityY(Entity e) - { - try - { - if (e.spr != null) - return e.spr.y; - } - catch { } - - try { return (e.cy + e.yr) * 24.0; } catch { return 0.0; } - } - - private static T SafeRead(Func read, T fallback) - { - try { return read(); } - catch { return fallback; } - } -} diff --git a/WorldSync/WorldObjectSync.cs b/WorldSync/WorldObjectSync.cs deleted file mode 100644 index 9e15481..0000000 --- a/WorldSync/WorldObjectSync.cs +++ /dev/null @@ -1,474 +0,0 @@ -using dc; -using dc.en; -using dc.en.inter; -using dc.pr; -using DeadCellsMultiplayerMod; -using DeadCellsMultiplayerMod.Interface.ModuleInitializing; -using DeadCellsMultiplayerMod.Interaction; -using ModCore.Events; -using ModCore.Events.Interfaces.Game.Hero; -using Serilog; -using System.Diagnostics; -using System.Globalization; -using System.Reflection; - -namespace DeadCellsMultiplayerMod.WorldSync; - -/// -/// Visibility-safe host/world-object state sync. -/// -/// v6.4.4 deliberately avoids direct sprite hiding/alpha changes. The previous broad v6.4 -/// scanner could read temporary culling/invisible sprite state as a real terminal state and then -/// made matching objects invisible on the other client. This version only lets the host broadcast -/// conservative terminal flags for specific object families, and the receiver only writes gameplay -/// state booleans. It never forces spr.visible=false or alpha=0. -/// -public sealed class WorldObjectSync : - IEventReceiver, - IOnAdvancedModuleInitializing, - IOnHeroUpdate -{ - private const double TileSizePx = 24.0; - private const double ScanIntervalSeconds = 1.85; - private const double HostCorrectionIntervalSeconds = 7.50; - private const int MaxObjectsPerScan = 48; - private const double MatchRadiusPx = 40.0; - private const double MatchRadiusSq = MatchRadiusPx * MatchRadiusPx; - - private readonly ILogger _log; - private long _nextScanTick; - private long _nextHostCorrectionTick; - private string _lastLevelId = string.Empty; - private readonly Dictionary _lastSentFlags = new(StringComparer.Ordinal); - private readonly Dictionary _recentApplied = new(StringComparer.Ordinal); - - private static readonly string[] InterestingTypeTokens = - { - "item", "loot", "weapon", "skill", "scroll", "blueprint", "key", "rune", - "secret", "legendary", "cursed", "chest", "reward", "treasure", - "altar", "breakable", "wall", "shrine", "protector", "guardian", - "challenge", "slab", "pedestal", "amulet" - }; - - private static readonly string[] BadTypeTokens = - { - "hero", "mob", "zombie", "grenader", "archer", "runner", "bat", "dasher", - "shielder", "shieldbearer", "bullet", "projectile", "grenade", "ammo", "pet", "familiar", - "fx", "particle", "decal", "cine", "camera", "ui", "hud", "minimap", "ghost" - }; - - public WorldObjectSync(ModEntry entry) - { - _log = entry.Logger; - EventSystem.AddReceiver(this); - } - - void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) - { - entry.Logger.Information("\x1b[32m[[WorldObjectSync] Initializing v6.4.4 visibility-safe host world-object sync...]\x1b[0m "); - } - - void IOnHeroUpdate.OnHeroUpdate(double dt) - { - var net = GameMenu.NetRef; - var hero = ModEntry.me; - var level = hero?._level; - if (net == null || !net.IsAlive || hero == null || level == null) - return; - - var levelId = TryGetCurrentLevelId(hero); - if (string.IsNullOrWhiteSpace(levelId)) - return; - - if (!string.Equals(_lastLevelId, levelId, StringComparison.Ordinal)) - { - _lastLevelId = levelId; - _lastSentFlags.Clear(); - _recentApplied.Clear(); - _nextScanTick = 0; - _nextHostCorrectionTick = 0; - } - - // Host-authoritative: clients apply host state, but clients do not broad-scan and send - // their own world object states. This prevents one client's temporary culling/hidden state - // from making every matching object invisible for the whole run. - if (!net.IsHost) - ApplyPendingWorldObjectStates(net, level, levelId); - - if (!net.IsHost) - return; - - var now = Stopwatch.GetTimestamp(); - if (now < _nextScanTick) - return; - - _nextScanTick = now + SecondsToTicks(ScanIntervalSeconds); - var forceCorrection = now >= _nextHostCorrectionTick; - if (forceCorrection) - _nextHostCorrectionTick = now + SecondsToTicks(HostCorrectionIntervalSeconds); - - ScanAndSendChangedWorldObjectStates(net, hero, level, levelId, forceCorrection); - } - - private void ScanAndSendChangedWorldObjectStates(NetNode net, Hero hero, Level level, string levelId, bool forceCorrection) - { - var entities = SafeRead(() => level.entities, null); - if (entities == null) - return; - - var sent = 0; - for (var i = 0; i < entities.length && sent < MaxObjectsPerScan; i++) - { - Entity? e = null; - try { e = entities.getDyn(i) as Entity; } catch { } - if (e == null || ReferenceEquals(e, hero)) - continue; - - var typeName = GetStableTypeName(e); - if (!ShouldTrackWorldObject(typeName)) - continue; - - var flags = ReadWorldObjectFlags(e, typeName); - if (flags == 0) - continue; - - var (x, y) = GetEntityPixelPos(e); - if (!IsUsefulPos(x, y)) - continue; - - var key = BuildStableKey(levelId, typeName, x, y); - if (!forceCorrection && _lastSentFlags.TryGetValue(key, out var last) && last == flags) - continue; - - _lastSentFlags[key] = flags; - net.SendWorldObjectState(levelId, typeName, x, y, flags); - sent++; - } - } - - private void ApplyPendingWorldObjectStates(NetNode net, Level level, string localLevelId) - { - if (!net.TryConsumeWorldObjectStates(out var states) || states == null || states.Count == 0) - return; - - foreach (var state in states) - { - if (string.IsNullOrWhiteSpace(state.LevelId) || - !string.Equals(state.LevelId, localLevelId, StringComparison.Ordinal)) - { - continue; - } - - var safeFlags = StripUnsafeFlags(state.Flags); - if ((safeFlags & (int)WorldObjectSyncFlags.Important) == 0) - continue; - - if (!ShouldTrackWorldObject(state.TypeName)) - continue; - - var key = BuildStableKey(state.LevelId, state.TypeName, state.X, state.Y); - var now = Stopwatch.GetTimestamp(); - if (_recentApplied.TryGetValue(key, out var last) && now - last < SecondsToTicks(0.85)) - continue; - _recentApplied[key] = now; - - var local = FindMatchingWorldObject(level, state); - if (local == null) - continue; - - try - { - ApplyWorldObjectFlags(local, state.TypeName, safeFlags); - } - catch (Exception ex) - { - _log.Debug(ex, "[WorldObjectSync] Apply failed type={Type} x={X} y={Y} flags={Flags}", state.TypeName, state.X, state.Y, safeFlags); - } - } - } - - private static Entity? FindMatchingWorldObject(Level level, WorldObjectState state) - { - var entities = SafeRead(() => level.entities, null); - if (entities == null) - return null; - - Entity? nearestSameType = null; - Entity? nearestCompatible = null; - var nearestSameSq = MatchRadiusSq; - var nearestCompatibleSq = MatchRadiusSq * 0.36; - var wantedType = state.TypeName ?? string.Empty; - - for (var i = 0; i < entities.length; i++) - { - Entity? e = null; - try { e = entities.getDyn(i) as Entity; } catch { } - if (e == null) - continue; - - var typeName = GetStableTypeName(e); - if (!ShouldTrackWorldObject(typeName)) - continue; - - var (x, y) = GetEntityPixelPos(e); - if (!IsUsefulPos(x, y)) - continue; - - var dx = x - state.X; - var dy = y - state.Y; - var dSq = dx * dx + dy * dy; - if (dSq > MatchRadiusSq) - continue; - - if (string.Equals(typeName, wantedType, StringComparison.Ordinal)) - { - if (dSq < nearestSameSq) - { - nearestSameSq = dSq; - nearestSameType = e; - } - } - else if (dSq < nearestCompatibleSq && AreWorldTypesCompatible(typeName, wantedType)) - { - nearestCompatibleSq = dSq; - nearestCompatible = e; - } - } - - return nearestSameType ?? nearestCompatible; - } - - private static bool AreWorldTypesCompatible(string a, string b) - { - if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) - return false; - a = a.ToLowerInvariant(); - b = b.ToLowerInvariant(); - - if ((a.Contains("chest") && b.Contains("chest")) || - (a.Contains("scroll") && b.Contains("scroll")) || - (a.Contains("item") && b.Contains("item")) || - (a.Contains("loot") && b.Contains("loot")) || - (a.Contains("weapon") && b.Contains("weapon")) || - (a.Contains("skill") && b.Contains("skill")) || - (a.Contains("blueprint") && b.Contains("blueprint")) || - (a.Contains("key") && b.Contains("key")) || - (a.Contains("secret") && b.Contains("secret")) || - (a.Contains("breakable") && b.Contains("breakable")) || - (a.Contains("legendary") && b.Contains("legendary")) || - (a.Contains("reward") && b.Contains("reward"))) - { - return true; - } - - return false; - } - - private static void ApplyWorldObjectFlags(Entity e, string typeName, int flags) - { - flags = StripUnsafeFlags(flags); - if ((flags & (int)WorldObjectSyncFlags.Important) == 0) - return; - - if ((flags & (int)WorldObjectSyncFlags.Opened) != 0 && IsOpenableWorldObjectType(typeName)) - { - SetBoolMember(e, "opened", true); - SetBoolMember(e, "isOpen", true); - SetBoolMember(e, "open", true); - SetBoolMember(e, "activated", true); - SetBoolMember(e, "used", true); - SetBoolMember(e, "done", true); - } - - if ((flags & (int)WorldObjectSyncFlags.Broken) != 0 && IsBreakableWorldObjectType(typeName)) - { - SetBoolMember(e, "broken", true); - } - - if ((flags & (int)WorldObjectSyncFlags.Consumed) != 0 && IsConsumableWorldObjectType(typeName)) - { - SetBoolMember(e, "picked", true); - SetBoolMember(e, "pickedUp", true); - SetBoolMember(e, "collected", true); - SetBoolMember(e, "taken", true); - SetBoolMember(e, "looted", true); - SetBoolMember(e, "used", true); - SetBoolMember(e, "isOutOfGame", true); - SetBoolMember(e, "outOfGame", true); - } - } - - private static int ReadWorldObjectFlags(Entity e, string typeName) - { - var flags = 0; - - if (IsConsumableWorldObjectType(typeName) && - (BoolMember(e, "picked") || BoolMember(e, "pickedUp") || BoolMember(e, "collected") || - BoolMember(e, "taken") || BoolMember(e, "looted") || BoolMember(e, "consumed"))) - { - flags |= (int)WorldObjectSyncFlags.Consumed; - } - - if (IsOpenableWorldObjectType(typeName) && - (BoolMember(e, "opened") || BoolMember(e, "isOpen") || BoolMember(e, "open") || - BoolMember(e, "activated") || BoolMember(e, "used") || BoolMember(e, "done"))) - { - flags |= (int)WorldObjectSyncFlags.Opened; - } - - if (IsBreakableWorldObjectType(typeName) && - (BoolMember(e, "broken") || BoolMember(e, "destroyed"))) - { - flags |= (int)WorldObjectSyncFlags.Broken; - } - - // Do not derive Hidden from sprite visibility/alpha. Dead Cells hides/culls many valid - // objects temporarily, and syncing that state made items/secrets/chests vanish remotely. - flags = StripUnsafeFlags(flags); - - if (flags != 0) - flags |= (int)WorldObjectSyncFlags.Important; - - return flags; - } - - private static int StripUnsafeFlags(int flags) - { - // Hidden was too broad for Dead Cells' renderer/culling state. Keep the enum/protocol for - // compatibility with older packets, but ignore it in v6.4.4 runtime logic. - flags &= ~(int)WorldObjectSyncFlags.Hidden; - if ((flags & ((int)WorldObjectSyncFlags.Consumed | (int)WorldObjectSyncFlags.Opened | (int)WorldObjectSyncFlags.Broken)) == 0) - flags &= ~(int)WorldObjectSyncFlags.Important; - return flags; - } - - private static bool ShouldTrackWorldObject(string typeName) - { - if (string.IsNullOrWhiteSpace(typeName)) - return false; - - var lower = typeName.ToLowerInvariant(); - foreach (var bad in BadTypeTokens) - { - if (lower.Contains(bad)) - return false; - } - - foreach (var token in InterestingTypeTokens) - { - if (lower.Contains(token)) - return true; - } - - return false; - } - - private static bool IsConsumableWorldObjectType(string typeName) - { - var lower = (typeName ?? string.Empty).ToLowerInvariant(); - return lower.Contains("item") || lower.Contains("loot") || lower.Contains("weapon") || - lower.Contains("skill") || lower.Contains("scroll") || lower.Contains("blueprint") || - lower.Contains("key") || lower.Contains("rune") || lower.Contains("reward") || - lower.Contains("treasure") || lower.Contains("amulet"); - } - - private static bool IsOpenableWorldObjectType(string typeName) - { - var lower = (typeName ?? string.Empty).ToLowerInvariant(); - return lower.Contains("chest") || lower.Contains("cursed") || lower.Contains("reward") || - lower.Contains("treasure") || lower.Contains("secret") || lower.Contains("legendary") || - lower.Contains("altar") || lower.Contains("shrine") || lower.Contains("pedestal") || - lower.Contains("slab") || lower.Contains("challenge"); - } - - private static bool IsBreakableWorldObjectType(string typeName) - { - var lower = (typeName ?? string.Empty).ToLowerInvariant(); - return lower.Contains("breakable") || lower.Contains("wall") || lower.Contains("secret"); - } - - private static bool BoolMember(object obj, string name) - { - try - { - var t = obj.GetType(); - var prop = t.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase); - if (prop != null && prop.PropertyType == typeof(bool)) - return (bool)(prop.GetValue(obj) ?? false); - var field = t.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase); - if (field != null && field.FieldType == typeof(bool)) - return (bool)(field.GetValue(obj) ?? false); - } - catch { } - return false; - } - - private static void SetBoolMember(object obj, string name, bool value) - { - try - { - var t = obj.GetType(); - var prop = t.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase); - if (prop != null && prop.CanWrite && prop.PropertyType == typeof(bool)) - { - prop.SetValue(obj, value); - return; - } - var field = t.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase); - if (field != null && field.FieldType == typeof(bool)) - field.SetValue(obj, value); - } - catch { } - } - - private static (double x, double y) GetEntityPixelPos(Entity e) - { - try - { - if (e.spr != null) - return (e.spr.x, e.spr.y); - } - catch { } - - try { return ((e.cx + e.xr) * TileSizePx, (e.cy + e.yr) * TileSizePx); } - catch { return (0, 0); } - } - - private static bool IsUsefulPos(double x, double y) - { - return double.IsFinite(x) && double.IsFinite(y) && (System.Math.Abs(x) > 0.01 || System.Math.Abs(y) > 0.01); - } - - private static string GetStableTypeName(object obj) - { - try { return obj.GetType().Name ?? string.Empty; } - catch { return string.Empty; } - } - - private static string TryGetCurrentLevelId(Hero hero) - { - try - { - var levelId = hero._level?.map?.id?.ToString(); - if (!string.IsNullOrWhiteSpace(levelId)) - return levelId.Trim(); - } - catch { } - return string.Empty; - } - - private static string BuildStableKey(string levelId, string typeName, double x, double y) - { - var qx = (int)System.Math.Round(x / 12.0); - var qy = (int)System.Math.Round(y / 12.0); - return $"{levelId}|{typeName}|{qx}|{qy}"; - } - - private static T? SafeRead(Func fn, T? fallback) - { - try { return fn(); } - catch { return fallback; } - } - - private static long SecondsToTicks(double seconds) => (long)(Stopwatch.Frequency * seconds); -} diff --git a/server/server.NetNode.Cleanup.cs b/server/server.NetNode.Cleanup.cs index 461aa3a..998e177 100644 --- a/server/server.NetNode.Cleanup.cs +++ b/server/server.NetNode.Cleanup.cs @@ -34,8 +34,6 @@ private void CleanupClient() _pendingInterBreakableGroundEvents.Clear(); _pendingBossRuneUpdateCells.Clear(); _pendingInterPortalEvents.Clear(); - _pendingInterGenericActivateEvents.Clear(); - _pendingWorldObjectStates.Clear(); } if (_useSteamTransport) { diff --git a/server/server.NetNode.Consume.cs b/server/server.NetNode.Consume.cs index d821704..143dad7 100644 --- a/server/server.NetNode.Consume.cs +++ b/server/server.NetNode.Consume.cs @@ -345,22 +345,6 @@ public bool TryConsumeInterPortalEvents(out List events) } } - public bool TryConsumeInterGenericActivateEvents(out List events) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingInterGenericActivateEvents, out events); - } - } - - public bool TryConsumeWorldObjectStates(out List states) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingWorldObjectStates, out states); - } - } - public bool TryGetRemoteHpSnapshots(out List snapshot) { lock (_sync) diff --git a/server/server.NetNode.Dispose.cs b/server/server.NetNode.Dispose.cs index e1e605e..79d3013 100644 --- a/server/server.NetNode.Dispose.cs +++ b/server/server.NetNode.Dispose.cs @@ -55,10 +55,6 @@ public void Dispose() try { _client?.Close(); } catch { } try { _listener?.Stop(); } catch { } try { _steamTransportTask?.Wait(400); } catch { } - if (_useSteamTransport && _role == NetRole.Host) - { - try { TryClearSteamRichPresence(); } catch { } - } GameDataSync.Seed = 0; lock (_hostCacheSync) { @@ -94,17 +90,6 @@ public void Dispose() _pendingBossHeroTeleports.Clear(); _pendingPlayerDownStates.Clear(); _pendingPlayerReviveRequests.Clear(); - _pendingInterDoorEvents.Clear(); - _pendingInterElevatorEvents.Clear(); - _pendingInterPressurePlateEvents.Clear(); - _pendingInterTreasureChestEvents.Clear(); - _pendingInterVineLadderEvents.Clear(); - _pendingInterTeleportEvents.Clear(); - _pendingInterBreakableGroundEvents.Clear(); - _pendingBossRuneUpdateCells.Clear(); - _pendingInterPortalEvents.Clear(); - _pendingInterGenericActivateEvents.Clear(); - _pendingWorldObjectStates.Clear(); } _stream = null; _client = null; _listener = null; try { _sendLock.Dispose(); } catch { } diff --git a/server/server.NetNode.Parse.cs b/server/server.NetNode.Parse.cs index 09ab2a3..b57abe6 100644 --- a/server/server.NetNode.Parse.cs +++ b/server/server.NetNode.Parse.cs @@ -389,16 +389,10 @@ private static bool TryParseMobDiePayload(string payload, int? senderId, bool fo return false; var generation = 0; - var typeIndex = 4; - if (parts.Length > 4 && - int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedGeneration)) - { - generation = parsedGeneration; - typeIndex = 5; - } + if (parts.Length > 4) + int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out generation); - var type = parts.Length > typeIndex ? parts[typeIndex] : string.Empty; - die = new MobDie(parsedUserId, mobIndex, x, y, type, generation); + die = new MobDie(parsedUserId, mobIndex, x, y, generation); return true; } @@ -901,26 +895,6 @@ private static bool TryParseBossHeroTeleportPayload(string payload, int? senderI return true; } - private static bool TryParseInterGenericActivatePayload(string payload, out InterGenericActivateEvent ev) - { - ev = default; - if (string.IsNullOrWhiteSpace(payload)) - return false; - - var parts = payload.Split('|'); - if (parts.Length < 2) - return false; - - if (!double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) - return false; - if (!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) - return false; - - var typeName = parts.Length >= 3 ? parts[2].Trim() : string.Empty; - ev = new InterGenericActivateEvent(x, y, typeName); - return true; - } - private static bool TryParseInterBreakableGroundPayload(string payload, out InterBreakableGroundEvent ev) { ev = default; @@ -963,34 +937,6 @@ private static bool TryParseInterPortalPayload(string payload, out InterPortalEv return true; } - private static bool TryParseWorldObjectStatePayload(string payload, out WorldObjectState state) - { - state = default; - if (string.IsNullOrWhiteSpace(payload)) - return false; - - var parts = payload.Split('|'); - if (parts.Length < 5) - return false; - - var levelId = (parts[0] ?? string.Empty).Trim(); - var typeName = (parts[1] ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(levelId) || string.IsNullOrWhiteSpace(typeName)) - return false; - - if (!double.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) - return false; - if (!double.TryParse(parts[3], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) - return false; - if (!int.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var flags)) - return false; - if (flags <= 0) - return false; - - state = new WorldObjectState(levelId, typeName, x, y, flags); - return true; - } - private static bool TryParsePositionLine(string line, int? senderId, out int remoteId, out double rx, out double ry, out int dir, out bool hasDir) { remoteId = 0; diff --git a/server/server.NetNode.Protocol.Incoming.cs b/server/server.NetNode.Protocol.Incoming.cs index b6a35cc..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..]; @@ -871,30 +893,6 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) return true; } - if (line.StartsWith("INTERGENACT|", StringComparison.OrdinalIgnoreCase)) - { - var payload = line["INTERGENACT|".Length..]; - if (TryParseInterGenericActivatePayload(payload, out var ev)) - { - lock (_sync) - { - _pendingInterGenericActivateEvents.Add(ev); - _hasRemote = true; - } - - if (_role == NetRole.Host && senderId.HasValue) - { - var safeType = (ev.TypeName ?? string.Empty) - .Replace("|", "/", StringComparison.Ordinal) - .Replace("\r", string.Empty, StringComparison.Ordinal) - .Replace("\n", string.Empty, StringComparison.Ordinal) - .Trim(); - forwardLine = $"INTERGENACT|{ev.X.ToString(CultureInfo.InvariantCulture)}|{ev.Y.ToString(CultureInfo.InvariantCulture)}|{safeType}\n"; - } - } - return true; - } - if (line.StartsWith("INTERBREAK|", StringComparison.OrdinalIgnoreCase)) { var payload = line["INTERBREAK|".Length..]; @@ -929,36 +927,6 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) return true; } - if (line.StartsWith("WORLDOBJ|", StringComparison.OrdinalIgnoreCase)) - { - var payload = line["WORLDOBJ|".Length..]; - if (TryParseWorldObjectStatePayload(payload, out var state)) - { - lock (_sync) - { - _pendingWorldObjectStates.Add(state); - _hasRemote = true; - } - - if (_role == NetRole.Host && senderId.HasValue) - { - var safeLevel = (state.LevelId ?? string.Empty) - .Replace("|", "/", StringComparison.Ordinal) - .Replace("\r", string.Empty, StringComparison.Ordinal) - .Replace("\n", string.Empty, StringComparison.Ordinal) - .Trim(); - var safeType = (state.TypeName ?? string.Empty) - .Replace("|", "/", StringComparison.Ordinal) - .Replace("\r", string.Empty, StringComparison.Ordinal) - .Replace("\n", string.Empty, StringComparison.Ordinal) - .Trim(); - forwardLine = - $"WORLDOBJ|{safeLevel}|{safeType}|{state.X.ToString(CultureInfo.InvariantCulture)}|{state.Y.ToString(CultureInfo.InvariantCulture)}|{state.Flags.ToString(CultureInfo.InvariantCulture)}\n"; - } - } - return true; - } - if (line.StartsWith("MOBEVENT|", StringComparison.OrdinalIgnoreCase)) { var payload = line["MOBEVENT|".Length..]; @@ -991,7 +959,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) } else if (ev == "die") { - var die = new MobDie(effectiveUserId, u.Index, u.X, u.Y, u.Type, u.Generation); + var die = new MobDie(effectiveUserId, u.Index, u.X, u.Y, u.Generation); _pendingMobDies.Add(die); if (_role == NetRole.Host && senderId.HasValue) hasDieToForward = true; diff --git a/server/server.NetNode.SendPublic.cs b/server/server.NetNode.SendPublic.cs index 6b8c476..2a1caaa 100644 --- a/server/server.NetNode.SendPublic.cs +++ b/server/server.NetNode.SendPublic.cs @@ -547,11 +547,6 @@ public void SendMobHit(int mobIndex, int hp, double x, double y, int generation } public void SendMobDie(int mobIndex, double x, double y, int generation = 0) - { - SendMobDie(mobIndex, x, y, string.Empty, generation); - } - - public void SendMobDie(int mobIndex, double x, double y, string type, int generation = 0) { if (_role != NetRole.Client && _role != NetRole.Host) return; @@ -560,10 +555,9 @@ public void SendMobDie(int mobIndex, double x, double y, string type, int genera if (ID <= 0) return; - var safeType = type ?? string.Empty; - var payload = string.IsNullOrWhiteSpace(safeType) - ? string.Create(CultureInfo.InvariantCulture, $"MOBDIE|{ID}|{mobIndex}|{x}|{y}|{generation}") - : string.Create(CultureInfo.InvariantCulture, $"MOBDIE|{ID}|{mobIndex}|{x}|{y}|{generation}|{safeType}"); + var payload = string.Create( + CultureInfo.InvariantCulture, + $"MOBDIE|{ID}|{mobIndex}|{x}|{y}|{generation}"); SendRaw(payload); } @@ -728,45 +722,28 @@ public void SendInterPortal(double x, double y, string action) SendRaw($"INTERPORTAL|{action}|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}"); } - public void SendInterGenericActivate(double x, double y, string typeName) + + public void SendLobbyState(string username, string levelId, int seed, string progressSignature) { if (!HasAnyConnection()) return; - if (ID <= 0) - return; - var safeType = (typeName ?? string.Empty) - .Replace("|", "/", StringComparison.Ordinal) - .Replace("\r", string.Empty, StringComparison.Ordinal) - .Replace("\n", string.Empty, StringComparison.Ordinal) - .Trim(); - - SendRaw($"INTERGENACT|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}|{safeType}"); + 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 SendWorldObjectState(string levelId, string typeName, double x, double y, int flags) + public void SendRuneProgress(string csvPermanentIds) { if (!HasAnyConnection()) return; - if (ID <= 0) - return; - if (string.IsNullOrWhiteSpace(levelId) || string.IsNullOrWhiteSpace(typeName) || flags <= 0) - return; - - var safeLevel = (levelId ?? string.Empty) - .Replace("|", "/", StringComparison.Ordinal) - .Replace("\r", string.Empty, StringComparison.Ordinal) - .Replace("\n", string.Empty, StringComparison.Ordinal) - .Trim(); - var safeType = (typeName ?? string.Empty) - .Replace("|", "/", StringComparison.Ordinal) - .Replace("\r", string.Empty, StringComparison.Ordinal) - .Replace("\n", string.Empty, StringComparison.Ordinal) - .Trim(); - if (string.IsNullOrWhiteSpace(safeLevel) || string.IsNullOrWhiteSpace(safeType)) + if (string.IsNullOrWhiteSpace(csvPermanentIds)) return; - SendRaw($"WORLDOBJ|{safeLevel}|{safeType}|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}|{flags.ToString(CultureInfo.InvariantCulture)}"); + var safe = csvPermanentIds.Replace("|", "/").Replace("\r", string.Empty).Replace("\n", string.Empty); + SendRaw($"RUNEPROG|{safe}"); } private void SendRaw(string payload) diff --git a/server/server.NetNode.Steam.cs b/server/server.NetNode.Steam.cs index d5ce72a..b2e9684 100644 --- a/server/server.NetNode.Steam.cs +++ b/server/server.NetNode.Steam.cs @@ -12,26 +12,14 @@ internal bool TrySetSteamHostRichPresence(ulong lobbyId) return false; var connect = lobbyId == 0UL ? string.Empty : $"+connect_lobby {lobbyId}"; - - var success = true; if (!_steamBridge.TrySetRichPresence("connect", connect, out var error)) { if (!string.IsNullOrWhiteSpace(error)) - _log.Warning("[NetNode] Steam worker set rich presence connect failed: {Error}", error); - success = false; + _log.Warning("[NetNode] Steam worker set rich presence failed: {Error}", error); + return false; } - // These keys make Steam friend-list joins more reliable. Some clients receive a - // GameRichPresenceJoinRequested_t callback instead of a lobby callback, and that - // callback needs the connect string above. The group keys also help Steam associate - // the invite with the active lobby instead of only showing generic Dead Cells. - var group = lobbyId == 0UL ? string.Empty : lobbyId.ToString(CultureInfo.InvariantCulture); - if (!_steamBridge.TrySetRichPresence("steam_player_group", group, out error) && !string.IsNullOrWhiteSpace(error)) - _log.Warning("[NetNode] Steam worker set rich presence group failed: {Error}", error); - if (!_steamBridge.TrySetRichPresence("steam_player_group_size", _connectedClientCount <= 0 ? "1" : (_connectedClientCount + 1).ToString(CultureInfo.InvariantCulture), out error) && !string.IsNullOrWhiteSpace(error)) - _log.Warning("[NetNode] Steam worker set rich presence group size failed: {Error}", error); - - return success; + return true; } internal bool TryClearSteamRichPresence() diff --git a/server/server.cs b/server/server.cs index a6ff67d..0c3b0d0 100644 --- a/server/server.cs +++ b/server/server.cs @@ -359,8 +359,6 @@ public readonly struct MobDie public readonly int Generation; public readonly double X; public readonly double Y; - /// Optional mob type signature. v6.2 uses this to avoid killing/rebinding the wrong mob after sync-id drift. - public readonly string Type; public MobDie(int userId, int mobIndex, double x, double y, int generation = 0) { @@ -369,17 +367,6 @@ public MobDie(int userId, int mobIndex, double x, double y, int generation = 0) Generation = generation; X = x; Y = y; - Type = string.Empty; - } - - public MobDie(int userId, int mobIndex, double x, double y, string type, int generation = 0) - { - UserId = userId; - MobIndex = mobIndex; - Generation = generation; - X = x; - Y = y; - Type = type ?? string.Empty; } } @@ -593,8 +580,6 @@ private static bool TryTakeNextUnusedClientId(out int assignedId) private List _pendingInterBreakableGroundEvents = new(); private List _pendingBossRuneUpdateCells = new(); private List _pendingInterPortalEvents = new(); - private List _pendingInterGenericActivateEvents = new(); - private List _pendingWorldObjectStates = new(); private int _primaryRemoteId; private readonly IPEndPoint _bindEp; // host bind From d833f560ae77996e741896943c5a439de22f7d68 Mon Sep 17 00:00:00 2001 From: realMiksy Date: Tue, 7 Jul 2026 22:47:23 +0300 Subject: [PATCH 3/5] fix/DeadCellsCoopPlus v0.8.36 fixed crashes and bugs --- CHANGELOG.md | 13 +- CODE_OF_CONDUCT.md | 37 ++++ COMPATIBILITY_NOTES_v0.8.33.md | 2 +- CONTRIBUTING.md | 4 +- CONTRIBUTING_ru.md | 2 +- MERGE_NOTES_v0.8.32.md | 2 +- Mobs/MonsterSynchronization.ClientReceive.cs | 6 +- NOTICE.md | 12 ++ README.md | 195 +++++++++---------- README_ru.md | 155 +++++++-------- SECURITY.md | 35 ++++ 11 files changed, 259 insertions(+), 204 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 NOTICE.md create mode 100644 SECURITY.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fa8fbd9..092cc90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +# Changelog + # v0.8.36 ZDoor sublevel render guard - Keeps the v0.8.35 KingSkin fix for normal biome transitions. @@ -18,7 +20,7 @@ - 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`, Claude MobSync fixes, new Party HUD, and stable v0.8.2 exit behavior. +- 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 @@ -26,21 +28,18 @@ - 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 Claude MobSync fixes, Party HUD, and v0.8.2 exit behavior. +- 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 + Claude MobSync + safe new Party HUD +# 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 Claude 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. +- 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. -# Changelog - - ## v0.8.2 - Dynamic Contains build fix - Fixed C# dynamic dispatch compile error in `AdvancedCoop/CoopAdvancedHardening.cs`. 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 index 8d356a5..6966418 100644 --- a/COMPATIBILITY_NOTES_v0.8.33.md +++ b/COMPATIBILITY_NOTES_v0.8.33.md @@ -12,7 +12,7 @@ Dead Cells received a PC stability patch on 15 June 2026. The current executable ## Preserved features -- Claude MobSync fixes from v0.8.32 +- 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 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/MERGE_NOTES_v0.8.32.md b/MERGE_NOTES_v0.8.32.md index 90a1b99..d433612 100644 --- a/MERGE_NOTES_v0.8.32.md +++ b/MERGE_NOTES_v0.8.32.md @@ -7,7 +7,7 @@ This source intentionally uses the v0.8.2 rollback as the base. - `ModEntry/ModEntry.cs` behavior unchanged except for a build-identification log line. - Original level/door loading and ghost lifecycle. -## Ported from later Claude builds +## Ported from later stabilization builds - MobSync client culled-mob safety gates. - Deferred client mob death execution. - Ghost-mob despawn echo. diff --git a/Mobs/MonsterSynchronization.ClientReceive.cs b/Mobs/MonsterSynchronization.ClientReceive.cs index 3598a95..850b13b 100644 --- a/Mobs/MonsterSynchronization.ClientReceive.cs +++ b/Mobs/MonsterSynchronization.ClientReceive.cs @@ -1021,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)) @@ -1704,7 +1704,7 @@ private static bool ShouldReplayIncomingHitWithoutLifeDelta(Mob mob) /// /// 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 AI state was never proximity-initialized, and forcing them awake makes vanilla + /// 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. /// @@ -1769,7 +1769,7 @@ private static void TryWakeMobForForcedSimulation(Mob mob) // The wake is required on the authoritative HOST (it must simulate mobs that remote // players are fighting). On clients it only served cosmetic hit/death replays; waking - // a locally culled mob there runs vanilla AI on uninitialized state and crashes. + // a locally culled mob there runs vanilla behavior logic on uninitialized state and crashes. if (!IsHost(GameMenu.NetRef) && IsMobCulledLocally(mob)) return; 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 9060278..4032e89 100644 --- a/README.md +++ b/README.md @@ -1,140 +1,131 @@ -# Dead Cells Multiplayer Mod — Advanced Stable Co-op v0.8.2 +# DeadCellsCoopPlus — Stable Co-op v0.8.36 -This package is a Vaiser-base hardening build focused on fewer crashes, same-world co-op, cleaner UI, Steam friend invites, safer mob/boss sync, rune/progression sync, and exit failsafes. +DeadCellsCoopPlus is a community-maintained fork of Vaiser’s original **Dead Cells Multiplayer Mod**, built with the **Dead Cells Core Modding API (DCCM)**. -See `ADVANCED_COOP_NOTES.md` for the exact v0.8.2 changes. +This release focuses on stable two-player co-op, safer level transitions, improved enemy synchronization, Steam joining, revive support, and a cleaner Party HUD. + +See `CHANGELOG.md` for the complete version history and `SUBLEVEL_REWARD_DOOR_NOTES_v0.8.36.md` for the latest transition fix. ---
English • [Русский](README_ru.md) - -
-

Dead Cells Multiplayer Mod

- -**DeadCellsMultiplayerMod** is a **multiplayer / co-op mod for Dead Cells**, built using the **Dead Cells Core Modding API (DCCM)**. - -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**. - ---- - -## 🎮 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 - ---- - -## ⭐ Support the Project - -If you find this project interesting: -- ⭐ Star the repository -- 🍴 Fork the project and experiment - -Every bit of feedback helps improve multiplayer support for **Dead Cells**. - ---- + -## 🧰 Requirements +## 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 - **Dead Cells (PC)** - **Dead Cells Core Modding API (DCCM)** -- Local network, Steam, or virtual LAN software (for online play) - ---- - -## 📦 Installation +- Steam, a local network, or a compatible virtual LAN for online TCP play -### 1️⃣ Install Dead Cells Core Modding API (DCCM) +## Installation -If you are using the **Steam version** of the game, follow the official installation guide: +### 1. Install DCCM -👉 [https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/](https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/) +For the Steam version of Dead Cells, follow the official DCCM installation guide: -This method will automatically install and keep DCCM up to date. +https://dead-cells-core-modding.github.io/docs/docs/tutorial/install-workshop/ -### 2️⃣ Install DeadCellsMultiplayerMod +### 2. Install the mod -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. +Copy the built mod folder into: -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 - -Example: -``` -Your game path/ - └──coremod/ +```text +Dead Cells/ +└── coremod/ └── mods/ └── DeadCellsMultiplayerMod/ ``` -### 3️⃣ Run the game via DCCM +The installed folder should contain the compiled DLL, `modinfo.json`, and any required resource files produced by the project build. -Start **Dead Cells** using **DCCM**. -On the first launch, required configuration files will be generated automatically. +### 3. Start through DCCM ---- +Launch Dead Cells through DCCM. Configuration files are generated automatically on first launch. -## 🕹️ How to Play (Multiplayer) +## How to play -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 +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. -🌐 **For online play**, use one of the following: -- Hamachi -- Radmin VPN -- ZeroTier -- Steam P2P (built-in) +Both players should use the same mod build. For v0.8.36, the game log should contain: ---- +```text +[NetMod] Source build: v0.8.36-zdoor-sublevel-render-guard +``` -## 🧪 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 +## Tested v0.8.36 paths ---- +- 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 -## 📜 Credits +## Development status -- **Dead Cells Core Modding API (DCCM)** - https://github.com/dead-cells-core-modding/core +- [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 + +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 - +- **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 -### v0.8.1 build note -Fixed the missing `User` namespace import in the advanced hardening module. +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..ba05fda 100644 --- a/README_ru.md +++ b/README_ru.md @@ -1,123 +1,104 @@ -

Dead Cells Multiplayer Mod

+# DeadCellsCoopPlus — стабильный кооператив v0.8.36 -**DeadCellsMultiplayerMod** — это **мод для совместной игры / мультиплеера в Dead Cells**, собранный на **Dead Cells Core Modding API (DCCM)**. +DeadCellsCoopPlus — это поддерживаемый сообществом форк оригинального **Dead Cells Multiplayer Mod** от Vaiser, созданный с помощью **Dead Cells Core Modding API (DCCM)**. -Мод добавляет **кооператив / мультиплеер** через **локальную или виртуальную сеть**: -один игрок поднимает сервер, второй подключается — оба **проходят уровни вместе в реальном времени**. +Версия v0.8.36 направлена на стабильный кооператив для двух игроков, безопасные переходы между уровнями, улучшенную синхронизацию врагов, подключение через Steam, систему возрождения и обновлённый Party HUD. ---- - -## 🎮 Возможности - -- ✅ Синхронизация между двумя игроками в реальном времени -- ✅ Локальный TCP или Steam P2P мультиплеер -- ✅ Архитектура хост / клиент -- ✅ Автоматический старт игры для подключённого клиента -- ✅ Камера-спектатор — переключение между игроками клавишами `,` / `.` или геймпадом -- ✅ Синхронизация множителей HP боссов и руны босса -- ✅ Синхронизация атак мобов клиента и их прерывание -- ✅ Синхронизация оружия, головы и косметики призрака -- ✅ Обработка смерти/возрождения и рестарта -- ✅ Синхронизация перезагрузки графа уровня (босс-клетки, переходы) -- ✅ Слоты сохранения мультиплеера +Полная история изменений находится в `CHANGELOG.md`, а подробности последнего исправления переходов — в `SUBLEVEL_REWARD_DOOR_NOTES_v0.8.36.md`. --- -## ⭐ Поддержка проекта +
-Если проект вам интересен: -- ⭐ Поставьте звезду репозиторию -- 🍴 Сделайте форк и экспериментируйте +[English](README.md) • Русский -Любая обратная связь помогает улучшить мультиплеер для **Dead Cells**. +
---- +## Основные изменения v0.8.36 -## 🧰 Требования +- Исправлены падения при обычных переходах между биомами. +- Исправлены падения при входе в комнаты награды за время и прохождение без урона. +- Используется актуальный стандартный путь `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.36 в логе должна быть строка: ---- - -## 🕹️ Как играть (мультиплеер) +```text +[NetMod] Source build: v0.8.36-zdoor-sublevel-render-guard +``` -1. Запустите игру через **DCCM** -2. Нажмите **Play Multiplayer** -3. Выберите **Host** или **Join** -4. Введите **IP-адрес** и **порт** (TCP) или подключитесь через Steam -5. Когда хост начнёт игру, клиент автоматически подключится к сессии +## Проверенные переходы v0.8.36 -🌐 **Для игры по интернету** можно использовать: -- 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. From e42e60a0b2175dc0954c8acd27fc507b391ef410 Mon Sep 17 00:00:00 2001 From: realMiksy Date: Wed, 8 Jul 2026 01:51:06 +0300 Subject: [PATCH 4/5] DeadCellsCoopPlus v0.8.36 fixes for crashes and bugs --- .gitignore | 7 ++++++- CHANGELOG.md | 6 ++++++ DeadCellsMultiplayerMod.csproj | 2 +- ModEntry/ModEntry.cs | 2 +- README.md | 10 +++++----- README_ru.md | 13 +++++++------ UI/GameMenu.Connection.cs | 9 +++++++-- UI/GameMenu.cs | 1 + UI/GameMenuHooks.cs | 4 ++-- 9 files changed, 36 insertions(+), 18 deletions(-) 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/CHANGELOG.md b/CHANGELOG.md index 092cc90..d3ee9b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +# 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. diff --git a/DeadCellsMultiplayerMod.csproj b/DeadCellsMultiplayerMod.csproj index ecd26e3..bfad712 100644 --- a/DeadCellsMultiplayerMod.csproj +++ b/DeadCellsMultiplayerMod.csproj @@ -8,7 +8,7 @@ Windows $(NoWarn);CA1416;CS0414;CS9107 - 0.8.36 + 0.8.37 mod DeadCellsMultiplayerMod.ModEntry diff --git a/ModEntry/ModEntry.cs b/ModEntry/ModEntry.cs index bca9460..98169f8 100644 --- a/ModEntry/ModEntry.cs +++ b/ModEntry/ModEntry.cs @@ -407,7 +407,7 @@ 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.36-zdoor-sublevel-render-guard"); + entry.Logger.Information("[NetMod] Source build: v0.8.37-blue-multiplayer-menu"); Hook_Game.init += Hook_gameinit; Hook_Hero.wakeup += hook_hero_wakeup; Hook_Hero.onLevelChanged += hook_level_changed; diff --git a/README.md b/README.md index 4032e89..5f3cbb3 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# DeadCellsCoopPlus — Stable Co-op v0.8.36 +# DeadCellsCoopPlus — Stable Co-op v0.8.37 DeadCellsCoopPlus is a community-maintained fork of Vaiser’s original **Dead Cells Multiplayer Mod**, built with the **Dead Cells Core Modding API (DCCM)**. -This release focuses on stable two-player co-op, safer level transitions, improved enemy synchronization, Steam joining, revive support, and a cleaner Party HUD. +This release keeps the stable v0.8.36 co-op fixes and gives the main-menu **Play Multiplayer** entry a soft blue highlight. See `CHANGELOG.md` for the complete version history and `SUBLEVEL_REWARD_DOOR_NOTES_v0.8.36.md` for the latest transition fix. @@ -78,13 +78,13 @@ Launch Dead Cells through DCCM. Configuration files are generated automatically 4. The second player joins through Steam, lobby code, or the host address. 5. Start a run after both players are connected. -Both players should use the same mod build. For v0.8.36, the game log should contain: +Both players should use the same mod build. For v0.8.37, the game log should contain: ```text -[NetMod] Source build: v0.8.36-zdoor-sublevel-render-guard +[NetMod] Source build: v0.8.37-blue-multiplayer-menu ``` -## Tested v0.8.36 paths +## Tested v0.8.37 paths - Prisoners’ Quarters to the passage area - Passage forge and mutation area diff --git a/README_ru.md b/README_ru.md index ba05fda..eaa69df 100644 --- a/README_ru.md +++ b/README_ru.md @@ -1,8 +1,8 @@ -# DeadCellsCoopPlus — стабильный кооператив v0.8.36 +# DeadCellsCoopPlus — стабильный кооператив v0.8.37 DeadCellsCoopPlus — это поддерживаемый сообществом форк оригинального **Dead Cells Multiplayer Mod** от Vaiser, созданный с помощью **Dead Cells Core Modding API (DCCM)**. -Версия v0.8.36 направлена на стабильный кооператив для двух игроков, безопасные переходы между уровнями, улучшенную синхронизацию врагов, подключение через Steam, систему возрождения и обновлённый Party HUD. +Версия v0.8.37 сохраняет все исправления стабильности v0.8.36 и выделяет пункт **Play Multiplayer** в главном меню мягким голубым цветом. Полная история изменений находится в `CHANGELOG.md`, а подробности последнего исправления переходов — в `SUBLEVEL_REWARD_DOOR_NOTES_v0.8.36.md`. @@ -14,8 +14,9 @@ DeadCellsCoopPlus — это поддерживаемый сообществом -## Основные изменения v0.8.36 +## Основные изменения v0.8.37 +- Пункт **Play Multiplayer** в главном меню теперь выделен мягким голубым цветом. - Исправлены падения при обычных переходах между биомами. - Исправлены падения при входе в комнаты награды за время и прохождение без урона. - Используется актуальный стандартный путь `Level.init` игры для лучшей совместимости. @@ -66,13 +67,13 @@ Dead Cells/ ### 3. Запустите игру через DCCM -Оба игрока должны использовать одинаковую версию мода. Для v0.8.36 в логе должна быть строка: +Оба игрока должны использовать одинаковую версию мода. Для v0.8.37 в логе должна быть строка: ```text -[NetMod] Source build: v0.8.36-zdoor-sublevel-render-guard +[NetMod] Source build: v0.8.37-blue-multiplayer-menu ``` -## Проверенные переходы v0.8.36 +## Проверенные переходы v0.8.37 - Prisoners’ Quarters → переходная безопасная зона - Кузница и выбор мутаций 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 30f593a..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; 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; } From 132e51607dded70aae185fd18a5b4c2fb0f95261 Mon Sep 17 00:00:00 2001 From: realMiksy Date: Wed, 8 Jul 2026 02:03:56 +0300 Subject: [PATCH 5/5] DeadCellsCoopPlus v0.8.36 fixes for crashes and bugs --- CHANGELOG.md | 9 ++ DeadCellsMultiplayerMod.csproj | 2 +- Ghost/KingDiveAttackHooksBridge.cs | 126 +++++++++++++++++++++- ModEntry/ModEntry.GhostSync.cs | 7 ++ ModEntry/ModEntry.KingSkinRenderSafety.cs | 7 +- ModEntry/ModEntry.cs | 2 +- README.md | 8 +- README_ru.md | 12 +-- SUBLEVEL_DIVE_COMBAT_NOTES_v0.8.38.md | 13 +++ 9 files changed, 168 insertions(+), 18 deletions(-) create mode 100644 SUBLEVEL_DIVE_COMBAT_NOTES_v0.8.38.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ee9b1..17042ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 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`). diff --git a/DeadCellsMultiplayerMod.csproj b/DeadCellsMultiplayerMod.csproj index bfad712..3080916 100644 --- a/DeadCellsMultiplayerMod.csproj +++ b/DeadCellsMultiplayerMod.csproj @@ -8,7 +8,7 @@ Windows $(NoWarn);CA1416;CS0414;CS9107 - 0.8.37 + 0.8.38 mod DeadCellsMultiplayerMod.ModEntry 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/ModEntry/ModEntry.GhostSync.cs b/ModEntry/ModEntry.GhostSync.cs index 058fed2..2502bda 100644 --- a/ModEntry/ModEntry.GhostSync.cs +++ b/ModEntry/ModEntry.GhostSync.cs @@ -907,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 index f9b2864..6daaf9f 100644 --- a/ModEntry/ModEntry.KingSkinRenderSafety.cs +++ b/ModEntry/ModEntry.KingSkinRenderSafety.cs @@ -54,8 +54,11 @@ internal static void PrepareRemoteKingsForSubLevelTransition(string reason) ? "sublevel-transition" : reason; + var instance = Instance; + instance?.DrainRemoteCombatQueuesAfterLevelChange(); + instance?.MarkDiveNetGuardAfterSpawnOrRoomChange(); PrepareRemoteKingsForLevelTransition(s_subLevelRenderGuardReason); - Instance?.Logger.Information( + instance?.Logger.Information( "[NetMod][SubLevelGuard] armed reason={Reason}", s_subLevelRenderGuardReason); } @@ -142,6 +145,8 @@ private void CompleteRemoteKingSubLevelTransitionGuard(string completionReason) DisposeClientSlot(slot, clearIdentity: false); FinishRemoteKingLevelTransition(); + DrainRemoteCombatQueuesAfterLevelChange(); + MarkDiveNetGuardAfterSpawnOrRoomChange(); SendCurrentRoomTarget(force: true); GameMenu.EnqueueMainThreadCoalesced("ghost:receive-coords", ReceiveGhostCoords); diff --git a/ModEntry/ModEntry.cs b/ModEntry/ModEntry.cs index 98169f8..f5ceadc 100644 --- a/ModEntry/ModEntry.cs +++ b/ModEntry/ModEntry.cs @@ -407,7 +407,7 @@ 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.37-blue-multiplayer-menu"); + 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; diff --git a/README.md b/README.md index 5f3cbb3..3030e71 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# DeadCellsCoopPlus — Stable Co-op v0.8.37 +# DeadCellsCoopPlus — Stable Co-op v0.8.38 DeadCellsCoopPlus is a community-maintained fork of Vaiser’s original **Dead Cells Multiplayer Mod**, built with the **Dead Cells Core Modding API (DCCM)**. @@ -78,13 +78,13 @@ Launch Dead Cells through DCCM. Configuration files are generated automatically 4. The second player joins through Steam, lobby code, or the host address. 5. Start a run after both players are connected. -Both players should use the same mod build. For v0.8.37, the game log should contain: +Both players should use the same mod build. For v0.8.38, the game log should contain: ```text -[NetMod] Source build: v0.8.37-blue-multiplayer-menu +[NetMod] Source build: v0.8.38-sublevel-dive-combat-guard ``` -## Tested v0.8.37 paths +## v0.8.38 regression test checklist - Prisoners’ Quarters to the passage area - Passage forge and mutation area diff --git a/README_ru.md b/README_ru.md index eaa69df..57d21c0 100644 --- a/README_ru.md +++ b/README_ru.md @@ -1,8 +1,8 @@ -# DeadCellsCoopPlus — стабильный кооператив v0.8.37 +# DeadCellsCoopPlus — стабильный кооператив v0.8.38 DeadCellsCoopPlus — это поддерживаемый сообществом форк оригинального **Dead Cells Multiplayer Mod** от Vaiser, созданный с помощью **Dead Cells Core Modding API (DCCM)**. -Версия v0.8.37 сохраняет все исправления стабильности v0.8.36 и выделяет пункт **Play Multiplayer** в главном меню мягким голубым цветом. +Версия v0.8.38 сохраняет все исправления стабильности v0.8.36, голубой пункт **Play Multiplayer** и добавляет защиту от устаревших dive-атак после выхода из дополнительных комнат. Полная история изменений находится в `CHANGELOG.md`, а подробности последнего исправления переходов — в `SUBLEVEL_REWARD_DOOR_NOTES_v0.8.36.md`. @@ -14,7 +14,7 @@ DeadCellsCoopPlus — это поддерживаемый сообществом -## Основные изменения v0.8.37 +## Основные изменения v0.8.38 - Пункт **Play Multiplayer** в главном меню теперь выделен мягким голубым цветом. - Исправлены падения при обычных переходах между биомами. @@ -67,13 +67,13 @@ Dead Cells/ ### 3. Запустите игру через DCCM -Оба игрока должны использовать одинаковую версию мода. Для v0.8.37 в логе должна быть строка: +Оба игрока должны использовать одинаковую версию мода. Для v0.8.38 в логе должна быть строка: ```text -[NetMod] Source build: v0.8.37-blue-multiplayer-menu +[NetMod] Source build: v0.8.38-sublevel-dive-combat-guard ``` -## Проверенные переходы v0.8.37 +## Чек-лист проверки v0.8.38 - Prisoners’ Quarters → переходная безопасная зона - Кузница и выбор мутаций 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.