# MC 26.3 多加载器模组开发探针 —— 可行性评估与开发规划

> 状态：评估定稿 + 规划定稿
> 阶段：**仅验证，不含任何实际代码**
> 基线仓库：`https://github.com/yan-mc/minecraft-mod-dev`（已 clone 至 `ref-minecraft-mod-dev`，HEAD 为 1.20.1 / Forge 47.4.x 版本）
> 目标：基于该插件的思路，做一个面向 **MC 26.3** 的、支持 **Fabric / Forge / NeoForge** 的多加载器探针系统，作为 DSH 的模组开发辅助工具
> 许可：本项目 Apache-2.0（见 `LICENSE` / `NOTICE` / `THIRD_PARTY_NOTICES.md`）

---

## 0. 一句话结论

**可行。** 成立靠三个前提，三条都已用一手证据闭合：

1. **26.1 起 MC 完全去混淆，Yarn / intermediary 退役** → 共享库可以用一份源码编进三家，不再需要任何映射抽象层。
2. **三加载器 26.3 构建链全部就位，且统一 Java 25** → Loom / ForgeGradle 7 / ModDevGradle 都能直接产 26.3 开发环境。
3. **原探针 1,902 行里只有 131 行需要按加载器重写**（6.9%），另有 21 行是机械替换 → 分叉面极小且集中在两个文件。

补充两条规划期结论：

4. **端差异是硬边界，但影响面很窄**：只有 `ClientProbeServer.java`（788 行）引用 `net.minecraft.client.*`，其余 1,114 行零引用（见 §7）。
5. **三类 MOD（纯服务端 / 双端必需 / 单端可选）需要三类不同的断言方向**，其中「双端必需」的断言是**反向**的（期望连接被拒绝），这是一整类新能力（见 §8）。

---

## 1. 目标与放弃项

### 1.1 做哪些加载器

| 加载器 | 决定 | 依据 |
|---|---|---|
| Fabric | **做** | 生态最大；26.3 构建链完整；Loader 0.19.5 stable |
| Forge | **做** | 用户明确要求；26.3-66.0.13 已发布；ForgeGradle 7 就位 |
| NeoForge | **做** | 用户明确要求；26.3.0.90 已发布；ModDevGradle 2.0.148 就位 |
| Quilt | **放弃** | 见 1.2 |

### 1.2 为什么放弃 Quilt（一手证据）

- `meta.quiltmc.org/v3/versions/loader/{26.3,26.2,26.1}` 三个响应体**逐字节相同**（len=22063，SHA256 `0598C88F24CA1AF9…`，均 307 条，均**不含** `intermediary` / `hashed` 字段）→ 是版本无关的兜底响应，不是「支持 26.3」的证据。
- `meta.fabricmc.net/v2/versions/yarn/26.3` → `[]`；`quilt-mappings-26.3+build.1.jar` 与 `hashed-26.3.jar` 均 404。
- quilt-mappings 最新停在 `1.21.11+build.2`。
- quilt-loader 最新 `0.31.0-beta.4`（2026-09-04）；quilt-loom 最后 commit 2026-01-23（默认分支 `dev/1.15`，无 1.16+ 分支）；QSL 最后 commit 2026-01-14（默认分支 `1.21.5`）。
- QFAPI README 原文：`> As of Minecraft version 26.1, this project has been archived and will no longer be ported to new versions.`

**结论**：26.3 上 Quilt 无映射、无 loom、无构建链。但 quilt-loader 源码内含 `net/fabricmc/api/ModInitializer`、`net/fabricmc/loader/api/*`、`FabricModMetadataReader.java`，因此 **Fabric 探针 jar 大概率能在 Quilt 上直接跑** —— 属于「未验证的赠品」，文档写清即可，不为它做任何适配。

### 1.3 为什么插件服不是「第五个加载器」

Modrinth 实测（`project_type:plugin`）：合计 **19,170** 个（paper 18,118 / spigot 12,122 / bukkit 9,205 / purpur 10,449 / folia 3,702 / velocity 949）。
而 `plugin+fabric` = **99**、`plugin+quilt` = **49**。

插件服 = Bukkit / Paper API：无 mixin、无 FakePlayer、无客户端侧，是**另一条产品线**（v2 再议），不是第四个加载器。
真正的「mods + plugins 混合服务器」（Arclight / Mohist / Magma / Cardboard）才落在本项目的射程内。

---

## 2. 26.3 生态事实

### 2.1 版本矩阵（全部一手核对）

| 加载器 | 26.3 版本 | 构建插件 | Java |
|---|---|---|---|
| Fabric | Loader **0.19.5**（stable）<br>API **0.163.0+26.3** | `net.fabricmc.fabric-loom` **1.18-SNAPSHOT** | **25** |
| Forge | **26.3-66.0.13** | `net.minecraftforge.gradle` **`[7.0.17,8)`**<br>（插件门户 latest 7.0.40） | **25** |
| NeoForge | **26.3.0.90**（本机实装 0.88） | `net.neoforged.moddev` **2.0.148**<br>（NeoGradle 7.1.39 并列一等公民） | **25** |

补充核对点：

- Fabric `example-mod` 26.3 分支 `gradle.properties`：`minecraft_version=26.3`、`loader_version=0.19.5`、`loom_version=1.18-SNAPSHOT`、`fabric_api_version=0.163.0+26.3`；`build.gradle` 用 `it.options.release = 25`。
- Forge `minecraft.versions.toml`：`java="25"`、`minecraft="26.3"`、`mcp="20260918.230105"`、`changelog-base="66.0"`。
- NeoForge `gradle.properties`：`java_version=25`、`moddevgradle_plugin_version=2.0.148`、`eventbus_version=8.0.5`、mixin `0.17.4+mixin.0.8.7`。
- **mixin 解析结果二分**：Fabric / NeoForge 都解析到 `net.fabricmc:sponge-mixin:0.17.4+mixin.0.8.7`（枚举含 `JAVA_25`）；**Forge 解析到 `org.spongepowered:mixin:0.8.7`（枚举最高 `JAVA_21`）**。P0 V7 实测：即便如此，Forge 注入 Java 25 目标类**照样成功**，只打一句告警。⇒ `ServerLevelMixin` 若不删，也只需改 `compatibilityLevel`。

### 2.2 去混淆：本项目最大的简化

**起点是 26.1，不是 26.3。** 26.1 是第一个完全去混淆的正式版；1.21.11 是最后一个混淆版本。

三源证据：

- Minecraft Wiki `Java Edition 26.1`：*"the first Java Edition release version … to be fully unobfuscated without an accompanying obfuscated variant"*（2026-03-24 发布，Minimum Java = Java SE 25）
- Fabric 官方博客 2025-10-31：*"Intermediary will no longer exist; the game will use Mojang's names at runtime"*；*"for the first time, mod developers will now be able to see official names for method parameters and local variables"*；Loom 2.0 *"may provide a slim version that contains no support for remapping"*
- Fabric 文档 *Migrating Mappings*：*"Minecraft 26.1 is unobfuscated and includes parameter names, so there is no need for any obfuscation mappings."*

本机实物（`<HMCL_HOME>`）：

| 证据 | 实测 |
|---|---|
| `26.3-Fabric.jar` | 41,483,720 B / 34,227 entries / **10,737 个 `net/minecraft/*.class`** |
| 根目录短名混淆类（`^[a-z]{1,3}\.class$`） | **0 个** |
| `libraries/net/fabricmc/intermediary/0.0.0/intermediary-0.0.0.jar` | **572 B**，内含 `mappings/mappings.tiny` 仅 **25 字节**，原文 `v1\tofficial\tintermediary\n` —— **只有表头，零条映射** |
| 同目录 `intermediary-1.21.11.jar` | 797,685 B，含真实映射（`CLASS\ta\tnet/minecraft/class_7833` …） |
| 第三方编译产物 `pairedportals-1.0.0.jar` | 引用 **official 名**：`net/minecraft/server/level/ServerLevel` 63 次、`net/minecraft/resources/Identifier` 18 次、`net/minecraft/server/level/ServerPlayer` 11 次（Yarn 会叫 `ServerWorld` / `util.Identifier`） |

### 2.3 连锁影响：一整类「静默失败坑」消失

原探针里有三处专为混淆存在的妥协，现在**全部不需要**：

| 原位置 | 原内容 | 26.3 处理 |
|---|---|---|
| `ServerLevelMixin.java` | `@Inject(..., remap = false)` | 删掉 `remap` |
| `mcdev_probe.mixins.json` | `"refmap": "..."` 字段 | 删掉该字段，`compatibilityLevel` 改 `JAVA_25` |
| `build.gradle:198-208` | 自定义 devJar、显式「不 reobf」 | 整个块删掉 |

这三处都属于「改错不会报错、只会行为不对」的类别。它们消失是本项目最实质的收益之一。

**额外收益（部署侧）**：去混淆之后 `gradlew build` 的产物**就是可部署物**，没有 reobf 步骤，也没有「dev jar 与生产 jar 不同」的区分。这直接决定了 §9 的部署方案可以只做 build + copy。

---

## 3. 原探针解剖

### 3.1 三层结构

| 层 | 载体 | 规模 | 加载器耦合 |
|---|---|---|---|
| L1 DSH 插件 | `plugin/minecraft-mod-dev/index.js` | 1,405 行，14 个 `mcdev_*` 工具 | **0 处硬编码加载器**；唯一耦合是 gradle 任务名 |
| L2 HTTP 探针 | JDK `com.sun.net.httpserver` + gson | 32 条路由 | 仅注册表取名字 |
| L3 探针 Java | `lab/mcdev-probe/` | **1,902 行 / 9 文件** | 见 3.2 |

L1 的加载器耦合只有 4 处：

- `index.js:261` `const task = kind === 'server' ? 'runServer' : 'runClient'`
- `index.js:266` spawn `gradlew.bat`
- `index.js:1436` `runGradle(... 'runGameTestServer')`
- `index.js:1438-1439` 正则 `/All (\d+) required tests passed/` 与 `/(\d+) required tests failed/`

### 3.2 精确行数与耦合（实测）

| 文件 | 行数 | Forge 耦合 | 归属 |
|---|---:|---:|---|
| `ClientProbeServer.java` | 788 | 7 行 | 共享（改 7 行） |
| `ProbeServer.java` | 646 | 14 行 | 共享（改 14 行） |
| `ProbeCommands.java` | 107 | 0 | 共享 |
| `RunRecorder.java` | 77 | 0 | 共享 |
| `ProbeLogs.java` | 71 | 0 | 共享 |
| `FakePlayers.java` | 66 | 2 import | **分叉** |
| `McdevProbe.java` | 65 | 10 import | **分叉** |
| `TickControl.java` | 56 | 0 | 共享 |
| `ServerLevelMixin.java` | 26 | 0 | 共享 |
| **合计** | **1,902** | **33 行** | — |

拆解：

- **需整份重写：131 行**（`McdevProbe` 65 + `FakePlayers` 66）= **6.9%**
- **机械替换：21 行** —— 全部是 `ForgeRegistries.BLOCKS.getKey(...)` / `ForgeRegistries.ITEMS.getValue(...)` / `ForgeRegistries.ITEMS.getKey(...)`，去混淆后可直接换成原版 `BuiltInRegistries.BLOCK` / `BuiltInRegistries.ITEM`，三家通用
- **零改动：1,750 行 = 92.0%**

### 3.3 32 条 HTTP 路由清单（实测）

**服务端 `ProbeServer`（15 条）**

```
/status  /command  /craft  /item  /items  /loot  /recipe  /run/dump
/screenshot  /ui/snapshot  /ui/click
/world/block  /world/break  /world/mine  /log/tail
```

**客户端 `ClientProbeServer`（17 条）**

```
/status  /command  /connect  /quit  /item/mainhand  /player/inventory
/player/use  /player/use_item  /player/break  /player/mine
/ui/snapshot  /ui/click  /ui/close  /ui/hover  /ui/hover_slot
/screenshot  /log/tail
```

