# MOD 与兼容包加载流程 本文记录 Android 兼容包、`STS2Mobile.dll`、`port_compat.pck`、原版 payload 和普通用户 MOD 的详细加载顺序。当前说明适用于内置的 `v0.103.x` / `v0.107.1` / `v0.108.0` 正式/稳定兼容 target、当前 `v0.109.0` public-beta target,以及旧 `v0.106.1` / `v0.107.0` beta 兼容 target。 ## 1. 术语 - **payload**:用户导入的 PC 版游戏 zip 解压结果,安装到 `/payloads//game/`;切换版本不再复制到固定 active 目录。 - **compat pack**:Android 移动端兼容包,安装到 `/compat-packs//`。当前兼容两种 manifest: - schema 1 legacy 单目标包:根目录包含 `compat_manifest.json`、`STS2Mobile.dll`、`port_compat.pck`、`SHA256SUMS`。 - schema 2 family 包,根目录包含 `compat_manifest.json`、`SHA256SUMS`,并在 `variants//` 下为每个目标版本放置 `STS2Mobile.dll` 与 `port_compat.pck`。 - **offline bootstrap**:`offline-bootstrap/` 构建的通用离线启动层,pack id 为 `sts2-android-offline-bootstrap`。它也是 schema 2 包,但 manifest 必须声明 `pack_kind=offline-bootstrap`、target `match_mode=offline-wildcard`、`versions=["*"]`;启动器只在没有 full compat 包按 SHA/version 命中当前 payload 时自动推荐它。它不静态引用 `sts2.dll`,只做 GodotSharp bootstrap、Android temp、反射式平台/存档保底补丁、原版 `ModelDb` two-phase 初始化保护和 runtime probe。 - **compat fallback**:APK assets 中的 `android/assets/dotnet_bcl/STS2Mobile.dll` 与 `android/assets/port_compat.pck`,主要用于兼容旧启动路径或无已选包的兜底;正常 launcher 启动会先检查当前启动配置的 compat pack,缺包时不静默 fallback。 - **launch profile / 启动配置**:安装在 `/instances//instance.json`,绑定一个 payload、一个可选 compat pack,并决定存档/设置与 MOD 使用全局目录还是 profile 独立目录。schema 2 family 包会同时记录 `compat_pack_id` 与 `compat_target_id`;兼容包选择属于启动配置,不再有运行时全局选中包 fallback。 - **普通用户 MOD**:默认放在全局 `/mods/`;当当前 launch profile 的 `mods_mode=isolated` 时放在 `/instances//mods/`。由游戏原版 `ModManager` 在被兼容层 patch 后扫描加载。 Compat pack 不是普通用户 MOD。它必须早于原版 `ModManager.Initialize()` 加载,否则无法 patch Steam/Sentry/platform、路径、MOD 扫描、输入、shader 等 Android 必需行为。 ## 2. 构建期流程 ```text legacy mode: port-mod branch -> dotnet build STS2Mobile.csproj -> STS2Mobile.dll -> make-port-overlay-pck.py -> port_compat.pck -> compat_manifest.json -> zip: sts2-android-compat-*.zip flat matrix mode: port-mod/targets/active/*/target.json -> dotnet build STS2Mobile.csproj per target ReferenceFlavor -> variants//STS2Mobile.dll -> variants//port_compat.pck -> schema 2 compat_manifest.json targets[] -> zip: sts2-android-compat.zip offline bootstrap: offline-bootstrap/src/STS2OfflineBootstrap -> dotnet build without sts2.dll reference -> variants/offline-any/STS2Mobile.dll -> minimal variants/offline-any/port_compat.pck -> schema 2 compat_manifest.json pack_kind=offline-bootstrap -> zip: sts2-android-offline-bootstrap.zip ``` 相关脚本: - `tools/android/build-port-mod.sh` - 构建当前 submodule checkout。 - 输出 fallback:`android/assets/dotnet_bcl/STS2Mobile.dll`、`android/assets/port_compat.pck`。 - `port-mod/tools/build-compat-pack.sh` - 构建当前 checkout 的 schema 1 独立 zip,主要用于 legacy 包对照或诊断。 - 写入 build metadata:branch、commit、dirty、timestamp。 - `tools/android/stage-bundled-compat-packs.sh` - 默认 matrix mode:调用 `port-mod/tools/build-compat-matrix.sh`,从同一 checkout 的 `targets/active/*/target.json` 生成 schema 2 family zip。 - `COMPAT_PACK_BUILD_MODE=legacy`:按 `tools/android/bundled-compat-packs.json` 构建多个 schema 1 分支包,非当前分支使用临时 git worktree;仅用于回退诊断。 - 输出到 gitignored 的 `android/assets/compat_packs/*.zip`,随本地 APK 打包但不由 git 跟踪。 - `offline-bootstrap/tools/build-offline-pack.sh` - 构建通用离线启动层,不读取 `port-mod/targets`,不接受 `ReferenceFlavor`,不引用 `sts2.dll`。 - 输出 `sts2-android-offline-bootstrap.zip`。 - `tools/android/stage-bundled-compat-artifacts.sh` - APK 打包使用的统一 staging 入口:一次清理 assets 后依次 stage full compat family 包和 offline bootstrap 包。 ## 3. 安装 / 首次进入设置页 1. Android 默认启动 `GameSettingsActivity`。设置页内部使用“画面 / 操作 / 存档 / 系统”顶部 Segmented Button 分区,较长的单选设置(如渲染器、分辨率、旋转模式、日志等级、桌面图标启动后)通过 Bottom Sheet 单选列表修改;预加载详细 BottomSheet 刚打开时可通过内容区上滑完整展开,完整展开后内容滚动区不参与降下/关闭,只能下拉顶部手柄关闭。首次安装和新建隔离档案首次生成设置时,推荐图形默认写入 `msaa=0`、`vsync=off`。画面高级里的“旋转模式”写入 `android_screen_rotation_mode`,默认 `user_landscape`(跟随系统旋转锁定设置,仅在两向横屏间受系统开关约束旋转);也可选 `auto`(强制自动旋转,即使系统关闭旋转锁定,游戏内也支持双向重力感应旋转);此外还可固定为 `landscape`(不旋转)或 `reverse_landscape`(翻转 180°)。桌面图标名称/图标使用主应用资源;“设置 → 系统 → 桌面图标启动后”默认打开附加设置,也可切换为向导完成后自动走 `GameSettingsActivity.launchGame()` 直接启动游戏。设置页快捷方式和游戏内返回设置不触发自动直启。 2. 设置页可在后台调用 `CompatPackManager.installBundledCompatPacks()`: - 枚举 APK assets `compat_packs/*.zip`。 - 复制到私有临时目录。 - 安全解压、寻找 `compat_manifest.json`。 - schema 1 校验根目录 `STS2Mobile.dll` 与 `port_compat.pck` 存在;schema 2 校验 `targets[]` 中每个 artifact 指向的 variant dll/pck 存在。 - 普通 full compat 包不允许在版本或 `sts2_dll_sha256` 中使用 `*`。只有 schema 2 且 `pack_kind=offline-bootstrap`、`match_mode=offline-wildcard` 的 target 可以声明完整版本字符串 `*`;`target_id` 和 `sts2_dll_sha256` 仍不允许通配符。 - 安装到 `/compat-packs//`。 - 不会自动把新安装的包设为全局选中包;用户需要在创建或编辑启动配置时选择。 - 从旧 schema 1 bundled 包升级到 flat matrix 包时,会把启动配置中旧 `sts2-android-compat-v0.*` bundled pack id 自动迁移到 `sts2-android-compat` family 包和对应 `compat_target_id`。例如 `sts2-android-compat-v0.107.0-beta` 会迁移为 `compat_pack_id=sts2-android-compat`、`compat_target_id=v0.107.0-beta`;用户手动导入/选择的非 bundled 包不会被覆盖。 3. 用户也可在“版本”页通过 SAF 导入外部 compat pack zip;导入后同样只进入已安装包列表。 ## 4. Payload 导入与版本匹配 1. 用户在设置页选择 `SlayTheSpire2.zip`,直装版从 APK assets `payload/SlayTheSpire2.zip` 解压,或在 Steam 中心使用自己拥有 STS2 的 Steam 账号通过 SteamPipe 下载。 2. zip 路径由 `PayloadManager` 复制 zip 到私有临时文件并计算 sha256;Steam 路径先解析并筛选 depot manifest,再把 prepared manifest 直接交给 `SteamDepotDirectoryDownloader`,避免正式下载阶段重复拉取 manifest。下载目录是按 branch、已选 app/depot/manifest 与下载布局版本生成的稳定 `/steam/downloads/payload-/`,随后调用 `PayloadManager.importPayloadDirectory(...)`。 - chunk worker 可在 Steam 中心选择 1 / 2 / 4,默认 2。worker 只负责并发请求与解密/解压,结果通过有界通道交给单 writer 按 offset 落盘;在途内存预算按 JVM 最大堆自适应,并限制在 16–64 MiB。 - 未完成文件写为 `*.steam.part`。同一 fingerprint 重试时,目录下载器会按 manifest chunk checksum 校验已有区间并只补缺失 chunk;完整正式文件也会经过长度和 manifest SHA-1 严格检查后复用。全部文件校验通过后才把 part 原子改为正式文件名并进入 payload 导入。下载至安装完成全程持有 `/steam/downloads/locks/payload-download.lock` 全局文件锁,防止 Activity 重建或不同 branch 并行任务同时修改 staging/payload store。旧 `staging-*` / `failed-*` 会清理,其他 fingerprint 任务保留 7 天后清理。 - Steam 目录若返回 `use_as_proxy`,CDN 传输按 proxy route → origin route 回退并遵守 bypass 类型;depot auth token 仅用于 Steam 返回的 CDN origin/proxy,不会转发到 Steam Community/API/图片的 rmbgame 兼容访问。取消任务会取消 coroutine,并继续向下取消当前 OkHttp Call。 3. staging 目录统一校验;若用户 zip 顶层只有一个目录且必需文件都在该目录内,导入器会先把该顶层目录展平: - `SlayTheSpire2.pck` - `release_info.json` - `data_sts2_windows_x86_64/sts2.dll` - `sts2.deps.json` - `sts2.runtimeconfig.json` 4. `PckPatcher` 只修改私有 PCK copy,禁用 Sentry autoload/gdextension 元数据,避免 Android 缺少桌面 Sentry 扩展导致启动前解析错误。 5. 写入 staging 中的 `.payload_manifest.json`,包含 `release_info`、`version`、`commit`、`sts2_dll_sha256`、PCK patch 结果等。 6. 按 manifest 身份生成 `payload_id`,原子安装到 `/payloads//game/`;同一 payload 已存在时只替换 payload store 中的该目录,不再复制到 `/game/`。 7. 导入/Steam 下载完成后创建或选择一个 launch profile,profile 会绑定该 payload;新建 profile 时会按 payload manifest 中的 `sts2_dll_sha256` 与 `version` 填入推荐 compat pack。匹配评分顺序为:精确 `sts2.dll` SHA、target 主版本、manifest 显式支持版本、offline bootstrap wildcard。schema 2 family 包在同等精确命中时优先;offline bootstrap 只有在没有任何 full compat 包命中当前 payload 时才会自动填入。schema 2 family 包会写入 `compat_pack_id` 和 `compat_target_id`;已有 profile 不会在每次导入/启动时被覆盖,只有旧 bundled schema 1 pack id 到 flat family pack 的升级迁移会自动改写。Steam 来源会在 `.payload_manifest.json` 的 `source.kind=steam_depot`、`source.steam.depots[]` 与 `source.steam.concurrent_chunks` 中记录。 8. 旧安装中的 `/game/` 与 `/game-versions//game/` 会在启动器 bootstrap 时尽量通过 rename 迁移到 payload store,避免大文件复制。 ## 5. 启动前检查 `GameSettingsActivity.launchGame()`: 1. 检查当前 launch profile 绑定的 payload 是否 ready;配置存在但本体缺失/被删除时不 fallback 到旧 `/game/`,而是提示重新导入/下载、切换配置或编辑配置;没有 payload 时提示导入,直装版可先解压内置 payload。 2. 如果 Android 兼容包开关启用: - 只读取当前 launch profile 的 `compat_pack_id` / `compat_target_id` 并解析已安装包。 - 若配置未选择兼容包或引用的包已删除,阻止启动并提示编辑启动配置。 - 若选中包 manifest 支持版本列表与 payload version 不一致,弹出风险对话框,用户可取消、去启动配置页或强制启动。 - 若选中包是 offline bootstrap wildcard,首次启动当前 `pack_id + target_id + compat_version + payload version + sts2.dll SHA` 组合时弹出风险确认;确认后只记住该组合。offline bootstrap 运行时会写 `/launcher/offline-bootstrap-probe.json` 记录反射 probe 结果。 3. 启动前 launcher 会为当前 launch profile account root 创建一份本地 `before-launch` 存档快照(默认只保留最近 5 个)。若 Steam Cloud 模式配置为“启动前拉取”或“完整自动”,且已保存 Steam refresh token,则随后拉取当前 account root 的 Steam Cloud 文件;若 WebDAV 模式配置为“启动前拉取”或“完整自动”,且已配置 WebDAV URL,则继续拉取同一 account root 的 WebDAV 文件。任一同步失败时会弹窗允许取消、打开对应云存档中心或跳过同步继续启动。 4. 启动后台线程执行 `GameLaunchPreparationManager.prepareForLaunch()`。 5. 准备完成后启动 `GodotApp` 并附加 `launch_prepared=true`。 ## 6. Launch preparation `GameLaunchPreparationManager.prepareForLaunch()` 顺序: 1. 配置 Android 私有 temp 目录,避免 Harmony/MonoMod 使用不可写 `/tmp`。 2. 规范化 Android locale 到游戏支持的语言 key,避免厂商 locale 字符串污染 `settings.save`。 3. 刷新内置 compat packs(如果开关启用)。 4. 输出当前 selected compat pack 的诊断日志:pack id、target、source zip sha、build branch/commit/dirty、notes。 5. 对旧安装的 payload 补做 PCK patch 记录。 6. payload PCK stamp 变化时清理 Godot texture import cache。 7. Stage overlay: - 兼容包开关关闭:删除 `/port_compat.pck`;启动前会先弹风险确认,用户选择继续才进入无兼容层准备流程。 - 有 selected pack:schema 1 复制 `/compat-packs//port_compat.pck`;schema 2 复制 `/compat-packs//variants//port_compat.pck` 到 `/port_compat.pck`。 - 无 selected pack:仅在兼容包开关开启但没有 profile 级兼容包选择的 fallback 准备路径中使用 APK assets `port_compat.pck` fallback;如果 profile 明确选择的 pack/target 缺失,会删除 staged overlay 并拒绝 asset fallback。 8. 准备 Mono publish 目录 `/.godot/mono/publish/arm64/`: - 复制 APK assets `dotnet_bcl/*`,但 `STS2Mobile.dll` 由 selected pack 决定。 - 兼容包开关关闭:删除 publish 目录中的 `STS2Mobile.dll`;`GodotApp` 的直接启动 fallback 也不会再从 selected pack 或 APK asset 强制补回。 - 有 selected pack:复制 selected `STS2Mobile.dll`;schema 2 的来源是当前 target variant。启动器会逐字节比较 selected DLL 与 publish 副本,只有内容完全一致才复用;文件长度相同或 publish mtime 更新都不能代替内容匹配,复制完成后还会再次校验,避免切换同尺寸 target variant 时继续加载旧版本 DLL。 - 无 selected pack:仅在兼容包开关开启但没有 profile 级兼容包选择的 fallback 准备路径中尝试复制 fallback `dotnet_bcl/STS2Mobile.dll`;如果 profile 明确选择的 pack/target 缺失,会删除 staged DLL 并拒绝 asset fallback。 - 复制当前 profile payload 目录中 `data_*/*` 的游戏 assemblies,跳过 `.so`,并保护 BCL/System/GodotSharp 等 runtime DLL。 - 使用 SharedPreferences stamp 避免不必要的大文件重复复制;compat stamp 包含已安装来源 zip SHA-256,用于识别 compat 包内容更新。payload/profile 变化时强制刷新游戏 assemblies,并清理 publish 目录里旧 payload 遗留的游戏 DLL/JSON。 `GodotApp` 仍保留 fallback:如果不是从设置页 prepared 启动,会自己调用同一准备流程;该 fallback 同样尊重兼容包开关,关闭时不会补回 `STS2Mobile.dll`。游戏通过 Android 兼容层的退出回设置路径触发 `GodotApp.restartToSettingsFromGame()` 时,会写入 `/launcher/expected_clean_game_exit.json`;下次设置页启动时会创建一份本地 `clean-exit` 存档快照,如 Steam Cloud 或 WebDAV 模式为完整自动,还会尝试上传当前 launch profile account root 的本地变化。 ## 7. Godot 启动与 runtime 入口 `GodotApp.getCommandLine()`: - 添加 renderer/display 参数。 - 不再根据 `fullscreen_render_size` 追加 Godot `--resolution`;该字段由 compat 在游戏运行中动态应用到根 renderer viewport。 - Android 高刷新路径由 `android_high_refresh_rate_enabled` 控制,默认启用:APK manifest 声明 `android:appCategory="game"` / `android:isGame="true"`,让 OEM 游戏/GPU 调度识别 `GodotApp`。启动、恢复前台、获得焦点和 Godot 主循环开始后的请求由 Activity 级 `HighRefreshRateController` 以 generation 合并,只在 Activity 已 resumed、有焦点、Godot `SurfaceView` 已 attach 且 `Surface` 有效时实际执行。控制器绑定 `SurfaceHolder.Callback`,为 Surface 生命周期维护独立 surface epoch,以 100/500/1500ms 有限重试等待渲染 Surface,并在失去焦点、`onPause`、`onDestroy` 或 `surfaceDestroyed` 时取消旧 generation/epoch 的重试和验证。Android 12+ 对每个有效 Surface epoch 只执行一次 `Surface.setFrameRate(..., CHANGE_FRAME_RATE_ALWAYS)`;有显式高刷 mode 时使用对应 `preferredDisplayModeId`,仅有 alternative refresh rate 时清空 mode ID 并使用 `preferredRefreshRate`,避免非零 mode ID 导致 rate 被忽略。约 1.2 秒后读取实际 mode/Hz,输出 `HighRefresh{state=verified}` 或 `verification_mismatch`。该路径不使用 `SurfaceControl`。关闭开关会使未完成工作失效并拆除 Surface callback。`GodotApp.onWindowFocusChanged()` 只更新 Java 侧状态和高刷请求,不再手工调用 `GodotLib.focusin/focusout`,Godot Activity 本身的 pause/resume 路径是 native 焦点派发的唯一来源。 - 默认配置 `--log-file` 到当前 profile 日志目录 `/instances//logs/godot.log`,没有 profile 时 fallback 到 `/logs/`;若附加设置 `log_level=off`,则不传 `--log-file`,完全禁用新的 `godot.log` 写入。 - `Sts2Application` 会在主进程早期启动应用内 logcat 采集器,统一写入全局 `/logs/sts2.log`;启动准备和 `GodotApp` 进入当前 profile 后也继续使用同一个全局文件,不再写入 `/instances//logs/sts2.log`。每次启动游戏会像 `godot.log` 一样把旧全局 `sts2.log` 归档为 `sts2YYYY-MM-DDTHH.mm.ss.log` 并只把最新采集写入 `sts2.log`;输出采用紧凑 `level tag message` 格式,例如 `I DOTNET [STS2Mobile] ...`,采集过滤遵循附加设置 `log_level`(`off`→停止采集、`info`→I/W/E、`debug`→D/I/W/E、`very_debug`→V/D/I/W/E)。该文件用于补充 `godot.log` 抓不到的 Java/Godot/Mono stderr/native 顶层日志(例如 `[STS2Mobile]`),但普通 app 只能读取自身 UID/进程可见 logcat,完整设备级日志仍需 ADB。 - 固定追加 STS2 原生命令行 `--force-steam off`,让原版 `NGame.InitializePlatform()` 即使在 Harmony/MonoMod detour 失效的 ROM 上也走内置 Steam 跳过分支,避免继续尝试加载桌面 `steam_api64`。 - 根据附加设置中的 `log_level`(默认 `info`,可选 `off` / `debug` / `very_debug`)追加 STS2 原生命令行 `-log `,覆盖 `Generic`、`Network`、`Actions`、`GameSync`、`VisualSync` 的运行日志等级;`off` 时不追加 STS2 `-log` 参数,Debug/Very Debug 会增加日志量并在下次启动生效。 - 根据附加设置中的 `android_performance_overlay_enabled` 写入或清理 `/launcher/enable_debug_menu.flag`;开启后 compat overlay 加载 `godot-debug-menu` 详细性能面板,默认关闭。 - 可选应用内快捷面板(`android_in_game_overlay_enabled`,默认关):`GodotApp` 用 `addContentView` 叠可拖动、自动贴边并避开系统手势/刘海安全区的“快捷”入口,点击打开从左侧滑入的无标题快捷抽屉,宽度约占可用屏幕 40%,左侧使用竖向图标页签区分功能页,**不**申请系统悬浮窗权限。抽屉右侧暗色空白区和系统 Back 优先关闭抽屉,所有主操作目标至少 48dp;快捷页在开发者工具开启时用带图标的 MD 卡片展示当前 profile / payload / compat,从附加设置返回游戏时会按当前开关拆除或重建入口。开发者工具(`android_dev_tools_enabled`)启用检查器;写入需 `android_dev_inspector_writable`。只有当前的 **full compat** 包包含 C# `DevToolsHost`;offline bootstrap 或旧完整包会明确提示检查器不可用。Host 独立于其余可选补丁启动,轮询 `/launcher/devtools/request.json`,用 `host.json` ready marker 公布就绪状态,并为 protocol 2 请求原子写入各自的 `response-.json`(客户端仍兼容旧 `response.json`);只读检查器请求在 host 已就绪但未响应时有限重试一次。检查器 Runtime 页支持 STS2/compat C# 根对象反射浏览/简单值改写;Scene 页 scene tree 默认全折叠,每行只显示节点名与按类型字符串稳定散列的高对比类型色,自定义类型隐藏 `managed / native` 斜杠后的 native type;缩进区用 Canvas 自绘 Godot/Windows 风格连接线与加减框,仅 tree 列表超宽时启用横向滚动,打开 Node/Godot 对象后列表强制测量为父容器可用宽度且不可横向滚动,左侧按钮和缩进区切换展开,正文进入 Node,右侧不再有进入按钮。Node 标题/固定信息不显示 Path;Node 与其他 Godot 自定义对象属性使用 `ToString()` 风格预览,并可通过对象实例引用继续嵌套下钻。不可编辑且不可下钻的信息会显示复制图标,点击正文或图标都复制值。顶部统一为等尺寸图标按钮且只在存在返回目标时显示返回键。可写模式下可编辑简单属性和执行临时 GDScript(变量 `root` / `tree` / `node`);非 Nil 返回值通过可选中、可复制的结果 Dialog 展示,无返回值只提示完成,不提供与脚本能力重复的独立 Node 方法调用入口;写入/脚本操作审计写入 `/logs/dev-tools.log`。它还支持重启游戏进程与 companion 设置 runtime apply。实时日志默认 tail 当前 profile 的 `godot.log`,可切换全局 `sts2.log`,日志正文用 `RecyclerView` 按行复用并按等级着色,右侧提供顶部、底部、自动贴底和筛选齿轮按钮;日志源、等级筛选与搜索框默认隐藏,点齿轮后显示。由于 `GodotApp` 仍使用 Godot DeviceDefault theme,抽屉中的 Material confirmation/edit dialog 必须用 `Theme.Sts2ExtraSettings` wrapper 创建。 - 如果当前 profile payload 的 `SlayTheSpire2.pck` 存在,添加: ```text --main-pack /payloads//game/SlayTheSpire2.pck ``` - 否则解压并使用 `bootstrap.pck`。 patched Godot/.NET runtime 随后在 Mono publish 目录中寻找并加载: ```text STS2Mobile.dll STS2Mobile.ModEntry ``` 调用入口: - `InitializeGodotSharp(...)`:初始化 GodotSharp bridge。 - `Apply()`:创建 Harmony 实例并应用 Android 兼容 patches。 ## 8. STS2Mobile patch 顺序 当前 `ModEntry.Apply()` 主要顺序: 1. temp 目录配置、build info 日志与 `HarmonyAndroidCompat` 后端准备。Android 上默认贴近 `../s2` 的 minimal bootstrap:不启用旧 native resolver / `DMDType=cecil` override,但会在真正的 `MonoMod.Utils` / `MonoMod.Core` 程序集上强制 MonoMod 使用 Android/Mono 后端,避免 HarmonyOS 等 ROM 被误判为 Posix/Linux 后在 `HarmonyLib.PatchFunctions.UpdateWrapper()` 中抛 `NotImplementedException`;`monomod_android_libc_shim` 仍由 AndroidSystem 按需提供指令缓存刷新和 `/proc/self/mem` executable-page patch fallback。`HarmonyMethodReferenceImporterShim` 会在后续大量 Harmony patch 前自检 `MMReflectionImporter` 是否丢失 STS2 方法引用上的 required/optional custom modifiers,必要时用极窄 postfix 原地修正带 modifiers 的 `sts2` 方法导入,避免普通 MOD patch 原方法体时生成无法绑定的动态 `MemberRef`。`EarlyLocalizationFallbackPatches` 会在普通 MOD 加载前保护 `LocString.GetFormattedText()`:Android/Mono 若在 MOD initializer 的 `PatchAll` 阶段提前运行游戏 UI 类型静态构造、而 `LocManager.Initialize()` 尚未执行,只临时返回 `locTable.locEntryKey` fallback;`LocManager.Initialize()` 正常结束后该 fallback 失效,后续本地化异常仍按原样抛出。`DeferredModPatchQueue` 会在普通 MOD initializer / `PatchAll` 窗口拦截 `PatchProcessor.Patch()`,对目标为 `sts2` 程序集 Godot/UI 类型(`MegaCrit.Sts2.Core.Nodes.*`、`MegaCrit.Sts2.addons.*` 或 `Godot.Node`/`Control` 派生类,且存在静态初始化器)的用户 MOD patch 排队,等 `ExecuteEssential` 完成 `LocManager.Initialize()`、`ModelDb.Init()`、`ModelIdSerializationCache.Init()`、`ModelDb.InitIds()` 以及原版网络 `MessageTypes.Initialize()` / `ActionTypes.Initialize()` 后按原顺序重放;模型类 patch 不进入该队列,避免破坏 MOD 的 `ModelDb.Init` 前置 hook,也不能漏掉原版网络类型表初始化,否则单人战斗结束写 `CombatReplay` 时也会因 `INetAction` 无法映射 ID 而失败。若 MOD 在 `ModelIdSerializationCache.Init()` 前误调用 `AbstractModel.InitId()`,兼容层只跳过该早调用,后续 `ModelDb.InitIds()` 仍会统一完成排序 ID 初始化。早期初始化不得调用 Godot C# API(例如 `OS.GetName()` / `ProjectSettings.GlobalizePath()`),避免 Godot `StringName`/JNI 尚未稳定时崩溃;静态/虚方法 Harmony self-test 默认跳过,仅在 `/launcher/enable_harmony_selftest.flag` 存在时运行,旧 bootstrap 仅在 `/launcher/enable_old_harmony_compat_bootstrap.flag` 存在时作为诊断启用。 2. `PlatformPatches` 与 `SavePathPatches` 作为保命 patch 最先独立应用;前者跳过桌面 Steam 初始化,后者重定向当前 launch profile 的存档/设置路径。两组 patch 分别捕获异常,避免后续诊断或 UI patch 在特定 ROM 上失败时导致原版 `steam_api64` 路径重新执行。 3. BaseLib/RitsuLib/ModelDb/UnlockState 兼容。 - 关键时序不变式:MOD 如 YuWanCard/BaseLib 用 `ModelDb.GetEntry` 的 Harmony postfix 给自定义内容 ID 加命名空间前缀(如 `ENCOUNTER.YUWANCARD-KILLER_ELITE`),并按 type **永久缓存**第一次 `GetEntry` 结果。PC 上每个 MOD 的 `PatchAll` 在 `ModManager.Initialize`(`ExecuteVeryEarly`)期间运行,严格早于 `ModelDb.Init`(`ExecuteEssential`),因此 ID 计算时前缀 patch 早已就位。兼容层必须**在 MOD patch 全部应用前,绝不对任何模型类型调用 `ModelDb.GetId`/`GetEntry`**,否则会污染前缀缓存并把模型注册到错误 key。 - `ModelDbInitPatch` 把 `ModelDb.Init` 替换为干净的 two-phase,并分两层处理占位: - **早期原版占位**:`ModLoaderPatches` 在加载任何 MOD 之前预注册 `AbstractModelSubtypes.All`(仅原版)。Android/Mono 下 MOD initializer Harmony patch 某个 getter(如 HextechRunes patch `UnlockState.Relics`)会提前触发 `UnlockState..cctor`,走 `ModelDb.AllEncounters -> Act` 等原版模型;BaseLib post-mod-init act patching 与 MOD 静态构造(如 `wuwancients.HiddenSeaRecord..cctor` 引用多个原版遗物)也会提前触发 `BowlbugsNormal..cctor -> MONSTER.BOWLBUG_EGG`。原版类型不带命名空间前缀,提前算 ID 不会污染任何 MOD 的 `GetEntry` 前缀缓存。兼容层会记录早期占位 id 对应的 owner type。 - **MOD initializer shield**:每个普通 MOD 的 `TryLoadMod` 调用期间,`ModelDb.Contains(Type)` 会对“非原版程序集类型,但当前 id 只命中早期原版占位”的情况短暂返回 `false`,还原 PC 上 MOD 初始化时 `ModelDb` 尚未被原版模型填充的行为。这样 RitsuLib/Valencina 这类 MOD 在初始化中构造与原版同名的模型(如 `Taunt`)时,不会因为 Android 早期原版占位误判 `DuplicateModelException`。该 shield 不隐藏原版类型、不隐藏同一 type 的真实重复,也不会在 phase 1/phase 2 后生效。 - phase 1:`ExecuteEssential` 中、调用 `ModelDb.Init()` **之前**,按**最终** `ModelDb.GetId(Type)`(MOD GetEntry 前缀此时已生效)补齐全部模型(含 MOD 自定义类型)的占位;已有的原版占位跳过。这样在 MOD 的 `ModelDb.Init` prefix(在 `Priority.Last` phase 2 之前运行)提前触发 MOD 间静态构造(如 `RELIC.LONG_SNAKE_NECKLACE`)时不会缺失。 - phase 2:`InitPrefix`(`Priority.Last`)在占位之上原地运行真实静态/实例构造器,跳过原版 one-pass body。部分 MOD 的 `ModelDb.Init` prefix 会自己返回 `false` 并让 Harmony 跳过后续 prefix,因此兼容层同时安装 `Priority.First` postfix 与 `ExecuteEssential` 后置兜底,确保构造 phase 一定执行。 - 真正的模型构造仍保留在 `OneTimeInitialization.ExecuteEssential -> ModelDb.Init`;用户 MOD 的 `ModelDb.Init` prefix/postfix 仍会执行(prefix `Priority.Last`,跑完后返回 false 跳过原版 one-pass body,postfix 照常运行);构造前后会清理早期占位枚举产生的 `ModelDb` / 模型实例派生缓存。 - 自定义模型 ID 完全交给原版 `ModelDb.Init` + MOD 的 `GetEntry` patch 自然产生,不再人为迁移 key 或做动态兜底。 - `EarlyLocalizationFallbackPatches` 是同一类 Android/Mono eager cctor 问题的本地化侧保护:例如 MinionLib patch `NPotionHolder.UsePotion()` 时,Harmony wrapper 生成可能提前跑 `NPotionHolder..cctor -> HoverTip..ctor -> LocString.GetFormattedText()`;此时 `LocManager.Initialize()` 还在后续 `ExecuteEssential` 中,直接抛异常会让 `NPotionHolder` 在整个进程内永久失败。该补丁只覆盖 `LocManager` 完成前的格式化失败,不改变 `LocManager.Initialize` 的生命周期点。若 UI 类型静态字段直接链式读取 `LocManager.Instance.GetTable().GetRawText()`,则由 `DeferredModPatchQueue` 延后用户 MOD 对该 UI 类型的 Harmony patch,避免 `.cctor` 在 very-early 阶段执行。 - `UnlockStateCompatPatches` 会在 `ModelDb` 初始化完成前让 `ModelDb.AllEncounters` 返回空列表,避免 Android/Mono 因 Harmony patch getter 提前运行 `UnlockState..cctor` 时枚举到尚未构造/注册完成的 MOD encounter;初始化完成后会修复可能提前创建的 static readonly `UnlockState.all`,恢复正常“全部 encounter 已见过”的语义。 4. Release info、settings、display、font/UI scale;其中 `AppPaths` 从 Mono publish 目录或 Android 进程包名推导 `` 后读取 `launcher/selected_instance.json`。`DisplaySettingsPatches` 是根窗口 `ContentScaleMode` / `ContentScaleAspect` / `ContentScaleSize` 的唯一协调者,逻辑布局只有 `FixedAspect > UiScaleAuto` 两种 owner,根 Window 始终为 `CanvasItems`。Auto 比例使用 `UiScalePatches` 提供的 UI scale target,固定比例使用对应 fixed target;owner 会在调用任何 Godot Window setter 前发布。所有 setter 均 compare-before-set,同时使用重入保护和 single-flight deferred 队列;`UiScalePatches` 不再直接写 `ContentScale*`,`NGlobalUi.OnWindowChange` / `NMainMenu.OnWindowChange` 只抑制原争写并请求一次延迟重算。`NGame._Notification` 只在 `NotificationApplicationResumed` 时失效 settings cache 并合并一次 deferred runtime apply,窗口焦点通知不再重建 viewport。每个 resume generation 在 canonical apply 后会 deferred 校验一次 Mode/Aspect/Size/Factor;若目标被覆盖,最多 compare-before-set 修复一次并再做只读终检,仍不一致只输出 warning,不进入循环重试。target revision 会让校验跳过期间发生的合法新设置,避免旧 resume 目标覆盖用户刚修改的显示配置。 `fullscreen_render_size` 不参与上述 owner 或 scene Window 的逻辑 ContentScale;游戏内修改设置后,compat 会在同一次 runtime apply 中立即切换根 renderer render target。顺序固定为先完成高层 `ContentScale*` setter,再调用 `RenderingServer.ViewportSetRenderDirectToScreen(rootRid, false)`、`ViewportSetSize(rootRid, target)` 和 `ViewportSetGlobalCanvasTransform(rootRid, scaledTransform)`。这只改变 RenderingServer 侧 RT 尺寸与 canvas 输出比例,scene `Window` 的 Size/ContentScale/输入逆变换保持原样,Android `Surface` 也不改变;不得调用 `SurfaceHolder.setFixedSize()` 或 `ViewportAttachToScreen()`。请求 `0x0` 时恢复当前 native attachment 的 RT 尺寸和高层 ContentScale 计算出的原始 global canvas transform。非零预设按当前 native attachment 比例采用 Expand 覆盖语义:以请求矩形为最低覆盖范围并保持 native 宽高比,例如 `2400x1080` 的超宽 attachment 选择 `1280x720` 后实际 target 为 `1600x720`。自定义目标长边上限为 `max(4096, native 长边)`,避免高分辨率设备恢复原生时被反向截断。根 Window `SizeChanged`、application resume 和一致性 repair 都会在逻辑 setter 之后重投这三个 RenderingServer 状态,防止窗口变化或高层 ContentScale 写入把动态目标复位。`global_scale` 始终独立作为 `ContentScaleFactor` 生效,`ui_font_scale_percent` 也独立;`user://ui_scale.cfg` 的原版 UI scale 在 FixedAspect 期间保留选择但不控制 Size,回到 Auto 后自动恢复。显示设置还会读取 `android_screen_rotation_mode`:`auto` 映射 Godot `SensorLandscape`,`user_landscape` 由 Java `GodotApp` 的 `SCREEN_ORIENTATION_USER_LANDSCAPE` 托管,兼容层不再调用 Godot `ScreenSetOrientation()` 覆盖,`landscape` 映射普通横屏,`reverse_landscape` 映射 180° 横屏;旧 `android_flip_screen_180` 只作为兼容 fallback 和同步字段保留。Java `GodotApp` manifest 默认 `sensorLandscape`,并在 `onCreate`、`onResume` 与 Godot 主循环开始后按同一字段调用 Android `setRequestedOrientation()`,避免 Activity 层固定横屏导致自动 180° 旋转无效。 5. 移动端 layout/input、事件/商店/奖励/战斗背景等 UI 修正。 6. Android UI safety、游戏内设置入口、shader overlay、transition material 防黑屏、Android back/touch/controller、奖励/商人二次确认、移动端 tooltip 显示策略、tap preview、hand layout。`mobile_tooltip_mode` 默认 `immediate`,保持 PC 端悬停即显示;在附加设置“设置 → 操作 → Tooltip 显示”切到 `long_press` 后,`MobileTooltipPatches` 会在 `NHoverTipSet.CreateAndShow*` 前建立当前 owner 的长按计时,允许原版创建并完成对齐后立即隐藏 tooltip,并通过 tooltip owner 的 `GuiInput` / `MouseExited` 与 `NGame._Input` 共同跟踪触摸,只在同一触点按住约 1 秒且未明显拖动时临时显示,松手或移动过大后再次隐藏(不因单纯 `MouseExited` 取消,以免 hover 动画导致控件在静止手指下移动)。若原版在长按过程中频繁 `Clear()`/重建 hover tips,兼容层会保留当前 owner/计时状态,避免计时被每帧重置;游戏内设置页切换该选项时会刷新 `AndroidSettingsBridge` 缓存并立即移除或显示已有普通 hover tooltip,`hidden` / `long_press` 模式会阻止后续普通 hover tooltip 创建,但不拦截 inspect card/relic/potion 等显式详情页面自己的说明区域。 7. intent animation、quick restart、lifecycle/performance。`QuickRestartPatches` 在 pause menu 提供 Android 内置“重打/Retry”按钮:快速重开会先等待当前 run save 任务,再读取 autosave;淡出后清理旧 run,并执行原版保存恢复入口(`RunManager.SetUpSavedSinglePlayer()`,`v0.107.0` 为 `SetUpSavedSingleplayer()`;返回 `Task` 的版本会等待完成)以完整初始化 `NetService` / `MapSelectionSynchronizer` 等同步器后才调用 `NGame.LoadRun()`,避免资源预加载关闭或 IO 较慢时新 `RunState` 提前进入地图初始化、触发 `MapSelectionSynchronizer.GetVote()` 越界;若淡出后任一步失败,会先尝试 `FadeIn()` 解除黑屏遮罩,再显示错误弹窗。`AndroidAssetCacheLifecyclePatches` 只修正 Android 资源释放生命周期:原版私有 `AssetCache.RemoveAndGetResource()` 仍照常从 cache 索引移除条目,原版 asset-set 选择、missed-set 清理和兼容层 protected-path 过滤均不变,但它的返回值会被置空,从而阻止 `UnloadAssets()` / `UnloadMissedCacheAssets()` 显式 `Dispose()` 仍可能被节点、对象池或异步任务持有的 Godot `Resource`。`LifecycleAndPerformancePatches` 的预加载范围、时机和缓存保护策略保持不变:它在 `NMainMenu._Ready` 后启动安全 deferred preload,并在需要细分或额外 warmup 时接管原版 `LoadCommonAndMainMenuAssets()`: - `preload_enabled`:总开关,默认 `true`。 - `preload_startup_common_enabled`:主菜单后加载 `AssetSets.CommonAssets`,默认 `true`。 - `preload_startup_main_menu_enabled`:主菜单后加载 `AssetSets.MainMenuSet`,默认 `true`。 - `preload_runtime_enabled`:保留 run/act/room 资源预加载,默认 `true`。 - `preload_menu_hotspots_enabled`:额外实例化单人/多人常用子菜单,默认 `false`。 - `preload_vfx_mode`:`off` / `hot` / `full`,默认 `off`;`hot` 仅实例化高频战斗 VFX,`full` 递归 `res://scenes/vfx/**/*.tscn`。 - `preload_vfx_tree_warmup_enabled`:实际播放 VFX 预热,默认 `false`;开启后把 VFX 临时加入场景树跑帧,让粒子、材质和首批动画帧真正执行。 - `preload_vfx_tree_warmup_scope`:VFX 实际播放范围,默认 `safe`;`safe` 只跑安全名单,包含常见战斗 VFX 与猎人小刀/匕首 VFX;`all` 会逐个尝试让 `res://scenes/vfx/**/*.tscn` 全部进树跑帧,单项失败会记录并跳过,主要用于卸载重装后填充 Godot shader cache 的高内存诊断,不会自动执行卡牌/怪物战斗逻辑。 - `preload_vfx_tree_warmup_frames`:普通安全名单 VFX 跑帧数,默认 `3`,启动器提供 `1/3/6/12`;小刀/匕首 VFX 会使用更高下限。 - `preload_vfx_retain_cache_enabled`:保留已预热 VFX 场景缓存,默认 `false`;开启后与缓存保护配合减少战斗中重复首次实例化。 - `preload_combat_animation_warmup_mode`:战斗动画预热,默认 `off`;`safe` 会在当前战斗房间创建后,对实际出场的玩家/怪物先走原版 `SetAnimationTrigger()` 真实触发路径,再短帧采样安全 Spine 动作(攻击、施法、受击、猎人小刀等)并恢复原动画/动画状态;`all` 会在跳过死亡、复活、逃跑、睡眠/醒来等危险 trigger 的前提下尽量走真实 trigger,并枚举当前房间所有 Spine clip 短帧采样,覆盖更广但耗时更高,仅建议高内存诊断使用。预热期间会临时覆盖当前战斗房间的全屏遮罩,背后的角色/怪物仍实际绘制以触发 GPU/Spine 热身,但不会把采样动作暴露给玩家。 - `preload_combat_animation_warmup_frames`:每个战斗动画 trigger/clip 的采样帧数,默认 `1`,启动器提供 `1/2/3/6`;采样发生在当前战斗房间节点真实存在后,不会在启动页实例化全游戏所有怪物。 - `preload_combat_hit_effect_warmup_enabled`:战斗命中特效预热,默认 `false`;开启后在当前战斗房间遮罩后走真实命中渲染路径,实例化伤害数字、命中火花、斩击/钝击 VFX,并以 0 音量触发当前怪物和常见敌人受击 FMOD 事件来预热 sample data,不会改血量、出牌或战斗历史。 - `preload_combat_code_enabled`:额外预热攻击/伤害/VFX 托管方法,默认 `false`。 - `preload_shader_mode`:`off` / `load_resources`,默认 `off`;仅加载已知 shader 资源,不保证 GPU pipeline 已完成编译。 - `preload_protect_warm_cache_enabled`:保护已预热缓存,默认 `true`;compat 层会过滤原版 `AssetCache.UnloadAssets()` / `UnloadMissedCacheAssets()` 对 Android warm cache 的卸载,降低房间/战斗资源集切换后重复首次加载的概率。卡牌 banner/frame 与卡面 blur/mask `ShaderMaterial` 额外作为 Android runtime pinned assets 固定保护,即使运行时预加载关闭、这些资源先通过 missed-cache 路径加载,也不能被房间切换 cleanup 释放,否则后续 `NCard.Reload()` 复用材质时会抛 `ObjectDisposedException: Godot.ShaderMaterial`。 - `preload_gameplay_assets_enabled`:实战资源补全包,默认 `false`;额外收集并加载能力/遗物/药水图标、意图、角色、Act、遭遇/怪物和保留的 VFX 资源,内存占用更高,主要供“尽量全热完”测试使用。 - `preload_learned_assets_enabled`:学习漏载资源,默认 `true`;预加载完成后实战中首次 miss 的 `res://` 资源会记录到 `/launcher/preload-learned-assets.json`,最多保留 512 条,后续启动自动加入 warm cache。 - 隐藏诊断字段 `preload_debug_enabled` 只供 ADB 自动化和本地排查使用;`tree_warmup` / `aggressive` 预加载 profile 会开启安全名单 VFX 进树跑帧、当前房间安全战斗动画预热、战斗命中特效/受击音效预热,用来判断卡顿来自资源未覆盖还是未触发实际渲染/动画/音频预热;`vfx_full_tree` 会启用全 VFX 场景进树探测;`animation_full` 进一步启用当前房间全部 Spine clip 采样并同时启用全 VFX 场景进树探测。摘要日志会分别统计 `resource_only`、`tree_warmed`、`tree_ineligible` 和 `tree_failed`,VFX warmup 完成日志还会输出 `/shader_cache` 的前后文件数/字节数;显式开启 `preload_debug_enabled=true` 后,预加载完成后继续发生的 `AssetCache.LoadAsset` miss 会标记 `postStartupPreload=true` 并按资源类别汇总,也会输出逐资源/逐动画细节。Godot 渲染侧会把已编译 shader 变体持久写入 `/shader_cache/**.cache`,因此首次真实绘制后的 shader 编译收益可跨进程和设备重启保留;兼容层自己的 protected warm cache 只是 `PreloadManager.Cache` 内存保护,不跨进程。 Android 附加设置页在顶部“系统”分区的系统卡片中显示 `preload_enabled` 总开关、预加载下方默认开启的 `android_high_refresh_rate_enabled` 高刷新请求开关,以及默认关闭的性能 overlay 开关;预加载右侧箭头打开预加载详细管理 BottomSheet,默认不会自动展开。总开关开/关只写入自身,不改写上述细分项目;BottomSheet 的“恢复默认”只重置细分项目,不修改 `preload_enabled`。预加载详细 BottomSheet 刚打开时内容区上滑会切到全屏展开,展开后滚动内容区不会下拉关闭,只有顶部手柄接受下拉关闭手势。默认组合保持本次改动前的预加载行为,不额外启用 VFX/菜单/shader/code/gameplay warmup,但会保留保护已预热缓存与学习漏载资源。 8. LAN bootstrap。`LanMultiplayerBootstrapPatches` 在主菜单就绪后才尝试应用本地 LAN 兼容补丁;若 `settings.save` 中 `lan_multiplayer_enabled=false`,或已加载 `sts2_lan_connect` / STS2 Game Lobby 大厅 MOD,`LanMultiplayerPatches` 会整组跳过,避免 Android LAN host/join、玩家 ID 等适配与大厅 MOD 自己的联机协议 profile 冲突。内置 LAN 补丁只处理 Android transport/UI/settings/player/save 兼容,不 patch `MessageTypes.ToId`、`MessageTypes.TryGetMessageType` 或 `NetMessageBus.TryDeserializeMessage`,也不维护固定消息表;消息类型发现、排序、ID 与序列化/反序列化始终由当前 payload 对应版本的原版实现负责,因此 Android 与未修改 PC 使用同一 wire protocol,普通 MOD 自定义 `INetMessage` 也继续按原版规则参与排序。启用本地 LAN patch 时,兼容层还会拦截多人读档 canonicalize 的本地玩家 ID:如果当前自定义平台/玩家 ID 不在 `current_run_mp.save` 的玩家列表中,会优先使用隐藏稳定字段 `lan_multiplayer_save_player_id`、旧自动 LAN ID 或单玩家存档中的唯一 `NetId`,避免用户修改自定义平台 ID 后旧多人存档被误判为不属于本机。 9. `ModLoaderPatches`。 10. save diagnostic。 11. `RenderDiagnosticPatches` 后置调度;它只用于设备/渲染信息采集,调度异常会记录但不阻断 Steam 跳过和存档路径重定向等核心 patch。 失败时 `ModEntry` 会按 patch group 记录异常;部分 patch 失败可能导致后续游戏启动不完整,因此 `sts2.log`(应用内 logcat 采集)、ADB logcat 和 `godot.log` 是首要诊断来源。 ## 9. Overlay PCK 加载 `ShaderCompatibilityPatches` 延迟等待 Godot main loop 就绪后加载: ```text OS.GetDataDir()/port_compat.pck ``` 成功后通过 `ProjectSettings.LoadResourcePack()` 挂载资源,并在节点加入树时替换已知桌面 shader 为 `res://shaders/mobile_compat/*`。 卡面/先古卡遮罩使用的 `res://shaders/blur/canvas_group_mask_blur.gdshader` 不再进入替换表,也不预加载或随 overlay 发布旧的 `mobile_compat/canvas_group_mask_blur_compat.gdshader`。该移动替代版在开启着色器兼容后可能把先古卡面渲染成纯白,因此保留原版 shader。 另外 `TransitionMaterialPatches` 会在 `NTransition._Ready` 后复制场景默认 `ShaderMaterial`,并在原版 `AssetCache.GetMaterial()` 返回 `fade_transition_mat.tres` / `fight_transition_mat.tres` 时返回缓存材质的副本。全局 disposal guard 已阻止 cache cleanup 显式释放资源;该补丁仍作为纵深保护,隔离 transition tween 对共享材质状态的修改并兼容旧兼容包行为。 `v0.107.0-beta`、`v0.107.1`、`v0.108.0` 与 `v0.109.0` target 的 `MapDrawingSceneCachePatches` 同样作为资源 owner 纵深保护:它拦截 `NMapDrawings.CreateLineForPlayer()`,让 v107-v109 地图画笔绘制/橡皮线条从 Android 兼容层自持有的 `PackedScene` 实例化,避免长期字段依赖已经离开 cache 索引的场景;橡皮线条会同步刷新 `_eraserMaterial`,保留原版保存时通过材质判断 eraser line 的行为。 是否启用由附加设置中的 `shader_compatibility_mode` 控制。 ## 10. 普通用户 MOD 加载 普通 MOD 不由 Android shell 直接注入游戏进程,而是由被 patch 后的原版 `ModManager` 加载。 `ModLoaderPatches` 行为: - Prefix 替换 `ModManager.Initialize()`,避免 Android 上高风险 IL transpiler;`v0.107.0` 起原方法返回 `Task`,跳过原方法时兼容层会返回 `Task.CompletedTask`,避免 `ExecuteVeryEarly()` `await` 到 `null`。`v0.107.1` 起原版用 `ModManager.State` 取代旧 `_initialized`,兼容层会反射写入 `Initialized`,并继续保留旧字段写入以兼容 `v0.107.0`。`v0.108.0` 保持该路径,并额外处理 `JoinFlow` 构造函数注入 `INetClientGameService`、Spine `SetAnimation()` 返回值移除、`AbstractModel` 构造器改用 `ModelDb.GetByIdOrNull()` 后对两阶段 placeholder 的同 ID 同 type 重复检测,以及原版 `ExecuteEssential()` 新增的 `AssemblyInfo.Init()` / `SavedPropertiesTypeCache.Init()` 启动顺序。`v0.109.0` 延续 Spine/JoinFlow API,并把 `ModelDb.Init` 改为带可选 `Type[]? injectedModelTypes`;兼容层通过 Harmony `__args` 兼容旧无参和新签名,正常 null 路径继续 Android two-phase 初始化,显式测试注入集合保留原版行为。版本新增网络消息继续由原版 `MessageTypes` / `ContentSorter` 初始化自动纳入排序,兼容层不维护版本消息清单。 - 设置原版私有字段 `_settings`、`_fileIo`、`_gameVersion`。 - 添加 assembly resolve fallback。 - 为对齐 PC 时序,在用户 MOD 的 Harmony patch 全部应用前不对任何 MOD 模型类型调用 `ModelDb.GetId`/`GetEntry`,也不提前调用完整 `LocManager.Initialize()`。原版模型占位提前到**加载任何 MOD 之前**(`ModLoaderPatches` 触发,原版不带前缀,安全,修复 MOD patch getter / MOD 静态构造引用原版模型的早访问);每个 MOD initializer 期间只隐藏非原版类型命中早期原版占位的 `ModelDb.Contains(Type)` 结果,避免同名模型误判;如果 MOD 因早期占位误判 ModelDb 已初始化而提前调用 `AbstractModel.InitId()`,兼容层会在 `ModelIdSerializationCache.Init()` 完成前跳过这次调用,等后续 `ModelDb.InitIds()` 统一设置排序 ID,避免提前分配或污染 net ID;MOD 自定义模型占位延迟到 `ModelDb.Init()` 之前的 phase 1,按最终 ID 进行。早期 UI 类型静态构造里的本地化格式化失败由 `EarlyLocalizationFallbackPatches` 临时兜底;直接 patch STS2 Godot/UI 类型且可能触发 `.cctor` 的用户 MOD patch 由 `DeferredModPatchQueue` 排队到 `ExecuteEssential` 初始化完成后重放。 - 扫描 `AppPaths.ModsDir`。该路径由当前 launch profile 决定: ```text # mods_mode=global /mods # mods_mode=isolated /instances//mods ``` - 跳过 `ReadSteamMods()`,不枚举 Steam Workshop。Steam 登录、游戏 depot 下载、Steam Cloud 与 WebDAV 存档同步均在 Android launcher 侧完成,不恢复桌面 Steamworks 到游戏进程内。 - 递归读取本地 MOD manifest,调用游戏原本的私有 scanner、dependency sort、TryLoadMod。 - 对 `mod_manifest.json` 自动生成 `.json` alias,以兼容当前 PC scanner 期望。 - 将 companion settings 中的启用/禁用状态投影回运行时 `ModSettings`。 ## 11. 关闭兼容包开关时 如果用户在附加设置中关闭 Android compat pack: - 启动前不会强制要求 selected compat pack。 - publish 目录中的 `STS2Mobile.dll` 会被删除。 - `/port_compat.pck` 会被删除。 - 游戏可能以更接近原版 PC 行为启动,但 Android 必需 patch 缺失,崩溃/黑屏/输入异常风险很高。 此模式主要用于诊断,不应作为普通推荐路径。 ## 12. 诊断入口 常用日志/检查: ```bash adb logcat | grep -E 'Sts2|STS2Mobile|GODOT' adb shell run-as com.megacrit.sts2re ls files/compat-packs adb shell run-as com.megacrit.sts2re cat files/launcher/selected_compat_pack.json adb shell run-as com.megacrit.sts2re ls files/.godot/mono/publish/arm64 adb shell run-as com.megacrit.sts2re cat files/logs/android-launch.log adb shell run-as com.megacrit.sts2re cat files/logs/sts2.log ``` 关键日志关键词: - `Selected compatibility pack for launch` - `Prepared compat entry dll` - `Prepared compat overlay` - `STS2Mobile Android port compatibility` - `Critical platform patches applied` - `Critical save path patches applied` - `CompatBuildInfo` - `Loading imported game PCK` - `Shader compatibility overlay pack load` - `[Mods] Android mod initialization loaded`