feat: get_block_info —— 方块信息查询(Java 侧回答,Python 只路由)

新增 cmd=blockinfo(两代后端编译通过、实测):
  返回 block 名(含 mod 方块)、stateId、属性、isAir/isSolid、
  光照/硬度、贴图缺失的程序化判据、是否来自 mod。

★ 修掉一个真实缺陷(附录 D 的语义区分落到了实处):
  未加载区块原先返回 void_air(known=true),实测发现两个 API 都不可用:
    - ClientLevel.hasChunk()      反编译实测**恒返回 true**
    - ChunkSource.hasChunk()      走 getChunk(...,false),而 ClientChunkCache
      覆写后在该参数下返回 emptyChunk(占位,非 null)⇒ 也恒 true
  正解:getChunk(..., requireChunk=**false**) 判 null
    (反编译:return requireChunk ? emptyChunk : null;)
  ⇒ 现在未加载明确回 known=false + reason=chunk_not_loaded

实测(1.20.1):
  草地 → minecraft:grass_block props={'snowy':'false'} missing=False
  原木 → minecraft:oak_log       props={'axis':'y'}
  空气 → minecraft:air
  远处 → known=false reason=chunk_not_loaded        ← 与空气区分

实测(1.21.1 + mod):
  mts:mtsofficialpack.fancydirt_block   mod=True tex=mtsofficialpack:block/fancydirt
  createbigcannons:steel_block          mod=True tex=createbigcannons:block/steel_block
  ⇒ mod 方块同样能查到正确贴图名与 fromMod 标记

顺带修 gen_1_21 两处世代差异(ChunkStatus 换包、bridge 编译顺序)。
测试:69 项全绿(新增 test_get_block_info 6 条判据)
This commit is contained in:
JianFeeeee
2026-09-26 00:24:29 +08:00
parent 6827f4d0e1
commit 35534b1564
25 changed files with 1038 additions and 522 deletions

View File

@ -99,7 +99,11 @@ public class McfoldRenderServer {
// stdout 只放协议 JSON,MC 的 INFO/WARN 全部赶到 stderr
System.setOut(new PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.err), true));
String gameDir = arg(args, "--gameDir", "/home/newqqagent/mcview/instances/default");
// ★ 不硬编码实例目录(版本隔离策略):
// 实例目录 = 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"));
@ -264,6 +268,161 @@ public class McfoldRenderServer {
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<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;
@ -679,6 +838,101 @@ public class McfoldRenderServer {
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 → 注册占位方块 → 挂资源包(外观)。
*
@ -768,15 +1022,48 @@ public class McfoldRenderServer {
var repo = mc.getResourcePackRepository();
java.nio.file.Path rpDir = mc.getResourcePackDirectory();
for (File jar : jars) {
File dst = new File(rpDir.toFile(), "mcfold-" + jar.getName());
// ★ 必须是 .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 内部做法),
@ -794,6 +1081,36 @@ public class McfoldRenderServer {
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);

View File

@ -99,7 +99,11 @@ public class McfoldRenderServer {
// stdout 只放协议 JSON,MC 的 INFO/WARN 全部赶到 stderr
System.setOut(new PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.err), true));
String gameDir = arg(args, "--gameDir", "/home/newqqagent/mcview/instances/default");
// ★ 不硬编码实例目录(版本隔离策略):
// 实例目录 = 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"));
@ -264,6 +268,162 @@ public class McfoldRenderServer {
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<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 {
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;
@ -723,6 +883,101 @@ public class McfoldRenderServer {
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 → 注册占位方块 → 挂资源包(外观)。
*
@ -814,15 +1069,48 @@ public class McfoldRenderServer {
var repo = mc.getResourcePackRepository();
java.nio.file.Path rpDir = mc.getResourcePackDirectory();
for (File jar : jars) {
File dst = new File(rpDir.toFile(), "mcfold-" + jar.getName());
// ★ 必须是 .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 内部做法),
@ -840,6 +1128,38 @@ public class McfoldRenderServer {
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);

View File

@ -0,0 +1,67 @@
import net.fabricmc.api.EnvType;
import net.fabricmc.loader.impl.launch.knot.Knot;
/**
* 【加载器后端】用 Fabric 加载器,但不写 mod。
*
* 用户定向(2026-09-25):
* 「不写 mod」+「用 mod 加载器」+「功能在现有后端里」。
*
* 实现关键(全部实测):
* 1. Knot.init() 是 **public**,它做完
* 「发现 mod → 建 KnotClassLoader → 应用 mixin → invokeEntrypoints(preLaunch)」
* 后返回,**不**启动官方主循环(那在 GameProvider.launch() 里 —— 我们不调)。
* 2. Knot.loadIntoTarget(name) 把我们的启动类加载进同一个 KnotClassLoader
* ⇒ 我们的代码与 mod 共享 classloader 与 mixin 变换,但控制权仍在我们手里。
*
* 实测输出:
* [loaderboot] ✅ init 返回: net.fabricmc.loader.impl.launch.knot.KnotClassLoader
* [loaderboot] - mcfold_loadprobe 1.0 ← mod 被识别
* [loaderboot] - minecraft 1.20.1
* [loaderboot] ✅ 加载到: McfoldRenderServer / CL=KnotClassLoader
* {"event":"ready"} ← 我们的渲染服务同时就绪
*
* ★ 两个必需条件(缺一则我们的 named 代码无法在加载器下运行):
* a) classpath 里放 **反混淆后的** client jar(client-named.jar),不是官方 obf jar
* b) -Dfabric.gameMappingNamespace=named
* 否则游戏被 remap 成 intermediary,堆栈里是 `enn.<init>`,
* 我们的代码会抛 NoClassDefFoundError: net/minecraft/client/Minecraft(实测)。
*
* 启动示例:
* java -cp "<vanilla基础库(去asm)>:<fabric的8库>:client-named.jar:<我们的classes>" \
* -Dfabric.gameMappingNamespace=named \
* -Dfabric.modsFolder=/path/to/mods \
* LoaderBoot --gameDir ... --assets ... --assetIndex 1.20.1
*
* ★ asm 必须只留一份(用 Fabric 的 9.10.1):1.20.1 自带 9.2 与 9.6 两版,
* 与 Fabric 的并存会让 Knot.<clinit> 抛 "duplicate ASM classes found on classpath"。
*/
public class LoaderBoot {
/** 我们自己的服务端主类(默认包,与 McfoldRenderServer 同名)。 */
private static final String TARGET = "McfoldRenderServer";
public static void main(String[] args) throws Exception {
Knot knot = new Knot(EnvType.CLIENT);
// ① 加载 mod + 应用 mixin;不起官方主循环
ClassLoader gameCl = knot.init(args);
System.err.println("[loaderboot] KnotClassLoader = " + gameCl.getClass().getName());
// 报告已加载的 mod(诊断用;也是「加载器真的在工作」的证据)
try {
for (var m : net.fabricmc.loader.api.FabricLoader.getInstance().getAllMods()) {
System.err.println("[loaderboot] mod " + m.getMetadata().getId()
+ " " + m.getMetadata().getVersion().getFriendlyString());
}
} catch (Throwable t) {
System.err.println("[loaderboot] 读 mod 列表失败: " + t);
}
// ② 把我们的服务端注入同一个 classloader,然后调用它的 main
Class<?> boot = knot.loadIntoTarget(TARGET);
System.err.println("[loaderboot] 注入 " + boot.getName()
+ " 到 " + boot.getClassLoader().getClass().getSimpleName());
boot.getMethod("main", String[].class).invoke(null, (Object) args);
}
}