**服务端命令（`ProbeCommands`，107 行）**

```
/mcdev status
/mcdev freeze <bool>
/mcdev step <ticks>
/mcdev fakeplayer spawn <name>
/mcdev fakeplayer use <name> <x> <y> <z>
/mcdev fakeplayer attack <name>
```

`/status` 返回 `side`、`tick`、`gameTime`、`dayTime`、`players`、`maxPlayers`、`level`、`version`、`frozen`、`stepBudget` —— **没有 `capabilities` 字段**，即客户端/服务端能做什么，外部无法查询。§8 的负向断言需要在这里加字段。

---

## 4. 目标架构

### 4.1 用户定的形态（m00261 原话要点）

> 这个开发之后应该主要是给 dsh 用的一套辅助开发工具……具体辅助 MOD 开发的时候，肯定是各个不同加载器的客户端分开进行测试，就算是开发一个支持多加载器的 MOD，那也是各个不同加载器的客户端分开测试，因此架构可以弄成共享库和基础 + 三套针对不同加载器客户端的探针 MOD

### 4.2 分层

```
┌─ DSH 插件层（一份，共用）
│   读项目文件判定目标加载器 → 选 gradle 任务 → 调 HTTP 探针
│   加载器耦合：1 处任务名特例（Fabric 的 runGametest）+ 多实例编排
├─ 基础层（三份）
│   三套官方 MDK 骨架，各改 ~10 行：版本号 / 共享 srcDir / 探针端口
│   Fabric: fabric-loom 1.18-SNAPSHOT + 无 mappings 行 + splitEnvironmentSourceSets()
│   Forge : ForgeGradle [7.0.17,8) + 无 mappings 行
│   NeoForge: ModDevGradle 2.0.148
├─ 共享库（一份，**P1 实测 934 行 = 6 文件**，见 p1-port-record.md）
│   ProbeServer(650) / RunRecorder(77) / ProbeLogs(71) / ProbeCommands(60) / TickControl(60) / ProbeLog(16)
│   注：ClientProbeServer（788 行）属于客户端专属，见 §7
│   注：ServerLevelMixin 已整份删除（26.3 有原生 TickRateManager，冻结不需要 mixin）
│   注：RegistryBridge 接口也不需要（BuiltInRegistries 本身就在 net.minecraft.*）
│   铁律：只许 import `net.minecraft.*`，禁止任何 net.minecraftforge / net.neoforged / net.fabricmc
└─ 三套探针（每家 ~150-250 行）
     入口（@Mod / ModInitializer）+ FakePlayers + （可选）mixin json
```

共享实现方式：每个子项目加一行

```gradle
sourceSets.main.java.srcDirs += '../shared/src/main/java'
```

三份物理目录 vs 复合构建的成本对比（结论：不用复合构建）：

| 方案 | 磁盘/维护 | 一次性搭建 | 失败模式 |
|---|---|---|---|
| A 复合构建（root + 3 subproject） | 一份 | **3-5 倍**[估计] | 插件冲突，报错难定位 |
| B 三份独立 + 脚本拷贝 | 1,902×3 | 低 | **拷贝遗漏 → 静默** |
| **C 三份独立 + 共享 srcDir（选此）** | 1,902 + 3 份骨架 | 低 | **编译报错 → 立即可见** |

A 的唯一优势（一条命令出 3 产物 / 统一 API jar）对 dev-only 探针用不上；稳态维护三者差异 <10%，差距全在一次性搭建与失败模式。

### 4.3 为什么不需要运行时加载器探测

因为「各加载器客户端分开测试」是前提本身。目标加载器在**打开项目**时由 DSH 插件静态判定：

| 判定依据 | 加载器 |
|---|---|
| `src/main/resources/fabric.mod.json` 存在 | Fabric |
| `src/main/resources/META-INF/neoforge.mods.toml` 存在 | NeoForge |
| `src/main/resources/META-INF/mods.toml` 存在 | Forge |
| 否则看 `build.gradle` 的 plugins 块 | 三者之一 |

约 20 行。不需要 `@Mod` 里做 `Services` 探测，不需要 shaded 多实现，不需要统一产物。

**但要注意**：这只判定「用哪套构建链」，不判定「被测 MOD 是哪一类 MOD」（纯服务端 / 双端必需 / 单端可选）。后者是 §8 的内容，**必须靠行为测试**。

### 4.4 探针自身的类别：单端可选

探针必须声明为**类别③（单端可选）**，不能是类别②（双端必需）。

理由：如果探针要求双端都装，那么「客户端不装探针、只装被测 MOD」这种测试场景就永远做不了 —— 而 §8 的 T2 / T4 恰恰需要它。

原代码本来就是类别③：单 jar 双端、各端启动自己的 HTTP server、互不依赖，运行时靠 `FMLEnvironment.dist` 分流。这个定位要保持。

---

## 5. 各加载器适配要点

### 5.1 Fabric

- 入口：`ModInitializer` / `ClientModInitializer`（`fabric.mod.json` 的 `entrypoints`）
- 无 mappings 行：26.3 的 `build.gradle` **不写** `mappings`（Yarn 已退役、intermediary 是空表、全程 official 名）
- 事件：`ServerLifecycleEvents.SERVER_STARTED` / `SERVER_STOPPING`、`CommandRegistrationCallback`
- 双端分流：`FabricLoader.getInstance().getEnvironmentType()` + `EnvType.CLIENT`
- **`fabric-loader 0.19.5` 有三个并列入口接口**（实测 `fabric-loader-0.19.5.jar`，1,984,980 B）：

| 接口 | `fabric.mod.json` 键 | 何时加载 |
|---|---|---|
| `net.fabricmc.api.ModInitializer` | `"main"` | 双端 |
| `net.fabricmc.api.ClientModInitializer` | `"client"` | 仅客户端 |
| `net.fabricmc.api.DedicatedServerModInitializer` | `"server"` | **仅专用服务器** |

  ⇒ Fabric 在**加载期**就能把客户端 / 服务端代码分开，Forge 与 NeoForge 没有对应物，只能运行时 `Dist` 判断。这是三家在「端分流」上唯一的结构性差异（见 §7.3）。

### 5.2 Forge（改动最大的一家）

- 入口：`@Mod("modid")` + 构造器注入 `FMLJavaModLoadingContext`
- **`MinecraftForge.EVENT_BUS` 在 26.3 已是迁移壳**：`MinecraftForge.java` 里是 `public static final EventBusMigrationHelper EVENT_BUS = EventBusMigrationHelper.INSTANCE;`，不是真总线
- 正确写法：`FMLJavaModLoadingContext.get().getModBusGroup()` 拿 `BusGroup`（注释明确说 mod 事件**不要**挂 `BusGroup.DEFAULT`）
- 事件总线升到 **EventBus 7**（`net.minecraftforge.eventbus.api.bus.BusGroup`），`@SubscribeEvent` 语义有变 → 入口这 65 行属于**小重写**，不是改名
- 元数据仍是 `META-INF/mods.toml`，`modLoader="javafml"`、`loaderVersion="[24,)"`
- **`FakePlayerFactory` 已移除**（26.3 源码里只剩 `test_old` 引用）→ 假玩家必须自建（见 §6）

### 5.3 NeoForge

- 入口：`@Mod("modid")` + 构造器注入 `(IEventBus bus, ModContainer container)`
- 事件总线 **8.0.5**（`net.neoforged.bus.api.IEventBus`）；`NeoForge.EVENT_BUS` 用于游戏事件
- 元数据 `META-INF/neoforge.mods.toml`，依赖用 `type="required"`；26.3 模板**不写** `modLoader` / `loaderVersion`
- 版本号前缀直接是 `26.3.`（旧的 `21.1.x ↔ 1.21.1` 错位映射已废止）
- `DeferredRegister` 包名与 Forge 不同（`net.neoforged.neoforge.registries.DeferredRegister`）
- **`FakePlayer` + `FakePlayerFactory` 仍在**（三家唯一现成的）→ 假玩家最省力

### 5.4 gradle 任务名映射

| 加载器 | 客户端 | 服务端 | GameTest |
|---|---|---|---|
| Fabric | `runClient` | `runServer` | **`runGametest`** |
| Forge | `runClient` | `runServer` | `runGameTestServer` |
| NeoForge | `runClient` | `runServer` | `runGameTestServer` |

**只有 Fabric 一个特例** → L1 的 `index.js:1436` 需要一处分支，其余零改动。

### 5.5 GameTest

- Fabric：入口点 key `fabric-gametest`，`@GameTest` 在 `fabric-gametest-api-v1`；该模块**不在 fabric-api 聚合包里**，需单独依赖
- Forge：`@GameTest` 在 **`net.minecraftforge.gametest`**（非原版包），默认 `structure()="forge:empty3x3x3"`、`maxTicks()=100`；官方模式 `ForgeGameTestHooks.gatherTests(getClass(), this)` + `BaseTestMod`
- NeoForge 26.3：**没有 `@GameTest` / `@GameTestHolder`**（`net/neoforged/neoforge/gametest/` 只剩 `BlockPosValueConverter`、`GameTestHooks`、`package-info`）。两条路：①`DeferredRegister<Consumer<GameTestHelper>>` 注册 `BuiltInRegistries.TEST_FUNCTION` + `test_instance/*.json`（需 datagen）；②听 `RegisterGameTestsEvent` 在运行期直接 `registerTest(id, GameTestInstance)`。**P0 V6 实测走 ②，11 秒跑完、`All 2 required tests passed`**，无需 json 与 datagen ⇒ 采用 ②。
- **重要**：原探针 `lab/mcdev-probe/` 里 **GameTest 零 Java 代码**（`grep GameTest|gametest|TEST_FUNCTION|runGameTestServer` 命中全在 `build.gradle` 的注释、`forge.enabledGameTestNamespaces` 属性、`workingDirectory project.file('run-gametest')`）。12 个 GameTest 都在示例模组 `lab/forge-1.20.1-test` 里。
- ⇒ **GameTest 的分叉不增加探针一行代码**，只是「被测模组」自己怎么写的问题，属于文档范畴。

---

## 6. 假玩家（FakePlayer）

原实现在 `FakePlayers.java`（66 行），机制：

```java
// :29-36
new GameProfile(UUID.nameUUIDFromBytes(name.getBytes()), name)
FakePlayerFactory.get(level, profile)
player.moveTo(x, y, z, yaw, pitch)
// :45-46  走完整原版交互
player.gameMode.useItemOn(player, level, stack, InteractionHand.MAIN_HAND, hit)
// :62-63
level.getEntitiesOfClass(LivingEntity.class, ...) → player.attack(...) + player.swing(...)
```

三家现状：

| 加载器 | `FakePlayerFactory` | 方案 |
|---|---|---|
| Fabric | 无 | 借 Carpet 的实现 |
| Forge | **已移除**（26.3 源码里只剩 `test_old` 引用） | 自建 |
| NeoForge | 有 `FakePlayer` + `FakePlayerFactory` | 能用，但只覆盖 use 路径 |

Carpet 的 Fabric 实现可直接参考（26.1 分支实测）：

| 文件 | 行数 | 作用 |
|---|---:|---|
| `src/main/java/carpet/patches/EntityPlayerMPFake.java` | 291 | `extends ServerPlayer`，入口 `createFake(String username, MinecraftServer server, Vec3 pos, double yaw, double pitch, ResourceKey<Level> dimensionId, GameType gamemode, boolean flying)` |
| `src/main/java/carpet/patches/FakeClientConnection.java` | 48 | `extends Connection`，用 `EmbeddedChannel` 造 Netty 假通道 |
| `src/main/java/carpet/patches/NetHandlerPlayServerFake.java` | 43 | `extends ServerGamePacketListenerImpl` |

合计 382 行，import 全是 official 名。探针只需要 spawn / use / attack 子集，裁到约 100 行。

