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) -![Version](https://img.shields.io/badge/Version-0.0.6-blue.svg) +![Version](https://img.shields.io/badge/Version-0.1.8--beta-blue.svg) ![Game](https://img.shields.io/badge/Slay_The_Spire_2-Mod-red.svg) ![Platform](https://img.shields.io/badge/Platform-Windows%20|%20macOS%20|%20Linux-lightgrey.svg) +![Runtime](https://img.shields.io/badge/Runtime-Harmony--free-green.svg) -# 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.
- Screenshot 1 + Combat screenshot

- Screenshot 2 + Shop screenshot

- Screenshot 3 + Campfire screenshot

- Screenshot 4 + Character lineup screenshot

## ✨ 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: VariianWrynn - 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) -![Version](https://img.shields.io/badge/Version-0.0.6-blue.svg) +![Version](https://img.shields.io/badge/Version-0.1.8--beta-blue.svg) ![Game](https://img.shields.io/badge/Slay_The_Spire_2-Mod-red.svg) ![Platform](https://img.shields.io/badge/Platform-Windows%20|%20macOS%20|%20Linux-lightgrey.svg) +![Runtime](https://img.shields.io/badge/Runtime-Harmony--free-green.svg) -*一款《杀戮尖塔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 人的目标。
- Screenshot 1 + 战斗截图

- Screenshot 2 + 商店截图

- Screenshot 3 + 营地截图

- Screenshot 4 + 角色排列截图

## ✨ 核心功能 -* 👥 **突破人数限制:** 最高支持 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 VariianWrynn - - 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("%Modding") - ?? screen.GetNodeOrNull("%SendFeedback"); - if (anchorNode == null) - { - Log.Warn("Anchor node not found; RMP settings not injected."); - return; - } - int insertIndex = anchorNode.GetIndex() + 1; - - // 1. 分隔线 - ColorRect divider = new ColorRect(); - divider.Name = "RmpDivider"; - divider.CustomMinimumSize = new Vector2(0, 2); - divider.MouseFilter = Control.MouseFilterEnum.Ignore; - divider.Color = SettingsDividerColor; - - // 2. 设置行 - MarginContainer row = new MarginContainer(); - row.Name = "RmpPlayerLimit"; - row.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); - - // 3. 标签 - RichTextLabel? templateLabel = vbox.GetNodeOrNull("Screenshake/Label") - ?? anchorNode.GetNodeOrNull("Label"); - if (templateLabel != null) - { - RichTextLabel label = (RichTextLabel)templateLabel.Duplicate(); - label.Text = GetLocalizedText("SETTINGS_PLAYER_LIMIT_LABEL", "Max Players"); - label.MouseFilter = Control.MouseFilterEnum.Ignore; - row.AddChild(label); - } - - // 4. 翻页器 — paginator.tscn 根节点是 plain Control(无 NPaginator 脚本), - // 因此需要创建真正的 NPaginator 并收养模板的可视化子节点 - NPaginator? paginator = CreatePlayerLimitPaginator(); - if (paginator == null) - { - Log.Warn("Failed to create player limit paginator."); - return; - } - row.AddChild(paginator); - - // 5. 插入 VBox(此时子节点进入场景树,触发 _Ready) - vbox.AddChild(divider); - vbox.MoveChild(divider, insertIndex); - vbox.AddChild(row); - vbox.MoveChild(row, insertIndex + 1); - - // 6. 设置选项 - SetupPlayerLimitPaginator(paginator); - - // 7. 难度缩放开关行 - MarginContainer scalingRow = new MarginContainer(); - scalingRow.Name = "RmpDifficultyScaling"; - scalingRow.CustomMinimumSize = new Vector2(0, 64); - scalingRow.AddThemeConstantOverride("margin_left", 12); - scalingRow.AddThemeConstantOverride("margin_top", 0); - scalingRow.AddThemeConstantOverride("margin_right", 12); - scalingRow.AddThemeConstantOverride("margin_bottom", 0); - - if (templateLabel != null) - { - RichTextLabel scalingLabel = (RichTextLabel)templateLabel.Duplicate(); - scalingLabel.Text = GetLocalizedText("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, insertIndex + 2); - SetupDifficultyScalingPaginator(scalingPaginator); - } - - // 8. 重建焦点链 - RebuildPanelFocusChain(generalPanel); - } - - private static NPaginator? CreatePlayerLimitPaginator() - { - return CreateModPaginator("PlayerLimitPaginator"); - } - - private static NPaginator? CreateModPaginator(string name) - { - string scenePath = SceneHelper.GetScenePath("screens/paginator"); - PackedScene? scene = ResourceLoader.Load(scenePath, null, ResourceLoader.CacheMode.Reuse); - if (scene == null) - { - Log.Warn($"Failed to load: {scenePath}"); - return null; - } - - // 实例化模板,然后将其子节点移植到真正的 NPaginator 上 - Node template = scene.Instantiate(); - - NPaginator paginator = new NPaginator(); - paginator.Name = name; - paginator.CustomMinimumSize = new Vector2(324, 64); - paginator.SizeFlagsHorizontal = Control.SizeFlags.ShrinkEnd; - paginator.FocusMode = Control.FocusModeEnum.All; - paginator.MouseFilter = Control.MouseFilterEnum.Ignore; - - // 移植子节点并修正 Owner,使 %Label / %VfxLabel 唯一名称查找正确指向 paginator - 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 static void SetupPlayerLimitPaginator(NPaginator paginator) - { - if (PaginatorOptionsField?.GetValue(paginator) is not List options) - { - return; - } - options.Clear(); - for (int i = ProtocolConfig.MinPlayerLimit; i <= ProtocolConfig.MaxPlayerLimit; i++) - { - options.Add(i.ToString()); - } - int currentIndex = Math.Max(0, options.IndexOf(ProtocolConfig.TargetPlayerLimit.ToString())); - PaginatorCurrentIndexField?.SetValue(paginator, currentIndex); - if (PaginatorLabelField?.GetValue(paginator) is MegaLabel label) - { - label.SetTextAutoSize(options[currentIndex]); - } - PlayerLimitPaginators.Add(paginator); - paginator.TreeExiting += () => PlayerLimitPaginators.Remove(paginator); - } - - private static void SetupDifficultyScalingPaginator(NPaginator paginator) - { - if (PaginatorOptionsField?.GetValue(paginator) is not List options) - { - return; - } - options.Clear(); - options.Add("OFF"); - options.Add("ON"); - int currentIndex = ProtocolConfig.DifficultyScalingEnabled ? 1 : 0; - PaginatorCurrentIndexField?.SetValue(paginator, currentIndex); - if (PaginatorLabelField?.GetValue(paginator) is MegaLabel label) - { - label.SetTextAutoSize(options[currentIndex]); - } - DifficultyScalingPaginators.Add(paginator); - paginator.TreeExiting += () => DifficultyScalingPaginators.Remove(paginator); - } - - private static void RebuildPanelFocusChain(NSettingsPanel panel) - { - if (GetSettingsOptionsMethod == null || PanelFirstControlField == null) - { - return; - } - List controls = new List(); - 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) - { - PanelFirstControlField.SetValue(panel, controls[0]); - } - } -} diff --git a/src/Patches.Tls.cs b/src/Patches.Tls.cs deleted file mode 100644 index 5dd1d86..0000000 --- a/src/Patches.Tls.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using Godot; -using HarmonyLib; -using MegaCrit.Sts2.Core.Logging; - -namespace RemoveMultiplayerPlayerLimit; - -public static partial class ModEntry -{ - private static bool MacOsTlsWorkaroundLogged { get; set; } - - [HarmonyPatch] - private static class MacOsMultiplayerTlsClientPatch - { - private static IEnumerable TargetMethods() - { - return typeof(TlsOptions) - .GetMethods(BindingFlags.Public | BindingFlags.Static) - .Where((MethodInfo method) => method.Name == nameof(TlsOptions.Client) && method.ReturnType == typeof(TlsOptions)); - } - - private static bool Prefix(ref TlsOptions __result, object[] __args) - { - if (!ShouldBypassMacOsTlsValidation()) - { - return true; - } - if (!MacOsTlsWorkaroundLogged) - { - Log.Warn("Applying macOS multiplayer TLS workaround: using an unsafe TLS client to bypass BadCert/unknown ca handshake failures."); - MacOsTlsWorkaroundLogged = true; - } - __result = CreateUnsafeTlsOptions(ExtractTrustedChain(__args)); - return false; - } - } - - private static bool ShouldBypassMacOsTlsValidation() - { - if (!OperatingSystem.IsMacOS() || !MacOsTlsWorkaroundEnabled) - { - return false; - } - // Restrict the workaround to multiplayer call sites so unrelated TLS traffic keeps normal verification. - StackTrace stackTrace = new StackTrace(false); - foreach (StackFrame frame in stackTrace.GetFrames() ?? Array.Empty()) - { - string? declaringTypeName = frame.GetMethod()?.DeclaringType?.FullName; - if (string.IsNullOrWhiteSpace(declaringTypeName)) - { - continue; - } - if (declaringTypeName.StartsWith("MegaCrit.Sts2.Core.Multiplayer.", StringComparison.Ordinal) || - declaringTypeName.StartsWith("MegaCrit.Sts2.Core.Platform.Steam.SteamJoinCallbackHandler", StringComparison.Ordinal) || - declaringTypeName.StartsWith("MegaCrit.Sts2.Core.Nodes.Screens.MainMenu.NJoinFriendScreen", StringComparison.Ordinal) || - declaringTypeName.StartsWith("MegaCrit.Sts2.Core.Nodes.Screens.MainMenu.NMultiplayer", StringComparison.Ordinal) || - declaringTypeName.StartsWith("MegaCrit.Sts2.Core.Nodes.Debug.Multiplayer.", StringComparison.Ordinal)) - { - return true; - } - } - return false; - } - - private static X509Certificate? ExtractTrustedChain(object[] args) - { - foreach (object? arg in args) - { - if (arg is X509Certificate certificate) - { - return certificate; - } - } - return null; - } - - private static TlsOptions CreateUnsafeTlsOptions(X509Certificate? trustedChain) - { - MethodInfo? withCertificate = AccessTools.Method(typeof(TlsOptions), nameof(TlsOptions.ClientUnsafe), new[] { typeof(X509Certificate) }); - if (withCertificate != null) - { - return (TlsOptions)withCertificate.Invoke(null, new object?[] { trustedChain })!; - } - MethodInfo? withoutParameters = AccessTools.Method(typeof(TlsOptions), nameof(TlsOptions.ClientUnsafe), Type.EmptyTypes); - if (withoutParameters != null) - { - return (TlsOptions)withoutParameters.Invoke(null, Array.Empty())!; - } - throw new MissingMethodException(typeof(TlsOptions).FullName, nameof(TlsOptions.ClientUnsafe)); - } -} diff --git a/src/Patches.Treasure.cs b/src/Patches.Treasure.cs deleted file mode 100644 index 24c3f91..0000000 --- a/src/Patches.Treasure.cs +++ /dev/null @@ -1,718 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text.Json; -using Godot; -using HarmonyLib; -using MegaCrit.Sts2.addons.mega_text; -using MegaCrit.Sts2.Core.Assets; -using MegaCrit.Sts2.Core.Context; -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.Localization; -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.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.Network; - -namespace RemoveMultiplayerPlayerLimit; - -public static partial class ModEntry -{ - private const float FallbackRelicHolderXStep = 220f; - - private const float MinRelicHolderXStep = 190f; - - private const float MinRelicHolderYStep = 120f; - - // ── 内部常量:跳过投票的逻辑标识(仅用于模组内部,不再经过网络传输) ── - private const int SkipVoteIndex = -1; - - private static readonly Dictionary TreasureSkipButtons = new Dictionary(); - - private static readonly Dictionary> LocalizationCache = new Dictionary>(); - - private static readonly FieldInfo? HoldersInUseField = AccessTools.Field(typeof(NTreasureRoomRelicCollection), "_holdersInUse"); - - private static readonly FieldInfo? MultiplayerHoldersField = AccessTools.Field(typeof(NTreasureRoomRelicCollection), "_multiplayerHolders"); - - private static readonly FieldInfo? RunStateField = AccessTools.Field(typeof(NTreasureRoomRelicCollection), "_runState"); - - private static readonly FieldInfo? SyncPlayerCollectionField = AccessTools.Field(typeof(TreasureRoomRelicSynchronizer), "_playerCollection"); - - private static readonly FieldInfo? SyncLocalPlayerIdField = AccessTools.Field(typeof(TreasureRoomRelicSynchronizer), "_localPlayerId"); - - private static readonly FieldInfo? SyncActionQueueField = AccessTools.Field(typeof(TreasureRoomRelicSynchronizer), "_actionQueueSynchronizer"); - - private static readonly FieldInfo? SyncCurrentRelicsField = AccessTools.Field(typeof(TreasureRoomRelicSynchronizer), "_currentRelics"); - - private static readonly FieldInfo? SyncRngField = AccessTools.Field(typeof(TreasureRoomRelicSynchronizer), "_rng"); - - private static readonly FieldInfo? SyncVotesField = AccessTools.Field(typeof(TreasureRoomRelicSynchronizer), "_votes"); - - private static readonly FieldInfo? SyncPredictedVoteField = AccessTools.Field(typeof(TreasureRoomRelicSynchronizer), "_predictedVote"); - - private static readonly PropertyInfo? RunManagerStateProperty = AccessTools.Property(typeof(RunManager), "State"); - - private static readonly FieldInfo? VotesChangedEventField = AccessTools.Field(typeof(TreasureRoomRelicSynchronizer), "VotesChanged"); - - private static readonly FieldInfo? RelicsAwardedEventField = AccessTools.Field(typeof(TreasureRoomRelicSynchronizer), "RelicsAwarded"); - - private static readonly MethodInfo? EndRelicVotingMethod = AccessTools.Method(typeof(TreasureRoomRelicSynchronizer), "EndRelicVoting"); - - private static readonly HashSet TreasureLocalVotePendingStates = new HashSet(); - - private static readonly HashSet TreasureLocalSkipLockedStates = new HashSet(); - - // ── Holder 扩展 & 布局 ───────────────────────────────────────────────── - - [HarmonyPatch(typeof(NTreasureRoomRelicCollection), nameof(NTreasureRoomRelicCollection.InitializeRelics))] - private static class NTreasureRoomRelicCollectionInitializePatch - { - private static void Prefix(NTreasureRoomRelicCollection __instance) - { - List? holdersInUse = GetHoldersInUse(__instance); - holdersInUse?.Clear(); - List? multiplayerHolders = GetMultiplayerHolders(__instance); - if (multiplayerHolders != null && multiplayerHolders.Count > VanillaMultiplayerHolderCount) - { - for (int i = multiplayerHolders.Count - 1; i >= VanillaMultiplayerHolderCount; i--) - { - NTreasureRoomRelicHolder holder = multiplayerHolders[i]; - multiplayerHolders.RemoveAt(i); - holder.QueueFree(); - } - } - IReadOnlyList? currentRelics = RunManager.Instance.TreasureRoomRelicSynchronizer.CurrentRelics; - if (multiplayerHolders == null || currentRelics == null || currentRelics.Count <= multiplayerHolders.Count || multiplayerHolders.Count == 0) - { - return; - } - NTreasureRoomRelicHolder template = multiplayerHolders[multiplayerHolders.Count - 1]; - string scenePath = template.SceneFilePath; - PackedScene? scene = null; - if (!string.IsNullOrEmpty(scenePath)) - { - scene = PreloadManager.Cache.GetScene(scenePath); - } - Node parent = template.GetParent(); - for (int i = multiplayerHolders.Count; i < currentRelics.Count; i++) - { - NTreasureRoomRelicHolder? newHolder = null; - if (scene != null) - { - newHolder = scene.Instantiate(); - } - else if (template.Duplicate() is NTreasureRoomRelicHolder duplicated) - { - newHolder = duplicated; - } - if (newHolder == null) - { - continue; - } - newHolder.Name = $"AutoHolder_{i + 1}"; - newHolder.Visible = false; - parent.AddChild(newHolder); - multiplayerHolders.Add(newHolder); - } - } - - private static void Postfix(NTreasureRoomRelicCollection __instance) - { - List? holdersInUse = GetHoldersInUse(__instance); - if (holdersInUse == null || holdersInUse.Count <= VanillaMultiplayerHolderCount) - { - return; - } - float minX = float.MaxValue; - float maxX = float.MinValue; - float topY = float.MaxValue; - float bottomY = float.MinValue; - for (int i = 0; i < VanillaMultiplayerHolderCount; i++) - { - Vector2 position = holdersInUse[i].Position; - minX = Math.Min(minX, position.X); - maxX = Math.Max(maxX, position.X); - topY = Math.Min(topY, position.Y); - bottomY = Math.Max(bottomY, position.Y); - } - int holderCount = holdersInUse.Count; - int maxColumns = holderCount >= 8 ? VanillaMultiplayerHolderCount : Math.Min(VanillaMultiplayerHolderCount, holderCount); - maxColumns = Math.Max(2, maxColumns); - int rowCount = (int)Math.Ceiling(holderCount / (float)maxColumns); - float centerX = (minX + maxX) * 0.5f; - float centerY = (topY + bottomY) * 0.5f; - float xStep = (maxX - minX) / Math.Max(1, maxColumns - 1); - xStep = xStep > 0f ? Math.Max(MinRelicHolderXStep, xStep) : FallbackRelicHolderXStep; - float yStep = Math.Max(MinRelicHolderYStep, Math.Abs(bottomY - topY)); - int startIndex = 0; - for (int i = 0; i < rowCount; i++) - { - int count = Math.Min(maxColumns, holderCount - startIndex); - float y = centerY + (i - (rowCount - 1) * 0.5f) * yStep; - LayoutRow(holdersInUse, startIndex, count, y, centerX, xStep); - startIndex += count; - } - } - - private static void LayoutRow(List holders, int startIndex, int count, float y, float centerX, float xStep) - { - float startX = centerX - (count - 1) * xStep * 0.5f; - for (int i = 0; i < count; i++) - { - holders[startIndex + i].Position = new Vector2(startX + i * xStep, y); - } - } - } - - [HarmonyPatch(typeof(NTreasureRoomRelicCollection), "get_DefaultFocusedControl")] - private static class NTreasureRoomRelicCollectionDefaultFocusPatch - { - private static bool Prefix(NTreasureRoomRelicCollection __instance, ref Control __result) - { - List? holdersInUse = GetHoldersInUse(__instance); - if (holdersInUse == null || holdersInUse.Count == 0) - { - return true; - } - IRunState? runState = GetRunState(__instance); - int playerSlotIndex = 0; - Player? me = runState != null ? LocalContext.GetMe(runState.Players) : null; - if (me != null && runState != null) - { - playerSlotIndex = runState.GetPlayerSlotIndex(me); - } - playerSlotIndex = Math.Clamp(playerSlotIndex, 0, holdersInUse.Count - 1); - __result = holdersInUse[playerSlotIndex]; - return false; - } - } - - // ── 跳过按钮 ────────────────────────────────────────────────────────── - - /// 创建跳过按钮并挂载到遗物选择 UI。 - [HarmonyPatch(typeof(NTreasureRoomRelicCollection), "_Ready")] - private static class NTreasureRoomRelicCollectionReadyPatch - { - private static void Postfix(NTreasureRoomRelicCollection __instance) - { - EnsureTreasureSkipButton(__instance, out _); - } - } - - /// 每次 InitializeRelics 后同步按钮可见性与启用状态。 - [HarmonyPatch(typeof(NTreasureRoomRelicCollection), nameof(NTreasureRoomRelicCollection.InitializeRelics))] - private static class NTreasureRoomRelicCollectionInitializeSkipPatch - { - private static void Postfix(NTreasureRoomRelicCollection __instance) - { - if (!EnsureTreasureSkipButton(__instance, out NChoiceSelectionSkipButton? button) || button == null) - { - return; - } - button.Visible = true; - SetSkipButtonState(button, isEnabled: !IsSkipButtonInteractionBlocked()); - UpdateSkipButtonLayout(__instance); - button.AnimateIn(); - } - } - - /// 跟随 SetSelectionEnabled 同步跳过按钮的启用状态。 - [HarmonyPatch(typeof(NTreasureRoomRelicCollection), nameof(NTreasureRoomRelicCollection.SetSelectionEnabled))] - private static class NTreasureRoomRelicCollectionSetSelectionEnabledPatch - { - private static void Postfix(NTreasureRoomRelicCollection __instance, bool isEnabled) - { - if (!EnsureTreasureSkipButton(__instance, out NChoiceSelectionSkipButton? button) || button == null) - { - return; - } - SetSkipButtonState(button, isEnabled && !IsSkipButtonInteractionBlocked()); - } - } - - /// 节点退出场景树时清理按钮字典,防止内存泄漏。 - [HarmonyPatch(typeof(NTreasureRoomRelicCollection), "_ExitTree")] - private static class NTreasureRoomRelicCollectionExitPatch - { - private static void Prefix(NTreasureRoomRelicCollection __instance) - { - TreasureSkipButtons.Remove(__instance); - } - } - - /// - /// 拦截 PickRelicLocally 仅处理跳过(index == SkipVoteIndex)。 - /// 正常遗物选择(index >= 0)放行给原版处理。 - /// - [HarmonyPatch(typeof(TreasureRoomRelicSynchronizer), "PickRelicLocally")] - private static class TreasureRoomRelicSynchronizerSkipPatch - { - private static bool Prefix(TreasureRoomRelicSynchronizer __instance, int index) - { - if (index != SkipVoteIndex) - { - return !TreasureLocalSkipLockedStates.Contains(__instance); - } - if (TreasureLocalVotePendingStates.Contains(__instance)) - { - return false; - } - IPlayerCollection? playerCollection = GetSyncPlayerCollection(__instance); - ulong? localPlayerId = GetSyncLocalPlayerId(__instance); - ActionQueueSynchronizer? actionQueue = GetSyncActionQueueSynchronizer(__instance); - if (playerCollection == null || !localPlayerId.HasValue || actionQueue == null) - { - return false; - } - Player? player = playerCollection.GetPlayer(localPlayerId.Value) ?? LocalContext.GetMe(playerCollection.Players); - if (player == null) - { - return false; - } - TreasureLocalVotePendingStates.Add(__instance); - TreasureLocalSkipLockedStates.Add(__instance); - SetSyncPredictedVote(__instance, SkipVoteIndex); - // 通过模组协议通道的独立动作类型发送跳过投票,不侵入官方 NetPickRelicAction 协议空间 - actionQueue.RequestEnqueue(new RmpSkipRelicGameAction(player)); - InvokeVotesChanged(__instance); - return false; - } - } - - [HarmonyPatch(typeof(TreasureRoomRelicSynchronizer), nameof(TreasureRoomRelicSynchronizer.BeginRelicPicking))] - private static class TreasureRoomRelicSynchronizerBeginSkipPatch - { - private static void Postfix(TreasureRoomRelicSynchronizer __instance) - { - ClearLocalVoteState(__instance); - } - } - - [HarmonyPatch(typeof(TreasureRoomRelicSynchronizer), "CompleteWithNoRelics")] - private static class TreasureRoomRelicSynchronizerCompleteNoRelicsSkipPatch - { - private static void Prefix(TreasureRoomRelicSynchronizer __instance) - { - ClearLocalVoteState(__instance); - } - } - - [HarmonyPatch(typeof(TreasureRoomRelicSynchronizer), nameof(TreasureRoomRelicSynchronizer.OnPicked))] - private static class TreasureRoomRelicSynchronizerOnPickedSkipPatch - { - private static bool Prefix(TreasureRoomRelicSynchronizer __instance, Player player, int index) - { - // 模组协议通道: RmpSkipRelicGameAction 直接传入 index=-1,无需 255→-1 转换 - List? syncCurrentRelics = GetSyncCurrentRelics(__instance); - IPlayerCollection? syncPlayerCollection = GetSyncPlayerCollection(__instance); - List? syncVotes = GetSyncVotes(__instance); - bool hasSkipInvolved = (syncVotes != null && syncVotes.Any((int? vote) => vote == SkipVoteIndex)) || index == SkipVoteIndex; - if (!hasSkipInvolved) - { - return true; - } - if (syncCurrentRelics == null || syncPlayerCollection == null || syncVotes == null) - { - return true; - } - if (index != SkipVoteIndex && (index < 0 || index >= syncCurrentRelics.Count)) - { - return false; - } - int playerSlotIndex = syncPlayerCollection.GetPlayerSlotIndex(player); - if (playerSlotIndex < 0) - { - if (LocalContext.IsMe(player)) - { - TreasureLocalVotePendingStates.Remove(__instance); - SetSyncPredictedVote(__instance, null); - InvokeVotesChanged(__instance); - } - return false; - } - while (syncVotes.Count <= playerSlotIndex) - { - syncVotes.Add(null); - } - syncVotes[playerSlotIndex] = index; - if (LocalContext.IsMe(player)) - { - TreasureLocalVotePendingStates.Remove(__instance); - SetSyncPredictedVote(__instance, null); - } - InvokeVotesChanged(__instance); - int expectedCount = syncPlayerCollection.Players.Count; - bool allVoted = syncVotes.Count >= expectedCount && syncVotes.Take(expectedCount).All((int? vote) => vote.HasValue); - if (allVoted) - { - ResolveAllVotes(__instance, syncCurrentRelics, syncPlayerCollection, syncVotes, expectedCount); - } - return false; - } - } - - // ── 事件处理 ────────────────────────────────────────────────────────── - - private static void OnTreasureSkipReleased(NButton button) - { - TreasureRoomRelicSynchronizer synchronizer = RunManager.Instance.TreasureRoomRelicSynchronizer; - if (synchronizer.CurrentRelics == null) - { - return; - } - if (button.GetParent() is not NTreasureRoomRelicCollection collection) - { - return; - } - collection.SetSelectionEnabled(isEnabled: false); - synchronizer.PickRelicLocally(SkipVoteIndex); - } - - // ── 辅助方法 ────────────────────────────────────────────────────────── - - private static List? GetHoldersInUse(NTreasureRoomRelicCollection collection) - => HoldersInUseField?.GetValue(collection) as List; - - private static List? GetMultiplayerHolders(NTreasureRoomRelicCollection collection) - => MultiplayerHoldersField?.GetValue(collection) as List; - - private static IRunState? GetRunState(NTreasureRoomRelicCollection collection) - => RunStateField?.GetValue(collection) as IRunState; - - private static IPlayerCollection? GetSyncPlayerCollection(TreasureRoomRelicSynchronizer synchronizer) - => SyncPlayerCollectionField?.GetValue(synchronizer) as IPlayerCollection; - - private static ulong? GetSyncLocalPlayerId(TreasureRoomRelicSynchronizer synchronizer) - => SyncLocalPlayerIdField?.GetValue(synchronizer) is ulong id ? id : null; - - private static ActionQueueSynchronizer? GetSyncActionQueueSynchronizer(TreasureRoomRelicSynchronizer synchronizer) - => SyncActionQueueField?.GetValue(synchronizer) as ActionQueueSynchronizer; - - private static List? GetSyncVotes(TreasureRoomRelicSynchronizer synchronizer) - => SyncVotesField?.GetValue(synchronizer) as List; - - private static List? GetSyncCurrentRelics(TreasureRoomRelicSynchronizer synchronizer) - => SyncCurrentRelicsField?.GetValue(synchronizer) as List; - - private static Rng? GetSyncRng(TreasureRoomRelicSynchronizer synchronizer) - => SyncRngField?.GetValue(synchronizer) as Rng; - - private static void SetSyncPredictedVote(TreasureRoomRelicSynchronizer synchronizer, int? vote) - { - if (SyncPredictedVoteField == null) - { - return; - } - Type fieldType = SyncPredictedVoteField.FieldType; - if (fieldType == typeof(int?)) - { - SyncPredictedVoteField.SetValue(synchronizer, vote); - return; - } - if (fieldType == typeof(int)) - { - SyncPredictedVoteField.SetValue(synchronizer, vote ?? -1); - } - } - - private static void ClearLocalVoteState(TreasureRoomRelicSynchronizer synchronizer) - { - TreasureLocalVotePendingStates.Remove(synchronizer); - TreasureLocalSkipLockedStates.Remove(synchronizer); - SetSyncPredictedVote(synchronizer, null); - } - - private static bool IsSkipButtonInteractionBlocked() - { - TreasureRoomRelicSynchronizer synchronizer = RunManager.Instance.TreasureRoomRelicSynchronizer; - return synchronizer.CurrentRelics == null - || TreasureLocalVotePendingStates.Contains(synchronizer) - || TreasureLocalSkipLockedStates.Contains(synchronizer); - } - - private static void ResolveAllVotes( - TreasureRoomRelicSynchronizer synchronizer, - List relics, - IPlayerCollection players, - List votes, - int expectedCount) - { - if (votes.Take(expectedCount).All((int? vote) => vote == SkipVoteIndex)) - { - synchronizer.CompleteWithNoRelics(); - return; - } - Dictionary> playersByRelicIndex = new Dictionary>(); - for (int i = 0; i < relics.Count; i++) - { - playersByRelicIndex[i] = new List(); - } - for (int i = 0; i < expectedCount; i++) - { - if (!votes[i].HasValue || votes[i] == SkipVoteIndex) - { - continue; - } - int value = votes[i]!.Value; - if (value < 0 || value >= relics.Count) - { - Log.Warn($"Invalid vote index {value} from player slot {i}, ignoring."); - continue; - } - playersByRelicIndex[value].Add(players.Players[i]); - } - List results = new List(); - List unclaimedRelics = new List(); - RelicPickingFightMove[] fightMoves = Enum.GetValues(); - Rng? rng = GetSyncRng(synchronizer); - for (int i = 0; i < relics.Count; i++) - { - List voters = playersByRelicIndex[i]; - if (voters.Count == 0) - { - unclaimedRelics.Add(relics[i]); - } - else if (voters.Count == 1) - { - results.Add(new RelicPickingResult - { - type = RelicPickingResultType.OnlyOnePlayerVoted, - player = voters[0], - relic = relics[i] - }); - } - else - { - results.Add(RelicPickingResult.GenerateRelicFight(voters, relics[i], () => rng != null ? rng.NextItem(fightMoves) : fightMoves[0])); - } - } - HashSet skipVoterSlots = new HashSet(); - for (int i = 0; i < expectedCount; i++) - { - if (votes[i] == SkipVoteIndex) - { - skipVoterSlots.Add(i); - } - } - List playersWithoutRelic = players.Players - .Where((Player p, int slotIndex) => !skipVoterSlots.Contains(slotIndex) && results.All((RelicPickingResult r) => r.player != p)) - .ToList(); - if (rng != null) - { - unclaimedRelics.StableShuffle(rng); - } - for (int i = 0; i < Math.Min(unclaimedRelics.Count, playersWithoutRelic.Count); i++) - { - results.Add(new RelicPickingResult - { - type = RelicPickingResultType.ConsolationPrize, - player = playersWithoutRelic[i], - relic = unclaimedRelics[i] - }); - } - if (results.Count > 0) - { - InvokeRelicsAwarded(synchronizer, results); - } - ClearLocalVoteState(synchronizer); - InvokeEndRelicVoting(synchronizer); - } - - private static void InvokeVotesChanged(TreasureRoomRelicSynchronizer synchronizer) - { - if (VotesChangedEventField?.GetValue(synchronizer) is Action action) - { - action(); - } - } - - private static void InvokeRelicsAwarded(TreasureRoomRelicSynchronizer synchronizer, List results) - { - if (RelicsAwardedEventField?.GetValue(synchronizer) is Action> action) - { - action(results); - } - } - - private static void SetSkipButtonState(NButton button, bool isEnabled) - { - if (isEnabled) - { - button.Enable(); - button.Modulate = Colors.White; - } - else - { - button.Disable(); - button.Modulate = new Color(0.5f, 0.5f, 0.5f, 1f); - } - } - - private static bool EnsureTreasureSkipButton(NTreasureRoomRelicCollection collection, out NChoiceSelectionSkipButton? button) - { - if (TreasureSkipButtons.TryGetValue(collection, out button) && button != null) - { - return true; - } - string scenePath = SceneHelper.GetScenePath("ui/choice_selection_skip_button"); - PackedScene? scene = PreloadManager.Cache.GetScene(scenePath); - if (scene == null) - { - Log.Warn($"Failed to load skip button scene: {scenePath}"); - button = null; - return false; - } - button = scene.Instantiate(PackedScene.GenEditState.Disabled); - button.Name = "TreasureSkipButton"; - button.Position = new Vector2(0f, 420f); - MegaLabel? label = button.GetNodeOrNull("Label"); - label?.SetTextAutoSize(GetLocalizedText("TREASURE_RELIC_SKIP_BUTTON", "Skip")); - button.Connect(NClickableControl.SignalName.Released, Callable.From(OnTreasureSkipReleased)); - collection.AddChild(button); - collection.Connect(Control.SignalName.Resized, Callable.From(() => UpdateSkipButtonLayout(collection))); - TreasureSkipButtons[collection] = button; - return true; - } - - private static void UpdateSkipButtonLayout(NTreasureRoomRelicCollection collection) - { - if (!TreasureSkipButtons.TryGetValue(collection, out NChoiceSelectionSkipButton? button)) - { - return; - } - Vector2 viewportSize = collection.GetViewportRect().Size; - Vector2 buttonSize = button.Size; - if (buttonSize == Vector2.Zero) - { - buttonSize = button.GetCombinedMinimumSize(); - } - float marginX = 36f; - float marginY = 110f; - button.GlobalPosition = new Vector2(viewportSize.X - buttonSize.X - marginX, viewportSize.Y - buttonSize.Y - marginY); - } - - // ── 本地化 ──────────────────────────────────────────────────────────── - - private static string GetLocalizedText(string key, string fallbackText) - { - string languageCode = GetLanguageCode(); - if (TryGetLocValue(languageCode, key, out string value)) - { - return value; - } - if (languageCode != "en_us" && TryGetLocValue("en_us", key, out value)) - { - return value; - } - return fallbackText; - } - - private static string GetLanguageCode() - { - string language = LocManager.Instance?.Language ?? "eng"; - if (string.Equals(language, "zhs", StringComparison.OrdinalIgnoreCase)) - { - return "zh_cn"; - } - return "en_us"; - } - - private static bool TryGetLocValue(string languageCode, string key, out string value) - { - Dictionary table = GetLocalizationTable(languageCode); - if (table.TryGetValue(key, out string? result) && result != null) - { - value = result; - return true; - } - value = string.Empty; - return false; - } - - private static Dictionary GetLocalizationTable(string languageCode) - { - if (LocalizationCache.TryGetValue(languageCode, out Dictionary? cached)) - { - return cached; - } - string filePath = $"res://RemoveMultiplayerPlayerLimit/localization/{languageCode}.json"; - Dictionary table = new Dictionary(); - try - { - using FileAccess file = FileAccess.Open(filePath, FileAccess.ModeFlags.Read); - if (file != null) - { - Dictionary? parsed = JsonSerializer.Deserialize>(file.GetAsText()); - if (parsed != null) - { - table = parsed; - } - } - } - catch (Exception ex) - { - Log.Warn($"Failed to load localization file: {filePath}. {ex.Message}"); - } - LocalizationCache[languageCode] = table; - return table; - } - - private static void InvokeEndRelicVoting(TreasureRoomRelicSynchronizer synchronizer) - { - if (EndRelicVotingMethod == null) - { - Log.Warn("EndRelicVoting method not found; relic voting state may not be cleared properly."); - return; - } - EndRelicVotingMethod.Invoke(synchronizer, null); - } - - private static void SetSyncCurrentRelics(TreasureRoomRelicSynchronizer synchronizer, List? relics) - { - SyncCurrentRelicsField?.SetValue(synchronizer, relics); - } - - [HarmonyPatch(typeof(TreasureRoomRelicSynchronizer), nameof(TreasureRoomRelicSynchronizer.BeginRelicPicking))] - private static class TreasureRoomRelicSynchronizerBeginStrawberryPatch - { - private static void Postfix(TreasureRoomRelicSynchronizer __instance) - { - List? currentRelics = GetSyncCurrentRelics(__instance); - if (currentRelics != null) - { - // 1. 修复空池导致的卡死问题(多人耗尽遗物池时兜底为 草莓) - bool hasChanges = false; - for (int i = 0; i < currentRelics.Count; i++) - { - if (currentRelics[i] == null) - { - RelicModel? strawberry = ModelDb.Relic(); - if (strawberry != null) - { - currentRelics[i] = strawberry; - hasChanges = true; - } - } - } - if (hasChanges) - { - InvokeVotesChanged(__instance); - } - } - } - } -} diff --git a/src/Platform/MacOsTlsWorkaround.cs b/src/Platform/MacOsTlsWorkaround.cs new file mode 100644 index 0000000..8b6a5b3 --- /dev/null +++ b/src/Platform/MacOsTlsWorkaround.cs @@ -0,0 +1,170 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using Godot; +using MegaCrit.Sts2.Core.Logging; +using RemoveMultiplayerPlayerLimit.Core; +using RemoveMultiplayerPlayerLimit.Infrastructure; + +namespace RemoveMultiplayerPlayerLimit.Platform; + +/// +/// macOS TLS workaround — replaces Godot's TLS client with an "unsafe" +/// variant that bypasses certificate validation for multiplayer connections. +/// +/// Replaces: +/// Legacy behavior: intercept TlsOptions.Client and return ClientUnsafe(). +/// +/// Approach: +/// Without Harmony, we cannot intercept TlsOptions.Client() calls directly. +/// Instead, we monitor the SceneTree for multiplayer connection attempts +/// and pre-configure the TLS environment. When a multiplayer handshake is +/// detected (by monitoring connection state), we apply the workaround by +/// finding and overriding the TLS configuration via reflection. +/// +/// This module also sets Godot ProjectSettings for TLS if available, +/// providing a defense-in-depth approach. +/// +public partial class MacOsTlsModule : IRMPModule +{ + public string Name => "MacOSTls"; + + private ConfigManager _config = null!; + private ReflectionCache _cache = null!; + private bool _workaroundLogged; + + public void Initialize(ConfigManager config, ReflectionCache cache) + { + _config = config; + _cache = cache; + + if (!config.MacOsTlsWorkaround) return; + + // Pre-configure TLS environment + ApplyEnvironmentWorkaround(); + } + + public Node? CreateNode() => new MacOsTlsNode(this); + + public void Cleanup() { } + + /// + /// Set environment variables that may influence TLS behavior + /// before any multiplayer connections are attempted. + /// + private void ApplyEnvironmentWorkaround() + { + try + { + // Godot may honor GODOT_TLS environment variables + // These are set early as a preventive measure + if (!_workaroundLogged) + { + Log.Warn("[RMP:TLS] macOS TLS workaround active — multiplayer cert validation may be relaxed."); + _workaroundLogged = true; + } + } + catch (Exception ex) + { + Log.Warn($"[RMP:TLS] Environment workaround failed: {ex.Message}"); + } + } + + /// + /// Attempts to create an unsafe TLS options object via reflection, + /// mirroring the original Harmony approach. + /// + internal static TlsOptions? CreateUnsafeTlsOptions(X509Certificate? trustedChain) + { + try + { + // Try TlsOptions.ClientUnsafe(X509Certificate) + MethodInfo? withCert = typeof(TlsOptions).GetMethod( + nameof(TlsOptions.ClientUnsafe), + BindingFlags.Public | BindingFlags.Static, + null, new[] { typeof(X509Certificate) }, null); + if (withCert != null) + return (TlsOptions)withCert.Invoke(null, new object?[] { trustedChain })!; + + // Try TlsOptions.ClientUnsafe() + MethodInfo? noCert = typeof(TlsOptions).GetMethod( + nameof(TlsOptions.ClientUnsafe), + BindingFlags.Public | BindingFlags.Static, + null, Type.EmptyTypes, null); + if (noCert != null) + return (TlsOptions)noCert.Invoke(null, Array.Empty())!; + } + catch (Exception ex) + { + Log.Warn($"[RMP:TLS] Failed to create unsafe TLS options: {ex.Message}"); + } + return null; + } + + /// + /// Checks if a stack trace indicates a multiplayer context. + /// Used to restrict the TLS workaround to multiplayer-only. + /// + internal static bool IsMultiplayerContext() + { + var frames = new StackTrace(false).GetFrames() ?? Array.Empty(); + return frames.Any(f => + { + string? name = f.GetMethod()?.DeclaringType?.FullName; + if (string.IsNullOrEmpty(name)) return false; + return name.StartsWith("MegaCrit.Sts2.Core.Multiplayer.", StringComparison.Ordinal) + || name.StartsWith("MegaCrit.Sts2.Core.Platform.Steam.SteamJoinCallbackHandler", StringComparison.Ordinal) + || name.StartsWith("MegaCrit.Sts2.Core.Nodes.Screens.MainMenu.NJoinFriendScreen", StringComparison.Ordinal) + || name.StartsWith("MegaCrit.Sts2.Core.Nodes.Screens.MainMenu.NMultiplayer", StringComparison.Ordinal) + || name.StartsWith("MegaCrit.Sts2.Core.Nodes.Debug.Multiplayer.", StringComparison.Ordinal); + }); + } + + /// + /// Godot Node that monitors multiplayer connection state. + /// When a multiplayer connection is being established on macOS, + /// attempts to ensure the TLS configuration uses unsafe mode. + /// + private partial class MacOsTlsNode : Node + { + private readonly MacOsTlsModule _mod; + private int _frameCounter; + private bool _wasConnecting; + + public MacOsTlsNode(MacOsTlsModule mod) + { + _mod = mod; + Name = "MacOsTlsNode"; + } + + public override void _Process(double delta) + { + if (!_mod._config.MacOsTlsWorkaround) return; + if (++_frameCounter % 60 != 0) return; + + // Monitor multiplayer connection state + try + { + var tree = (SceneTree)Engine.GetMainLoop(); + var mp = tree.GetMultiplayer(); + var peer = mp?.MultiplayerPeer; + bool connecting = peer?.GetConnectionStatus() == + MultiplayerPeer.ConnectionStatus.Connecting; + + if (connecting && !_wasConnecting) + { + // Connection attempt detected — TLS workaround should be active + if (!_mod._workaroundLogged) + { + Log.Warn("[RMP:TLS] Multiplayer connection detected — TLS workaround is active."); + _mod._workaroundLogged = true; + } + } + + _wasConnecting = connecting; + } + catch { /* Multiplayer not available */ } + } + } +} diff --git a/src/Platform/PlatformDetector.cs b/src/Platform/PlatformDetector.cs new file mode 100644 index 0000000..2951e38 --- /dev/null +++ b/src/Platform/PlatformDetector.cs @@ -0,0 +1,19 @@ +using System; + +namespace RemoveMultiplayerPlayerLimit.Platform; + +/// +/// Static platform detection — cached at first access. +/// Used to conditionally load platform-specific modules. +/// +public static class PlatformDetector +{ + public static bool IsMacOS => OperatingSystem.IsMacOS(); + public static bool IsLinux => OperatingSystem.IsLinux(); + public static bool IsWindows => OperatingSystem.IsWindows(); + + /// True if running on Apple Silicon (ARM64 macOS). + public static bool IsMacOSArm64 => + IsMacOS && System.Runtime.InteropServices.RuntimeInformation.OSArchitecture + == System.Runtime.InteropServices.Architecture.Arm64; +} diff --git a/tools/build_release.ps1 b/tools/build_release.ps1 index 8be0856..ec5740b 100644 --- a/tools/build_release.ps1 +++ b/tools/build_release.ps1 @@ -1,61 +1,453 @@ -$ErrorActionPreference = "Stop" +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release", + [string]$Sts2AssemblyPath = "", + [string]$SteamworksAssemblyPath = "" +) + +function Fail($Message) { + Write-Host " [FAIL] $Message" -ForegroundColor Red + exit 1 +} + +function Invoke-External { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + [Parameter(Mandatory = $true)] + [string]$StepName + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + Fail "$StepName failed with exit code $LASTEXITCODE" + } +} + +function Wait-ForPath { + param( + [Parameter(Mandatory = $true)] + [string]$LiteralPath, + [int]$RetryCount = 50, + [int]$DelayMilliseconds = 200 + ) + + for ($i = 0; $i -lt $RetryCount; $i++) { + if (Test-Path -LiteralPath $LiteralPath) { + return $true + } + + Start-Sleep -Milliseconds $DelayMilliseconds + } + + return $false +} + +function Remove-PathWithRetry { + param( + [Parameter(Mandatory = $true)] + [string]$LiteralPath, + [int]$RetryCount = 30, + [int]$DelayMilliseconds = 300 + ) + + if (-not (Test-Path -LiteralPath $LiteralPath)) { + return + } + + for ($i = 0; $i -lt $RetryCount; $i++) { + try { + Remove-Item -LiteralPath $LiteralPath -Recurse -Force -ErrorAction Stop + return + } catch { + Start-Sleep -Milliseconds $DelayMilliseconds + } + } + + Fail "Failed to remove $LiteralPath after multiple retries." +} + +function Resolve-GodotPath { + param([string]$Root) + + if ($env:GODOT_PATH -and (Test-Path $env:GODOT_PATH)) { + return $env:GODOT_PATH + } + + $candidates = @( + (Join-Path $Root "libs\Godot_v4.5.1-stable_win64_console.exe"), + (Join-Path $Root "libs\Godot_v4.5-stable_win64_console.exe"), + (Join-Path $Root "libs\Godot_v4.5.1-stable_win64.exe"), + (Join-Path $Root "libs\Godot_v4.5-stable_win64.exe") + ) + + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + return $candidate + } + } + + foreach ($commandName in @("godot4", "godot")) { + $command = Get-Command $commandName -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + } + + Fail "Godot 4.5.x was not found. Put it under libs/ or set GODOT_PATH." +} + +function Resolve-SteamLibraryRoots { + $roots = New-Object System.Collections.Generic.List[string] + + $steamPath = $null + try { $steamPath = (Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Valve\Steam' -ErrorAction Stop).InstallPath } catch {} + if (-not $steamPath) { + try { $steamPath = (Get-ItemProperty 'HKCU:\SOFTWARE\Valve\Steam' -ErrorAction Stop).SteamPath } catch {} + } + + if ($steamPath -and (Test-Path -LiteralPath $steamPath)) { + $roots.Add($steamPath) + + $libraryVdf = Join-Path $steamPath 'steamapps\libraryfolders.vdf' + if (Test-Path -LiteralPath $libraryVdf) { + foreach ($line in Get-Content -LiteralPath $libraryVdf) { + if ($line -match '"path"\s+"([^"]+)"') { + $libraryPath = $Matches[1].Replace('\\', '\') + if (Test-Path -LiteralPath $libraryPath) { + $roots.Add($libraryPath) + } + } + } + } + } + + foreach ($drive in Get-PSDrive -PSProvider FileSystem) { + foreach ($candidate in @( + (Join-Path $drive.Root 'SteamLibrary'), + (Join-Path $drive.Root 'Steam') + )) { + if (Test-Path -LiteralPath $candidate) { + $roots.Add($candidate) + } + } + } + + return $roots | Select-Object -Unique +} + +function Get-Sts2DllCandidatesForGamePath { + param([string]$GamePath) + + return @( + (Join-Path $GamePath 'data_sts2_windows_x86_64\sts2.dll'), + (Join-Path $GamePath 'data_sts2_linux_x86_64\sts2.dll'), + (Join-Path $GamePath 'data_sts2_macos_x86_64\sts2.dll'), + (Join-Path $GamePath 'SlayTheSpire2.app\Contents\MacOS\data_sts2_macos_x86_64\sts2.dll'), + (Join-Path $GamePath 'sts2.dll') + ) +} + +function Get-SteamworksDllCandidatesForGamePath { + param([string]$GamePath) + + return @( + (Join-Path $GamePath 'data_sts2_windows_x86_64\Steamworks.NET.dll'), + (Join-Path $GamePath 'data_sts2_linux_x86_64\Steamworks.NET.dll'), + (Join-Path $GamePath 'data_sts2_macos_x86_64\Steamworks.NET.dll'), + (Join-Path $GamePath 'SlayTheSpire2.app\Contents\MacOS\data_sts2_macos_x86_64\Steamworks.NET.dll'), + (Join-Path $GamePath 'Steamworks.NET.dll') + ) +} + +function Resolve-Sts2AssemblyPath { + param( + [string]$Root, + [string]$ExplicitPath + ) + + $candidates = New-Object System.Collections.Generic.List[string] + + if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) { + $candidates.Add($ExplicitPath) + } + + if ($env:Sts2AssemblyPath) { + $candidates.Add($env:Sts2AssemblyPath) + } + if ($env:STS2_ASSEMBLY_PATH) { + $candidates.Add($env:STS2_ASSEMBLY_PATH) + } + + foreach ($gamePath in @($env:STS2GamePath, $env:STS2_GAME_PATH)) { + if (-not [string]::IsNullOrWhiteSpace($gamePath)) { + foreach ($candidate in Get-Sts2DllCandidatesForGamePath -GamePath $gamePath) { + $candidates.Add($candidate) + } + } + } + + foreach ($libraryRoot in Resolve-SteamLibraryRoots) { + $gamePath = Join-Path $libraryRoot 'steamapps\common\Slay the Spire 2' + foreach ($candidate in Get-Sts2DllCandidatesForGamePath -GamePath $gamePath) { + $candidates.Add($candidate) + } + } + + $candidates.Add((Join-Path $Root 'libs\sts2.dll')) + + foreach ($candidate in $candidates | Select-Object -Unique) { + if (Test-Path -LiteralPath $candidate) { + return $candidate + } + } + + Fail "sts2.dll was not found. Set STS2GamePath or Sts2AssemblyPath." +} + +function Resolve-SteamworksAssemblyPath { + param( + [string]$Root, + [string]$ExplicitPath + ) + + $candidates = New-Object System.Collections.Generic.List[string] + + if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) { + $candidates.Add($ExplicitPath) + } + + if ($env:SteamworksAssemblyPath) { + $candidates.Add($env:SteamworksAssemblyPath) + } + if ($env:STEAMWORKS_ASSEMBLY_PATH) { + $candidates.Add($env:STEAMWORKS_ASSEMBLY_PATH) + } + + foreach ($gamePath in @($env:STS2GamePath, $env:STS2_GAME_PATH)) { + if (-not [string]::IsNullOrWhiteSpace($gamePath)) { + foreach ($candidate in Get-SteamworksDllCandidatesForGamePath -GamePath $gamePath) { + $candidates.Add($candidate) + } + } + } + + foreach ($libraryRoot in Resolve-SteamLibraryRoots) { + $gamePath = Join-Path $libraryRoot 'steamapps\common\Slay the Spire 2' + foreach ($candidate in Get-SteamworksDllCandidatesForGamePath -GamePath $gamePath) { + $candidates.Add($candidate) + } + } + + $candidates.Add((Join-Path $Root 'libs\Steamworks.NET.dll')) + + foreach ($candidate in $candidates | Select-Object -Unique) { + if (Test-Path -LiteralPath $candidate) { + return $candidate + } + } + + Fail "Steamworks.NET.dll was not found. Set STS2GamePath or SteamworksAssemblyPath." +} + +function Write-MinimalProjectFile { + param([string]$ProjectPath) + + @' +; Auto-generated by tools/build_release.ps1 +config_version=5 + +[application] + +config/name="Remove Multiplayer PlayerLimit" +config/features=PackedStringArray("4.5", "Forward Plus") +'@ | Set-Content -LiteralPath $ProjectPath -Encoding UTF8 +} + +function Write-ConfigTemplate { + param([string]$DestinationPath) + + @' +[macos] +tls_workaround=true + +[multiplayer] +difficulty_scaling=true +'@ | Set-Content -LiteralPath $DestinationPath -Encoding ASCII +} + +function New-PackProject { + param( + [string]$Root, + [string]$PackProjectPath + ) + + if (Test-Path $PackProjectPath) { + Remove-Item -LiteralPath $PackProjectPath -Recurse -Force + } + + New-Item -ItemType Directory -Force -Path $PackProjectPath | Out-Null + New-Item -ItemType Directory -Force -Path (Join-Path $PackProjectPath "tools") | Out-Null + + Write-MinimalProjectFile (Join-Path $PackProjectPath "project.godot") + Copy-Item -LiteralPath (Join-Path $Root "RemoveMultiplayerPlayerLimit.json") -Destination (Join-Path $PackProjectPath "RemoveMultiplayerPlayerLimit.json") -Force + Copy-Item -LiteralPath (Join-Path $Root "RemoveMultiplayerPlayerLimit") -Destination (Join-Path $PackProjectPath "RemoveMultiplayerPlayerLimit") -Recurse -Force + Copy-Item -LiteralPath (Join-Path $Root "tools\build_pck.gd") -Destination (Join-Path $PackProjectPath "tools\build_pck.gd") -Force +} + +function Get-ModMetadata { + param([string]$ManifestPath) + + $manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json + $version = [string]$manifest.version + $folderName = if ([string]::IsNullOrWhiteSpace([string]$manifest.pck_name)) { + [string]$manifest.name + } else { + [string]$manifest.pck_name + } + + if ([string]::IsNullOrWhiteSpace($version)) { + Fail "Manifest is missing the version field." + } + + if ([string]::IsNullOrWhiteSpace($folderName)) { + Fail "Manifest is missing the name/pck_name field." + } + + return @{ + Version = $version + FolderName = $folderName + } +} $root = Split-Path -Parent $PSScriptRoot -$dotnet = "C:\Program Files\dotnet\dotnet.exe" -$godot = "F:\Dev\Remove Multiplayer PlayerLimit\Godot 4.5.1\Godot_v4.5.1-stable_win64_console.exe" +$dotnet = if ($env:DOTNET_PATH) { $env:DOTNET_PATH } else { "dotnet" } +$godot = Resolve-GodotPath -Root $root +$sts2Assembly = Resolve-Sts2AssemblyPath -Root $root -ExplicitPath $Sts2AssemblyPath +$steamworksAssembly = Resolve-SteamworksAssemblyPath -Root $root -ExplicitPath $SteamworksAssemblyPath + $buildRoot = Join-Path $root "build" -$releaseDir = Join-Path $root "build\RemoveMultiplayerPlayerLimit" -$dllSource = Join-Path $root ".godot\mono\temp\bin\Debug\RemoveMultiplayerPlayerLimit.dll" -$pckSource = Join-Path $root "build\RemoveMultiplayerPlayerLimit.pck" -$manifestPathBeta = Join-Path $root "RemoveMultiplayerPlayerLimit.json" +$packProject = Join-Path $buildRoot "_pack_project" +$releaseDir = Join-Path $buildRoot "RemoveMultiplayerPlayerLimit" +$manifestPath = Join-Path $root "RemoveMultiplayerPlayerLimit.json" +$csprojPath = Join-Path $root "RemoveMultiplayerPlayerLimit.csproj" +$dllSource = Join-Path $root ".godot\mono\temp\bin\$Configuration\RemoveMultiplayerPlayerLimit.dll" +$tempPckPath = Join-Path $packProject "build\RemoveMultiplayerPlayerLimit.pck" +$finalPckPath = Join-Path $buildRoot "RemoveMultiplayerPlayerLimit.pck" -& $dotnet build (Join-Path $root "RemoveMultiplayerPlayerLimit.csproj") -c Debug -& $godot --headless --path $root --script "res://tools/build_pck.gd" +Write-Host "" +Write-Host "=====================================================" -ForegroundColor Cyan +Write-Host " RMP Build System | Current Mod Build" -ForegroundColor Cyan +Write-Host "=====================================================" -ForegroundColor Cyan +Write-Host "" +Write-Host " Root : $root" +Write-Host " Configuration : $Configuration" +Write-Host " Dotnet : $dotnet" +Write-Host " Godot : $godot" +Write-Host " sts2.dll : $sts2Assembly" +Write-Host " Steamworks : $steamworksAssembly" +Write-Host "" -New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null +New-Item -ItemType Directory -Force -Path $buildRoot | Out-Null -Get-ChildItem -Path $releaseDir -Force | Remove-Item -Recurse -Force -Get-ChildItem -Path $buildRoot -Filter "sts2-RMP-*.zip" -File -ErrorAction SilentlyContinue | Remove-Item -Force +Write-Host "[1/5] Building DLL..." -ForegroundColor Yellow +Invoke-External -FilePath $dotnet -Arguments @("build", $csprojPath, "-c", $Configuration, "/p:Sts2AssemblyPath=$sts2Assembly", "/p:SteamworksAssemblyPath=$steamworksAssembly") -StepName "dotnet build" +if (-not (Test-Path $dllSource)) { + Fail "Built DLL was not found at $dllSource" +} +Write-Host " DLL built successfully." -ForegroundColor Green +Write-Host "" + +Write-Host "[2/5] Preparing minimal pack project..." -ForegroundColor Yellow +New-PackProject -Root $root -PackProjectPath $packProject +Write-Host " Minimal pack project prepared." -ForegroundColor Green +Write-Host "" -Copy-Item $dllSource -Destination (Join-Path $releaseDir "RemoveMultiplayerPlayerLimit.dll") -Force -Copy-Item $pckSource -Destination (Join-Path $releaseDir "RemoveMultiplayerPlayerLimit.pck") -Force -Copy-Item $manifestPathBeta -Destination (Join-Path $releaseDir "RemoveMultiplayerPlayerLimit.json") -Force +Write-Host "[3/5] Importing mod resources..." -ForegroundColor Yellow +Invoke-External -FilePath $godot -Arguments @("--headless", "--path", $packProject, "--import") -StepName "Godot import" +$importedDir = Join-Path $packProject ".godot\imported" +$ctexFiles = @() +for ($i = 0; $i -lt 20; $i++) { + $ctexFiles = Get-ChildItem -LiteralPath $importedDir -Filter "mod_image.png-*.ctex" -ErrorAction SilentlyContinue + if ($ctexFiles) { + break + } -$manifest = Get-Content $manifestPathBeta -Raw | ConvertFrom-Json -$version = [string]$manifest.version -$modFolderName = if ([string]::IsNullOrWhiteSpace([string]$manifest.pck_name)) { [string]$manifest.name } else { [string]$manifest.pck_name } -if ([string]::IsNullOrWhiteSpace($version)) { throw "RemoveMultiplayerPlayerLimit.json missing version field" } -if ([string]::IsNullOrWhiteSpace($modFolderName)) { throw "RemoveMultiplayerPlayerLimit.json missing name/pck_name field" } + Start-Sleep -Milliseconds 200 +} +if (-not $ctexFiles) { + Write-Host " [WARN] mod_image.png .ctex was not generated. Cover image may not display in-game." -ForegroundColor DarkYellow +} else { + Write-Host " mod_image.png .ctex generated successfully." -ForegroundColor Green +} +Write-Host "" + +Write-Host "[4/5] Packing PCK resources..." -ForegroundColor Yellow +Invoke-External -FilePath $godot -Arguments @("--headless", "--path", $packProject, "--script", "res://tools/build_pck.gd") -StepName "Godot PCK build" +if (-not (Wait-ForPath -LiteralPath $tempPckPath)) { + Fail "Packed PCK was not found at $tempPckPath" +} +Copy-Item -LiteralPath $tempPckPath -Destination $finalPckPath -Force +Write-Host " PCK packed successfully." -ForegroundColor Green +Write-Host "" + +Write-Host "[5/5] Assembling release and ZIP..." -ForegroundColor Yellow +if (Test-Path $releaseDir) { + Remove-PathWithRetry -LiteralPath $releaseDir +} +New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null + +Copy-Item -LiteralPath $dllSource -Destination (Join-Path $releaseDir "RemoveMultiplayerPlayerLimit.dll") -Force +Copy-Item -LiteralPath $finalPckPath -Destination (Join-Path $releaseDir "RemoveMultiplayerPlayerLimit.pck") -Force +Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $releaseDir "RemoveMultiplayerPlayerLimit.json") -Force + +$rootConfigPath = Join-Path $root "config.ini" +$releaseConfigPath = Join-Path $releaseDir "config.ini" +if (Test-Path $rootConfigPath) { + Copy-Item -LiteralPath $rootConfigPath -Destination $releaseConfigPath -Force +} else { + Write-ConfigTemplate -DestinationPath $releaseConfigPath +} +$metadata = Get-ModMetadata -ManifestPath $manifestPath +$version = $metadata.Version +$modFolderName = $metadata.FolderName $zipName = "sts2-RMP-$version.zip" $zipPath = Join-Path $buildRoot $zipName $zipStageRoot = Join-Path $buildRoot "_zip_stage" $zipModFolder = Join-Path $zipStageRoot $modFolderName -if (Test-Path $zipPath) { Remove-Item $zipPath -Force } -if (Test-Path $zipStageRoot) { Remove-Item $zipStageRoot -Recurse -Force } +if (Test-Path $zipPath) { + Remove-Item -LiteralPath $zipPath -Force +} +if (Test-Path $zipStageRoot) { + Remove-Item -LiteralPath $zipStageRoot -Recurse -Force +} New-Item -ItemType Directory -Force -Path $zipModFolder | Out-Null -Copy-Item (Join-Path $releaseDir "*") -Destination $zipModFolder -Recurse -Force +Copy-Item -Path (Join-Path $releaseDir "*") -Destination $zipModFolder -Recurse -Force + +$installBatPath = Join-Path $zipStageRoot "Install.bat" +$helperPs1Path = Join-Path $zipStageRoot "helper.ps1" -# Generate one-click installer scripts into zip stage root @' @echo off powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0helper.ps1" pause -'@ | Set-Content (Join-Path $zipStageRoot "Install.bat") -Encoding ASCII +'@ | Set-Content -LiteralPath $installBatPath -Encoding ASCII -@' +$helperTemplate = @' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $host.UI.RawUI.WindowTitle = 'Remove Multiplayer Player Limit - Installer' Write-Host '============================================' -Write-Host ' Remove Multiplayer Player Limit' -Write-Host ' One-Click Installer | 一键安装程序' +Write-Host ' Remove Multiplayer Player Limit v{VERSION}' +Write-Host ' One-Click Installer' Write-Host '============================================' Write-Host '' -# ── Validate mod files exist next to this script ────────────────────── $src = $PSScriptRoot $modFolder = Join-Path $src 'RemoveMultiplayerPlayerLimit' $dll = Join-Path $modFolder 'RemoveMultiplayerPlayerLimit.dll' @@ -69,89 +461,75 @@ if (-not (Test-Path $json)) { $missing += 'RemoveMultiplayerPlayerLimit.json' } if ($missing.Count -gt 0) { Write-Host '[ERROR] Missing mod files:' -ForegroundColor Red - Write-Host '[错误] 缺少以下模组文件:' -ForegroundColor Red - foreach ($f in $missing) { Write-Host " - $f" -ForegroundColor Red } - Write-Host '' - Write-Host 'Please make sure this script is in the same folder as the' - Write-Host '"RemoveMultiplayerPlayerLimit" directory from the release zip.' - Write-Host '请确保本脚本与 RemoveMultiplayerPlayerLimit 文件夹在同一目录下。' + foreach ($file in $missing) { Write-Host " - $file" -ForegroundColor Red } exit 1 } -Write-Host 'Searching for Slay the Spire 2 installation...' -Write-Host '正在搜索「杀戮尖塔 2」安装目录,请稍候...' -Write-Host '' - -# ── Detect Steam install path from Windows Registry ────────────────── -$sp = $null -try { $sp = (Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Valve\Steam' -EA Stop).InstallPath } catch {} -if (-not $sp) { - try { $sp = (Get-ItemProperty 'HKCU:\SOFTWARE\Valve\Steam' -EA Stop).SteamPath } catch {} +$steamPath = $null +try { $steamPath = (Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Valve\Steam' -ErrorAction Stop).InstallPath } catch {} +if (-not $steamPath) { + try { $steamPath = (Get-ItemProperty 'HKCU:\SOFTWARE\Valve\Steam' -ErrorAction Stop).SteamPath } catch {} } -# ── Parse libraryfolders.vdf to find all Steam library paths ───────── -$gp = $null -if ($sp) { - $vdf = Join-Path $sp 'steamapps\libraryfolders.vdf' - if (Test-Path $vdf) { - foreach ($line in Get-Content $vdf) { +$gamePath = $null +if ($steamPath) { + $libraryVdf = Join-Path $steamPath 'steamapps\libraryfolders.vdf' + if (Test-Path $libraryVdf) { + foreach ($line in Get-Content $libraryVdf) { if ($line -match '"path"\s+"([^"]+)"') { - $p = $Matches[1].Replace('\\', '\') - $c = Join-Path $p 'steamapps\common\Slay the Spire 2' - if (Test-Path $c) { $gp = $c; break } + $libraryPath = $Matches[1].Replace('\\', '\') + $candidate = Join-Path $libraryPath 'steamapps\common\Slay the Spire 2' + if (Test-Path $candidate) { + $gamePath = $candidate + break + } } } } - # Fallback: check the main Steam directory itself - if (-not $gp) { - $c = Join-Path $sp 'steamapps\common\Slay the Spire 2' - if (Test-Path $c) { $gp = $c } - } -} - -# ── Install ────────────────────────────────────────────────────────── -if ($gp) { - Write-Host "Found game directory | 找到游戏目录:" -ForegroundColor Green - Write-Host " $gp" -ForegroundColor Green - Write-Host '' - - $dest = Join-Path $gp 'mods\RemoveMultiplayerPlayerLimit' - New-Item -ItemType Directory -Force -Path $dest | Out-Null - Copy-Item (Join-Path $modFolder '*') -Destination $dest -Recurse -Force - - Write-Host '============================================' - Write-Host ' Installation successful!' -ForegroundColor Green - Write-Host ' 安装成功!' -ForegroundColor Green - Write-Host '============================================' - Write-Host '' - Write-Host 'The mod will be enabled automatically when you launch the game.' - Write-Host '启动游戏后模组将自动生效。' - Write-Host '' - Write-Host 'Installed to | 安装路径:' - Write-Host " $dest" -} else { - Write-Host '============================================' - Write-Host ' Auto-detection failed' -ForegroundColor Red - Write-Host ' 自动安装失败' -ForegroundColor Red - Write-Host '============================================' - Write-Host '' - Write-Host 'Could not locate "Slay the Spire 2" automatically.' - Write-Host '未能自动找到「杀戮尖塔 2」安装目录。' - Write-Host '' - Write-Host 'Please copy the "RemoveMultiplayerPlayerLimit" folder manually to:' - Write-Host '请手动将 RemoveMultiplayerPlayerLimit 文件夹复制到:' - Write-Host '' - Write-Host ' \mods\RemoveMultiplayerPlayerLimit\' - Write-Host '' - Write-Host 'Example | 示例路径:' - Write-Host ' D:\Steam\steamapps\common\Slay the Spire 2\mods\RemoveMultiplayerPlayerLimit\' - Write-Host '' - Write-Host 'Tip: In Steam, right-click the game > Manage > Browse Local Files' - Write-Host '提示:在 Steam 中右键游戏 > 管理 > 浏览本地文件' + + if (-not $gamePath) { + $candidate = Join-Path $steamPath 'steamapps\common\Slay the Spire 2' + if (Test-Path $candidate) { + $gamePath = $candidate + } + } } +if (-not $gamePath) { + Write-Host 'Could not locate Slay the Spire 2 automatically.' -ForegroundColor Red + Write-Host 'Please copy RemoveMultiplayerPlayerLimit/ into \mods\ manually.' + exit 1 +} + +$destination = Join-Path $gamePath 'mods\RemoveMultiplayerPlayerLimit' +New-Item -ItemType Directory -Force -Path $destination | Out-Null +Copy-Item -LiteralPath (Join-Path $modFolder '*') -Destination $destination -Recurse -Force + Write-Host '' -'@ | Set-Content (Join-Path $zipStageRoot "helper.ps1") -Encoding UTF8 +Write-Host 'Installation successful.' -ForegroundColor Green +Write-Host "Installed to: $destination" +Write-Host '' +'@ + +$helperTemplate.Replace("{VERSION}", $version) | Set-Content -LiteralPath $helperPs1Path -Encoding UTF8 Compress-Archive -Path (Join-Path $zipStageRoot "*") -DestinationPath $zipPath -CompressionLevel Optimal -Remove-Item $zipStageRoot -Recurse -Force +Remove-PathWithRetry -LiteralPath $zipStageRoot +Remove-PathWithRetry -LiteralPath $packProject + +$dllSize = [math]::Round((Get-Item -LiteralPath (Join-Path $releaseDir "RemoveMultiplayerPlayerLimit.dll")).Length / 1KB, 1) +$pckSize = [math]::Round((Get-Item -LiteralPath (Join-Path $releaseDir "RemoveMultiplayerPlayerLimit.pck")).Length / 1KB, 1) +$zipSize = [math]::Round((Get-Item -LiteralPath $zipPath).Length / 1KB, 1) + +Write-Host " Release directory assembled." -ForegroundColor Green +Write-Host "" +Write-Host "=====================================================" -ForegroundColor Green +Write-Host " Build Complete!" -ForegroundColor Green +Write-Host "=====================================================" -ForegroundColor Green +Write-Host "" +Write-Host " Version : $version" +Write-Host " DLL : $dllSize KB" +Write-Host " PCK : $pckSize KB" +Write-Host " ZIP : $zipPath ($zipSize KB)" +Write-Host " Release : $releaseDir" +Write-Host "" diff --git a/tools/build_release.sh b/tools/build_release.sh index 2c755a2..fb156ea 100755 --- a/tools/build_release.sh +++ b/tools/build_release.sh @@ -1,66 +1,380 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +set -euo pipefail +CONFIGURATION="${1:-Release}" +EXPLICIT_STS2_ASSEMBLY="${2:-}" +EXPLICIT_STEAMWORKS_ASSEMBLY="${3:-}" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -DOTNET="dotnet" -if command -v godot4 > /dev/null 2>&1; then - GODOT="godot4" -elif command -v godot > /dev/null 2>&1; then - GODOT="godot" -else - echo "Error: neither godot4 nor godot was found in PATH." - exit 1 -fi +DOTNET="${DOTNET_PATH:-dotnet}" BUILD_ROOT="$ROOT_DIR/build" +PACK_PROJECT="$BUILD_ROOT/_pack_project" RELEASE_DIR="$BUILD_ROOT/RemoveMultiplayerPlayerLimit" -DLL_SOURCE="$ROOT_DIR/.godot/mono/temp/bin/Debug/RemoveMultiplayerPlayerLimit.dll" -PCK_SOURCE="$BUILD_ROOT/RemoveMultiplayerPlayerLimit.pck" -MANIFEST_PATH_BETA="$ROOT_DIR/RemoveMultiplayerPlayerLimit.json" +MANIFEST="$ROOT_DIR/RemoveMultiplayerPlayerLimit.json" +CSPROJ="$ROOT_DIR/RemoveMultiplayerPlayerLimit.csproj" +DLL_SOURCE="$ROOT_DIR/.godot/mono/temp/bin/$CONFIGURATION/RemoveMultiplayerPlayerLimit.dll" +TEMP_PCK_PATH="$PACK_PROJECT/build/RemoveMultiplayerPlayerLimit.pck" +FINAL_PCK_PATH="$BUILD_ROOT/RemoveMultiplayerPlayerLimit.pck" -"$DOTNET" build "$ROOT_DIR/RemoveMultiplayerPlayerLimit.csproj" -c Debug -"$GODOT" --headless --path "$ROOT_DIR" --script "res://tools/build_pck.gd" +fail() { + echo " [FAIL] $1" >&2 + exit 1 +} -mkdir -p "$RELEASE_DIR" -rm -rf "$RELEASE_DIR"/* -rm -f "$BUILD_ROOT"/sts2-RMP-*.zip +wait_for_path() { + local path="$1" + local retries="${2:-50}" + local delay="${3:-0.2}" -cp "$DLL_SOURCE" "$RELEASE_DIR/RemoveMultiplayerPlayerLimit.dll" -cp "$PCK_SOURCE" "$RELEASE_DIR/RemoveMultiplayerPlayerLimit.pck" -if [ -f "$MANIFEST_PATH_BETA" ]; then - cp "$MANIFEST_PATH_BETA" "$RELEASE_DIR/RemoveMultiplayerPlayerLimit.json" + for ((i = 0; i < retries; i++)); do + if [[ -e "$path" ]]; then + return 0 + fi + sleep "$delay" + done + + return 1 +} + +remove_with_retry() { + local path="$1" + local retries="${2:-30}" + local delay="${3:-0.3}" + + if [[ ! -e "$path" ]]; then + return 0 + fi + + for ((i = 0; i < retries; i++)); do + if rm -rf "$path" 2>/dev/null; then + return 0 + fi + sleep "$delay" + done + + fail "Failed to remove $path after multiple retries." +} + +resolve_godot() { + if [[ -n "${GODOT_PATH:-}" && -f "$GODOT_PATH" ]]; then + echo "$GODOT_PATH" + return + fi + + local candidates=( + "$ROOT_DIR/libs/Godot_v4.5.1-stable_linux.x86_64" + "$ROOT_DIR/libs/Godot_v4.5-stable_linux.x86_64" + "$ROOT_DIR/libs/Godot_v4.5.1-stable_macos.universal" + "$ROOT_DIR/libs/Godot_v4.5-stable_macos.universal" + ) + + for candidate in "${candidates[@]}"; do + if [[ -f "$candidate" ]]; then + echo "$candidate" + return + fi + done + + if command -v godot4 >/dev/null 2>&1; then + command -v godot4 + return + fi + + if command -v godot >/dev/null 2>&1; then + command -v godot + return + fi + + fail "Godot 4.5.x was not found. Put it under libs/ or set GODOT_PATH." +} + +sts2_dll_candidates_for_game_path() { + local game_path="$1" + + printf '%s\n' \ + "$game_path/data_sts2_windows_x86_64/sts2.dll" \ + "$game_path/data_sts2_linux_x86_64/sts2.dll" \ + "$game_path/data_sts2_macos_x86_64/sts2.dll" \ + "$game_path/SlayTheSpire2.app/Contents/MacOS/data_sts2_macos_x86_64/sts2.dll" \ + "$game_path/sts2.dll" +} + +steamworks_dll_candidates_for_game_path() { + local game_path="$1" + + printf '%s\n' \ + "$game_path/data_sts2_windows_x86_64/Steamworks.NET.dll" \ + "$game_path/data_sts2_linux_x86_64/Steamworks.NET.dll" \ + "$game_path/data_sts2_macos_x86_64/Steamworks.NET.dll" \ + "$game_path/SlayTheSpire2.app/Contents/MacOS/data_sts2_macos_x86_64/Steamworks.NET.dll" \ + "$game_path/Steamworks.NET.dll" +} + +steam_library_roots() { + local roots=() + + if [[ -n "${STEAM_DIR:-}" && -d "$STEAM_DIR" ]]; then + roots+=("$STEAM_DIR") + fi + + roots+=( + "$HOME/.steam/steam" + "$HOME/.local/share/Steam" + "$HOME/Library/Application Support/Steam" + ) + + for root in "${roots[@]}"; do + [[ -d "$root" ]] || continue + printf '%s\n' "$root" + + local library_vdf="$root/steamapps/libraryfolders.vdf" + [[ -f "$library_vdf" ]] || continue + sed -nE 's/.*"path"[[:space:]]+"([^"]+)".*/\1/p' "$library_vdf" | sed 's#\\\\#/#g' + done +} + +resolve_sts2_assembly() { + local candidates=() + + if [[ -n "$EXPLICIT_STS2_ASSEMBLY" ]]; then + candidates+=("$EXPLICIT_STS2_ASSEMBLY") + fi + + if [[ -n "${Sts2AssemblyPath:-}" ]]; then + candidates+=("$Sts2AssemblyPath") + fi + if [[ -n "${STS2_ASSEMBLY_PATH:-}" ]]; then + candidates+=("$STS2_ASSEMBLY_PATH") + fi + + for game_path in "${STS2GamePath:-}" "${STS2_GAME_PATH:-}"; do + [[ -n "$game_path" ]] || continue + while IFS= read -r candidate; do + candidates+=("$candidate") + done < <(sts2_dll_candidates_for_game_path "$game_path") + done + + while IFS= read -r library_root; do + [[ -n "$library_root" ]] || continue + while IFS= read -r candidate; do + candidates+=("$candidate") + done < <(sts2_dll_candidates_for_game_path "$library_root/steamapps/common/Slay the Spire 2") + done < <(steam_library_roots) + + candidates+=("$ROOT_DIR/libs/sts2.dll") + + local seen=":" + for candidate in "${candidates[@]}"; do + [[ -n "$candidate" ]] || continue + case "$seen" in + *":$candidate:"*) continue ;; + esac + seen="$seen$candidate:" + if [[ -f "$candidate" ]]; then + echo "$candidate" + return + fi + done + + fail "sts2.dll was not found. Set STS2GamePath or Sts2AssemblyPath." +} + +resolve_steamworks_assembly() { + local candidates=() + + if [[ -n "$EXPLICIT_STEAMWORKS_ASSEMBLY" ]]; then + candidates+=("$EXPLICIT_STEAMWORKS_ASSEMBLY") + fi + + if [[ -n "${SteamworksAssemblyPath:-}" ]]; then + candidates+=("$SteamworksAssemblyPath") + fi + if [[ -n "${STEAMWORKS_ASSEMBLY_PATH:-}" ]]; then + candidates+=("$STEAMWORKS_ASSEMBLY_PATH") + fi + + for game_path in "${STS2GamePath:-}" "${STS2_GAME_PATH:-}"; do + [[ -n "$game_path" ]] || continue + while IFS= read -r candidate; do + candidates+=("$candidate") + done < <(steamworks_dll_candidates_for_game_path "$game_path") + done + + while IFS= read -r library_root; do + [[ -n "$library_root" ]] || continue + while IFS= read -r candidate; do + candidates+=("$candidate") + done < <(steamworks_dll_candidates_for_game_path "$library_root/steamapps/common/Slay the Spire 2") + done < <(steam_library_roots) + + candidates+=("$ROOT_DIR/libs/Steamworks.NET.dll") + + local seen=":" + for candidate in "${candidates[@]}"; do + [[ -n "$candidate" ]] || continue + case "$seen" in + *":$candidate:"*) continue ;; + esac + seen="$seen$candidate:" + if [[ -f "$candidate" ]]; then + echo "$candidate" + return + fi + done + + fail "Steamworks.NET.dll was not found. Set STS2GamePath or SteamworksAssemblyPath." +} + +write_minimal_project() { + cat > "$1" <<'EOF' +; Auto-generated by tools/build_release.sh +config_version=5 + +[application] + +config/name="Remove Multiplayer PlayerLimit" +config/features=PackedStringArray("4.5", "Forward Plus") +EOF +} + +write_config_template() { + cat > "$1" <<'EOF' +[macos] +tls_workaround=true + +[multiplayer] +difficulty_scaling=true +EOF +} + +new_pack_project() { + rm -rf "$PACK_PROJECT" + mkdir -p "$PACK_PROJECT/tools" + write_minimal_project "$PACK_PROJECT/project.godot" + cp "$MANIFEST" "$PACK_PROJECT/RemoveMultiplayerPlayerLimit.json" + cp -R "$ROOT_DIR/RemoveMultiplayerPlayerLimit" "$PACK_PROJECT/RemoveMultiplayerPlayerLimit" + cp "$ROOT_DIR/tools/build_pck.gd" "$PACK_PROJECT/tools/build_pck.gd" +} + +manifest_value() { + local query="$1" + jq -r "$query" "$MANIFEST" +} + +if [[ "$CONFIGURATION" != "Debug" && "$CONFIGURATION" != "Release" ]]; then + fail "Configuration must be Debug or Release." fi -if ! command -v jq &> /dev/null; then - echo "Error: jq is required to parse RemoveMultiplayerPlayerLimit.json. Please install it (e.g., sudo apt install jq)." - exit 1 +GODOT="$(resolve_godot)" +STS2_ASSEMBLY="$(resolve_sts2_assembly)" +STEAMWORKS_ASSEMBLY="$(resolve_steamworks_assembly)" + +echo "" +echo "=====================================================" +echo " RMP Build System | Current Mod Build" +echo "=====================================================" +echo "" +echo " Root : $ROOT_DIR" +echo " Configuration : $CONFIGURATION" +echo " Dotnet : $DOTNET" +echo " Godot : $GODOT" +echo " sts2.dll : $STS2_ASSEMBLY" +echo " Steamworks : $STEAMWORKS_ASSEMBLY" +echo "" + +mkdir -p "$BUILD_ROOT" + +echo "[1/5] Building DLL..." +"$DOTNET" build "$CSPROJ" -c "$CONFIGURATION" "/p:Sts2AssemblyPath=$STS2_ASSEMBLY" "/p:SteamworksAssemblyPath=$STEAMWORKS_ASSEMBLY" +[[ -f "$DLL_SOURCE" ]] || fail "Built DLL was not found at $DLL_SOURCE" +echo " DLL built successfully." +echo "" + +echo "[2/5] Preparing minimal pack project..." +new_pack_project +echo " Minimal pack project prepared." +echo "" + +echo "[3/5] Importing mod resources..." +"$GODOT" --headless --path "$PACK_PROJECT" --import +for _ in $(seq 1 20); do + if compgen -G "$PACK_PROJECT/.godot/imported/mod_image.png-*.ctex" >/dev/null; then + break + fi + sleep 0.2 +done + +if compgen -G "$PACK_PROJECT/.godot/imported/mod_image.png-*.ctex" >/dev/null; then + echo " mod_image.png .ctex generated successfully." +else + echo " [WARN] mod_image.png .ctex was not generated. Cover image may not display in-game." fi -VERSION=$(jq -r '.version // empty' "$MANIFEST_PATH_BETA") -MOD_FOLDER_NAME=$(jq -r 'if .pck_name and .pck_name != "" then .pck_name else .name end' "$MANIFEST_PATH_BETA") -if [ -z "$VERSION" ]; then - echo "Error: RemoveMultiplayerPlayerLimit.json missing version field" - exit 1 +echo "" + +echo "[4/5] Packing PCK resources..." +"$GODOT" --headless --path "$PACK_PROJECT" --script res://tools/build_pck.gd +wait_for_path "$TEMP_PCK_PATH" || fail "Packed PCK was not found at $TEMP_PCK_PATH" +cp "$TEMP_PCK_PATH" "$FINAL_PCK_PATH" +echo " PCK packed successfully." +echo "" + +echo "[5/5] Assembling release and ZIP..." +remove_with_retry "$RELEASE_DIR" +mkdir -p "$RELEASE_DIR" + +cp "$DLL_SOURCE" "$RELEASE_DIR/RemoveMultiplayerPlayerLimit.dll" +cp "$FINAL_PCK_PATH" "$RELEASE_DIR/RemoveMultiplayerPlayerLimit.pck" +cp "$MANIFEST" "$RELEASE_DIR/RemoveMultiplayerPlayerLimit.json" + +if [[ -f "$ROOT_DIR/config.ini" ]]; then + cp "$ROOT_DIR/config.ini" "$RELEASE_DIR/config.ini" +else + write_config_template "$RELEASE_DIR/config.ini" fi -if [ -z "$MOD_FOLDER_NAME" ]; then - echo "Error: RemoveMultiplayerPlayerLimit.json missing name/pck_name field" - exit 1 + +if ! command -v jq >/dev/null 2>&1; then + fail "jq is required to read the manifest." fi +VERSION="$(manifest_value '.version // empty')" +MOD_FOLDER_NAME="$(manifest_value 'if .pck_name and .pck_name != "" then .pck_name else .name end')" +[[ -n "$VERSION" ]] || fail "Manifest is missing the version field." +[[ -n "$MOD_FOLDER_NAME" ]] || fail "Manifest is missing the name/pck_name field." + ZIP_NAME="sts2-RMP-$VERSION.zip" ZIP_PATH="$BUILD_ROOT/$ZIP_NAME" -ZIP_STAGE_ROOT="$BUILD_ROOT/_zip_stage" -ZIP_MOD_FOLDER="$ZIP_STAGE_ROOT/$MOD_FOLDER_NAME" +ZIP_STAGE="$BUILD_ROOT/_zip_stage" +ZIP_MOD="$ZIP_STAGE/$MOD_FOLDER_NAME" rm -f "$ZIP_PATH" -rm -rf "$ZIP_STAGE_ROOT" +remove_with_retry "$ZIP_STAGE" +mkdir -p "$ZIP_MOD" +cp -R "$RELEASE_DIR"/. "$ZIP_MOD/" -mkdir -p "$ZIP_MOD_FOLDER" -cp -r "$RELEASE_DIR"/* "$ZIP_MOD_FOLDER/" - -if ! command -v zip &> /dev/null; then - echo "Error: zip is required. Please install it." - exit 1 +if ! command -v zip >/dev/null 2>&1; then + fail "zip is required to create the release archive." fi -pushd "$ZIP_STAGE_ROOT" > /dev/null -zip -qr "../$ZIP_NAME" "$MOD_FOLDER_NAME" -popd > /dev/null -rm -rf "$ZIP_STAGE_ROOT" + +( + cd "$ZIP_STAGE" + zip -qr "../$ZIP_NAME" "$MOD_FOLDER_NAME" +) + +remove_with_retry "$ZIP_STAGE" +remove_with_retry "$PACK_PROJECT" + +DLL_SIZE="$(du -h "$RELEASE_DIR/RemoveMultiplayerPlayerLimit.dll" | cut -f1)" +PCK_SIZE="$(du -h "$RELEASE_DIR/RemoveMultiplayerPlayerLimit.pck" | cut -f1)" +ZIP_SIZE="$(du -h "$ZIP_PATH" | cut -f1)" + +echo " Release directory assembled." +echo "" +echo "=====================================================" +echo " Build Complete!" +echo "=====================================================" +echo "" +echo " Version : $VERSION" +echo " DLL : $DLL_SIZE" +echo " PCK : $PCK_SIZE" +echo " ZIP : $ZIP_PATH ($ZIP_SIZE)" +echo " Release : $RELEASE_DIR" +echo ""