决定性验证:gen_1_21 后端在 1.21.1 上收到 {"event":"ready"}
⇒ 世代分派架构成立:同一套 Python 层,两个独立编译的 Java 后端
运行时发现第 12 处世代差异(编译期看不出):
1.20.x registryAccess 是可变 LayeredRegistryAccess → 事后反射替换
1.21.x registryAccess 是 final RegistryAccess$Frozen → 反射抛
"Can not set final field"
处理:gen_1_21 改为先载注册表 → 经 CommonListenerCookie.receivedRegistries
传入 → 再构造 listener。比 1.20 的反射做法更干净。
新增 mcfold/provision.py(从官方 manifest 装任意版本,版本无关):
- 跳过音频资源:1.21.1 实测 3911 条里跳过 3744 条
- natives 两种形态都认(1.21 起是独立库条目,非 classifiers)
- 实测 1.21.1:274 文件 / 200MB / 32s / 0 失败
4.2 KiB
4.2 KiB
世代差异清单(实测,非推断)
来源:官方 client_mappings.txt 逐版本对比(1.20.1 / 1.20.2 / 1.21.1),
以及两侧源码对各自 client jar 的实际编译结果。
判据
net.minecraft.client.DeltaTracker 类是否存在 ⇒ 1.21+。
这是 1.21 引入的时间抽象,也正是 GameRenderer.render 签名变化的根源。
比硬编码版本号稳(官方改版本号不影响判据)。
实现:mcfold/backends/__init__.py:detect_generation()
差异全表(8 处,覆盖 21 个检查点中的 13 个「完全一致」之外的部分)
| # | API | 1.20.x | 1.21+ | 处理 |
|---|---|---|---|---|
| 1 | Minecraft.setLevel |
(ClientLevel) |
(ClientLevel, ReceivingLevelScreen$Reason) |
gen_1_21 传 Reason.OTHER |
| 2 | GameRenderer.render |
(float,long,boolean) |
(DeltaTracker, boolean) |
gen_1_21 用 mc.getTimer() |
| 3 | 区块计数 | countRenderedChunks() |
countRenderedSections() |
各世代用各自名字 |
| 4 | 同理 | hasRenderedAllChunks() |
hasRenderedAllSections() |
同上 |
| 5 | ServerPacksSource.createPackRepository |
(Path) |
(Path, DirectoryValidator) |
gen_1_21 构造 validator |
| 6 | ResourceLocation 构造 |
new ResourceLocation(s) |
ResourceLocation.parse(s) |
静态工厂 |
| 7 | SharedConstants.enableDataFixerOptimizations |
有 | 已移除 | gen_1_21 不调 |
| 8 | User 构造 |
(String,String sessionId,...) |
(String,UUID,...) |
传 UUID |
| 9 | ChunkStatus |
world.level.chunk.ChunkStatus |
world.level.chunk.status.ChunkStatus |
包路径变了 |
| 10 | ClientPacketListener 构造 |
(Minecraft,Screen,Connection,ServerData,GameProfile,Telemetry) |
(Minecraft,Connection,CommonListenerCookie) |
★ 参数个数与语义都变 |
| 11 | GameRenderer.loadEffect |
package-private(同包可调) | private(同包也调不到) | gen_1_21 用反射 |
★ 关键结论:差异集中在 11 处,而不是「每个版本一套」。
21 个检查点里 13 个(如 tick/allChanged/replaceWithPacketData/
updateViewCenter/getChunkSource/PackRepository.*)跨世代完全一致。
为什么必须分后端(而不是一个文件里 switch)
Java 编译期绑定签名。实测:
世代 B 的调用对 1.20.1 jar 编译:
error: cannot find symbol class DeltaTracker
DeltaTracker 在 1.20.x 的 jar 里不存在 ⇒ 两代的调用无法共存于一份源码。
所以每个世代独立编译,Python 层做分派。
加新世代的做法(用户 2026-09-25 定向)
scaffold/java/gen_<ver>/建目录,拷上一代源码- 按上表改差异点(对着 javap 输出改,不要凭印象写 API 名)
mcfold/backends/gen_<ver>.py声明markers(判据类)mcfold/backends/__init__.py的GENERATIONS加一条- 各自对对应版本的 named jar 编译
⚠️ 教训(本轮踩过):Reason.LEVEL_LOAD、DirectoryValidator.NONE
都是凭印象写出来的、实际不存在的 API;loadEffect 的可见性也没验证。
改之前先 javap。
补充:1.21.1 实际运行验证(比编译更强的证据)
gen_1_21 已实测启动成功(stdout 收到 {"event":"ready"})。
运行期又暴露 1 处「编译期看不出」的差异:
| # | API | 1.20.x | 1.21+ | 处理 |
|---|---|---|---|---|
| 12 | ClientPacketListener.registryAccess |
可变 LayeredRegistryAccess,可事后反射替换 |
final RegistryAccess$Frozen,反射抛 Can not set final field |
gen_1_21 改为先载注册表 → 经 CommonListenerCookie.receivedRegistries 传入 → 再构造 listener |
★ 这条是本轮最有价值的发现:1.21 的 cookie 机制让注册表注入比 1.20 的反射更干净。 两代的启动顺序因此不同:
1.20.x: 构造 listener → 反射替换 registryAccess 字段
1.21.x: 先加载注册表 → 构造 cookie(含注册表) → 构造 listener(final 字段一次到位)
provision 的实测发现
- natives 在 1.21.1 是独立库条目(
name含:natives-linux), 不走老版downloads.classifiers结构 ⇒ 两种形态都要认。 - 跳过音频资源可省 3744/3911 条(仅 167 条实际下载), 实测 1.21.1 全量 274 文件 / 200MB / 32s,0 失败。