**注意**：Carpet 的假玩家**不是「仅专用服务器」的东西** —— `ServerPlayer` / `ServerLevel` 在客户端的内嵌服务器里同样存在。单人游戏里假玩家照样能 spawn。真正的专用服务器差异只有两条，见 §7.1。

---

## 7. 端差异与服务端段适配（新增）

### 7.1 硬边界只有一条：`net.minecraft.client.*` 不可达

逐个文件统计原探针对 `net.minecraft.client.*` 的引用：

| 文件 | 行数 | client import 数 | 归属端 |
|---|---:|---:|---|
| `ClientProbeServer.java` | 788 | **11 个 import / 11 处正文** | 仅客户端 |
| `ProbeServer.java` | 646 | 0 | 双端 |
| `ProbeCommands.java` | 107 | 0 | 双端 |
| `RunRecorder.java` | 77 | 0 | 双端 |
| `ProbeLogs.java` | 71 | 0 | 双端 |
| `TickControl.java` | 56 | 0 | 双端 |
| `ServerLevelMixin.java` | 26 | 0 | 双端 |
| `FakePlayers.java` | 66 | 0 | 双端 |
| `McdevProbe.java` | 65 | 0 | 双端 |

⇒ **端差异的影响面是 788 / 1,902 = 41% 的文件，但只占 1 个文件**。

**纠错（早期判断有误）**：曾经以为 `FakePlayers` 与 `ServerLevelMixin` 是「仅专用服务器」的。不对 —— `ServerLevel` 与 `ServerPlayer` 在客户端内嵌服务器里一样存在，单人游戏里这两样照样工作。**真正的专用服务器差异只有两条**：

1. `net.minecraft.client.*` 在专用服务器上不可达
2. 入口走 `DedicatedServerModInitializer`（Fabric）/ `Dist.DEDICATED_SERVER` 判断（Forge、NeoForge），而不是客户端入口

### 7.2 原代码对「服务端无 GUI」已是显式失败，不是静默失败

```java
// ProbeServer.java:75-77  —— 三条客户端专属路由绑到统一的拒绝处理器
server.createContext("/screenshot",  handleNotClient);
server.createContext("/ui/snapshot", handleNotClient);
server.createContext("/ui/click",    handleNotClient);

// ProbeServer.java:573
return error(ex, 501, "client-only endpoint; this probe is running on the server");
```

这是**正确**的做法，也是要保留的：在服务器上请求客户端能力时返回 501 + 明确文案，而不是假装成功。

§8 的负向断言需要在此基础上再进一步 —— 不只是「我没这个能力」，还要能表达「**这个测试预期它会失败**」。

### 7.3 三家在「端分流」上的结构性差异

| 加载器 | 加载期分流 | 运行时判断 |
|---|---|---|
| Fabric | ✅ `fabric.mod.json` 的 `"main"` / `"client"` / `"server"` 三键，loader 层面直接不加载另一端的入口 | `FabricLoader.getInstance().getEnvironmentType()` |
| Forge | ❌ 无 | `FMLEnvironment.dist == Dist.CLIENT / Dist.DEDICATED_SERVER` |
| NeoForge | ❌ 无 | `FMLEnvironment.dist`（同 Forge） |

对共享库的影响：**为零** —— 因为共享库的 8 个文件零 client 引用。差异全部收敛在「三套探针的入口里怎么把 `ClientProbeServer` 藏起来」。

### 7.4 客户端代码的隔离策略

| 加载器 | 能否物理隔离 | 做法 |
|---|---|---|
| Fabric | ✅ 能 | Loom 的 `splitEnvironmentSourceSets()` → 客户端代码进 `src/client/java`，服务端运行时**根本不在 classpath** |
| Forge | ❌ 不能 | `runServer` 与 `runClient` 用同一套编译产物 |
| NeoForge | ❌ 不能 | 同 Forge |

Forge / NeoForge 上只能靠**运行时 `Dist` 判断 + JVM 懒加载**。这条路是有实证的：原探针是**同一个 jar** 在 Forge 1.20.1 上双端都能跑，`if (dist == Dist.CLIENT) ClientProbeServer.start(...)` 分支在服务端不执行，类就不会被加载。

⇒ **结论：保持原结构（同一 jar + 运行时 dist 判断）在 Forge / NeoForge 上是安全的；Fabric 额外可以用 `splitEnvironmentSourceSets()`，但不是必需，也不该为此把三家的目录结构搞得不一致。**

[未实测] 现代 Forge / NeoForge 的 `runServer` 是否仍做 dist 剥离（dist cleaner）。本机 `<HMCL_HOME>\libraries` 里 `*forge*server*` / `*neoforge*server*` / `*minecraft-server*` 全部无命中，只有 `minecraft-client-patched-26.3.0.88.jar`，所以无法从本机判断。但上一条的实证（同 jar 双端可跑）使这个问题**不阻塞设计** —— 无论剥离与否，懒加载策略都成立。

### 7.5 服务端段的适配增量

| 项 | 增量 |
|---|---|
| `DedicatedServerModInitializer`（Fabric 专用服务器入口） | ~30 行 |
| 三个加载器的入口里 `dist` 分支改写 | 已计入 §5 的 131 行重写 |
| `/status` 增加 `capabilities` 字段（哪端、有哪些能力） | ~40 行，三家共用 |
| 服务端专属路由从 501 升级为「声明式能力矩阵」 | ~80 行，三家共用 |
| 服务端启动前置检查（EULA / server.properties / 端口占用） | ~150 行，DSH 插件层 |
| **合计** | **约 300 行 / 0.5 天**[估计] |

---

## 8. 三类 MOD 与测试矩阵（新增）

### 8.1 三个类别

用户提出的分类：

| 类别 | 定义 | 典型例子 |
|---|---|---|
| ① 纯服务端 | 客户端从不加载；服务端功能不依赖客户端有它 | 服务端管理插件、性能优化（c2me 的服务端部分） |
| ② 双端必需 | 两侧都必须装，否则拒绝连接（或功能降级） | 新增方块/物品/实体的内容 MOD、双端通信 MOD |
| ③ 单端可选 | 任一端都能独立存在；只影响装了的那一端 | 小地图、HUD、纯客户端 QoL |

**关键结论：类别①/③ 期望「连得上」，类别② 期望「连不上」。断言方向是反的。**

原探针的 `/connect` 只有成功/失败，没有「预期失败」这个语义。这是一整类新能力。

### 8.2 三家怎么表达这个类别（26.3 实测）

| 类别 | Fabric | Forge 26.3 | NeoForge 26.3 |
|---|---|---|---|
| ① 纯服务端 | `fabric.mod.json` `"environment": "server"` | 无声明字段；靠不发 S2C payload | 不发 S2C payload |
| ② 双端必需 | **无声明机制**，只能自己握手 + kick | `mods.toml` `displayTest="MATCH_VERSION"`（默认值） | payload 注册**不加** `.optional()` |
| ③ 单端可选 | `"environment": "*"` | `displayTest="IGNORE_SERVER_VERSION"` / `"IGNORE_ALL_VERSION"` | payload 注册**加** `.optional()` |

证据链：

- **Fabric**：`fabric-loader-0.19.5.jar` 里有 `net/fabricmc/loader/api/metadata/ModEnvironment.class`，三态 `CLIENT` / `SERVER` / `UNIVERSAL`（对应 `"client"` / `"server"` / `"*"`）。但**它只表达「能不能在这一端加载」，不表达「另一端必须存在」** ⇒ 类别②在 Fabric 上没有声明式机制，只能靠 MOD 自己在 join 时检查并 kick。
- **Forge 26.3**：`displayTest` 字符串在 `forge-26.3-66.0.13-universal.jar`（2,772,217 B）的三个类里 —— `net/minecraftforge/network/ServerStatusPing.class`、`net/minecraftforge/client/ForgeHooksClient.class`、`net/minecraftforge/common/ForgeMod.class`。机制仍在，且是**声明式**的。
- **NeoForge 26.3**：**`displayTest` 零命中**。扫描范围：`neoforge-26.3.0.88-universal.jar`（7,142,241 B）、`fancymodloader/loader-12.0.12.jar`（664,069 B）、`bus-8.0.5.jar`（30,340 B），三处全扫一个都没有。取而代之的是 `net/neoforged/neoforge/network/registration/PayloadRegistrar.class` 的方法集：

```
optional   versioned   executesOn
playToClient   playToServer   playBidirectional
configurationToClient   configurationToServer
commonToClient   commonToServer
handler   serverHandler   clientHandler
```

⇒ **Forge 与 NeoForge 在「双端必需」这件事上已经分叉**：Forge 是元数据里的**声明式**，NeoForge 是网络层的**命令式**（注册 payload 时不加 `.optional()` 即为必需）。同一份 MOD 逻辑，两家要写两套完全不同的强制性表达。

[未实测] `PayloadRegistrar.optional()` 的确切语义（是「可以不存在」还是「存在才校验」）目前只从方法名推断，需要一次最小实验确认。

### 8.3 测试矩阵 T1–T5

| 场景 | 服务端 | 客户端 | 断言方向 | 靠哪端探针 |
|---|---|---|---|---|
| **T1** 纯服务端 | MOD | 无 | 启动正常 + 功能生效 | 服务端 |
| **T2** 双端必需·缺客户端 | MOD | 无 | **连接必须被拒绝 + reason 正确** | 客户端（失败分支） |
| **T3** 双端必需·齐 | MOD | MOD | 连接成功 + 双端功能 | 两端 |
| **T4** 单端可选·仅服务端 | MOD | 无 | 连接成功 + 服务端行为变 | 服务端 |
| **T5a** 单端可选·仅客户端（单人） | — | MOD | 单人游戏里客户端功能生效 | 客户端 + 内嵌服务器 |
| **T5b** 单端可选·仅客户端（连服） | 无 MOD | MOD | 连接成功 + 客户端效果可见 + 服务端不受影响 | 客户端 |

T5 必须拆成两条的原因：单人游戏里 `"environment": "*"` 的 MOD 会在**内嵌服务器**上也加载一遍，所以 T5a 证明不了「服务端完全没有这个 MOD 时的行为」。T5b 才能。

**T5b 还有个额外价值**：它是唯一能抓住「客户端 MOD 误用服务端类」的环境。去混淆之后没有 intermediary 层做缓冲，这类误引用在专用服务器上直接 `NoClassDefFoundError`，而在 1.20.1 时代经常被 remap 掩盖。

### 8.4 三个新增能力

**① 负向断言 + 结构化失败原因**

`/connect` 现在只有成功/失败。需要返回：

```json
{"ok": false, "reason": "This server requires mod X to be installed", "reason_raw": "..."}
```

`reason` 必须能取到**服务端下发的原文**，否则「被拒绝了但原因不对」这种 bug 查不出来 —— 而这恰恰是类别②最容易出错的地方。

**② 单人游戏入口（T5a）**

不需要 GUI 自动化。26.3 客户端的 `net/minecraft/client/main/Main.class` 实测包含这些参数：

```
quickPlaySingleplayer  ✔
quickPlayMultiplayer   ✔
quickPlayPath          ✔
quickPlayRealms        ✔
```

⇒ `--quickPlaySingleplayer <存档名>` 可以直接进世界，跳过「创建新世界」那套脆弱的 GUI 点击流程。对可重复测试是决定性的。

**③ 类别判定：元数据优先，行为兜底**

- Forge / NeoForge：能读元数据（`displayTest` / `.optional()`）
- Fabric：**读不到**（类别②无声明）

所以工具应该是：元数据能读就读，读不到就按 T1–T5 跑一遍行为测试，**以行为结论为准**。这顺带解决「MOD 声明成类别③但实际写了双端必需逻辑」这种声明与实现不一致的情况 —— 那类 bug 只有行为测试能抓。

---

## 9. 部署与测试环境（新增）

### 9.1 用户的实际玩法

本地起一个 **Fabric 专用服务器** + **Fabric 客户端**连上去。这恰好是**原探针唯一完整工作的场景**：

