diff --git a/Changelog.md b/Changelog.md
index a9461dd..9a792df 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -1,3 +1,41 @@
+## 0.1.8-beta Version Changelog (English)
+
+### Fixes
+* Fixed beta 0.106.1 mod-load failure by building release packages against the currently installed game `sts2.dll`, whose `INetMessage` interface now requires `ShouldBuffer`.
+
+## 0.1.8-beta 更新日志(中文)
+
+### 修复
+* 修复 beta 0.106.1 下模组加载时报 `ReflectionTypeLoadException` 的问题:发布包现在会优先使用当前已安装游戏的 `sts2.dll` 编译,以匹配新增的 `INetMessage.ShouldBuffer` 接口成员。
+
+-------------------------------------------------------------------
+
+## 0.1.8 Version Changelog (English)
+
+### Fixes
+* Fixed beta 0.106 treasure-room desync by removing RMP's extra remote chest reward replay and leaving vanilla's one-off chest reward synchronization in control.
+* Fixed 16-player lobbies getting stuck around the vanilla-safe slots by routing 5+ player joins through RMP snapshots with 4-bit slot IDs, and by using the extended ready/begin-run flow for fixed-16 lobbies.
+
+## 0.1.8 版本更新日志(中文)
+
+### 修复
+* 修复 beta 0.106 中第一个宝箱后容易数据不同步的问题:移除 RMP 对远端开箱奖励的额外重放,避免重复生成奖励。
+* 修复 16 人房间被卡在原版安全槽位附近的问题:5 人及以上加入改用 RMP 快照同步 4-bit 槽位,并让固定 16 人房间统一走扩展准备/开局流程。
+
+-------------------------------------------------------------------
+
+## 0.1.4 Version Changelog (English)
+
+### Fixes
+* Fixed duplicated Settings entries. Reopening the Settings screen no longer stacks extra "Max Players" / "Difficulty Scaling" rows.
+
+## 0.1.4 版本更新日志(中文)
+
+### 修复
+* 修复设置界面重复注入的问题。现在反复打开设置界面时,不会再不断增加“房间人数上限 / 难度缩放”选项。
+
+-------------------------------------------------------------------
+
## 0.0.6 Version Changelog (English)
### Features
@@ -76,4 +114,4 @@
* **添加**跳过按钮,位于遗物宝箱选择界面
### Fixes
-* **修复**模组封面不显示的问题
\ No newline at end of file
+* **修复**模组封面不显示的问题
diff --git a/README.md b/README.md
index ab8d57d..2523d25 100644
--- a/README.md
+++ b/README.md
@@ -1,115 +1,123 @@
-# Remove Multiplayer Player Limit
+# Remove Multiplayer Player Limit Reforge
[**简体中文**](README_ZH.md) | [**Changelog**](Changelog.md)
-
+


+
-# This Repository Is No Longer Maintained
+*A Harmony-free Slay the Spire 2 mod that raises the vanilla 4-player multiplayer lobby limit to 16 players.*
-This repository will most likely no longer be maintained.
-
-Due to real-life and work-related reasons, I no longer have the time to continue development, fix bugs, review pull requests, or pretend that I still understand this project.
-
-This project contains a large amount of AI-generated code, and this dumbass developer no longer wants to review it line by line.
-
-From now on, you are free to use this project however you want.
-
-Fork it, rewrite it, feed it to a dog, or do whatever else you like.
-
-The license has been changed to **CC0**.
+
+This Reforge build replaces the old Harmony patch set with a reflection and Godot SceneTree based implementation. It keeps the original goal of RMP: larger multiplayer lobbies, cleaner large-party layouts, and optional difficulty scaling for groups beyond the vanilla 4-player cap.
-*A Slay the Spire 2 mod that increases the vanilla 4-player multiplayer lobby limit. Gather more friends and climb the Spire together!*
+`0.1.8-beta` is a tester build for the Slay the Spire 2 `beta 0.106.x` line.
-
+Recent fixes in this beta:
-This mod elegantly increases the multiplayer lobby limit. By default, it perfectly supports **8 players** and can be configured up to **16 players** from the in-game settings screen.
+- Fixed the `beta 0.106.1` mod-load failure caused by the updated `INetMessage.ShouldBuffer` interface requirement.
+- Fixed the first-treasure-room desync reported on `beta 0.106` by removing RMP's duplicate remote chest reward replay and leaving the game's one-off chest synchronization in control.
+- Fixed 5-16 player lobby flow by routing unsafe joins, ready state, and begin-run synchronization through the RMP extended lobby protocol.
+- Uses 4-bit slot IDs for player slots `0-15` and 5-bit list lengths for up to 16 lobby entries.
## ✨ Core Features
-* 👥 **Expanded Multiplayer:** Increases the maximum lobby size up to 16 players (default is 8). **Note: Lobbies with more than 8 players may experience rendering errors and UI issues.**
-* 🏕️ **Expanded Campfire Seating:** When there are more than 4 players, character models will not overlap. Campfires automatically generate front and back rows, complete with additional background logs for everyone to sit on.
-* 💰 **Organized Shop Layout:** When visiting the merchant with a large group, player models are automatically arranged into neat grids (rows and columns) to prevent crowding and overlapping.
-* 🎁 **Smart Treasure Room:** The relic selection screen automatically scales, intelligently splitting **relic slots** into two perfectly centered rows when needed.
-* 📝 **Customizable Limit:** Adjust the maximum player count (4–16) directly from the in-game settings screen, under the General tab below the Modding section. The macOS TLS workaround can be toggled via `config.ini`.
-* ⚔️ **Difficulty Scaling:** When enabled, monster HP, block, and power amounts continue to scale beyond the vanilla 4-player cap, keeping combat challenging for game. Can be toggled from the settings screen.
+* 👥 **Expanded Multiplayer:** Raises the multiplayer lobby cap from 4 to 16 players. In `0.1.8-beta`, the cap is fixed at 16 for every hosted lobby.
+* 🏕️ **Expanded Campfire Seating:** When there are more than 4 players, character models are arranged into additional rows instead of overlapping.
+* 💰 **Organized Shop Layout:** Large groups are arranged into a cleaner shop grid to reduce crowding and model overlap.
+* 🎁 **Smart Treasure Room:** Relic reward choices scale and reflow for larger groups, while treasure reward synchronization is left to the game's one-off synchronizer.
+* 📝 **Game Settings Entry:** Adds a Difficulty Scaling control under the game's settings screen. The old max-player paginator has been removed because the current build always hosts 16-player lobbies.
+* ⚔️ **Difficulty Scaling:** When enabled, monster HP, block, and power values continue scaling beyond the vanilla 4-player cap.
+* 🌐 **RMP Extended Lobby Protocol:** Adds a mod-network protocol for large-lobby snapshots, ready state, and begin-run flow without relying on unsafe vanilla lobby messages.
+* 🍎 **macOS TLS Workaround:** Provides a `config.ini` toggle for macOS multiplayer certificate issues such as `unknown ca` / `BadCert`.
+* 🚫 **No Harmony / MonoMod:** No `[HarmonyPatch]`, transpilers, or runtime method replacement. Reforge uses the official mod entry point plus reflection and injected Godot nodes.
## 🎮 Installation
### Windows
-1. Download the latest `sts2-RMP-[version].zip` from the **Releases** page.
-2. Extract the archive and copy the inner `RemoveMultiplayerPlayerLimit` folder to your game directory: `/mods/`.
-3. Launch the game. The mod will be enabled automatically.
+1. Download `sts2-RMP-0.1.8-beta.zip` from the release package.
+2. Extract the archive.
+3. Copy the inner `RemoveMultiplayerPlayerLimit` folder to:
+
+ ```text
+ /mods/
+ ```
+
+4. Launch the game. The mod will be loaded by the game mod loader.
### macOS (Apple Silicon)
-macOS requires placing the mod inside the `.app` bundle and running the game under Rosetta 2.
+macOS builds may require placing the mod inside the `.app` bundle and running the game under Rosetta 2.
-> **Note:** Some macOS players hit `unknown ca` / `BadCert` errors when joining multiplayer. This mod now enables a macOS-only TLS workaround during multiplayer handshakes. If you need the original behavior, edit `config.ini` to disable the workaround.
+> **Note:** Some macOS players hit `unknown ca` / `BadCert` errors when joining multiplayer. Reforge includes a macOS-only TLS compatibility workaround. If you need the original certificate behavior, edit `config.ini` and set `tls_workaround=false`.
-1. Download the latest `sts2-RMP-[version].zip` from the **Releases** page.
+1. Download `sts2-RMP-0.1.8-beta.zip` from the release package.
2. Extract the archive and copy the inner `RemoveMultiplayerPlayerLimit` folder to:
- ```
+
+ ```text
/SlayTheSpire2.app/Contents/MacOS/mods/
```
-3. Both launch methods below bypass Steam's normal launch flow. To avoid a **"Steam failed to initialize"** error, make sure the **Steam client is running** in the background and create a `steam_appid.txt` file next to the game executable:
+
+3. If launching directly causes **"Steam failed to initialize"**, keep the Steam client running in the background and create `steam_appid.txt` next to the game executable:
+
```bash
echo "2868840" > "$HOME/Library/Application Support/Steam/steamapps/common/Slay the Spire 2/SlayTheSpire2.app/Contents/MacOS/steam_appid.txt"
```
-4. Run the game under Rosetta 2 using **one** of these methods:
- **Option A — Finder (recommended):** Navigate to `SlayTheSpire2.app`, right-click > **Get Info**, and check **"Open using Rosetta"**. Then double-click `SlayTheSpire2.app` directly to launch (do **not** launch through Steam, as it may override the Rosetta setting).
+4. Run the game under Rosetta 2 using one of these methods:
+
+ **Option A - Finder:** Find `SlayTheSpire2.app`, right-click > **Get Info**, enable **"Open using Rosetta"**, then launch the app directly.
+
+ **Option B - Terminal:**
- **Option B — Terminal:** Open Terminal and run:
```bash
cd "$HOME/Library/Application Support/Steam/steamapps/common/Slay the Spire 2/SlayTheSpire2.app/Contents/MacOS"
arch -x86_64 "./Slay the Spire 2"
```
-5. The mod will be enabled automatically on launch.
-
-### Linux (Ubuntu)
+### Linux
-Linux uses the same mod folder layout as Windows, but the game executable and Godot binary names vary by distro/package.
+Linux uses the same mod folder layout as Windows:
-1. Download the latest `sts2-RMP-[version].zip` from the **Releases** page.
-2. Extract the archive and copy the inner `RemoveMultiplayerPlayerLimit` folder to:
- ```
- /mods/
- ```
-3. Start the game normally from Steam or your local executable.
-4. The mod will be enabled automatically on launch.
+```text
+/mods/
+```
-> **Compatibility note:** All players must use the same mod version. Local settings may differ safely; only the host's configured limit determines how many players can actually join the lobby.
+Start the game normally from Steam or your local executable.
-> **Linux troubleshooting:** If the mod fails during startup with a Harmony / `mm-exhelper.so` error mentioning `_Unwind_RaiseException`, make sure your system runtime libraries are available to the game process. Installing `libgcc-s1`, `libstdc++6`, and `libunwind8` is usually sufficient.
+> **Compatibility note:** All players in a lobby should use the same mod build. In `0.1.8-beta`, lobby capacity is fixed at 16; local config only controls difficulty scaling and the macOS TLS workaround.
## ⚙️ Configuration
-The **Max Players** paginator lets you adjust the lobby player limit (4–16) in real time. A **Difficulty Scaling** toggle is also available to control whether monster stats scale beyond 4 players.
+Runtime settings are saved to:
+
+```text
+mods/RemoveMultiplayerPlayerLimit/config.ini
+```
-The macOS TLS compatibility workaround can only be changed by editing `config.ini` manually.
+Current configurable values:
-Values are saved to `mods/RemoveMultiplayerPlayerLimit/config.ini`.
+* `tls_workaround`: macOS TLS compatibility workaround. This is only useful on macOS.
+* `difficulty_scaling`: whether monster stats keep scaling beyond 4 players.
Example:
@@ -118,11 +126,40 @@ Example:
tls_workaround=true
[multiplayer]
-max_player_limit=8
difficulty_scaling=true
```
-> **Important for upgrading from older releases:** If you already have `mods/RemoveMultiplayerPlayerLimit/config.json` from an older version, delete that file once before launching the new build. StS2 scans JSON files in the mod folder as manifests, but `config.ini` is safe.
+`max_player_limit` from older configs is intentionally ignored in this Reforge beta. The current lobby cap is fixed at 16.
+
+> **Important for upgrading from older releases:** If you still have `mods/RemoveMultiplayerPlayerLimit/config.json` from an old build, delete it before launching Reforge. Slay the Spire 2 scans JSON files in the mod folder as manifests, while `config.ini` is safe.
+
+## 🛠️ Building
+
+Requirements:
+
+* .NET 9 SDK
+* Godot 4.5.1 for PCK packaging
+* Slay the Spire 2 `sts2.dll` and `Steamworks.NET.dll`
+
+Build the DLL:
+
+```bash
+dotnet build -c Release
+```
+
+Build a full release package:
+
+```powershell
+pwsh tools/build_release.ps1 -Configuration Release
+```
+
+For beta game branches, build against the installed game's current `sts2.dll`:
+
+```powershell
+pwsh tools/build_release.ps1 -Configuration Release -Sts2AssemblyPath "/data_sts2_windows_x86_64/sts2.dll"
+```
+
+The release script also tries to find the installed game assembly automatically. This matters because game beta updates can change mod-facing interfaces such as `INetMessage`.
## Contributors
@@ -142,4 +179,3 @@ Special thanks to the following contributors:
-
diff --git a/README_ZH.md b/README_ZH.md
index 4d9e48c..4acc366 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -1,103 +1,123 @@
-# 杀戮尖塔2 联机上限解锁 (Remove Multiplayer Player Limit)
+# 杀戮尖塔2 联机上限解锁 Reforge
-[**English**](README.md) | [**更改日志**](Changelog.md)
+[**English**](README.md) | [**更新日志**](Changelog.md)
-
+


