Files
agentmc/scaffold/java/gen_1_20/McfoldRenderServer.java
JianFeeeee 547f4beb7e M1+M2:1.21.1 构建跑通 + 协议客户端进 play 阶段
M1(构建):
- build.sh 编译 CP 加入 $OUT/fabric/*.jar,解决 net.fabricmc.* 找不到
- gen_1_21 内联 Sodium 通知改为委托共用 RenderCompat,补 reportRenderCompat
- 1.21.1 全量七步构建产物齐备(classes-intermediary.jar / names*.json / srg / inheritance.map)

M2(协议):
- login_start 按协议数据发 UUID(1.21 必填;1.20 可选)
- login_acknowledged / serverbound 包 id 改为查表,不再写死 0x00
- send() 实现压缩帧格式(varint(dataLen)+数据,小包不压)
- configuration 双向协商:发 settings、回 select_known_packs、拒 resource_pack
- protodef 补 bitflags / entityMetadataLoop / count(ctx引用) / compareTo 路径表达式
- NBT 读取器由占位符改为真实现(anonymousNbt 支撑 registry_data)
- 坏包容错:长度前缀是帧层权威,解不开整包丢弃并布告,不断流

实测:login→configuration(11 注册表)→play,位置 (5.5,-60.0,-0.5),map_chunk×9
判据:protodef 6→13 项(含线上真实 entity_metadata 字节回归);全量 77 项通过
2026-09-26 12:15:48 +08:00

1338 lines
69 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.Blocks;
import net.minecraft.core.Registry;
import net.minecraft.world.level.block.state.BlockBehaviour;
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;
import java.lang.reflect.Method;
import mcfold.McfoldNames;
import mcfold.RenderCompat;
/**
* mcfold 渲染服务:常驻进程,NDJSON(行分隔 JSON) 协议,由 Python 侧驱动。
*
* 设计约束(用户 2026-09-25):
* - 不起完整客户端:不跑主循环、不连服、不登录、不作玩家
* - 我们的世界数据喂给官方渲染器出图
*
* 协议(stdin/stdout 各一行一个 JSON):
* → {"id":1,"cmd":"chunk","cx":0,"cz":0,"data":"<b64>","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":"<b64 png>"}
*
* 启动就绪时主动发 {"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();
// ★★★ 补一步官方广播:ClientLevel.onChunkLoaded(pos)
//
// 背景(2026-09-26 实测):装了 Sodium(或任何靠「区块加载
// 事件」得知区块存在的渲染替换件)时,区块**画不出来**:
// 画面只有天空、chunks=0、它的重建队列恒空,
// 而 blockinfo 能正确读出方块 ⇒ 数据在,只是它不知道。
//
// 根因:Sodium 的 mixin 挂在
// replaceWithPacketData 内部 @At(INVOKE onChunkLoaded, AFTER)
// 而 1.20.1 的 replaceWithPacketData **内部并不调** onChunkLoaded
//(它在 getChunk / drop 里各有一处,是别的路径)⇒ 注入点永不命中。
//
// 为什么这一步是**官方调用**而不是给某个 mod 打补丁:
// onChunkLoaded 是「区块已就绪」的公开广播点
// (染色缓存 + 实体存储启动),
// 我们绕过网络包直接塞数据 ⇒ 补上这一步才与官方路径等价。
// 幂等:官方实现只做广播;Sodium 的 onChunkStatusAdded
// 先读已有 status,相同即 return(实测字节码 :13/:31)
// ⇒ 重复调安全。
announceChunkLoaded(cx, cz);
// ★ 必须触发网格重建 —— 否则渲染器不知道该 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 = McfoldNames.field(Minecraft.class,
"net/minecraft/client/Minecraft/modelManager");
mmF.setAccessible(true);
Object mm = mmF.get(mc);
missing = McfoldNames.method(mm.getClass(),
"net/minecraft/client/resources/model/ModelManager/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 = McfoldNames.method(baked.getClass(),
"net/minecraft/client/resources/model/BakedModel/getParticleIcon",
com.mojang.serialization.Codec.class).invoke(baked,
(Object) 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<RL,Sprite>
// (早先用 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 {
// ★ 世代差异:1.20 无 parse() 静态工厂(1.21+ 才有),用构造器
var sprite = fn.apply(new ResourceLocation(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()
.getChunk(pcx, pcz, net.minecraft.world.level.chunk.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();
}
// ── HUD(截图场景下应关掉)──
// ★ 用**官方选项**而非改渲染代码:
// GameRenderer.render 里 `if (!options.hideGui) gui.render(...)`
// (字节码 :559 getfield hideGui / :562 ifeq)
// ⇒ 这就是官方 F1 的同一条路径,改一个 bool 即可。
// 为什么要关:截图只关心画面,HUD 是噪声,而且它会给
// distinctColors 掺一大块固定像素(实测污染判据)。
if (req.has("hideGui")) {
mc.options.hideGui = req.get("hideGui").getAsBoolean();
}
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, new ResourceLocation(eff));
}
}
// 回包带上真实生效值,便于验证
JsonObject r = new JsonObject();
r.addProperty("ok", true);
JsonObject cur = new JsonObject();
try {
java.lang.reflect.Field pf = McfoldNames.field(
net.minecraft.client.renderer.GameRenderer.class,
"net/minecraft/client/renderer/GameRenderer/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.countRenderedChunks());
r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllChunks());
var got = cc.getChunk(pcx, pcz, net.minecraft.world.level.chunk.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()));
reportRenderCompat(r);
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<BlockEntityTagOutput>(消费这个接口本身),不是该接口的方法签名。
// 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;
}
/**
* 广播「区块已就绪」(等价于网络包路径里会发生的那一步)。
*
* ★ 为什么单独一个方法:两个世代后端都要用,且它是**加载器共存的关键** ——
* 渲染替换件(Sodium 等)靠这个事件发现区块,缺了它们就永远看不见地形。
* 实测症状:画面只有天空 + chunks=0 + 它的重建队列恒空,
* 而 blockinfo 能读出方块(数据在,只是没人通知)。
*/
static void announceChunkLoaded(int cx, int cz) {
// ① 官方广播:对 vanilla 路径无害(只做染色缓存 + 实体存储启动),
// 但让**依赖该事件的 mod** 能正常感知区块。
try {
level.onChunkLoaded(new ChunkPos(cx, cz));
} catch (Throwable t) {
System.err.println("[mcfold] onChunkLoaded(" + cx + "," + cz + ") 失败: " + t);
}
// ② 渲染替换件的 tracker(实测:Sodium 靠这个才知道区块存在)
RenderCompat.notifyChunkLoaded(level, cx, cz);
}
/** 诊断:地形渲染替换件(Sodium 等)的真实状态,写进 debug 响应。
*
* ★ 为什么需要它:vanilla 的 countRenderedChunks() 在 Sodium 下**恒为 0**
* (它数的是 vanilla 的网格,替换件不走那条路)⇒ 光看这个数会把
* 「换了渲染器」误判成「没有数据」。而 blockinfo 能读方块 ⇒ 数据是在的。
* ⇒ 排障必须问**渲染器自己**。
*/
static void reportRenderCompat(JsonObject r) {
if (!RenderCompat.available()) {
r.addProperty("renderCompat", "absent");
return;
}
r.addProperty("renderCompat", "present");
long[] snap = RenderCompat.trackerSnapshot(level);
if (snap != null) {
r.addProperty("rc_chunkStatus", snap[0]);
r.addProperty("rc_chunkReady", snap[1]);
r.addProperty("rc_loadQueue", snap[2]);
r.addProperty("rc_unloadQueue", snap[3]);
}
Integer vis = RenderCompat.visibleChunks(level);
if (vis != null) r.addProperty("rc_visibleChunks", vis);
Boolean done = RenderCompat.terrainComplete(level);
if (done != null) r.addProperty("rc_terrainComplete", done);
String dbg = RenderCompat.chunksDebugString(level);
if (dbg != null) r.addProperty("rc_chunksDebug", dbg);
int[] q = RenderCompat.rebuildQueue(level);
if (q != null) {
JsonObject qo = new JsonObject();
qo.addProperty("INITIAL_BUILD", q[0]);
qo.addProperty("REBUILD", q[1]);
qo.addProperty("IMPORTANT_REBUILD", q[2]);
r.add("rc_rebuildQueue", qo);
}
r.addProperty("rc_deltaChunkReady", SODIUM_DELTA[0]);
r.addProperty("rc_deltaLoadQueue", SODIUM_DELTA[1]);
}
static void levelRendererAllChanged() {
mc.levelRenderer.allChanged();
}
/** 本次渲染期间 [chunkReady 增量, loadQueue 增量]。 */
static final long[] SODIUM_DELTA = new long[2];
/** 探针:Sodium 的 setupTerrain/processChunkEvents 到底有没有被调(计数)。 */
static final java.util.concurrent.atomic.AtomicLong SODIUM_SETUP_TERRAIN =
new java.util.concurrent.atomic.AtomicLong();
static final java.util.concurrent.atomic.AtomicLong SODIUM_PROCESS_EVENTS =
new java.util.concurrent.atomic.AtomicLong();
/**
* 探针:Sodium 的 setupTerrain 到底有没有被它的 hook 调到。
*
* ★ 为什么需要它(实测困境):
* Sodium 的 mixin 挂 LevelRenderer.setupRender(private),
* 而 setupRender 只在 renderLevel 内部被调(字节码 :484),
* 我们**确实**调了 renderLevel —— 按理 setupTerrain 该被调到,
* 但 chunkReady 恒 0。
* ⇒ 缺一个「它被调了几次」这个数,否则无法区分:
* (a) hook 没走到 (b) 走到了但参数无效 (c) 走到了但内部失败
*
* 做法:在调用**之前**记录现状(已调次数),渲染后由 debug 报差值。
* 只观测,不驱动 —— 驱动它要构造 Frustum,属于接管渲染循环。
*/
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);
mc.gameRenderer.render(0.0F, (long) f * 16_000_000L, 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 {
// ★ 观测:渲染**前**的 tracker 快照(用来判断 processChunkEvents 有没有被调)
long[] before = RenderCompat.trackerSnapshot(level);
tickFrames(frames);
long[] after = RenderCompat.trackerSnapshot(level);
if (after != null && before != null) {
// delta 放进静态字段,供 debug 读取
SODIUM_DELTA[0] = after[0] - before[0];
SODIUM_DELTA[1] = after[1] - before[1];
}
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<Integer> 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.countRenderedChunks());
r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllChunks());
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();
SharedConstants.enableDataFixerOptimizations();
Bootstrap.bootStrap();
File runDir = new File(gameDir);
runDir.mkdirs();
User user = new User("mcfold_cam", "0".repeat(32), "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");
Connection conn = new Connection(PacketFlow.CLIENTBOUND);
GameProfile profile = new GameProfile(UUID.randomUUID(), "mcfold_cam");
WorldSessionTelemetryManager telemetry = new WorldSessionTelemetryManager(
TelemetryEventSender.DISABLED, false, Duration.ZERO, "");
ClientPacketListener listener = new ClientPacketListener(mc, null, conn, null, profile, telemetry);
var packs = net.minecraft.server.packs.repository.ServerPacksSource.createPackRepository(
new File(runDir, "datapacks").toPath());
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<RegistryDataLoader.RegistryData<?>>();
all.addAll(RegistryDataLoader.WORLDGEN_REGISTRIES);
all.addAll(RegistryDataLoader.DIMENSION_REGISTRIES);
RegistryAccess.Frozen loaded = RegistryDataLoader.load(dataRm, listener.registryAccess(), all);
var layered = net.minecraft.client.multiplayer.ClientRegistryLayer.createRegistryAccess()
.replaceFrom(net.minecraft.client.multiplayer.ClientRegistryLayer.REMOTE,
new RegistryAccess.Frozen[]{loaded});
var rf = McfoldNames.field(ClientPacketListener.class,
"net/minecraft/client/multiplayer/ClientPacketListener/registryAccess");
rf.setAccessible(true);
rf.set(listener, layered);
Holder<DimensionType> dimHolder = loaded.registryOrThrow(Registries.DIMENSION_TYPE)
.getHolderOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, new ResourceLocation("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);
mc.setLevel(level);
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());
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/<ns>/blockstates/*.json,取第一个 variant 的 model,
* 改写成 {"variants":{"":{"model":"<那个 model>"}}},打进一个 zip。
* 该 zip 作为**最后选中**的资源包,优先级最高,从而覆盖原 blockstates。
* 模型/贴图仍由原 jar 提供(不动)。
*/
static File buildBlockstateOverlay(java.util.List<File> jars, java.nio.file.Path outDir) throws Exception {
File overlay = new File(outDir.toFile(), "mcfold-blockstate-overlay.zip");
java.util.TreeMap<String, byte[]> 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 = McfoldNames.field(c, "net/minecraft/core/MappedRegistry/frozen");
f.setAccessible(true);
f.setBoolean(blockReg, false);
} catch (NoSuchFieldException ignore) { }
try {
var f = McfoldNames.field(c,
"net/minecraft/core/MappedRegistry/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<String> seen = new java.util.LinkedHashSet<>();
while (en.hasMoreElements()) {
String name = en.nextElement().getName();
// 方块名来源:assets/<ns>/blockstates/<name>.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;
ResourceLocation rl = new ResourceLocation(ns, blk);
try {
if (BuiltInRegistries.BLOCK.containsKey(rl)) { skip++; continue; }
Block b = new Block(BlockBehaviour.Properties.copy(Blocks.STONE));
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<File> 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<String> 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<net.minecraft.server.packs.PackResources> packs = new java.util.ArrayList<>();
for (var pk : repo.getSelectedPacks()) packs.add(pk.open());
var rmF = McfoldNames.field(Minecraft.class,
"net/minecraft/client/Minecraft/resourceManager");
rmF.setAccessible(true);
var rm = (net.minecraft.server.packs.resources.ReloadableResourceManager) rmF.get(mc);
var resF = McfoldNames.field(
net.minecraft.server.packs.resources.ReloadableResourceManager.class,
"net/minecraft/server/packs/resources/ReloadableResourceManager/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;
ResourceLocation rid = new ResourceLocation(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 = McfoldNames.field(Minecraft.class,
"net/minecraft/client/Minecraft/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 <T> java.util.concurrent.CompletableFuture<T> 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<net.minecraft.server.packs.resources.PreparableReloadListener> 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<net.minecraft.server.packs.resources.PreparableReloadListener>) 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();
}
}