```java
// McdevProbe.java:46-55
public void onServerStarted(ServerStartedEvent event) {
    if (FMLEnvironment.dist != Dist.DEDICATED_SERVER) return;   // 单人游戏里直接 return
    RunRecorder.start(...);
    ProbeServer.start(...);
}
```

⇒ 单人游戏里 `ProbeServer` **不启动**，只有客户端探针可用。这个分工在 26.3 上要原样保留（`DedicatedServerModInitializer` / `Dist` 判断）。

### 9.2 部署方式对比（实测）

| 方式 | 迭代成本 | 备注 |
|---|---|---|
| 手工 copy jar | build + copy×2 + 重启×2 | 最笨，但最不依赖构建链 |
| `-Dfabric.addMods=<jar>` | build + 重启 | 机制**实测存在**：`net/fabricmc/loader/impl/util/Arguments.class`，拆分逻辑在 `ArgumentModCandidateFinder.class`，用 `File.pathSeparator` 分隔 |
| `gradlew runServer` / `runClient` | 一次重启 | Loom / FG / MDG 直连 source set，**迭代最快**，开发期首选 |

去混淆使「build 产物即可部署」成立 —— 没有 reobf 步骤，原 `build.gradle:198-208` 的 no-reobf devJar 特例整类消失。

### 9.3 服务端启动器 MSL（实测）

`<MC_ROOT>\ServerList.json` 是个 **dict，key 就是实例号**，字段：

```
Name / Java / Base / Core / Memory / Args / Mode(0) / EncodingIn(UTF8) / EncodingOut(ANSI)
BackupConfigs / TimerTasks
```

生产服 `Server-26.3` 实测：

| 项 | 值 |
|---|---|
| `Core` | `fabric-26.3.jar`（179,051 B，MANIFEST `Main-Class: net.fabricmc.installer.ServerLauncher`，`Implementation-Version: 1.1.1`） |
| 原版 jar | `versions\26.3\server-26.3.jar` |
| mods | **96 个**（含 carpet-ams / carpet-extra / carpet-extra-extras / carpet-tis 四个 carpet 系 + c2me + architectury） |
| `server.properties` | `online-mode=false`、`difficulty=hard`、`gamemode=survival`、`view-distance=32`、`max-players=20`、`level-name=world`、`server-port=25565` |
| JVM | `-Xms8192M -Xmx8192M` + Aikar flags |
| `Java` | `<GRAALVM_HOME>\bin\java.exe` → **服务端 JDK 25 已就绪** |

**探针端口本来就是可配的**（不用改代码）：

```java
// McdevProbe.java:35
Integer.getInteger("mcdev.probe.client.port", DEFAULT_CLIENT_PORT)   // 默认 39002
// McdevProbe.java:52
Integer.getInteger("mcdev.probe.port", DEFAULT_PORT)                  // 默认 39001
// McdevProbe.java:36 / :53
System.getProperty("mcdev.probe.token", DEFAULT_TOKEN)                // 默认 "mcdev"
// ClientProbeServer.java:111  /connect 的 port 查询默认 25566
```

MSL 的 `Args` 是 **JVM 参数**（在 `-jar` 之前）⇒ `-Dmcdev.probe.port=39011` 直接生效。

### 9.4 实例现状（用户已建三台，实测 2026-10-19 00:10–00:15）

用户已在 MSL 里为三个加载器各建了一台纯净服务端。实测配置：

| MSL 名 | 目录 | `Core` | Java | 首次启动证据 |
|---|---|---|---|---|
| `MyServer26.3` | `Server-26.3` | `fabric-26.3.jar` | `<GRAALVM_HOME>\bin\java.exe` | 生产服，有 96 mods + world + 184 KB 日志（10-18 19:33） |
| `26.3-fabric-test` | `Server` | `fabric-26.3.jar` | `<JAVA_EXE>` | **无 eula / 无 properties / 无 logs** ⇒ 从未启动 |
| `26.3-forge-test` | `Server1` | `@libraries/net/minecraftforge/forge/26.3-66.0.13/win_args.txt` | 同上 | 同上 |
| `26.3-neoforge-test` | `Server2` | `@libraries/net/neoforged/neoforge/26.3.0.88/win_args.txt` | 同上 | 同上 |

**JDK 25 已就位**：`<JAVA_HOME>` = **Temurin 25.0.4.1+1 LTS**（`openjdk version "25.0.4.1"`）。这是 GPLv2+CE，比生产服用的 Oracle GraalVM（GFTC）许可更干净 —— §11 的「探测复用 + 首次下载」策略可以把这条路径放进探测链首位。

**建议的起始配置**（三台现在全是 `Memory` / `Args` 空、无 `server.properties`、无 `mods` 目录）：

| 实例 | `server-port` | `-Dmcdev.probe.port` | `-Xmx` | `online-mode` |
|---|---:|---:|---|---|
| `26.3-fabric-test` | 25566 | 39011 | 2G | false |
| `26.3-forge-test` | 25567 | 39012 | 2G | false |
| `26.3-neoforge-test` | 25568 | 39013 | 2G | false |
| `MyServer26.3`（生产） | 25565 | 不装探针 | 8192M | false |

**首次启动前必须补的四件事**（否则会是「起了又退」的假失败）：

1. **`eula.txt`**：三台都没有。首启会生成 `eula=false` 并立即退出。先手写 `eula=true`。
2. **`server.properties`**：三台都没有。首启会自动生成，默认 `server-port=25565` ⇒ **三台与生产服撞端口**。必须先写死 25566/25567/25568。
3. **`mods/` 目录**：三台都没有，探针 jar 要放这里。
4. **探针端口**：`Args` 为空 ⇒ 无 `-Dmcdev.probe.port`，三台都会用默认 39001/39002，**同时跑必撞**。

**端口规划**（三台同时跑时的分配；只跑一台时可省）：

| 实例 | 游戏端口 | 探针端口 |
|---|---:|---:|
| `26.3-fabric-test` | 25566 | 39011 |
| `26.3-forge-test` | 25567 | 39012 |
| `26.3-neoforge-test` | 25568 | 39013 |

**适配顺序按风险排**：Fabric（先验共享库与 HTTP 层，风险最低）→ NeoForge（`FakePlayer` 现成，GameTest 走 `RegisterGameTestsEvent`，P0 V6 已通）→ Forge（最难：EventBus 7、`FakePlayerFactory` 已移除、入口全变）。

**一个 Java 实现混用的注意点**：生产服跑 GraalVM、三台测试服跑 Temurin。功能测试无影响，但**性能类观察不能拿测试服的数据去推生产服**。

### 9.5 MSL 与 Forge / NeoForge：结构上已解决，等一次真启动

原先的判断是「MSL 的 `Core` 字段只认单 jar 启动器，Forge / NeoForge 的 `--installServer` 产出可能塞不进去」。**用户的实际做法把它解决了**：`Core` 填 `@libraries/.../win_args.txt`，即把 Forge / NeoForge 官方生成的 argfile 直接交给 java 展开。

实测两个 argfile 的内容（决定这招能不能成立）：

- **Forge**（`Server1\libraries\net\minecraftforge\forge\26.3-66.0.13\win_args.txt`）：
  `-Djava.net.preferIPv6Addresses=system -XX:+UseCompactObjectHeaders -XX:StackShadowPages=32 -jar forge-26.3-66.0.13-shim.jar`
  ⇒ 走 Forge 官方的 bootstrap shim（`net.minecraftforge.bootstrap.shim.Main`，16,201 B）。**相对路径 `-jar`**，要求 CWD = 实例目录。
- **NeoForge**（`Server2\libraries\net\neoforged\neoforge\26.3.0.88\win_args.txt`）：
  `--add-opens ... --add-exports ... -DlibraryDirectory=libraries -classpath libraries/...（40 余个 jar，`;` 分隔）net.neoforged.fml.startup.Server --fml.neoForgeVersion 26.3.0.88 --fml.mcVersion 26.3 --fml.neoFormVersion 1`
  ⇒ 同样是相对路径，同样要求 CWD = 实例目录。

**MSL 的 CWD 是实例目录**（间接证据：生产服用 `fabric-26.3.jar` 这种相对 `Core` 能跑起来）。所以 `@win_args.txt` 方案在结构上成立。

**但仍是 [未实测]**：三台 test 实例没有任何启动记录（无 `eula.txt` / `server.properties` / `logs`）。真正要验证的是「MSL 把 `Core=@file` 拼成的命令行，java 是否正确展开且相对路径解析到实例目录」。

**若失败**：备选是直接用官方 `run.bat` 命令行起 Forge / NeoForge，Fabric 用 MSL。探针通过 HTTP 控制，启动方式对 DSH 插件层透明（`Launcher` 接口，§10.2）。

**顺带一个好消息**：Forge 侧的 `forge-26.3-66.0.13-shim.jar` 是 Forge 官方产物（不是 MSL 打的补丁），说明 Forge 26.3 自己也提供单 jar 引导入口 —— 这对「未来不依赖 MSL」也是条可用路径。

### 9.6 跨端编排（DSH 插件层的职责）

六个步骤，全部应在插件层：

1. 起服务端（选实例 → 传端口参数 → 等 `/status` 就绪）
2. 起客户端（`runClient` 或启动器 → 等 `/status` 就绪）
3. 客户端 `/connect` 到服务端（含 T2 的负向断言分支）
4. 等 `/status` 的 `players` ≥ 预期值
5. 驱动动作（`/command`、`/world/*`、`/player/*`、`/ui/*`）
6. 断言 + 收尾（拉 `/log/tail`、关客户端、关服务端）

### 9.7 这个拓扑特有的三个坑

1. **存档不可重置**：`world/` 是持久化的，同一测试跑两次结果可能不同。需要在测试开始前重置世界（删目录 / 从模板复制）。
2. **`online-mode=false` 影响身份**：生产服是离线模式，`GameProfile` 的 UUID 由名字派生（`UUID.nameUUIDFromBytes`），与正版 UUID 不同。被测 MOD 若按正版 UUID 做事，测试会与生产不一致。
3. **端差异只在分体可见**：`DedicatedServerModInitializer` 的代码路径在单人游戏里根本不会执行 —— 这是唯一能验证它的环境，也是唯一能抓「客户端 MOD 误用服务端类」的环境（见 §8.3 T5b）。

---

## 10. 玩家操作覆盖面（新增需求，需重新设计）

> 用户原话（m00390 要点）：探针需要能做到的操作，可能需要涵盖**玩家所有能进行的操作**，包括一些**创造模式 / 生存模式 / 旁观模式的特有权限和操作**。

### 10.1 这条需求改变了什么

原探针的能力是「**结果级** + 少量**动作级**」。用户要的是「**动作全集**」。这不是加几个路由，是加一层能力。

关键区分：

- **结果捷径**：`/command give @p diamond` 让玩家拿到钻石，但**不触发**拾取事件、不消耗、不进统计、不推进进度。
- **玩家路径**：`FakePlayer` 走到掉落物旁 → 触发 `PlayerPickupXpEvent` / 物品入包 → 触发 `ItemEntity` 合流。

模组常常挂在**路径**上而不是**结果**上。所以「用命令把世界改成想要的样子」和「验证玩家操作能否正常触发 mod 逻辑」是两件事，后者才是探针的价值。

### 10.2 覆盖矩阵（现状 vs 需求）

图例：✔ 有 · △ 只有结果捷径或部分 · ✘ 无