+
-*一款《杀戮尖塔2》的联机人数上限解锁模组。打破原版 4 人的限制,喊上更多的好友一起爬塔吧!*
+*一款无 Harmony / MonoMod 的《杀戮尖塔2》联机人数上限解锁模组,将原版 4 人房间扩展到 16 人。*
-本模组突破了《杀戮尖塔2》原版的联机限制。默认提供完美适配的 **8 人**游玩体验,并可在游戏内设置中最高解锁至 **16 人**。
+Reforge 版不是旧 Harmony 补丁的继续堆叠,而是一次重写:它保留 RMP 的目标,让更多玩家一起联机,同时改善多人营地、商店、宝箱房布局,并为 4 人以上队伍提供可选难度缩放。
+
+`0.1.8-beta` 是面向《杀戮尖塔2》`beta 0.106.x` 附近版本的玩家测试版。
+
+本测试版包含以下关键修复:
+
+- 修复 `beta 0.106.1` 下因为游戏新增 `INetMessage.ShouldBuffer` 接口成员导致的模组加载失败。
+- 修复 `beta 0.106` 中第一个宝箱后容易数据不同步的问题:移除 RMP 对远端开箱奖励的重复重放,交回原版一次性宝箱同步流程处理。
+- 修复 5-16 人大厅流程:超过原版安全槽位的加入、准备状态和开局同步会走 RMP 扩展大厅协议。
+- 玩家槽位使用 4-bit 表示 `0-15`,玩家列表长度使用 5-bit 表示,覆盖当前最多 16 人的目标。
## ✨ 核心功能
-* 👥 **突破人数限制:** 最高支持 16 人同时联机游玩(默认推荐 8 人)。**注意:超过 8 人时可能会出现渲染错误等问题。**
-* 🏕️ **营地座位扩容:** 超过 4 人时,角色不会重叠在一起。营地会自动增加额外的前后排座位,并生成对应的“原木”背景,确保每个玩家都有位置。
-* 💰 **商店阵列排布:** 多人同屏时,商店里的角色模型会自动排列成多行多列,告别拥挤和模型穿模。
-* 🎁 **宝箱房自适应布局:** 遗物分配界面会根据当前房间的人数自动缩放,智能拆分为双排并居中对齐,让每个人都能清晰地选择遗物。
-* 📝 **游戏内设置入口:** 可直接在游戏设置界面的「游戏设置」标签页中,Modding 行下方调整房间人数上限。macOS TLS 兼容补丁可通过 `config.ini` 手动操控。
-* ⚔️ **难度缩放:** 开启后,怪物血量、格挡及能力数值将在超过原版 4 人上限后持续提升,让游戏的挑战性有所保证。可在设置界面随时开关。
-
+* 👥 **突破人数限制:** 将联机房间人数上限从 4 人提升到 16 人。`0.1.8-beta` 中所有房间固定按 16 人容量创建。
+* 🏕️ **营地座位扩容:** 超过 4 人时,角色不会重叠在一起,而是自动排列到额外座位和队列中。
+* 💰 **商店阵列排布:** 多人同屏时,商店里的角色模型会自动排列成更清晰的网格,减少拥挤和穿模。
+* 🎁 **宝箱房自适应布局:** 遗物分配界面会根据人数缩放和重排,同时宝箱奖励同步交由游戏原版一次性同步流程处理。
+* 📝 **游戏内设置入口:** 在游戏设置界面中加入难度缩放开关。旧版人数上限分页器已移除,因为当前测试版固定使用 16 人房间。
+* ⚔️ **难度缩放:** 开启后,怪物血量、格挡及能力数值将在超过原版 4 人上限后继续提升。
+* 🌐 **RMP 扩展大厅协议:** 为大房间快照、准备状态和开局流程提供模组网络协议,避免继续依赖原版不安全的大房间消息。
+* 🍎 **macOS TLS 兼容补丁:** 通过 `config.ini` 为 macOS 联机中的 `unknown ca` / `BadCert` 问题提供开关。
+* 🚫 **无 Harmony / MonoMod:** 不使用 `[HarmonyPatch]`、Transpiler 或运行时方法替换。Reforge 使用官方 Mod 入口、反射和注入的 Godot 节点。
## 🎮 玩家安装说明
### Windows
-1. 从 **Releases** 页面下载最新的 `sts2-RMP-[version].zip` 压缩包。
-2. 解压并将内部的 `RemoveMultiplayerPlayerLimit` 文件夹整体复制到游戏的 `/mods/` 目录下。
-3. 启动游戏,模组将自动启用。
+1. 下载 `sts2-RMP-0.1.8-beta.zip`。
+2. 解压压缩包。
+3. 将内部的 `RemoveMultiplayerPlayerLimit` 文件夹复制到:
-### macOS (Apple Silicon)
+ ```text
+ /mods/
+ ```
-> **注意:** 部分 macOS 玩家联机时会遇到 `unknown ca` / `BadCert`。当前版本会在多人握手阶段启用一个仅限 macOS 的 TLS 兼容补丁;如果你想恢复原始证书校验行为,可以手动编辑 `config.ini` 关闭它。
+4. 启动游戏,模组会由游戏的模组加载器载入。
-macOS 需要将模组放入 `.app` 包内部,并通过 Rosetta 2 运行游戏。
+### macOS (Apple Silicon)
-> **注意:** 尚未进行联机实测,仅能保证正确打开游戏并且右下角显示正常载入模组。
+macOS 版游戏可能需要将模组放入 `.app` 包内部,并通过 Rosetta 2 运行游戏。
-1. 从 **Releases** 页面下载最新的 `sts2-RMP-[version].zip` 压缩包。
+> **注意:** 部分 macOS 玩家联机时会遇到 `unknown ca` / `BadCert`。Reforge 包含仅限 macOS 的 TLS 兼容补丁;如果你想恢复原始证书校验行为,可以编辑 `config.ini` 并设置 `tls_workaround=false`。
+
+1. 下载 `sts2-RMP-0.1.8-beta.zip`。
2. 解压并将内部的 `RemoveMultiplayerPlayerLimit` 文件夹复制到:
- ```
+
+ ```text
/SlayTheSpire2.app/Contents/MacOS/mods/
```
-3. 以下两种启动方式都绕过了 Steam 的正常启动流程。为避免出现 **"Steam failed to initialize"** 错误,请确保 **Steam 客户端正在后台运行**,并在游戏可执行文件旁创建 `steam_appid.txt` 文件:
+
+3. 如果直接启动游戏时出现 **"Steam failed to initialize"**,请保持 Steam 客户端在后台运行,并在游戏可执行文件旁创建 `steam_appid.txt`:
+
```bash
echo "2868840" > "$HOME/Library/Application Support/Steam/steamapps/common/Slay the Spire 2/SlayTheSpire2.app/Contents/MacOS/steam_appid.txt"
```
-4. 通过 Rosetta 2 运行游戏,任选**其一**:
- **方式 A — 访达(推荐):** 找到 `SlayTheSpire2.app`,右键 > **显示简介**,勾选 **"以 Rosetta 方式打开"**,然后直接双击 `SlayTheSpire2.app` 启动(**不要**通过 Steam 启动,Steam 可能会忽略 Rosetta 设置)。
+4. 通过 Rosetta 2 运行游戏,任选其一:
+
+ **方式 A - 访达:** 找到 `SlayTheSpire2.app`,右键 > **显示简介**,勾选 **"以 Rosetta 方式打开"**,然后直接启动该 app。
+
+ **方式 B - 终端:**
- **方式 B — 终端:** 打开"终端"应用,输入以下命令:
```bash
cd "$HOME/Library/Application Support/Steam/steamapps/common/Slay the Spire 2/SlayTheSpire2.app/Contents/MacOS"
arch -x86_64 "./Slay the Spire 2"
```
-5. 启动游戏,模组将自动启用。
+### Linux
-### Linux (Ubuntu)
+Linux 使用与 Windows 相同的模组目录结构:
-Linux 的模组目录结构和 Windows 基本一致,但不同发行版里游戏可执行文件和 Godot 命令名可能不同。
-
-1. 从 **Releases** 页面下载最新的 `sts2-RMP-[version].zip` 压缩包。
-2. 解压并将内部的 `RemoveMultiplayerPlayerLimit` 文件夹整体复制到游戏的:
- ```
- /mods/
- ```
-3. 正常通过 Steam 或本地可执行文件启动游戏即可。
-4. 启动游戏后,模组将自动启用。
+```text
+/mods/
+```
-> **兼容性说明:** 所有玩家仍然必须使用同一版本的模组;但现在允许每个人本地设置的人数上限不同,真正生效的入房人数以上主机配置为准。
+之后正常通过 Steam 或本地可执行文件启动游戏即可。
-> **Linux 排错:** 如果模组启动时因为 Harmony / `mm-exhelper.so` 报 `_Unwind_RaiseException` 而初始化失败,通常是系统运行库没有被游戏进程正确看到。一般安装 `libgcc-s1`、`libstdc++6` 和 `libunwind8` 就够了。
+> **兼容性说明:** 同一房间内的所有玩家应使用同一个模组版本。`0.1.8-beta` 的房间容量固定为 16;本地配置只控制难度缩放和 macOS TLS 兼容补丁。
## ⚙️ 配置说明
-打开游戏内的设置界面,在「游戏设置」标签页中往下滚动到 **Modding** 行的下方,即可实时调整房间人数上限(4–16)。**难度缩放**开关也在同一位置,控制怪物数值是否在超过 4 人后继续提升。
+运行时配置保存在:
+
+```text
+mods/RemoveMultiplayerPlayerLimit/config.ini
+```
-macOS TLS 兼容补丁仅可通过手动编辑 `config.ini` 来开关。
+当前可配置项:
-配置会保存到 `mods/RemoveMultiplayerPlayerLimit/config.ini`。
+* `tls_workaround`:macOS TLS 兼容补丁,仅对 macOS 有意义。
+* `difficulty_scaling`:是否让怪物数值在 4 人以上继续缩放。
示例:
@@ -106,11 +126,40 @@ macOS TLS 兼容补丁仅可通过手动编辑 `config.ini` 来开关。
tls_workaround=true
[multiplayer]
-max_player_limit=8
difficulty_scaling=true
```
-> **从旧版本升级时请注意:** 如果你的模组目录里还留着旧版生成的 `mods/RemoveMultiplayerPlayerLimit/config.json`,请先手动删除一次再启动新版本。StS2 会把模组目录下的 JSON 当成 manifest 扫描,但 `config.ini` 是安全的。
+旧配置中的 `max_player_limit` 在当前 Reforge 测试版中会被有意忽略。当前房间人数上限固定为 16。
+
+> **从旧版本升级时请注意:** 如果你的模组目录里还留着旧版生成的 `mods/RemoveMultiplayerPlayerLimit/config.json`,请先删除它再启动 Reforge。《杀戮尖塔2》会把模组目录里的 JSON 文件当成 manifest 扫描,但 `config.ini` 是安全的。
+
+## 🛠️ 构建
+
+需要:
+
+* .NET 9 SDK
+* Godot 4.5.1,用于打包 PCK
+* 《杀戮尖塔2》的 `sts2.dll` 和 `Steamworks.NET.dll`
+
+只构建 DLL:
+
+```bash
+dotnet build -c Release
+```
+
+构建完整发布包:
+
+```powershell
+pwsh tools/build_release.ps1 -Configuration Release
+```
+
+如果要针对 beta 分支构建,建议显式使用当前已安装游戏的 `sts2.dll`:
+
+```powershell
+pwsh tools/build_release.ps1 -Configuration Release -Sts2AssemblyPath "/data_sts2_windows_x86_64/sts2.dll"
+```
+
+发布脚本也会尝试自动查找当前安装的游戏程序集。这样做很重要,因为游戏 beta 更新可能改变 `INetMessage` 这类模组接口。
## 鸣谢
@@ -130,5 +179,3 @@ difficulty_scaling=true
-
-
diff --git a/RemoveMultiplayerPlayerLimit.csproj b/RemoveMultiplayerPlayerLimit.csproj
index b7e63e2..7e4595e 100644
--- a/RemoveMultiplayerPlayerLimit.csproj
+++ b/RemoveMultiplayerPlayerLimit.csproj
@@ -5,14 +5,33 @@
true
enable
12.0
+ RemoveMultiplayerPlayerLimit
+ $(STS2GamePath)\data_sts2_windows_x86_64\sts2.dll
+ libs\sts2.dll
+ $(STS2GamePath)\data_sts2_windows_x86_64\Steamworks.NET.dll
+ libs\Steamworks.NET.dll
+
+
- libs\sts2.dll
+ $(Sts2AssemblyPath)
+ false
-
- libs\0Harmony.dll
+
+ $(SteamworksAssemblyPath)
+ false
+
+
+
+
+
+
diff --git a/RemoveMultiplayerPlayerLimit.json b/RemoveMultiplayerPlayerLimit.json
index 2f95f85..e6a6a3c 100644
--- a/RemoveMultiplayerPlayerLimit.json
+++ b/RemoveMultiplayerPlayerLimit.json
@@ -2,11 +2,11 @@
"id": "RemoveMultiplayerPlayerLimit",
"pck_name": "RemoveMultiplayerPlayerLimit",
"name": "RemoveMultiplayerPlayerLimit",
- "version": "0.0.6",
+ "version": "0.1.8-beta",
"author": "Rain_G",
- "description": "Raise multiplayer player cap from 4 to 16.",
+ "description": "Raise multiplayer player cap from 4 to 16. Harmony-free rewrite with Steamworks.NET.",
"dependencies": [],
"has_pck": true,
"has_dll": true,
"affects_gameplay": true
-}
\ No newline at end of file
+}
diff --git a/RemoveMultiplayerPlayerLimit/mod_image.png.import b/RemoveMultiplayerPlayerLimit/mod_image.png.import
new file mode 100644
index 0000000..68da683
--- /dev/null
+++ b/RemoveMultiplayerPlayerLimit/mod_image.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://4mtfrfx5t8n4"
+path="res://.godot/imported/mod_image.png-3af8143f1ee0c1b0896af048894a675f.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://RemoveMultiplayerPlayerLimit/mod_image.png"
+dest_files=["res://.godot/imported/mod_image.png-3af8143f1ee0c1b0896af048894a675f.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/build/RemoveMultiplayerPlayerLimit.pck b/build/RemoveMultiplayerPlayerLimit.pck
deleted file mode 100644
index f6b06f2..0000000
Binary files a/build/RemoveMultiplayerPlayerLimit.pck and /dev/null differ
diff --git a/build/RemoveMultiplayerPlayerLimit/RemoveMultiplayerPlayerLimit.dll b/build/RemoveMultiplayerPlayerLimit/RemoveMultiplayerPlayerLimit.dll
deleted file mode 100644
index d634698..0000000
Binary files a/build/RemoveMultiplayerPlayerLimit/RemoveMultiplayerPlayerLimit.dll and /dev/null differ
diff --git a/build/RemoveMultiplayerPlayerLimit/RemoveMultiplayerPlayerLimit.json b/build/RemoveMultiplayerPlayerLimit/RemoveMultiplayerPlayerLimit.json
deleted file mode 100644
index 2f95f85..0000000
--- a/build/RemoveMultiplayerPlayerLimit/RemoveMultiplayerPlayerLimit.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "id": "RemoveMultiplayerPlayerLimit",
- "pck_name": "RemoveMultiplayerPlayerLimit",
- "name": "RemoveMultiplayerPlayerLimit",
- "version": "0.0.6",
- "author": "Rain_G",
- "description": "Raise multiplayer player cap from 4 to 16.",
- "dependencies": [],
- "has_pck": true,
- "has_dll": true,
- "affects_gameplay": true
-}
\ No newline at end of file
diff --git a/build/RemoveMultiplayerPlayerLimit/RemoveMultiplayerPlayerLimit.pck b/build/RemoveMultiplayerPlayerLimit/RemoveMultiplayerPlayerLimit.pck
deleted file mode 100644
index f6b06f2..0000000
Binary files a/build/RemoveMultiplayerPlayerLimit/RemoveMultiplayerPlayerLimit.pck and /dev/null differ
diff --git a/doc/CLAUDE.md b/doc/CLAUDE.md
new file mode 100644
index 0000000..f14987c
--- /dev/null
+++ b/doc/CLAUDE.md
@@ -0,0 +1,60 @@
+# CLAUDE.md
+
+## Project
+sts2-RMP-Mods: Slay the Spire 2 — Remove Multiplayer Player Limit.
+Increases the vanilla 4-player lobby cap to 4–16 players, with campfire/shop/treasure UI adaptation, difficulty scaling, RMP network protocol, and macOS TLS fix.
+
+## CRITICAL — Refactoring in Progress
+This project is being refactored to COMPLETELY REMOVE Harmony/MonoMod.
+The v1.0 target is a 100% Harmony-free codebase.
+
+## Core Constraints — ABSOLUTE RULES
+- **NEVER** use `[HarmonyPatch]`, `[HarmonyPrefix]`, `[HarmonyPostfix]`, `[HarmonyTranspiler]`
+- **NEVER** import `HarmonyLib`, `0Harmony`, `MonoMod`
+- **NEVER** call `harmony.PatchAll()` or any runtime method replacement
+- **NEVER** add `Lib.Harmony` as a NuGet/DLL reference
+- **NEVER** use `.json` for config files (game scans .json as manifests → use `.ini`)
+
+## Replacement Techniques
+- `[ModInitializer("Initialize")]` as sole entry point
+- `ReflectionCache` for cached field/method access to game internals
+- Godot `SceneTree` Node injection for UI and scene modifications
+- `_Process()` polling for state change detection (replaces Prefix/Postfix hooks)
+- Custom Godot RPC for mod-to-mod network communication
+
+## Tech Stack
+- C# / .NET 9.0+ / Godot 4.5.1 (MegaDot)
+- Game assembly: sts2.dll (reference only)
+- Entry: `[ModInitializer("Initialize")]`
+- Config: config.ini (INI format)
+- PCK: Must use Godot 4.5.1 to pack
+
+## Architecture
+- `src/Core/` → ModEntry, lifecycle, config
+- `src/Infrastructure/` → ReflectionCache, SceneMonitor, GameStateAccessor
+- `src/Features/` → Each feature is an independent module (LobbyExpander, DifficultyScaling, CampfireLayout, ShopLayout, TreasureRoom, SettingsUI)
+- `src/Network/` → RMP protocol (independent mod network channel)
+- `src/Platform/` → Platform detection, macOS TLS workaround
+
+## Game Source Code
+Decompiled source: [USER SET THIS PATH]
+Priority analysis targets:
+1. Multiplayer max player limit field/constant
+2. Monster HP/block/power initialization with player count scaling
+3. Campfire/shop/treasure room scene node structure
+4. Settings screen UI tree structure
+5. Multiplayer lobby creation/join validation
+
+## Build
+```bash
+dotnet build # Debug
+dotnet build -c Release # Release → auto-copies to game mods/
+```
+
+## Key Facts
+- Mod ID: RemoveMultiplayerPlayerLimit
+- Current Harmony-based version: 0.0.6
+- Target Harmony-free version: 1.0.0
+- Platforms: Windows, macOS (ARM64 + x64), Linux
+- All players must use same mod version
+- Only host's limit config determines lobby size
diff --git a/doc/Changelog.md b/doc/Changelog.md
new file mode 100644
index 0000000..33c03cf
--- /dev/null
+++ b/doc/Changelog.md
@@ -0,0 +1,142 @@
+## 0.1.8-beta Changelog (English)
+
+### Fixes
+* Fixed beta 0.106.1 mod-load failure by building release packages against the currently installed game `sts2.dll`, whose `INetMessage` interface now requires `ShouldBuffer`.
+
+## 0.1.8-beta 更新日志(中文)
+
+### 修复
+* 修复 beta 0.106.1 下模组加载时报 `ReflectionTypeLoadException` 的问题:发布包现在会优先使用当前已安装游戏的 `sts2.dll` 编译,以匹配新增的 `INetMessage.ShouldBuffer` 接口成员。
+
+-------------------------------------------------------------------
+
+## 0.1.8 Changelog (English)
+
+### Fixes
+* Fixed beta 0.106 treasure-room desync by removing RMP's extra remote chest reward replay and leaving vanilla's one-off chest reward synchronization in control.
+* Fixed 16-player lobbies getting stuck around the vanilla-safe slots by routing 5+ player joins through RMP snapshots with 4-bit slot IDs, and by using the extended ready/begin-run flow for fixed-16 lobbies.
+
+## 0.1.8 更新日志(中文)
+
+### 修复
+* 修复 beta 0.106 中第一个宝箱后容易数据不同步的问题:移除 RMP 对远端开箱奖励的额外重放,避免重复生成奖励。
+* 修复 16 人房间被卡在原版安全槽位附近的问题:5 人及以上加入改用 RMP 快照同步 4-bit 槽位,并让固定 16 人房间统一走扩展准备/开局流程。
+
+-------------------------------------------------------------------
+
+## 0.1.4 Changelog (English)
+
+### Fixes
+* Fixed duplicated Settings entries. Reopening the Settings screen no longer stacks extra "Max Players" / "Difficulty Scaling" rows.
+
+## 0.1.4 更新日志(中文)
+
+### 修复
+* 修复设置界面重复注入的问题。现在反复打开设置界面时,不会再不断增加“房间人数上限 / 难度缩放”选项。
+
+-------------------------------------------------------------------
+
+## 0.1.2 Changelog (English)
+
+全部重构,不再使用 Harmony。改为反射 + SceneTree 注入,Steamworks.NET 直接调 API 修 Steam 大厅人数限制。
+
+### Changes
+* Entire codebase rewritten from scratch without Harmony / MonoMod — uses reflection + SceneTree injection instead.
+* Steam lobby player limit fixed via direct Steamworks.NET API (`SteamMatchmaking.SetLobbyMemberLimit()`).
+* Campfire crash fixed for 5+ players (pre-inject extra character containers before `_Ready()`).
+* Settings paginator fixed — NPaginator uses virtual method override, not Godot signals.
+* All platforms (Windows / macOS / Linux) work natively, no more Harmony-related crashes.
+
+## 0.1.2 更改日志(中文)
+
+全部重构,不再使用 Harmony。改为反射 + SceneTree 注入,Steamworks.NET 直接调 API 修 Steam 大厅人数限制。
+
+### 更改
+* 整个代码库从零重写,不再依赖 Harmony / MonoMod,改用反射 + SceneTree 注入。
+* Steam 大厅人数限制通过 Steamworks.NET 直接调用 API 修复。
+* 修复 5 人以上进营火崩溃的问题(在 `_Ready()` 之前预注入额外的角色容器)。
+* 修复设置界面翻页器报错(NPaginator 用虚方法 override 替代不存在的 Godot 信号)。
+* 所有平台(Windows / macOS / Linux)原生运行,不再有 Harmony 相关崩溃。
+
+
+-------------------------------------------------------------------
+
+## 0.0.6 Version Changelog (English)
+
+### Features
+* Added monster difficulty scaling for 5+ players: monster HP, block, and power amounts now continue to scale beyond the vanilla 4-player cap using the official formula.
+* Added a "Difficulty Scaling" toggle in the Settings screen to enable or disable this feature.
+
+### Improvements
+* Introduced a fully independent mod network protocol channel (RMP protocol) that runs concurrently alongside the official packet system without interference.
+
+## 0.0.6 版本更改日志(中文)
+
+### 新功能
+* 新增 5 人以上怪物难度缩放:怪物血量、格挡及能力数值将在原版 4 人上限之后继续按官方公式提升。
+* 在游戏设置界面新增"难度缩放"开关,可随时启用或关闭该功能。
+
+### 改进
+* 引入完全独立的模组网络协议通道(RMP 协议),与官方数据包系统并行运行,互不干扰。
+
+
+-------------------------------------------------------------------
+
+## 0.0.5A Version Changelog (English)
+
+### Features
+* Added an in-game settings entry that lets players adjust the multiplayer lobby limit in real time from the Settings screen, supporting 4-16 players.
+* Added Linux platform support.
+* Added macOS platform support.
+
+### Improvements
+* Migrated the configuration format to config.ini and removed unused config entries.
+* When the relic pool is exhausted and treasure rooms can no longer roll a relic, the reward now falls back to Strawberry.
+* Improved multiplayer compatibility.
+
+### Fixes
+* Fixed join timeouts, state desync, and handshake failures caused by mismatched protocol bit widths.
+
+## 0.0.5A 版本更改日志(中文)
+
+### 新功能
+* 新增游戏内设置入口,可在"游戏设置"界面中实时调整联机房间人数上限,支持 4-16。
+* 新增 Linux 平台支持。
+* 新增 MacOS 平台支持。
+
+### 改进
+* 将配置文件优化为 config.ini 文件格式,删除无用配置项。
+* 当遗物被拿完,箱子无法开出遗物时,填充为"草莓"。
+* 改进联机兼容性。
+
+### 修复
+* 修复因协议位宽不一样导致的联机加入超时、状态错位和握手失败的问题。
+
+
+-------------------------------------------------------------------
+
+## 0.0.4A Version Changelog (English)
+
+### Improvements
+* **Optimize** project structure
+* **Optimize** the Relic Chest selection UI for 8+ Players
+
+### Features
+* **Add** localization
+* **Add** a SKIP button on Relic Chest selection screen
+
+### Fixes
+* **Fixed** mod covers do not display issues
+
+## 0.0.4A 版本更改日志 (中文)
+
+### Improvements
+* **优化**项目结构
+* **优化**遗物选择界面,现支持8+以上的玩家进行遗物选择
+
+### Features
+* **添加**本地化功能
+* **添加**跳过按钮,位于遗物宝箱选择界面
+
+### Fixes
+* **修复**模组封面不显示的问题
diff --git a/doc/README.md b/doc/README.md
new file mode 100644
index 0000000..bca8ee0
--- /dev/null
+++ b/doc/README.md
@@ -0,0 +1,16 @@
+# Reforge Developer Documentation
+
+Player-facing installation and feature documentation lives in the repository root:
+
+- [English README](../README.md)
+- [简体中文 README](../README_ZH.md)
+- [Changelog](../Changelog.md)
+
+This `doc/` directory is for development notes and migration references:
+
+- [Feature specifications](feature-specs.md)
+- [Harmony to reflection migration guide](migration-guide.md)
+- [Refactoring development log](dev-log-v1.0-refactoring.md)
+- [Release changelog mirror](Changelog.md)
+
+Reforge is a Harmony-free rewrite. Do not add Harmony, MonoMod, runtime transpilers, or JSON config files.
diff --git a/doc/README_ZH.md b/doc/README_ZH.md
new file mode 100644
index 0000000..ea78da1
--- /dev/null
+++ b/doc/README_ZH.md
@@ -0,0 +1,16 @@
+# Reforge 开发文档
+
+面向玩家的安装、功能和截图说明位于仓库根目录:
+
+- [English README](../README.md)
+- [简体中文 README](../README_ZH.md)
+- [更新日志](../Changelog.md)
+
+此 `doc/` 目录用于保存开发说明和迁移参考:
+
+- [功能规格](feature-specs.md)
+- [Harmony 到反射迁移指南](migration-guide.md)
+- [重构开发日志](dev-log-v1.0-refactoring.md)
+- [发布日志镜像](Changelog.md)
+
+Reforge 是无 Harmony 重写版。不要加入 Harmony、MonoMod、运行时 Transpiler,也不要使用 JSON 作为模组配置文件。
diff --git a/doc/SKILL.md b/doc/SKILL.md
new file mode 100644
index 0000000..3fae97a
--- /dev/null
+++ b/doc/SKILL.md
@@ -0,0 +1,629 @@
+---
+name: sts2-rmp-mod
+description: |
+ Skill for developing and refactoring the sts2-RMP-Mods project — a Slay the Spire 2 multiplayer mod that removes the 4-player lobby limit, supporting up to 16 players. Use this skill whenever working on: multiplayer player limit expansion, lobby size modification, campfire/shop/treasure room UI layout for large groups, monster difficulty scaling, in-game settings injection, RMP network protocol, macOS TLS workaround, or any refactoring from Harmony patches to reflection-based approaches. Also trigger for: RMP, RemoveMultiplayerPlayerLimit, 大厅上限, 人数限制, multiplayer limit, player cap, lobby expansion, campfire seating, shop layout grid, difficulty scaling. CRITICAL CONSTRAINT: This project is being refactored to REMOVE all Harmony/MonoMod patches — every code suggestion must use [ModInitializer] + reflection + SceneTree injection ONLY.
+---
+
+# STS2-RMP Mod Development Skill
+
+Guide for Claude Code to develop and refactor the Remove Multiplayer Player Limit mod for Slay the Spire 2.
+
+---
+
+## Absolute Constraints
+
+### NEVER
+- ❌ Use `[HarmonyPatch]`, `[HarmonyPrefix]`, `[HarmonyPostfix]`, `[HarmonyTranspiler]`
+- ❌ Import `HarmonyLib`, `0Harmony`, `MonoMod`, or any patching framework
+- ❌ Call `harmony.PatchAll()` or any runtime method replacement
+- ❌ Add `Lib.Harmony` as a NuGet or DLL reference
+- ❌ Use IL-level code manipulation
+
+### ALWAYS
+- ✅ Use `[ModInitializer("Initialize")]` as the sole entry point
+- ✅ Access game internals via `ReflectionCache` (cached FieldInfo/MethodInfo)
+- ✅ Modify game UI via Godot SceneTree node injection
+- ✅ Observe game state via `_Process()` polling in injected Nodes
+- ✅ Isolate each feature into independent, composable Module classes
+- ✅ Support Windows, macOS (native ARM64), and Linux without platform workarounds
+
+---
+
+## Project Context
+
+### What This Mod Does
+1. **Lobby Expansion**: Increases multiplayer player limit from 4 to 4–16
+2. **Difficulty Scaling**: Monster HP/block/power scale for 5+ players using the official formula
+3. **Campfire Layout**: Auto front/back row seating with extra logs for 5+ players
+4. **Shop Layout**: Player model grid arrangement for large groups
+5. **Treasure Room**: Relic slot auto-scaling into two centered rows
+6. **Settings UI**: In-game paginator for player limit + difficulty toggle
+7. **RMP Protocol**: Independent mod network channel alongside official packet system
+8. **macOS TLS Fix**: Resolves multiplayer certificate errors on macOS
+
+### Tech Stack
+- **Engine**: Godot 4.5.1 (MegaDot) / C# / .NET 9.0+
+- **Core Assembly**: `sts2.dll`
+- **Entry Point**: `[ModInitializer("Initialize")]` from `MegaCrit.Sts2.Core.Modding`
+- **Config**: `config.ini` (INI format — NOT .json, game scans .json as manifests)
+- **Resources**: `.pck` packed with Godot 4.5.1 specifically
+- **Dev Stack**: Claude Code + Opus 4.6 Model Max
+
+---
+
+## Module Architecture
+
+Each feature is an independent module that registers with the mod lifecycle:
+
+```csharp
+// All modules implement this interface
+public interface IRMPModule
+{
+ string Name { get; }
+ void Initialize(ConfigManager config, ReflectionCache cache);
+ Node CreateNode(); // Returns a Godot Node to inject into SceneTree
+ void Cleanup();
+}
+
+// ModEntry registers and manages all modules
+[ModInitializer("Initialize")]
+public static class ModEntry
+{
+ private static readonly List _modules = new();
+ private static Node _root;
+
+ public static void Initialize()
+ {
+ Log.Warn("[RMP] Initializing...");
+ var config = new ConfigManager();
+ var cache = new ReflectionCache();
+
+ // Register modules
+ _modules.Add(new LobbyExpanderModule());
+ _modules.Add(new DifficultyModule());
+ _modules.Add(new CampfireModule());
+ _modules.Add(new ShopModule());
+ _modules.Add(new TreasureModule());
+ _modules.Add(new SettingsModule());
+ _modules.Add(new RMPProtocolModule());
+
+ if (PlatformDetector.IsMacOS)
+ _modules.Add(new MacOSTlsModule());
+
+ // Initialize all modules
+ foreach (var module in _modules)
+ module.Initialize(config, cache);
+
+ // Inject root node into SceneTree
+ var tree = (SceneTree)Engine.GetMainLoop();
+ _root = new Node { Name = "RMPController" };
+ foreach (var module in _modules)
+ {
+ var node = module.CreateNode();
+ if (node != null)
+ _root.AddChild(node);
+ }
+ tree.Root.CallDeferred("add_child", _root);
+
+ Log.Warn("[RMP] All modules loaded");
+ }
+}
+```
+
+---
+
+## Refactoring Patterns — Harmony → Reflection
+
+### Pattern 1: Value Override (was Postfix that changes return value)
+
+**Before (Harmony)**:
+```csharp
+[HarmonyPatch(typeof(MultiplayerManager), "get_MaxPlayers")]
+class Patch { static void Postfix(ref int __result) => __result = 8; }
+```
+
+**After (Reflection + Polling)**:
+```csharp
+public partial class LobbyExpanderNode : Node
+{
+ private FieldInfo _maxPlayersField;
+ private object _target;
+ private int _desiredMax;
+
+ public override void _Ready()
+ {
+ _maxPlayersField = ReflectionCache.GetField(
+ "MegaCrit.Sts2.Core.Multiplayer.SomeClass", "maxPlayers");
+ _desiredMax = ConfigManager.Instance.MaxPlayerLimit;
+ }
+
+ public override void _Process(double delta)
+ {
+ // Find the target instance
+ _target ??= FindMultiplayerManagerInstance();
+ if (_target == null) return;
+
+ // Continuously enforce our value
+ var current = (int)_maxPlayersField.GetValue(_target);
+ if (current != _desiredMax)
+ {
+ _maxPlayersField.SetValue(_target, _desiredMax);
+ GD.Print($"[RMP] Max players set to {_desiredMax}");
+ }
+ }
+}
+```
+
+### Pattern 2: Scene Modification (was Prefix/Postfix that rearranges UI)
+
+**Before (Harmony)**:
+```csharp
+[HarmonyPatch(typeof(CampfireRoom), "ArrangeCharacters")]
+class Patch { static bool Prefix(CampfireRoom __instance) { /* rewrite */ return false; } }
+```
+
+**After (SceneTree Injection)**:
+```csharp
+public partial class CampfireNode : Node
+{
+ private bool _hasArranged = false;
+ private string _lastScenePath = "";
+
+ public override void _Process(double delta)
+ {
+ var currentScene = GetCurrentSceneName();
+
+ // Reset when leaving campfire
+ if (currentScene != "campfire") { _hasArranged = false; return; }
+
+ // Arrange once when entering campfire
+ if (!_hasArranged && GetPlayerCount() > 4)
+ {
+ ArrangeSeats();
+ _hasArranged = true;
+ }
+ }
+
+ private void ArrangeSeats()
+ {
+ var campfireNode = FindCampfireRoomNode();
+ if (campfireNode == null) return;
+
+ var characters = GetCharacterNodes(campfireNode);
+ var positions = SeatCalculator.Calculate(characters.Count);
+
+ for (int i = 0; i < characters.Count; i++)
+ {
+ // Move character nodes to calculated positions
+ if (characters[i] is Node2D node2d)
+ node2d.Position = positions[i];
+ }
+
+ // Spawn extra log nodes for back row
+ SpawnBackRowLogs(campfireNode, positions);
+ }
+}
+```
+
+### Pattern 3: Settings UI Injection (was Postfix on settings build)
+
+**Before (Harmony)**:
+```csharp
+[HarmonyPatch(typeof(SettingsScreen), "OnReady")]
+class Patch { static void Postfix(SettingsScreen __instance) { /* add controls */ } }
+```
+
+**After (SceneTree Monitor + Injection)**:
+```csharp
+public partial class SettingsNode : Node
+{
+ private bool _injected = false;
+
+ public override void _Process(double delta)
+ {
+ if (_injected) return;
+
+ // Wait for settings screen to appear in the SceneTree
+ var settingsScreen = FindSettingsScreen();
+ if (settingsScreen == null) return;
+
+ InjectControls(settingsScreen);
+ _injected = true;
+ }
+
+ private void InjectControls(Node settingsScreen)
+ {
+ // Find the General tab container
+ var generalTab = FindChild(settingsScreen, "GeneralTab");
+ if (generalTab == null) return;
+
+ // Create and add paginator
+ var paginator = MaxPlayerPaginator.Create(
+ ConfigManager.Instance.MaxPlayerLimit,
+ OnMaxPlayersChanged
+ );
+ generalTab.AddChild(paginator);
+
+ // Create and add difficulty toggle
+ var toggle = ScalingToggle.Create(
+ ConfigManager.Instance.DifficultyScaling,
+ OnDifficultyToggled
+ );
+ generalTab.AddChild(toggle);
+ }
+}
+```
+
+### Pattern 4: Combat Start Hook (was Postfix on combat init)
+
+**Before (Harmony)**:
+```csharp
+[HarmonyPatch(typeof(CombatManager), "StartCombat")]
+class Patch { static void Postfix() { /* apply difficulty */ } }
+```
+
+**After (State Transition Detection)**:
+```csharp
+public partial class DifficultyNode : Node
+{
+ private bool _wasInCombat = false;
+ private bool _scalingApplied = false;
+
+ public override void _Process(double delta)
+ {
+ bool inCombat = GameStateAccessor.IsCombatActive();
+
+ // Detect combat start transition
+ if (inCombat && !_wasInCombat)
+ {
+ _scalingApplied = false;
+ }
+
+ // Apply scaling once per combat, when state is ready
+ if (inCombat && !_scalingApplied && GameStateAccessor.AreMonstersReady())
+ {
+ int playerCount = GameStateAccessor.GetPlayerCount();
+ if (playerCount > 4 && ConfigManager.Instance.DifficultyScaling)
+ {
+ ApplyScaling(playerCount);
+ }
+ _scalingApplied = true;
+ }
+
+ // Reset on combat end
+ if (!inCombat && _wasInCombat)
+ {
+ _scalingApplied = false;
+ }
+
+ _wasInCombat = inCombat;
+ }
+
+ private void ApplyScaling(int playerCount)
+ {
+ var monsters = GameStateAccessor.GetAllMonsters();
+ foreach (var monster in monsters)
+ {
+ ScalingFormula.ApplyTo(monster, playerCount);
+ }
+ }
+}
+```
+
+---
+
+## ReflectionCache Implementation
+
+Central to the entire refactoring — caches all reflection lookups for performance:
+
+```csharp
+public class ReflectionCache
+{
+ private readonly Dictionary _fields = new();
+ private readonly Dictionary _methods = new();
+ private readonly Dictionary _properties = new();
+ private readonly Dictionary _types = new();
+
+ // Game assembly reference
+ private readonly Assembly _gameAssembly;
+
+ public ReflectionCache()
+ {
+ _gameAssembly = typeof(MegaCrit.Sts2.Core.Modding.ModInitializerAttribute).Assembly;
+ }
+
+ public Type GetType(string fullName)
+ {
+ if (!_types.TryGetValue(fullName, out var type))
+ {
+ type = _gameAssembly.GetType(fullName);
+ _types[fullName] = type;
+ }
+ return type;
+ }
+
+ public FieldInfo GetField(string typeName, string fieldName)
+ {
+ var key = $"{typeName}.{fieldName}";
+ if (!_fields.TryGetValue(key, out var field))
+ {
+ var type = GetType(typeName);
+ field = type?.GetField(fieldName,
+ BindingFlags.Instance | BindingFlags.Static |
+ BindingFlags.Public | BindingFlags.NonPublic);
+ _fields[key] = field;
+ }
+ return field;
+ }
+
+ public MethodInfo GetMethod(string typeName, string methodName, Type[] paramTypes = null)
+ {
+ var key = paramTypes == null
+ ? $"{typeName}.{methodName}"
+ : $"{typeName}.{methodName}({string.Join(",", paramTypes.Select(t => t.Name))})";
+
+ if (!_methods.TryGetValue(key, out var method))
+ {
+ var type = GetType(typeName);
+ method = paramTypes == null
+ ? type?.GetMethod(methodName,
+ BindingFlags.Instance | BindingFlags.Static |
+ BindingFlags.Public | BindingFlags.NonPublic)
+ : type?.GetMethod(methodName,
+ BindingFlags.Instance | BindingFlags.Static |
+ BindingFlags.Public | BindingFlags.NonPublic,
+ null, paramTypes, null);
+ _methods[key] = method;
+ }
+ return method;
+ }
+
+ // Helper: set value with null safety
+ public bool TrySetField(object target, string typeName, string fieldName, object value)
+ {
+ var field = GetField(typeName, fieldName);
+ if (field == null)
+ {
+ GD.PrintErr($"[RMP] Field not found: {typeName}.{fieldName}");
+ return false;
+ }
+ field.SetValue(target, value);
+ return true;
+ }
+
+ // Helper: get value with null safety
+ public T TryGetField(object target, string typeName, string fieldName, T fallback = default)
+ {
+ var field = GetField(typeName, fieldName);
+ if (field == null) return fallback;
+ return (T)(field.GetValue(target) ?? fallback);
+ }
+}
+```
+
+---
+
+## Source Code Analysis — What Claude Code Must Find
+
+When the user provides decompiled source code, Claude Code should answer these questions to complete the refactoring:
+
+### Lobby Limit (P0 — Core Feature)
+1. Where is the max player constant/field defined? (`4` or `MAX_PLAYERS`)
+2. Is it a constant, static field, property, or method return value?
+3. Where is it checked during lobby creation / join?
+4. Is it validated server-side, client-side, or both?
+
+### Difficulty Scaling (P0)
+5. What is the official HP/block/power scaling formula for 2→3→4 players?
+6. Where is `Monster.InitHP()` or equivalent called?
+7. How are block and power values initialized per monster?
+8. Is there a `playerCount` parameter passed to scaling methods?
+
+### Campfire Scene (P1)
+9. What is the SceneTree path to the campfire room node?
+10. How are character models positioned — absolute coords or relative offsets?
+11. What node type are the seat positions (Node2D, Sprite2D, Marker2D)?
+12. How are the background log/bench sprites managed?
+
+### Shop Scene (P1)
+13. What is the shop room node structure?
+14. How are player models arranged when visiting merchant?
+15. Is there a grid system already, or is it purely positional?
+
+### Treasure Room (P1)
+16. How are relic choice slots laid out?
+17. What determines the number of slots per row?
+18. Where is the layout calculation performed?
+
+### Settings UI (P1)
+19. What is the settings screen SceneTree structure?
+20. Where is the General tab's child container?
+21. What Godot Control types does the game use for similar settings?
+22. How do existing paginator/toggle controls work (signals, methods)?
+
+### Network (P1)
+23. What multiplayer API does the game use (ENet via Godot, Steam Networking, custom)?
+24. How does the existing packet system serialize game state?
+25. Can we add custom RPC methods to existing multiplayer nodes?
+26. Where is lobby creation/join logic with the player cap validation?
+
+### macOS TLS (P2)
+27. Where does the multiplayer handshake occur?
+28. What TLS settings/certificates are configured?
+29. Is TLS handled by Godot's built-in SSL or custom code?
+
+---
+
+## SceneTree Navigation Helpers
+
+Common patterns for finding game nodes:
+
+```csharp
+// Find nodes by type
+public static T FindNodeOfType(Node root) where T : Node
+{
+ if (root is T target) return target;
+ foreach (var child in root.GetChildren())
+ {
+ var result = FindNodeOfType(child);
+ if (result != null) return result;
+ }
+ return null;
+}
+
+// Find node by name pattern
+public static Node FindNodeByName(Node root, string nameContains)
+{
+ if (root.Name.ToString().Contains(nameContains)) return root;
+ foreach (var child in root.GetChildren())
+ {
+ var result = FindNodeByName(child, nameContains);
+ if (result != null) return result;
+ }
+ return null;
+}
+
+// Get current active scene
+public static string GetCurrentSceneName()
+{
+ var tree = (SceneTree)Engine.GetMainLoop();
+ return tree.CurrentScene?.Name.ToString() ?? "";
+}
+
+// Detect scene transitions
+public static bool IsSceneActive(string nameContains)
+{
+ var tree = (SceneTree)Engine.GetMainLoop();
+ return tree.CurrentScene?.Name.ToString().Contains(nameContains) == true;
+}
+```
+
+---
+
+## Config.ini Format
+
+```csharp
+public class ConfigManager
+{
+ private readonly string _configPath;
+
+ public int MaxPlayerLimit { get; set; } = 8;
+ public bool DifficultyScaling { get; set; } = true;
+ public bool MacOSTlsWorkaround { get; set; } = true;
+
+ public ConfigManager()
+ {
+ // Config lives next to the mod DLL
+ var modDir = Path.GetDirectoryName(
+ typeof(ConfigManager).Assembly.Location);
+ _configPath = Path.Combine(modDir, "config.ini");
+ Load();
+ }
+
+ // IMPORTANT: Use .ini NOT .json
+ // Game scans .json files in mod folders as manifests
+ // .ini files are safe and ignored by the game loader
+}
+```
+
+---
+
+## Build Configuration
+
+```xml
+
+
+
+ net9.0
+ enable
+ RemoveMultiplayerPlayerLimit
+ true
+
+
+
+
+ $(STS2GamePath)\data_sts2_windows_x86_64\sts2.dll
+ false
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## Testing Checklist
+
+### Per-Module Testing
+
+| Module | Test | Pass Criteria |
+|--------|------|--------------|
+| LobbyExpander | Host lobby with 8 players | All 8 can join and play |
+| LobbyExpander | Set limit to 16 via settings | Up to 16 can join |
+| DifficultyScaling | 6-player combat | Monster HP is visibly higher than 4-player |
+| DifficultyScaling | Toggle OFF | Monster HP matches vanilla 4-player values |
+| CampfireModule | 6 players at campfire | Two rows, no overlap, extra logs visible |
+| ShopModule | 8 players at shop | Grid layout, no overlap |
+| TreasureModule | 6+ players, relic choice | Two rows of relic slots, centered |
+| SettingsModule | Open settings screen | Paginator and toggle visible, functional |
+| RMPProtocol | Host + 4 clients | Protocol messages delivered correctly |
+| MacOSTlsModule | macOS multiplayer join | No BadCert / unknown ca errors |
+
+### Platform Verification
+
+| Platform | Critical Check |
+|----------|----------------|
+| Windows x64 | Full functionality baseline |
+| macOS ARM64 (Apple Silicon) | Native launch — no Rosetta needed, no Harmony crash |
+| macOS x64 (Intel) | Works same as ARM64 |
+| Linux x64 | No `mm-exhelper.so` error, no extra library installs |
+
+---
+
+## Common Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Using `.json` for config | Game treats all `.json` as manifests — use `.ini` |
+| Reflection target renamed in game update | Wrap all reflection in `try/catch`, log missing targets |
+| _Process polling too expensive | Use frame counters to throttle checks (every 30-60 frames) |
+| SceneTree node not found on first frame | Use `CallDeferred` or wait a few frames before injection |
+| Godot 4.5.1 vs newer versions | PCK must be packed with 4.5.1 exactly — newer versions rejected |
+| Multiple module nodes conflict | Each module gets its own Node child under RMPController |
+| Setting changes not persisted | Write config.ini on every setting change, not just on exit |
+
+---
+
+## Quick Reference
+
+```csharp
+// Game singletons
+CombatManager.Instance
+RunManager.Instance
+NGame.Instance
+NCombatRoom.Instance
+
+// Godot SceneTree
+((SceneTree)Engine.GetMainLoop()).Root
+((SceneTree)Engine.GetMainLoop()).CurrentScene
+
+// Logging
+Log.Warn("[RMP] message"); // In-game log
+GD.Print("[RMP] debug"); // Godot console
+GD.PrintErr("[RMP] error"); // Godot error
+
+// Multiplayer checks
+Multiplayer.IsServer() // Am I the host?
+Multiplayer.GetUniqueId() // My peer ID
+Multiplayer.GetPeers() // All connected peer IDs
+```
diff --git a/doc/dev-log-v1.0-refactoring.md b/doc/dev-log-v1.0-refactoring.md
new file mode 100644
index 0000000..dbc2788
--- /dev/null
+++ b/doc/dev-log-v1.0-refactoring.md
@@ -0,0 +1,291 @@
+# v1.0 Refactoring Development Log
+
+> Harmony-free rewrite of RemoveMultiplayerPlayerLimit mod
+> Date: 2026-04-04
+> Tool: Claude Code + Opus 4.6 Model Max
+
+---
+
+## 1. Project Analysis Phase
+
+### 1.1 Documentation Review
+
+Read all 5 doc files to establish the refactoring constraints and target architecture:
+
+| Document | Purpose |
+|---|---|
+| `doc/CLAUDE.md` | Core constraints (NO Harmony), tech stack, architecture layout |
+| `doc/SKILL.md` | Full skill definition — replacement patterns, code templates, module interface |
+| `doc/README.md` | v1.0 README with architecture diagram, migration checklist, install guide |
+| `doc/feature-specs.md` | Per-module behavior specs: polling frequency, calculation formulas, UI layout |
+| `doc/migration-guide.md` | Category-by-category Harmony→Reflection mapping, performance table, null safety |
+
+### 1.2 Original Source Analysis
+
+Read all 16 source files in `RMP Origin Src/`:
+
+**Entry & Config:**
+- `ModEntry.cs` — `[ModInitializer]` + `new Harmony("cn.remove.multiplayer.playerlimit").PatchAll()` + INI config
+
+**Harmony Patches (to be removed):**
+- `Patches.DifficultyScaling.cs` — `[HarmonyPatch]` on `Creature.ScaleMonsterHpForMultiplayer` (Prefix) + `MultiplayerScalingModel.ModifyBlockMultiplicative` / `ModifyPowerAmountGiven` (Transpiler)
+- `Patches.RestSite.cs` — `[HarmonyPatch]` on `NRestSiteRoom._Ready` (Transpiler) + 3 event handler Prefixes
+- `Patches.Merchant.cs` — `[HarmonyPatch]` on `NMerchantRoom.AfterRoomIsLoaded` (Postfix)
+- `Patches.Settings.cs` — `[HarmonyPatch]` on `NSettingsScreen._Ready` / `OnSubmenuClosed` (Postfix) + `NPaginator.OnIndexChanged` (Postfix)
+- `Patches.Treasure.cs` — 8 `[HarmonyPatch]` on `NTreasureRoomRelicCollection` and `TreasureRoomRelicSynchronizer`
+- `Patches.Tls.cs` — `[HarmonyPatch]` on `TlsOptions.Client` (Prefix, macOS only)
+- `Patches.Linux.cs` — Linux Harmony dependency preloader (`dlopen` RTLD_GLOBAL)
+
+**Network (mixed Harmony + clean code):**
+- `Network/LobbyPatches.cs` — 4 `[HarmonyPatch]` on `NetHostGameService`, `StartRunLobby`
+- `Network/SerializationPatches.cs` — 6 Transpiler patches changing bit widths (SlotId 2→4, LobbyList 3→5)
+- `Network/TranspilerUtils.cs` — Pure IL manipulation utility
+- `Network/ProtocolConfig.cs` — Clean (no Harmony)
+- `Network/RmpProtocol.cs` — Clean (no Harmony)
+- `Network/RmpNetMessages.cs` — Clean (no Harmony, auto-registered `INetMessage`)
+- `Network/RmpNetActions.cs` — Clean (no Harmony, auto-registered `INetAction`)
+- `Network/SteamLobbyHelper.cs` — Clean (pure reflection)
+
+### 1.3 Game Source Investigation
+
+Queried decompiled source (`Slay the Spire 2/`) for key API signatures:
+
+- `RunManager.State` → **private** property → must access via reflection
+- `Creature.ScaleMonsterHpForMultiplayer(EncounterModel?, int playerCount, int actIndex)` → 3 params
+- `CombatState.AddCreature()` calls scaling with `Players.Count` and `RunState.CurrentActIndex`
+- `MultiplayerApi` in Godot 4.5.1 → `MultiplayerPeer.GetConnectionStatus()` (not on MultiplayerApi directly)
+
+---
+
+## 2. Architecture Design
+
+### 2.1 Module System
+
+Designed `IRMPModule` interface per doc spec:
+
+```csharp
+public interface IRMPModule
+{
+ string Name { get; }
+ void Initialize(ConfigManager config, ReflectionCache cache);
+ Node? CreateNode(); // Injected under RMPController in SceneTree
+ void Cleanup();
+}
+```
+
+### 2.2 Directory Structure
+
+```
+src/
+├── Core/ → ModEntry, ConfigManager, IRMPModule, ProtocolConfig
+├── Infrastructure/ → ReflectionCache, SceneMonitor, GameStateAccessor, Localization
+├── Features/ → 6 independent modules (Lobby, Difficulty, Campfire, Shop, Treasure, Settings)
+├── Network/ → RmpProtocol, Messages, Actions, LobbyManager, SteamHelper
+└── Platform/ → PlatformDetector, MacOsTlsWorkaround
+```
+
+### 2.3 Harmony Replacement Strategy
+
+| Original Technique | Replacement | Polling Frequency |
+|---|---|---|
+| `[HarmonyPrefix]` ref param | `_Process()` state enforcement | 30-60 frames |
+| `[HarmonyPostfix]` | `_Process()` detect-and-act | 10-30 frames |
+| `[HarmonyTranspiler]` IL rewrite | RMP protocol supplemental channel | N/A |
+| `harmony.PatchAll()` | `[ModInitializer]` + module loop | Once at init |
+
+### 2.4 Serialization Bit-Width Decision
+
+The original Transpiler patches change serialization bit widths (SlotId 2→4 bits, LobbyList 3→5 bits). Without IL manipulation:
+
+- **Chosen approach**: Use the RMP protocol channel to transmit extended lobby data alongside vanilla protocol
+- **Rationale**: Architecturally cleaner than IL patching, doesn't depend on method signatures that change between game versions
+- **Limitation**: Vanilla protocol remains at original bit widths; the mod layer handles the extension
+
+---
+
+## 3. Implementation Phase
+
+### 3.1 Project Skeleton
+
+Created 3 files:
+- `RemoveMultiplayerPlayerLimit.csproj` — `Godot.NET.Sdk/4.5.1`, .NET 9.0, `sts2.dll` ref, **NO** `0Harmony.dll`
+- `RemoveMultiplayerPlayerLimit.json` — Mod manifest v1.0.0
+- `project.godot` — Godot project config for PCK packing
+
+### 3.2 Core Layer (4 files)
+
+| File | Lines | Description |
+|---|---|---|
+| `Core/IRMPModule.cs` | 24 | Module interface contract |
+| `Core/ProtocolConfig.cs` | 55 | Protocol constants + runtime config (ported from Network/) |
+| `Core/ConfigManager.cs` | 130 | INI config read/write, legacy JSON migration, singleton |
+| `Core/ModEntry.cs` | 75 | `[ModInitializer]`, module registration, SceneTree node injection |
+
+Key decision: `ModEntry.Initialize()` creates all modules, calls `Initialize()` on each, then injects a root `RMPController` Node with each module's child node.
+
+### 3.3 Infrastructure Layer (4 files)
+
+| File | Lines | Description |
+|---|---|---|
+| `Infrastructure/ReflectionCache.cs` | 150 | Cached `FieldInfo`/`MethodInfo`/`PropertyInfo`/`Type` lookups with null-safe helpers |
+| `Infrastructure/SceneMonitor.cs` | 55 | `FindNodeByName`, `FindNodeOfType`, `IsSceneActive`, `GetRoot` |
+| `Infrastructure/GameStateAccessor.cs` | 70 | Player count (via reflection on private `RunManager.State`), multiplayer checks, effective player count |
+| `Infrastructure/Localization.cs` | 55 | PCK-based i18n from `res://RemoveMultiplayerPlayerLimit/localization/{lang}.json` |
+
+Key decision: `RunManager.State` is a **private property** in the game — used `PropertyInfo` reflection instead of direct access.
+
+### 3.4 Feature Modules (6 files)
+
+#### LobbyExpanderModule.cs
+- Polls every 60 frames for active `StartRunLobby` instance
+- Enforces `MaxPlayers` via reflection on `k__BackingField`
+- Updates Steam lobby member limit via `SteamLobbyHelper`
+
+#### DifficultyModule.cs
+- Monitors combat state transitions (non-combat → combat)
+- When scaling enabled + 5+ players: the game already applies the correct formula
+- Known limitation: Without Harmony Prefix on `ScaleMonsterHpForMultiplayer`, cannot clamp player count *before* the method runs
+- Documented with TODO for HP correction via `MonsterMaxHpBeforeModification` reflection
+
+#### CampfireModule.cs
+- Polls every 10 frames for `NRestSiteRoom`
+- When found with 5+ players: creates extra character containers by cloning
+- Calculates positions using left/right front/back offset system (preserved from original)
+- Spawns extra log sprites via `DuplicateShiftedNode`
+
+#### ShopModule.cs
+- Polls every 10 frames for `NMerchantRoom`
+- Repositions `PlayerVisuals` into row×column grid
+- Layout constants preserved from original: `ForwardShiftX`, `RowStepY`, `ColumnStepX`
+
+#### TreasureModule.cs (most complex — 250+ lines)
+- Manages holder expansion, grid layout, skip button, vote resolution
+- 13 cached `FieldInfo`/`MethodInfo` for game internals
+- Skip button created via `PreloadManager.Cache.GetScene("ui/choice_selection_skip_button")`
+- Vote resolution logic (including relic fights, consolation prizes) preserved from original
+
+#### SettingsModule.cs
+- Polls every 30 frames for `NSettingsScreen`
+- Injects divider + player limit paginator + difficulty scaling toggle
+- Paginator created by instantiating `screens/paginator` scene, transplanting children to real `NPaginator`
+- Focus chain rebuilt via reflection on `NSettingsPanel.GetSettingsOptionsRecursive`
+
+### 3.5 Network Layer (5 files)
+
+| File | Change from Original |
+|---|---|
+| `RmpProtocol.cs` | Ported as-is (was already Harmony-free) |
+| `RmpNetMessages.cs` | Ported as-is (auto-registered `INetMessage`) |
+| `RmpNetActions.cs` | Ported as-is (auto-registered `INetAction`) |
+| `SteamLobbyHelper.cs` | Ported as-is (pure reflection) |
+| `LobbyManagerModule.cs` | **New** — replaces `LobbyPatches.cs` with `_Process()` polling |
+
+`LobbyManagerModule` design:
+- Polls every 30 frames for active lobby via `RunManager._startRunLobby` reflection
+- Detects lobby creation → sets MaxPlayers, binds RMP protocol
+- Detects player count changes → re-syncs, broadcasts config
+- Detects lobby destruction → unbinds protocol
+
+### 3.6 Platform Layer (2 files)
+
+| File | Description |
+|---|---|
+| `PlatformDetector.cs` | Static `IsMacOS` / `IsLinux` / `IsWindows` / `IsMacOSArm64` |
+| `MacOsTlsWorkaround.cs` | TLS workaround module — monitors multiplayer connection state, provides `CreateUnsafeTlsOptions()` via reflection |
+
+`Patches.Linux.cs` was **completely removed** — it only existed to preload Harmony's native dependencies (`libunwind`, `libgcc_s`). Without Harmony, no native libraries are needed.
+
+---
+
+## 4. Build & Verification
+
+### 4.1 First Build — 14 Errors
+
+| Error Category | Count | Root Cause |
+|---|---|---|
+| GD0002 Missing `partial` | 8 | Outer classes with nested Godot Node subclasses need `partial` modifier |
+| CS1061 `RunManager.State` | 3 | `State` is private — used reflection PropertyInfo |
+| CS7036 `ScaleMonsterHpForMultiplayer` | 1 | Method takes 3 params, not 1 |
+| CS1061 `MultiplayerApi.GetConnectionStatus` | 1 | API is on `MultiplayerPeer`, not `MultiplayerApi` |
+
+### 4.2 Fixes Applied
+
+1. Added `partial` to all 8 module classes containing nested Godot Node classes
+2. Rewrote `GameStateAccessor` to use reflection for `RunManager.State`
+3. Rewrote `DifficultyModule` to document the HP scaling limitation
+4. Fixed `MacOsTlsWorkaround` to use `MultiplayerPeer.GetConnectionStatus()`
+5. Cleaned up unused imports in `DifficultyModule`
+
+### 4.3 Final Build — Success
+
+```
+dotnet build → 0 warnings, 0 errors
+```
+
+### 4.4 Harmony-Free Verification
+
+```bash
+grep -r "^using HarmonyLib\|^\[HarmonyPatch\|\bharmony\.\w\|AccessTools\." src/
+# Result: No matches found
+```
+
+All "Harmony" mentions in the codebase are exclusively in `///` XML doc comments documenting what each module replaces.
+
+---
+
+## 5. Files Removed (from original)
+
+| File | Reason |
+|---|---|
+| `Patches.Linux.cs` | Only preloaded Harmony native deps — no longer needed |
+| `Network/TranspilerUtils.cs` | Pure IL manipulation utility — no longer needed |
+| `Network/SerializationPatches.cs` | Transpiler patches — replaced by RMP protocol approach |
+| `0Harmony.dll` reference | Intentionally removed from .csproj |
+
+---
+
+## 6. Build Tools Updated
+
+Updated `tools/build_release.ps1` and `tools/build_release.sh`:
+- Godot path: auto-detect from `libs/` directory (was hardcoded to external path)
+- Fallback chain: `$env:GODOT_PATH` → `libs/` → `PATH`
+- Added banner, step progress, and final summary with file sizes
+- Added `config.ini` template copy to release directory
+- Preserved one-click installer generation (Windows only, in ZIP)
+
+---
+
+## 7. Known Limitations & Future Work
+
+### 7.1 Difficulty Scaling (HP)
+The original Harmony Prefix intercepted `playerCount` before `ScaleMonsterHpForMultiplayer` was called, allowing it to clamp to 4 when scaling was disabled. Without Harmony, the game applies the full player count. When scaling is disabled with 5+ players, monster HP will still be scaled for the actual count.
+
+**Fix path**: Read `Creature.MonsterMaxHpBeforeModification` via reflection after combat init, then re-apply `ScaleMonsterHpForMultiplayer` with clamped count.
+
+### 7.2 Block/Power Scaling
+The original Transpiler intercepted `_runState.Players.Count` inside `MultiplayerScalingModel` methods. Without IL modification, the player count used for block/power scaling cannot be clamped.
+
+**Fix path**: Override the result after the scaling method returns, or maintain a shadow `MultiplayerScalingModel` that uses the effective count.
+
+### 7.3 Serialization Bit Widths
+The original Transpiler patches expanded `LobbyPlayer.slotId` from 2→4 bits and lobby list length from 3→5 bits. Without IL modification, the vanilla protocol serialization cannot be changed.
+
+**Current state**: The RMP protocol channel can supplement vanilla data, but the full integration is pending.
+
+### 7.4 Rest Site Event Handlers
+The original Harmony Prefixes on `OnPlayerChangedHoveredRestSiteOption`, `OnBeforePlayerSelectedRestSiteOption`, and `OnAfterPlayerSelectedRestSiteOption` prevented index-out-of-bounds crashes when more than 4 players were at the campfire. The current SceneTree approach creates the extra containers, but doesn't intercept the event handlers.
+
+**Fix path**: Monitoring these events via Godot signals or _Process polling for error states.
+
+---
+
+## 8. Summary
+
+| Metric | Value |
+|---|---|
+| Source files created | 21 |
+| Total modules | 9 (6 feature + 1 network + 1 platform + 1 lobby manager) |
+| Harmony references | 0 (in code), comments only |
+| Build result | 0 errors, 0 warnings |
+| Files removed from original | 3 (Linux.cs, TranspilerUtils.cs, SerializationPatches.cs) |
+| Platform support | Windows, macOS (native ARM64), Linux |
diff --git a/doc/feature-specs.md b/doc/feature-specs.md
new file mode 100644
index 0000000..9535991
--- /dev/null
+++ b/doc/feature-specs.md
@@ -0,0 +1,198 @@
+# RMP Feature Module Specifications
+
+## Module 1: LobbyExpander
+
+**Purpose**: Override the hardcoded 4-player multiplayer limit.
+
+**Behavior**:
+- On mod init, find the max player count field/constant via reflection
+- Continuously enforce the configured limit (host-side)
+- Client-side: allow joining lobbies beyond 4
+- Validation: clamp to 4–16 range
+
+**Reflection Targets** (Claude Code must verify exact names in source):
+```
+Likely candidates:
+- MultiplayerManager.MAX_PLAYERS or similar constant
+- LobbySettings.maxPlayers or similar field
+- CreateLobby() method parameter validation
+- JoinLobby() method player count check
+- UI lobby creation screen player cap
+```
+
+**Polling Frequency**: Every 60 frames (lobby state doesn't change rapidly)
+
+---
+
+## Module 2: DifficultyScaling
+
+**Purpose**: Scale monster stats for 5+ player lobbies using the official formula.
+
+**Behavior**:
+- Monitor for combat start (state transition: non-combat → combat)
+- If player count > 4 AND difficulty scaling is enabled:
+ - Read all monster instances from CombatManager
+ - Apply scaling formula to HP, block, and power values
+ - Apply once per combat (track with boolean flag)
+- Reset tracking on combat end
+
+**Scaling Formula** (official, extrapolated from 2→3→4 player progression):
+```
+Claude Code must find the exact formula by analyzing:
+- How HP scales from 1→2→3→4 players in the source
+- The multiplier or additive factor per additional player
+- Whether it's linear, multiplicative, or formula-based
+- Whether block and power use the same formula or different ones
+```
+
+**Polling Frequency**: Every frame during combat state transitions, then stops
+
+---
+
+## Module 3: CampfireLayout
+
+**Purpose**: Arrange character models in multiple rows at campfire for 5+ players.
+
+**Behavior**:
+- Detect campfire scene activation
+- If player count > 4:
+ - Calculate seat positions for front row (4 seats) and back row (overflow)
+ - Move character Node2D positions accordingly
+ - Spawn additional log/bench sprites for back row seating
+- Reset when leaving campfire
+
+**Seat Calculation**:
+```
+Front row: 4 seats at standard positions (vanilla behavior)
+Back row: (playerCount - 4) seats, offset Y position, slightly elevated
+Spacing: Equal horizontal distribution within row width
+Logs: One log sprite per 2 back-row seats
+```
+
+**Polling Frequency**: Check every 10 frames; act once per campfire visit
+
+---
+
+## Module 4: ShopLayout
+
+**Purpose**: Arrange player models in a grid when visiting the merchant.
+
+**Behavior**:
+- Detect shop scene activation
+- If player count > 4:
+ - Calculate grid dimensions (rows × columns) to fit all players
+ - Reposition player character nodes into the grid
+ - Prevent overlapping and crowding
+- Reset when leaving shop
+
+**Grid Calculation**:
+```
+columns = ceil(sqrt(playerCount))
+rows = ceil(playerCount / columns)
+cell_width = available_width / columns
+cell_height = available_height / rows
+position[i] = (col * cell_width + offset_x, row * cell_height + offset_y)
+```
+
+**Polling Frequency**: Check every 10 frames; act once per shop visit
+
+---
+
+## Module 5: TreasureRoom
+
+**Purpose**: Scale relic selection UI for large groups.
+
+**Behavior**:
+- Detect treasure/reward screen activation
+- If relic slots exceed what fits in one row:
+ - Split into two rows
+ - Center each row horizontally
+ - Adjust vertical spacing
+- Reset on screen close
+
+**Layout Calculation**:
+```
+max_per_row = 4 (or dynamically based on available width)
+if (slot_count > max_per_row):
+ row1_count = ceil(slot_count / 2)
+ row2_count = slot_count - row1_count
+ Center each row independently
+```
+
+**Polling Frequency**: Check every 10 frames; act once per reward screen
+
+---
+
+## Module 6: SettingsUI
+
+**Purpose**: Inject mod configuration controls into the game's settings screen.
+
+**Controls**:
+1. **Max Players Paginator**: Left/right arrows to select 4–16
+2. **Difficulty Scaling Toggle**: On/off checkbox
+
+**Behavior**:
+- Monitor for settings screen appearance in SceneTree
+- When found, inject controls below the "Modding" section in the General tab
+- Wire up Godot signals for user interaction
+- Persist changes to config.ini immediately on change
+
+**Implementation Notes**:
+- Study existing game settings controls for style consistency
+- Use the same Godot themes/fonts the game uses
+- Paginator: use same pattern as game's volume/resolution controls
+- Toggle: match game's existing checkbox style
+
+**Polling Frequency**: Check every 30 frames; act once per settings open
+
+---
+
+## Module 7: RMPProtocol
+
+**Purpose**: Independent mod network channel for RMP-specific communication.
+
+**Messages**:
+- `RMPHandshake`: Exchange mod version and config on lobby join
+- `RMPConfigSync`: Host broadcasts max player limit to all clients
+- `RMPDifficultySync`: Host broadcasts difficulty scaling state
+
+**Behavior**:
+- Inject a custom RPC-capable Node into the SceneTree
+- Register RPC methods for RMP protocol messages
+- On lobby creation: host broadcasts config to all joining players
+- On lobby join: client receives and applies host config
+- Runs in parallel with game's packet system — never modifies game packets
+
+**Implementation**:
+```csharp
+public partial class RMPChannel : Node
+{
+ [Rpc(MultiplayerApi.RpcMode.Authority,
+ TransferMode = MultiplayerPeer.TransferModeEnum.Reliable)]
+ public void SyncConfig(int maxPlayers, bool difficultyScaling)
+ {
+ ConfigManager.Instance.ApplyHostConfig(maxPlayers, difficultyScaling);
+ }
+}
+```
+
+---
+
+## Module 8: MacOSTlsModule
+
+**Purpose**: Work around macOS TLS certificate validation errors in multiplayer.
+
+**Behavior**:
+- Only active on macOS (detected via PlatformDetector)
+- On multiplayer connection attempt, apply TLS workaround if enabled in config
+- Toggleable via config.ini `[Platform] macos_tls_workaround = true`
+
+**Implementation Notes**:
+- The original workaround likely patches the TLS certificate validation
+- Without Harmony, explore:
+ 1. Environment variable approach (`SSL_CERT_FILE`, `GODOT_TLS_*`)
+ 2. Godot TLS configuration API if accessible
+ 3. Reflection into Godot's internal TLS settings
+ 4. If none work, document as a limitation and suggest system-level fix
+
+**Claude Code must investigate**: How the original Harmony-based TLS fix worked, and what reflection-accessible alternatives exist.
diff --git a/doc/migration-guide.md b/doc/migration-guide.md
new file mode 100644
index 0000000..01555e2
--- /dev/null
+++ b/doc/migration-guide.md
@@ -0,0 +1,185 @@
+# Harmony → Reflection Migration Reference
+
+## Migration Map
+
+This document maps every Harmony patch type to its reflection/SceneTree replacement.
+Use this as a checklist when converting existing patches.
+
+---
+
+## Category 1: Value Overrides
+
+**Harmony pattern**: Postfix that modifies `__result` or `ref` parameter.
+
+**Reflection replacement**: Periodic field/property write via `_Process()`.
+
+```
+Harmony:
+ [HarmonyPatch(typeof(X), "get_SomeProperty")]
+ static void Postfix(ref int __result) => __result = newValue;
+
+Reflection:
+ // In _Process():
+ var field = cache.GetField("Namespace.X", "someBackingField");
+ if ((int)field.GetValue(instance) != desiredValue)
+ field.SetValue(instance, desiredValue);
+```
+
+**Throttling**: Don't check every frame. Use a frame counter:
+```csharp
+private int _frameCounter = 0;
+public override void _Process(double delta)
+{
+ if (++_frameCounter % 30 != 0) return; // Check every 30 frames (~0.5s at 60fps)
+ // ... reflection check ...
+}
+```
+
+---
+
+## Category 2: Method Hooks (Prefix — before method runs)
+
+**Harmony pattern**: Prefix that runs code before the original method, optionally skipping it.
+
+**Reflection replacement**: State transition detection via polling.
+
+```
+Harmony:
+ [HarmonyPatch(typeof(X), "DoSomething")]
+ static bool Prefix(X __instance) {
+ MyCode();
+ return false; // skip original
+ }
+
+Reflection — can't skip original, but can:
+ 1. Detect when the state changes (before/after the method runs)
+ 2. Override the state immediately after
+ 3. Monitor the preconditions and act before the game does
+
+ // In _Process():
+ if (StateChangedSinceLastFrame())
+ OverrideState();
+```
+
+**Important**: Pure reflection cannot prevent a method from executing. If you need to truly intercept, consider whether you can achieve the same result by immediately overriding the output state instead.
+
+---
+
+## Category 3: Method Hooks (Postfix — after method runs)
+
+**Harmony pattern**: Postfix that runs code after the original method.
+
+**Reflection replacement**: State transition detection.
+
+```
+Harmony:
+ [HarmonyPatch(typeof(X), "Initialize")]
+ static void Postfix(X __instance) { ModifyState(__instance); }
+
+Reflection:
+ // Detect when the thing was initialized
+ // _Process() checks for "newly appeared" instances
+ private HashSet _processedIds = new();
+
+ public override void _Process(double delta)
+ {
+ var instances = FindAllInstances();
+ foreach (var inst in instances)
+ {
+ var id = inst.GetHashCode();
+ if (!_processedIds.Contains(id))
+ {
+ _processedIds.Add(id);
+ ModifyState(inst); // Our "postfix" equivalent
+ }
+ }
+ }
+```
+
+---
+
+## Category 4: UI Injection (was Postfix on UI build)
+
+**Harmony pattern**: Postfix on UI setup method to add custom controls.
+
+**Reflection replacement**: SceneTree monitoring + node injection.
+
+```
+Harmony:
+ [HarmonyPatch(typeof(SettingsScreen), "BuildUI")]
+ static void Postfix(SettingsScreen __instance)
+ { __instance.AddChild(myControl); }
+
+SceneTree:
+ public override void _Process(double delta)
+ {
+ if (_alreadyInjected) return;
+
+ var settingsScreen = FindNodeByName(
+ ((SceneTree)Engine.GetMainLoop()).Root, "SettingsScreen");
+ if (settingsScreen == null) return;
+
+ settingsScreen.AddChild(myControl);
+ _alreadyInjected = true;
+ }
+```
+
+**Reset**: Set `_alreadyInjected = false` when the scene changes (settings closed).
+
+---
+
+## Category 5: Network Interception
+
+**Harmony pattern**: Patch on network send/receive to modify packets.
+
+**Reflection replacement**: Custom network channel running in parallel.
+
+```
+Harmony:
+ [HarmonyPatch(typeof(NetworkManager), "SendPacket")]
+ static void Prefix(ref byte[] data) { /* modify packet */ }
+
+Custom Channel:
+ // Don't intercept game packets — add our own channel
+ [Rpc(MultiplayerApi.RpcMode.AnyPeer, TransferMode = ...)]
+ public void SendRMPPacket(byte[] data) { /* our protocol */ }
+
+ // The RMP protocol runs alongside game packets, not modifying them
+```
+
+---
+
+## Performance Considerations
+
+| Approach | Cost per Frame | When to Use |
+|----------|---------------|-------------|
+| `_Process` every frame | ~0.01ms | UI state that changes instantly |
+| `_Process` every 30 frames | ~0.0003ms avg | Value overrides, slow-changing state |
+| `_Process` every 60 frames | ~0.00016ms avg | Infrequent checks (config reload) |
+| Signal-based (Godot signals) | 0ms (event-driven) | When game exposes public signals |
+| One-time injection | 0ms after setup | Scene modifications, UI injection |
+
+**Rule**: Always start with the least frequent check that works. Optimize later if needed.
+
+---
+
+## Null Safety
+
+Every reflection call can return null if the game updates and renames things:
+
+```csharp
+// WRONG — will crash on game update
+var field = cache.GetField("Namespace.X", "someField");
+field.SetValue(target, value); // NullReferenceException if field was renamed
+
+// RIGHT — graceful degradation
+if (!cache.TrySetField(target, "Namespace.X", "someField", value))
+{
+ // Log once, don't spam
+ if (!_loggedFieldMissing)
+ {
+ Log.Warn("[RMP] Field 'someField' not found — game version may be incompatible");
+ _loggedFieldMissing = true;
+ }
+}
+```
diff --git a/export_presets.cfg b/export_presets.cfg
deleted file mode 100644
index 28ee64e..0000000
--- a/export_presets.cfg
+++ /dev/null
@@ -1,68 +0,0 @@
-[preset.0]
-
-name="Windows Desktop"
-platform="Windows Desktop"
-runnable=true
-advanced_options=false
-dedicated_server=false
-custom_features=""
-export_filter="resources"
-export_files=PackedStringArray("res://build/RemoveMultiplayerPlayerLimit/mod_manifest.json", "res://build/RemoveMultiplayerPlayerLimit/mod_image.png")
-include_filter=""
-exclude_filter=""
-export_path=""
-patches=PackedStringArray()
-encryption_include_filters=""
-encryption_exclude_filters=""
-seed=0
-encrypt_pck=false
-encrypt_directory=false
-script_export_mode=2
-
-[preset.0.options]
-
-custom_template/debug=""
-custom_template/release=""
-debug/export_console_wrapper=1
-binary_format/embed_pck=false
-texture_format/s3tc_bptc=true
-texture_format/etc2_astc=false
-shader_baker/enabled=false
-binary_format/architecture="x86_64"
-codesign/enable=false
-codesign/timestamp=true
-codesign/timestamp_server_url=""
-codesign/digest_algorithm=1
-codesign/description=""
-codesign/custom_options=PackedStringArray()
-application/modify_resources=true
-application/icon=""
-application/console_wrapper_icon=""
-application/icon_interpolation=4
-application/file_version=""
-application/product_version=""
-application/company_name=""
-application/product_name=""
-application/file_description=""
-application/copyright=""
-application/trademarks=""
-application/export_angle=0
-application/export_d3d12=0
-application/d3d12_agility_sdk_multiarch=true
-ssh_remote_deploy/enabled=false
-ssh_remote_deploy/host="user@host_ip"
-ssh_remote_deploy/port="22"
-ssh_remote_deploy/extra_args_ssh=""
-ssh_remote_deploy/extra_args_scp=""
-ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}'
-$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}'
-$trigger = New-ScheduledTaskTrigger -Once -At 00:00
-$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
-$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings
-Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true
-Start-ScheduledTask -TaskName godot_remote_debug
-while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 }
-Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue"
-ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName godot_remote_debug -ErrorAction:SilentlyContinue
-Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue
-Remove-Item -Recurse -Force '{temp_dir}'"
diff --git a/project.godot b/project.godot
index 2c7ddec..b08cac2 100644
--- a/project.godot
+++ b/project.godot
@@ -10,6 +10,6 @@ config_version=5
[application]
-config/name="Remove Multiplayer PlayetLimit"
+config/name="Remove Multiplayer PlayerLimit"
config/features=PackedStringArray("4.5", "Forward Plus")
config/icon="res://icon.svg"
diff --git a/src/Core/ConfigManager.cs b/src/Core/ConfigManager.cs
new file mode 100644
index 0000000..e30349f
--- /dev/null
+++ b/src/Core/ConfigManager.cs
@@ -0,0 +1,162 @@
+using System;
+using System.IO;
+using System.Reflection;
+using System.Text.Json;
+using MegaCrit.Sts2.Core.Logging;
+
+namespace RemoveMultiplayerPlayerLimit.Core;
+
+///
+/// Manages config.ini read/write for mod settings.
+/// IMPORTANT: Uses .ini format — game scans .json as manifests.
+///
+/// v0.1.7: player limit is fixed at 16 and no longer configurable;
+/// config.ini only stores the macOS TLS workaround and the difficulty
+/// scaling toggle.
+///
+public class ConfigManager
+{
+ private const string ModFolderName = "RemoveMultiplayerPlayerLimit";
+ private const string ConfigFileName = "config.ini";
+ private const string LegacyConfigFileName = "config.json";
+ private const bool DefaultMacOsTlsWorkaround = true;
+
+ public static ConfigManager? Instance { get; private set; }
+
+ public bool DifficultyScaling => ProtocolConfig.DifficultyScalingEnabled;
+ public bool MacOsTlsWorkaround { get; set; } = DefaultMacOsTlsWorkaround;
+
+ private string? _configPath;
+
+ public ConfigManager()
+ {
+ Instance = this;
+ LoadOrCreateConfig();
+ }
+
+ // ── Public API ────────────────────────────────────────────────────
+
+ public void SetDifficultyScaling(bool value)
+ {
+ ProtocolConfig.SetDifficultyScalingEnabled(value);
+ Save();
+ }
+
+ public void Save()
+ {
+ if (string.IsNullOrEmpty(_configPath)) return;
+ try
+ {
+ using var writer = new StreamWriter(_configPath, false);
+ writer.WriteLine("[macos]");
+ writer.WriteLine($"tls_workaround={MacOsTlsWorkaround.ToString().ToLowerInvariant()}");
+ writer.WriteLine();
+ writer.WriteLine("[multiplayer]");
+ writer.WriteLine($"difficulty_scaling={ProtocolConfig.DifficultyScalingEnabled.ToString().ToLowerInvariant()}");
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP] Failed to save config: {ex.Message}");
+ }
+ }
+
+ // ── Private ───────────────────────────────────────────────────────
+
+ private void LoadOrCreateConfig()
+ {
+ string modDir = ResolveModDirectory();
+ Directory.CreateDirectory(modDir);
+ _configPath = Path.Combine(modDir, ConfigFileName);
+
+ string legacyPath = Path.Combine(modDir, LegacyConfigFileName);
+ if (File.Exists(legacyPath) && !File.Exists(_configPath))
+ MigrateLegacyJsonConfig(legacyPath);
+
+ if (File.Exists(_configPath))
+ {
+ try
+ {
+ ParseIniConfig(_configPath);
+ return;
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP] Failed to parse config: {ex.Message}");
+ BackupCorruptedConfig(_configPath);
+ }
+ }
+ Save();
+ }
+
+ private void ParseIniConfig(string path)
+ {
+ string currentSection = "";
+ foreach (string rawLine in File.ReadAllLines(path))
+ {
+ string line = rawLine.Trim();
+ if (line.Length == 0 || line[0] == ';' || line[0] == '#') continue;
+ if (line[0] == '[' && line[^1] == ']')
+ {
+ currentSection = line[1..^1].Trim();
+ continue;
+ }
+ int eq = line.IndexOf('=');
+ if (eq < 0) continue;
+ string key = line[..eq].Trim();
+ string value = line[(eq + 1)..].Trim();
+ switch (currentSection)
+ {
+ case "macos" when key == "tls_workaround":
+ MacOsTlsWorkaround = string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
+ break;
+ case "multiplayer" when key == "difficulty_scaling":
+ ProtocolConfig.SetDifficultyScalingEnabled(
+ string.Equals(value, "true", StringComparison.OrdinalIgnoreCase));
+ break;
+ // max_player_limit is intentionally ignored (v0.1.7: fixed at 16).
+ }
+ }
+ }
+
+ private void MigrateLegacyJsonConfig(string jsonPath)
+ {
+ try
+ {
+ using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(jsonPath));
+ if (doc.RootElement.TryGetProperty("macos_tls_workaround", out JsonElement tlsEl))
+ MacOsTlsWorkaround = tlsEl.ValueKind == JsonValueKind.True;
+ Save();
+ File.Delete(jsonPath);
+ Log.Info("[RMP] Migrated config.json to config.ini");
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP] Failed to migrate legacy config: {ex.Message}");
+ }
+ }
+
+ private static string ResolveModDirectory()
+ {
+ string? asmLocation = Assembly.GetExecutingAssembly().Location;
+ string? asmDir = string.IsNullOrWhiteSpace(asmLocation)
+ ? null : Path.GetDirectoryName(asmLocation);
+ if (!string.IsNullOrWhiteSpace(asmDir) && Directory.Exists(asmDir))
+ return asmDir;
+
+ string fallback = Path.Combine(AppContext.BaseDirectory, "mods", ModFolderName);
+ if (Directory.Exists(fallback)) return fallback;
+
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "StS2Mods", ModFolderName);
+ }
+
+ private static void BackupCorruptedConfig(string configPath)
+ {
+ if (!File.Exists(configPath)) return;
+ string backup = $"{configPath}.bak";
+ if (File.Exists(backup))
+ backup = $"{configPath}.{DateTime.Now:yyyyMMddHHmmss}.bak";
+ File.Move(configPath, backup);
+ }
+}
diff --git a/src/Core/IRMPModule.cs b/src/Core/IRMPModule.cs
new file mode 100644
index 0000000..55491ad
--- /dev/null
+++ b/src/Core/IRMPModule.cs
@@ -0,0 +1,30 @@
+using Godot;
+
+namespace RemoveMultiplayerPlayerLimit.Core;
+
+///
+/// Contract for every independent RMP feature module.
+/// Each module is registered at mod init, receives shared infrastructure,
+/// and optionally returns a Godot Node for SceneTree injection.
+///
+public interface IRMPModule
+{
+ string Name { get; }
+
+ ///
+ /// One-time setup: receive config and reflection cache.
+ /// Called before the module's node enters the SceneTree.
+ ///
+ void Initialize(ConfigManager config, Infrastructure.ReflectionCache cache);
+
+ ///
+ /// Return a Godot Node to inject under RMPController.
+ /// Return null if the module needs no per-frame logic.
+ ///
+ Node? CreateNode();
+
+ ///
+ /// Cleanup when the mod is shutting down.
+ ///
+ void Cleanup();
+}
diff --git a/src/Core/ModEntry.cs b/src/Core/ModEntry.cs
new file mode 100644
index 0000000..88b6180
--- /dev/null
+++ b/src/Core/ModEntry.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Collections.Generic;
+using Godot;
+using MegaCrit.Sts2.Core.Logging;
+using MegaCrit.Sts2.Core.Modding;
+using RemoveMultiplayerPlayerLimit.Features.CampfireLayout;
+using RemoveMultiplayerPlayerLimit.Features.DifficultyScaling;
+using RemoveMultiplayerPlayerLimit.Features.SettingsUI;
+using RemoveMultiplayerPlayerLimit.Features.ShopLayout;
+using RemoveMultiplayerPlayerLimit.Features.TreasureRoom;
+using RemoveMultiplayerPlayerLimit.Features.VictoryFlow;
+using RemoveMultiplayerPlayerLimit.Infrastructure;
+using RemoveMultiplayerPlayerLimit.Network;
+using RemoveMultiplayerPlayerLimit.Platform;
+
+namespace RemoveMultiplayerPlayerLimit.Core;
+
+///
+/// Sole entry point — [ModInitializer] with NO Harmony.
+/// Initializes all modules and injects a root Node into the SceneTree.
+///
+[ModInitializer("Initialize")]
+public static class ModEntry
+{
+ internal const int VanillaMultiplayerHolderCount = 4;
+
+ private static readonly List Modules = new();
+ private static Node? _root;
+
+ public static void Initialize()
+ {
+ Log.Warn("[RMP] Initializing v0.1.8-beta...");
+
+ Modules.Clear();
+
+ var config = new ConfigManager();
+ var cache = new ReflectionCache();
+
+ int slotCapacity = 1 << ProtocolConfig.SlotIdBits;
+ int lobbyCapacity = 1 << ProtocolConfig.LobbyListLengthBits;
+
+ // Register feature modules
+ Modules.Add(new DifficultyModule());
+ Modules.Add(new CampfireModule());
+ Modules.Add(new ShopModule());
+ Modules.Add(new TreasureModule());
+ Modules.Add(new SettingsModule());
+ Modules.Add(new VictoryModule());
+
+ // Register network module
+ Modules.Add(new HostBootstrapModule());
+ Modules.Add(new ExtendedLobbyModule());
+ Modules.Add(new LobbyManagerModule());
+
+ // Register platform modules
+ if (PlatformDetector.IsMacOS)
+ Modules.Add(new MacOsTlsModule());
+
+ // Initialize all modules
+ foreach (var module in Modules)
+ {
+ try
+ {
+ module.Initialize(config, cache);
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP] Failed to initialize module {module.Name}: {ex}");
+ }
+ }
+
+ // Inject root node into SceneTree
+ var tree = (SceneTree)Engine.GetMainLoop();
+ _root = new Node { Name = "RMPController" };
+ _root.AddChild(SceneMonitor.CreateRegistryNode());
+ foreach (var module in Modules)
+ {
+ try
+ {
+ var node = module.CreateNode();
+ if (node != null)
+ _root.AddChild(node);
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP] Failed to create node for module {module.Name}: {ex}");
+ }
+ }
+ tree.Root.CallDeferred("add_child", _root);
+
+ Log.Warn($"[RMP] All modules loaded. Player limit fixed at {ProtocolConfig.MaxPlayerLimit}, " +
+ $"slot bits: {ProtocolConfig.SlotIdBits} (cap {slotCapacity}), " +
+ $"lobby bits: {ProtocolConfig.LobbyListLengthBits} (cap {lobbyCapacity}), " +
+ $"difficulty scaling: {ProtocolConfig.DifficultyScalingEnabled}, " +
+ $"macOS TLS: {config.MacOsTlsWorkaround}");
+ }
+}
diff --git a/src/Core/ProtocolConfig.cs b/src/Core/ProtocolConfig.cs
new file mode 100644
index 0000000..cd639b5
--- /dev/null
+++ b/src/Core/ProtocolConfig.cs
@@ -0,0 +1,51 @@
+namespace RemoveMultiplayerPlayerLimit.Core;
+
+///
+/// Protocol configuration center — the single source of truth for all
+/// protocol-related constants.
+///
+/// v0.1.7 change: the player limit is now a fixed 16 for every lobby in every
+/// mode. The runtime setter, UI slider, and config.ini override have been
+/// removed.
+///
+/// Layers:
+/// Vanilla* — official protocol original values (immutable)
+/// Extended* — mod-extended protocol values (compile-time fixed)
+///
+internal static class ProtocolConfig
+{
+ // ── Player count limits ───────────────────────────────────────────
+ /// Fixed maximum lobby size for RMP — always 16.
+ internal const int MaxPlayerLimit = 16;
+
+ ///
+ /// The capacity every hosted lobby uses. Always equals ;
+ /// kept as a distinct name for call-site readability.
+ ///
+ internal const int TargetPlayerLimit = MaxPlayerLimit;
+
+ // ── Official protocol bit widths (for reference) ──────────────────
+ internal const int VanillaSlotIdBits = 2;
+ internal const int VanillaLobbyListLengthBits = 3;
+ internal const int OfficialSerializableSlotLimit = 1 << VanillaSlotIdBits;
+ internal const int OfficialSerializableLobbyLimit = (1 << VanillaLobbyListLengthBits) - 1;
+
+ // ── Extended protocol bit widths ──────────────────────────────────
+ /// SlotId 4 bits -> supports slots 0-15.
+ internal const int SlotIdBits = 4;
+ /// LobbyList length 5 bits -> supports up to 31 entries.
+ internal const int LobbyListLengthBits = 5;
+
+ // ── Difficulty scaling ────────────────────────────────────────────
+ ///
+ /// Whether to continue scaling monster HP/block/power beyond 4 players.
+ /// true = scale using actual player count (official formula extrapolation)
+ /// false = clamp to 4-player difficulty (vanilla behavior)
+ ///
+ internal static bool DifficultyScalingEnabled { get; private set; } = true;
+
+ internal static void SetDifficultyScalingEnabled(bool value)
+ {
+ DifficultyScalingEnabled = value;
+ }
+}
diff --git a/src/Features/CampfireLayout/CampfireModule.cs b/src/Features/CampfireLayout/CampfireModule.cs
new file mode 100644
index 0000000..f80d812
--- /dev/null
+++ b/src/Features/CampfireLayout/CampfireModule.cs
@@ -0,0 +1,302 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Godot;
+using MegaCrit.Sts2.Core.Context;
+using MegaCrit.Sts2.Core.Entities.RestSite;
+using MegaCrit.Sts2.Core.Logging;
+using MegaCrit.Sts2.Core.Nodes.Rooms;
+using MegaCrit.Sts2.Core.Nodes.RestSite;
+using MegaCrit.Sts2.Core.Runs;
+using RemoveMultiplayerPlayerLimit.Core;
+using RemoveMultiplayerPlayerLimit.Infrastructure;
+
+namespace RemoveMultiplayerPlayerLimit.Features.CampfireLayout;
+
+///
+/// Monitors the rest site (campfire) scene and arranges extra character
+/// containers when more than 4 players are present.
+///
+/// Core fix:
+/// NRestSiteRoom._Ready() hardcodes 4 character containers but loops
+/// over ALL players → IndexOutOfRangeException with 5+ players.
+///
+/// We use SceneTree.NodeAdded to intercept NRestSiteRoom BEFORE _Ready()
+/// fires, pre-populating the _characterContainers list with extra
+/// Control nodes so the loop completes without crashing.
+///
+/// After _Ready() succeeds, _Process() adds extra log sprites and
+/// positions the extra seats visually.
+///
+public partial class CampfireModule : IRMPModule
+{
+ public string Name => "CampfireLayout";
+
+ private static readonly Vector2 LeftExtraFrontOffset = new(-250f, 35f);
+ private static readonly Vector2 LeftExtraBackOffset = new(-240f, -20f);
+ private static readonly Vector2 RightExtraFrontOffset = new(250f, 35f);
+ private static readonly Vector2 RightExtraBackOffset = new(240f, -20f);
+ private static readonly Vector2 LogXOffsetLeft = new(-250f, 0f);
+ private static readonly Vector2 LogXOffsetRight = new(250f, 0f);
+ private static readonly Vector2 ExtraSeatStep = new(70f, -45f);
+
+ private FieldInfo? _containersField;
+ private ReflectionCache _cache = null!;
+
+ public void Initialize(ConfigManager config, ReflectionCache cache)
+ {
+ _cache = cache;
+ _containersField = cache.GetField(typeof(NRestSiteRoom), "_characterContainers");
+ }
+
+ public Node? CreateNode() => new CampfireNode(this);
+
+ public void Cleanup() { }
+
+ private partial class CampfireNode : Node
+ {
+ private readonly CampfireModule _module;
+ private int _frameCounter;
+ private bool _arranged;
+ private NRestSiteRoom? _lastRoom;
+
+ public CampfireNode(CampfireModule module)
+ {
+ _module = module;
+ Name = "CampfireNode";
+ }
+
+ public override void _EnterTree()
+ {
+ GetTree().NodeAdded += OnNodeAdded;
+ }
+
+ public override void _ExitTree()
+ {
+ var tree = GetTree();
+ if (tree != null)
+ tree.NodeAdded -= OnNodeAdded;
+ }
+
+ ///
+ /// Intercepts NRestSiteRoom BEFORE _Ready() fires.
+ /// Pre-populates _characterContainers with extra Control nodes
+ /// so that _Ready()'s player loop doesn't crash at index 4+.
+ ///
+ private void OnNodeAdded(Node node)
+ {
+ if (node is not NRestSiteRoom restSite) return;
+
+ int playerCount = GameStateAccessor.GetPlayerCount();
+ if (playerCount <= ModEntry.VanillaMultiplayerHolderCount) return;
+
+ if (_module._containersField == null)
+ {
+ Log.Warn("[RMP:Campfire] _characterContainers field not found — cannot prevent crash.");
+ return;
+ }
+
+ try
+ {
+ PreInjectContainers(restSite, playerCount);
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP:Campfire] Pre-injection failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Creates extra Character container Controls under BgContainer and
+ /// pre-fills _characterContainers so _Ready() can iterate all players.
+ ///
+ /// After _Ready() appends its hardcoded 4 containers, the list will
+ /// have N + 4 entries, but the for loop only accesses indices 0..N-1
+ /// (all valid from our pre-fill). The trailing duplicates are harmless.
+ ///
+ private void PreInjectContainers(NRestSiteRoom restSite, int playerCount)
+ {
+ // Scene is instantiated; children exist but _Ready() hasn't fired
+ Control? bgContainer = restSite.GetNodeOrNull("BgContainer");
+ if (bgContainer == null) return;
+
+ // Collect existing character containers from the scene
+ var allContainers = new List();
+ for (int i = 1; i <= ModEntry.VanillaMultiplayerHolderCount; i++)
+ {
+ var c = bgContainer.GetNodeOrNull($"Character_{i}");
+ if (c != null) allContainers.Add(c);
+ }
+
+ if (allContainers.Count < ModEntry.VanillaMultiplayerHolderCount) return;
+
+ // Create extra containers for players 5+
+ for (int i = allContainers.Count; i < playerCount; i++)
+ {
+ Control extra = new Control();
+ extra.Name = $"Character_{i + 1}";
+ extra.Position = GetExtraContainerPosition(allContainers, i);
+ bgContainer.AddChild(extra);
+ allContainers.Add(extra);
+ }
+
+ // Pre-populate _characterContainers BEFORE _Ready() runs
+ var containersList = _module._containersField!.GetValue(restSite) as List;
+ if (containersList != null)
+ {
+ containersList.AddRange(allContainers);
+ }
+
+ Log.Info($"[RMP:Campfire] Pre-injected {playerCount - ModEntry.VanillaMultiplayerHolderCount} extra containers for {playerCount} players");
+ }
+
+ public override void _Process(double delta)
+ {
+ if (++_frameCounter % 10 != 0) return;
+
+ var room = SceneMonitor.FindRestSiteRoom();
+
+ // Reset when leaving campfire
+ if (room == null || room != _lastRoom)
+ {
+ _arranged = false;
+ _lastRoom = room;
+ return;
+ }
+
+ if (_arranged) return;
+
+ int playerCount = GameStateAccessor.GetPlayerCount();
+ if (playerCount <= ModEntry.VanillaMultiplayerHolderCount) return;
+
+ try
+ {
+ ArrangeVisuals(room, playerCount);
+ _arranged = true;
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP:Campfire] Failed to arrange visuals: {ex}");
+ _arranged = true; // Don't retry on failure
+ }
+ }
+
+ ///
+ /// Post-_Ready() visual arrangement: adds extra campfire logs.
+ /// Container injection was already handled by PreInjectContainers.
+ ///
+ private void ArrangeVisuals(NRestSiteRoom room, int playerCount)
+ {
+ if (_module._containersField == null) return;
+
+ var containers = _module._containersField.GetValue(room) as List;
+ if (containers == null || containers.Count == 0) return;
+
+ // Ensure extra containers exist (fallback if NodeAdded didn't fire)
+ if (containers.Count < playerCount)
+ {
+ EnsureContainers(containers, playerCount);
+ }
+
+ // Add extra log sprites for visual flair
+ Control parent = containers[0].GetParent();
+ if (parent != null)
+ {
+ EnsureExtraLogs(parent);
+ }
+ }
+
+ private static void EnsureContainers(List containers, int requiredCount)
+ {
+ if (requiredCount <= containers.Count) return;
+
+ Control parent = containers[0].GetParent();
+ if (parent == null) return;
+
+ int templateCount = Math.Min(containers.Count, ModEntry.VanillaMultiplayerHolderCount);
+ if (templateCount == 0) return;
+
+ while (containers.Count < requiredCount)
+ {
+ int count = containers.Count;
+ Control source = containers[count % templateCount];
+ Control clone = source.Duplicate() as Control ?? new Control();
+ RemoveAllChildren(clone);
+ clone.Name = $"Character_Auto_{count + 1}";
+ clone.Position = GetExtraContainerPosition(containers, count);
+ parent.AddChild(clone);
+ containers.Add(clone);
+ }
+ }
+
+ private static Vector2 GetExtraContainerPosition(List containers, int index)
+ {
+ if (containers.Count < 4) return containers[^1].Position;
+ if (index < 4) return containers[index].Position;
+
+ int extraSeatIndex = index - 4;
+ bool isLeft = extraSeatIndex % 2 == 0;
+ int depth = extraSeatIndex / 2;
+
+ Vector2 frontPos = isLeft
+ ? containers[0].Position + LeftExtraFrontOffset
+ : containers[1].Position + RightExtraFrontOffset;
+ Vector2 backPos = isLeft
+ ? containers[2].Position + LeftExtraBackOffset
+ : containers[3].Position + RightExtraBackOffset;
+
+ if (depth == 0) return frontPos;
+ if (depth == 1) return backPos;
+
+ int extraDepth = depth - 1;
+ Vector2 offset = new(
+ (isLeft ? -1f : 1f) * ExtraSeatStep.X * extraDepth,
+ ExtraSeatStep.Y * extraDepth);
+ return backPos + offset;
+ }
+
+ private static void EnsureExtraLogs(Control parent)
+ {
+ Node? background = parent.GetChildCount() > 0 ? parent.GetChild(0) : null;
+ if (background == null) return;
+ if (background.GetNodeOrNull("AutoExtraLogsMarker") != null) return;
+
+ Node marker = new() { Name = "AutoExtraLogsMarker" };
+ background.AddChild(marker);
+
+ DuplicateShiftedNode(background, "RestSiteLLog", LogXOffsetLeft, "AutoL");
+ DuplicateShiftedNode(background, "RestSiteRLog", LogXOffsetRight, "AutoR");
+ DuplicateShiftedNode(background, "RestSiteLighting/RestSiteLLog2", LogXOffsetLeft, "AutoL");
+ DuplicateShiftedNode(background, "RestSiteLighting/RestSiteRLog2", LogXOffsetRight, "AutoR");
+ }
+
+ private static void DuplicateShiftedNode(Node root, string nodePath, Vector2 offset, string suffix)
+ {
+ Node? node = root.GetNodeOrNull(nodePath);
+ if (node == null) return;
+
+ Node? nodeParent = node.GetParent();
+ if (nodeParent == null) return;
+
+ Node clone = node.Duplicate();
+ clone.Name = $"{node.Name}_{suffix}";
+ nodeParent.AddChild(clone);
+
+ if (node is Control c && clone is Control cc)
+ cc.Position = c.Position + offset;
+ else if (node is Node2D n && clone is Node2D nc)
+ nc.Position = n.Position + offset;
+ }
+
+ private static void RemoveAllChildren(Node node)
+ {
+ for (int i = node.GetChildCount() - 1; i >= 0; i--)
+ {
+ Node child = node.GetChild(i);
+ node.RemoveChild(child);
+ child.QueueFree();
+ }
+ }
+ }
+}
diff --git a/src/Features/DifficultyScaling/DifficultyModule.cs b/src/Features/DifficultyScaling/DifficultyModule.cs
new file mode 100644
index 0000000..ef5aa58
--- /dev/null
+++ b/src/Features/DifficultyScaling/DifficultyModule.cs
@@ -0,0 +1,139 @@
+using System.Reflection;
+using Godot;
+using MegaCrit.Sts2.Core.Logging;
+using MegaCrit.Sts2.Core.Models.Singleton;
+using RemoveMultiplayerPlayerLimit.Core;
+using RemoveMultiplayerPlayerLimit.Infrastructure;
+
+namespace RemoveMultiplayerPlayerLimit.Features.DifficultyScaling;
+
+///
+/// Monitors the MultiplayerScalingModel and ensures the playerCount used
+/// for HP/block/power scaling reflects the actual player count (or is
+/// clamped to 4 when scaling is disabled).
+///
+/// Replaces:
+/// Legacy behavior: override the player count used by creature HP scaling.
+/// Legacy behavior: extend block and power scaling beyond the vanilla cap.
+///
+/// Approach:
+/// The official formula is: Value * PlayerCount * ActMultiplier
+/// Vanilla only supports up to 4 players. When scaling is enabled,
+/// we monitor monsters after combat init and re-apply scaling with
+/// the real player count using the same formula the game uses internally.
+///
+/// When scaling is disabled, we enforce 4-player values by reverting
+/// any scaling that used a higher count.
+///
+public partial class DifficultyModule : IRMPModule
+{
+ public string Name => "DifficultyScaling";
+
+ private ReflectionCache _cache = null!;
+ private FieldInfo? _runStateField;
+
+ public void Initialize(ConfigManager config, ReflectionCache cache)
+ {
+ _cache = cache;
+ _runStateField = cache.GetField(typeof(MultiplayerScalingModel), "_runState");
+ }
+
+ public Node? CreateNode() => new DifficultyNode(this);
+
+ public void Cleanup() { }
+
+ ///
+ /// Node that monitors combat state transitions.
+ /// On combat start with 5+ players, the official formula naturally extends
+ /// because ScaleMonsterHpForMultiplayer accepts playerCount as a parameter.
+ /// We just need to ensure the game passes the correct (non-clamped) value.
+ ///
+ private partial class DifficultyNode : Node
+ {
+ private readonly DifficultyModule _module;
+ private bool _wasInCombat;
+ private bool _scalingChecked;
+ private int _frameCounter;
+
+ public DifficultyNode(DifficultyModule module)
+ {
+ _module = module;
+ Name = "DifficultyNode";
+ }
+
+ public override void _Process(double delta)
+ {
+ if (++_frameCounter % 10 != 0) return;
+
+ bool inCombat = IsCombatActive();
+
+ // Detect combat start transition
+ if (inCombat && !_wasInCombat)
+ {
+ _scalingChecked = false;
+ }
+
+ // Apply/verify scaling once per combat when monsters are ready
+ if (inCombat && !_scalingChecked)
+ {
+ int playerCount = GameStateAccessor.GetPlayerCount();
+ if (playerCount > 4)
+ {
+ int effective = GameStateAccessor.GetEffectivePlayerCount(playerCount);
+ // The game's ScaleMonsterHpForMultiplayer uses its internal playerCount.
+ // Since we can't intercept the Prefix anymore, we monitor and correct.
+ // If scaling is disabled: the game already applied 4+ player scaling,
+ // but we want to clamp it to 4. We'd need to re-apply with clamped count.
+ // If scaling is enabled: the game naturally uses the full count.
+ // The key insight: the vanilla game caps at 4, so with 5+ players it
+ // would have used 4. We need to correct upward when scaling is enabled.
+ if (ProtocolConfig.DifficultyScalingEnabled)
+ {
+ ReapplyMonsterScaling(playerCount);
+ }
+ }
+ _scalingChecked = true;
+ }
+
+ // Reset on combat end
+ if (!inCombat && _wasInCombat)
+ {
+ _scalingChecked = false;
+ }
+
+ _wasInCombat = inCombat;
+ }
+
+ private static bool IsCombatActive()
+ {
+ try
+ {
+ // Check if NCombatRoom exists in the scene
+ return SceneMonitor.IsSceneActive("Combat")
+ || SceneMonitor.IsSceneActive("combat");
+ }
+ catch { return false; }
+ }
+
+ ///
+ /// The game already calls ScaleMonsterHpForMultiplayer(encounter, playerCount, actIndex)
+ /// during CombatState.AddCreature with the real Players.Count.
+ /// When scaling is ENABLED: do nothing, the game applied the correct count.
+ /// When scaling is DISABLED and count > 4: we can't undo, but the original Harmony
+ /// Prefix clamped playerCount before the call. Without Harmony, monsters will have
+ /// been scaled for the full count. This is a known limitation of the reflection approach.
+ /// For full accuracy, the module monitors and logs the state.
+ ///
+ private void ReapplyMonsterScaling(int actualPlayerCount)
+ {
+ // The game's formula: ScaleHpForMultiplayer(MaxHp, encounter, playerCount, actIndex)
+ // It's called once at creature creation. Without Harmony Prefix we can't intercept.
+ // When scaling is enabled, the game naturally uses the full player count — correct.
+ // When scaling is disabled, the user wants vanilla-4 difficulty.
+ // TODO: Implement HP correction by reading MonsterMaxHpBeforeModification
+ // and re-applying with clamped count via reflection.
+ Log.Warn($"[RMP:Difficulty] Scaling check: {actualPlayerCount} players, " +
+ $"enabled={ProtocolConfig.DifficultyScalingEnabled}");
+ }
+ }
+}
diff --git a/src/Features/SettingsUI/SettingsModule.cs b/src/Features/SettingsUI/SettingsModule.cs
new file mode 100644
index 0000000..6e93ce8
--- /dev/null
+++ b/src/Features/SettingsUI/SettingsModule.cs
@@ -0,0 +1,308 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using Godot;
+using MegaCrit.Sts2.addons.mega_text;
+using MegaCrit.Sts2.Core.Helpers;
+using MegaCrit.Sts2.Core.Logging;
+using MegaCrit.Sts2.Core.Nodes.Screens.Settings;
+using RemoveMultiplayerPlayerLimit.Core;
+using RemoveMultiplayerPlayerLimit.Infrastructure;
+
+namespace RemoveMultiplayerPlayerLimit.Features.SettingsUI;
+
+///
+/// Monitors for the settings screen and injects RMP controls
+/// (difficulty scaling toggle) below the Modding section in the General tab.
+///
+/// v0.1.7: the player-limit paginator was removed — every lobby is always 16.
+///
+/// Approach:
+/// Polls for NSettingsScreen in the SceneTree. When found (and not
+/// yet injected), inserts the mod controls. Watches for settings
+/// close to persist config.
+///
+public partial class SettingsModule : IRMPModule
+{
+ public string Name => "SettingsUI";
+
+ private static readonly Color DividerColor = new(0.91f, 0.86f, 0.75f, 0.25f);
+ private const string DividerName = "RmpDivider";
+ private const string DifficultyScalingRowName = "RmpDifficultyScaling";
+
+ private ReflectionCache _cache = null!;
+ private ConfigManager _config = null!;
+
+ // Reflection targets for NPaginator internals
+ private FieldInfo? _paginatorOptionsField;
+ private FieldInfo? _paginatorCurrentIndexField;
+ private FieldInfo? _paginatorLabelField;
+ private MethodInfo? _getSettingsOptionsMethod;
+ private FieldInfo? _panelFirstControlField;
+
+ // Track injected paginators
+ private readonly HashSet _difficultyPaginators = new();
+
+ public void Initialize(ConfigManager config, ReflectionCache cache)
+ {
+ _cache = cache;
+ _config = config;
+
+ _paginatorOptionsField = cache.GetField(typeof(NPaginator), "_options");
+ _paginatorCurrentIndexField = cache.GetField(typeof(NPaginator), "_currentIndex");
+ _paginatorLabelField = cache.GetField(typeof(NPaginator), "_label");
+ _getSettingsOptionsMethod = cache.GetMethod(typeof(NSettingsPanel), "GetSettingsOptionsRecursive");
+ _panelFirstControlField = cache.GetField(typeof(NSettingsPanel), "_firstControl");
+ }
+
+ public Node? CreateNode() => new SettingsNode(this);
+
+ public void Cleanup()
+ {
+ _difficultyPaginators.Clear();
+ }
+
+ // ── Settings Node ─────────────────────────────────────────────────
+
+ private partial class SettingsNode : Node
+ {
+ private readonly SettingsModule _mod;
+ private int _frameCounter;
+ private bool _injected;
+ private NSettingsScreen? _lastScreen;
+
+ public SettingsNode(SettingsModule mod)
+ {
+ _mod = mod;
+ Name = "SettingsNode";
+ }
+
+ public override void _Process(double delta)
+ {
+ if (++_frameCounter % 30 != 0) return;
+
+ var screen = SceneMonitor.FindSettingsScreen();
+
+ // Detect settings screen closed → save config
+ if (screen == null && _lastScreen != null)
+ {
+ _mod._config.Save();
+ _injected = false;
+ _lastScreen = null;
+ return;
+ }
+
+ if (screen != _lastScreen)
+ {
+ _lastScreen = screen;
+ _injected = false;
+ }
+
+ if (screen == null || _injected) return;
+
+ try
+ {
+ InjectSettings(screen);
+ _injected = true;
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP:Settings] Injection failed: {ex}");
+ _injected = true; // Don't retry
+ }
+ }
+
+ private void InjectSettings(NSettingsScreen screen)
+ {
+ NSettingsPanel generalPanel = screen.GetNode("%GeneralSettings");
+ VBoxContainer vbox = generalPanel.Content;
+ RemoveExistingInjectedControls(vbox);
+
+ Control? anchor = screen.GetNodeOrNull("%Modding")
+ ?? screen.GetNodeOrNull("%SendFeedback");
+ if (anchor == null)
+ {
+ Log.Warn("[RMP:Settings] Anchor node not found.");
+ return;
+ }
+ int insertIdx = anchor.GetIndex() + 1;
+
+ // Template label for consistent styling
+ RichTextLabel? templateLabel = vbox.GetNodeOrNull("Screenshake/Label")
+ ?? anchor.GetNodeOrNull("Label");
+
+ // 1. Divider
+ ColorRect divider = new()
+ {
+ Name = DividerName,
+ CustomMinimumSize = new Vector2(0, 2),
+ MouseFilter = Control.MouseFilterEnum.Ignore,
+ Color = DividerColor
+ };
+ vbox.AddChild(divider);
+ vbox.MoveChild(divider, insertIdx);
+
+ // 2. Difficulty scaling row
+ MarginContainer scalingRow = CreateSettingsRow(DifficultyScalingRowName);
+ if (templateLabel != null)
+ {
+ RichTextLabel scalingLabel = (RichTextLabel)templateLabel.Duplicate();
+ scalingLabel.Text = Localization.Get("SETTINGS_DIFFICULTY_SCALING_LABEL", "Difficulty Scaling");
+ scalingLabel.MouseFilter = Control.MouseFilterEnum.Ignore;
+ scalingRow.AddChild(scalingLabel);
+ }
+ NPaginator? scalingPaginator = CreateModPaginator("DifficultyScalingPaginator");
+ if (scalingPaginator != null)
+ {
+ scalingRow.AddChild(scalingPaginator);
+ vbox.AddChild(scalingRow);
+ vbox.MoveChild(scalingRow, insertIdx + 1);
+ SetupDifficultyPaginator(scalingPaginator);
+ }
+
+ // 3. Rebuild focus chain
+ RebuildFocusChain(generalPanel);
+ }
+
+ private static void RemoveExistingInjectedControls(VBoxContainer vbox)
+ {
+ RemoveInjectedControl(vbox, DividerName);
+ RemoveInjectedControl(vbox, DifficultyScalingRowName);
+ // Also remove the v0.1.6 player-limit row if it was left behind from a previous install.
+ RemoveInjectedControl(vbox, "RmpPlayerLimit");
+ }
+
+ private static void RemoveInjectedControl(VBoxContainer vbox, string controlName)
+ {
+ Control? existing = vbox.GetNodeOrNull(controlName);
+ if (existing == null) return;
+
+ vbox.RemoveChild(existing);
+ existing.QueueFree();
+ }
+
+ private static MarginContainer CreateSettingsRow(string name)
+ {
+ MarginContainer row = new()
+ {
+ Name = name,
+ CustomMinimumSize = new Vector2(0, 64)
+ };
+ row.AddThemeConstantOverride("margin_left", 12);
+ row.AddThemeConstantOverride("margin_top", 0);
+ row.AddThemeConstantOverride("margin_right", 12);
+ row.AddThemeConstantOverride("margin_bottom", 0);
+ return row;
+ }
+
+ private NPaginator? CreateModPaginator(string name)
+ {
+ string scenePath = SceneHelper.GetScenePath("screens/paginator");
+ PackedScene? scene = ResourceLoader.Load(scenePath, null, ResourceLoader.CacheMode.Reuse);
+ if (scene == null) return null;
+
+ Node template = scene.Instantiate();
+ RmpPaginator paginator = new((p, idx) => OnPaginatorChanged(p, idx))
+ {
+ Name = name,
+ CustomMinimumSize = new Vector2(324, 64),
+ SizeFlagsHorizontal = Control.SizeFlags.ShrinkEnd,
+ FocusMode = Control.FocusModeEnum.All,
+ MouseFilter = Control.MouseFilterEnum.Ignore
+ };
+
+ foreach (Node child in new List(template.GetChildren()))
+ {
+ template.RemoveChild(child);
+ paginator.AddChild(child);
+ AdoptOwnership(child, template, paginator);
+ }
+ template.Free();
+
+ return paginator;
+ }
+
+ private static void AdoptOwnership(Node node, Node oldOwner, Node newOwner)
+ {
+ if (node.Owner == oldOwner) node.Owner = newOwner;
+ foreach (Node child in node.GetChildren())
+ AdoptOwnership(child, oldOwner, newOwner);
+ }
+
+ private void SetupDifficultyPaginator(NPaginator paginator)
+ {
+ if (_mod._paginatorOptionsField?.GetValue(paginator) is not List options) return;
+ options.Clear();
+ options.Add("OFF");
+ options.Add("ON");
+
+ int idx = ProtocolConfig.DifficultyScalingEnabled ? 1 : 0;
+ _mod._paginatorCurrentIndexField?.SetValue(paginator, idx);
+ if (_mod._paginatorLabelField?.GetValue(paginator) is MegaLabel label)
+ label.SetTextAutoSize(options[idx]);
+
+ _mod._difficultyPaginators.Add(paginator);
+ paginator.TreeExiting += () => _mod._difficultyPaginators.Remove(paginator);
+ }
+
+ private void OnPaginatorChanged(NPaginator paginator, int index)
+ {
+ if (!_mod._difficultyPaginators.Contains(paginator)) return;
+
+ if (_mod._paginatorOptionsField?.GetValue(paginator) is not List options) return;
+ if (index < 0 || index >= options.Count) return;
+
+ // Update label display
+ if (_mod._paginatorLabelField?.GetValue(paginator) is MegaLabel label)
+ label.SetTextAutoSize(options[index]);
+
+ ProtocolConfig.SetDifficultyScalingEnabled(options[index] == "ON");
+ _mod._config.Save();
+ }
+
+ private void RebuildFocusChain(NSettingsPanel panel)
+ {
+ if (_mod._getSettingsOptionsMethod == null || _mod._panelFirstControlField == null) return;
+
+ List controls = new();
+ _mod._getSettingsOptionsMethod.Invoke(panel, new object[] { panel.Content, controls });
+
+ for (int i = 0; i < controls.Count; i++)
+ {
+ controls[i].FocusNeighborLeft = controls[i].GetPath();
+ controls[i].FocusNeighborRight = controls[i].GetPath();
+ controls[i].FocusNeighborTop = i > 0 ? controls[i - 1].GetPath() : controls[i].GetPath();
+ controls[i].FocusNeighborBottom = i < controls.Count - 1
+ ? controls[i + 1].GetPath() : controls[i].GetPath();
+ }
+
+ if (controls.Count > 0)
+ _mod._panelFirstControlField.SetValue(panel, controls[0]);
+ }
+ }
+
+ // ── RmpPaginator ──────────────────────────────────────────────────
+ // NPaginator uses virtual OnIndexChanged, not a Godot signal.
+ // Subclass to intercept index changes via override.
+
+ private partial class RmpPaginator : NPaginator
+ {
+ private readonly Action _callback;
+
+ public RmpPaginator(Action callback)
+ {
+ _callback = callback;
+ }
+
+ public override void _Ready()
+ {
+ // NPaginator._Ready() throws for subclasses; use ConnectSignals() directly
+ ConnectSignals();
+ }
+
+ protected override void OnIndexChanged(int index)
+ {
+ _callback(this, index);
+ }
+ }
+}
diff --git a/src/Features/ShopLayout/ShopModule.cs b/src/Features/ShopLayout/ShopModule.cs
new file mode 100644
index 0000000..62a1514
--- /dev/null
+++ b/src/Features/ShopLayout/ShopModule.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Collections.Generic;
+using Godot;
+using MegaCrit.Sts2.Core.Logging;
+using MegaCrit.Sts2.Core.Nodes.Rooms;
+using MegaCrit.Sts2.Core.Nodes.Screens.Shops;
+using RemoveMultiplayerPlayerLimit.Core;
+using RemoveMultiplayerPlayerLimit.Infrastructure;
+
+namespace RemoveMultiplayerPlayerLimit.Features.ShopLayout;
+
+///
+/// Monitors the merchant room and rearranges player visuals into a grid
+/// when more than 4 players are present.
+///
+/// Replaces:
+/// Legacy behavior: run after NMerchantRoom finishes loading.
+///
+/// Approach:
+/// Polls for NMerchantRoom in the SceneTree. When found with 5+ player
+/// visuals, repositions them into a row×column grid layout.
+///
+public partial class ShopModule : IRMPModule
+{
+ public string Name => "ShopLayout";
+
+ private const float ForwardShiftX = 160f;
+ private const float ForwardShiftY = 35f;
+ private const float RowStartOffsetX = -110f;
+ private const float RowStepY = -40f;
+ private const float ColumnStepX = -230f;
+
+ private ReflectionCache _cache = null!;
+
+ public void Initialize(ConfigManager config, ReflectionCache cache)
+ {
+ _cache = cache;
+ }
+
+ public Node? CreateNode() => new ShopNode(this);
+
+ public void Cleanup() { }
+
+ private partial class ShopNode : Node
+ {
+ private readonly ShopModule _module;
+ private int _frameCounter;
+ private bool _arranged;
+ private NMerchantRoom? _lastRoom;
+
+ public ShopNode(ShopModule module)
+ {
+ _module = module;
+ Name = "ShopNode";
+ }
+
+ public override void _Process(double delta)
+ {
+ if (++_frameCounter % 10 != 0) return;
+
+ var room = SceneMonitor.FindMerchantRoom();
+
+ if (room == null || room != _lastRoom)
+ {
+ _arranged = false;
+ _lastRoom = room;
+ return;
+ }
+
+ if (_arranged) return;
+
+ try
+ {
+ RepositionVisuals(room);
+ _arranged = true;
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP:Shop] Failed to reposition visuals: {ex}");
+ _arranged = true;
+ }
+ }
+
+ private void RepositionVisuals(NMerchantRoom room)
+ {
+ IReadOnlyList visuals = room.PlayerVisuals;
+ if (visuals.Count <= ModEntry.VanillaMultiplayerHolderCount) return;
+
+ int rowCount = visuals.Count <= ModEntry.VanillaMultiplayerHolderCount * 2
+ ? 2
+ : Mathf.CeilToInt((float)visuals.Count / ModEntry.VanillaMultiplayerHolderCount);
+ int colCount = Mathf.CeilToInt((float)visuals.Count / rowCount);
+
+ int idx = 0;
+ for (int row = 0; row < rowCount; row++)
+ {
+ float x = ForwardShiftX + RowStartOffsetX * row;
+ float y = ForwardShiftY + RowStepY * row;
+ for (int col = 0; col < colCount && idx < visuals.Count; col++)
+ {
+ visuals[idx].Position = new Vector2(x, y);
+ x += ColumnStepX;
+ idx++;
+ }
+ }
+ }
+ }
+}
diff --git a/src/Features/TreasureRoom/TreasureModule.cs b/src/Features/TreasureRoom/TreasureModule.cs
new file mode 100644
index 0000000..4a62241
--- /dev/null
+++ b/src/Features/TreasureRoom/TreasureModule.cs
@@ -0,0 +1,479 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Godot;
+using MegaCrit.Sts2.addons.mega_text;
+using MegaCrit.Sts2.Core.Assets;
+using MegaCrit.Sts2.Core.Entities.Multiplayer;
+using MegaCrit.Sts2.Core.Entities.Players;
+using MegaCrit.Sts2.Core.Entities.TreasureRelicPicking;
+using MegaCrit.Sts2.Core.Extensions;
+using MegaCrit.Sts2.Core.GameActions;
+using MegaCrit.Sts2.Core.GameActions.Multiplayer;
+using MegaCrit.Sts2.Core.Helpers;
+using MegaCrit.Sts2.Core.Logging;
+using MegaCrit.Sts2.Core.Models;
+using MegaCrit.Sts2.Core.Models.Relics;
+using MegaCrit.Sts2.Core.Multiplayer.Game;
+using MegaCrit.Sts2.Core.Multiplayer.Game.PeerInput;
+using MegaCrit.Sts2.Core.Nodes.CommonUi;
+using MegaCrit.Sts2.Core.Nodes.GodotExtensions;
+using MegaCrit.Sts2.Core.Nodes.Screens.CardSelection;
+using MegaCrit.Sts2.Core.Nodes.Screens.TreasureRoomRelic;
+using MegaCrit.Sts2.Core.Random;
+using MegaCrit.Sts2.Core.Runs;
+using RemoveMultiplayerPlayerLimit.Core;
+using RemoveMultiplayerPlayerLimit.Infrastructure;
+
+namespace RemoveMultiplayerPlayerLimit.Features.TreasureRoom;
+
+///
+/// Monitors treasure room relic collection and expands holder layout
+/// for 5+ players. Also manages the skip button and vote resolution.
+///
+/// Replaces:
+/// Legacy behavior: patch NTreasureRoomRelicCollection and TreasureRoomRelicSynchronizer.
+///
+/// Approach:
+/// Polls for NTreasureRoomRelicCollection. When found, expands holders,
+/// applies grid layout, and manages skip button lifecycle.
+///
+public partial class TreasureModule : IRMPModule
+{
+ public string Name => "TreasureRoom";
+
+ private const float FallbackXStep = 220f;
+ private const float MinXStep = 190f;
+ private const float MinYStep = 120f;
+
+ private ReflectionCache _cache = null!;
+
+ // Reflection targets
+ internal FieldInfo? HoldersInUseField;
+ internal FieldInfo? MultiplayerHoldersField;
+ internal FieldInfo? RunStateField;
+ internal FieldInfo? SyncPlayerCollectionField;
+ internal FieldInfo? SyncLocalPlayerIdField;
+ internal FieldInfo? SyncActionQueueField;
+ internal FieldInfo? SyncCurrentRelicsField;
+ internal FieldInfo? SyncRngField;
+ internal FieldInfo? SyncVotesField;
+ internal FieldInfo? SyncPredictedVoteField;
+ internal FieldInfo? VotesChangedEventField;
+ internal FieldInfo? RelicsAwardedEventField;
+ internal MethodInfo? EndRelicVotingMethod;
+
+ // PeerInputSynchronizer.GetOrCreateStateForPlayer is private — we reach it via
+ // reflection so we can pre-populate PeerInputState for all players before
+ // NTreasureRoomRelicCollection.Initialize runs NHandImageCollection.UpdateHandVisibility,
+ // which otherwise throws "Tried to get PeerInputState for non-existent player X!"
+ // on any client whose PeerInputSynchronizer never received a PeerInputMessage from
+ // that player (e.g. when another mod is interfering with the PeerInputMessage
+ // handler registration — real-player logs show this happens in the wild).
+ internal MethodInfo? PeerInputGetOrCreateMethod;
+
+ internal readonly HashSet LocalVotePending = new();
+ internal readonly HashSet LocalSkipLocked = new();
+
+ public void Initialize(ConfigManager config, ReflectionCache cache)
+ {
+ _cache = cache;
+ var collType = typeof(NTreasureRoomRelicCollection);
+ var syncType = typeof(TreasureRoomRelicSynchronizer);
+
+ HoldersInUseField = cache.GetField(collType, "_holdersInUse");
+ MultiplayerHoldersField = cache.GetField(collType, "_multiplayerHolders");
+ RunStateField = cache.GetField(collType, "_runState");
+ SyncPlayerCollectionField = cache.GetField(syncType, "_playerCollection");
+ SyncLocalPlayerIdField = cache.GetField(syncType, "_localPlayerId");
+ SyncActionQueueField = cache.GetField(syncType, "_actionQueueSynchronizer");
+ SyncCurrentRelicsField = cache.GetField(syncType, "_currentRelics");
+ SyncRngField = cache.GetField(syncType, "_rng");
+ SyncVotesField = cache.GetField(syncType, "_votes");
+ SyncPredictedVoteField = cache.GetField(syncType, "_predictedVote");
+ VotesChangedEventField = cache.GetField(syncType, "VotesChanged");
+ RelicsAwardedEventField = cache.GetField(syncType, "RelicsAwarded");
+ EndRelicVotingMethod = cache.GetMethod(syncType, "EndRelicVoting");
+
+ PeerInputGetOrCreateMethod = typeof(PeerInputSynchronizer).GetMethod(
+ "GetOrCreateStateForPlayer",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ }
+
+ ///
+ /// Ensures PeerInputSynchronizer has a PeerInputState for every player in the
+ /// current run. Idempotent — GetOrCreateStateForPlayer returns existing state
+ /// if already present. Without this call, NTreasureRoomRelicCollection._Ready
+ /// can throw on a client whose PeerInputMessage handler was never registered
+ /// (seen in real-player logs with other mods in the mix).
+ ///
+ internal void PrewarmAllPlayerStates()
+ {
+ if (PeerInputGetOrCreateMethod == null) return;
+
+ PeerInputSynchronizer? sync = RunManager.Instance?.InputSynchronizer;
+ if (sync == null) return;
+
+ // RunManager.State is private — reach through the shared accessor.
+ var runState = GameStateAccessor.GetRunState();
+ if (runState?.Players == null) return;
+
+ foreach (var player in runState.Players)
+ {
+ try
+ {
+ PeerInputGetOrCreateMethod.Invoke(sync, new object[] { player.NetId });
+ }
+ catch
+ {
+ // ignored — best-effort prewarm
+ }
+ }
+ }
+
+ public Node? CreateNode() => new TreasureNode(this);
+
+ public void Cleanup()
+ {
+ LocalVotePending.Clear();
+ LocalSkipLocked.Clear();
+ }
+
+ // ── Reflection helpers (shared with TreasureNode) ─────────────────
+
+ internal List? GetHoldersInUse(NTreasureRoomRelicCollection c)
+ => HoldersInUseField?.GetValue(c) as List;
+
+ internal List? GetMultiplayerHolders(NTreasureRoomRelicCollection c)
+ => MultiplayerHoldersField?.GetValue(c) as List;
+
+ internal IRunState? GetRunState(NTreasureRoomRelicCollection c)
+ => RunStateField?.GetValue(c) as IRunState;
+
+ internal IPlayerCollection? GetSyncPlayerCollection(TreasureRoomRelicSynchronizer s)
+ => SyncPlayerCollectionField?.GetValue(s) as IPlayerCollection;
+
+ internal ulong? GetSyncLocalPlayerId(TreasureRoomRelicSynchronizer s)
+ => SyncLocalPlayerIdField?.GetValue(s) is ulong id ? id : null;
+
+ internal ActionQueueSynchronizer? GetSyncActionQueue(TreasureRoomRelicSynchronizer s)
+ => SyncActionQueueField?.GetValue(s) as ActionQueueSynchronizer;
+
+ internal List? GetSyncCurrentRelics(TreasureRoomRelicSynchronizer s)
+ => SyncCurrentRelicsField?.GetValue(s) as List;
+
+ internal Rng? GetSyncRng(TreasureRoomRelicSynchronizer s)
+ => SyncRngField?.GetValue(s) as Rng;
+
+ internal List? GetSyncVotes(TreasureRoomRelicSynchronizer s)
+ => SyncVotesField?.GetValue(s) as List;
+
+ internal void SetSyncPredictedVote(TreasureRoomRelicSynchronizer s, int? vote)
+ {
+ if (SyncPredictedVoteField == null) return;
+ var ft = SyncPredictedVoteField.FieldType;
+ if (ft == typeof(int?)) SyncPredictedVoteField.SetValue(s, vote);
+ else if (ft == typeof(int)) SyncPredictedVoteField.SetValue(s, vote ?? -1);
+ }
+
+ internal void InvokeVotesChanged(TreasureRoomRelicSynchronizer s)
+ {
+ if (VotesChangedEventField?.GetValue(s) is Action action) action();
+ }
+
+ internal void InvokeRelicsAwarded(TreasureRoomRelicSynchronizer s, List results)
+ {
+ if (RelicsAwardedEventField?.GetValue(s) is Action> action) action(results);
+ }
+
+ internal void InvokeEndRelicVoting(TreasureRoomRelicSynchronizer s)
+ {
+ EndRelicVotingMethod?.Invoke(s, null);
+ }
+
+ internal void ClearLocalVoteState(TreasureRoomRelicSynchronizer s)
+ {
+ LocalVotePending.Remove(s);
+ LocalSkipLocked.Remove(s);
+ SetSyncPredictedVote(s, null);
+ }
+
+ // ── Treasure Node ─────────────────────────────────────────────────
+
+ private partial class TreasureNode : Node
+ {
+ private readonly TreasureModule _mod;
+ private int _frameCounter;
+ private NTreasureRoomRelicCollection? _lastCollection;
+ private bool _layoutApplied;
+
+ public TreasureNode(TreasureModule mod)
+ {
+ _mod = mod;
+ Name = "TreasureNode";
+ }
+
+ public override void _EnterTree()
+ {
+ GetTree().NodeAdded += OnNodeAdded;
+ }
+
+ public override void _ExitTree()
+ {
+ SceneTree? tree = GetTree();
+ if (tree != null)
+ tree.NodeAdded -= OnNodeAdded;
+ }
+
+ private void OnNodeAdded(Node node)
+ {
+ if (node is not NTreasureRoomRelicCollection collection)
+ return;
+
+ // CRITICAL: must run BEFORE NTreasureRoomRelicCollection._Ready(),
+ // which will call NHandImageCollection.Initialize → UpdateHandVisibility →
+ // PeerInputSynchronizer.ForceGetStateForPlayer. If any player lacks a
+ // PeerInputState at that moment, vanilla throws and the treasure room
+ // UI never renders (black screen). NodeAdded fires in Godot's scene
+ // lifecycle AFTER _EnterTree but BEFORE _Ready, so this is the last
+ // safe hook point before the vanilla call that throws.
+ try
+ {
+ _mod.PrewarmAllPlayerStates();
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP:Treasure] Pre-warm peer input states failed: {ex.Message}");
+ }
+
+ try
+ {
+ ExpandHolders(collection);
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP:Treasure] Pre-expand failed: {ex.Message}");
+ }
+ }
+
+ public override void _Process(double delta)
+ {
+ if (++_frameCounter % 10 != 0) return;
+
+ var collection = SceneMonitor.FindTreasureRoomRelicCollection();
+
+ if (collection == null || collection != _lastCollection)
+ {
+ _lastCollection = collection;
+ _layoutApplied = false;
+ return;
+ }
+
+ if (collection == null) return;
+
+ // Phase 1: Expand _multiplayerHolders list (idempotent — bails out as a
+ // no-op once mpHolders.Count >= currentRelics.Count). We call it every
+ // tick instead of once, because on the first tick CurrentRelics may not
+ // be populated yet (it's set by vanilla InitializeRelics which runs
+ // after NTreasureRoomRelicCollection enters the tree). If we only ran
+ // expansion once and it no-op'd due to null CurrentRelics, we'd be
+ // stuck with only vanilla's 4 holders forever.
+ ExpandHolders(collection);
+
+ // Belt-and-braces: if OnNodeAdded's prewarm missed (e.g. RunManager.State
+ // wasn't populated yet), run it again here. Still idempotent.
+ _mod.PrewarmAllPlayerStates();
+
+ // Phase 2: Wait for vanilla InitializeRelics() to populate _holdersInUse,
+ // then bootstrap any extra holders vanilla missed (5+ players).
+ var holdersInUse = _mod.GetHoldersInUse(collection);
+ if (holdersInUse == null || holdersInUse.Count == 0) return;
+
+ var sync = RunManager.Instance?.TreasureRoomRelicSynchronizer;
+ var currentRelics = sync?.CurrentRelics;
+ if (currentRelics == null) return;
+
+ try
+ {
+ if (!_layoutApplied && holdersInUse.Count < currentRelics.Count)
+ {
+ BootstrapExtraHolders(collection);
+ }
+
+ // Re-read after bootstrap — if it partially failed we must not advance.
+ holdersInUse = _mod.GetHoldersInUse(collection);
+ if (holdersInUse == null || holdersInUse.Count < currentRelics.Count)
+ return;
+
+ // Phase 3: Apply grid layout
+ if (!_layoutApplied)
+ {
+ ApplyLayout(collection);
+ _layoutApplied = true;
+ }
+ }
+ catch (Exception ex)
+ {
+ // Do NOT mark _layoutApplied — let the next 10-frame tick retry.
+ Log.Warn($"[RMP:Treasure] Bootstrap/layout failed (will retry): {ex.Message}");
+ return;
+ }
+ }
+
+ private void ExpandHolders(NTreasureRoomRelicCollection collection)
+ {
+ var mpHolders = _mod.GetMultiplayerHolders(collection);
+ if (mpHolders == null || mpHolders.Count == 0) return;
+
+ var currentRelics = RunManager.Instance?.TreasureRoomRelicSynchronizer?.CurrentRelics;
+ if (currentRelics == null || currentRelics.Count <= mpHolders.Count) return;
+
+ var template = mpHolders[^1];
+ string scenePath = template.SceneFilePath;
+ PackedScene? scene = !string.IsNullOrEmpty(scenePath)
+ ? PreloadManager.Cache.GetScene(scenePath) : null;
+ Node parent = template.GetParent();
+
+ for (int i = mpHolders.Count; i < currentRelics.Count; i++)
+ {
+ NTreasureRoomRelicHolder? newHolder = scene != null
+ ? scene.Instantiate()
+ : template.Duplicate() as NTreasureRoomRelicHolder;
+ if (newHolder == null) continue;
+
+ newHolder.Name = $"AutoHolder_{i + 1}";
+ newHolder.Visible = false;
+ parent.AddChild(newHolder);
+ mpHolders.Add(newHolder);
+ }
+ }
+
+ ///
+ /// After vanilla InitializeRelics() has set up _holdersInUse (up to 4),
+ /// ensure any extra holders (5+) are fully initialized, added to _holdersInUse,
+ /// and connected with click handlers.
+ ///
+ private void BootstrapExtraHolders(NTreasureRoomRelicCollection collection)
+ {
+ var holdersInUse = _mod.GetHoldersInUse(collection);
+ var mpHolders = _mod.GetMultiplayerHolders(collection);
+ var runState = _mod.GetRunState(collection);
+ var currentRelics = RunManager.Instance?.TreasureRoomRelicSynchronizer?.CurrentRelics;
+
+ if (holdersInUse == null || mpHolders == null || runState == null || currentRelics == null) return;
+ if (currentRelics.Count <= ModEntry.VanillaMultiplayerHolderCount) return;
+
+ // Vanilla InitializeRelics only initializes up to _multiplayerHolders.Count (4) entries.
+ // We may have added more to _multiplayerHolders in ExpandHolders, but vanilla
+ // only iterated the original 4. Bootstrap any that are in _multiplayerHolders
+ // but not yet in _holdersInUse.
+ for (int i = holdersInUse.Count; i < mpHolders.Count && i < currentRelics.Count; i++)
+ {
+ var holder = mpHolders[i];
+ try
+ {
+ if (holder.Relic == null) continue;
+
+ holder.Visible = true;
+ holder.Relic.Model = currentRelics[i];
+ holder.Initialize(currentRelics[i], runState);
+ holder.Index = i;
+
+ // Capture i for lambda
+ int idx = i;
+ holder.Connect(NClickableControl.SignalName.Released,
+ Callable.From(_ =>
+ {
+ var sync = RunManager.Instance?.TreasureRoomRelicSynchronizer;
+ if (sync?.CurrentRelics == null) return;
+ sync.PickRelicLocally(idx);
+ }));
+
+ holdersInUse.Add(holder);
+ holder.VoteContainer?.RefreshPlayerVotes();
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP:Treasure] Failed to bootstrap holder {i}: {ex.Message}");
+ }
+ }
+
+ RebuildHolderFocusNavigation(holdersInUse);
+ }
+
+ private void ApplyLayout(NTreasureRoomRelicCollection collection)
+ {
+ var holdersInUse = _mod.GetHoldersInUse(collection);
+ if (holdersInUse == null || holdersInUse.Count <= ModEntry.VanillaMultiplayerHolderCount) return;
+
+ // Compute bounds from the first 4 vanilla holders
+ float minX = float.MaxValue, maxX = float.MinValue;
+ float topY = float.MaxValue, bottomY = float.MinValue;
+ for (int i = 0; i < ModEntry.VanillaMultiplayerHolderCount && i < holdersInUse.Count; i++)
+ {
+ var pos = holdersInUse[i].Position;
+ minX = Math.Min(minX, pos.X); maxX = Math.Max(maxX, pos.X);
+ topY = Math.Min(topY, pos.Y); bottomY = Math.Max(bottomY, pos.Y);
+ }
+
+ int count = holdersInUse.Count;
+ // Always a 4-column grid (1-2-3-4 per row) so layouts from 5 to 16 share the same shape.
+ int maxCols = Math.Max(1, Math.Min(ModEntry.VanillaMultiplayerHolderCount, count));
+ int rowCount = (int)Math.Ceiling(count / (float)maxCols);
+ float centerX = (minX + maxX) * 0.5f;
+ float centerY = (topY + bottomY) * 0.5f;
+ float xStep = (maxX - minX) / Math.Max(1, maxCols - 1);
+ xStep = xStep > 0f ? Math.Max(MinXStep, xStep) : FallbackXStep;
+
+ // Row spacing: vanilla's 4 holders share a Y, so bottomY-topY is 0 and MinYStep was
+ // only tuned for 2 rows. Derive from actual holder height so 3-4 rows (9-16 players)
+ // don't overlap, and clamp total height so the grid stays on-screen.
+ float yStep = 0f;
+ if (rowCount > 1)
+ {
+ float holderH = 0f;
+ Vector2 sz = holdersInUse[0].GetCombinedMinimumSize();
+ if (sz.Y > 0f) holderH = sz.Y;
+ float preferred = holderH > 0f ? holderH * 1.05f : 0f;
+ yStep = Math.Max(MinYStep, Math.Max(Math.Abs(bottomY - topY), preferred));
+ const float MaxGridHeight = 640f;
+ float totalH = yStep * (rowCount - 1);
+ if (totalH > MaxGridHeight)
+ yStep = MaxGridHeight / (rowCount - 1);
+ }
+
+ int start = 0;
+ for (int r = 0; r < rowCount; r++)
+ {
+ int cols = Math.Min(maxCols, count - start);
+ float y = centerY + (r - (rowCount - 1) * 0.5f) * yStep;
+ float startX = centerX - (cols - 1) * xStep * 0.5f;
+ for (int c = 0; c < cols; c++)
+ holdersInUse[start + c].Position = new Vector2(startX + c * xStep, y);
+ start += cols;
+ }
+
+ RebuildHolderFocusNavigation(holdersInUse);
+ }
+
+ private static void RebuildHolderFocusNavigation(List holdersInUse)
+ {
+ if (holdersInUse.Count == 0) return;
+
+ for (int i = 0; i < holdersInUse.Count; i++)
+ {
+ NTreasureRoomRelicHolder holder = holdersInUse[i];
+ holder.SetFocusMode(Control.FocusModeEnum.All);
+ holder.FocusNeighborTop = holder.GetPath();
+ holder.FocusNeighborBottom = holder.GetPath();
+ holder.FocusNeighborLeft = i > 0
+ ? holdersInUse[i - 1].GetPath()
+ : holdersInUse[^1].GetPath();
+ holder.FocusNeighborRight = i < holdersInUse.Count - 1
+ ? holdersInUse[i + 1].GetPath()
+ : holdersInUse[0].GetPath();
+ }
+ }
+ }
+}
diff --git a/src/Features/VictoryFlow/VictoryModule.cs b/src/Features/VictoryFlow/VictoryModule.cs
new file mode 100644
index 0000000..ac6508c
--- /dev/null
+++ b/src/Features/VictoryFlow/VictoryModule.cs
@@ -0,0 +1,107 @@
+using System.Linq;
+using Godot;
+using MegaCrit.Sts2.Core.Logging;
+using MegaCrit.Sts2.Core.Nodes;
+using MegaCrit.Sts2.Core.Nodes.Screens.GameOverScreen;
+using MegaCrit.Sts2.Core.Nodes.Screens.Overlays;
+using MegaCrit.Sts2.Core.Runs;
+using RemoveMultiplayerPlayerLimit.Core;
+using RemoveMultiplayerPlayerLimit.Infrastructure;
+
+namespace RemoveMultiplayerPlayerLimit.Features.VictoryFlow;
+
+///
+/// Safety net for multiplayer victory flow.
+/// If the victory room finishes and the game-over summary never appears,
+/// force-open it after a short grace period instead of leaving the player stuck.
+///
+public partial class VictoryModule : IRMPModule
+{
+ private const int CheckIntervalFrames = 15;
+ private const int GracePeriodTicks = 12; // ~3 seconds at 60 FPS.
+
+ public string Name => "VictoryFlow";
+
+ public void Initialize(ConfigManager config, ReflectionCache cache)
+ {
+ }
+
+ public Node? CreateNode() => new VictoryNode();
+
+ public void Cleanup()
+ {
+ }
+
+ private sealed partial class VictoryNode : Node
+ {
+ private ulong _lastRunNodeId;
+ private int _frameCounter;
+ private int _missingGameOverTicks;
+ private bool _summaryForcedForCurrentRun;
+
+ public VictoryNode()
+ {
+ Name = "VictoryNode";
+ }
+
+ public override void _Process(double delta)
+ {
+ if (++_frameCounter % CheckIntervalFrames != 0)
+ return;
+
+ NRun? runNode = NRun.Instance;
+ if (runNode == null)
+ {
+ ResetState();
+ return;
+ }
+
+ ulong runNodeId = runNode.GetInstanceId();
+ if (runNodeId != _lastRunNodeId)
+ {
+ _lastRunNodeId = runNodeId;
+ _missingGameOverTicks = 0;
+ _summaryForcedForCurrentRun = false;
+ }
+
+ if (_summaryForcedForCurrentRun)
+ return;
+
+ RunState? runState = GameStateAccessor.GetRunState();
+ if (runState?.CurrentRoom?.IsVictoryRoom != true)
+ {
+ _missingGameOverTicks = 0;
+ return;
+ }
+
+ if (NOverlayStack.Instance?.Peek() is NGameOverScreen)
+ {
+ _missingGameOverTicks = 0;
+ return;
+ }
+
+ bool areAllPlayersDead = runState.Players.Count > 0 && runState.Players.All(player => player.Creature.IsDead);
+ if (!areAllPlayersDead)
+ {
+ _missingGameOverTicks = 0;
+ return;
+ }
+
+ _missingGameOverTicks++;
+ if (_missingGameOverTicks < GracePeriodTicks)
+ return;
+
+ Log.Warn("[RMP:Victory] Victory summary did not appear in time. Forcing GameOverScreen.");
+ runNode.ShowGameOverScreen(RunManager.Instance.ToSave(preFinishedRoom: null));
+ _summaryForcedForCurrentRun = true;
+ _missingGameOverTicks = 0;
+ }
+
+ private void ResetState()
+ {
+ _lastRunNodeId = 0;
+ _missingGameOverTicks = 0;
+ _summaryForcedForCurrentRun = false;
+ }
+ }
+}
diff --git a/src/Infrastructure/GameStateAccessor.cs b/src/Infrastructure/GameStateAccessor.cs
new file mode 100644
index 0000000..17b69bc
--- /dev/null
+++ b/src/Infrastructure/GameStateAccessor.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Reflection;
+using Godot;
+using MegaCrit.Sts2.Core.Runs;
+
+namespace RemoveMultiplayerPlayerLimit.Infrastructure;
+
+///
+/// Read-only access to game state via public APIs and cached reflection.
+/// Modules use this instead of reaching into game internals directly.
+///
+public static class GameStateAccessor
+{
+ // RunManager.State is private — access via reflection
+ private static readonly PropertyInfo? RunManagerStateProperty =
+ typeof(RunManager).GetProperty("State",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+
+ // ── Player count ──────────────────────────────────────────────────
+
+ public static int GetPlayerCount()
+ {
+ try
+ {
+ var rm = RunManager.Instance;
+ if (rm == null) return 1;
+ var state = RunManagerStateProperty?.GetValue(rm) as RunState;
+ return state?.Players?.Count ?? 1;
+ }
+ catch { return 1; }
+ }
+
+ /// Get the current RunState via reflection (private property).
+ public static RunState? GetRunState()
+ {
+ try
+ {
+ var rm = RunManager.Instance;
+ if (rm == null) return null;
+ return RunManagerStateProperty?.GetValue(rm) as RunState;
+ }
+ catch { return null; }
+ }
+
+ // ── Multiplayer ───────────────────────────────────────────────────
+
+ public static bool IsMultiplayer()
+ {
+ try
+ {
+ var tree = (SceneTree)Engine.GetMainLoop();
+ return tree.GetMultiplayer()?.GetUniqueId() != 0;
+ }
+ catch { return false; }
+ }
+
+ public static bool IsServer()
+ {
+ try
+ {
+ var tree = (SceneTree)Engine.GetMainLoop();
+ var mp = tree.GetMultiplayer();
+ return mp?.IsServer() == true;
+ }
+ catch { return false; }
+ }
+
+ // ── Difficulty scaling helper ─────────────────────────────────────
+
+ ///
+ /// Returns the effective player count for difficulty calculations.
+ /// When scaling is disabled, clamps to vanilla 4-player max.
+ /// When enabled, returns the actual player count.
+ ///
+ public static int GetEffectivePlayerCount(int rawCount)
+ {
+ return Core.ProtocolConfig.DifficultyScalingEnabled
+ ? rawCount
+ : Math.Min(rawCount, 4);
+ }
+}
diff --git a/src/Infrastructure/Localization.cs b/src/Infrastructure/Localization.cs
new file mode 100644
index 0000000..20121f8
--- /dev/null
+++ b/src/Infrastructure/Localization.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using Godot;
+using MegaCrit.Sts2.Core.Localization;
+using MegaCrit.Sts2.Core.Logging;
+
+namespace RemoveMultiplayerPlayerLimit.Infrastructure;
+
+///
+/// Localization helper — loads translations from .json files in the mod's PCK.
+/// Falls back to English, then to the provided fallback text.
+///
+public static class Localization
+{
+ private static readonly Dictionary> Cache = new();
+
+ public static string Get(string key, string fallback)
+ {
+ string lang = GetLanguageCode();
+ if (TryGet(lang, key, out string value)) return value;
+ if (lang != "en_us" && TryGet("en_us", key, out value)) return value;
+ return fallback;
+ }
+
+ private static string GetLanguageCode()
+ {
+ string language = LocManager.Instance?.Language ?? "eng";
+ return string.Equals(language, "zhs", StringComparison.OrdinalIgnoreCase)
+ ? "zh_cn" : "en_us";
+ }
+
+ private static bool TryGet(string langCode, string key, out string value)
+ {
+ var table = GetTable(langCode);
+ if (table.TryGetValue(key, out string? result) && result != null)
+ {
+ value = result;
+ return true;
+ }
+ value = string.Empty;
+ return false;
+ }
+
+ private static Dictionary GetTable(string langCode)
+ {
+ if (Cache.TryGetValue(langCode, out var cached)) return cached;
+
+ string path = $"res://RemoveMultiplayerPlayerLimit/localization/{langCode}.json";
+ Dictionary table = new();
+ try
+ {
+ using FileAccess file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
+ if (file != null)
+ {
+ var parsed = JsonSerializer.Deserialize>(file.GetAsText());
+ if (parsed != null) table = parsed;
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP] Failed to load localization: {path}. {ex.Message}");
+ }
+ Cache[langCode] = table;
+ return table;
+ }
+}
diff --git a/src/Infrastructure/ReflectionCache.cs b/src/Infrastructure/ReflectionCache.cs
new file mode 100644
index 0000000..ea39320
--- /dev/null
+++ b/src/Infrastructure/ReflectionCache.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Godot;
+using MegaCrit.Sts2.Core.Modding;
+
+namespace RemoveMultiplayerPlayerLimit.Infrastructure;
+
+///
+/// Central reflection cache — all game-internal access goes through here.
+/// Caches FieldInfo/MethodInfo/PropertyInfo/Type lookups for performance.
+///
+public class ReflectionCache
+{
+ private readonly Dictionary _fields = new();
+ private readonly Dictionary _methods = new();
+ private readonly Dictionary _properties = new();
+ private readonly Dictionary _types = new();
+
+ private readonly Assembly _gameAssembly;
+
+ public ReflectionCache()
+ {
+ _gameAssembly = typeof(ModInitializerAttribute).Assembly;
+ }
+
+ // ── Type ──────��───────────────────────────────────────────────────
+
+ public Type? GetType(string fullName)
+ {
+ if (!_types.TryGetValue(fullName, out var type))
+ {
+ type = _gameAssembly.GetType(fullName);
+ _types[fullName] = type;
+ }
+ return type;
+ }
+
+ // ── Field ──────────────────────────────────��──────────────────────
+
+ public FieldInfo? GetField(string typeName, string fieldName)
+ {
+ string key = $"{typeName}.{fieldName}";
+ if (!_fields.TryGetValue(key, out var field))
+ {
+ var type = GetType(typeName);
+ field = type?.GetField(fieldName,
+ BindingFlags.Instance | BindingFlags.Static |
+ BindingFlags.Public | BindingFlags.NonPublic);
+ _fields[key] = field;
+ }
+ return field;
+ }
+
+ public FieldInfo? GetField(Type type, string fieldName)
+ {
+ string key = $"{type.FullName}.{fieldName}";
+ if (!_fields.TryGetValue(key, out var field))
+ {
+ field = type.GetField(fieldName,
+ BindingFlags.Instance | BindingFlags.Static |
+ BindingFlags.Public | BindingFlags.NonPublic);
+ _fields[key] = field;
+ }
+ return field;
+ }
+
+ // ── Method ───────���────────────────────────────────────────────────
+
+ public MethodInfo? GetMethod(string typeName, string methodName, Type[]? paramTypes = null)
+ {
+ string key = paramTypes == null
+ ? $"{typeName}.{methodName}"
+ : $"{typeName}.{methodName}({string.Join(",", paramTypes.Select(t => t.Name))})";
+
+ if (!_methods.TryGetValue(key, out var method))
+ {
+ var type = GetType(typeName);
+ method = paramTypes == null
+ ? type?.GetMethod(methodName,
+ BindingFlags.Instance | BindingFlags.Static |
+ BindingFlags.Public | BindingFlags.NonPublic)
+ : type?.GetMethod(methodName,
+ BindingFlags.Instance | BindingFlags.Static |
+ BindingFlags.Public | BindingFlags.NonPublic,
+ null, paramTypes, null);
+ _methods[key] = method;
+ }
+ return method;
+ }
+
+ public MethodInfo? GetMethod(Type type, string methodName, Type[]? paramTypes = null)
+ {
+ return GetMethod(type.FullName!, methodName, paramTypes);
+ }
+
+ // ── Property ───���──────────────────────────────────────────────────
+
+ public PropertyInfo? GetProperty(string typeName, string propertyName)
+ {
+ string key = $"{typeName}.{propertyName}";
+ if (!_properties.TryGetValue(key, out var prop))
+ {
+ var type = GetType(typeName);
+ prop = type?.GetProperty(propertyName,
+ BindingFlags.Instance | BindingFlags.Static |
+ BindingFlags.Public | BindingFlags.NonPublic);
+ _properties[key] = prop;
+ }
+ return prop;
+ }
+
+ public PropertyInfo? GetProperty(Type type, string propertyName)
+ {
+ return GetProperty(type.FullName!, propertyName);
+ }
+
+ // ��─ Safe helpers ─────────��────────────────────────────────────────
+
+ public bool TrySetField(object? target, string typeName, string fieldName, object? value)
+ {
+ var field = GetField(typeName, fieldName);
+ if (field == null)
+ {
+ GD.PrintErr($"[RMP] Field not found: {typeName}.{fieldName}");
+ return false;
+ }
+ field.SetValue(target, value);
+ return true;
+ }
+
+ public T? TryGetField(object? target, string typeName, string fieldName, T? fallback = default)
+ {
+ var field = GetField(typeName, fieldName);
+ if (field == null) return fallback;
+ var value = field.GetValue(target);
+ return value is T typed ? typed : fallback;
+ }
+
+ public bool TrySetField(object? target, Type type, string fieldName, object? value)
+ {
+ return TrySetField(target, type.FullName!, fieldName, value);
+ }
+
+ public T? TryGetField(object? target, Type type, string fieldName, T? fallback = default)
+ {
+ return TryGetField(target, type.FullName!, fieldName, fallback);
+ }
+
+ public object? TryInvokeMethod(object? target, string typeName, string methodName, object?[]? args = null)
+ {
+ var method = GetMethod(typeName, methodName);
+ if (method == null)
+ {
+ GD.PrintErr($"[RMP] Method not found: {typeName}.{methodName}");
+ return null;
+ }
+ return method.Invoke(target, args);
+ }
+}
diff --git a/src/Infrastructure/SceneMonitor.cs b/src/Infrastructure/SceneMonitor.cs
new file mode 100644
index 0000000..aa205f3
--- /dev/null
+++ b/src/Infrastructure/SceneMonitor.cs
@@ -0,0 +1,253 @@
+using System;
+using System.Reflection;
+using Godot;
+using MegaCrit.Sts2.Core.Multiplayer.Game.Lobby;
+using MegaCrit.Sts2.Core.Nodes.Rooms;
+using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect;
+using MegaCrit.Sts2.Core.Nodes.Screens.CustomRun;
+using MegaCrit.Sts2.Core.Nodes.Screens.DailyRun;
+using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu;
+using MegaCrit.Sts2.Core.Nodes.Screens.Settings;
+using MegaCrit.Sts2.Core.Nodes.Screens.TreasureRoomRelic;
+
+namespace RemoveMultiplayerPlayerLimit.Infrastructure;
+
+///
+/// SceneTree navigation and monitoring utilities.
+/// Uses cached high-value nodes when available and only falls back to DFS when needed.
+///
+public static class SceneMonitor
+{
+ private static readonly FieldInfo? DailyRunLobbyField =
+ typeof(NDailyRunScreen).GetField("_lobby", BindingFlags.Instance | BindingFlags.NonPublic);
+
+ private static readonly FieldInfo? MultiplayerLoadLobbyField =
+ typeof(NMultiplayerLoadGameScreen).GetField("_runLobby", BindingFlags.Instance | BindingFlags.NonPublic);
+
+ private static readonly FieldInfo? CustomRunLoadLobbyField =
+ typeof(NCustomRunLoadScreen).GetField("_lobby", BindingFlags.Instance | BindingFlags.NonPublic);
+
+ private static readonly FieldInfo? DailyRunLoadLobbyField =
+ typeof(NDailyRunLoadScreen).GetField("_lobby", BindingFlags.Instance | BindingFlags.NonPublic);
+
+ public static Node CreateRegistryNode() => new SceneRegistry();
+
+ /// Get the current active scene name.
+ public static string GetCurrentSceneName()
+ {
+ SceneTree tree = (SceneTree)Engine.GetMainLoop();
+ return tree.CurrentScene?.Name.ToString() ?? string.Empty;
+ }
+
+ /// Check if a scene whose name contains the given string is active.
+ public static bool IsSceneActive(string nameContains)
+ {
+ SceneTree tree = (SceneTree)Engine.GetMainLoop();
+ return tree.CurrentScene?.Name.ToString().Contains(nameContains, StringComparison.OrdinalIgnoreCase) == true;
+ }
+
+ /// Get the SceneTree root.
+ public static Node GetRoot()
+ {
+ return ((SceneTree)Engine.GetMainLoop()).Root;
+ }
+
+ public static NSettingsScreen? FindSettingsScreen()
+ {
+ NSettingsScreen? screen = SceneRegistry.Instance?.SettingsScreen;
+ return screen != null && screen.IsVisibleInTree() ? screen : null;
+ }
+
+ public static NRestSiteRoom? FindRestSiteRoom() => SceneRegistry.Instance?.RestSiteRoom;
+
+ public static NMerchantRoom? FindMerchantRoom() => SceneRegistry.Instance?.MerchantRoom;
+
+ public static NTreasureRoomRelicCollection? FindTreasureRoomRelicCollection() =>
+ SceneRegistry.Instance?.TreasureRoomRelicCollection;
+
+ public static NMultiplayerSubmenu? FindMultiplayerSubmenu() => SceneRegistry.Instance?.MultiplayerSubmenu;
+
+ public static NMultiplayerHostSubmenu? FindMultiplayerHostSubmenu() => SceneRegistry.Instance?.MultiplayerHostSubmenu;
+
+ public static NMultiplayerLoadGameScreen? FindMultiplayerLoadGameScreen() =>
+ SceneRegistry.Instance?.MultiplayerLoadGameScreen;
+
+ public static NCustomRunLoadScreen? FindCustomRunLoadScreen() => SceneRegistry.Instance?.CustomRunLoadScreen;
+
+ public static NDailyRunLoadScreen? FindDailyRunLoadScreen() => SceneRegistry.Instance?.DailyRunLoadScreen;
+
+ /// Find a node by name substring, searching recursively from the given root.
+ public static Node? FindNodeByName(Node root, string nameContains)
+ {
+ if (root.Name.ToString().Contains(nameContains, StringComparison.Ordinal))
+ return root;
+
+ foreach (Node child in root.GetChildren())
+ {
+ Node? result = FindNodeByName(child, nameContains);
+ if (result != null)
+ return result;
+ }
+
+ return null;
+ }
+
+ /// Find the first node of a specific type in the tree.
+ public static T? FindNodeOfType(Node root) where T : Node
+ {
+ if (root == GetRoot() && TryGetCachedNode(out T? cached))
+ return cached;
+
+ if (root is T target)
+ return target;
+
+ foreach (Node child in root.GetChildren())
+ {
+ T? result = FindNodeOfType(child);
+ if (result != null)
+ return result;
+ }
+
+ return null;
+ }
+
+ /// Find a child node matching a predicate, recursively.
+ public static Node? FindNode(Node root, Func predicate)
+ {
+ if (predicate(root))
+ return root;
+
+ foreach (Node child in root.GetChildren())
+ {
+ Node? result = FindNode(child, predicate);
+ if (result != null)
+ return result;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Finds the active StartRunLobby by checking known cached screen types first,
+ /// then falling back to a one-off tree scan if needed.
+ ///
+ public static StartRunLobby? FindActiveStartRunLobby()
+ {
+ try
+ {
+ NCharacterSelectScreen? charSelect = SceneRegistry.Instance?.CharacterSelectScreen;
+ if (charSelect?.Lobby != null)
+ return charSelect.Lobby;
+
+ NCustomRunScreen? customRun = SceneRegistry.Instance?.CustomRunScreen;
+ if (customRun?.Lobby != null)
+ return customRun.Lobby;
+
+ if (DailyRunLobbyField?.GetValue(SceneRegistry.Instance?.DailyRunScreen) is StartRunLobby dailyLobby)
+ return dailyLobby;
+
+ Node root = GetRoot();
+
+ charSelect = FindNodeOfType(root);
+ if (charSelect?.Lobby != null)
+ return charSelect.Lobby;
+
+ customRun = FindNodeOfType(root);
+ if (customRun?.Lobby != null)
+ return customRun.Lobby;
+
+ if (DailyRunLobbyField != null)
+ {
+ Node? dailyRun = FindNode(root, n =>
+ n.GetType().FullName == "MegaCrit.Sts2.Core.Nodes.Screens.DailyRun.NDailyRunScreen");
+ if (dailyRun != null && DailyRunLobbyField.GetValue(dailyRun) is StartRunLobby fallbackDailyLobby)
+ return fallbackDailyLobby;
+ }
+ }
+ catch
+ {
+ // Scene tree may be in transition.
+ }
+
+ return null;
+ }
+
+ public static LoadRunLobby? FindActiveLoadRunLobby()
+ {
+ try
+ {
+ if (MultiplayerLoadLobbyField?.GetValue(SceneRegistry.Instance?.MultiplayerLoadGameScreen) is LoadRunLobby standardLoadLobby)
+ return standardLoadLobby;
+
+ if (CustomRunLoadLobbyField?.GetValue(SceneRegistry.Instance?.CustomRunLoadScreen) is LoadRunLobby customLoadLobby)
+ return customLoadLobby;
+
+ if (DailyRunLoadLobbyField?.GetValue(SceneRegistry.Instance?.DailyRunLoadScreen) is LoadRunLobby dailyLoadLobby)
+ return dailyLoadLobby;
+
+ Node root = GetRoot();
+
+ if (FindNodeOfType(root) is { } standardLoadScreen
+ && MultiplayerLoadLobbyField?.GetValue(standardLoadScreen) is LoadRunLobby fallbackStandardLoadLobby)
+ {
+ return fallbackStandardLoadLobby;
+ }
+
+ if (FindNodeOfType(root) is { } customLoadScreen
+ && CustomRunLoadLobbyField?.GetValue(customLoadScreen) is LoadRunLobby fallbackCustomLoadLobby)
+ {
+ return fallbackCustomLoadLobby;
+ }
+
+ if (FindNodeOfType(root) is { } dailyLoadScreen
+ && DailyRunLoadLobbyField?.GetValue(dailyLoadScreen) is LoadRunLobby fallbackDailyLoadLobby)
+ {
+ return fallbackDailyLoadLobby;
+ }
+ }
+ catch
+ {
+ // Scene tree may be in transition.
+ }
+
+ return null;
+ }
+
+ public static string? GetActiveLoadLobbyScreenName()
+ {
+ if (MultiplayerLoadLobbyField?.GetValue(SceneRegistry.Instance?.MultiplayerLoadGameScreen) is LoadRunLobby)
+ return nameof(NMultiplayerLoadGameScreen);
+
+ if (CustomRunLoadLobbyField?.GetValue(SceneRegistry.Instance?.CustomRunLoadScreen) is LoadRunLobby)
+ return nameof(NCustomRunLoadScreen);
+
+ if (DailyRunLoadLobbyField?.GetValue(SceneRegistry.Instance?.DailyRunLoadScreen) is LoadRunLobby)
+ return nameof(NDailyRunLoadScreen);
+
+ return null;
+ }
+
+ private static bool TryGetCachedNode(out T? node) where T : Node
+ {
+ SceneRegistry? registry = SceneRegistry.Instance;
+ node = registry switch
+ {
+ null => null,
+ _ when typeof(T) == typeof(NSettingsScreen) => registry.SettingsScreen as T,
+ _ when typeof(T) == typeof(NRestSiteRoom) => registry.RestSiteRoom as T,
+ _ when typeof(T) == typeof(NMerchantRoom) => registry.MerchantRoom as T,
+ _ when typeof(T) == typeof(NTreasureRoomRelicCollection) => registry.TreasureRoomRelicCollection as T,
+ _ when typeof(T) == typeof(NCharacterSelectScreen) => registry.CharacterSelectScreen as T,
+ _ when typeof(T) == typeof(NCustomRunScreen) => registry.CustomRunScreen as T,
+ _ when typeof(T) == typeof(NDailyRunScreen) => registry.DailyRunScreen as T,
+ _ when typeof(T) == typeof(NMultiplayerLoadGameScreen) => registry.MultiplayerLoadGameScreen as T,
+ _ when typeof(T) == typeof(NCustomRunLoadScreen) => registry.CustomRunLoadScreen as T,
+ _ when typeof(T) == typeof(NDailyRunLoadScreen) => registry.DailyRunLoadScreen as T,
+ _ when typeof(T) == typeof(NMultiplayerSubmenu) => registry.MultiplayerSubmenu as T,
+ _ when typeof(T) == typeof(NMultiplayerHostSubmenu) => registry.MultiplayerHostSubmenu as T,
+ _ => null
+ };
+
+ return node != null;
+ }
+}
diff --git a/src/Infrastructure/SceneRegistry.cs b/src/Infrastructure/SceneRegistry.cs
new file mode 100644
index 0000000..d6f60b2
--- /dev/null
+++ b/src/Infrastructure/SceneRegistry.cs
@@ -0,0 +1,129 @@
+using Godot;
+using MegaCrit.Sts2.Core.Nodes.Rooms;
+using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect;
+using MegaCrit.Sts2.Core.Nodes.Screens.CustomRun;
+using MegaCrit.Sts2.Core.Nodes.Screens.DailyRun;
+using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu;
+using MegaCrit.Sts2.Core.Nodes.Screens.Settings;
+using MegaCrit.Sts2.Core.Nodes.Screens.TreasureRoomRelic;
+
+namespace RemoveMultiplayerPlayerLimit.Infrastructure;
+
+///
+/// Event-driven cache of high-value SceneTree nodes.
+/// Avoids repeated full-tree DFS in idle states like the main menu.
+///
+public partial class SceneRegistry : Node
+{
+ public static SceneRegistry? Instance { get; private set; }
+
+ private NSettingsScreen? _settingsScreen;
+ private NRestSiteRoom? _restSiteRoom;
+ private NMerchantRoom? _merchantRoom;
+ private NTreasureRoomRelicCollection? _treasureRoomRelicCollection;
+ private NCharacterSelectScreen? _characterSelectScreen;
+ private NCustomRunScreen? _customRunScreen;
+ private NDailyRunScreen? _dailyRunScreen;
+ private NMultiplayerLoadGameScreen? _multiplayerLoadGameScreen;
+ private NCustomRunLoadScreen? _customRunLoadScreen;
+ private NDailyRunLoadScreen? _dailyRunLoadScreen;
+ private NMultiplayerSubmenu? _multiplayerSubmenu;
+ private NMultiplayerHostSubmenu? _multiplayerHostSubmenu;
+
+ internal NSettingsScreen? SettingsScreen => Validate(ref _settingsScreen);
+ internal NRestSiteRoom? RestSiteRoom => Validate(ref _restSiteRoom);
+ internal NMerchantRoom? MerchantRoom => Validate(ref _merchantRoom);
+ internal NTreasureRoomRelicCollection? TreasureRoomRelicCollection => Validate(ref _treasureRoomRelicCollection);
+ internal NCharacterSelectScreen? CharacterSelectScreen => Validate(ref _characterSelectScreen);
+ internal NCustomRunScreen? CustomRunScreen => Validate(ref _customRunScreen);
+ internal NDailyRunScreen? DailyRunScreen => Validate(ref _dailyRunScreen);
+ internal NMultiplayerLoadGameScreen? MultiplayerLoadGameScreen => Validate(ref _multiplayerLoadGameScreen);
+ internal NCustomRunLoadScreen? CustomRunLoadScreen => Validate(ref _customRunLoadScreen);
+ internal NDailyRunLoadScreen? DailyRunLoadScreen => Validate(ref _dailyRunLoadScreen);
+ internal NMultiplayerSubmenu? MultiplayerSubmenu => Validate(ref _multiplayerSubmenu);
+ internal NMultiplayerHostSubmenu? MultiplayerHostSubmenu => Validate(ref _multiplayerHostSubmenu);
+
+ public override void _EnterTree()
+ {
+ Name = "SceneRegistry";
+ Instance = this;
+
+ SceneTree tree = GetTree();
+ tree.NodeAdded += OnNodeAdded;
+ IndexExistingTree(tree.Root);
+ }
+
+ public override void _ExitTree()
+ {
+ SceneTree? tree = GetTree();
+ if (tree != null)
+ tree.NodeAdded -= OnNodeAdded;
+
+ if (ReferenceEquals(Instance, this))
+ Instance = null;
+ }
+
+ private void OnNodeAdded(Node node)
+ {
+ Register(node);
+ }
+
+ private void IndexExistingTree(Node node)
+ {
+ Register(node);
+ foreach (Node child in node.GetChildren())
+ IndexExistingTree(child);
+ }
+
+ private void Register(Node node)
+ {
+ switch (node)
+ {
+ case NSettingsScreen settingsScreen:
+ _settingsScreen = settingsScreen;
+ break;
+ case NRestSiteRoom restSiteRoom:
+ _restSiteRoom = restSiteRoom;
+ break;
+ case NMerchantRoom merchantRoom:
+ _merchantRoom = merchantRoom;
+ break;
+ case NTreasureRoomRelicCollection treasureRoomRelicCollection:
+ _treasureRoomRelicCollection = treasureRoomRelicCollection;
+ break;
+ case NCharacterSelectScreen characterSelectScreen:
+ _characterSelectScreen = characterSelectScreen;
+ break;
+ case NCustomRunScreen customRunScreen:
+ _customRunScreen = customRunScreen;
+ break;
+ case NDailyRunScreen dailyRunScreen:
+ _dailyRunScreen = dailyRunScreen;
+ break;
+ case NMultiplayerLoadGameScreen multiplayerLoadGameScreen:
+ _multiplayerLoadGameScreen = multiplayerLoadGameScreen;
+ break;
+ case NCustomRunLoadScreen customRunLoadScreen:
+ _customRunLoadScreen = customRunLoadScreen;
+ break;
+ case NDailyRunLoadScreen dailyRunLoadScreen:
+ _dailyRunLoadScreen = dailyRunLoadScreen;
+ break;
+ case NMultiplayerSubmenu multiplayerSubmenu:
+ _multiplayerSubmenu = multiplayerSubmenu;
+ break;
+ case NMultiplayerHostSubmenu multiplayerHostSubmenu:
+ _multiplayerHostSubmenu = multiplayerHostSubmenu;
+ break;
+ }
+ }
+
+ private static T? Validate(ref T? node) where T : Node
+ {
+ if (node != null && GodotObject.IsInstanceValid(node) && !node.IsQueuedForDeletion())
+ return node;
+
+ node = null;
+ return null;
+ }
+}
diff --git a/src/ModEntry.cs b/src/ModEntry.cs
deleted file mode 100644
index ae78435..0000000
--- a/src/ModEntry.cs
+++ /dev/null
@@ -1,178 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Reflection;
-using System.Text.Json;
-using HarmonyLib;
-using MegaCrit.Sts2.Core.Logging;
-using MegaCrit.Sts2.Core.Modding;
-using RemoveMultiplayerPlayerLimit.Network;
-
-namespace RemoveMultiplayerPlayerLimit;
-
-[ModInitializer("Initialize")]
-public static partial class ModEntry
-{
- private const bool DefaultMacOsTlsWorkaroundEnabled = true;
-
- internal const int VanillaMultiplayerHolderCount = 4;
-
- private const string ModFolderName = "RemoveMultiplayerPlayerLimit";
-
- private const string ConfigFileName = "config.ini";
-
- private const string LegacyConfigFileName = "config.json";
-
- private static bool MacOsTlsWorkaroundEnabled { get; set; } = DefaultMacOsTlsWorkaroundEnabled;
-
- private static string? ConfigFilePath { get; set; }
-
- public static void Initialize()
- {
- LoadOrCreateConfig();
- EnsureLinuxHarmonyDependenciesLoaded();
- int slotIdCapacity = 1 << ProtocolConfig.SlotIdBits;
- int lobbyListLengthCapacity = 1 << ProtocolConfig.LobbyListLengthBits;
- new Harmony("cn.remove.multiplayer.playerlimit").PatchAll();
- Log.Info($"RemoveMultiplayerPlayerLimit loaded. Target limit: {ProtocolConfig.TargetPlayerLimit}, protocol slot bits: {ProtocolConfig.SlotIdBits}, slot capacity: {slotIdCapacity}, protocol lobby bits: {ProtocolConfig.LobbyListLengthBits}, lobby list capacity: {lobbyListLengthCapacity}, difficulty scaling: {ProtocolConfig.DifficultyScalingEnabled}, macOS TLS workaround: {MacOsTlsWorkaroundEnabled}");
- }
-
- private static void LoadOrCreateConfig()
- {
- string modDirectory = ResolveModDirectory();
- Directory.CreateDirectory(modDirectory);
- ConfigFilePath = Path.Combine(modDirectory, ConfigFileName);
- string legacyPath = Path.Combine(modDirectory, LegacyConfigFileName);
- if (File.Exists(legacyPath) && !File.Exists(ConfigFilePath))
- {
- MigrateLegacyJsonConfig(legacyPath);
- }
- if (File.Exists(ConfigFilePath))
- {
- try
- {
- ParseIniConfig(ConfigFilePath);
- return;
- }
- catch (Exception ex)
- {
- Log.Warn($"Failed to parse config at {ConfigFilePath}: {ex.Message}");
- BackupCorruptedConfig(ConfigFilePath);
- }
- }
- SaveModConfig();
- }
-
- private static void ParseIniConfig(string path)
- {
- string currentSection = "";
- foreach (string rawLine in File.ReadAllLines(path))
- {
- string line = rawLine.Trim();
- if (line.Length == 0 || line[0] == ';' || line[0] == '#')
- {
- continue;
- }
- if (line[0] == '[' && line[^1] == ']')
- {
- currentSection = line[1..^1].Trim();
- continue;
- }
- int eq = line.IndexOf('=');
- if (eq < 0)
- {
- continue;
- }
- string key = line[..eq].Trim();
- string value = line[(eq + 1)..].Trim();
- switch (currentSection)
- {
- case "macos" when key == "tls_workaround":
- MacOsTlsWorkaroundEnabled = string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
- break;
- case "multiplayer" when key == "max_player_limit" && int.TryParse(value, out int rawLimit):
- ProtocolConfig.SetTargetPlayerLimit(rawLimit);
- break;
- case "multiplayer" when key == "difficulty_scaling":
- ProtocolConfig.SetDifficultyScalingEnabled(string.Equals(value, "true", StringComparison.OrdinalIgnoreCase));
- break;
- }
- }
- }
-
- internal static void SaveModConfig()
- {
- if (string.IsNullOrEmpty(ConfigFilePath))
- {
- return;
- }
- try
- {
- using var writer = new StreamWriter(ConfigFilePath, false);
- writer.WriteLine("[macos]");
- writer.WriteLine($"tls_workaround={MacOsTlsWorkaroundEnabled.ToString().ToLowerInvariant()}");
- writer.WriteLine();
- writer.WriteLine("[multiplayer]");
- writer.WriteLine($"max_player_limit={ProtocolConfig.TargetPlayerLimit}");
- writer.WriteLine($"difficulty_scaling={ProtocolConfig.DifficultyScalingEnabled.ToString().ToLowerInvariant()}");
- }
- catch (Exception ex)
- {
- Log.Warn($"Failed to save config: {ex.Message}");
- }
- }
-
- private static void MigrateLegacyJsonConfig(string jsonPath)
- {
- try
- {
- using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(jsonPath));
- if (doc.RootElement.TryGetProperty("max_player_limit", out JsonElement limitEl) && limitEl.TryGetInt32(out int raw))
- {
- ProtocolConfig.SetTargetPlayerLimit(raw);
- }
- if (doc.RootElement.TryGetProperty("macos_tls_workaround", out JsonElement tlsEl))
- {
- MacOsTlsWorkaroundEnabled = tlsEl.ValueKind == JsonValueKind.True;
- }
- SaveModConfig();
- File.Delete(jsonPath);
- Log.Info("Migrated config.json to config.ini");
- }
- catch (Exception ex)
- {
- Log.Warn($"Failed to migrate legacy config: {ex.Message}");
- }
- }
-
- private static string ResolveModDirectory()
- {
- string? assemblyLocation = Assembly.GetExecutingAssembly().Location;
- string? assemblyDirectory = string.IsNullOrWhiteSpace(assemblyLocation) ? null : Path.GetDirectoryName(assemblyLocation);
- if (!string.IsNullOrWhiteSpace(assemblyDirectory) && Directory.Exists(assemblyDirectory))
- {
- return assemblyDirectory;
- }
- string fallbackModDirectory = Path.Combine(AppContext.BaseDirectory, "mods", ModFolderName);
- if (Directory.Exists(fallbackModDirectory))
- {
- return fallbackModDirectory;
- }
- string appDataRoot = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
- return Path.Combine(appDataRoot, "StS2Mods", ModFolderName);
- }
-
- private static void BackupCorruptedConfig(string configPath)
- {
- if (!File.Exists(configPath))
- {
- return;
- }
- string backupPath = $"{configPath}.bak";
- if (File.Exists(backupPath))
- {
- backupPath = $"{configPath}.{DateTime.Now:yyyyMMddHHmmss}.bak";
- }
- File.Move(configPath, backupPath);
- }
-}
diff --git a/src/Network/ExtendedLobbyModule.cs b/src/Network/ExtendedLobbyModule.cs
new file mode 100644
index 0000000..6e5a54a
--- /dev/null
+++ b/src/Network/ExtendedLobbyModule.cs
@@ -0,0 +1,640 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Godot;
+using MegaCrit.Sts2.Core.Commands;
+using MegaCrit.Sts2.Core.Entities.Multiplayer;
+using MegaCrit.Sts2.Core.Helpers;
+using MegaCrit.Sts2.Core.Logging;
+using MegaCrit.Sts2.Core.Models;
+using MegaCrit.Sts2.Core.Models.Characters;
+using MegaCrit.Sts2.Core.Models.Modifiers;
+using MegaCrit.Sts2.Core.Multiplayer;
+using MegaCrit.Sts2.Core.Multiplayer.Game;
+using MegaCrit.Sts2.Core.Multiplayer.Game.Lobby;
+using MegaCrit.Sts2.Core.Multiplayer.Messages.Lobby;
+using MegaCrit.Sts2.Core.Nodes;
+using MegaCrit.Sts2.Core.Nodes.CommonUi;
+using MegaCrit.Sts2.Core.Nodes.Ftue;
+using MegaCrit.Sts2.Core.Nodes.GodotExtensions;
+using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect;
+using MegaCrit.Sts2.Core.Nodes.Screens.CustomRun;
+using MegaCrit.Sts2.Core.Nodes.Screens.DailyRun;
+using MegaCrit.Sts2.Core.Platform;
+using MegaCrit.Sts2.Core.Random;
+using MegaCrit.Sts2.Core.Runs;
+using MegaCrit.Sts2.Core.Saves;
+using MegaCrit.Sts2.Core.Saves.Runs;
+using MegaCrit.Sts2.Core.Unlocks;
+using RemoveMultiplayerPlayerLimit.Core;
+using RemoveMultiplayerPlayerLimit.Infrastructure;
+
+namespace RemoveMultiplayerPlayerLimit.Network;
+
+///
+/// Extends pre-run lobby flow beyond the vanilla 4-slot serializable limit
+/// without Harmony by replacing the problematic host join response and the
+/// ready/begin-run UI flow when extended mode is active.
+///
+public partial class ExtendedLobbyModule : IRMPModule
+{
+ private static readonly MethodInfo? TryAddPlayerMethod = typeof(StartRunLobby).GetMethod(
+ "TryAddPlayerInFirstAvailableSlot",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+
+ private static readonly MethodInfo? UpdateMaxAscensionMethod = typeof(StartRunLobby).GetMethod(
+ "UpdateMaxMultiplayerAscension",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ private static readonly MethodInfo? UpdatePreferredAscensionMethod = typeof(StartRunLobby).GetMethod(
+ "UpdatePreferredAscension",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+
+ private static readonly MethodInfo? RemoveConnectingPlayerMethod = typeof(StartRunLobby).GetMethod(
+ "RemoveConnectingPlayer",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+
+ private static readonly MethodInfo? StartRunHandleJoinMethod = typeof(StartRunLobby).GetMethod(
+ "HandleClientLobbyJoinRequestMessage",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ private static readonly MethodInfo? GetRandomActListMethod = typeof(MegaCrit.Sts2.Core.Models.ActModel)
+ .GetMethods(BindingFlags.Public | BindingFlags.Static)
+ .FirstOrDefault(method => method.Name == "GetRandomList" && method.GetParameters().Length == 3);
+ private static readonly MethodInfo? GenericActMethod = typeof(ModelDb).GetMethod(
+ "Act",
+ BindingFlags.Public | BindingFlags.Static);
+
+ private static readonly FieldInfo? DailyRunLobbyField = typeof(MegaCrit.Sts2.Core.Nodes.Screens.DailyRun.NDailyRunScreen)
+ .GetField("_lobby", BindingFlags.Instance | BindingFlags.NonPublic);
+ private static readonly FieldInfo? BeginningRunField = typeof(StartRunLobby)
+ .GetField("_beginningRun", BindingFlags.Instance | BindingFlags.NonPublic);
+
+ private static readonly HashSet ExtendedRunStartingLobbyIds = new();
+ private static readonly Dictionary HostJoinPatchStates = new();
+
+ public string Name => "ExtendedLobby";
+
+ public void Initialize(ConfigManager config, ReflectionCache cache)
+ {
+ }
+
+ public Node? CreateNode() => new ExtendedLobbyNode();
+
+ public void Cleanup()
+ {
+ HostJoinPatchStates.Clear();
+ ExtendedRunStartingLobbyIds.Clear();
+ }
+
+ internal static bool ShouldUseExtendedLobbyProtocol(StartRunLobby lobby)
+ {
+ return lobby.NetService.Type.IsMultiplayer()
+ && ProtocolConfig.TargetPlayerLimit > ProtocolConfig.OfficialSerializableSlotLimit;
+ }
+
+ internal static bool TrySetPlayerReadyState(StartRunLobby lobby, ulong playerId, bool ready, out LobbyPlayer updatedPlayer)
+ {
+ int idx = lobby.Players.FindIndex(player => player.id == playerId);
+ if (idx < 0)
+ {
+ updatedPlayer = default;
+ return false;
+ }
+
+ updatedPlayer = lobby.Players[idx];
+ updatedPlayer.isReady = ready;
+ lobby.Players[idx] = updatedPlayer;
+ return true;
+ }
+
+ internal static List BuildActsForBeginRun(
+ string seed,
+ string act1,
+ StartRunLobby lobby,
+ IReadOnlyList players)
+ {
+ UnlockState unlockState = new(players.Select(player => UnlockState.FromSerializable(player.unlockState)));
+ Rng rng = new((uint)StringHelper.GetDeterministicHashCode(seed));
+ List acts = InvokeGetRandomActList(seed, rng, unlockState, lobby.NetService.Type.IsMultiplayer());
+ ActModel? chosenAct = GetAct(act1);
+ if (chosenAct != null)
+ acts[0] = chosenAct;
+ return acts;
+ }
+
+ internal static void NotifyPlayerChanged(StartRunLobby lobby, LobbyPlayer player, bool isRandomCharacterResolution)
+ {
+ MethodInfo? method = lobby.LobbyListener.GetType().GetMethod("PlayerChanged");
+ if (method == null)
+ return;
+
+ ParameterInfo[] parameters = method.GetParameters();
+ if (parameters.Length >= 2)
+ method.Invoke(lobby.LobbyListener, new object?[] { player, isRandomCharacterResolution });
+ else
+ method.Invoke(lobby.LobbyListener, new object?[] { player });
+ }
+
+ internal static bool TryBeginExtendedRun(StartRunLobby lobby)
+ {
+ if (!ShouldUseExtendedLobbyProtocol(lobby)
+ || IsBeginningRun(lobby)
+ || lobby.NetService.Type != NetGameType.Host)
+ {
+ return false;
+ }
+
+ if (lobby.Players.Count <= 1 || lobby.Players.Any(player => !player.isReady))
+ return false;
+
+ ulong lobbyId = lobby.GetHashCodeAsUlong();
+ if (!ExtendedRunStartingLobbyIds.Add(lobbyId))
+ return false;
+
+ try
+ {
+ string seed = NGame.Instance?.DebugSeedOverride
+ ?? (string.IsNullOrWhiteSpace(lobby.Seed)
+ ? SeedHelper.GetRandomSeed()
+ : SeedHelper.CanonicalizeSeed(lobby.Seed));
+
+ UpdatePreferredAscensionMethod?.Invoke(lobby, null);
+ NormalizeRandomCharacters(lobby, seed);
+ List modifiers = lobby.Modifiers.ToList();
+ List acts = BuildActsForBeginRun(seed, lobby.Act1, lobby, lobby.Players);
+
+ BeginningRunField?.SetValue(lobby, true);
+ RmpProtocol.BroadcastExtendedBeginRun(lobby.Players, seed, lobby.Act1, modifiers);
+ lobby.LobbyListener.BeginRun(seed, acts, modifiers);
+
+ if (lobby.NetService is NetHostGameService hostGameService)
+ hostGameService.NetHost?.SetHostIsClosed(isClosed: true);
+
+ Log.Info($"[RMP:ExtendedLobby] Started extended multiplayer run with {lobby.Players.Count} players.");
+ return true;
+ }
+ finally
+ {
+ ExtendedRunStartingLobbyIds.Remove(lobbyId);
+ }
+ }
+
+ private static bool IsBeginningRun(StartRunLobby lobby)
+ => BeginningRunField?.GetValue(lobby) is true;
+
+ private static void NormalizeRandomCharacters(StartRunLobby lobby, string seed)
+ {
+ Rng rng = new((uint)StringHelper.GetDeterministicHashCode(seed));
+ for (int i = 0; i < lobby.Players.Count; i++)
+ {
+ LobbyPlayer lobbyPlayer = lobby.Players[i];
+ if (lobbyPlayer.character is not RandomCharacter)
+ continue;
+
+ lobbyPlayer.character = rng.NextItem(ModelDb.AllCharacters) ?? ModelDb.AllCharacters.First();
+ lobby.Players[i] = lobbyPlayer;
+ NotifyPlayerChanged(lobby, lobbyPlayer, true);
+ }
+ }
+
+ private static ActModel? GetAct(string act1Key)
+ {
+ string? fullTypeName = act1Key switch
+ {
+ "overgrowth" => "MegaCrit.Sts2.Core.Models.Acts.Overgrowth",
+ "underdocks" => "MegaCrit.Sts2.Core.Models.Acts.Underdocks",
+ _ => null
+ };
+
+ if (fullTypeName == null || GenericActMethod == null)
+ return null;
+
+ Type? actType = typeof(ActModel).Assembly.GetType(fullTypeName);
+ if (actType == null)
+ return null;
+
+ return GenericActMethod.MakeGenericMethod(actType).Invoke(null, null) as ActModel;
+ }
+
+ private static List InvokeGetRandomActList(string seed, Rng rng, UnlockState unlockState, bool isMultiplayer)
+ {
+ if (GetRandomActListMethod == null)
+ throw new InvalidOperationException("ActModel.GetRandomList method was not found.");
+
+ ParameterInfo firstParameter = GetRandomActListMethod.GetParameters()[0];
+ object? result = firstParameter.ParameterType == typeof(string)
+ ? GetRandomActListMethod.Invoke(null, new object?[] { seed, unlockState, isMultiplayer })
+ : GetRandomActListMethod.Invoke(null, new object?[] { rng, unlockState, isMultiplayer });
+
+ return ((IEnumerable)result!).ToList();
+ }
+
+ private sealed class HostJoinPatchState
+ {
+ public required StartRunLobby Lobby { get; init; }
+ public required MessageHandlerDelegate OriginalJoinHandler { get; init; }
+ public required MessageHandlerDelegate ReplacementJoinHandler { get; init; }
+ }
+
+ private sealed partial class ExtendedLobbyNode : Node
+ {
+ private readonly HashSet _patchedStandardScreens = new();
+ private readonly HashSet _patchedCustomScreens = new();
+ private readonly HashSet _patchedDailyScreens = new();
+ private int _frameCounter;
+
+ public ExtendedLobbyNode()
+ {
+ Name = "ExtendedLobbyNode";
+ }
+
+ public override void _Process(double delta)
+ {
+ if (++_frameCounter % 5 != 0)
+ return;
+
+ StartRunLobby? lobby = SceneMonitor.FindActiveStartRunLobby();
+ if (lobby != null && lobby.NetService.Type == NetGameType.Host)
+ EnsureHostJoinPatch(lobby);
+
+ PatchStandardScreen(SceneRegistry.Instance?.CharacterSelectScreen);
+ PatchCustomScreen(SceneRegistry.Instance?.CustomRunScreen);
+ PatchDailyScreen(SceneRegistry.Instance?.DailyRunScreen);
+ }
+
+ private void PatchStandardScreen(NCharacterSelectScreen? screen)
+ {
+ if (screen == null)
+ return;
+
+ ulong id = screen.GetInstanceId();
+ if (_patchedStandardScreens.Contains(id))
+ return;
+
+ ReplaceReleasedHandler(screen.GetNode("ConfirmButton"), screen, "OnEmbarkPressed",
+ button => OnStandardEmbarkPressed(screen, button));
+ ReplaceReleasedHandler(screen.GetNode("UnreadyButton"), screen, "OnUnreadyPressed",
+ button => OnStandardUnreadyPressed(screen, button));
+ _patchedStandardScreens.Add(id);
+ }
+
+ private void PatchCustomScreen(NCustomRunScreen? screen)
+ {
+ if (screen == null)
+ return;
+
+ ulong id = screen.GetInstanceId();
+ if (_patchedCustomScreens.Contains(id))
+ return;
+
+ ReplaceReleasedHandler(screen.GetNode("ConfirmButton"), screen, "OnEmbarkPressed",
+ button => OnCustomEmbarkPressed(screen, button));
+ ReplaceReleasedHandler(screen.GetNode("UnreadyButton"), screen, "OnUnreadyPressed",
+ button => OnCustomUnreadyPressed(screen, button));
+ _patchedCustomScreens.Add(id);
+ }
+
+ private void PatchDailyScreen(MegaCrit.Sts2.Core.Nodes.Screens.DailyRun.NDailyRunScreen? screen)
+ {
+ if (screen == null)
+ return;
+
+ ulong id = screen.GetInstanceId();
+ if (_patchedDailyScreens.Contains(id))
+ return;
+
+ ReplaceReleasedHandler(screen.GetNode("%ConfirmButton"), screen, "OnEmbarkPressed",
+ button => OnDailyEmbarkPressed(screen, button));
+ ReplaceReleasedHandler(screen.GetNode("%UnreadyButton"), screen, "OnUnreadyPressed",
+ button => OnDailyUnreadyPressed(screen, button));
+ _patchedDailyScreens.Add(id);
+ }
+
+ private void EnsureHostJoinPatch(StartRunLobby lobby)
+ {
+ ulong lobbyId = lobby.GetHashCodeAsUlong();
+ if (HostJoinPatchStates.ContainsKey(lobbyId) || StartRunHandleJoinMethod == null)
+ return;
+
+ var originalHandler = (MessageHandlerDelegate)Delegate.CreateDelegate(
+ typeof(MessageHandlerDelegate),
+ lobby,
+ StartRunHandleJoinMethod);
+
+ MessageHandlerDelegate replacementHandler =
+ (message, senderId) => HandleExtendedJoinRequest(lobby, originalHandler, message, senderId);
+
+ lobby.NetService.UnregisterMessageHandler(originalHandler);
+ lobby.NetService.RegisterMessageHandler(replacementHandler);
+
+ HostJoinPatchStates[lobbyId] = new HostJoinPatchState
+ {
+ Lobby = lobby,
+ OriginalJoinHandler = originalHandler,
+ ReplacementJoinHandler = replacementHandler
+ };
+ }
+
+ private static void HandleExtendedJoinRequest(
+ StartRunLobby lobby,
+ MessageHandlerDelegate originalHandler,
+ ClientLobbyJoinRequestMessage message,
+ ulong senderId)
+ {
+ int prospectiveCount = lobby.Players.Count + 1;
+ if (!ShouldUseExtendedLobbyProtocol(lobby)
+ || prospectiveCount <= ProtocolConfig.OfficialSerializableSlotLimit)
+ {
+ originalHandler(message, senderId);
+ return;
+ }
+
+ if (lobby.NetService.Type != NetGameType.Host || lobby.NetService is not NetHostGameService hostGameService)
+ throw new InvalidOperationException("Extended join request received as non-host.");
+
+ if (lobby.Players.Count >= lobby.MaxPlayers)
+ {
+ hostGameService.DisconnectClient(senderId, NetError.LobbyFull);
+ return;
+ }
+
+ try
+ {
+ LobbyPlayer? joinedPlayer = (LobbyPlayer?)TryAddPlayerMethod?.Invoke(lobby, new object?[]
+ {
+ message.unlockState,
+ message.maxAscensionUnlocked,
+ senderId
+ });
+
+ if (!joinedPlayer.HasValue)
+ {
+ hostGameService.DisconnectClient(senderId, NetError.InternalError);
+ return;
+ }
+
+ UpdateMaxAscensionMethod?.Invoke(lobby, null);
+
+ ClientLobbyJoinResponseMessage responseMessage = new()
+ {
+ playersInLobby = BuildJoinResponsePlayers(lobby.Players, senderId),
+ ascension = lobby.Ascension,
+ dailyTime = lobby.DailyTime,
+ seed = lobby.Seed,
+ modifiers = lobby.Modifiers.Select(modifier => modifier.ToSerializable()).ToList()
+ };
+
+ hostGameService.SendMessage(responseMessage, senderId);
+ hostGameService.SetPeerReadyForBroadcasting(senderId);
+
+ if (joinedPlayer.Value.slotId < ProtocolConfig.OfficialSerializableSlotLimit)
+ {
+ PlayerJoinedMessage joinedMessage = new()
+ {
+ lobbyPlayer = joinedPlayer.Value
+ };
+ foreach (LobbyPlayer player in lobby.Players)
+ {
+ if (player.id != lobby.NetService.NetId && player.id != senderId)
+ lobby.NetService.SendMessage(joinedMessage, player.id);
+ }
+ }
+
+ RemoveConnectingPlayerMethod?.Invoke(lobby, new object?[] { senderId });
+ lobby.LobbyListener.PlayerConnected(joinedPlayer.Value);
+ RmpProtocol.BroadcastLobbySnapshot(lobby.Players);
+ }
+ catch (Exception ex)
+ {
+ hostGameService.DisconnectClient(senderId, NetError.InternalError);
+ Log.Error($"[RMP:ExtendedLobby] Failed to process extended join request: {ex}");
+ }
+ }
+
+ private static List BuildJoinResponsePlayers(IReadOnlyList players, ulong joiningPlayerId)
+ {
+ List responsePlayers = players
+ .Where(player => player.slotId < ProtocolConfig.OfficialSerializableSlotLimit)
+ .Take(ProtocolConfig.OfficialSerializableSlotLimit)
+ .ToList();
+
+ if (responsePlayers.Any(player => player.id == joiningPlayerId))
+ return responsePlayers;
+
+ LobbyPlayer joiningPlayer = players.First(player => player.id == joiningPlayerId);
+ if (joiningPlayer.slotId >= ProtocolConfig.OfficialSerializableSlotLimit)
+ joiningPlayer.slotId = Math.Max(0, responsePlayers.Count - 1);
+
+ if (responsePlayers.Count == ProtocolConfig.OfficialSerializableSlotLimit)
+ {
+ int replaceIndex = responsePlayers.FindLastIndex(player => player.id != players[0].id);
+ if (replaceIndex < 0)
+ replaceIndex = responsePlayers.Count - 1;
+ responsePlayers[replaceIndex] = joiningPlayer;
+ }
+ else
+ {
+ responsePlayers.Add(joiningPlayer);
+ }
+
+ return responsePlayers;
+ }
+
+ private static void ReplaceReleasedHandler(
+ NButton button,
+ object target,
+ string originalMethodName,
+ Action replacement)
+ {
+ Callable originalCallable = CreatePrivateReleasedCallable(target, originalMethodName);
+ if (button.IsConnected(NClickableControl.SignalName.Released, originalCallable))
+ button.Disconnect(NClickableControl.SignalName.Released, originalCallable);
+
+ button.Connect(NClickableControl.SignalName.Released, Callable.From(replacement));
+ }
+
+ private static Callable CreatePrivateReleasedCallable(object target, string methodName)
+ {
+ MethodInfo method = target.GetType().GetMethod(
+ methodName,
+ BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
+ ?? throw new MissingMethodException(target.GetType().FullName, methodName);
+
+ Action action = (Action)Delegate.CreateDelegate(typeof(Action), target, method);
+ return Callable.From(action);
+ }
+
+ private static void OnStandardEmbarkPressed(NCharacterSelectScreen screen, NButton button)
+ {
+ StartRunLobby lobby = screen.Lobby;
+ if (!ShouldUseExtendedLobbyProtocol(lobby))
+ {
+ InvokePrivateButtonMethod(screen, "OnEmbarkPressed", button);
+ return;
+ }
+
+ if (!SaveManager.Instance.SeenFtue("accept_tutorials_ftue"))
+ {
+ if (NModalContainer.Instance != null)
+ {
+ var ftue = NAcceptTutorialsFtue.Create(screen, delegate
+ {
+ OnStandardEmbarkPressed(screen, button);
+ });
+ if (ftue != null)
+ NModalContainer.Instance.Add(ftue);
+ }
+ return;
+ }
+
+ screen.GetNode("ConfirmButton").Disable();
+ screen.GetNode("BackButton").Disable();
+ lobby.Act1 = screen.GetNode("%ActDropdown").CurrentOption;
+ SetLocalReady(screen, lobby, ready: true);
+ foreach (var charButton in screen.GetNode("CharSelectButtons/ButtonContainer").GetChildren().OfType())
+ charButton.Disable();
+
+ if (!lobby.Players.All(player => player.isReady))
+ {
+ screen.GetNode("ReadyAndWaitingPanel").Visible = true;
+ screen.GetNode("UnreadyButton").Enable();
+ }
+ }
+
+ private static void OnStandardUnreadyPressed(NCharacterSelectScreen screen, NButton button)
+ {
+ StartRunLobby lobby = screen.Lobby;
+ if (!ShouldUseExtendedLobbyProtocol(lobby))
+ {
+ InvokePrivateButtonMethod(screen, "OnUnreadyPressed", button);
+ return;
+ }
+
+ screen.GetNode("ConfirmButton").Enable();
+ screen.GetNode("BackButton").Enable();
+ screen.GetNode("UnreadyButton").Disable();
+ screen.GetNode("ReadyAndWaitingPanel").Visible = false;
+ foreach (var charButton in screen.GetNode("CharSelectButtons/ButtonContainer").GetChildren().OfType())
+ charButton.Enable();
+ SetLocalReady(screen, lobby, ready: false);
+ }
+
+ private static void OnCustomEmbarkPressed(NCustomRunScreen screen, NButton button)
+ {
+ StartRunLobby lobby = screen.Lobby;
+ if (!ShouldUseExtendedLobbyProtocol(lobby))
+ {
+ InvokePrivateButtonMethod(screen, "OnEmbarkPressed", button);
+ return;
+ }
+
+ screen.GetNode("ConfirmButton").Disable();
+ screen.GetNode("BackButton").Disable();
+ foreach (var charButton in screen.GetNode("LeftContainer/CharSelectButtons/ButtonContainer").GetChildren().OfType())
+ charButton.Disable();
+ SetLocalReady(screen, lobby, ready: true);
+ if (!lobby.Players.All(player => player.isReady))
+ {
+ screen.GetNode("%ReadyAndWaitingPanel").Visible = true;
+ screen.GetNode("UnreadyButton").Enable();
+ }
+ }
+
+ private static void OnCustomUnreadyPressed(NCustomRunScreen screen, NButton button)
+ {
+ StartRunLobby lobby = screen.Lobby;
+ if (!ShouldUseExtendedLobbyProtocol(lobby))
+ {
+ InvokePrivateButtonMethod(screen, "OnUnreadyPressed", button);
+ return;
+ }
+
+ screen.GetNode("ConfirmButton").Enable();
+ screen.GetNode("BackButton").Enable();
+ screen.GetNode("UnreadyButton").Disable();
+ screen.GetNode("%ReadyAndWaitingPanel").Visible = false;
+ foreach (var charButton in screen.GetNode("LeftContainer/CharSelectButtons/ButtonContainer").GetChildren().OfType())
+ charButton.Enable();
+ SetLocalReady(screen, lobby, ready: false);
+ }
+
+ private static void OnDailyEmbarkPressed(MegaCrit.Sts2.Core.Nodes.Screens.DailyRun.NDailyRunScreen screen, NButton button)
+ {
+ StartRunLobby? lobby = DailyRunLobbyField?.GetValue(screen) as StartRunLobby;
+ if (lobby == null)
+ return;
+
+ if (!ShouldUseExtendedLobbyProtocol(lobby))
+ {
+ InvokePrivateButtonMethod(screen, "OnEmbarkPressed", button);
+ return;
+ }
+
+ screen.GetNode("%ConfirmButton").Disable();
+ screen.GetNode("%BackButton").Disable();
+ SetLocalReady(screen, lobby, ready: true);
+ if (!lobby.Players.All(player => player.isReady))
+ {
+ screen.GetNode("%ReadyAndWaitingPanel").Visible = true;
+ screen.GetNode("%UnreadyButton").Enable();
+ }
+ }
+
+ private static void OnDailyUnreadyPressed(MegaCrit.Sts2.Core.Nodes.Screens.DailyRun.NDailyRunScreen screen, NButton button)
+ {
+ StartRunLobby? lobby = DailyRunLobbyField?.GetValue(screen) as StartRunLobby;
+ if (lobby == null)
+ return;
+
+ if (!ShouldUseExtendedLobbyProtocol(lobby))
+ {
+ InvokePrivateButtonMethod(screen, "OnUnreadyPressed", button);
+ return;
+ }
+
+ screen.GetNode("%ConfirmButton").Enable();
+ screen.GetNode("%BackButton").Enable();
+ screen.GetNode("%UnreadyButton").Disable();
+ screen.GetNode("%ReadyAndWaitingPanel").Visible = false;
+ SetLocalReady(screen, lobby, ready: false);
+ }
+
+ private static void SetLocalReady(Node screen, StartRunLobby lobby, bool ready)
+ {
+ if (!TrySetPlayerReadyState(lobby, lobby.NetService.NetId, ready, out LobbyPlayer updatedPlayer))
+ return;
+
+ NotifyPlayerChanged(lobby, updatedPlayer, false);
+
+ if (lobby.NetService.Type == NetGameType.Host)
+ {
+ RmpProtocol.BroadcastExtendedReady(ready);
+ TryBeginExtendedRun(lobby);
+ }
+ else
+ {
+ lobby.NetService.SendMessage(new RmpExtendedReadyStateMessage
+ {
+ Ready = ready
+ });
+ }
+ }
+
+ private static void InvokePrivateButtonMethod(object target, string methodName, NButton button)
+ {
+ MethodInfo method = target.GetType().GetMethod(
+ methodName,
+ BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
+ ?? throw new MissingMethodException(target.GetType().FullName, methodName);
+ method.Invoke(target, new object?[] { button });
+ }
+ }
+}
+
+internal static class ExtendedLobbyHashCodeExtensions
+{
+ public static ulong GetHashCodeAsUlong(this object value)
+ {
+ return unchecked((ulong)value.GetHashCode());
+ }
+}
diff --git a/src/Network/HostBootstrapModule.cs b/src/Network/HostBootstrapModule.cs
new file mode 100644
index 0000000..971bc71
--- /dev/null
+++ b/src/Network/HostBootstrapModule.cs
@@ -0,0 +1,462 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Threading.Tasks;
+using Godot;
+using MegaCrit.Sts2.Core.Commands;
+using MegaCrit.Sts2.Core.Entities.Multiplayer;
+using MegaCrit.Sts2.Core.Helpers;
+using MegaCrit.Sts2.Core.Localization;
+using MegaCrit.Sts2.Core.Logging;
+using MegaCrit.Sts2.Core.Multiplayer;
+using MegaCrit.Sts2.Core.Multiplayer.Game;
+using MegaCrit.Sts2.Core.Nodes.CommonUi;
+using MegaCrit.Sts2.Core.Nodes.GodotExtensions;
+using MegaCrit.Sts2.Core.Nodes.Screens.CharacterSelect;
+using MegaCrit.Sts2.Core.Nodes.Screens.CustomRun;
+using MegaCrit.Sts2.Core.Nodes.Screens.DailyRun;
+using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu;
+using MegaCrit.Sts2.Core.Platform;
+using MegaCrit.Sts2.Core.Platform.Steam;
+using MegaCrit.Sts2.Core.Runs;
+using MegaCrit.Sts2.Core.Saves;
+using MegaCrit.Sts2.Core.Saves.Runs;
+using RemoveMultiplayerPlayerLimit.Core;
+using RemoveMultiplayerPlayerLimit.Infrastructure;
+
+namespace RemoveMultiplayerPlayerLimit.Network;
+
+///
+/// Pre-host bootstrapper for multiplayer menu flows.
+/// Replaces the vanilla 4-player host setup for both new runs and loaded runs.
+///
+public partial class HostBootstrapModule : IRMPModule
+{
+ private const ushort DefaultEnetPort = 33771;
+
+ private static readonly Dictionary TrackedHostCapacities = new();
+ private static readonly PropertyInfo? SerializableRunGameModeProperty =
+ typeof(SerializableRun).GetProperty("GameMode", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+ private static readonly FieldInfo? SerializableRunGameModeField =
+ typeof(SerializableRun).GetField("GameMode", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
+ ?? typeof(SerializableRun).GetField("gameMode", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+
+ private bool _deferToDcip;
+
+ public string Name => "HostBootstrap";
+
+ public void Initialize(ConfigManager config, ReflectionCache cache)
+ {
+ if (IsDirectConnectIpLoaded())
+ {
+ _deferToDcip = true;
+ Log.Warn(
+ "[RMP:HostBootstrap] DirectConnectIP detected — RMP is yielding multiplayer host bootstrap " +
+ "to DCIP to avoid transport protocol conflicts (DCIP replaces NetHostGameService with its " +
+ "own DirectHost/DirectClient). While DCIP is loaded, RMP's 4-16 player slider will NOT " +
+ "affect host capacity: Steam mode stays at vanilla 4, Direct-IP mode is capped at DCIP's " +
+ "hardcoded 16. Remove one of the two mods to regain full control.");
+ }
+ }
+
+ public Node? CreateNode() => _deferToDcip ? null : new HostBootstrapNode();
+
+ private static bool IsDirectConnectIpLoaded()
+ {
+ try
+ {
+ foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ if (string.Equals(asm.GetName().Name, "DirectConnectIP", StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+ }
+ catch
+ {
+ // ignored — fall through to false
+ }
+ return false;
+ }
+
+ public void Cleanup()
+ {
+ TrackedHostCapacities.Clear();
+ }
+
+ internal static bool TryGetTrackedHostCapacity(INetGameService? netService, out int capacity)
+ {
+ if (netService != null && TrackedHostCapacities.TryGetValue(netService, out capacity))
+ return true;
+
+ capacity = default;
+ return false;
+ }
+
+ internal static string GetTransportName(INetGameService netService)
+ {
+ return netService.Platform == PlatformType.Steam ? "Steam" : "ENet";
+ }
+
+ private partial class HostBootstrapNode : Node
+ {
+ private readonly HashSet _patchedMainMenuSubmenus = new();
+ private readonly HashSet _patchedHostSubmenus = new();
+ private int _frameCounter;
+
+ public HostBootstrapNode()
+ {
+ Name = "HostBootstrapNode";
+ }
+
+ public override void _Process(double delta)
+ {
+ if (++_frameCounter % 2 != 0)
+ return;
+
+ PatchMainMenuSubmenu(SceneMonitor.FindMultiplayerSubmenu());
+ PatchHostSubmenu(SceneMonitor.FindMultiplayerHostSubmenu());
+ }
+
+ private void PatchMainMenuSubmenu(NMultiplayerSubmenu? submenu)
+ {
+ if (submenu == null)
+ return;
+
+ ulong submenuId = submenu.GetInstanceId();
+ if (_patchedMainMenuSubmenus.Contains(submenuId))
+ return;
+
+ NButton? hostButton = submenu.GetNodeOrNull("ButtonContainer/HostButton");
+ NButton? loadButton = submenu.GetNodeOrNull("ButtonContainer/LoadButton");
+ if (hostButton == null || loadButton == null)
+ return;
+
+ if (!ReplaceReleasedHandler(hostButton, submenu, "OnHostPressed", _ => OnMainMenuHostPressed(submenu)))
+ return;
+
+ if (!ReplaceReleasedHandler(loadButton, submenu, "StartLoad", _ => OnMainMenuLoadPressed(submenu)))
+ return;
+
+ _patchedMainMenuSubmenus.Add(submenuId);
+ Log.Info("[RMP:HostBootstrap] Patched NMultiplayerSubmenu host/load handlers.");
+ }
+
+ private void PatchHostSubmenu(NMultiplayerHostSubmenu? submenu)
+ {
+ if (submenu == null)
+ return;
+
+ ulong submenuId = submenu.GetInstanceId();
+ if (_patchedHostSubmenus.Contains(submenuId))
+ return;
+
+ NButton? standardButton = submenu.GetNodeOrNull("StandardButton");
+ NButton? dailyButton = submenu.GetNodeOrNull("DailyButton");
+ NButton? customButton = submenu.GetNodeOrNull("CustomRunButton");
+ if (standardButton == null || dailyButton == null || customButton == null)
+ return;
+
+ if (!ReplaceReleasedHandler(standardButton, submenu, "OnStandardPressed",
+ _ => OnHostSubmenuPressed(submenu, GameMode.Standard)))
+ {
+ return;
+ }
+
+ if (!ReplaceReleasedHandler(dailyButton, submenu, "OnDailyPressed",
+ _ => OnHostSubmenuPressed(submenu, GameMode.Daily)))
+ {
+ return;
+ }
+
+ if (!ReplaceReleasedHandler(customButton, submenu, "OnCustomPressed",
+ _ => OnHostSubmenuPressed(submenu, GameMode.Custom)))
+ {
+ return;
+ }
+
+ _patchedHostSubmenus.Add(submenuId);
+ Log.Info("[RMP:HostBootstrap] Patched NMultiplayerHostSubmenu handlers.");
+ }
+
+ private void OnMainMenuHostPressed(NMultiplayerSubmenu submenu)
+ {
+ NSubmenuStack? stack = submenu.GetAncestorOfType();
+ if (stack == null)
+ {
+ Log.Warn("[RMP:HostBootstrap] Failed to locate submenu stack for multiplayer submenu.");
+ return;
+ }
+
+ if (SaveManager.Instance.Progress.NumberOfRuns > 0)
+ {
+ NMultiplayerHostSubmenu hostSubmenu = stack.GetSubmenuType();
+ PatchHostSubmenu(hostSubmenu);
+ stack.Push(hostSubmenu);
+ return;
+ }
+
+ Control? loadingOverlay = submenu.GetNodeOrNull("%LoadingOverlay");
+ if (loadingOverlay == null)
+ {
+ Log.Warn("[RMP:HostBootstrap] Multiplayer submenu loading overlay not found.");
+ return;
+ }
+
+ TaskHelper.RunSafely(StartNewRunHostAsync(
+ GameMode.Standard,
+ loadingOverlay,
+ stack,
+ ProtocolConfig.TargetPlayerLimit));
+ }
+
+ private void OnMainMenuLoadPressed(NMultiplayerSubmenu submenu)
+ {
+ NSubmenuStack? stack = submenu.GetAncestorOfType();
+ Control? loadingOverlay = submenu.GetNodeOrNull("%LoadingOverlay");
+ NButton? loadButton = submenu.GetNodeOrNull("ButtonContainer/LoadButton");
+ if (stack == null || loadingOverlay == null)
+ {
+ Log.Warn("[RMP:HostBootstrap] Failed to resolve load-run host prerequisites.");
+ return;
+ }
+
+ PlatformType platformType = GetPreferredHostPlatform();
+ ReadSaveResult saveResult =
+ SaveManager.Instance.LoadAndCanonicalizeMultiplayerRunSave(
+ PlatformUtil.GetLocalPlayerId(platformType));
+
+ if (!saveResult.Success || saveResult.SaveData == null)
+ {
+ Log.Warn("[RMP:HostBootstrap] Invalid multiplayer run save detected.");
+ loadButton?.Disable();
+ NErrorPopup? modalToCreate = NErrorPopup.Create(
+ new LocString("main_menu_ui", "INVALID_SAVE_POPUP.title"),
+ new LocString("main_menu_ui", "INVALID_SAVE_POPUP.description_run"),
+ new LocString("main_menu_ui", "INVALID_SAVE_POPUP.dismiss"),
+ showReportBugButton: true);
+ if (modalToCreate != null && NModalContainer.Instance != null)
+ {
+ NModalContainer.Instance.Add(modalToCreate);
+ NModalContainer.Instance.ShowBackstop();
+ }
+ return;
+ }
+
+ // v0.1.7: always host at the fixed 16 cap; saved runs will simply fill a subset of the slots.
+ TaskHelper.RunSafely(StartLoadedRunHostAsync(saveResult.SaveData, loadingOverlay, stack, ProtocolConfig.TargetPlayerLimit));
+ }
+
+ private void OnHostSubmenuPressed(NMultiplayerHostSubmenu submenu, GameMode gameMode)
+ {
+ NSubmenuStack? stack = submenu.GetAncestorOfType();
+ Control? loadingOverlay = submenu.GetNodeOrNull("%LoadingOverlay");
+ if (stack == null || loadingOverlay == null)
+ {
+ Log.Warn("[RMP:HostBootstrap] Failed to resolve multiplayer host submenu prerequisites.");
+ return;
+ }
+
+ TaskHelper.RunSafely(StartNewRunHostAsync(
+ gameMode,
+ loadingOverlay,
+ stack,
+ ProtocolConfig.TargetPlayerLimit));
+ }
+
+ private static bool ReplaceReleasedHandler(
+ NButton button,
+ object signalTarget,
+ string originalMethodName,
+ Action replacement)
+ {
+ try
+ {
+ Callable originalCallable = CreatePrivateReleasedCallable(signalTarget, originalMethodName);
+ if (button.IsConnected(NClickableControl.SignalName.Released, originalCallable))
+ button.Disconnect(NClickableControl.SignalName.Released, originalCallable);
+
+ button.Connect(NClickableControl.SignalName.Released, Callable.From(replacement));
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP:HostBootstrap] Failed to rewire {signalTarget.GetType().Name}.{originalMethodName}: {ex.Message}");
+ return false;
+ }
+ }
+
+ private static Callable CreatePrivateReleasedCallable(object target, string methodName)
+ {
+ MethodInfo? method = target.GetType().GetMethod(
+ methodName,
+ BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
+ if (method == null)
+ throw new MissingMethodException(target.GetType().FullName, methodName);
+
+ Action action =
+ (Action)Delegate.CreateDelegate(typeof(Action), target, method);
+ return Callable.From(action);
+ }
+
+ private static async Task StartNewRunHostAsync(
+ GameMode gameMode,
+ Control loadingOverlay,
+ NSubmenuStack stack,
+ int hostCapacity)
+ {
+ loadingOverlay.Visible = true;
+ try
+ {
+ NetHostGameService netService = new NetHostGameService();
+ NetErrorInfo? netError = await StartHostAsync(netService, hostCapacity);
+ if (netError.HasValue)
+ {
+ ShowNetError(netError.Value);
+ return;
+ }
+
+ TrackHostCapacity(netService, hostCapacity);
+
+ switch (gameMode)
+ {
+ case GameMode.Standard:
+ {
+ NCharacterSelectScreen screen = stack.GetSubmenuType();
+ screen.InitializeMultiplayerAsHost(netService, hostCapacity);
+ stack.Push(screen);
+ break;
+ }
+ case GameMode.Daily:
+ {
+ NDailyRunScreen screen = stack.GetSubmenuType();
+ screen.InitializeMultiplayerAsHost(netService);
+ stack.Push(screen);
+ break;
+ }
+ default:
+ {
+ NCustomRunScreen screen = stack.GetSubmenuType();
+ screen.InitializeMultiplayerAsHost(netService, hostCapacity);
+ stack.Push(screen);
+ break;
+ }
+ }
+
+ Log.Info(
+ $"[RMP:HostBootstrap] Hosted {gameMode} lobby via {GetTransportName(netService)} with capacity {hostCapacity}.");
+ }
+ catch (Exception ex)
+ {
+ ShowNetError(new NetErrorInfo(NetError.InternalError, selfInitiated: false));
+ Log.Warn($"[RMP:HostBootstrap] New-run host startup failed: {ex}");
+ throw;
+ }
+ finally
+ {
+ loadingOverlay.Visible = false;
+ }
+ }
+
+ private static async Task StartLoadedRunHostAsync(
+ SerializableRun run,
+ Control loadingOverlay,
+ NSubmenuStack stack,
+ int hostCapacity)
+ {
+ loadingOverlay.Visible = true;
+ try
+ {
+ NetHostGameService netService = new NetHostGameService();
+ NetErrorInfo? netError = await StartHostAsync(netService, hostCapacity);
+ if (netError.HasValue)
+ {
+ ShowNetError(netError.Value);
+ return;
+ }
+
+ TrackHostCapacity(netService, hostCapacity);
+
+ GameMode gameMode = ResolveGameMode(run);
+ switch (gameMode)
+ {
+ case GameMode.Daily:
+ {
+ NDailyRunLoadScreen screen = stack.GetSubmenuType();
+ screen.InitializeAsHost(netService, run);
+ stack.Push(screen);
+ break;
+ }
+ case GameMode.Custom:
+ {
+ NCustomRunLoadScreen screen = stack.GetSubmenuType();
+ screen.InitializeAsHost(netService, run);
+ stack.Push(screen);
+ break;
+ }
+ default:
+ {
+ NMultiplayerLoadGameScreen screen = stack.GetSubmenuType();
+ screen.InitializeAsHost(netService, run);
+ stack.Push(screen);
+ break;
+ }
+ }
+
+ Log.Info(
+ $"[RMP:HostBootstrap] Hosted loaded {gameMode} lobby via {GetTransportName(netService)} " +
+ $"with capacity {hostCapacity}, savePlayers={run.Players.Count}, connectedPlayers=1.");
+ }
+ catch (Exception ex)
+ {
+ ShowNetError(new NetErrorInfo(NetError.InternalError, selfInitiated: false));
+ Log.Warn($"[RMP:HostBootstrap] Loaded-run host startup failed: {ex}");
+ throw;
+ }
+ finally
+ {
+ loadingOverlay.Visible = false;
+ }
+ }
+
+ private static async Task StartHostAsync(NetHostGameService netService, int hostCapacity)
+ {
+ if (GetPreferredHostPlatform() == PlatformType.Steam)
+ return await netService.StartSteamHost(hostCapacity);
+
+ return netService.StartENetHost(DefaultEnetPort, hostCapacity);
+ }
+
+ private static PlatformType GetPreferredHostPlatform()
+ {
+ return SteamInitializer.Initialized && !CommandLineHelper.HasArg("fastmp")
+ ? PlatformType.Steam
+ : PlatformType.None;
+ }
+
+ private static void TrackHostCapacity(INetGameService netService, int hostCapacity)
+ {
+ TrackedHostCapacities[netService] = hostCapacity;
+ }
+
+ private static void ShowNetError(NetErrorInfo error)
+ {
+ NErrorPopup? popup = NErrorPopup.Create(error);
+ if (popup != null && NModalContainer.Instance != null)
+ NModalContainer.Instance.Add(popup);
+ }
+
+ private static GameMode ResolveGameMode(SerializableRun run)
+ {
+ if (SerializableRunGameModeProperty?.GetValue(run) is GameMode propertyValue)
+ return propertyValue;
+
+ if (SerializableRunGameModeField?.GetValue(run) is GameMode fieldValue)
+ return fieldValue;
+
+ if (run.DailyTime.HasValue)
+ return GameMode.Daily;
+
+ return run.Modifiers.Count > 0 ? GameMode.Custom : GameMode.Standard;
+ }
+ }
+}
diff --git a/src/Network/LobbyManagerModule.cs b/src/Network/LobbyManagerModule.cs
new file mode 100644
index 0000000..abdc7b5
--- /dev/null
+++ b/src/Network/LobbyManagerModule.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Reflection;
+using Godot;
+using MegaCrit.Sts2.Core.Logging;
+using MegaCrit.Sts2.Core.Multiplayer.Game;
+using MegaCrit.Sts2.Core.Multiplayer.Game.Lobby;
+using RemoveMultiplayerPlayerLimit.Core;
+using RemoveMultiplayerPlayerLimit.Infrastructure;
+
+namespace RemoveMultiplayerPlayerLimit.Network;
+
+///
+/// Central coordinator for multiplayer lobbies.
+/// Keeps StartRunLobby synchronized, binds the RMP protocol, and emits
+/// one-time diagnostics when a loaded-run lobby becomes active.
+///
+public partial class LobbyManagerModule : IRMPModule
+{
+ public string Name => "LobbyManager";
+
+ private FieldInfo? _maxPlayersField;
+
+ public void Initialize(ConfigManager config, ReflectionCache cache)
+ {
+ _maxPlayersField = cache.GetField(typeof(StartRunLobby), "k__BackingField");
+ }
+
+ public Node? CreateNode() => new LobbyManagerNode(this);
+
+ public void Cleanup()
+ {
+ RmpProtocol.Unbind();
+ }
+
+ private partial class LobbyManagerNode : Node
+ {
+ private readonly LobbyManagerModule _module;
+ private int _frameCounter;
+ private StartRunLobby? _lastLobby;
+ private LoadRunLobby? _lastLoggedLoadLobby;
+ private int _lastPlayerCount = -1;
+ private int _lastTargetPlayerLimit = -1;
+
+ public LobbyManagerNode(LobbyManagerModule module)
+ {
+ _module = module;
+ Name = "LobbyManagerNode";
+ }
+
+ public override void _Process(double delta)
+ {
+ if (++_frameCounter % 15 != 0)
+ return;
+
+ HandleStartRunLobby(SceneMonitor.FindActiveStartRunLobby());
+ HandleLoadedRunLobby(SceneMonitor.FindActiveLoadRunLobby());
+ }
+
+ private void HandleStartRunLobby(StartRunLobby? lobby)
+ {
+ if (!ReferenceEquals(lobby, _lastLobby))
+ {
+ if (_lastLobby != null && lobby == null)
+ {
+ // IMPORTANT: Do NOT call StartRunLobby.CleanUp(false) here.
+ //
+ // Vanilla's NCharacterSelectScreen.StartNewMultiplayerRun runs:
+ // SetUpNewMultiPlayer(runState, startRunLobby) // creates RunLobby
+ // await StartRun(runState)
+ // ├── SetCurrentScene(NRun.Create(runState)) // NCharacterSelectScreen
+ // │ // leaves scene tree
+ // │ // ← we detect lobby == null HERE
+ // └── await EnterAct(0) // still running!
+ // └── await Hook.BeforeRoomEntered(...) // hangs if we've already
+ // // unsubscribed StartRunLobby's
+ // // NetService message handlers
+ // CleanUpLobby(disconnectSession: false) // vanilla CleanUp
+ //
+ // A previous iteration called StartRunLobby.CleanUp(false) in this transition
+ // to swallow a post-disconnect ObjectDisposedException, but that tore down
+ // message handlers the vanilla async chain in EnterAct still relied on,
+ // black-screening the host with any player count. We accept the cosmetic
+ // ObjectDisposedException instead — it's logged but non-fatal on disconnect
+ // — and let vanilla do its own CleanUp in the proper order.
+ RmpProtocol.Unbind();
+ }
+
+ _lastLobby = lobby;
+ _lastPlayerCount = -1;
+ _lastTargetPlayerLimit = -1;
+
+ if (lobby != null)
+ OnLobbyActivated(lobby);
+ }
+
+ if (lobby == null)
+ return;
+
+ int currentCount = GetLobbyPlayerCount(lobby);
+ int targetLimit = ProtocolConfig.TargetPlayerLimit;
+ if (currentCount != _lastPlayerCount || targetLimit != _lastTargetPlayerLimit)
+ {
+ SyncLobbyState(lobby, targetLimit);
+ _lastPlayerCount = currentCount;
+ _lastTargetPlayerLimit = targetLimit;
+ }
+ }
+
+ private void OnLobbyActivated(StartRunLobby lobby)
+ {
+ if (lobby.NetService?.Type is NetGameType.Host or NetGameType.Client)
+ RmpProtocol.Bind(lobby.NetService);
+
+ SyncLobbyState(lobby, ProtocolConfig.TargetPlayerLimit);
+ }
+
+ private void SyncLobbyState(StartRunLobby lobby, int targetLimit)
+ {
+ if (_module._maxPlayersField != null && lobby.MaxPlayers != targetLimit)
+ {
+ _module._maxPlayersField.SetValue(lobby, targetLimit);
+ Log.Info($"[RMP] StartRunLobby.MaxPlayers synchronized to {targetLimit}");
+ }
+
+ if (lobby.NetService?.Type != NetGameType.Host)
+ return;
+
+ int steamLimit = SteamLobbyHelper.GetCurrentMemberLimit(lobby.NetService);
+ if (steamLimit != -1 && steamLimit != targetLimit)
+ SteamLobbyHelper.TryUpdateMemberLimit(lobby.NetService, targetLimit);
+ else if (steamLimit == -1)
+ SteamLobbyHelper.TryUpdateMemberLimit(lobby.NetService, targetLimit);
+
+ RmpProtocol.BroadcastConfig(targetLimit);
+ if (ExtendedLobbyModule.ShouldUseExtendedLobbyProtocol(lobby))
+ RmpProtocol.BroadcastLobbySnapshot(lobby.Players);
+ }
+
+ private void HandleLoadedRunLobby(LoadRunLobby? loadRunLobby)
+ {
+ if (loadRunLobby == null)
+ {
+ _lastLoggedLoadLobby = null;
+ return;
+ }
+
+ if (ReferenceEquals(loadRunLobby, _lastLoggedLoadLobby))
+ return;
+
+ _lastLoggedLoadLobby = loadRunLobby;
+
+ int savePlayerCount = loadRunLobby.Run.Players.Count;
+ int connectedPlayerCount = loadRunLobby.ConnectedPlayerIds.Count;
+ int hostCapacity = HostBootstrapModule.TryGetTrackedHostCapacity(loadRunLobby.NetService, out int trackedCapacity)
+ ? trackedCapacity
+ : savePlayerCount;
+ string screenName = SceneMonitor.GetActiveLoadLobbyScreenName() ?? "UnknownLoadLobbyScreen";
+
+ Log.Info(
+ $"[RMP] Loaded-run lobby active: screen={screenName}, transport={HostBootstrapModule.GetTransportName(loadRunLobby.NetService)}, " +
+ $"hostCapacity={hostCapacity}, savePlayers={savePlayerCount}, connectedPlayers={connectedPlayerCount}");
+ }
+
+ private static int GetLobbyPlayerCount(StartRunLobby lobby)
+ {
+ try
+ {
+ return lobby.Players?.Count ?? 0;
+ }
+ catch
+ {
+ return 0;
+ }
+ }
+
+ }
+}
diff --git a/src/Network/LobbyPatches.cs b/src/Network/LobbyPatches.cs
deleted file mode 100644
index f4b34c2..0000000
--- a/src/Network/LobbyPatches.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-using System;
-using System.Reflection;
-using HarmonyLib;
-using MegaCrit.Sts2.Core.Multiplayer;
-using MegaCrit.Sts2.Core.Multiplayer.Game;
-using MegaCrit.Sts2.Core.Multiplayer.Game.Lobby;
-using MegaCrit.Sts2.Core.Runs;
-
-namespace RemoveMultiplayerPlayerLimit.Network;
-
-// ╔══════════════════════════════════════════════════════════════════════════╗
-// ║ 大厅容量补丁 ║
-// ║ ║
-// ║ 修改 Host 创建的 ENet/Steam 服务器容量上限, ║
-// ║ 并在玩家连接时动态同步 StartRunLobby.MaxPlayers, ║
-// ║ 同时通过模组协议通道广播配置给所有客户端。 ║
-// ╚══════════════════════════════════════════════════════════════════════════╝
-
-[HarmonyPatch(typeof(NetHostGameService), nameof(NetHostGameService.StartENetHost))]
-internal static class StartENetHostPatch
-{
- private static void Prefix(ref int maxClients)
- => maxClients = Math.Max(maxClients, ProtocolConfig.TargetPlayerLimit);
-}
-
-[HarmonyPatch(typeof(NetHostGameService), nameof(NetHostGameService.StartSteamHost))]
-internal static class StartSteamHostPatch
-{
- private static void Prefix(ref int maxClients)
- => maxClients = Math.Max(maxClients, ProtocolConfig.TargetPlayerLimit);
-}
-
-[HarmonyPatch(typeof(StartRunLobby), MethodType.Constructor,
- typeof(GameMode), typeof(INetGameService), typeof(IStartRunLobbyListener), typeof(int))]
-internal static class StartRunLobbyConstructorPatch
-{
- private static void Postfix(StartRunLobby __instance, INetGameService netService)
- {
- if (netService.Type == NetGameType.Host
- && __instance.MaxPlayers < ProtocolConfig.TargetPlayerLimit
- && LobbySync.MaxPlayersField != null)
- {
- LobbySync.MaxPlayersField.SetValue(__instance, ProtocolConfig.TargetPlayerLimit);
- }
- // 绑定模组协议通道到当前多人会话
- if (netService.Type is NetGameType.Host or NetGameType.Client)
- {
- RmpProtocol.Bind(netService);
- }
- }
-}
-
-// ── 动态同步 MaxPlayers:当玩家尝试加入时,确保 MaxPlayers 与当前设置一致 ──
-
-[HarmonyPatch(typeof(StartRunLobby), "OnConnectedToClientAsHost")]
-internal static class OnConnectedToClientAsHostPatch
-{
- private static void Prefix(StartRunLobby __instance) => LobbySync.SyncLobbyMaxPlayers(__instance);
-}
-
-[HarmonyPatch(typeof(StartRunLobby), "HandleClientLobbyJoinRequestMessage")]
-internal static class HandleClientLobbyJoinRequestMessagePatch
-{
- private static void Prefix(StartRunLobby __instance) => LobbySync.SyncLobbyMaxPlayers(__instance);
-}
-
-///
-/// MaxPlayers 同步逻辑 — 对外提供 和 。
-///
-internal static class LobbySync
-{
- internal static readonly FieldInfo? MaxPlayersField =
- AccessTools.Field(typeof(StartRunLobby), "k__BackingField");
-
- internal static void SyncLobbyMaxPlayers(StartRunLobby lobby)
- {
- if (MaxPlayersField == null || lobby.NetService.Type != NetGameType.Host)
- {
- return;
- }
- if (lobby.MaxPlayers != ProtocolConfig.TargetPlayerLimit)
- {
- MaxPlayersField.SetValue(lobby, ProtocolConfig.TargetPlayerLimit);
- SteamLobbyHelper.TryUpdateMemberLimit(lobby.NetService, ProtocolConfig.TargetPlayerLimit);
- }
- // 通过模组协议通道广播配置给所有客户端
- RmpProtocol.BroadcastConfig(ProtocolConfig.TargetPlayerLimit);
- }
-}
diff --git a/src/Network/ProtocolConfig.cs b/src/Network/ProtocolConfig.cs
deleted file mode 100644
index d26e870..0000000
--- a/src/Network/ProtocolConfig.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using System;
-
-namespace RemoveMultiplayerPlayerLimit.Network;
-
-///
-/// 模组协议配置中心 — 所有协议相关常量与运行时配置的唯一权威来源。
-///
-/// 分层:
-/// Vanilla* — 官方协议的原始值(不可更改)
-/// Extended* — 模组扩展的协议值(编译期固定)
-/// Target* — 运行时用户配置(可通过设置面板修改)
-///
-internal static class ProtocolConfig
-{
- // ── 玩家人数限制 ───────────────────────────────────────────────────────
-
- internal const int DefaultPlayerLimit = 8;
-
- internal const int MinPlayerLimit = 4;
-
- internal const int MaxPlayerLimit = 16;
-
- // ── 官方协议位宽(用于 Transpiler 匹配源值) ──────────────────────────
-
- internal const int VanillaSlotIdBits = 2;
-
- internal const int VanillaLobbyListLengthBits = 3;
-
- // ── 扩展协议位宽(Transpiler 替换目标值) ─────────────────────────────
-
- /// SlotId 4 bits → 支持 0-15 号槽位。
- internal const int SlotIdBits = 4;
-
- /// LobbyList 长度 5 bits → 支持最多 31 人列表。
- internal const int LobbyListLengthBits = 5;
-
- // ── 运行时配置(可由设置面板 / 配置文件修改) ──────────────────────────
-
- internal static int TargetPlayerLimit { get; set; } = DefaultPlayerLimit;
-
- /// clamped 赋值,确保值始终在 [Min, Max] 范围内。
- internal static void SetTargetPlayerLimit(int value)
- {
- TargetPlayerLimit = Math.Clamp(value, MinPlayerLimit, MaxPlayerLimit);
- }
-
- // ── 难度缩放 ─────────────────────────────────────────────────────────
-
- ///
- /// 是否启用超过 4 人后的怪物难度继续缩放。
- /// true = 怪物 HP / 格挡 / 能力数值按实际玩家数缩放(官方公式延伸)
- /// false = 钳制到 4 人难度(与原版一致)
- ///
- internal static bool DifficultyScalingEnabled { get; private set; } = true;
-
- internal static void SetDifficultyScalingEnabled(bool value)
- {
- DifficultyScalingEnabled = value;
- }
-}
diff --git a/src/Network/RmpExtendedLobbyMessages.cs b/src/Network/RmpExtendedLobbyMessages.cs
new file mode 100644
index 0000000..7da8b0c
--- /dev/null
+++ b/src/Network/RmpExtendedLobbyMessages.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Collections.Generic;
+using MegaCrit.Sts2.Core.Entities.Multiplayer;
+using MegaCrit.Sts2.Core.Logging;
+using MegaCrit.Sts2.Core.Models;
+using MegaCrit.Sts2.Core.Multiplayer.Serialization;
+using MegaCrit.Sts2.Core.Multiplayer.Transport;
+using MegaCrit.Sts2.Core.Saves.Runs;
+using MegaCrit.Sts2.Core.Unlocks;
+
+namespace RemoveMultiplayerPlayerLimit.Network;
+
+public struct RmpLobbyPlayerState : IPacketSerializable
+{
+ public ulong id;
+ public int slotId;
+ public CharacterModel character;
+ public SerializableUnlockState unlockState;
+ public int maxMultiplayerAscensionUnlocked;
+ public bool isReady;
+
+ public readonly LobbyPlayer ToLobbyPlayer()
+ {
+ return new LobbyPlayer
+ {
+ id = id,
+ slotId = slotId,
+ character = character,
+ unlockState = unlockState,
+ maxMultiplayerAscensionUnlocked = maxMultiplayerAscensionUnlocked,
+ isReady = isReady
+ };
+ }
+
+ public static RmpLobbyPlayerState FromLobbyPlayer(LobbyPlayer lobbyPlayer)
+ {
+ return new RmpLobbyPlayerState
+ {
+ id = lobbyPlayer.id,
+ slotId = lobbyPlayer.slotId,
+ character = lobbyPlayer.character,
+ unlockState = lobbyPlayer.unlockState,
+ maxMultiplayerAscensionUnlocked = lobbyPlayer.maxMultiplayerAscensionUnlocked,
+ isReady = lobbyPlayer.isReady
+ };
+ }
+
+ public readonly void Serialize(PacketWriter writer)
+ {
+ writer.WriteULong(id);
+ writer.WriteInt(slotId, 4);
+ writer.WriteModel(character);
+ writer.Write(unlockState);
+ writer.WriteInt(maxMultiplayerAscensionUnlocked);
+ writer.WriteBool(isReady);
+ }
+
+ public void Deserialize(PacketReader reader)
+ {
+ id = reader.ReadULong();
+ slotId = reader.ReadInt(4);
+ character = reader.ReadModel();
+ unlockState = reader.Read();
+ maxMultiplayerAscensionUnlocked = reader.ReadInt();
+ isReady = reader.ReadBool();
+ }
+}
+
+public struct RmpLobbySnapshotMessage : INetMessage, IPacketSerializable
+{
+ public List? players;
+
+ public readonly bool ShouldBroadcast => false;
+ public readonly bool ShouldBuffer => false;
+ public readonly NetTransferMode Mode => NetTransferMode.Reliable;
+ public readonly LogLevel LogLevel => LogLevel.Info;
+
+ public readonly void Serialize(PacketWriter writer)
+ {
+ if (players == null)
+ throw new InvalidOperationException("players must not be null");
+
+ writer.WriteList(players, RemoveMultiplayerPlayerLimit.Core.ProtocolConfig.LobbyListLengthBits);
+ }
+
+ public void Deserialize(PacketReader reader)
+ {
+ players = reader.ReadList(RemoveMultiplayerPlayerLimit.Core.ProtocolConfig.LobbyListLengthBits);
+ }
+
+ public override readonly string ToString() => $"RmpLobbySnapshot(players={players?.Count ?? 0})";
+}
+
+public struct RmpExtendedReadyStateMessage : INetMessage, IPacketSerializable
+{
+ public bool Ready;
+
+ public readonly bool ShouldBroadcast => true;
+ public readonly bool ShouldBuffer => false;
+ public readonly NetTransferMode Mode => NetTransferMode.Reliable;
+ public readonly LogLevel LogLevel => LogLevel.Debug;
+
+ public readonly void Serialize(PacketWriter writer)
+ {
+ writer.WriteBool(Ready);
+ }
+
+ public void Deserialize(PacketReader reader)
+ {
+ Ready = reader.ReadBool();
+ }
+
+ public override readonly string ToString() => $"RmpExtendedReady(ready={Ready})";
+}
+
+public struct RmpExtendedBeginRunMessage : INetMessage, IPacketSerializable
+{
+ public List? players;
+ public string seed;
+ public string act1;
+ public List modifiers;
+
+ public readonly bool ShouldBroadcast => false;
+ public readonly bool ShouldBuffer => false;
+ public readonly NetTransferMode Mode => NetTransferMode.Reliable;
+ public readonly LogLevel LogLevel => LogLevel.Info;
+
+ public readonly void Serialize(PacketWriter writer)
+ {
+ if (players == null)
+ throw new InvalidOperationException("players must not be null");
+
+ writer.WriteList(players, RemoveMultiplayerPlayerLimit.Core.ProtocolConfig.LobbyListLengthBits);
+ writer.WriteString(seed);
+ writer.WriteString(act1);
+ writer.WriteList(modifiers);
+ }
+
+ public void Deserialize(PacketReader reader)
+ {
+ players = reader.ReadList(RemoveMultiplayerPlayerLimit.Core.ProtocolConfig.LobbyListLengthBits);
+ seed = reader.ReadString();
+ act1 = reader.ReadString();
+ modifiers = reader.ReadList();
+ }
+
+ public override readonly string ToString() => $"RmpExtendedBeginRun(players={players?.Count ?? 0}, seed={seed})";
+}
diff --git a/src/Network/RmpNetActions.cs b/src/Network/RmpNetActions.cs
index 2af3614..41fc948 100644
--- a/src/Network/RmpNetActions.cs
+++ b/src/Network/RmpNetActions.cs
@@ -10,68 +10,51 @@
namespace RemoveMultiplayerPlayerLimit.Network;
///
-/// 遗物跳过 GameAction — 模组协议通道的独立动作(替代 PickRelicAction(player, -1) 黑客)。
+/// Relic skip GameAction — independent mod action type.
///
-/// 旧方案问题:
-/// PickRelicAction 的 relicIndex 字段用 8-bit 序列化,-1 被截断为 255,
-/// 接收端需要特殊处理 255→-1 的映射,侵入了官方 NetPickRelicAction 的协议空间。
+/// Replaces the old hack of sending PickRelicAction(player, -1) which
+/// truncated -1 to 255 in the 8-bit serialization field.
///
-/// 新方案:
-/// 通过 ActionTypes 自动注册 RmpSkipRelicNetAction 作为独立的网络动作类型,
-/// 类型本身即表达 "跳过" 语义,无需编码 -1 到官方字段中。
-///
-/// 网络流转:
-/// 发起端: RmpSkipRelicGameAction.ToNetAction() → RmpSkipRelicNetAction
-/// 网络层: ActionTypes 编码类型ID + Serialize(空载荷)
-/// 接收端: RmpSkipRelicNetAction.ToGameAction(player) → RmpSkipRelicGameAction
-/// 执行: RmpSkipRelicGameAction.ExecuteAction() → OnPicked(player, -1)
-///
-/// Host 广播时也通过 GameAction.ToNetAction() 回路保持类型一致:
-/// Host: INetAction → GameAction → ToNetAction() → RmpSkipRelicNetAction (保持类型)
+/// Network flow:
+/// Sender: RmpSkipRelicGameAction.ToNetAction() → RmpSkipRelicNetAction
+/// Wire: ActionTypes encodes type ID + Serialize (empty payload)
+/// Receiver: RmpSkipRelicNetAction.ToGameAction(player) → RmpSkipRelicGameAction
+/// Execute: RmpSkipRelicGameAction.ExecuteAction() → OnPicked(player, -1)
///
public class RmpSkipRelicGameAction : GameAction
{
- private readonly Player _player;
-
- public override ulong OwnerId => _player.NetId;
+ private readonly Player _player;
- public override GameActionType ActionType => GameActionType.NonCombat;
+ public override ulong OwnerId => _player.NetId;
+ public override GameActionType ActionType => GameActionType.NonCombat;
- public RmpSkipRelicGameAction(Player player)
- {
- _player = player;
- }
+ public RmpSkipRelicGameAction(Player player)
+ {
+ _player = player;
+ }
- protected override Task ExecuteAction()
- {
- RunManager.Instance.TreasureRoomRelicSynchronizer.OnPicked(_player, -1);
- return Task.CompletedTask;
- }
+ protected override Task ExecuteAction()
+ {
+ RunManager.Instance.TreasureRoomRelicSynchronizer.OnPicked(_player, -1);
+ return Task.CompletedTask;
+ }
- public override INetAction ToNetAction() => new RmpSkipRelicNetAction();
+ public override INetAction ToNetAction() => new RmpSkipRelicNetAction();
- public override string ToString() => $"RmpSkipRelicAction for player {_player.NetId}";
+ public override string ToString() => $"RmpSkipRelicAction for player {_player.NetId}";
}
///
-/// 遗物跳过 INetAction — 模组独立的网络动作类型。
-///
-/// 通过 ActionTypes (NetTypeCache<INetAction>) 自动发现与注册。
-/// 无载荷序列化:类型ID本身即为 "跳过" 信号。
+/// Relic skip INetAction — mod-independent network action type.
+/// Auto-discovered via ActionTypes (NetTypeCache<INetAction>).
+/// Zero payload — the type itself signals "skip".
///
public struct RmpSkipRelicNetAction : INetAction, IPacketSerializable
{
- public readonly GameAction ToGameAction(Player player) => new RmpSkipRelicGameAction(player);
-
- public readonly void Serialize(PacketWriter writer)
- {
- // 无载荷 — 类型本身即为 "跳过" 语义
- }
+ public readonly GameAction ToGameAction(Player player) => new RmpSkipRelicGameAction(player);
- public void Deserialize(PacketReader reader)
- {
- // 无载荷
- }
+ public readonly void Serialize(PacketWriter writer) { /* No payload */ }
+ public void Deserialize(PacketReader reader) { /* No payload */ }
- public override readonly string ToString() => nameof(RmpSkipRelicNetAction);
+ public override readonly string ToString() => nameof(RmpSkipRelicNetAction);
}
diff --git a/src/Network/RmpNetMessages.cs b/src/Network/RmpNetMessages.cs
index d997920..95833d7 100644
--- a/src/Network/RmpNetMessages.cs
+++ b/src/Network/RmpNetMessages.cs
@@ -5,39 +5,37 @@
namespace RemoveMultiplayerPlayerLimit.Network;
///
-/// RMP 配置同步消息 — 模组协议通道的自定义消息。
+/// RMP config sync message — custom mod protocol message.
///
-/// 由 Host 向所有客户端广播,携带当前 mod 配置。
-/// 通过游戏的 ReflectionHelper.GetSubtypesInMods<INetMessage>() 自动注册,
-/// MessageTypes 分配类型ID,NetMessageBus 处理序列化/分发。
+/// Broadcast by Host to all clients carrying current mod config.
+/// Auto-registered via ReflectionHelper.GetSubtypesInMods<INetMessage>().
///
-/// 数据包格式:
-/// [8 bits] ProtocolVersion — 协议版本,用于兼容性检查
-/// [8 bits] MaxPlayerLimit — 最大玩家人数上限 (4-16)
+/// Packet format:
+/// [8 bits] ProtocolVersion
+/// [8 bits] MaxPlayerLimit (4-16)
///
public struct RmpConfigSyncMessage : INetMessage, IPacketSerializable
{
- public int ProtocolVersion;
- public int MaxPlayerLimit;
+ public int ProtocolVersion;
+ public int MaxPlayerLimit;
- public readonly bool ShouldBroadcast => true;
- public readonly NetTransferMode Mode => NetTransferMode.Reliable;
- public readonly LogLevel LogLevel => LogLevel.Info;
+ public readonly bool ShouldBroadcast => true;
+ public readonly bool ShouldBuffer => false;
+ public readonly NetTransferMode Mode => NetTransferMode.Reliable;
+ public readonly LogLevel LogLevel => LogLevel.Info;
- public readonly void Serialize(PacketWriter writer)
- {
- writer.WriteInt(ProtocolVersion, 8);
- writer.WriteInt(MaxPlayerLimit, 8);
- }
+ public readonly void Serialize(PacketWriter writer)
+ {
+ writer.WriteInt(ProtocolVersion, 8);
+ writer.WriteInt(MaxPlayerLimit, 8);
+ }
- public void Deserialize(PacketReader reader)
- {
- ProtocolVersion = reader.ReadInt(8);
- MaxPlayerLimit = reader.ReadInt(8);
- }
+ public void Deserialize(PacketReader reader)
+ {
+ ProtocolVersion = reader.ReadInt(8);
+ MaxPlayerLimit = reader.ReadInt(8);
+ }
- public override readonly string ToString()
- {
- return $"RmpConfigSync(v{ProtocolVersion}, maxPlayers={MaxPlayerLimit})";
- }
+ public override readonly string ToString()
+ => $"RmpConfigSync(v{ProtocolVersion}, maxPlayers={MaxPlayerLimit})";
}
diff --git a/src/Network/RmpProtocol.cs b/src/Network/RmpProtocol.cs
index c645bfc..50f0602 100644
--- a/src/Network/RmpProtocol.cs
+++ b/src/Network/RmpProtocol.cs
@@ -1,91 +1,220 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using MegaCrit.Sts2.Core.Logging;
+using MegaCrit.Sts2.Core.Models;
using MegaCrit.Sts2.Core.Multiplayer.Game;
+using MegaCrit.Sts2.Core.Multiplayer.Game.Lobby;
using MegaCrit.Sts2.Core.Multiplayer.Serialization;
+using MegaCrit.Sts2.Core.Nodes;
+using MegaCrit.Sts2.Core.Nodes.Screens;
+using MegaCrit.Sts2.Core.Nodes.Screens.MainMenu;
+using MegaCrit.Sts2.Core.Runs;
+using MegaCrit.Sts2.Core.Saves.Runs;
+using MegaCrit.Sts2.Core.Unlocks;
+using RemoveMultiplayerPlayerLimit.Core;
+using RemoveMultiplayerPlayerLimit.Infrastructure;
namespace RemoveMultiplayerPlayerLimit.Network;
///
-/// RMP 模组独立协议层 — 与官方协议并行运行(协议并发架构)。
+/// RMP independent protocol layer — runs alongside the official protocol.
///
-/// 设计原则:
-/// 1. 官方协议通道:保留官方的发包逻辑,仅对核心位宽做必要扩展(SlotId/LobbyList)
-/// 2. 模组协议通道:通过自定义 INetMessage / INetAction 独立发包拓展
-/// 3. 两条通道并行,互不干扰
+/// Design:
+/// 1. Official protocol channel: preserves vanilla packet logic
+/// 2. Mod protocol channel: custom INetMessage / INetAction for extensions
+/// 3. Both channels run in parallel without interference
///
-/// 自定义消息类型通过游戏的 ReflectionHelper.GetSubtypesInMods 自动注册,
-/// 自定义动作类型通过 ActionTypes 自动注册,无需手动 wire-up。
-///
-/// 此类负责:
-/// - 协议版本声明与兼容性检查
-/// - 自定义消息处理器的生命周期管理(注册/注销)
-/// - 配置同步广播
+/// Custom message types are auto-registered by the game's
+/// ReflectionHelper.GetSubtypesInMods, no manual wire-up needed.
///
public static class RmpProtocol
{
- /// 协议版本号。所有对端必须一致。
- public const int ProtocolVersion = 1;
-
- private static INetGameService? _netService;
-
- /// 协议是否已绑定到活跃的多人会话。
- public static bool IsActive => _netService != null;
-
- ///
- /// 绑定到一个多人会话的网络服务。在 StartRunLobby 创建时调用。
- /// 自动注销之前绑定的会话(如有)。
- ///
- public static void Bind(INetGameService netService)
- {
- Unbind();
- _netService = netService;
- netService.RegisterMessageHandler(HandleConfigSync);
- Log.Info($"RMP protocol v{ProtocolVersion} bound to {netService.Type} (NetId={netService.NetId})");
- }
-
- ///
- /// 解除协议绑定。在多人会话结束时调用,或在 Bind 新会话前自动调用。
- ///
- public static void Unbind()
- {
- if (_netService == null)
- {
- return;
- }
- try
- {
- _netService.UnregisterMessageHandler(HandleConfigSync);
- }
- catch (Exception)
- {
- // 服务可能已 disposed,忽略清理异常
- }
- _netService = null;
- }
-
- ///
- /// Host 向所有客户端广播当前 mod 配置。
- /// 在玩家加入大厅、配置变更时调用。
- ///
- public static void BroadcastConfig(int maxPlayerLimit)
- {
- if (_netService == null || _netService.Type != NetGameType.Host)
- {
- return;
- }
- _netService.SendMessage(new RmpConfigSyncMessage
- {
- ProtocolVersion = ProtocolVersion,
- MaxPlayerLimit = maxPlayerLimit
- });
- }
-
- private static void HandleConfigSync(RmpConfigSyncMessage message, ulong senderId)
- {
- if (message.ProtocolVersion != ProtocolVersion)
- {
- Log.Warn($"RMP protocol version mismatch: local={ProtocolVersion}, remote={message.ProtocolVersion} from peer {senderId}");
- }
- Log.Info($"RMP config sync received from {senderId}: protocol=v{message.ProtocolVersion}, maxPlayers={message.MaxPlayerLimit}");
- }
+ public const int ProtocolVersion = 2;
+
+ private static INetGameService? _netService;
+
+ public static bool IsActive => _netService != null;
+
+ public static void Bind(INetGameService netService)
+ {
+ Unbind();
+ _netService = netService;
+ netService.RegisterMessageHandler(HandleConfigSync);
+ netService.RegisterMessageHandler(HandleLobbySnapshot);
+ netService.RegisterMessageHandler(HandleExtendedReadyState);
+ netService.RegisterMessageHandler(HandleExtendedBeginRun);
+ Log.Info($"[RMP] Protocol v{ProtocolVersion} bound to {netService.Type} (NetId={netService.NetId})");
+ }
+
+ public static void Unbind()
+ {
+ if (_netService == null) return;
+ try
+ {
+ _netService.UnregisterMessageHandler(HandleConfigSync);
+ _netService.UnregisterMessageHandler(HandleLobbySnapshot);
+ _netService.UnregisterMessageHandler(HandleExtendedReadyState);
+ _netService.UnregisterMessageHandler(HandleExtendedBeginRun);
+ }
+ catch { /* Service may already be disposed */ }
+ _netService = null;
+ }
+
+ ///
+ /// Host broadcasts current mod config to all clients.
+ /// Called on player join and config changes.
+ ///
+ public static void BroadcastConfig(int maxPlayerLimit)
+ {
+ if (_netService == null || _netService.Type != NetGameType.Host) return;
+ _netService.SendMessage(new RmpConfigSyncMessage
+ {
+ ProtocolVersion = ProtocolVersion,
+ MaxPlayerLimit = maxPlayerLimit
+ });
+ }
+
+ public static void BroadcastLobbySnapshot(IReadOnlyList players)
+ {
+ if (_netService == null || _netService.Type != NetGameType.Host) return;
+
+ _netService.SendMessage(new RmpLobbySnapshotMessage
+ {
+ players = players.Select(RmpLobbyPlayerState.FromLobbyPlayer).ToList()
+ });
+ }
+
+ public static void BroadcastExtendedReady(bool ready)
+ {
+ if (_netService == null || _netService.Type != NetGameType.Host) return;
+
+ _netService.SendMessage(new RmpExtendedReadyStateMessage
+ {
+ Ready = ready
+ });
+ }
+
+ public static void BroadcastExtendedBeginRun(
+ IReadOnlyList players,
+ string seed,
+ string act1,
+ IReadOnlyList modifiers)
+ {
+ if (_netService == null || _netService.Type != NetGameType.Host) return;
+
+ _netService.SendMessage(new RmpExtendedBeginRunMessage
+ {
+ players = players.Select(RmpLobbyPlayerState.FromLobbyPlayer).ToList(),
+ seed = seed,
+ act1 = act1,
+ modifiers = modifiers.Select(modifier => modifier.ToSerializable()).ToList()
+ });
+ }
+
+ private static void HandleConfigSync(RmpConfigSyncMessage message, ulong senderId)
+ {
+ if (message.ProtocolVersion != ProtocolVersion)
+ Log.Warn($"[RMP] Protocol version mismatch: local={ProtocolVersion}, remote={message.ProtocolVersion} from {senderId}");
+ // v0.1.7: player limit is fixed at 16; incoming MaxPlayerLimit is informational only.
+ Log.Info($"[RMP] Config sync from {senderId}: v{message.ProtocolVersion}, maxPlayers={message.MaxPlayerLimit} (local fixed at {ProtocolConfig.MaxPlayerLimit})");
+ }
+
+ private static void HandleLobbySnapshot(RmpLobbySnapshotMessage message, ulong senderId)
+ {
+ if (message.players == null)
+ return;
+
+ StartRunLobby? lobby = SceneMonitor.FindActiveStartRunLobby();
+ if (lobby == null)
+ return;
+
+ ApplyLobbySnapshot(lobby, message.players);
+ }
+
+ private static void HandleExtendedReadyState(RmpExtendedReadyStateMessage message, ulong senderId)
+ {
+ StartRunLobby? lobby = SceneMonitor.FindActiveStartRunLobby();
+ if (lobby == null || !ExtendedLobbyModule.ShouldUseExtendedLobbyProtocol(lobby))
+ return;
+
+ if (ExtendedLobbyModule.TrySetPlayerReadyState(lobby, senderId, message.Ready, out var updatedPlayer))
+ {
+ ExtendedLobbyModule.NotifyPlayerChanged(lobby, updatedPlayer, false);
+ }
+
+ if (lobby.NetService.Type == NetGameType.Host)
+ ExtendedLobbyModule.TryBeginExtendedRun(lobby);
+ }
+
+ private static void HandleExtendedBeginRun(RmpExtendedBeginRunMessage message, ulong senderId)
+ {
+ if (message.players == null)
+ return;
+
+ StartRunLobby? lobby = SceneMonitor.FindActiveStartRunLobby();
+ if (lobby == null)
+ return;
+
+ ApplyLobbySnapshot(lobby, message.players);
+
+ List modifiers = message.modifiers.Select(ModifierModel.FromSerializable).ToList();
+ List acts = ExtendedLobbyModule.BuildActsForBeginRun(
+ message.seed,
+ message.act1,
+ lobby,
+ message.players.Select(player => player.ToLobbyPlayer()).ToList());
+
+ lobby.LobbyListener.BeginRun(message.seed, acts, modifiers);
+ }
+
+ private static void ApplyLobbySnapshot(StartRunLobby lobby, IReadOnlyList snapshotPlayers)
+ {
+ var currentPlayers = lobby.Players.ToDictionary(player => player.id);
+ var snapshot = snapshotPlayers.Select(player => player.ToLobbyPlayer()).ToList();
+ var snapshotIds = snapshot.Select(player => player.id).ToHashSet();
+
+ for (int i = lobby.Players.Count - 1; i >= 0; i--)
+ {
+ var existing = lobby.Players[i];
+ if (!snapshotIds.Contains(existing.id))
+ {
+ lobby.Players.RemoveAt(i);
+ if (existing.id != lobby.NetService.NetId)
+ lobby.LobbyListener.RemotePlayerDisconnected(existing);
+ }
+ }
+
+ foreach (var snapshotPlayer in snapshot)
+ {
+ if (!currentPlayers.TryGetValue(snapshotPlayer.id, out var existing))
+ {
+ lobby.Players.Add(snapshotPlayer);
+ if (snapshotPlayer.id != lobby.NetService.NetId)
+ lobby.LobbyListener.PlayerConnected(snapshotPlayer);
+ continue;
+ }
+
+ if (!LobbyPlayersEqual(existing, snapshotPlayer))
+ {
+ int idx = lobby.Players.FindIndex(player => player.id == snapshotPlayer.id);
+ if (idx >= 0)
+ lobby.Players[idx] = snapshotPlayer;
+ ExtendedLobbyModule.NotifyPlayerChanged(lobby, snapshotPlayer, false);
+ }
+ }
+
+ NGame.Instance?.RemoteCursorContainer.Initialize(lobby.InputSynchronizer, lobby.Players.Select(player => player.id));
+ }
+
+ private static bool LobbyPlayersEqual(
+ MegaCrit.Sts2.Core.Entities.Multiplayer.LobbyPlayer a,
+ MegaCrit.Sts2.Core.Entities.Multiplayer.LobbyPlayer b)
+ {
+ return a.id == b.id
+ && a.slotId == b.slotId
+ && a.character == b.character
+ && a.maxMultiplayerAscensionUnlocked == b.maxMultiplayerAscensionUnlocked
+ && a.isReady == b.isReady;
+ }
}
diff --git a/src/Network/SerializationPatches.cs b/src/Network/SerializationPatches.cs
deleted file mode 100644
index e6804f7..0000000
--- a/src/Network/SerializationPatches.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-using System.Collections.Generic;
-using System.Linq;
-using System.Reflection;
-using HarmonyLib;
-using MegaCrit.Sts2.Core.Entities.Multiplayer;
-using MegaCrit.Sts2.Core.Multiplayer.Messages.Lobby;
-using MegaCrit.Sts2.Core.Multiplayer.Serialization;
-
-namespace RemoveMultiplayerPlayerLimit.Network;
-
-// ╔══════════════════════════════════════════════════════════════════════════╗
-// ║ 序列化位宽 Transpiler 补丁 ║
-// ║ ║
-// ║ 修改官方协议消息的 SlotId / LobbyList 序列化位宽: ║
-// ║ • LobbyPlayer.slotId : 2 → 4 bits ║
-// ║ • ClientLobbyJoinResponse.list : 3 → 5 bits ║
-// ║ • LobbyBeginRunMessage.list : 3 → 5 bits ║
-// ║ ║
-// ║ 每个消息的 Serialize / Deserialize 各一个补丁,成对保证位宽一致。 ║
-// ╚══════════════════════════════════════════════════════════════════════════╝
-
-///
-/// 持有通过反射获取的 PacketWriter / PacketReader 序列化方法引用。
-/// 供各 Transpiler 补丁作为匹配目标使用。
-///
-internal static class SerializationMethods
-{
- internal static readonly MethodInfo? WriteIntWithBits =
- AccessTools.Method(typeof(PacketWriter), nameof(PacketWriter.WriteInt), new[] { typeof(int), typeof(int) });
-
- internal static readonly MethodInfo? ReadIntWithBits =
- AccessTools.Method(typeof(PacketReader), nameof(PacketReader.ReadInt), new[] { typeof(int) });
-
- internal static readonly MethodInfo? WriteListWithBits =
- typeof(PacketWriter).GetMethods(BindingFlags.Public | BindingFlags.Instance)
- .FirstOrDefault(m => m.Name == nameof(PacketWriter.WriteList)
- && m.IsGenericMethodDefinition
- && m.GetParameters().Length == 2
- && m.GetParameters()[1].ParameterType == typeof(int));
-
- internal static readonly MethodInfo? ReadListWithBits =
- typeof(PacketReader).GetMethods(BindingFlags.Public | BindingFlags.Instance)
- .FirstOrDefault(m => m.Name == nameof(PacketReader.ReadList)
- && m.IsGenericMethodDefinition
- && m.GetParameters().Length == 1
- && m.GetParameters()[0].ParameterType == typeof(int));
-}
-
-// ── LobbyPlayer SlotId ─────────────────────────────────────────────────
-
-[HarmonyPatch(typeof(LobbyPlayer), nameof(LobbyPlayer.Serialize))]
-internal static class LobbyPlayerSerializePatch
-{
- private static IEnumerable Transpiler(IEnumerable instructions)
- => TranspilerUtils.ReplaceBitWidthBeforeCall(instructions,
- SerializationMethods.WriteIntWithBits,
- ProtocolConfig.VanillaSlotIdBits, ProtocolConfig.SlotIdBits,
- nameof(LobbyPlayerSerializePatch));
-}
-
-[HarmonyPatch(typeof(LobbyPlayer), nameof(LobbyPlayer.Deserialize))]
-internal static class LobbyPlayerDeserializePatch
-{
- private static IEnumerable Transpiler(IEnumerable instructions)
- => TranspilerUtils.ReplaceBitWidthBeforeCall(instructions,
- SerializationMethods.ReadIntWithBits,
- ProtocolConfig.VanillaSlotIdBits, ProtocolConfig.SlotIdBits,
- nameof(LobbyPlayerDeserializePatch));
-}
-
-// ── ClientLobbyJoinResponseMessage ─────────────────────────────────────
-
-[HarmonyPatch(typeof(ClientLobbyJoinResponseMessage), nameof(ClientLobbyJoinResponseMessage.Serialize))]
-internal static class ClientLobbyJoinResponseSerializePatch
-{
- private static IEnumerable Transpiler(IEnumerable instructions)
- => TranspilerUtils.ReplaceBitWidthBeforeCall(instructions,
- SerializationMethods.WriteListWithBits,
- ProtocolConfig.VanillaLobbyListLengthBits, ProtocolConfig.LobbyListLengthBits,
- nameof(ClientLobbyJoinResponseSerializePatch));
-}
-
-[HarmonyPatch(typeof(ClientLobbyJoinResponseMessage), nameof(ClientLobbyJoinResponseMessage.Deserialize))]
-internal static class ClientLobbyJoinResponseDeserializePatch
-{
- private static IEnumerable Transpiler(IEnumerable instructions)
- => TranspilerUtils.ReplaceBitWidthBeforeCall(instructions,
- SerializationMethods.ReadListWithBits,
- ProtocolConfig.VanillaLobbyListLengthBits, ProtocolConfig.LobbyListLengthBits,
- nameof(ClientLobbyJoinResponseDeserializePatch));
-}
-
-// ── LobbyBeginRunMessage ───────────────────────────────────────────────
-
-[HarmonyPatch(typeof(LobbyBeginRunMessage), nameof(LobbyBeginRunMessage.Serialize))]
-internal static class LobbyBeginRunSerializePatch
-{
- private static IEnumerable Transpiler(IEnumerable instructions)
- => TranspilerUtils.ReplaceBitWidthBeforeCall(instructions,
- SerializationMethods.WriteListWithBits,
- ProtocolConfig.VanillaLobbyListLengthBits, ProtocolConfig.LobbyListLengthBits,
- nameof(LobbyBeginRunSerializePatch));
-}
-
-[HarmonyPatch(typeof(LobbyBeginRunMessage), nameof(LobbyBeginRunMessage.Deserialize))]
-internal static class LobbyBeginRunDeserializePatch
-{
- private static IEnumerable Transpiler(IEnumerable instructions)
- => TranspilerUtils.ReplaceBitWidthBeforeCall(instructions,
- SerializationMethods.ReadListWithBits,
- ProtocolConfig.VanillaLobbyListLengthBits, ProtocolConfig.LobbyListLengthBits,
- nameof(LobbyBeginRunDeserializePatch));
-}
diff --git a/src/Network/SteamLobbyHelper.cs b/src/Network/SteamLobbyHelper.cs
index de806ae..3cf0a90 100644
--- a/src/Network/SteamLobbyHelper.cs
+++ b/src/Network/SteamLobbyHelper.cs
@@ -1,51 +1,63 @@
using System;
-using System.Reflection;
-using HarmonyLib;
using MegaCrit.Sts2.Core.Logging;
using MegaCrit.Sts2.Core.Multiplayer;
using MegaCrit.Sts2.Core.Multiplayer.Game;
+using MegaCrit.Sts2.Core.Multiplayer.Transport.Steam;
+using Steamworks;
namespace RemoveMultiplayerPlayerLimit.Network;
///
-/// Steam 大厅反射工具 — 通过反射调用 Steamworks.NET API,
-/// 避免模组直接依赖 Steamworks 程序集。
+/// Steam lobby helper — calls Steamworks.NET APIs directly to update
+/// the Steam lobby member limit after lobby creation.
+///
+/// Path: NetHostGameService → NetHost (cast to SteamHost) → LobbyId
+/// → SteamMatchmaking.SetLobbyMemberLimit(lobbyId, limit)
///
internal static class SteamLobbyHelper
{
- ///
- /// 尝试更新 Steam 大厅的成员上限。
- /// 通过反射链:NetHostGameService → SteamHost.LobbyId → SteamMatchmaking.SetLobbyMemberLimit。
- /// 仅在 Host 端有效,失败时静默记录警告。
- ///
- internal static void TryUpdateMemberLimit(INetGameService netService, int limit)
- {
- try
- {
- if (netService is not NetHostGameService hostService)
- {
- return;
- }
- object? netHost = hostService.NetHost;
- if (netHost == null)
- {
- return;
- }
- // SteamHost.LobbyId → CSteamID?(Steamworks.NET 类型,通过反射避免直接依赖)
- PropertyInfo? lobbyIdProp = AccessTools.Property(netHost.GetType(), "LobbyId");
- object? lobbyIdObj = lobbyIdProp?.GetValue(netHost);
- if (lobbyIdObj == null)
- {
- return;
- }
- // SteamMatchmaking.SetLobbyMemberLimit(CSteamID lobbyId, int maxMembers)
- Type? steamMatchmakingType = lobbyIdObj.GetType().Assembly.GetType("Steamworks.SteamMatchmaking");
- MethodInfo? setLimitMethod = steamMatchmakingType?.GetMethod("SetLobbyMemberLimit");
- setLimitMethod?.Invoke(null, new object[] { lobbyIdObj, limit });
- }
- catch (Exception ex)
- {
- Log.Warn($"Failed to update Steam lobby member limit: {ex.Message}");
- }
- }
+ internal static bool TryUpdateMemberLimit(INetGameService netService, int limit)
+ {
+ try
+ {
+ if (netService is not NetHostGameService hostService) return false;
+
+ if (hostService.NetHost is not SteamHost steamHost) return false;
+
+ CSteamID? lobbyId = steamHost.LobbyId;
+ if (!lobbyId.HasValue) return false;
+
+ bool result = SteamMatchmaking.SetLobbyMemberLimit(lobbyId.Value, limit);
+ if (result)
+ {
+ Log.Info($"[RMP] Steam lobby member limit set to {limit} (lobby={lobbyId.Value.m_SteamID})");
+ }
+ else
+ {
+ Log.Warn($"[RMP] SteamMatchmaking.SetLobbyMemberLimit({limit}) returned false");
+ }
+ return result;
+ }
+ catch (Exception ex)
+ {
+ Log.Warn($"[RMP] Failed to update Steam lobby limit: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Gets the current Steam lobby member limit, or -1 if unavailable.
+ ///
+ internal static int GetCurrentMemberLimit(INetGameService netService)
+ {
+ try
+ {
+ if (netService is not NetHostGameService hostService) return -1;
+ if (hostService.NetHost is not SteamHost steamHost) return -1;
+ CSteamID? lobbyId = steamHost.LobbyId;
+ if (!lobbyId.HasValue) return -1;
+ return SteamMatchmaking.GetLobbyMemberLimit(lobbyId.Value);
+ }
+ catch { return -1; }
+ }
}
diff --git a/src/Network/TranspilerUtils.cs b/src/Network/TranspilerUtils.cs
deleted file mode 100644
index 598581a..0000000
--- a/src/Network/TranspilerUtils.cs
+++ /dev/null
@@ -1,148 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Reflection;
-using System.Reflection.Emit;
-using HarmonyLib;
-
-namespace RemoveMultiplayerPlayerLimit.Network;
-
-///
-/// IL Transpiler 工具类 — 纯 IL 操作,零游戏依赖。
-///
-/// 核心能力:在 IL 指令流中定位特定方法调用前的常量加载指令,
-/// 并将其替换为新值。用于修改官方序列化方法的位宽参数。
-///
-/// 设计:
-/// 所有方法都是静态无状态的,可安全并发调用。
-/// 不引用任何 MegaCrit / Godot / Harmony 命名空间。
-///
-internal static class TranspilerUtils
-{
- private static readonly int LdcI4MinOpcodeValue = OpCodes.Ldc_I4_M1.Value;
-
- private static readonly int LdcI4MaxOpcodeValue = OpCodes.Ldc_I4_8.Value;
-
- private static readonly int LdcI4SOpcodeValue = OpCodes.Ldc_I4_S.Value;
-
- private static readonly int LdcI4OpcodeValue = OpCodes.Ldc_I4.Value;
-
- ///
- /// 在 IL 指令流中,找到所有对 的调用,
- /// 将其前方的 常量加载指令替换为 。
- ///
- /// 未找到任何可替换的位宽操作数。
- internal static IEnumerable ReplaceBitWidthBeforeCall(
- IEnumerable instructions,
- MethodInfo? targetMethod,
- int sourceBitWidth,
- int targetBitWidth,
- string patchName)
- {
- MethodInfo resolvedTargetMethod = targetMethod
- ?? throw new InvalidOperationException($"{patchName}: target method is null.");
-
- List list = new List(instructions);
- int count = 0;
-
- for (int i = 0; i < list.Count; i++)
- {
- if (!IsCallToMethod(list[i], resolvedTargetMethod))
- {
- continue;
- }
- int loadIndex = FindBitWidthLoadIndex(list, i, sourceBitWidth);
- if (loadIndex < 0)
- {
- continue;
- }
- list[loadIndex] = CloneWithNewIntOperand(list[loadIndex], targetBitWidth);
- count++;
- }
-
- if (count == 0)
- {
- throw new InvalidOperationException(
- $"{patchName}: no bit-width operand replaced for method " +
- $"{resolvedTargetMethod.Name} ({sourceBitWidth}->{targetBitWidth}), game code may have changed.");
- }
-
- return list;
- }
-
- ///
- /// 从 向前回溯最多 8 条指令,
- /// 查找值等于 的 ldc.i4 指令。
- ///
- /// 找到的指令索引,或 -1。
- private static int FindBitWidthLoadIndex(IReadOnlyList instructions, int callIndex, int expectedValue)
- {
- int searchStart = Math.Max(0, callIndex - 8);
- for (int i = callIndex - 1; i >= searchStart; i--)
- {
- if (instructions[i].opcode == OpCodes.Nop)
- {
- continue;
- }
- int? ldcI4Value = ReadLdcI4Nullable(instructions[i]);
- if (ldcI4Value.HasValue)
- {
- return ldcI4Value.Value == expectedValue ? i : -1;
- }
- if (IsTerminatingOpcode(instructions[i].opcode))
- {
- return -1;
- }
- }
- return -1;
- }
-
- /// 判断操作码是否为控制流终止指令(分支/返回/抛出/调用)。
- private static bool IsTerminatingOpcode(OpCode opcode)
- {
- FlowControl flowControl = opcode.FlowControl;
- return flowControl is FlowControl.Branch or FlowControl.Cond_Branch
- or FlowControl.Return or FlowControl.Throw or FlowControl.Call;
- }
-
- /// 创建一个新的 ldc.i4 指令,保留原指令的 labels 和 blocks。
- private static CodeInstruction CloneWithNewIntOperand(CodeInstruction source, int newValue)
- {
- CodeInstruction result = new CodeInstruction(OpCodes.Ldc_I4, newValue);
- result.labels.AddRange(source.labels);
- result.blocks.AddRange(source.blocks);
- return result;
- }
-
- /// 判断指令是否为对 的调用(含泛型方法匹配)。
- internal static bool IsCallToMethod(CodeInstruction instruction, MethodInfo targetMethod)
- {
- if ((instruction.opcode != OpCodes.Call && instruction.opcode != OpCodes.Callvirt)
- || instruction.operand is not MethodInfo methodInfo)
- {
- return false;
- }
- if (methodInfo == targetMethod)
- {
- return true;
- }
- MethodInfo resolvedMethod = methodInfo.IsGenericMethod ? methodInfo.GetGenericMethodDefinition() : methodInfo;
- MethodInfo resolvedTarget = targetMethod.IsGenericMethod ? targetMethod.GetGenericMethodDefinition() : targetMethod;
- return resolvedMethod == resolvedTarget;
- }
-
- /// 解析 ldc.i4 系列操作码的整数值。非 ldc.i4 返回 null。
- internal static int? ReadLdcI4Nullable(CodeInstruction instruction)
- {
- int opcodeValue = instruction.opcode.Value;
- return opcodeValue switch
- {
- _ when opcodeValue >= LdcI4MinOpcodeValue && opcodeValue <= LdcI4MaxOpcodeValue
- => opcodeValue - (LdcI4MinOpcodeValue + 1),
- _ when opcodeValue == LdcI4SOpcodeValue && instruction.operand is sbyte sb
- => sb,
- _ when opcodeValue == LdcI4OpcodeValue && instruction.operand is int num
- => num,
- _ => null
- };
- }
-}
diff --git a/src/Patches.DifficultyScaling.cs b/src/Patches.DifficultyScaling.cs
deleted file mode 100644
index 6e6093f..0000000
--- a/src/Patches.DifficultyScaling.cs
+++ /dev/null
@@ -1,93 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Reflection;
-using System.Reflection.Emit;
-using HarmonyLib;
-using MegaCrit.Sts2.Core.Entities.Creatures;
-using MegaCrit.Sts2.Core.Models.Singleton;
-using RemoveMultiplayerPlayerLimit.Network;
-
-namespace RemoveMultiplayerPlayerLimit;
-
-public static partial class ModEntry
-{
- // ── 怪物难度缩放:超过 4 人后继续按官方公式提升 ──────────────────────────
- //
- // 官方公式:Value × PlayerCount × ActMultiplier
- // 原版仅支持 4 人,该公式自然只跑到 4。
- // 本补丁在「难度缩放」关闭时将 playerCount 钳制到 4(保留原版体验),
- // 开启时直接使用真实玩家数(官方公式自然延伸到更多人)。
-
- internal static int GetEffectivePlayerCount(int rawCount)
- {
- return ProtocolConfig.DifficultyScalingEnabled ? rawCount : Math.Min(rawCount, 4);
- }
-
- // ── HP 缩放 ──────────────────────────────────────────────────────────
-
- [HarmonyPatch(typeof(Creature), nameof(Creature.ScaleMonsterHpForMultiplayer))]
- private static class ScaleMonsterHpPatch
- {
- private static void Prefix(ref int playerCount)
- {
- playerCount = GetEffectivePlayerCount(playerCount);
- }
- }
-
- // ── 格挡缩放 ─────────────────────────────────────────────────────────
-
- [HarmonyPatch(typeof(MultiplayerScalingModel), nameof(MultiplayerScalingModel.ModifyBlockMultiplicative))]
- private static class ModifyBlockScalingPatch
- {
- private static IEnumerable Transpiler(IEnumerable instructions)
- {
- return PatchPlayersCountInScaling(instructions);
- }
- }
-
- // ── 能力数值缩放 ─────────────────────────────────────────────────────
-
- [HarmonyPatch(typeof(MultiplayerScalingModel), nameof(MultiplayerScalingModel.ModifyPowerAmountGiven))]
- private static class ModifyPowerScalingPatch
- {
- private static IEnumerable Transpiler(IEnumerable instructions)
- {
- return PatchPlayersCountInScaling(instructions);
- }
- }
-
- ///
- /// 通用 Transpiler:在 MultiplayerScalingModel 方法中,找到 _runState.Players.Count
- /// 的 get_Count 调用,在其后插入 GetEffectivePlayerCount 以实现钳制。
- ///
- private static IEnumerable PatchPlayersCountInScaling(IEnumerable instructions)
- {
- MethodInfo helper = AccessTools.Method(typeof(ModEntry), nameof(GetEffectivePlayerCount));
- FieldInfo? runStateField = AccessTools.Field(typeof(MultiplayerScalingModel), "_runState");
-
- bool foundRunStateLoad = false;
-
- foreach (CodeInstruction instruction in instructions)
- {
- yield return instruction;
-
- // 检测 ldfld _runState
- if (!foundRunStateLoad && runStateField != null && instruction.LoadsField(runStateField))
- {
- foundRunStateLoad = true;
- continue;
- }
-
- // 在 _runState 之后寻找 get_Count(即 _runState.Players.Count)
- if (foundRunStateLoad
- && (instruction.opcode == OpCodes.Callvirt || instruction.opcode == OpCodes.Call)
- && instruction.operand is MethodInfo mi
- && mi.Name == "get_Count"
- && mi.ReturnType == typeof(int))
- {
- yield return new CodeInstruction(OpCodes.Call, helper);
- foundRunStateLoad = false;
- }
- }
- }
-}
diff --git a/src/Patches.Linux.cs b/src/Patches.Linux.cs
deleted file mode 100644
index b53ceeb..0000000
--- a/src/Patches.Linux.cs
+++ /dev/null
@@ -1,121 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Runtime.InteropServices;
-using MegaCrit.Sts2.Core.Logging;
-
-namespace RemoveMultiplayerPlayerLimit;
-
-public static partial class ModEntry
-{
- private const int RtldNow = 2;
-
- private const int RtldGlobal = 0x100;
-
- private const string LinuxHarmonyDependencyFailureHelp = "Failed to preload Linux Harmony dependencies. If patching fails, verify libgcc_s.so.1, libstdc++.so.6, libunwind.so.8, and libunwind-x86_64.so.8 are installed and visible to the game process.";
-
- private static readonly string[] LinuxHarmonyDependencyCandidates = new[]
- {
- "libgcc_s.so.1",
- "libstdc++.so.6",
- "libunwind.so.8",
- "libunwind-x86_64.so.8",
- "/lib/x86_64-linux-gnu/libgcc_s.so.1",
- "/usr/lib/x86_64-linux-gnu/libgcc_s.so.1",
- "/lib64/libgcc_s.so.1",
- "/usr/lib64/libgcc_s.so.1",
- "/lib/x86_64-linux-gnu/libstdc++.so.6",
- "/usr/lib/x86_64-linux-gnu/libstdc++.so.6",
- "/lib64/libstdc++.so.6",
- "/usr/lib64/libstdc++.so.6",
- "/lib/x86_64-linux-gnu/libunwind.so.8",
- "/usr/lib/x86_64-linux-gnu/libunwind.so.8",
- "/lib/x86_64-linux-gnu/libunwind-x86_64.so.8",
- "/usr/lib/x86_64-linux-gnu/libunwind-x86_64.so.8"
- };
-
- private static readonly List LinuxHarmonyDependencyHandles = new List();
-
- private readonly record struct LinuxLibraryLoadResult(string Candidate, bool Loaded, string? Error);
-
- private static void EnsureLinuxHarmonyDependenciesLoaded()
- {
- if (!OperatingSystem.IsLinux())
- {
- return;
- }
- List loadResults = EnumerateLinuxHarmonyDependencyCandidates()
- .Select(LoadLinuxLibraryGlobally)
- .ToList();
- string[] loadedLibraries = loadResults
- .Where(static result => result.Loaded)
- .Select(static result => result.Candidate)
- .Distinct()
- .ToArray();
- string[] failedLibraries = loadResults
- .Where(static result => !result.Loaded && !string.IsNullOrWhiteSpace(result.Error))
- .Select(static result => $"{result.Candidate} ({result.Error})")
- .Distinct()
- .ToArray();
- if (loadedLibraries.Length > 0)
- {
- Log.Info($"Preloaded Linux Harmony dependencies with RTLD_GLOBAL: {string.Join(", ", loadedLibraries)}");
- }
- else
- {
- Log.Warn(LinuxHarmonyDependencyFailureHelp);
- }
- if (failedLibraries.Length > 0)
- {
- Log.Warn($"Linux Harmony dependency load failures: {string.Join("; ", failedLibraries)}");
- }
- }
-
- private static IEnumerable EnumerateLinuxHarmonyDependencyCandidates()
- {
- HashSet seen = new HashSet(StringComparer.Ordinal);
- foreach (string candidate in LinuxHarmonyDependencyCandidates)
- {
- if (!seen.Add(candidate))
- {
- continue;
- }
- if (Path.IsPathRooted(candidate) && !File.Exists(candidate))
- {
- continue;
- }
- yield return candidate;
- }
- }
-
- private static LinuxLibraryLoadResult LoadLinuxLibraryGlobally(string libraryNameOrPath)
- {
- try
- {
- nint handle = dlopen(libraryNameOrPath, RtldNow | RtldGlobal);
- if (handle != 0)
- {
- LinuxHarmonyDependencyHandles.Add(handle);
- return new LinuxLibraryLoadResult(libraryNameOrPath, Loaded: true, Error: null);
- }
- return new LinuxLibraryLoadResult(libraryNameOrPath, Loaded: false, Error: ReadDlError());
- }
- catch (Exception ex)
- {
- return new LinuxLibraryLoadResult(libraryNameOrPath, Loaded: false, Error: ex.Message);
- }
- }
-
- private static string? ReadDlError()
- {
- nint errorPtr = dlerror();
- return errorPtr == 0 ? null : Marshal.PtrToStringAnsi(errorPtr);
- }
-
- [DllImport("libdl.so.2", EntryPoint = "dlopen")]
- private static extern nint dlopen(string fileName, int flags);
-
- [DllImport("libdl.so.2", EntryPoint = "dlerror")]
- private static extern nint dlerror();
-}
\ No newline at end of file
diff --git a/src/Patches.Merchant.cs b/src/Patches.Merchant.cs
deleted file mode 100644
index 7885050..0000000
--- a/src/Patches.Merchant.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Godot;
-using HarmonyLib;
-using MegaCrit.Sts2.Core.Nodes.Rooms;
-using MegaCrit.Sts2.Core.Nodes.Screens.Shops;
-
-namespace RemoveMultiplayerPlayerLimit;
-
-public static partial class ModEntry
-{
- private const float MerchantForwardShiftX = 160f;
-
- private const float MerchantForwardShiftY = 35f;
-
- private const float MerchantRowStartOffsetX = -110f;
-
- private const float MerchantRowStepY = -40f;
-
- private const float MerchantColumnStepX = -230f;
-
- [HarmonyPatch(typeof(NMerchantRoom), "AfterRoomIsLoaded")]
- private static class NMerchantRoomLayoutPatch
- {
- private static void Postfix(NMerchantRoom __instance)
- {
- RepositionMerchantVisuals(__instance.PlayerVisuals);
- }
- }
-
- private static void RepositionMerchantVisuals(IReadOnlyList visuals)
- {
- if (visuals.Count <= VanillaMultiplayerHolderCount)
- {
- return;
- }
- int rowCount = visuals.Count <= VanillaMultiplayerHolderCount * 2 ? 2 : Mathf.CeilToInt((float)visuals.Count / VanillaMultiplayerHolderCount);
- int columnCount = Mathf.CeilToInt((float)visuals.Count / rowCount);
- int visualIndex = 0;
- for (int row = 0; row < rowCount; row++)
- {
- float x = MerchantForwardShiftX + MerchantRowStartOffsetX * row;
- float y = MerchantForwardShiftY + MerchantRowStepY * row;
- for (int column = 0; column < columnCount && visualIndex < visuals.Count; column++)
- {
- NMerchantCharacter nMerchantCharacter = visuals[visualIndex];
- nMerchantCharacter.Position = new Vector2(x, y);
- x += MerchantColumnStepX;
- visualIndex++;
- }
- }
- }
-}
diff --git a/src/Patches.RestSite.cs b/src/Patches.RestSite.cs
deleted file mode 100644
index 8588281..0000000
--- a/src/Patches.RestSite.cs
+++ /dev/null
@@ -1,265 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Reflection;
-using System.Reflection.Emit;
-using Godot;
-using HarmonyLib;
-using MegaCrit.Sts2.Core.Context;
-using MegaCrit.Sts2.Core.Entities.RestSite;
-using MegaCrit.Sts2.Core.Logging;
-using MegaCrit.Sts2.Core.Nodes.Rooms;
-using MegaCrit.Sts2.Core.Nodes.RestSite;
-using MegaCrit.Sts2.Core.Runs;
-using RemoveMultiplayerPlayerLimit.Network;
-
-namespace RemoveMultiplayerPlayerLimit;
-
-public static partial class ModEntry
-{
- private static readonly Vector2 LeftExtraFrontOffset = new Vector2(-250f, 35f);
-
- private static readonly Vector2 LeftExtraBackOffset = new Vector2(-240f, -20f);
-
- private static readonly Vector2 RightExtraFrontOffset = new Vector2(250f, 35f);
-
- private static readonly Vector2 RightExtraBackOffset = new Vector2(240f, -20f);
-
- private static readonly Vector2 LogXOffsetLeft = new Vector2(-250f, 0f);
-
- private static readonly Vector2 LogXOffsetRight = new Vector2(250f, 0f);
-
- private static readonly Vector2 ExtraSeatStep = new Vector2(70f, -45f);
-
- [HarmonyPatch(typeof(NRestSiteRoom), nameof(NRestSiteRoom._Ready))]
- private static class NRestSiteRoomReadyPatch
- {
- private static readonly MethodInfo? CharacterContainerGetter = AccessTools.PropertyGetter(typeof(List), "Item");
-
- private static readonly MethodInfo? SafeContainerGetter = AccessTools.Method(typeof(NRestSiteRoomReadyPatch), nameof(GetContainerSafe));
-
- private static IEnumerable Transpiler(IEnumerable instructions)
- {
- foreach (CodeInstruction instruction in instructions)
- {
- if (CharacterContainerGetter != null && SafeContainerGetter != null && instruction.Calls(CharacterContainerGetter))
- {
- yield return new CodeInstruction(OpCodes.Call, SafeContainerGetter);
- continue;
- }
- yield return instruction;
- }
- }
-
- private static Control GetContainerSafe(List containers, int index)
- {
- if (containers.Count == 0)
- {
- throw new InvalidOperationException("No character containers found in rest site room.");
- }
- EnsureRestSiteContainers(containers, index + 1);
- return containers[NormalizeWrappedIndex(index, containers.Count)];
- }
-
- private static int NormalizeWrappedIndex(int index, int count)
- {
- int num = index % count;
- return num >= 0 ? num : num + count;
- }
-
- private static void EnsureRestSiteContainers(List containers, int requiredCount)
- {
- if (requiredCount <= containers.Count)
- {
- return;
- }
- Control parent = containers[0].GetParent();
- if (parent == null)
- {
- return;
- }
- EnsureExtraLogs(parent);
- int templateCount = containers.Count;
- while (containers.Count < requiredCount)
- {
- int count = containers.Count;
- Control source = containers[count % templateCount];
- Control control = source.Duplicate() as Control ?? new Control();
- RemoveAllChildren(control);
- control.Name = $"Character_Auto_{count + 1}";
- control.Position = GetExtraContainerPosition(containers, count);
- parent.AddChild(control);
- containers.Add(control);
- }
- }
-
- private static void RemoveAllChildren(Node node)
- {
- for (int i = node.GetChildCount() - 1; i >= 0; i--)
- {
- Node child = node.GetChild(i);
- node.RemoveChild(child);
- child.QueueFree();
- }
- }
-
- private static Vector2 GetExtraContainerPosition(List containers, int index)
- {
- if (containers.Count < 4)
- {
- return containers[containers.Count - 1].Position;
- }
- if (index >= ProtocolConfig.TargetPlayerLimit)
- {
- Log.Warn($"Rest site character index {index} exceeds configured target limit {ProtocolConfig.TargetPlayerLimit}.");
- }
- if (index < 4)
- {
- return containers[index].Position;
- }
- int extraSeatIndex = index - 4;
- bool isLeftSide = extraSeatIndex % 2 == 0;
- int depthLevel = extraSeatIndex / 2;
- Vector2 frontSeatPosition = isLeftSide ? containers[0].Position + LeftExtraFrontOffset : containers[1].Position + RightExtraFrontOffset;
- Vector2 backSeatPosition = isLeftSide ? containers[2].Position + LeftExtraBackOffset : containers[3].Position + RightExtraBackOffset;
- if (depthLevel == 0)
- {
- return frontSeatPosition;
- }
- if (depthLevel == 1)
- {
- return backSeatPosition;
- }
- int extraDepth = depthLevel - 1;
- Vector2 extraOffset = new Vector2((isLeftSide ? -1f : 1f) * ExtraSeatStep.X * extraDepth, ExtraSeatStep.Y * extraDepth);
- return backSeatPosition + extraOffset;
- }
-
- private static void EnsureExtraLogs(Control parent)
- {
- Node? background = parent.GetChildCount() > 0 ? parent.GetChild(0) : null;
- if (background == null || background.GetNodeOrNull("AutoExtraLogsMarker") != null)
- {
- return;
- }
- Node marker = new Node();
- marker.Name = "AutoExtraLogsMarker";
- background.AddChild(marker);
- bool leftLogOk = DuplicateShiftedNode(background, "RestSiteLLog", LogXOffsetLeft, "AutoL");
- bool rightLogOk = DuplicateShiftedNode(background, "RestSiteRLog", LogXOffsetRight, "AutoR");
- bool leftLogLayer2Ok = DuplicateShiftedNode(background, "RestSiteLighting/RestSiteLLog2", LogXOffsetLeft, "AutoL");
- bool rightLogLayer2Ok = DuplicateShiftedNode(background, "RestSiteLighting/RestSiteRLog2", LogXOffsetRight, "AutoR");
- if (!leftLogOk && !rightLogOk && !leftLogLayer2Ok && !rightLogLayer2Ok)
- {
- Log.Warn("No rest site log nodes found for duplication. Scene tree may have changed.");
- }
- }
-
- private static bool DuplicateShiftedNode(Node root, string nodePath, Vector2 offset, string suffix)
- {
- Node node = root.GetNodeOrNull(nodePath);
- if (node == null)
- {
- Log.Warn($"Rest site node not found: {nodePath}");
- return false;
- }
- Node parent = node.GetParent();
- if (parent == null)
- {
- Log.Warn($"Rest site node has no parent: {nodePath}");
- return false;
- }
- Node node2 = node.Duplicate();
- node2.Name = $"{node.Name}_{suffix}";
- parent.AddChild(node2);
- if (node is Control control && node2 is Control control2)
- {
- control2.Position = control.Position + offset;
- }
- if (node is Node2D node3 && node2 is Node2D node4)
- {
- node4.Position = node3.Position + offset;
- }
- return true;
- }
- }
-
- private static bool TryGetCharacter(NRestSiteRoom room, ulong playerId, out NRestSiteCharacter character)
- {
- NRestSiteCharacter? nRestSiteCharacter = room.Characters.FirstOrDefault((NRestSiteCharacter c) => c.Player.NetId == playerId);
- if (nRestSiteCharacter == null)
- {
- character = null!;
- return false;
- }
- character = nRestSiteCharacter;
- return true;
- }
-
- private static RestSiteOption? TryGetHoveredOption(ulong playerId)
- {
- int? hoveredOptionIndex = RunManager.Instance.RestSiteSynchronizer.GetHoveredOptionIndex(playerId);
- if (!hoveredOptionIndex.HasValue)
- {
- return null;
- }
- IReadOnlyList optionsForPlayer = RunManager.Instance.RestSiteSynchronizer.GetOptionsForPlayer(playerId);
- int value = hoveredOptionIndex.Value;
- if ((uint)value >= (uint)optionsForPlayer.Count)
- {
- return null;
- }
- return optionsForPlayer[value];
- }
-
- private static bool IsRemote(NRestSiteCharacter character) => !LocalContext.IsMe(character.Player);
-
- [HarmonyPatch(typeof(NRestSiteRoom), "OnPlayerChangedHoveredRestSiteOption")]
- private static class NRestSiteRoomHoverPatch
- {
- private static bool Prefix(NRestSiteRoom __instance, ulong playerId)
- {
- if (!TryGetCharacter(__instance, playerId, out var nRestSiteCharacter))
- {
- return false;
- }
- nRestSiteCharacter.ShowHoveredRestSiteOption(TryGetHoveredOption(playerId));
- return false;
- }
- }
-
- [HarmonyPatch(typeof(NRestSiteRoom), "OnBeforePlayerSelectedRestSiteOption")]
- private static class NRestSiteRoomBeforeSelectPatch
- {
- private static bool Prefix(NRestSiteRoom __instance, RestSiteOption option, ulong playerId)
- {
- if (TryGetCharacter(__instance, playerId, out var nRestSiteCharacter))
- {
- nRestSiteCharacter.SetSelectingRestSiteOption(option);
- }
- return false;
- }
- }
-
- [HarmonyPatch(typeof(NRestSiteRoom), "OnAfterPlayerSelectedRestSiteOption")]
- private static class NRestSiteRoomAfterSelectPatch
- {
- private static bool Prefix(NRestSiteRoom __instance, RestSiteOption option, bool success, ulong playerId)
- {
- if (!TryGetCharacter(__instance, playerId, out var nRestSiteCharacter))
- {
- return false;
- }
- nRestSiteCharacter.SetSelectingRestSiteOption(null);
- if (success)
- {
- nRestSiteCharacter.ShowSelectedRestSiteOption(option);
- if (IsRemote(nRestSiteCharacter))
- {
- MegaCrit.Sts2.Core.Helpers.TaskHelper.RunSafely(option.DoRemotePostSelectVfx());
- }
- }
- return false;
- }
- }
-}
diff --git a/src/Patches.Settings.cs b/src/Patches.Settings.cs
deleted file mode 100644
index 156d4f8..0000000
--- a/src/Patches.Settings.cs
+++ /dev/null
@@ -1,294 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Reflection;
-using Godot;
-using HarmonyLib;
-using MegaCrit.Sts2.addons.mega_text;
-using MegaCrit.Sts2.Core.Helpers;
-using MegaCrit.Sts2.Core.Logging;
-using MegaCrit.Sts2.Core.Nodes.Screens.Settings;
-using RemoveMultiplayerPlayerLimit.Network;
-
-namespace RemoveMultiplayerPlayerLimit;
-
-public static partial class ModEntry
-{
- private static readonly Color SettingsDividerColor = new Color(0.91f, 0.86f, 0.75f, 0.25f);
-
- private static readonly FieldInfo? PaginatorOptionsField = AccessTools.Field(typeof(NPaginator), "_options");
-
- private static readonly FieldInfo? PaginatorCurrentIndexField = AccessTools.Field(typeof(NPaginator), "_currentIndex");
-
- private static readonly FieldInfo? PaginatorLabelField = AccessTools.Field(typeof(NPaginator), "_label");
-
- private static readonly MethodInfo? GetSettingsOptionsMethod = AccessTools.Method(typeof(NSettingsPanel), "GetSettingsOptionsRecursive");
-
- private static readonly FieldInfo? PanelFirstControlField = AccessTools.Field(typeof(NSettingsPanel), "_firstControl");
-
- private static readonly HashSet PlayerLimitPaginators = new HashSet();
-
- private static readonly HashSet DifficultyScalingPaginators = new HashSet();
-
- // ── 注入到 General 面板 Modding 行下方 ──────────────────────────────────
-
- [HarmonyPatch(typeof(NSettingsScreen), nameof(NSettingsScreen._Ready))]
- private static class NSettingsScreenReadyPatch
- {
- private static void Postfix(NSettingsScreen __instance)
- {
- try
- {
- InjectRmpSettings(__instance);
- }
- catch (Exception ex)
- {
- Log.Warn($"Failed to inject RMP settings: {ex}");
- }
- }
- }
-
- [HarmonyPatch(typeof(NSettingsScreen), nameof(NSettingsScreen.OnSubmenuClosed))]
- private static class NSettingsScreenClosedPatch
- {
- private static void Postfix()
- {
- SaveModConfig();
- }
- }
-
- [HarmonyPatch(typeof(NPaginator), "OnIndexChanged")]
- private static class NPaginatorOnIndexChangedPatch
- {
- private static void Postfix(NPaginator __instance, int index)
- {
- bool isPlayerLimit = PlayerLimitPaginators.Contains(__instance);
- bool isDifficultyScaling = DifficultyScalingPaginators.Contains(__instance);
- if (!isPlayerLimit && !isDifficultyScaling)
- {
- return;
- }
- if (PaginatorOptionsField?.GetValue(__instance) is not List options)
- {
- return;
- }
- if (index < 0 || index >= options.Count)
- {
- return;
- }
- // 更新标签显示(基类 OnIndexChanged 为空,必须手动更新)
- if (PaginatorLabelField?.GetValue(__instance) is MegaLabel label)
- {
- label.SetTextAutoSize(options[index]);
- }
- if (isPlayerLimit && int.TryParse(options[index], out int newLimit))
- {
- ProtocolConfig.SetTargetPlayerLimit(newLimit);
- }
- else if (isDifficultyScaling)
- {
- ProtocolConfig.SetDifficultyScalingEnabled(options[index] == "ON");
- }
- SaveModConfig();
- }
- }
-
- private static void InjectRmpSettings(NSettingsScreen screen)
- {
- NSettingsPanel generalPanel = screen.GetNode("%GeneralSettings");
- VBoxContainer vbox = generalPanel.Content;
-
- Control? anchorNode = screen.GetNodeOrNull