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:
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
67
scaffold/java/launcher_fabric/LoaderBoot.java
Normal file
67
scaffold/java/launcher_fabric/LoaderBoot.java
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user