| 能力 | 服务端探针 | 客户端探针 | 说明 |
|---|:---:|:---:|---|
| **移动与视角** | | | |
| 位移 / 朝向设定 | ✘ | ✘ | 无任何 move / look 路由 |
| 走路 / 冲刺 / 潜行 / 跳跃 / 游泳 | ✘ | ✘ | 需实现输入注入 |
| 创造飞行 / 鞘翅滑翔 | ✘ | ✘ | |
| 传送 | △ | △ | 仅 `/command tp` |
| **方块** | | | |
| 挖掘（含进度） | ✔ `/world/mine` | ✔ `/player/mine` | 原探针已覆盖 |
| 瞬间破坏（创造） | △ | △ | 可行但无显式语义 |
| **放置方块** | ✘ | ✘ | **两条链路都没有 place 路由** |
| 右键方块 | △ `/world/block` | ✔ `/player/use` | |
| pick block（中键） | ✘ | ✘ | |
| **实体** | | | |
| 攻击 | ✔ `fakeplayer attack` | ✘ | 客户端侧无 attack |
| 右键实体（村民 / 动物 / 骑乘） | ✘ | ✘ | |
| 拴绳 / 繁殖 / 喂食 / 交易 | ✘ | ✘ | |
| **物品** | | | |
| 读（手持 / 背包 / 容器） | ✔ `/item` `/items` | ✔ `/item/mainhand` `/player/inventory` | |
| 移动 / 交换 / 丢弃 / 拾取 | ✘ | ✘ | 仅 `/command` 造结果 |
| 合成 | ✔ `/craft` `/recipe` | ✘ | |
| 容器 GUI 交互 | ✔ `/ui/*` | ✔ `/ui/*` | 含 hover / hover_slot / close |
| **模式特有** | | | |
| 切换 gamemode | △ `/command` | △ | 无显式 API、无前置声明 |
| 创造：飞行 / 创造物品栏 / 无限物品 / 无伤 | ✘ | ✘ | |
| 生存：饥饿 / 伤害 / 死亡重生 / 经验 / 工具耐久 | ✘ | ✘ | |
| 旁观：noclip / 附身实体 / 右键传送 / 零交互 | ✘ | ✘ | |
| 冒险：`can_destroy` / `can_place_on` 限制 | ✘ | ✘ | |
| **状态与效果** | | | |
| 生命 / 饥饿 / 经验查询 | ✘ | ✘ | `/status` 只有 tick / players / level |
| 药水效果施加与查询 | △ | △ | 仅 `/command effect` |
| 死亡 / 重生 | ✘ | ✘ | |
| 睡床 / 时间流逝 | △ | △ | 仅 `/command time` |
| **客户端** | | | |
| 截图 | ✘ `handleNotClient` | ✔ | |
| GUI 快照 / 点击 | ✘ | ✔ | |
| 连接 / 断开服务器 | — | ✔ | |
| 视角 / 相机朝向 | ✘ | ✘ | |

### 10.3 三条结论

**① 「全操作」的大头在客户端侧，而且是免费的。**
客户端探针操作的是**真实的本地玩家**，本来就有完整的输入管线、物理、碰撞、网络往返、客户端预测。要「走路」只需注入 `KeyMapping` 或 `LocalPlayer` 输入；要「右键」只需发 `useItem` 包。**它不缺能力，缺的是控制接口。**
服务端假玩家则是**重造一个玩家**：碰撞、连接、客户端信息、初始包序列全部要自己搭。Carpet 花了 382 行才做出一个能用的假玩家。

**② 建议把「全操作」主要落在客户端探针。**
服务端假玩家只保留多玩家并发 / 专用服务器场景必需的最小集（spawn / use / attack / moveTo）。这样：
- 能力面最大的一层，工作量最小
- 避免在三个加载器上各写一套 300 行的假玩家

**③ gamemode 必须作为动作的前置参数，不能靠测试自己切。**
同一个「挖掘」在创造模式是瞬间破坏、生存模式有速度与耐久、旁观模式根本不能挖。如果 API 不显式带 `GameType` 前置，测试会在错误的模式下静默得到错误结论 —— 这是典型的静默失败，必须在接口层面消灭。

### 10.4 增量成本[估计]

| 项 | 行数 | 说明 |
|---|---:|---|
| 共享 `PlayerAction` 抽象层 | 400-600 | move / look / use / attack / place / mine / inventory / gamemode，含前置校验 |
| 客户端输入注入（3 家共用） | 200-300 | 大部分在共享库 |
| 服务端假玩家最小集 | 每家 100-300 | Fabric 借 Carpet、NeoForge 有工厂、Forge 自建 |
| 覆盖矩阵补齐的查询路由 | 100-150 | 生命 / 饥饿 / 经验 / 效果 / gamemode |

合计约 **1,000-1,500 行新增**，与共享库本体（1,114 行）同一量级 —— 也就是说，用户这条需求把项目规模**翻了一倍**。这是本报告里最需要提前认知的一条。

### 10.5 权威口径：58 个 serverbound 包（实测）

「玩家能做什么」不需要猜。从 26.3 客户端 jar（`<HMCL_HOME>\versions\26.3-Fabric\26.3-Fabric.jar`，34,227 条目）里枚举 `net/minecraft/network/protocol/game/Serverbound*Packet.class`，得到 **58 个包** —— 这是服务端视角下玩家能动用的**全部**动作类型。

**定义**：「玩家能做什么」= 服务端能收到哪些 serverbound 包。这是定义而非观点，去混淆使这件事首次零成本。

包内的细粒度动作由 **11 个嵌套枚举**展开。其中 `ServerboundPlayerActionPacket$Action` 有 9 个值：

```
START_DESTROY_BLOCK  CHANGE_DESTROY_DIRECTION  ABORT_DESTROY_BLOCK
STOP_DESTROY_BLOCK   DROP_ALL_ITEMS            DROP_ITEM
RELEASE_USE_ITEM     SWAP_ITEM_WITH_OFFHAND    STAB
```

`ServerboundPlayerInputPacket` 的载荷是 `net.minecraft.world.entity.player.Input`（record，7 个 boolean）：

```java
public Input(boolean forward, boolean backward, boolean left,
             boolean right, boolean jump, boolean shift, boolean sprint)
```

⇒ **「让假玩家走路」在协议层只是构造一个 7 位布尔、每 tick 发一个 `ServerboundMovePlayerPacket`**，不是玄学。

其余关键签名：

```java
ServerboundAttackPacket(int entityId)                                  // 攻击只带实体 ID
ServerboundPunchPacket()                                               // 单例 INSTANCE，无参
ServerboundInteractPacket(int entityId, InteractionHand hand,
                          Vec3 location, boolean usingSecondaryAction) // 攻击/右键实体/右键特定点
ServerboundPickItemFromBlockPacket(BlockPos pos, boolean includeData)  // pick block
ServerboundPlaceRecipePacket(int containerId, RecipeDisplayId, boolean useMaxItems)
ServerboundSpectatorActionPacket(OptionalInt spectateEntityId)          // 旁观附身
```

分类统计：

| 域 | 包数 | 代表 |
|---|---:|---|
| 移动 / 姿态 / 骑乘 | 6 | MovePlayer（+4 变体）、PlayerInput、PlayerCommand（START_SPRINTING / START_FALL_FLYING…）、MoveVehicle、PaddleBoat、AcceptTeleportation |
| 方块操作 | 3 | PlayerAction（9 子动作）、UseItemOn、PickItemFromBlock |
| 物品 / 攻击 / 实体交互 | 5 | UseItem、Attack、Punch、Interact、PickItemFromEntity |
| 容器 / 背包 / 交易 | 14 | ContainerClick、ContainerClose、ContainerButtonClick、SetCreativeModeSlot、SetCarriedItem、PlaceRecipe、RenameItem、SelectTrade、SetBeacon、EditBook… |
| 模式 / 能力 / 重生 | 3 | ChangeGameMode、PlayerAbilities、ClientCommand（PERFORM_RESPAWN） |
| 旁观 | 2 | SpectatorAction、TeleportToEntity |
| 特殊方块 UI | 7 | SetCommandBlock、SetStructureBlock、SetJigsawBlock、SetTestBlock、TestInstanceBlockAction、SignUpdate… |
| 命令 / 聊天 | 6 | ChatCommand、Chat、ChatCommandSigned、ChatAck、ChatSessionUpdate、CommandSuggestion |
| 查询 / 会话 / 管理 | 12 | ClientTickEnd、PlayerLoaded、ChangeDifficulty、SetGameRule、EntityTagQuery、DebugSubscriptionRequest… |
| **合计** | **58** | |

另有 **29 个 `*Menu`**（`ContainerClickPacket` 的落点）：InventoryMenu、CraftingMenu、AbstractFurnaceMenu、MerchantMenu、AnvilMenu、EnchantmentMenu、BeaconMenu、StonecutterMenu、SmithingMenu、LoomMenu、GrindstoneMenu、CartographyTableMenu、BrewingStandMenu、HopperMenu、DispenserMenu、CrafterMenu、ShulkerBoxMenu、LecternMenu、HorseInventoryMenu、NautilusInventoryMenu 等。

**探针现状 vs 全集：能直接生成的只有 7 个包。**

| 包 | 对应路由 |
|---|---|
| `UseItemOnPacket` | `/player/use_item`、`fakeplayer use` |
| `UseItemPacket` | `/player/use` |
| `PlayerActionPacket` | `/player/break`、`/player/mine`（9 个子动作只用了 START / STOP / ABORT 三个） |
| `AttackPacket` | `fakeplayer attack` |
| `ContainerClickPacket` | `/ui/click` |
| `ContainerClosePacket` | `/ui/close` |
| `ChatCommandPacket` | `/command` |

**7 / 58 = 12%**，且 `PlayerActionPacket` 的 9 个子动作只用了 3 个。

完全无路的是 30 个包 / 5 个整域：

- **移动（6）** —— 探针无法让任何玩家移动一步
- **模式与能力（3）** —— 无法切创造飞行（`PlayerAbilitiesPacket`）、无法重生（`ClientCommandPacket.PERFORM_RESPAWN`）
- **旁观（2）** —— 无法附身实体
- **特殊方块 UI（7）**
- **查询与管理（12）**

半覆盖的两处最要紧：

- 容器 14 个包里只有 Click / Close 有路 → **创造物品栏拿东西（`SetCreativeModeSlotPacket`）做不到**
- 交互只有 `AttackPacket` → **右键实体、村民交易（`SelectTradePacket`）、骑乘全无**

本节这些数字，就是 §10.4 那 1,000-1,500 行的来源。

---

## 11. JDK 25 策略

26.3 要求 Java 25（四份客户端 json 的 `javaVersion.component = "java-runtime-epsilon"`、`majorVersion = 25`）。

本机可用：`<GRAALVM_HOME>`（Oracle GraalVM 25.0.1+8.1，`javac 25.0.1`，完整 JDK，有 jmods / jlink）。服务端 `<MC_ROOT>` 的 `Server-26.3` 也指向同一个 java.exe。

体积实测：**1,057.9 MB / 566 文件**（bin 112.2 MB、jmods 113.4 MB、lib 831.5 MB，其中 `lib\modules` 162.6 MB）。体积主要来自 GraalVM 的 JVMCI / Truffle / SVM，**不能当普通 JDK 的嵌入基准**。

用户决定（m00378）：**不嵌入 JDK**，采用「探测复用 + 首次下载到缓存」。

| 档 | 做法 | 成本 |
|---|---|---|
| ① 探测复用 | `JAVA_HOME` → 常见安装路径 → HMCL `javaDir` → MSL `ServerList.json` 的 `Java` 字段 → `PATH` | 0 |
| ② 首次下载到工具缓存（默认兜底） | Temurin 25，约 200 MB，只下一次 | 一次性网络 |
| ~~③ 真嵌入~~ | 安装包 +200 MB | 已否决 |

许可注意：Oracle GraalVM 是 GFTC，重分发需单独确认；要嵌入应选 Temurin（GPLv2+CE）或 Zulu / Liberica。走「下载」路线可把许可风险转给最终用户。

---

## 12. 工作量估算[估计]

