import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.mojang.blaze3d.pipeline.MainTarget; import com.mojang.blaze3d.platform.DisplayData; import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.authlib.GameProfile; import com.mojang.authlib.properties.PropertyMap; import net.minecraft.SharedConstants; import net.minecraft.client.Minecraft; import net.minecraft.client.User; import net.minecraft.client.main.GameConfig; import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.client.multiplayer.ClientPacketListener; import net.minecraft.client.telemetry.TelemetryEventSender; import net.minecraft.client.telemetry.WorldSessionTelemetryManager; import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; import net.minecraft.core.RegistryAccess; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; import net.minecraft.network.Connection; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.protocol.PacketFlow; import net.minecraft.resources.RegistryDataLoader; import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.Bootstrap; import net.minecraft.server.packs.resources.ResourceManager; import net.minecraft.util.profiling.InactiveProfiler; import net.minecraft.world.Difficulty; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.Level; import net.minecraft.world.level.LightLayer; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.state.BlockBehaviour; import net.minecraft.world.level.block.Blocks; import net.minecraft.core.Registry; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.DataLayer; import net.minecraft.world.level.chunk.LevelChunk; import net.minecraft.world.level.chunk.LevelChunkSection; import net.minecraft.world.level.dimension.DimensionType; import org.lwjgl.opengl.GL11; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.InputStreamReader; import java.io.PrintStream; import java.net.Proxy; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Base64; import java.util.Optional; import java.util.OptionalInt; import java.util.UUID; /** * mcfold 渲染服务:常驻进程,NDJSON(行分隔 JSON) 协议,由 Python 侧驱动。 * * 设计约束(用户 2026-09-25): * - 不起完整客户端:不跑主循环、不连服、不登录、不作玩家 * - 我们的世界数据喂给官方渲染器出图 * * 协议(stdin/stdout 各一行一个 JSON): * → {"id":1,"cmd":"chunk","cx":0,"cz":0,"data":"","heightmaps":null} * → {"id":2,"cmd":"camera","x":8,"y":70,"z":8,"yaw":0,"pitch":0} * → {"id":3,"cmd":"render","frames":60,"out":"/tmp/a.png"} * ← {"id":3,"ok":true,"width":854,"height":480,"chunksRendered":29, * "distinctColors":1450,"image":""} * * 启动就绪时主动发 {"event":"ready"}。 * * ★ 关键实现点(全部实测得来,详见 scaffold/README.md): * 1. tryDetectVersion 必须先于 Bootstrap.bootStrap * 2. FolderData 顺序 (gameDir, resourcePackDir, assets, assetIndex),assetIndex 用落盘名 * 3. 资源必须手工同步重载(各 listener 的 public reload()),不能 join Minecraft 的 * 4. LoadingOverlay + ReceivingLevelScreen 都要清,否则渲出加载屏 * 5. 每帧 mc.tick() 才能推进区块网格重建队列 * 6. 读像素必须绑 FBO * 7. chunk 数据走官方 replaceWithPacketData(与网络包同路径) */ public class McfoldRenderServer { static final Gson GSON = new Gson(); static final PrintStream OUT = new PrintStream( new java.io.FileOutputStream(java.io.FileDescriptor.out), true, StandardCharsets.UTF_8); static Minecraft mc; static ClientLevel level; static int width = 854, height = 480; public static void main(String[] args) throws Exception { // stdout 只放协议 JSON,MC 的 INFO/WARN 全部赶到 stderr System.setOut(new PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.err), true)); // ★ 不硬编码实例目录(版本隔离策略): // 实例目录 = mods/config/options 的独占边界,必须由调用方按档案指定。 // 缺省只给一个不含 mods 的临时目录,避免污染任何真实实例。 String gameDir = arg(args, "--gameDir", System.getProperty("java.io.tmpdir") + "/mcfold-noinstance"); String assets = arg(args, "--assets", "/home/newqqagent/mcview/store/vanilla-1.20.1/assets"); String assetIdx = arg(args, "--assetIndex", "1.20.1"); width = Integer.parseInt(arg(args, "--width", "854")); height = Integer.parseInt(arg(args, "--height", "480")); try { boot(gameDir, assets, assetIdx); } catch (Throwable t) { emitError(null, "启动失败: " + t); t.printStackTrace(System.err); System.exit(1); } event("ready", null); BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); String line; while ((line = in.readLine()) != null) { line = line.trim(); if (line.isEmpty()) continue; JsonObject req; try { req = JsonParser.parseString(line).getAsJsonObject(); } catch (Exception e) { emitError(null, "非法 JSON: " + e.getMessage()); continue; } Integer id = req.has("id") ? req.get("id").getAsInt() : null; try { handle(id, req); } catch (Throwable t) { emitError(id, t.toString()); t.printStackTrace(System.err); } } System.exit(0); } // ---------------- 协议处理 ---------------- static void handle(Integer id, JsonObject req) throws Exception { String cmd = req.get("cmd").getAsString(); switch (cmd) { case "shutdown": { JsonObject r = new JsonObject(); r.addProperty("ok", true); emit(id, r); System.exit(0); } case "chunk": { int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt(); byte[] data = decode(req.get("data").getAsString()); var resp = applyChunk(cx, cz, data); JsonObject r = new JsonObject(); r.addProperty("ok", true); r.add("result", resp); emit(id, r); break; } case "chunks": { JsonArray items = req.getAsJsonArray("items"); int ok = 0, failed = 0; String firstErr = null; for (JsonElement el : items) { JsonObject it = el.getAsJsonObject(); try { applyChunk(it.get("cx").getAsInt(), it.get("cz").getAsInt(), decode(it.get("data").getAsString())); ok++; } catch (Throwable t) { failed++; if (firstErr == null) { firstErr = it.get("cx") + "," + it.get("cz") + ": " + t; } } } levelRendererAllChanged(); JsonObject r = new JsonObject(); r.addProperty("ok", true); r.addProperty("injected", ok); r.addProperty("failed", failed); if (firstErr != null) r.addProperty("firstError", firstErr); var cache2 = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource(); r.addProperty("loadedChunks", cache2.getLoadedChunksCount()); emit(id, r); break; } case "setblocks": { // ★ 世代无关:Python 只做路由,方块写入、调色板组装、入 cache // 全部由 Java 用官方 API 完成 —— 版本差异不落回 Python。 // 载荷:sections[].rle = [[stateId,count],...],按 (y,z,x) 线性序覆盖该 section 4096 格。 // 优势:协议位打包(历代改过)不再由 Python 复制;入 cache 走官方 // ClientboundLevelChunkPacketData(LevelChunk) → getReadBuffer(), // 签名两代一致,**无需反射**。 int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt(); LevelChunk chunk = new LevelChunk(level, new ChunkPos(cx, cz)); LevelChunkSection[] sections = chunk.getSections(); int placed = 0; long cells = 0; for (JsonElement se : req.getAsJsonArray("sections")) { JsonObject so = se.getAsJsonObject(); int si = so.get("index").getAsInt(); if (si < 0 || si >= sections.length) continue; LevelChunkSection sec = sections[si]; int x = 0, y = 0, z = 0; for (JsonElement re : so.getAsJsonArray("rle")) { JsonArray pair = re.getAsJsonArray(); int stateId = pair.get(0).getAsInt(); int count = pair.get(1).getAsInt(); BlockState st = stateId == 0 ? null : Block.stateById(stateId); for (int i = 0; i < count; i++) { if (st != null) { sec.setBlockState(x, y, z, st); placed++; } cells++; if (++x == 16) { x = 0; if (++z == 16) { z = 0; y++; } } } } } // 光照(无光照数据则全亮,否则地形全黑) var light = level.getChunkSource().getLightEngine(); for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) { DataLayer sky = new DataLayer(); for (int lx = 0; lx < 16; lx++) for (int ly = 0; ly < 16; ly++) for (int lz = 0; lz < 16; lz++) sky.set(lx, ly, lz, 15); light.queueSectionData(LightLayer.SKY, net.minecraft.core.SectionPos.of(cx, sy, cz), sky); light.queueSectionData(LightLayer.BLOCK, net.minecraft.core.SectionPos.of(cx, sy, cz), sky); } // ★ 官方序列化 → 官方入口入 cache(两代签名一致) var pkt = new net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData(chunk); level.getChunkSource().replaceWithPacketData(cx, cz, pkt.getReadBuffer(), pkt.getHeightmaps(), new java.util.function.Consumer< net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput>() { @Override public void accept( net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput t) { } }); light.setLightEnabled(new ChunkPos(cx, cz), true); light.runLightUpdates(); // ★ 必须触发网格重建 —— 否则渲染器不知道该 section 有数据(实测) mc.levelRenderer.allChanged(); JsonObject r = new JsonObject(); r.addProperty("ok", true); r.addProperty("placed", placed); r.addProperty("cells", cells); r.addProperty("sections", sections.length); r.addProperty("loadedChunks", level.getChunkSource().getLoadedChunksCount()); emit(id, r); break; } case "mods": { // ★ mod 支持全在 Java 侧完成(用户 2026-09-25 定向): // Java 自己扫 mods 目录、自己读 jar、自己注册方块、自己加载资源包。 // Python 只传目录路径。 String dir = req.has("dir") ? req.get("dir").getAsString() : null; if (dir == null) { JsonObject r = new JsonObject(); r.addProperty("ok", true); r.addProperty("mods", 0); r.addProperty("note", "未提供 dir"); emit(id, r); break; } JsonObject res = loadMods(dir); emit(id, res); break; } case "modelcheck": { // ★ 程序化判据:mod 方块的 baked model 是否就是 missing model // (画面里看到紫黑棋盘格是「缺贴图」的视觉表现,这里给出可断言的答案) JsonArray specs = req.getAsJsonArray("blocks"); var shaper = mc.getBlockRenderer().getBlockModelShaper(); Object missing = null; try { var mmF = Minecraft.class.getDeclaredField("modelManager"); mmF.setAccessible(true); Object mm = mmF.get(mc); missing = mm.getClass().getMethod("getMissingModel").invoke(mm); } catch (Throwable t) { } var lookup = BuiltInRegistries.BLOCK.asLookup(); JsonArray rows = new JsonArray(); for (JsonElement el : specs) { String spec = el.getAsString(); JsonObject ro = new JsonObject(); ro.addProperty("spec", spec); try { var pr = net.minecraft.commands.arguments.blocks.BlockStateParser .parseForBlock(lookup, spec, false); var st = pr.blockState(); Object baked = shaper.getBlockModel(st); ro.addProperty("isMissing", baked == missing); ro.addProperty("modelClass", baked.getClass().getSimpleName()); try { Object icon = baked.getClass().getMethod("getParticleIcon", com.mojang.serialization.Codec.class).invoke(baked, net.minecraft.client.renderer.texture.TextureAtlasSprite.class == null ? null : null); } catch (Throwable ignore) { } } catch (Throwable t) { ro.addProperty("error", String.valueOf(t)); } rows.add(ro); } JsonObject r = new JsonObject(); r.addProperty("ok", true); r.add("results", rows); emit(id, r); break; } case "atlascheck": { // ★ 判据:mod 贴图是否真进了 blocks atlas // 正确 API 是 Minecraft.getTextureAtlas(...) → Function // (早先用 ModelManager.getAtlasSet 是错的,导致全 NPE 假绿) JsonArray arr = req.getAsJsonArray("textures"); var fn = mc.getTextureAtlas( net.minecraft.client.renderer.texture.TextureAtlas.LOCATION_BLOCKS); JsonArray rows = new JsonArray(); for (JsonElement el : arr) { String tex = el.getAsString(); JsonObject ro = new JsonObject(); ro.addProperty("texture", tex); ro.addProperty("isMissing", true); ro.addProperty("sprite", ""); try { var sprite = fn.apply(ResourceLocation.parse(tex)); String reg = sprite.contents().name().toString(); ro.addProperty("sprite", reg); ro.addProperty("isMissing", reg.contains("missingno")); } catch (Throwable t) { ro.addProperty("sprite", "ERROR: " + t); } rows.add(ro); } JsonObject r = new JsonObject(); r.addProperty("ok", true); r.add("results", rows); emit(id, r); break; } case "blockinfo": { // ★ 方块信息查询(Java 侧,因为只有它有 level + 注册表 + 模型状态)。 // Python 只路由,不做任何格式/语义判断。 // 入参:{"cmd":"blockinfo","x":..,"y":..,"z":..} int bx = req.get("x").getAsInt(); int by = req.get("y").getAsInt(); int bz = req.get("z").getAsInt(); BlockPos pos = new BlockPos(bx, by, bz); JsonObject r = new JsonObject(); r.addProperty("ok", true); r.addProperty("x", bx); r.addProperty("y", by); r.addProperty("z", bz); int pcx = bx >> 4, pcz = bz >> 4; // ★ 判据必须是「真拿到了 LevelChunk」: // ClientLevel.hasChunk() 实测**恒返回 true**(反编译见 306 行), // 基类 ChunkSource.hasChunk() 走 getChunk(...,false), // 而 ClientChunkCache 覆写后在该参数下返回 emptyChunk(void_air 占位) // ⇒ 都不可用于「未加载 vs 空气」的区分。 // 可靠做法:getChunk(..., requireChunk=**false**) —— 反编译实测 // return requireChunk ? emptyChunk : null; // 即 requireChunk=false 时**未加载才给 null**(传 true 反而给占位)。 var realChunk = level.getChunkSource() // ★ 世代差异:1.21 把 ChunkStatus 移到 world.level.chunk.status .getChunk(pcx, pcz, net.minecraft.world.level.chunk.status.ChunkStatus.FULL, false); boolean loaded = realChunk != null; r.addProperty("chunkLoaded", loaded); r.addProperty("inBuildHeight", level.isInWorldBounds(pos)); if (!loaded) { // ★ 未加载 ≠ 空气(设计文档附录 D 强调的语义区分) r.addProperty("known", false); r.addProperty("reason", "chunk_not_loaded"); emit(id, r); break; } BlockState st = level.getBlockState(pos); Block blk = st.getBlock(); ResourceLocation key = BuiltInRegistries.BLOCK.getKey(blk); r.addProperty("known", true); r.addProperty("block", key == null ? "unknown" : key.toString()); r.addProperty("stateId", Block.getId(st)); r.addProperty("state", st.toString()); r.addProperty("isAir", st.isAir()); r.addProperty("isSolid", st.isSolid()); // 方块属性(朝向/开关等)—— 渲染与指令都需要 JsonObject props = new JsonObject(); try { for (var e : st.getValues().entrySet()) { props.addProperty(e.getKey().getName(), String.valueOf(e.getValue())); } } catch (Throwable ignore) { } r.add("properties", props); // 光照与硬度(agent 判断「这能不能挖/亮不亮」用) try { r.addProperty("lightEmission", st.getLightEmission()); r.addProperty("destroySpeed", st.getDestroySpeed(level, pos)); r.addProperty("canOcclude", st.canOcclude()); r.addProperty("hasBlockEntity", st.hasBlockEntity()); } catch (Throwable ignore) { } // 贴图是否真的在 atlas 里(缺贴图的**程序化判据**,不靠看图) // BlockModelShaper.getParticleIcon(BlockState) 是静态的; // 它返回的 sprite 名字若含 missingno 就是缺贴图。 try { var shaper = mc.getBlockRenderer().getBlockModelShaper(); // 实例方法(1.20.1 与 1.21.1 签名一致) var icon = shaper.getParticleIcon(st); String reg = icon.contents().name().toString(); r.addProperty("particleTexture", reg); r.addProperty("isMissingTexture", reg.contains("missingno")); } catch (Throwable t) { r.addProperty("particleTexture", "unavailable: " + t.getClass().getSimpleName()); } // 是否来自 mod(零加载器注册的方块 namespace 不在 minecraft) r.addProperty("fromMod", key != null && !key.getNamespace().equals("minecraft")); emit(id, r); break; } case "camera": { double x = req.get("x").getAsDouble(), y = req.get("y").getAsDouble(), z = req.get("z").getAsDouble(); float yaw = req.has("yaw") ? req.get("yaw").getAsFloat() : 0f; float pitch = req.has("pitch") ? req.get("pitch").getAsFloat() : 0f; // ★ 必须 setPosRaw + 手动更新 chunk 追踪,否则实体仍属旧区块 mc.player.setPosRaw(x, y, z); mc.player.xo = x; mc.player.yo = y; mc.player.zo = z; mc.player.setYRot(yaw); mc.player.setXRot(pitch); mc.player.yRotO = yaw; mc.player.xRotO = pitch; mc.setCameraEntity(mc.player); // 视野中心跟着相机走,否则目标区域区块被 ClientChunkCache 丢弃 level.getChunkSource().updateViewCenter((int)Math.floor(x) >> 4, (int)Math.floor(z) >> 4); mc.levelRenderer.allChanged(); tickFrames(5); JsonObject r = new JsonObject(); r.addProperty("ok", true); JsonArray c = new JsonArray(); c.add(x); c.add(y); c.add(z); r.add("camera", c); emit(id, r); break; } case "view": { if (req.has("cameraType")) { mc.options.setCameraType(net.minecraft.client.CameraType.valueOf(req.get("cameraType").getAsString())); } if (req.has("renderDistance")) { int rd = req.get("renderDistance").getAsInt(); // ★ 两处都要设:renderDistance 是本地设置; // serverRenderDistance 模拟服务端下发的视距上限 // (getEffectiveRenderDistance = min(两者)) mc.options.renderDistance().set(rd); mc.options.setServerRenderDistance(rd); // ★ 必须同步 ClientChunkCache 的 storage 半径, // 否则区块被 inRange 丢弃(官方靠服务端包触发 updateViewRadius) level.getChunkSource().updateViewRadius(rd); mc.levelRenderer.allChanged(); } if (req.has("fov")) { mc.options.fov().set(req.get("fov").getAsInt()); } // ── 原版画质选项(光影的基础层)── if (req.has("graphicsMode")) { mc.options.graphicsMode().set(net.minecraft.client.GraphicsStatus .valueOf(req.get("graphicsMode").getAsString())); mc.levelRenderer.allChanged(); // FANCY 会启用更多渲染层 } if (req.has("ambientOcclusion")) { mc.options.ambientOcclusion().set(req.get("ambientOcclusion").getAsBoolean()); mc.levelRenderer.allChanged(); } if (req.has("mipmapLevels")) { mc.options.mipmapLevels().set(req.get("mipmapLevels").getAsInt()); } if (req.has("gamma")) { mc.options.gamma().set(req.get("gamma").getAsDouble()); } if (req.has("entityShadows")) { mc.options.entityShadows().set(req.get("entityShadows").getAsBoolean()); } if (req.has("clouds")) { mc.options.cloudStatus().set(net.minecraft.client.CloudStatus .valueOf(req.get("clouds").getAsString())); } if (req.has("particles")) { mc.options.particles().set(net.minecraft.client.ParticleStatus .valueOf(req.get("particles").getAsString())); } // ── 原版后处理链(PostChain 基础设施)── if (req.has("postEffect")) { String eff = req.get("postEffect").getAsString(); if (eff.isEmpty() || eff.equals("none")) { mc.gameRenderer.shutdownEffect(); } else { // loadEffect 是包私有 → 走同包 bridge net.minecraft.client.renderer.McfoldBridge.loadEffect( mc.gameRenderer, ResourceLocation.parse(eff)); } } // 回包带上真实生效值,便于验证 JsonObject r = new JsonObject(); r.addProperty("ok", true); JsonObject cur = new JsonObject(); try { java.lang.reflect.Field pf = net.minecraft.client.renderer.GameRenderer.class .getDeclaredField("postEffect"); pf.setAccessible(true); cur.addProperty("postEffect", pf.get(mc.gameRenderer) != null ? "active" : "none"); } catch (Throwable ignore) { cur.addProperty("postEffect", "unknown"); } cur.addProperty("graphicsMode", mc.options.graphicsMode().get().name()); cur.addProperty("ambientOcclusion", mc.options.ambientOcclusion().get()); cur.addProperty("mipmapLevels", mc.options.mipmapLevels().get()); cur.addProperty("gamma", mc.options.gamma().get()); cur.addProperty("entityShadows", mc.options.entityShadows().get()); cur.addProperty("clouds", mc.options.cloudStatus().get().name()); cur.addProperty("renderDistance", mc.options.getEffectiveRenderDistance()); r.add("current", cur); emit(id, r); break; } case "capacity": { // ★ 世界尺寸由运行时提供 —— Python 不硬编码版本相关常量。 // 这些方法都在 LevelHeightAccessor 上(Level 实现),两代 API 一致。 JsonObject r = new JsonObject(); r.addProperty("ok", true); r.addProperty("sectionsCount", level.getSectionsCount()); r.addProperty("minSection", level.getMinSection()); r.addProperty("maxSection", level.getMaxSection()); r.addProperty("minBuildHeight", level.getMinBuildHeight()); r.addProperty("maxBuildHeight", level.getMaxBuildHeight()); r.addProperty("height", level.getHeight()); // 生物群系默认 id(不硬编码 1) try { var bio = mc.level.registryAccess().registryOrThrow(Registries.BIOME); var key = bio.getKey(bio.byId(1)); r.addProperty("defaultBiome", key != null ? key.toString() : "minecraft:plains"); } catch (Throwable t) { r.addProperty("defaultBiome", "minecraft:plains"); } emit(id, r); break; } case "registry": { JsonObject r = new JsonObject(); r.addProperty("ok", true); r.addProperty("blockCount", BuiltInRegistries.BLOCK.size()); r.addProperty("stateCount", Block.BLOCK_STATE_REGISTRY.size()); emit(id, r); break; } case "lookup": { int sid = req.get("stateId").getAsInt(); BlockState st = Block.stateById(sid); ResourceLocation key = BuiltInRegistries.BLOCK.getKey(st.getBlock()); JsonObject r = new JsonObject(); r.addProperty("ok", true); r.addProperty("stateId", sid); r.addProperty("block", key.toString()); r.addProperty("state", st.toString()); r.addProperty("inRange", sid >= 0 && sid < Block.BLOCK_STATE_REGISTRY.size()); emit(id, r); break; } case "fog": { // 关雾:fogStart/fogEnd 是 levelRenderer 内部状态; // 更简单可靠的是加大 renderDistance + 用 BLOCK 光照 JsonObject r = new JsonObject(); r.addProperty("ok", true); emit(id, r); break; } case "resolve": { // ★ 方块名(+属性) → state id。存档 palette 是名字,网络载荷要 id, // 而这个名字→id 的权威映射只有客户端注册表有。 // 入参: {"cmd":"resolve","blocks":["minecraft:oak_log[axis=y]", ...]} JsonArray arr = req.getAsJsonArray("blocks"); // ★ parseForBlock 要 HolderLookup(不是 Registry)。 // 用**静态注册表**的 asLookup():它是方块名→id 的权威来源 // (mod 方块也注册在这里),且两代 API 一致。 // 不用 mc.level.registryAccess() —— 1.21 上它来自 cookie, // 只含 worldgen 那批注册表,查 block 会报 "Registry not found"(实测)。 var reg = BuiltInRegistries.BLOCK.asLookup(); JsonArray out = new JsonArray(); int unknown = 0; for (JsonElement el : arr) { String spec = el.getAsString(); int sid; try { var res = net.minecraft.commands.arguments.blocks.BlockStateParser .parseForBlock(reg, spec, false); sid = Block.getId(res.blockState()); } catch (Throwable t) { sid = -1; unknown++; } out.add(sid); } JsonObject r = new JsonObject(); r.addProperty("ok", true); r.add("ids", out); r.addProperty("unknown", unknown); r.addProperty("total", arr.size()); emit(id, r); break; } case "debug": { JsonObject r = new JsonObject(); r.addProperty("ok", true); r.addProperty("playerPos", mc.player.getX() + "," + mc.player.getY() + "," + mc.player.getZ()); int pcx = (int)Math.floor(mc.player.getX()) >> 4, pcz = (int)Math.floor(mc.player.getZ()) >> 4; r.addProperty("playerChunk", pcx + "," + pcz); var cc = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource(); r.addProperty("loadedChunks", cc.getLoadedChunksCount()); r.addProperty("chunksRendered", mc.levelRenderer.countRenderedSections()); r.addProperty("screenNow", mc.screen != null ? mc.screen.getClass().getSimpleName() : "null"); r.addProperty("overlayNow", mc.getOverlay() != null ? mc.getOverlay().getClass().getSimpleName() : "null"); r.addProperty("levelNow", mc.level != null); r.addProperty("playerNow", mc.player != null); r.addProperty("cameraEntity", mc.getCameraEntity() != null ? "set" : "null"); r.addProperty("noRender", mc.noRender); r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllSections()); var got = cc.getChunk(pcx, pcz, net.minecraft.world.level.chunk.status.ChunkStatus.FULL, false); r.addProperty("chunkAtPlayer", got != null ? got.getClass().getSimpleName() : "null"); if (got != null) { // 扫描玩家所在列,找最高的非空气方块 int top = Integer.MIN_VALUE; for (int y = level.getMaxBuildHeight() - 1; y >= level.getMinBuildHeight(); y--) { var st = got.getBlockState(new BlockPos( (int)Math.floor(mc.player.getX()) & 15, y, (int)Math.floor(mc.player.getZ()) & 15)); if (!st.isAir()) { top = y; r.addProperty("topSolidY", y); r.addProperty("topSolidBlock", BuiltInRegistries.BLOCK.getKey(st.getBlock()).toString()); r.addProperty("topSolidProps", st.toString()); break; } } if (top == Integer.MIN_VALUE) r.addProperty("topSolidY", "none"); } // viewCenter r.addProperty("fogStart", String.valueOf(mc.options.renderDistance().get())); emit(id, r); break; } case "viewport": { // ★ viewCenter 决定 ClientChunkCache 收哪些区块(超范围会被丢弃) int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt(); if (req.has("radius")) { level.getChunkSource().updateViewRadius(req.get("radius").getAsInt()); } level.getChunkSource().updateViewCenter(cx, cz); JsonObject r = new JsonObject(); r.addProperty("ok", true); r.addProperty("viewCenter", cx + "," + cz); emit(id, r); break; } case "render": { int frames = req.has("frames") ? req.get("frames").getAsInt() : 60; String out = req.has("out") ? req.get("out").getAsString() : null; if (req.has("width")) width = req.get("width").getAsInt(); if (req.has("height")) height = req.get("height").getAsInt(); render(id, frames, out); break; } default: emitError(id, "未知命令: " + cmd); } } static JsonObject applyChunk(int cx, int cz, byte[] data) { // ★ 官方路径:ClientChunkCache.replaceWithPacketData // 内部:new LevelChunk + chunk.replaceWithPacketData(buf, heightmaps, consumer) // 与网络包 handleLevelChunkWithLight 完全同一路径 FriendlyByteBuf buf = new FriendlyByteBuf(io.netty.buffer.Unpooled.wrappedBuffer(data)); var cache = level.getChunkSource(); // 显式匿名类而非 lambda:lambda 参数类型推断在此处会失败(实测) net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput beOut = new net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput() { @Override public void accept(BlockPos bp, net.minecraft.world.level.block.entity.BlockEntityType type, net.minecraft.nbt.CompoundTag tag) { } }; // ★ 参数是 Consumer(消费这个接口本身),不是该接口的方法签名。 // LevelChunk.replaceWithPacketData 内部会调 beOut.accept(...),这里直接空实现。 cache.replaceWithPacketData(cx, cz, buf, new net.minecraft.nbt.CompoundTag(), out -> {}); // 无光照数据 ⇒ 显式全亮,否则地形全黑 var light = cache.getLightEngine(); for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) { DataLayer sky = new DataLayer(); for (int x = 0; x < 16; x++) for (int y = 0; y < 16; y++) for (int z = 0; z < 16; z++) sky.set(x, y, z, 15); light.queueSectionData(LightLayer.SKY, net.minecraft.core.SectionPos.of(cx, sy, cz), sky); light.queueSectionData(LightLayer.BLOCK, net.minecraft.core.SectionPos.of(cx, sy, cz), sky); } light.setLightEnabled(new ChunkPos(cx, cz), true); int upd = light.runLightUpdates(); JsonObject r = new JsonObject(); r.addProperty("lightUpdates", upd); return r; } static void levelRendererAllChanged() { mc.levelRenderer.allChanged(); } /** tick + 渲染各帧(区块网格重建队列靠 tick 推进) */ static void tickFrames(int n) { MainTarget target = (MainTarget) mc.getMainRenderTarget(); for (int f = 0; f < n; f++) { try { mc.tick(); } catch (Throwable t) { if (f == 0) { System.err.println("[mcfold] tick 异常(第1帧): " + t); t.printStackTrace(System.err); } } RenderSystem.clear(16640, Minecraft.ON_OSX); target.bindWrite(true); // ★ 世代差异(1.21+):render(DeltaTracker, boolean) mc.gameRenderer.render(mc.getTimer(), true); target.unbindWrite(); target.blitToScreen(mc.getWindow().getWidth(), mc.getWindow().getHeight()); mc.getWindow().updateDisplay(); } } static void render(Integer id, int frames, String out) throws Exception { // ★ 1.21 实测:setLevel 会挂 ReceivingLevelScreen; // 若渲染前它有残留,画面就是加载屏(distinctColors 停在 ~199)。 mc.setScreen(null); mc.setOverlay(null); tickFrames(frames); MainTarget target = (MainTarget) mc.getMainRenderTarget(); int w = mc.getWindow().getWidth(), h = mc.getWindow().getHeight(); org.lwjgl.opengl.GL30.glBindFramebuffer(org.lwjgl.opengl.GL30.GL_FRAMEBUFFER, target.frameBufferId); GL11.glReadBuffer(org.lwjgl.opengl.GL30.GL_COLOR_ATTACHMENT0); var buf = org.lwjgl.BufferUtils.createByteBuffer(w * h * 4); GL11.glReadPixels(0, 0, w, h, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, buf); org.lwjgl.opengl.GL30.glBindFramebuffer(org.lwjgl.opengl.GL30.GL_FRAMEBUFFER, 0); BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); java.util.Set colors = new java.util.HashSet<>(); for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) { int i = ((h - 1 - y) * w + x) * 4; int rgb = ((buf.get(i) & 0xFF) << 16) | ((buf.get(i + 1) & 0xFF) << 8) | (buf.get(i + 2) & 0xFF); img.setRGB(x, y, rgb); colors.add(rgb); } ByteArrayOutputStream bos = new ByteArrayOutputStream(); ImageIO.write(img, "png", bos); byte[] png = bos.toByteArray(); if (out != null) ImageIO.write(img, "png", new File(out)); JsonObject r = new JsonObject(); r.addProperty("ok", true); r.addProperty("width", w); r.addProperty("height", h); r.addProperty("chunksRendered", mc.levelRenderer.countRenderedSections()); r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllSections()); r.addProperty("distinctColors", colors.size()); JsonArray cam = new JsonArray(); cam.add(mc.player.getX()); cam.add(mc.player.getY()); cam.add(mc.player.getZ()); r.add("camera", cam); if (out != null) r.addProperty("path", out); r.addProperty("image", Base64.getEncoder().encodeToString(png)); emit(id, r); } // ---------------- 启动 ---------------- static void boot(String gameDir, String assets, String assetIdx) throws Exception { SharedConstants.tryDetectVersion(); // ★ 世代差异(1.21+):enableDataFixerOptimizations() 已被移除(实测映射表无此方法) Bootstrap.bootStrap(); File runDir = new File(gameDir); runDir.mkdirs(); // ★ 世代差异(1.21+):User(String, UUID, String, Optional, Optional, Type) // 1.20.x 是 (String, String sessionId, String, ...) User user = new User("mcfold_cam", java.util.UUID.nameUUIDFromBytes("mcfold_cam".getBytes()), "mcfold_cam", Optional.empty(), Optional.empty(), User.Type.LEGACY); PropertyMap props = new PropertyMap(); GameConfig cfg = new GameConfig( new GameConfig.UserData(user, props, props, Proxy.NO_PROXY), new DisplayData(width, height, OptionalInt.empty(), OptionalInt.empty(), false), new GameConfig.FolderData(runDir, new File(runDir, "resourcepacks"), new File(assets), assetIdx), new GameConfig.GameData(false, "mcfold", "release", false, false), new GameConfig.QuickPlayData(null, null, null, null)); RenderSystem.initRenderThread(); RenderSystem.beginInitialization(); mc = new Minecraft(cfg); RenderSystem.finishInitialization(); System.err.println("[mcfold] Minecraft constructed"); reloadResources(mc); System.err.println("[mcfold] resources reloaded"); // ★★ 世代差异(1.21+):注册表经 CommonListenerCookie.receivedRegistries 传入, // 并在 ClientPacketListener 构造时赋给 **final** 字段 // (实测:1.20.x 是可变 LayeredRegistryAccess,可事后反射替换; // 1.21 是 final RegistryAccess$Frozen,反射会抛 // "Can not set final ... field")。 // ⇒ 本世代的正确顺序:**先加载注册表,再构造 cookie + listener**。 // 这比 1.20 的反射注入更干净。 var packs = net.minecraft.server.packs.repository.ServerPacksSource.createPackRepository( new File(runDir, "datapacks").toPath(), new net.minecraft.world.level.validation.DirectoryValidator(p -> false)); packs.reload(); ResourceManager dataRm = new net.minecraft.server.packs.resources.MultiPackResourceManager( net.minecraft.server.packs.PackType.SERVER_DATA, java.util.List.of(packs.getPack("vanilla").open())); var all = new java.util.ArrayList>(); all.addAll(RegistryDataLoader.WORLDGEN_REGISTRIES); all.addAll(RegistryDataLoader.DIMENSION_REGISTRIES); RegistryAccess.Frozen base = net.minecraft.client.multiplayer.ClientRegistryLayer .createRegistryAccess().compositeAccess(); RegistryAccess.Frozen loaded = RegistryDataLoader.load(dataRm, base, all); System.err.println("[mcfold] registries loaded: dimension_type=" + loaded.registryOrThrow(Registries.DIMENSION_TYPE).size()); Connection conn = new Connection(PacketFlow.CLIENTBOUND); GameProfile profile = new GameProfile(UUID.randomUUID(), "mcfold_cam"); WorldSessionTelemetryManager telemetry = new WorldSessionTelemetryManager( TelemetryEventSender.DISABLED, false, Duration.ZERO, ""); var cookie = new net.minecraft.client.multiplayer.CommonListenerCookie( profile, telemetry, loaded, // ← 注册表从这里进 net.minecraft.world.flag.FeatureFlags.DEFAULT_FLAGS, null, null, null, java.util.Map.of(), null, false, java.util.Map.of(), net.minecraft.server.ServerLinks.EMPTY); ClientPacketListener listener = new ClientPacketListener(mc, conn, cookie); System.err.println("[mcfold] listener ok, registryAccess dims=" + listener.registryAccess().registryOrThrow(Registries.DIMENSION_TYPE).size()); Holder dimHolder = loaded.registryOrThrow(Registries.DIMENSION_TYPE) .getHolderOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, ResourceLocation.parse("minecraft:overworld"))); var levelData = new ClientLevel.ClientLevelData(Difficulty.NORMAL, false, false); level = new ClientLevel(listener, levelData, Level.OVERWORLD, dimHolder, Math.max(8, mc.options.renderDistance().get()), 8, () -> InactiveProfiler.INSTANCE, mc.levelRenderer, false, 0L); // ★ 世代差异(1.21+):setLevel(ClientLevel, ReceivingLevelScreen$Reason) // 1.20.x 只有一个参数。这是本文件与 gen_1_20 的差异之一。 // 枚举值实测:NETHER_PORTAL / END_PORTAL / OTHER(没有 LEVEL_LOAD) // 我们不是经传送门进入世界 ⇒ OTHER mc.setLevel(level, net.minecraft.client.gui.screens.ReceivingLevelScreen.Reason.OTHER); var player = new net.minecraft.client.player.LocalPlayer(mc, level, listener, new net.minecraft.stats.StatsCounter(), new net.minecraft.client.ClientRecipeBook(), false, false); player.setPos(8.0, 70.0, 8.0); mc.player = player; mc.setCameraEntity(player); mc.gameMode = new net.minecraft.client.multiplayer.MultiPlayerGameMode(mc, listener); net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager()); // ★★ 世代差异(1.21+,编译期看不出): // GameRenderer.render(DeltaTracker,boolean) 里有门禁 // boolean ok = minecraft.isGameLoadFinished(); // if (ok && renderLevel && level != null) renderLevel(...); // 而 gameLoadFinished 只在 **reloadResourcePacks 的回调** // (onResourceLoadFinished)里被置 true。 // 我们不跑异步 reload(会死锁)⇒ 该位恒 false ⇒ 世界永不渲染 // (症状:画面停在 Mojang 加载屏,distinctColors≈199,chunksRendered=0)。 // 1.20.x 无此门禁,所以 gen_1_20 不受影响。 net.minecraft.client.renderer.McfoldBridge.markGameLoadFinished(mc); mc.setOverlay(null); mc.setScreen(null); mc.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK); System.err.println("[mcfold] boot done"); } /** * 把 blockstate 里的 model 引用规范化成 jar 里实际存在的路径。 * * 实测两类写法: * "ns:block/foo" → models/block/foo.json (createbigcannons 风格,正确) * "ns:foo" → models/foo.json(不存在!) (MTS 风格,实际在 models/block/foo.json) * 所以先原样查,再试补 "block/"。 */ static String normalizeModelPath(java.util.zip.ZipFile zf, String model) { int i = model.indexOf(':'); String ns = i > 0 ? model.substring(0, i) : "minecraft"; String path = i > 0 ? model.substring(i + 1) : model; if (zf.getEntry("assets/" + ns + "/models/" + path + ".json") != null) return model; if (zf.getEntry("assets/" + ns + "/models/block/" + path + ".json") != null) { return ns + ":block/" + path; } return model; // 都不存在 → 保持原样(会显示 missing,但至少不误导) } /** * 生成一个「blockstates 覆盖包」:把 mod 的变体键统一改写成 ""。 * * ★ 为什么需要(实测根因): * 我们的占位方块只有 defaultBlockState(变体键 ""), * 而 mod 的 blockstates 常用属性键(如 "normal"、"facing=north")⇒ 匹配不上 * ⇒ missing model ⇒ 画面上是紫黑棋盘格。 * 例:createbigcannons:steel_block 的键是 ""(渲染正常,测过), * mts:...fancydirt_block 的键是 "normal"(棋盘格)。 * * 做法:读每个 mod jar 的 assets//blockstates/*.json,取第一个 variant 的 model, * 改写成 {"variants":{"":{"model":"<那个 model>"}}},打进一个 zip。 * 该 zip 作为**最后选中**的资源包,优先级最高,从而覆盖原 blockstates。 * 模型/贴图仍由原 jar 提供(不动)。 */ static File buildBlockstateOverlay(java.util.List jars, java.nio.file.Path outDir) throws Exception { File overlay = new File(outDir.toFile(), "mcfold-blockstate-overlay.zip"); java.util.TreeMap entries = new java.util.TreeMap<>(); entries.put("pack.mcmeta", ("{\"pack\":{\"description\":\"mcfold blockstate overlay\",\"pack_format\":15}}") .getBytes(java.nio.charset.StandardCharsets.UTF_8)); int rewritten = 0; for (File jar : jars) { try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(jar)) { var en = zf.entries(); while (en.hasMoreElements()) { var e = en.nextElement(); String nm = e.getName(); if (!nm.startsWith("assets/") || !nm.contains("/blockstates/") || !nm.endsWith(".json")) continue; String text; try (var in = zf.getInputStream(e)) { text = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); } JsonObject root; try { root = JsonParser.parseString(text).getAsJsonObject(); } catch (Throwable t) { continue; } if (!root.has("variants") || !root.get("variants").isJsonObject()) continue; // 取第一个 variant 的第一个 model —— 只求外观,不求语义 String model = null; for (var ve : root.getAsJsonObject("variants").entrySet()) { JsonElement v = ve.getValue(); JsonElement one = v.isJsonArray() ? (v.getAsJsonArray().size() > 0 ? v.getAsJsonArray().get(0) : null) : v; if (one != null && one.isJsonObject() && one.getAsJsonObject().has("model")) { model = one.getAsJsonObject().get("model").getAsString(); break; } } if (model == null) continue; // ★ 规范化 model 路径(实测根因): // MTS 写 "mts:mtsofficialpack.fancydirt_block"(缺 block/), // 而文件在 models/block/... ⇒ MC 找不到 ⇒ missing model ⇒ 棋盘格。 // createbigcannons 写 "createbigcannons:block/steel_block"(有 block/),正常。 // 策略:先按原样看 jar 里有没有;没有就试补 block/ 前缀。 model = normalizeModelPath(zf, model); String out = "{\"variants\":{\"\":{\"model\":\"" + model + "\"}}}"; entries.put(nm, out.getBytes(java.nio.charset.StandardCharsets.UTF_8)); rewritten++; } } } if (rewritten == 0) return null; try (var zos = new java.util.zip.ZipOutputStream( new java.io.FileOutputStream(overlay))) { for (var kv : entries.entrySet()) { zos.putNextEntry(new java.util.zip.ZipEntry(kv.getKey())); zos.write(kv.getValue()); zos.closeEntry(); } } System.err.println("[mcfold] overlay: 重写 " + rewritten + " 个 blockstates → " + overlay); return overlay; } /** * 加载 mod:扫 jar → 注册占位方块 → 挂资源包(外观)。 * * ★ 全部在 Java 侧完成(用户 2026-09-25 定向):Java 自己读 jar、自己注册、 * 自己挂资源包。Python 只传目录路径。 * * 原理(见 scaffold/MOD-BLOCKS.md):方块**渲染**只需要 * (a) 注册表里有这个名字(拿 state id) * (b) 资源包里有它的 blockstates/models/textures * 与方块行为无关 ⇒ 零 mod 加载器也能正确显示。 */ static JsonObject loadMods(String dir) { JsonObject out = new JsonObject(); File d = new File(dir); File[] jars = d.listFiles((f, n) -> n.endsWith(".jar")); if (jars == null || jars.length == 0) { out.addProperty("ok", true); out.addProperty("mods", 0); out.addProperty("blocksRegistered", 0); return out; } // ── 1. 解冻 BLOCK 注册表(frozen=false + 恢复 intrusiveHolders)── // 两个字段都必须处理:只清 frozen 会抛 "can't create intrusive holders" Object blockReg = BuiltInRegistries.BLOCK; try { for (Class c = blockReg.getClass(); c != null; c = c.getSuperclass()) { try { var f = c.getDeclaredField("frozen"); f.setAccessible(true); f.setBoolean(blockReg, false); } catch (NoSuchFieldException ignore) { } try { var f = c.getDeclaredField("unregisteredIntrusiveHolders"); f.setAccessible(true); if (f.get(blockReg) == null) f.set(blockReg, new java.util.IdentityHashMap<>()); } catch (NoSuchFieldException ignore) { } } } catch (Throwable t) { System.err.println("[mcfold] 解冻失败: " + t); } int mods = 0, blocksAdded = 0, skipped = 0; JsonArray detail = new JsonArray(); for (File jar : jars) { JsonObject m = new JsonObject(); m.addProperty("jar", jar.getName()); int n = 0, skip = 0; try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(jar)) { var en = zf.entries(); java.util.Set seen = new java.util.LinkedHashSet<>(); while (en.hasMoreElements()) { String name = en.nextElement().getName(); // 方块名来源:assets//blockstates/.json(权威,见 P5 实测) java.util.regex.Matcher mm = java.util.regex.Pattern .compile("^assets/([^/]+)/blockstates/(.+)\\.json$").matcher(name); if (!mm.matches()) continue; String ns = mm.group(1), blk = mm.group(2); if (!seen.add(ns + ":" + blk)) continue; // ★ 世代差异(1.21+):ResourceLocation 构造变 private → 静态工厂 ResourceLocation rl = ResourceLocation.fromNamespaceAndPath(ns, blk); try { if (BuiltInRegistries.BLOCK.containsKey(rl)) { skip++; continue; } // ★ 世代差异(1.21+):Properties.copy(Block) 已移除,只有 of() Block b = new Block(BlockBehaviour.Properties.of()); Registry.register(BuiltInRegistries.BLOCK, rl, b); // ★ BlockState 不会自动进 BLOCK_STATE_REGISTRY(实测 state id 会是 0) Block.BLOCK_STATE_REGISTRY.add(b.defaultBlockState()); n++; } catch (Throwable t) { skip++; } } mods++; } catch (Throwable t) { m.addProperty("error", String.valueOf(t)); } m.addProperty("blocksRegistered", n); m.addProperty("skipped", skip); blocksAdded += n; skipped += skip; detail.add(m); } // ── 2. 挂资源包(外观:blockstates/models/textures)── // 把 mod jar 作为资源包加入并选中,然后重载资源管理器。 int packsAdded = 0; try { var repo = mc.getResourcePackRepository(); java.nio.file.Path rpDir = mc.getResourcePackDirectory(); for (File jar : jars) { // ★ 必须是 .zip 后缀:FolderRepositorySource 只认 // isDirectory+pack.mcmeta 或 **.zip**(实测它不看 .jar) String base = jar.getName().replaceAll("\\.jar$", ""); File dst = new File(rpDir.toFile(), "mcfold-" + base + ".zip"); if (!dst.isFile()) java.nio.file.Files.copy(jar.toPath(), dst.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); } // ★ 生成 blockstates 覆盖包并放进资源包目录 // (把 mod 的属性变体键改写成 "" —— 占位方块只有 defaultBlockState) java.util.List jarList = new java.util.ArrayList<>(); for (File j : jars) jarList.add(j); File overlay = buildBlockstateOverlay(jarList, rpDir); if (overlay != null) { File od = new File(rpDir.toFile(), "zzz-mcfold-blockstate-overlay.zip"); java.nio.file.Files.copy(overlay.toPath(), od.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); } repo.reload(); // 诊断(放进响应,stderr 会被后续日志冲掉) out.addProperty("packDir", rpDir.toString()); JsonArray avail = new JsonArray(); for (String ai : repo.getAvailableIds()) avail.add(ai); out.add("availableIds", avail); JsonArray compat = new JsonArray(); for (var pk2 : repo.getAvailablePacks()) { JsonObject po = new JsonObject(); po.addProperty("id", pk2.getId()); po.addProperty("compat", String.valueOf(pk2.getCompatibility())); compat.add(po); } out.add("availablePacks", compat); var sel = new java.util.LinkedHashSet<>(repo.getSelectedIds()); // 先加 mod 包(提供 models/textures),再加 overlay(覆盖 blockstates) java.util.List overlayIds = new java.util.ArrayList<>(); for (String packId : repo.getAvailableIds()) { if (packId.contains("zzz-mcfold-blockstate-overlay")) { overlayIds.add(packId); continue; } if (packId.contains("mcfold-")) { sel.add(packId); packsAdded++; } } sel.addAll(overlayIds); // 最后 = 最高优先级 JsonArray selArr = new JsonArray(); for (String si : sel) selArr.add(si); out.add("selectedIds", selArr); repo.setSelected(sel); // 用新 pack 列表重建 resource manager(照官方 createReload 内部做法), // 再手工驱动 listener(不能 join Minecraft.reloadResourcePacks,会死锁) java.util.List packs = new java.util.ArrayList<>(); for (var pk : repo.getSelectedPacks()) packs.add(pk.open()); var rmF = Minecraft.class.getDeclaredField("resourceManager"); rmF.setAccessible(true); var rm = (net.minecraft.server.packs.resources.ReloadableResourceManager) rmF.get(mc); var resF = net.minecraft.server.packs.resources.ReloadableResourceManager.class .getDeclaredField("resources"); resF.setAccessible(true); resF.set(rm, new net.minecraft.server.packs.resources.MultiPackResourceManager( net.minecraft.server.packs.PackType.CLIENT_RESOURCES, packs)); reloadResources(mc); net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager()); mc.levelRenderer.allChanged(); // ★ 自验证:重载后 mod 资源是否真的可读(TOO_OLD 是否阻止加载) ResourceManager rm2 = mc.getResourceManager(); System.err.println("[mcfold] resourceManager=" + rm2.getClass().getSimpleName() + " namespaces=" + rm2.getNamespaces()); JsonArray check = new JsonArray(); for (File jar : jars) { try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(jar)) { var en2 = zf.entries(); while (en2.hasMoreElements()) { String nm = en2.nextElement().getName(); java.util.regex.Matcher m2 = java.util.regex.Pattern .compile("^assets/([^/]+)/(blockstates/.+\\.json)$").matcher(nm); if (!m2.matches()) continue; // ★ 世代差异(1.21+):构造 private → 静态工厂 ResourceLocation rid = ResourceLocation.fromNamespaceAndPath( m2.group(1), m2.group(2)); JsonObject co = new JsonObject(); co.addProperty("id", rid.toString()); var got2 = rm2.getResource(rid); co.addProperty("readable", got2.isPresent()); if (got2.isPresent()) { try (var in2 = got2.get().open()) { co.addProperty("bytes", in2.readAllBytes().length); } } check.add(co); break; // 每个 jar 只查一个样本 } } catch (Throwable ignore) { } } out.add("resourceCheck", check); } catch (Throwable t) { out.addProperty("packError", String.valueOf(t)); System.err.println("[mcfold] 资源包挂载失败: " + t); } out.addProperty("ok", true); out.addProperty("mods", mods); out.addProperty("blocksRegistered", blocksAdded); out.addProperty("skipped", skipped); out.addProperty("packsAdded", packsAdded); out.add("detail", detail); return out; } /** 同步驱动各 reload listener(不能 join Minecraft 的 reloadResourcePacks:等主循环 → 死锁)*/ static void reloadResources(Minecraft mc) throws Exception { var rmF = Minecraft.class.getDeclaredField("resourceManager"); rmF.setAccessible(true); var rm = (net.minecraft.server.packs.resources.ReloadableResourceManager) rmF.get(mc); mc.getResourcePackRepository().reload(); var barrier = new net.minecraft.server.packs.resources.PreparableReloadListener.PreparationBarrier() { @Override public java.util.concurrent.CompletableFuture wait(T t) { return java.util.concurrent.CompletableFuture.completedFuture(t); } }; var prof = InactiveProfiler.INSTANCE; java.util.concurrent.Executor sync = Runnable::run; for (var listener : reloadListeners(rm)) { String name = listener.getName(); try { var fut = listener.reload(barrier, rm, prof, prof, sync, sync); long deadline = System.currentTimeMillis() + 60_000; while (!fut.isDone() && System.currentTimeMillis() < deadline) Thread.sleep(10); if (fut.isDone()) fut.join(); else System.err.println("[mcfold] TIMEOUT " + name); } catch (Throwable e) { System.err.println("[mcfold] FAILED " + name + ": " + e); } } } @SuppressWarnings("unchecked") static java.util.List reloadListeners(Object rm) throws Exception { for (var f : rm.getClass().getDeclaredFields()) { if (java.util.List.class.isAssignableFrom(f.getType())) { f.setAccessible(true); Object v = f.get(rm); if (v instanceof java.util.List l && !l.isEmpty() && l.get(0) instanceof net.minecraft.server.packs.resources.PreparableReloadListener) { return (java.util.List) l; } } } throw new IllegalStateException("reload listeners not found"); } // ---------------- 工具 ---------------- static byte[] decode(String b64) { return Base64.getDecoder().decode(b64); } static String arg(String[] a, String key, String def) { for (int i = 0; i < a.length - 1; i++) if (a[i].equals(key)) return a[i + 1]; return def; } static synchronized void emit(Integer id, JsonObject obj) { if (id != null) obj.addProperty("id", id); OUT.println(GSON.toJson(obj)); OUT.flush(); } static void emitError(Integer id, String msg) { JsonObject o = new JsonObject(); o.addProperty("ok", false); o.addProperty("error", msg); emit(id, o); } static void event(String name, JsonObject payload) { JsonObject o = new JsonObject(); o.addProperty("event", name); if (payload != null) o.add("payload", payload); OUT.println(GSON.toJson(o)); OUT.flush(); } }