| 阶段 | 人日 | 对应章节 |
|---|---:|---|
| P0 地基验证（三套骨架各跑通一次 build + runServer） | 1.5 | §13 P0 |
| P1 共享库 1.20.1 → 26.3 移植（1,114 行 + mixin 改造） | 4-6 | §13 P1 |
| P2 三套适配（入口 + `RegistryBridgeImpl`；**`FakePlayers` 降级为可选**） | 2-3 | §13 P2 |
| P3 客户端探针与端差异（**实际 762 行 + capabilities 字段 + `/ui/*` 全部可用**） | 2 | §7、§13 P3 |
| P4 玩家操作全集（`PlayerAction` 抽象层） | 8-12 | §10、§13 P4 |
| P5 三类 MOD 测试矩阵（T1–T5 + 负向断言） | 3 | §8、§13 P5 |
| P6 DSH 插件层（14 工具 + 加载器判定 + 多实例编排） | 2-3 | §9.6、§13 P6 |
| P7 文档与发布（含许可三件套、安装说明） | 2 | §13 P7 |
| 可选：服务端 FakePlayer（三平台各 100–300 行） | +3-5 | §6、§8.2 |
| **合计（不含可选项）** | **24.5-31.5** | 约 5-6.5 周（单人） |

工作量相比上一版减少约 1.5-2.5 人日：用户已定「角色操作在客户端实现」（m00625）⇒ P0 的 V5 出局、P2 的 `FakePlayers` 三家重写从必做变为可选。

对比：**不含「玩家操作全集」与「三类 MOD 测试」时约 13.5-16.5 人日**。两条新增需求合计带来 **+11-15 人日**，其中玩家操作全集占大头。

### 12.1 多版本的边际成本（★ 预案，当前不启用）

**当前口径（m00831 定案）**：**只支持 26.3**，只有 `main` 一条分支。其他版本「等发布后看反响如何再说」，现在只做零成本预留（架构文档 §11.2 六条纪律）。所以本节的数字是**预案**，不是当前预算 —— 上表的 24.5–31.5 人日就是全部投入。

版本计数按 **minor** 算：26.1 / 26.2 / 26.3 各算一个大版本，26.1.x 补丁只跟最后一个（26.1.2）。明年 27.x 线重新计数。

一旦决定启用多分支，每增加一条受支持版本分支的边际成本：

| 边际项 | 成本 |
|---|---|
| 切分支的首次构建（改版本号 + 修编译错误） | **跟新版** 0.5-1 天；**往前补旧版** 2-4 天（架构文档 §11.6 流程 B，会遇到「API 不存在」而非「API 改名」） |
| 每个回迁功能的 cherry-pick + 该版本测试 | 该功能人日 × **0.2-0.5**，乘数由架构文档 I7（改动落点局部化）的遵守度决定 |
| 测试矩阵 | 3 加载器 × N 版本（P5 已含自动化，但每版本要各跑一遍） |

估算：**2 条分支 ≈ 基线 +15%~25%；3 条 ≈ 基线 +30%~50%**。

「往前支持」比「跟新版」贵得多 —— 它收缩 API 基线（架构文档 B4），要反向改写所有分支里 `shared/` 的代码。所以「最低支持版本」要么一次性定死，要么接受反复收缩。这是架构文档 §11.1 建议「活跃分支 ≤3 条」的量化依据。

**硬边界**：以上数字**只适用于 26.x 线内**（26.1 ↔ 26.3，全部去混淆、同一套构建链）。跨到 **1.21.11 及更早**不是「多一条分支」，而是「再做一个版本线」—— 映射表、`refmap`、reobf、Loom 非 slim 版全部回归，构建链要重做。判据与证据见架构文档 §11.4。

---

## 13. 开发规划

### 13.1 阶段与交付物

#### P0 地基验证 —— 唯一可能推翻整个方案的阶段【已执行，2026-10-19】

目标：把「三套构建链在 26.3 上真能跑」从「官方文件显示可以」变成「实测可以」。

工具链实况：Java = `<JAVA_HOME>`（Temurin 25.0.4.1+1 LTS）；gradle 三套 MDK 自带 wrapper，**Forge 用 9.7.1 / NeoForge 用 9.2.1 / Fabric 用 9.7.1**；骨架落在 `<REPO>\lab\{fabric,forge,neoforge}`，共享源码在 `lab\shared`。

| 验证项 | 实测结果 | 关键数字 |
|---|---|---|
| V1 Fabric 骨架 | ✅ **BUILD SUCCESSFUL** + `runServer` 到 `Done` | build 3m13s（`Fabric Loom: 1.18.4`）→ `modid-1.0.0.jar` 8,355 B；`Done (2.165s)!`，`*:25565` LISTEN |
| V2 Forge 骨架 | ✅ **BUILD SUCCESSFUL** + `runServer` 到 `Done` | build 4m44s（其中 mavenizer 4:09.798）→ `examplemod-1.0.0.jar` 8,648 B；`Done (1.586s)!`，`*:25567` LISTEN |
| V3 NeoForge 骨架 | ✅ **BUILD SUCCESSFUL** + `runServer` 到 `Done` | build 4m53s（NeoForm 全流程 162.10s，含 decompile 29.34s / recompile 7301 源文件 36.77s）→ `examplemod-1.0.0.jar` 10,037 B；`Done (1.597s)!`，`*:25568` LISTEN |
| V4 一个共享类三家编译 | ✅ 三家同一份源码编出**字节级相同**的 class | `SharedProbe.class` 3,896 B，三家 sha256 均 `31F16079E6976FFE…`；`RegistryBridge.class` 315 B 均 `2F9834F5E5D2C9A8…` |
| ~~V5~~ **已降级为可选，不进 P0 门槛** | 见下方说明 | —— |
| V6 NeoForge GameTest | ✅ `All 2 required tests passed :)`，退出码 0 | `runGameTestServer` 11s；marker 文件 `[MCDEV-V6-OK] neoforge gametest body executed` 落盘 |
| V7 mixin 注入 Java 25 字节码 | ✅ 三家**全部注入成功** | Forge 那句 `Class version 69 required … JAVA_21 supports class version 65` 只是告警，不是拦截 |
| V8 三台实例能真启动 | ✅ 三台全到 `Done` | Fabric 1.517s / Forge 1.586s / NeoForge 1.597s，端口 25566/25567/25568 |
| V9 MSL 吃 `Core=@win_args.txt` | ✅ **通过（GUI 实点验证）** | MSL 日志原文：`尝试启动服务器：26.3-forge-test` → `启动参数：  @libraries/net/minecraftforge/forge/26.3-66.0.13/win_args.txt nogui` —— **原样透传，不二次加 `@libraries/` 前缀**；服务端 `Done (0.252s)!`，25567 LISTEN，探针 `/status` 200 |
| V10 部署回路 | ✅ 三家 `/status` 都 200 | 端口 39011/39012/39013，响应体 `{"ok":true,"data":{"probe":"p0-v10","tickRate":"running"}}` |

**V6 的做法与文档原设想不同（原设想错了）**：不必 `DeferredRegister<Consumer<GameTestHelper>>` 注册 `BuiltInRegistries.TEST_FUNCTION`，也不需要 `test_instance/*.json` 与 datagen。`RegisterGameTestsEvent` 直接提供运行期注册口子：

```java
Holder<TestEnvironmentDefinition<?>> env = event.registerEnvironment(
        Identifier.parse("examplemod:p0_env"), new TestEnvironmentDefinition.AllOf());
TestData<Holder<TestEnvironmentDefinition<?>>> data =
        new TestData<>(env, Identifier.parse("minecraft:empty"), 20, 1, true);
event.registerTest(Identifier.parse("examplemod:v6_p0_smoke"),
        new MarkerTest(BuiltinTestFunctions.ALWAYS_PASS, data));
```

结构用 `minecraft:empty`（原版 jar 里确有 `data/minecraft/structure/empty.nbt`，全 jar 共 1,511 个结构模板）。

**V7 的结论比预期更简单，但暴露了三个真坑**：

1. `compatibilityLevel` 在三家**都不是硬约束** —— Forge 解析到 `org.spongepowered:mixin:0.8.7`（枚举最高 `JAVA_21`），注入 major=69 的类仍然成功；Fabric/NeoForge 解析到 `net.fabricmc:sponge-mixin:0.17.4+mixin.0.8.7`（含 `JAVA_25`）。⇒ 这个字段写 `JAVA_25` 是为了消警，不是为了过关。
2. **Forge 的 `mods.toml` `[[mixins]]` 是死配置**（26.3 已不读），mixin 必须靠 jar `MANIFEST.MF` 的 `MixinConfigs` 注册。这是静默失败：不报错、不加载、无提示。
3. **Forge / NeoForge 不捕获 `System.out`**（Fabric 会记进 `latest.log`）⇒ 探针与测试的证据必须落文件或走 HTTP，不能只 print。
4. 附带发现：`ServerLevelMixin` 其实**不需要** —— tick 冻结有公开 API（`MinecraftServer.tickRateManager()`），§5.5 那套 mixin 冻结方案可以整段砍掉。

**V5（Forge 自建 FakePlayer）为什么从 P0 门槛里拿掉**：


用户已定「测试就是在服务端加载 MOD 然后开客户端探测，角色操作也都在客户端实现」（m00625）。⇒ 被测玩家恒为客户端那个真实玩家，服务端探针只做**观察 + 环境控制**，`FakePlayer` 只在两个场景才需要：多玩家并发、无客户端的纯服务端自动化（如 CI 跑 GameTest）。

因此 V5 从「唯一会改变方案边界的阻塞风险」降为**可选增强功能**：Forge 没有现成 API 就先不做，`ServerActionBackend` 只保留 `/status` `/world/*` `/log/tail` `/command`。真要补时再用 Carpet 那套（`EntityPlayerMPFake` 291 行 + `FakeClientConnection` 48 行 + `NetHandlerPlayServerFake` 43 行）平移。

**P0 期间遇到并解决的环境障碍**（不是方案问题，但会重复出现在每次换机部署里）：

| 障碍 | 现象 | 解法 |
|---|---|---|
| gradle 发行版下载 | wrapper 卡在 `gradle-X-bin.zip.part` 0 B —— `services.gradle.org` 307 到 GitHub releases，本机拉不动 | 走腾讯镜像 `mirrors.cloud.tencent.com/gradle/`，手工预置 wrapper 缓存（目录名 = 官方 hash：9.7.1 `1w1c7tv4s851m17nbqdsro2tv`、9.2.1 `2t0n5ozlw9xmuyvbp7dnzaxug`），放 zip + 解压 + 建 0 字节 `.ok` |
| `maven.neoforged.net` Java 侧握手被断 | `Could not GET …/neoform-runtime-2.0.31.pom` → `Got socket exception … SSL misconfiguration`；同一 URL 用 curl / .NET 都 200 | **只有代理管用**：给 gradle 加 `-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=<PROXY_PORT>`（本机 verge-mihomo 的 HTTP 口）。`hosts` 钉 IP **无效**，见下 |
| ForgeGradle 的 mavenizer 要 JDK 8 | 同上从 GitHub 下载 `jdk8`，0 B 卡死，**且父进程无声退出、无 FAILURE、无产物** | 从清华镜像取 `OpenJDK8U-jdk_x64_windows_hotspot_8uxxx.zip`（106,457,258 B，sha256 `ea43d46ed…`）预置进 `~/.gradle/caches/minecraftforge/forgegradle/mavenizer/caches/` |
| Fabric 实装 loader 版本与骨架不符 | `ServerLauncher` 装了 **0.20.0**，而官方 example-mod 26.3 分支写 `loader_version=0.19.5` | ServerLauncher 无视 `stable` 标记取最新。骨架跟 example-mod（0.19.5），部署用实装值，二者不冲突但要知情 |
| 26.3 新增 `pause-when-empty-seconds` | 空服 60 秒后日志 `Server empty for 60 seconds, pausing`，tick 停走 | 测试实例与本项目 `run/server.properties` 统一设 `pause-when-empty-seconds=0` |
| MSL 安装路径 | 早先记成 `<MC_ROOT>\MSL.exe`（多了一层目录） | **exe 与数据目录同名并列**：`<MC_ROOT>.exe`；`<MC_ROOT>\` 是数据目录（`ServerList.json` / `config.json` / 实例目录都在里面） |
| 读 Forge/NeoForge 控制台日志 | `console.out.log` 被 java 独占锁住 | 用 `[IO.File]::Open($p,'Open','Read','ReadWrite')` 共享读；正文是 GBK，须 `[Text.Encoding]::GetEncoding(936)` 解码 |

**`hosts` 钉 IP 为什么无效（P0 后复查，已把钉的三行全部移除）**：本机 DNS 确实被污染（`maven.neoforged.net` 解析到 `108.160.165.147`，DoH 真值是 CNAME → CDN77 的 `156.146.53.32/33/35/36`），钉了 IP 后 `Resolve-DnsName` 也确实返回钉的地址 —— **解析这一层是成功的**。但清掉缓存实测：`java` 直连仍是 `IOException: 远程主机强迫关闭了一个现有的连接`（同 URL `curl --noproxy '*'` 反而 200）。⇒ 拦截点不是 DNS，而是 **Java 的 TLS ClientHello 本身**（curl 走 schannel、指纹不同；Java 25 的 ClientHello 含 ML-KEM 混合作业，体积偏大）。逐项排除过的 JVM 开关：`jdk.tls.namedGroups=x25519,secp256r1` / `jdk.tls.client.protocols=TLSv1.2` / `jdk.tls.client.enableStatusRequestExtension=false` / `jdk.httpclient.forceHTTP1=true` 全部无效；`jsse.enableSNIExtension=false` 能过握手但停在证书校验（`No subject alternative DNS name matching`）。⇒ 结论：**这类拦截只能靠隧道绕开**（代理有自己的成熟 TLS 栈），钉 IP 属于无效操作且有 IP 漂移风险，已从 `hosts` 撤除（备份在 `lab/hosts.bak.*`）。真正跑通的 NeoForge 构建是用代理跑的。
**V9 怎么验的**（记录方法，因为它是唯一需要碰 GUI 的一项）：MSL.exe 是 GUI 应用、无 CLI，所以做了三步可回滚的操作 —— ① 备份并临时把 `config.json` 的 `SelectedServer` 从 `0`（生产服 `MyServer26.3`）改成 `2`（`26.3-forge-test`），确保误点也点不到生产服；② 启动 MSL，界面底部按钮已显示 `26.3-forge-test`，点「开启服务器」；③ 读 `<MC_ROOT>\logs\MSL_*.log`。验证完已停服、关 MSL、还原 `config.json` / `ServerList.json` 并删掉备份，三台 `mods/` 清空。

**MSL 的启动命令拼装（从 MSL.exe 的 UTF-16 字符串池反推 + 实测印证）**：池里并存两组字面量 —— `@libraries/` + ` nogui`（Forge/NeoForge 路线）与 ` -jar "` + `" nogui`（单 jar 路线）。实测 `Core=@libraries/...` 走的是**透传**：拼出 `… @libraries/net/minecraftforge/forge/26.3-66.0.13/win_args.txt nogui`，没有二次前缀。⇒ `Core` 填什么就传什么，这一点可以放心依赖。

**验收物**：三份能启动的服务端（✅ 三家）+ 各自探针 `/status` 返回 200（✅ 三家）+ 一份 V1–V4、V6–V10 的实测记录（✅ 全部，含 V9）。**P0 已全部闭合。**


#### P1 共享库移植 —— ✅ 已完成（实测记录）

计划写的是「8 个文件 1,114 行 + mixin 改造 + 15 行 `RegistryBridge`」。实际落地的形态与计划**不同**，而且比计划简单：

| 计划项 | 实际 | 原因 |
|---|---|---|
| 8 文件 / 1,114 行 | **6 文件 / 934 行**（+ 新增 `ProbeLog.java` 16 行） | `ClientProbeServer`（788 行）归 P3；`FakePlayers`（66 行）归 P2/P4；`McdevProbe`（65 行）由平台入口替代 |
| `ServerLevelMixin` 改造（删 `refmap` / 删 `remap=false` / `JAVA_25`） | **整份删除，零 mixin** | 26.3 有 `MinecraftServer.tickRateManager()` → `net.minecraft.server.ServerTickRateManager`，冻结/步进是公开 API |
| 21 行 `ForgeRegistries` → `BuiltInRegistries` | ✅ 13 处（`tryGetKey` / `tryGetValue` 包一层，`getKey` 返回 `Identifier`） | `ClientProbeServer` 那 6 处随 P3 走 |
| 15 行 `RegistryBridge` 接口 | **不需要** | `BuiltInRegistries.BLOCK` / `.ITEM` 属于 `net.minecraft.*`，不违反共享库铁律 |

**落盘文件**（`lab/shared/src/main/java/com/mcdev/probe/`）：`ProbeServer.java` 650 / `RunRecorder.java` 77 / `ProbeLogs.java` 71 / `ProbeCommands.java` 60 / `TickControl.java` 60 / `ProbeLog.java` 16，合计 **934 行**。

**17 个 API 演进错误**（这是「1.20.1 → 26.3 迁移成本」的实测样本，逐条记在 [p1-port-record.md](p1-port-record.md) §3）：

| 1.20.1 写法 | 26.3 写法 |
|---|---|
| `level.getDayTime()` | `level.getOverworldClockTime()` |
| `version.getName()` | `version.name()`（record 化） |
| `performPrefixedCommand(...)` 返回 int | 返回 `void`（`result` 恒 0） |
| `new Identifier(dim)` | `Identifier.parse(dim)`（构造器转私有） |
| `ResourceKey.location()` | `ResourceKey.identifier()` |
| `getAnalogOutputSignal(Level, BlockPos)` | 多一个 `Direction` 参数 |
| `chunkMap.saveWithoutMetadata()` | `saveWithoutMetadata(HolderLookup.Provider)` |
| `RecipeManager.byKey(Identifier)` | `byKey(ResourceKey<Recipe<?>>)` → `Optional<RecipeHolder<?>>` |
| `Recipe#getIngredients()` | `placementInfo().ingredients()` |
| `Recipe#getResultItem(RegistryAccess)` | `display()` → `SlotDisplay.resolveForFirstStack(ContextMap.EMPTY)` |
| `Inventory.items` 字段 | `getNonEquipmentItems()` |
| `Player.drop(ItemStack, boolean)` | `ServerPlayer.drop(ItemStack, boolean, Prediction.SERVER_ONLY)` |
| `TagParser.parseTag(String)` | `parseCompoundFully(String)` |
| `ItemStack#setTag(CompoundTag)` | `ItemStack.CODEC.parse(NbtOps.INSTANCE, tag)` |
| `ResourceLocation.tryParse` | `Identifier.tryParse` |

**验收结果（全部实测）**：

- **编译零警告**：`gradlew clean build` → `BUILD SUCCESSFUL in 4s`，8 task 执行，`警告扫描 = 0 条警告 / 0 条 NOTE`（`lab/p1-clean-build.log`）
- **服务端 15 条路由全部响应**：12× `200` + 3× `501`（`/screenshot` `/ui/snapshot` `/ui/click` 是 client-only，按设计返回 501 + 明确文案），**0 条失败**
- **落盘产物正常**：`/run/dump` 写出 `.mcdev/runs/20261019-042354/snapshot-*.json`（1,281 B）
- **`/mcdev` 命令可用**：`status` / `freeze true|false` 经 `/command` 转发全部 `result:0`
- **服务端日志零 ERROR / 零异常**（81 行）

**P1 得到的两条纠正**：

1. **`/status` 的 `tick` 字段不能当冻结判据。** `MinecraftServer.getTickCount()` 数的是**服务端主循环次数**，`/tick freeze` 也不停它。实测冻结 6 秒：`tick` 854 → 916 → 977（照涨），而 `gameTime` / `dayTime` 811 → 812 → 812（**冻住**），解冻后 812 → 873。⇒ 断言「时间没走」要看 `gameTime`，`frozen` 字段反映的是 `ServerTickRateManager.isFrozen()`，两者都对，但语义不同。
2. **探针对 fabric-api 是硬依赖。** `CommandRegistrationCallback` 来自 fabric-api，缺它会 `HARD_DEP_NO_CANDIDATE`（`modid 1.0.0 {depends fabric-api @ [*]}`）直接拒绝加载。P1 期间用 `lab/cache/fabric-api-0.163.0+26.3.jar`（1,774,707 B）垫上。**P2 要决定：继续依赖 fabric-api，还是改用 loader 原生命令注册去掉这条依赖**（后者能减少一个分发物料）。

#### P2 三套适配 ✅（2026-10-19 完成，实测记录见 [p2-adaptation-record.md](p2-adaptation-record.md)）

原计划顺序 Fabric → NeoForge → Forge（按风险从低到高）。实际执行：Fabric 沿用 P1 入口，
新写 Forge / NeoForge 两个入口类（69 / 67 行），**共享库只加了 19 行**（`loader` 字段 + `setLoader()`）。

- **`FakePlayers` 未做** —— 按 m00625「角色操作在客户端实现」，服务端假玩家降级为可选（P4）
- **`RegistryBridgeImpl` 不需要** —— P1 已证 `BuiltInRegistries` 就是 `net.minecraft.*`
- **验收全过**：三家 `/status` 返回 `side=server` + 正确的 `loader`（`fabric`/`forge`/`neoforge`），
  `/command list` 三家 `result:0`，15 条路由矩阵三家一致（12×200 + 3×501），`clean build` 零警告

**P2 的三条实测结论**：

1. **Forge 26.3 是 EventBus 7，与 1.20.1 完全不同。** `MinecraftForge.EVENT_BUS.register(this)` 在 26.3
   连名字都对不上（该字段现在是 `EventBusMigrationHelper` 迁移壳）。正确写法 `XxxEvent.BUS.addListener(Consumer<T>)`
   （`ServerStartedEvent` / `ServerStoppingEvent` 是 record，`RegisterCommandsEvent` 也是 record）；
   `FMLJavaModLoadingContext.getModBusGroup()` 返回的 `BusGroup` 是 **mod 生命周期总线**，游戏事件不用它。
   NeoForge 26.3 反而是「传统」形状（`NeoForge.EVENT_BUS` 就是 `IEventBus` 8.0.5）。
2. **`/status` 的 `loader` 字段只能由平台入口告知。** 共享库判断加载器需要加载器 API（违反架构 §I1），
   所以是 `setLoader("forge")` 而非自动探测，且在 `start()` 之前调（否则留一个「监听已开、标志未写」的微秒级窗口）。
3. **P0 的 mixin 遗留会崩启动。** `mcdev.mixins.json`（`required:true` + `defaultRequire:1`）指向的
   `ServerRunMixin` 在 P1 已随 `TickRateManager` 改造删除，json 还在 ⇒ 启动期 mixin 加载失败。
   已删两个 json + 两处声明（Forge 的 jar MANIFEST `MixinConfigs`、NeoForge 的 `[[mixins]]`）。

#### P3 客户端探针与端差异 ✅（2026-10-19 完成，实测记录见 [p3-client-record.md](p3-client-record.md)）

**结果：Fabric 上 17 条客户端路由全部在位（补完 `/ui/*` 后 15×200 + 1×400 缺参 + 1×409 无目标，无 501），端到端连本地专用服成功。**

| 计划 | 实际 |
|---|---|
| `ClientProbeServer` 788 行移植 | **584 行**（砍掉 UI 交互实现），11 个 `net.minecraft.client.*` import，加载器耦合 **0 处** |
| `/status` 加 `capabilities` | 已加。服务端 `[core,command,world,inventory,craft]`；客户端 `[core,net,inventory,command,player_action,client_ui]`，**由路由注册表派生，不手写** |
| Fabric 用 `splitEnvironmentSourceSets()` | 用了。客户端源码进 `lab/shared/src/client/java`，经 `sourceSets.client.java.srcDirs` 接入；**Forge/NeoForge 客户端入口未做**（2026-10-20 已补：`lab/forge` / `lab/neoforge` 的 `*ClientProbe`，实测见 `docs/architecture.md` §9.8） |
| 客户端 `/connect` 到本地服 | ✅ 服务端 `players` 0→1，日志 `Player520 joined the game` |
| `/screenshot` 在服务端返回 501 | 保留（`handleNotClient`） |

**P3 原以为最有价值的发现（后被打翻）：`Minecraft.screen` 在 26.3 被删。**

> ⚠️ **更正（2026-10-19 13:35 实测）**：`Minecraft` 确实没有 `screen` 了，但 `Screen` 搬到了
> `net.minecraft.client.gui.Gui`（`mc.gui.screen()` / `setScreen(Screen)` / `overlay()`）。
> ⇒ `/ui/snapshot|click|hover|hover_slot|close` **五条全部可实现并已跑通**（`/ui/click` 必须调
> `AbstractWidget.onClick(MouseButtonEvent, boolean)`，只调 `Screen.mouseClicked` 界面不动）。
> 唯一剩余缺口是 `AbstractContainerScreen.hoveredSlot` 为 protected 无公开 accessor，
> `/ui/hover_slot` 只给 menu 名 + 槽位数量 + `hoverDispatched`。详见 [p3-client-record.md](p3-client-record.md) 文末「§3 更正」。
`Screen.mouseClicked(double,double,int)` → `(MouseButtonEvent, boolean)`；
`getGuiLeft/getGuiTop` → protected 字段 `leftPos/topPos`；`Window.getWindow()` →
`Window.handle()` 返回 `long` 且底层换成 **SDL**（`org.lwjgl.sdl.SDL_Event`，不再是 GLFW）；
`ItemStack.getTag()` 删除；`getMainRenderTarget()` 删除；`Screenshot.grab` 签名重做；
`ClickType` → `ContainerInput`；`ServerData(String,String,boolean)` → `(String,String,ServerData$Type)`；
`ConnectScreen.startConnecting(...)` 变 6 参。存活的只有 `mc.player/gameMode/level/hitResult/getFps/getConnection`。

#### P4 玩家操作全集（8-12 天）—— 最大一块

- `PlayerAction` 抽象层（400-600 行）：move / look / use / attack / place / mine / inventory / gamemode，`GameType` 必填前置
- 客户端输入注入（200-300 行，三家共用）
- 服务端假玩家最小集（每家 100-300 行）
- 查询路由补齐（100-150 行）
- **验收**：58 个 serverbound 包覆盖 ≥45 个（78%），未覆盖的 13 个必须都是「测试用不到」且写进文档

#### P5 三类 MOD 测试矩阵（3 天）

- T1–T5b 六条路径
- 负向断言（`/connect` 返回结构化 `reason`）
- `--quickPlaySingleplayer` 单人游戏入口
- 类别判定：元数据 + 行为兜底
- **验收**：用一个「类别②」的样例 MOD，T2 能正确报告「被拒绝 + 原因原文」；用一个「类别③」的样例，T5b 能证明「客户端单独存在不影响服务端」

#### P6 DSH 插件层（2-3 天）

- 14 个 `mcdev_*` 工具改造（`index.js` 1,405 行）
- 加载器判定（20 行，读 `fabric.mod.json` / `mods.toml` / `neoforge.mods.toml` / `build.gradle`）
- 多实例编排（§9.6 六步）
- 任务名特例（Fabric `runGametest`）
- **验收**：一句话驱动「起 Forge 服 + 起客户端 + 放方块 + 断言」全链路跑通

#### P7 文档与发布（2 天）

- 安装说明 + 三加载器使用差异（尤其 §8.2 的类别表达方式）
- 许可三件套核验（`LICENSE` / `NOTICE` / `THIRD_PARTY_NOTICES.md` 已在工作区根）
- 待回填：NeoForge MDK 许可证、`NOTICE` 的版权名义确认

### 13.2 里程碑与可交付检查点

| 里程碑 | 完成标志 | 累计人日 |
|---|---|---:|
| **M1 地基成立** | P0 全部通过（V1–V4、V6–V10） | 1.5 |
| **M2 三家都能跑** | P1 + P2 完成，三家 `/status` 与 `/command` 可用 | 8-10.5 |
| **M3 客户端可用** | P3 完成，客户端能连服并通过 `/player/*` 操作 | 10-12.5 |
| **M4 操作全集** | P4 完成，覆盖率 ≥45/58 | 18-24.5 |
| **M5 测试能力完整** | P5 完成，T1–T5b 可跑 | 21-27.5 |
| **M6 可发布** | P6 + P7 完成 | 24.5-31.5 |

### 13.3 排序理由

1. **P0 第一**，因为它是唯一可能推翻整个方案的阶段。所有版本号、任务名、插件坐标都来自官方仓库文件，但**三套 build 从未真跑过一次**。花 1.5 天买这个确定性，比在 P1 之后才发现某套构建链起不来划算得多。（原先 P0 还包含「Forge 自建 FakePlayer」，用户定「角色操作在客户端实现」后该项已移出 P0 门槛。）
2. **P3 不放在最后**，因为端差异影响 `/status` 的字段设计，越晚改代价越大。
3. **P4 排在 P5 之前**，因为「操作全集」是 T1–T5 的驱动手段 —— 测试矩阵需要动作 API 才能写。

### 13.4 明确的 out-of-scope

本项目**不做**：

- 运行时加载器探测 / 统一产物（§4.3）
- Quilt 适配（§1.2）
- 插件服（Bukkit / Paper）支持（§1.3，v2 再议）
- 用户生产服 `Server-26.3` 的任何改动
- **26.3 以外的 MC 版本（声明了范围，但本期不实现）**：支持范围暂定 **26.1.2 ~ 26.3**（下限 26.1.2 = 26.1.x 线最后一个补丁版，m00898），但本期只做 26.3；26.1 / 26.2 不进任何里程碑，架构只做零成本预留（架构文档 §11.2）
- **1.21.11 及更早版本**（不是「暂缓」，是硬边界：去混淆的界在 26.1，跨过去要重做整套构建链，见架构文档 §11.4）
- **一份源码同时编出多个 MC 版本的 jar**（模式 B / stonecutter）—— 触发条件 = 受支持 minor 版本 ≥2，见架构文档 §11.3
- **多分支并行**（模式 C）—— 已设计为预案，当前不启用（只有 `main` == 26.3）
- 非 Windows 平台的启动器适配
- 自动修复被测 MOD 的 bug（只报告，不修）

---

## 14. 风险与未验证项

必读 —— 以下状态截至 P0 执行完毕（2026-10-19）：

1. ~~**三套 build 从未真跑过。**~~ **已闭合（P0 V1–V3）。** 三家 `gradlew build` 全过：Fabric 3m13s、Forge 4m44s、NeoForge 4m53s；三家 `runServer` 均到 `Done`。
2. ~~**Forge 26.3 自建 FakePlayer 可行性未实测。**~~ **已降级为可选功能，不再是风险。** 用户已定「角色操作在客户端实现」（m00625），服务端探针只做观察 + `/command`；Forge 的 `FakePlayerFactory` 已移除这件事只影响「要不要做多玩家并发测试」这一可选能力，不影响方案边界。（真要补时才需验证 Carpet 那套能否平移到 Forge：`Connection` 与 `ServerGamePacketListenerImpl` 的构造差异）
3. ~~**NeoForge GameTest 走 `DeferredRegister` 未实测。**~~ **已闭合，且原设想被推翻（P0 V6）。** 不需要 `DeferredRegister` + `test_instance/*.json` + datagen；`RegisterGameTestsEvent.registerTest(id, GameTestInstance)` 就能运行期注册。实测 `All 2 required tests passed :)`，退出码 0。
4. **26.3 客户端侧探针注入点只部分核对。** 尤其是创造物品栏、旁观者菜单这些 GUI 的可访问性。（P4 期间逐步验证）
5. **Forge EventBus 7 的 `@SubscribeEvent` 新语义仍未完整阅读。** P0 期间官方 MDK 模板的 `MinecraftForge.EVENT_BUS.register(this)` + `@SubscribeEvent` 在 26.3 上能正常加载与启动，但「迁移壳到真总线」这件事对**探针要监听的每一个事件**（`ServerStartedEvent` / `RegisterCommandsEvent` / `ServerStoppingEvent`）都还要逐个核对一遍，属 P2 的工作量而非 P0。（P0 V2 只证明「能起」，没证明「能收齐事件」）
6. ~~**mixin `compatibilityLevel` 从 `JAVA_17` 改到 `JAVA_25` 的实际行为未测。**~~ **已闭合（P0 V7）。** 三家全部注入 major=69 的 `MinecraftServer` 成功；`compatibilityLevel` 只是消警字段，不是硬门槛。真正的坑是 **Forge 的 `mods.toml [[mixins]]` 是死配置**，必须走 jar `MANIFEST.MF` 的 `MixinConfigs`。
7. **NeoForge `PayloadRegistrar.optional()` 的确切语义未实测。** 目前只从方法名推断（§8.2）。
8. ~~**Forge / NeoForge 能否被 MSL 的 `Core` 单 jar 模型驱动未实测。**~~ **已闭合（P0 V8 + V9）。** 三台 test 实例补完前置后全部到 `Done`；Forge/NeoForge 用 `@user_jvm_args.txt @libraries/.../win_args.txt` 这套 argfile 展开成功；**MSL 本身也实测通过** —— 界面点一次「开启服务器」，日志打印 `启动参数：  @libraries/.../win_args.txt nogui`，`Done (0.252s)!`，探针 `/status` 200。⇒ 单一 `Launcher` 实现（MSL）能覆盖三家，不需要为 Forge/NeoForge 备第二套。
9. ~~**现代 Forge / NeoForge 的 `runServer` 是否仍做 dist 剥离未实测。**~~ **已确认（P0 V2）。** ForgeGradle 的构建步骤序列里明确有 `extractServer → stripServer → stripClient → merge`，dist 剥离**仍然存在** ⇒ `net.minecraft.client.*` 在专用服确实不可达，§7.4 的「客户端代码必须物理隔离」不是保守估计而是硬约束。
10. **新增：环境依赖比预想的重。** gradle 发行版、`maven.neoforged.net`、ForgeGradle 的 JDK 8 三处都因网络原因在本机不可直取，全部靠镜像/代理/手工预置绕开（§13.1 表）。这不是方案缺陷，但意味着**「一键装好」的体验需要显式设计**，不能假定用户网络能直连 mojang / neoforge / github。


四个环境缺口（本机）：

- Forge / NeoForge 客户端**无 `mods` 目录**（干净裸壳），塞探针需自建
- 本机实装 NeoForge 是 **26.3.0.88**，MDK 模板引用 26.3.0.90，差一个小版
- `<HMCL_HOME>\server` **零条目**，服务端侧素材在 `<MC_ROOT>`（96 mods 生产服 + `fabric-26.3.jar`）
- 无 gradle on PATH，但 `~/.gradle` 下已有 caches / daemon / wrapper

---

## 15. 参考

- 基线仓库：https://github.com/yan-mc/minecraft-mod-dev
- Fabric 去混淆公告：https://fabricmc.net/2025/10/31/obfuscation.html
- Fabric 映射迁移文档：https://docs.fabricmc.net/develop/porting/mappings/
- Minecraft Wiki `Java Edition 26.1`：https://minecraft.wiki/w/Java_Edition_26.1
- Fabric Carpet（假玩家参考）：https://github.com/gnembon/fabric-carpet（分支 `26.1`）
- Modrinth API：https://api.modrinth.com/v2/search
- Apache-2.0 官方文本：https://www.apache.org/licenses/LICENSE-2.0.txt

---

*本文件为评估 + 规划产物。P0（V1–V4、V6–V8、V10）已于 2026-10-19 执行完毕并回填 §13.1；V9 因 MSL 无 CLI 入口，待一次人工点击后回填。§14 的剩余项归入 P2–P4 阶段验证。*
