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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
logs/2026-09-26-1.log.gz Normal file

Binary file not shown.

BIN
logs/2026-09-26-2.log.gz Normal file

Binary file not shown.

BIN
logs/2026-09-26-3.log.gz Normal file

Binary file not shown.

BIN
logs/2026-09-26-4.log.gz Normal file

Binary file not shown.

View File

@ -1,6 +1,6 @@
[19:20:23] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 129 milliseconds [00:23:03] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 79 milliseconds
[19:20:24] [main/INFO]: Environment: authHost='https://authserver.mojang.com', accountsHost='https://api.mojang.com', sessionHost='https://sessionserver.mojang.com', servicesHost='https://api.minecraftservices.com', name='PROD' [00:23:04] [main/INFO]: Environment: authHost='https://authserver.mojang.com', accountsHost='https://api.mojang.com', sessionHost='https://sessionserver.mojang.com', servicesHost='https://api.minecraftservices.com', name='PROD'
[19:20:25] [main/ERROR]: Failed to verify authentication [00:23:05] [main/ERROR]: Failed to verify authentication
com.mojang.authlib.exceptions.InvalidCredentialsException: Status: 401 com.mojang.authlib.exceptions.InvalidCredentialsException: Status: 401
at com.mojang.authlib.exceptions.MinecraftClientHttpException.toAuthenticationException(MinecraftClientHttpException.java:56) ~[authlib-4.0.43.jar:?] at com.mojang.authlib.exceptions.MinecraftClientHttpException.toAuthenticationException(MinecraftClientHttpException.java:56) ~[authlib-4.0.43.jar:?]
at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.fetchProperties(YggdrasilUserApiService.java:156) ~[authlib-4.0.43.jar:?] at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.fetchProperties(YggdrasilUserApiService.java:156) ~[authlib-4.0.43.jar:?]
@ -8,24 +8,24 @@ com.mojang.authlib.exceptions.InvalidCredentialsException: Status: 401
at com.mojang.authlib.yggdrasil.YggdrasilAuthenticationService.createUserApiService(YggdrasilAuthenticationService.java:124) ~[authlib-4.0.43.jar:?] at com.mojang.authlib.yggdrasil.YggdrasilAuthenticationService.createUserApiService(YggdrasilAuthenticationService.java:124) ~[authlib-4.0.43.jar:?]
at net.minecraft.client.Minecraft.createUserApiService(SourceFile:733) ~[client-named.jar:?] at net.minecraft.client.Minecraft.createUserApiService(SourceFile:733) ~[client-named.jar:?]
at net.minecraft.client.Minecraft.<init>(SourceFile:442) ~[client-named.jar:?] at net.minecraft.client.Minecraft.<init>(SourceFile:442) ~[client-named.jar:?]
at McfoldRenderServer.boot(McfoldRenderServer.java:605) ~[classes/:?] at McfoldRenderServer.boot(McfoldRenderServer.java:788) ~[classes/:?]
at McfoldRenderServer.main(McfoldRenderServer.java:106) ~[classes/:?] at McfoldRenderServer.main(McfoldRenderServer.java:113) ~[classes/:?]
Caused by: com.mojang.authlib.exceptions.MinecraftClientHttpException: Status: 401 Caused by: com.mojang.authlib.exceptions.MinecraftClientHttpException: Status: 401
at com.mojang.authlib.minecraft.client.MinecraftClient.readInputStream(MinecraftClient.java:85) ~[authlib-4.0.43.jar:?] at com.mojang.authlib.minecraft.client.MinecraftClient.readInputStream(MinecraftClient.java:85) ~[authlib-4.0.43.jar:?]
at com.mojang.authlib.minecraft.client.MinecraftClient.get(MinecraftClient.java:48) ~[authlib-4.0.43.jar:?] at com.mojang.authlib.minecraft.client.MinecraftClient.get(MinecraftClient.java:48) ~[authlib-4.0.43.jar:?]
at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.fetchProperties(YggdrasilUserApiService.java:129) ~[authlib-4.0.43.jar:?] at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.fetchProperties(YggdrasilUserApiService.java:129) ~[authlib-4.0.43.jar:?]
... 6 more ... 6 more
[19:20:25] [main/INFO]: Setting user: mcfold_cam [00:23:05] [main/INFO]: Setting user: mcfold_cam
[19:20:25] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7 [00:23:05] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7
[19:20:25] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers. [00:23:05] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers.
[19:20:25] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it [00:23:05] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it
[19:20:26] [main/INFO]: Reloading ResourceManager: vanilla [00:23:06] [main/INFO]: Reloading ResourceManager: vanilla
[19:20:26] [Worker-Main-10/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading [00:23:06] [Worker-Main-3/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading
[19:20:26] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed [00:23:06] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed
[19:21:26] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager [00:24:06] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager
[19:21:26] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play [00:24:06] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play
[19:21:26] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break [00:24:06] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break
[19:21:26] [main/ERROR]: Error starting SoundSystem. Turning off sounds & music [00:24:06] [main/ERROR]: Error starting SoundSystem. Turning off sounds & music
java.lang.IllegalStateException: Failed to open OpenAL device java.lang.IllegalStateException: Failed to open OpenAL device
at com.mojang.blaze3d.audio.Library.openDeviceOrFallback(SourceFile:283) ~[client-named.jar:?] at com.mojang.blaze3d.audio.Library.openDeviceOrFallback(SourceFile:283) ~[client-named.jar:?]
at com.mojang.blaze3d.audio.Library.init(SourceFile:153) ~[client-named.jar:?] at com.mojang.blaze3d.audio.Library.init(SourceFile:153) ~[client-named.jar:?]
@ -40,23 +40,23 @@ java.lang.IllegalStateException: Failed to open OpenAL device
at java.util.concurrent.CompletableFuture.uniAcceptStage(CompletableFuture.java:735) ~[?:?] at java.util.concurrent.CompletableFuture.uniAcceptStage(CompletableFuture.java:735) ~[?:?]
at java.util.concurrent.CompletableFuture.thenAcceptAsync(CompletableFuture.java:2223) ~[?:?] at java.util.concurrent.CompletableFuture.thenAcceptAsync(CompletableFuture.java:2223) ~[?:?]
at net.minecraft.server.packs.resources.SimplePreparableReloadListener.reload(SourceFile:13) ~[client-named.jar:?] at net.minecraft.server.packs.resources.SimplePreparableReloadListener.reload(SourceFile:13) ~[client-named.jar:?]
at McfoldRenderServer.reloadResources(McfoldRenderServer.java:676) ~[classes/:?] at McfoldRenderServer.reloadResources(McfoldRenderServer.java:1146) ~[classes/:?]
at McfoldRenderServer.boot(McfoldRenderServer.java:609) ~[classes/:?] at McfoldRenderServer.boot(McfoldRenderServer.java:792) ~[classes/:?]
at McfoldRenderServer.main(McfoldRenderServer.java:106) ~[classes/:?] at McfoldRenderServer.main(McfoldRenderServer.java:113) ~[classes/:?]
[19:21:26] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading [00:24:06] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading
[19:21:26] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas [00:24:06] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas
[19:21:26] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas [00:24:06] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas
[19:21:26] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas [00:24:06] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas
[19:21:26] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas [00:24:06] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas
[19:21:26] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas [00:24:06] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas
[19:21:26] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas [00:24:06] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas
[19:21:26] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas [00:24:06] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas
[19:21:26] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas [00:24:06] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas
[19:21:27] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas [00:24:06] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas
[19:21:27] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. [00:24:07] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program.
[19:21:27] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas [00:24:07] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas
[19:21:27] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas [00:24:07] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas
[19:21:27] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas [00:24:07] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas
[19:21:27] [main/INFO]: [STDERR]: [mcfold] resources reloaded [00:24:07] [main/INFO]: [STDERR]: [mcfold] resources reloaded
[19:21:27] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. [00:24:07] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program.
[19:21:27] [main/INFO]: [STDERR]: [mcfold] boot done [00:24:07] [main/INFO]: [STDERR]: [mcfold] boot done

View File

@ -29,6 +29,7 @@ from pathlib import Path
# 世代的权威判据:这个类出现在哪个 version,就是那个世代 # 世代的权威判据:这个类出现在哪个 version,就是那个世代
__all__ = ["Gen_1_20", "Gen_1_21", "Gen", "GENERATIONS", __all__ = ["Gen_1_20", "Gen_1_21", "Gen", "GENERATIONS",
"detect_generation", "detect_from_store", "backend_class", "detect_generation", "detect_from_store", "backend_class",
"LAUNCHERS", "list_launchers",
"backend_for", "list_generations"] "backend_for", "list_generations"]
GEN_MARKERS = { GEN_MARKERS = {
@ -87,16 +88,32 @@ def detect_from_store(store: str | Path) -> Gen | None:
def backend_class(client_jar: str | Path | None = None, def backend_class(client_jar: str | Path | None = None,
generation: str | None = None): generation: str | None = None,
launcher: str = ""):
"""选中后端类。 """选中后端类。
★ 这是「Python 层 switch」的落点:加新世代只需在下面加一条 elif。 ★ 这是「Python 层 switch」的落点:加新世代只需在下面加一条分支。
两个轴是**正交**的(用户 2026-09-25 定向):
世代(版本差异) × 启动架构(加载器差异)
- 世代决定 McfoldRenderServer 怎么调 MC API(见 VERSION-DIFFS.md)
- 启动架构决定「谁起游戏」:裸启动 = 我们自己的 main;
launcher="fabric" = 经 Fabric 加载器(能加载 mod / Iris,但不写我们的 mod)
正交 ⇒ 用动态子类组合,不写 N 个类。
""" """
if generation is None: if generation is None:
generation = backend_for(client_jar) if client_jar else "gen_1_20" generation = backend_for(client_jar) if client_jar else "gen_1_20"
if generation == "gen_1_21": base = Gen_1_21 if generation == "gen_1_21" else Gen_1_20
return Gen_1_21 if not launcher:
return Gen_1_20 return base
# 动态子类:只覆盖 launcher,其余沿用世代实现
name = f"{base.__name__}_{launcher}"
existing = globals().get(name)
if existing is not None:
return existing
cls = type(name, (base,), {"launcher": launcher})
globals()[name] = cls
return cls
def backend_for(client_jar: str | Path) -> str: def backend_for(client_jar: str | Path) -> str:
@ -107,6 +124,17 @@ def backend_for(client_jar: str | Path) -> str:
return g.backend return g.backend
LAUNCHERS = [
("", "裸启动", "我们自己的 main;不加载 mod(零加载器方案可注册占位方块)"),
("fabric", "Fabric 加载器", "经 launcher_fabric.LoaderBoot;能加载 fabric mod / Iris,不写我们的 mod"),
]
def list_launchers() -> list[tuple[str, str, str]]:
"""(标识, 名称, 说明) —— 启动架构与世代正交。"""
return list(LAUNCHERS)
def list_generations() -> list[tuple[str, str, str]]: def list_generations() -> list[tuple[str, str, str]]:
"""(世代名, 版本区间, Java 目录) —— 供诊断/文档用。""" """(世代名, 版本区间, Java 目录) —— 供诊断/文档用。"""
return [ return [

View File

@ -87,6 +87,9 @@ class RenderBackend:
java_dir: str = "" java_dir: str = ""
markers: tuple[str, ...] = () markers: tuple[str, ...] = ()
main_class: str = "McfoldRenderServer" main_class: str = "McfoldRenderServer"
# 启动架构(与世代正交):"" = 裸启动(我们自己的 main)
# "fabric" = 经 Fabric 加载器启动(用 mod 与 Iris,但不写我们的 mod)
launcher: str = ""
def __init__( def __init__(
self, self,
@ -98,6 +101,7 @@ class RenderBackend:
java_args: list[str] | None = None, java_args: list[str] | None = None,
startup_timeout: float = 240.0, startup_timeout: float = 240.0,
cwd: str | None = None, cwd: str | None = None,
mods_dir: str | None = None,
): ):
self.java = java self.java = java
if classpath is None: if classpath is None:
@ -110,6 +114,8 @@ class RenderBackend:
self.asset_index = asset_index self.asset_index = asset_index
self.startup_timeout = startup_timeout self.startup_timeout = startup_timeout
self.cwd = cwd self.cwd = cwd
# 加载器后端的 mod 目录(launcher="fabric" 时经 -Dfabric.modsFolder 传入)
self.mods_dir = mods_dir
self.java_args = java_args or [ self.java_args = java_args or [
"-Xmx1200M", "-Xms512M", "-Djava.awt.headless=true", "-Xmx1200M", "-Xms512M", "-Djava.awt.headless=true",
] ]
@ -134,7 +140,18 @@ class RenderBackend:
f"未提供 classpath。设置 MCFOLD_RENDER_CLASSPATH," f"未提供 classpath。设置 MCFOLD_RENDER_CLASSPATH,"
f"或先跑 scaffold/build.sh 编译 {self.generation} 后端。" f"或先跑 scaffold/build.sh 编译 {self.generation} 后端。"
) )
cmd = [self.java, *self.java_args, "-cp", self.classpath, self.main_class] java_args = list(self.java_args)
# ★ 启动架构:走 Fabric 加载器时,主类是 launcher_fabric.LoaderBoot,
# 并需两个系统属性(实测缺一不可):
# fabric.gameMappingNamespace=named → 游戏类名保持 official 名
# fabric.modsFolder=<dir> → mod 目录
main_class = self.main_class
if self.launcher == "fabric":
main_class = "LoaderBoot"
java_args.append("-Dfabric.gameMappingNamespace=named")
if self.mods_dir:
java_args.append(f"-Dfabric.modsFolder={self.mods_dir}")
cmd = [self.java, *java_args, "-cp", self.classpath, main_class]
if self.game_dir: if self.game_dir:
cmd += ["--gameDir", self.game_dir] cmd += ["--gameDir", self.game_dir]
if self.assets: if self.assets:
@ -225,6 +242,21 @@ class RenderBackend:
return self._request({"cmd": "setblocks", "cx": cx, "cz": cz, return self._request({"cmd": "setblocks", "cx": cx, "cz": cz,
"sections": items}, timeout=300.0) "sections": items}, timeout=300.0)
def load_mods(self, mods_dir: str) -> dict:
"""加载 mod:Java 侧自己扫 jar、注册占位方块、挂资源包。
★ Python 只传目录路径 —— 版本/格式相关的处理全在 Java 侧
(用户 2026-09-25 定向:Python 相当于路由)。
原理(见 scaffold/MOD-BLOCKS.md):方块**渲染**只需要
(a) 注册表里有这个名字(拿 state id)
(b) 资源包里有 blockstates/models/textures
与方块行为无关 ⇒ 不需要 Fabric/Forge 加载器。
代价(诚实标注):方块**行为**(红石/交互)与第三方光影包(Iris)不支持。
"""
return self._request({"cmd": "mods", "dir": str(mods_dir)}, timeout=600.0)
def set_viewport(self, cx: int, cz: int, radius: int | None = None) -> dict: def set_viewport(self, cx: int, cz: int, radius: int | None = None) -> dict:
"""视野中心 —— 必须在注入前设好,否则区块被 ClientChunkCache 丢弃(实测)。""" """视野中心 —— 必须在注入前设好,否则区块被 ClientChunkCache 丢弃(实测)。"""
req: dict[str, Any] = {"cmd": "viewport", "cx": cx, "cz": cz} req: dict[str, Any] = {"cmd": "viewport", "cx": cx, "cz": cz}
@ -275,6 +307,17 @@ class RenderBackend:
return [] return []
return self._request({"cmd": "resolve", "blocks": specs})["ids"] return self._request({"cmd": "resolve", "blocks": specs})["ids"]
def get_block_info(self, x: int, y: int, z: int) -> dict:
"""查询某个坐标的方块信息。
★ 由 Java 侧回答(它才有 level + 注册表 + 模型状态),Python 只路由。
返回:block 名(含 mod 方块)、stateId、属性(朝向等)、isAir/isSolid、
光照/硬度、贴图是否缺失(程序化判据)、是否来自 mod。
未加载的区块会明确回 known=false + reason="chunk_not_loaded"
—— 与「空气」语义区分(设计文档附录 D)。
"""
return self._request({"cmd": "blockinfo", "x": int(x), "y": int(y), "z": int(z)})
def debug(self) -> dict: def debug(self) -> dict:
return self._request({"cmd": "debug"}) return self._request({"cmd": "debug"})

View File

@ -149,13 +149,38 @@ class WorldRenderer:
def open(cls, instance: Any = None, *, store: str | None = None, def open(cls, instance: Any = None, *, store: str | None = None,
classpath: str | None = None, start: bool = True, classpath: str | None = None, start: bool = True,
java: str = "java", client_jar: str | None = None, java: str = "java", client_jar: str | None = None,
generation: str | None = None) -> "WorldRenderer": generation: str | None = None, launcher: str = "",
mods_dir: str | None = None) -> "WorldRenderer":
"""从 instance(mcfold.instance.Instance)或 store 目录打开渲染器。 """从 instance(mcfold.instance.Instance)或 store 目录打开渲染器。
classpath 缺省取环境变量 MCFOLD_RENDER_CLASSPATH; classpath 缺省取环境变量 MCFOLD_RENDER_CLASSPATH;
可先跑 mcfold/scaffold/build.sh 生成。 可先跑 mcfold/scaffold/build.sh 生成。
""" """
game_dir = assets = asset_index = None game_dir = assets = asset_index = None
# ★ 版本隔离策略:产物在**内容根**的 .mcfold/ 下(可只读共享),
# 不是 /tmp(tmpfs,重启即丢)。见 scaffold/build.sh。
content_root = None
if instance is not None:
cr = getattr(instance, "content_root", None)
if cr:
content_root = Path(cr)
elif store:
content_root = Path(store) if Path(store).name.startswith(("vanilla-", "forge-",
"fabric-", "neoforge-", "quilt-")) else None
if content_root is not None and (content_root / ".mcfold").is_dir():
mf = content_root / ".mcfold"
if (mf / "classes").is_dir():
# 内容根产物优先(除非调用方显式给了 classpath)
if classpath is None:
cp_file = mf / "classpath.txt"
parts = []
if cp_file.is_file():
parts = [ln.strip() for ln in cp_file.read_text().splitlines()
if ln.strip()]
parts += [str(mf / "client-named.jar"), str(mf / "classes")]
classpath = os.pathsep.join(parts)
if (mf / "natives").is_dir():
os.environ.setdefault("MCFOLD_NATIVES_DIR", str(mf / "natives"))
if instance is not None: if instance is not None:
root = getattr(instance, "content_root", None) or getattr(instance, "path", None) root = getattr(instance, "content_root", None) or getattr(instance, "path", None)
if root: if root:
@ -171,16 +196,28 @@ class WorldRenderer:
assets = str(store_p / "assets") assets = str(store_p / "assets")
names = sorted(p.stem for p in (store_p / "assets" / "indexes").glob("*.json")) names = sorted(p.stem for p in (store_p / "assets" / "indexes").glob("*.json"))
asset_index = names[-1] if names else None asset_index = names[-1] if names else None
game_dir = game_dir or str(store_p / "instances" / "default") # ★ 不默认写生产实例 default(版本隔离策略)。
# 实例目录与内容根**同级**(都挂在 mcview/ 下),
# 绝不嵌套进内容根 —— 内容根要可只读共享(见 MULTI-INSTANCE-DESIGN §1.1)。
# 缺省按内容根名派生一个独立实例,避免撞上任何真实实例。
# 权威布局(mcview/lib/versions.cjs:25):
# BASE(可由 MCVIEW_BASE 覆盖)= .../mcview
# contentRoot = BASE/store/<loader>-<version>
# instanceDir = BASE/instances/<name>
# 所以 store 参数若是内容根,则 BASE = 内容根的 parent.parent。
base = Path(os.environ.get("MCVIEW_BASE") or store_p.parent.parent)
game_dir = game_dir or str(base / "instances" / f"exp-{store_p.name}")
# ★ Python 层 switch:按 client jar 的世代选后端 # ★ Python 层 switch:按 client jar 的世代选后端
# (1.21+ 的 DeltaTracker 在 1.20.x jar 中不存在 ⇒ 必须独立编译的后端) # (1.21+ 的 DeltaTracker 在 1.20.x jar 中不存在 ⇒ 必须独立编译的后端)
# ★ 变量名不能叫 cls —— 会覆盖 classmethod 的 cls, # ★ 变量名不能叫 cls —— 会覆盖 classmethod 的 cls,
# 导致下面 cls(bridge, instance) 构造出后端而非 WorldRenderer(实测踩过) # 导致下面 cls(bridge, instance) 构造出后端而非 WorldRenderer(实测踩过)
backend_cls = backend_class(client_jar=client_jar, generation=generation) backend_cls = backend_class(client_jar=client_jar, generation=generation,
launcher=launcher)
bridge = backend_cls( bridge = backend_cls(
java=java, classpath=classpath, java=java, classpath=classpath,
game_dir=game_dir, assets=assets, asset_index=asset_index, game_dir=game_dir, assets=assets, asset_index=asset_index,
mods_dir=mods_dir,
) )
wr = cls(bridge, instance) wr = cls(bridge, instance)
if start: if start:
@ -239,6 +276,40 @@ class WorldRenderer:
# ---------------- 方块名 → state id ---------------- # ---------------- 方块名 → state id ----------------
def get_block_info(self, x: int, y: int, z: int) -> dict:
"""查询坐标处的方块信息(Java 侧回答,Python 只路由)。
含:方块名(mod 方块亦然)、stateId、属性、光照/硬度、
贴图缺失的程序化判据、是否来自 mod;未加载区块明确区分于空气。
"""
return self.bridge.get_block_info(x, y, z)
def load_mods(self, mods_dir: str | Path | None = None) -> dict:
"""加载 mod(Java 侧全权处理:扫 jar / 注册方块 / 挂资源包)。
mods_dir 缺省取实例的 mods/ 目录。
★ 为什么不需要 Fabric/Forge 加载器(见 scaffold/MOD-BLOCKS.md):
方块**渲染**只需要「注册表里有名字」+「资源包有模型/贴图」,
与方块行为无关。所以零加载器也能让 mod 方块正确显示。
代价(诚实标注):方块行为(红石/交互)与第三方光影包(Iris)不支持。
"""
if mods_dir is None:
inst_path = getattr(self.instance, "path", None) if self.instance else None
if inst_path:
mods_dir = Path(inst_path) / "mods"
if mods_dir is None:
return {"ok": False, "error": "未提供 mods_dir 且无法从实例推断"}
d = Path(mods_dir)
if not d.is_dir():
return {"ok": True, "mods": 0, "note": f"{d} 不存在"}
r = self.bridge.load_mods(str(d))
# 加载 mod 后方块变了 ⇒ 规格缓存作废(否则仍按旧表解析)
self._spec_cache.clear()
self._biome_id = None
self.refresh_capacity()
return r
def resolve_specs(self, specs: list[str]) -> dict[str, int]: def resolve_specs(self, specs: list[str]) -> dict[str, int]:
"""批量把方块名规格解析成 state id(渲染器侧权威注册表)。""" """批量把方块名规格解析成 state id(渲染器侧权威注册表)。"""
todo = [s for s in dict.fromkeys(specs) if s not in self._spec_cache] todo = [s for s in dict.fromkeys(specs) if s not in self._spec_cache]

View File

@ -1,82 +0,0 @@
import com.mojang.blaze3d.platform.DisplayData;
import com.mojang.blaze3d.systems.RenderSystem;
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.core.registries.BuiltInRegistries;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import java.io.File;
import java.net.Proxy;
import java.util.Optional;
import java.util.OptionalInt;
/**
* 证明:真客户端的注册表能给出 state id ↔ 方块名的权威映射。
*
* 这正是不起完整客户端、只用客户端注册表的价值所在:
* 1.20.1 不从网络下发方块表(附录D),mod 方块的 state id 只有客户端注册表知道。
*/
public class RegistryProbe {
public static void main(String[] args) throws Exception {
String gameDir = arg(args, "--gameDir", "/home/newqqagent/mcview/instances/default");
String assets = arg(args, "--assets", "/home/newqqagent/mcview/store/vanilla-1.20.1/assets");
String assetIdx = arg(args, "--assetIndex", "1.20.1");
SharedConstants.tryDetectVersion();
SharedConstants.enableDataFixerOptimizations();
Bootstrap.bootStrap();
File runDir = new File(gameDir);
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(854, 480, 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();
Minecraft mc = new Minecraft(cfg);
RenderSystem.finishInitialization();
System.out.println("[reg] Minecraft constructed");
// ★ 核心:客户端的方块注册表
int blockCount = BuiltInRegistries.BLOCK.size();
int stateCount = Block.BLOCK_STATE_REGISTRY.size();
System.out.println("[reg] BLOCK registry size = " + blockCount);
System.out.println("[reg] BLOCK_STATE registry size = " + stateCount);
// ★ 关键验证:mineflayer 会静默误映射的那些越界 id,客户端怎么答
int[] probes = {0, 1, 1002, 1003, 1502, 30000, stateCount - 1};
for (int id : probes) {
BlockState st = Block.stateById(id);
ResourceLocation key = BuiltInRegistries.BLOCK.getKey(st.getBlock());
System.out.println("[reg] state_id=" + id + " -> " + key + " [" + st + "]");
}
// ★ 列出几个方块名(证明可读)
System.out.println("[reg] sample blocks:");
for (int i = 0; i < 5; i++) {
ResourceLocation k = BuiltInRegistries.BLOCK.getKey(BuiltInRegistries.BLOCK.byId(i));
System.out.println(" id=" + i + " -> " + k);
}
System.out.println("[reg] DONE");
System.exit(0);
}
private 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;
}
}

View File

@ -1,344 +0,0 @@
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.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.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.dimension.DimensionType;
import org.lwjgl.opengl.GL11;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.net.Proxy;
import java.time.Duration;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.UUID;
/**
* 世界渲染器 —— 把世界数据喂给真客户端渲染器并出图。
*
* 实测要点(2026-09-25):
* 1. 不跑主循环 ⇒ 渲染栈手动初始化(tryDetectVersion/Bootstrap/RenderSystem/new Minecraft)
* 2. 注册表从客户端自带 data/ 加载(RegistryDataLoader + vanilla pack)
* 3. shader 需显式编译(GameRenderer.reloadShaders 包私有 → 同包 bridge)
* 4. LoadingOverlay 必须摘掉,否则永远渲出加载屏
* 5. 区块靠 LevelChunk + setBlockState 注入 + setSectionDirty 触发网格重建
* 6. 读像素必须绑 FBO(glBindFramebuffer(frameBufferId)),GL_BACK 读到的是残留
*/
public class WorldRender {
public static void main(String[] args) throws Exception {
String gameDir = arg(args, "--gameDir", "/home/newqqagent/mcview/instances/default");
String assets = arg(args, "--assets", "/home/newqqagent/mcview/store/vanilla-1.20.1/assets");
String assetIdx = arg(args, "--assetIndex", "1.20.1");
String out = arg(args, "--out", "/tmp/scaffold-world.png");
int W = Integer.parseInt(arg(args, "--width", "854"));
int H = Integer.parseInt(arg(args, "--height", "480"));
SharedConstants.tryDetectVersion();
SharedConstants.enableDataFixerOptimizations();
Bootstrap.bootStrap();
File runDir = new File(gameDir);
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(W, H, 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();
Minecraft mc = new Minecraft(cfg);
RenderSystem.finishInitialization();
System.out.println("[wr] Minecraft ok");
// ── 资源:字体/贴图/模型 ──
// createReload 的 future 不该在主线程 join(会死锁),但字体加载是同步的,
// 这里用官方 FontManager.reload 直接同步加载字体(修「文字成方框」)
// ★ 资源重载:modelManager/fontManager/textureManager 全是 reload listener,
// 不跑重载则方块模型/字体/贴图全空(地形渲不出来)。
// 驱动方式照抄 LoadingOverlay:轮询 isDone(),不能 join(会死锁)。
reloadResources(mc);
System.out.println("[wr] resources reloaded");
// ── listener ──
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);
// ── 注册表:从客户端自带 data/ 加载 ──
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()));
java.util.List<RegistryDataLoader.RegistryData<?>> all = new java.util.ArrayList<>();
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});
java.lang.reflect.Field rf = ClientPacketListener.class.getDeclaredField("registryAccess");
rf.setAccessible(true);
rf.set(listener, layered);
System.out.println("[wr] registries loaded: dimension_type="
+ loaded.registryOrThrow(Registries.DIMENSION_TYPE).size());
Holder<DimensionType> dimHolder = loaded.registryOrThrow(Registries.DIMENSION_TYPE)
.getHolderOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, new ResourceLocation("overworld")));
// ── ClientLevel ──
var levelData = new ClientLevel.ClientLevelData(Difficulty.NORMAL, false, false);
ClientLevel level = new ClientLevel(listener, levelData, Level.OVERWORLD, dimHolder,
8, 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);
System.out.println("[wr] level + player ok");
// ── shader 编译(包私有,走同包 bridge)──
net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager());
System.out.println("[wr] shaders ok: " + (RenderSystem.getShader() != null));
// ── LoadingOverlay 必须摘掉 ──
mc.setOverlay(null);
// ★ "Joining world..." 是 ReceivingLevelScreen;不清它客户端始终认为未进世界
mc.setScreen(null);
// ── 注入区块 ──
int injected = injectWorld(level, mc);
System.out.println("[wr] injected chunks=" + injected);
// ── 第三人称(跳过 renderItemInHand)──
mc.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK);
// ── 渲染(每帧先 tick:区块网格重建队列靠 tick 推进)──
MainTarget target = (MainTarget) mc.getMainRenderTarget();
for (int f = 0; f < 60; f++) {
try { mc.tick(); } catch (Throwable t) { if (f == 0) System.out.println("[wr] tick err: " + t); }
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();
}
System.out.println("[wr] rendered 20 frames");
// ── 诊断:区块与网格状态 ──
System.out.println("[wr] hasRenderedAllChunks=" + mc.levelRenderer.hasRenderedAllChunks()
+ " countRenderedChunks=" + mc.levelRenderer.countRenderedChunks());
var got = level.getChunkSource().getChunk(0, 0, net.minecraft.world.level.chunk.ChunkStatus.FULL, false);
System.out.println("[wr] getChunk(0,0)=" + (got != null ? got.getClass().getSimpleName() : "null")
+ " block(0,64,0)=" + (got != null ? got.getBlockState(new BlockPos(0,64,0)) : "-"));
System.out.println("[wr] isChunkCompiled(0,64,0)=" + mc.levelRenderer.isChunkCompiled(new BlockPos(0,64,0)));
save(target, W, H, out);
System.exit(0);
}
/**
* 同步驱动各 reload listener。
*
* 为什么不用 Minecraft.reloadResourcePacks():
* 它的 future 靠主循环 tick 推进(LoadingOverlay),我们不跑主循环 ⇒ 死锁。
* 为什么不用 createReload():
* prepare 阶段仍走真后台池,实测 120s 超时。
* 正解:直接调各 listener 的 public reload(),barrier 同步返回,
* 且 apply 用 Runnable::run 保证在渲染线程执行。
*/
private static void reloadResources(Minecraft mc) throws Exception {
var rmF = Minecraft.class.getDeclaredField("resourceManager");
rmF.setAccessible(true);
var rm = (net.minecraft.server.packs.resources.ReloadableResourceManager) rmF.get(mc);
var packs = mc.getResourcePackRepository();
packs.reload();
// 同步 barrier:prepare 完成后立刻交出,不排队
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;
// prepare 用当前线程(同步跑),apply 也用当前线程(渲染线程)
java.util.concurrent.Executor sync = Runnable::run;
var all = reloadListeners(mc);
System.out.println("[wr] listeners=" + all.size());
for (var listener : all) {
String name = listener.getName();
System.out.println("[wr] -> " + name + " ...");
try {
var fut = listener.reload(barrier, rm, prof, prof, sync, sync);
boolean done = false;
long dl = System.currentTimeMillis() + 30_000;
while (!done && System.currentTimeMillis() < dl) {
done = fut.isDone();
if (!done) Thread.sleep(10);
}
if (!done) { System.out.println("[wr] TIMEOUT " + name); continue; }
fut.join();
System.out.println("[wr] ok: " + name);
} catch (Throwable e) {
System.out.println("[wr] FAILED " + name + ": " + e);
}
}
}
/** 取出注册过的 listener(private 字段) */
@SuppressWarnings("unchecked")
private static java.util.List<net.minecraft.server.packs.resources.PreparableReloadListener> reloadListeners(Minecraft mc) throws Exception {
var rmF = Minecraft.class.getDeclaredField("resourceManager");
rmF.setAccessible(true);
var rm = rmF.get(mc);
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");
}
/** 造地形并注入:区块 + 光照 + 网格重建 */
private static int injectWorld(ClientLevel level, Minecraft mc) throws Exception {
int radius = 4; // 9x9 区块 = 144x144 格
int yBottom = level.getMinBuildHeight();
var cache = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource();
var light = cache.getLightEngine();
// 反射拿 Storage.chunks / getIndex(包私有)
var storageF = net.minecraft.client.multiplayer.ClientChunkCache.class.getDeclaredField("storage");
storageF.setAccessible(true);
Object storage = storageF.get(cache);
var chunksF = storage.getClass().getDeclaredField("chunks");
chunksF.setAccessible(true);
var chunks = (java.util.concurrent.atomic.AtomicReferenceArray<LevelChunk>) chunksF.get(storage);
var getIndex = storage.getClass().getDeclaredMethod("getIndex", int.class, int.class);
getIndex.setAccessible(true);
System.out.println("[wr] storage chunks.length=" + chunks.length());
int count = 0;
for (int cx = -radius; cx <= radius; cx++) {
for (int cz = -radius; cz <= radius; cz++) {
LevelChunk chunk = new LevelChunk(level, new ChunkPos(cx, cz));
for (int lx = 0; lx < 16; lx++) {
for (int lz = 0; lz < 16; lz++) {
int wx = cx * 16 + lx, wz = cz * 16 + lz;
for (int y = yBottom; y < 63; y++)
chunk.setBlockState(new BlockPos(lx, y, lz), Blocks.STONE.defaultBlockState(), false);
chunk.setBlockState(new BlockPos(lx, 63, lz), Blocks.DIRT.defaultBlockState(), false);
chunk.setBlockState(new BlockPos(lx, 64, lz), Blocks.GRASS_BLOCK.defaultBlockState(), false);
if ((wx % 7 == 0 && wz % 7 == 0) && (wx != 0 || wz != 0)) {
for (int y = 65; y < 72; y++)
chunk.setBlockState(new BlockPos(lx, y, lz), Blocks.OAK_LOG.defaultBlockState(), false);
for (int dx = -2; dx <= 2; dx++)
for (int dz = -2; dz <= 2; dz++)
if (lx+dx >= 0 && lx+dx < 16 && lz+dz >= 0 && lz+dz < 16
&& Math.abs(dx) + Math.abs(dz) <= 2)
chunk.setBlockState(new BlockPos(lx+dx, 72, lz+dz),
Blocks.OAK_LEAVES.defaultBlockState(), false);
}
}
}
// ★ 核心:直接放进 ClientChunkCache.Storage.chunks
int idx = (int) getIndex.invoke(storage, cx, cz);
chunks.set(idx, chunk);
count++;
// ★ 光照:全亮(否则地形全黑)。section y 0..19 覆盖 build height
for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) {
var sky = new net.minecraft.world.level.chunk.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(net.minecraft.world.level.LightLayer.SKY,
net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
light.queueSectionData(net.minecraft.world.level.LightLayer.BLOCK,
net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
}
light.setLightEnabled(new ChunkPos(cx, cz), true);
chunk.setLightCorrect(true);
}
}
int upd = light.runLightUpdates();
System.out.println("[wr] light updates=" + upd);
// 触发区块网格重建
mc.levelRenderer.allChanged();
System.out.println("[wr] allChanged done");
return count;
}
private static void save(MainTarget target, int W, int H, String out) throws Exception {
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);
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++) {
int i = ((H - 1 - y) * W + x) * 4;
img.setRGB(x, y, ((buf.get(i) & 0xFF) << 16) | ((buf.get(i+1) & 0xFF) << 8) | (buf.get(i+2) & 0xFF));
}
ImageIO.write(img, "png", new File(out));
java.util.Set<Integer> colors = new java.util.HashSet<>();
for (int p : img.getRGB(0, 0, W, H, null, 0, W)) colors.add(p & 0xFFFFFF);
System.out.println("[wr] wrote " + out + " distinct colors=" + colors.size());
}
private 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;
}
}

View File

@ -1,76 +1,105 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# mcfold scaffold 构建脚本 —— 把「官方客户端 jar」变成「可直接调用的渲染库」 # mcfold 构建脚本 —— 把官方客户端 jar 变成「可直接调用的渲染库」。
# #
# 实测链路(2026-09-25 全绿): # ★ 版本隔离策略(用户 2026-09-25 强调)
# 1. 下官方 ProGuard 映射(client_mappings.txt) # 产物写入**内容根** store/<loader>-<version>/.mcfold/,不写 /tmp:
# 2. 转成 SRG(SpecialSource 只吃 SRG) # 内容根/.mcfold/client-named.jar 反混淆后的客户端(可只读共享)
# 3. 重映射 client.jar → client-named.jar(类名可读,javap/javac 可用) # 内容根/.mcfold/classes/ 我们的渲染服务
# 4. 编译脚手架 # 内容根/.mcfold/natives/ LWJGL .so
# 理由:这些都由该版本的 client jar 派生,属「可共享内容」;
# /tmp 是 tmpfs,重启即丢(实测踩过)。实例独占的东西(mods/config/options)
# 一律不进这里。
# #
# 用法: ./build.sh [实例store目录] [MC版本] # 用法: ./build.sh <内容根|实例名> <MC版本> [java模式目录]
# ./build.sh /home/newqqagent/mcview/store/vanilla-1.20.1 1.20.1
# ./build.sh /home/newqqagent/mcview/store/vanilla-1.21.1 1.21.1 java/gen_1_21
set -euo pipefail set -euo pipefail
STORE="${1:-/home/newqqagent/mcview/store/vanilla-1.20.1}" STORE="${1:?用法: build.sh <内容根> <MC版本> [java目录]}"
MCVER="${2:-1.20.1}" MCVER="${2:?需要 MC 版本}"
JAVA_SUB="${3:-}"
HERE="$(cd "$(dirname "$0")" && pwd)" HERE="$(cd "$(dirname "$0")" && pwd)"
WORK="${MCFOLD_SCAFFOLD_WORK:-/tmp/mcfold-scaffold}"
mkdir -p "$WORK" # 按版本挑世代目录(除非显式指定)
cd "$WORK" if [ -z "$JAVA_SUB" ]; then
case "$MCVER" in
1.21*) JAVA_SUB="java/gen_1_21" ;;
*) JAVA_SUB="java/gen_1_20" ;;
esac
fi
OUT="$STORE/.mcfold"
WORK="${MCFOLD_BUILD_WORK:-$(mktemp -d /tmp/mcfold-build-XXXXXX)}"
CLIENT_JAR="$STORE/versions/$MCVER/$MCVER.jar" CLIENT_JAR="$STORE/versions/$MCVER/$MCVER.jar"
[ -f "$CLIENT_JAR" ] || { echo "找不到客户端 jar: $CLIENT_JAR"; exit 1; } [ -f "$CLIENT_JAR" ] || { echo "找不到客户端 jar: $CLIENT_JAR"; exit 1; }
# ── 1. 映射文件 ──────────────────────────────────────────────── mkdir -p "$OUT/classes" "$OUT/natives" "$WORK"
MAPPINGS_URL=$(python3 - "$STORE/versions/$MCVER/$MCVER.json" <<'PY' echo "[build] 版本 $MCVER 世代 $JAVA_SUB"
echo "[build] 输出 → $OUT"
# ── 1. 官方映射 ────────────────────────────────────────────────
if [ ! -s "$WORK/client_mappings.txt" ]; then
URL=$(python3 - "$STORE/versions/$MCVER/$MCVER.json" <<'PY'
import json,sys import json,sys
d=json.load(open(sys.argv[1])) print(json.load(open(sys.argv[1]))['downloads']['client_mappings']['url'])
print(d['downloads']['client_mappings']['url'])
PY PY
) )
if [ ! -s client_mappings.txt ]; then echo "[1/5] 下载官方映射..."
echo "[1/4] 下载官方映射..." curl -sL -o "$WORK/client_mappings.txt" "$URL"
curl -sL -o client_mappings.txt "$MAPPINGS_URL"
fi fi
echo "[1/4] 映射: $(wc -l < client_mappings.txt) 行" echo "[1/5] 映射 $(wc -l < "$WORK/client_mappings.txt") 行"
# ── 2. ProGuard → SRG ───────────────────────────────────────── # ── 2. ProGuard → SRG ─────────────────────────────────────────
echo "[2/4] 转换映射格式..." echo "[2/5] 转换映射格式..."
python3 "$HERE/pg2srg.py" client_mappings.txt mappings.srg python3 "$HERE/pg2srg.py" "$WORK/client_mappings.txt" "$WORK/mappings.srg"
# ── 3. 重映射 jar ───────────────────────────────────────────── # ── 3. 重映射客户端 jar ───────────────────────────────────────
if [ ! -f client-named.jar ] || [ "$CLIENT_JAR" -nt client-named.jar ]; then NAMED="$OUT/client-named.jar"
echo "[3/4] 重映射客户端 jar(约 1-2 分钟)..." if [ ! -f "$NAMED" ] || [ "$CLIENT_JAR" -nt "$NAMED" ]; then
echo "[3/5] 重映射(1-3 分钟)..."
SS=$(find /home/minecraft/libraries -name "SpecialSource-*.jar" 2>/dev/null | head -1) SS=$(find /home/minecraft/libraries -name "SpecialSource-*.jar" 2>/dev/null | head -1)
[ -n "$SS" ] || { echo "找不到 SpecialSource"; exit 1; }
GUAVA=$(find /home/minecraft/libraries -name "guava-*.jar" ! -name "*sources*" 2>/dev/null | head -1) GUAVA=$(find /home/minecraft/libraries -name "guava-*.jar" ! -name "*sources*" 2>/dev/null | head -1)
[ -n "$SS" ] || { echo "找不到 SpecialSource"; exit 1; }
for a in asm asm-commons asm-tree; do for a in asm asm-commons asm-tree; do
[ -f "$a.jar" ] || curl -sL -o "$a.jar" "https://repo1.maven.org/maven2/org/ow2/asm/$a/9.7/$a-9.7.jar" [ -f "$WORK/$a.jar" ] || curl -sL -o "$WORK/$a.jar" \
"https://repo1.maven.org/maven2/org/ow2/asm/$a/9.7/$a-9.7.jar"
done done
[ -f joptsimple.jar ] || curl -sL -o joptsimple.jar \ [ -f "$WORK/joptsimple.jar" ] || curl -sL -o "$WORK/joptsimple.jar" \
"https://repo1.maven.org/maven2/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar" "https://repo1.maven.org/maven2/net/sf/jopt-simple/jopt-simple/5.0.4/joptsimple-5.0.4.jar"
java -Xmx3G -cp "$SS:joptsimple.jar:asm.jar:asm-commons.jar:asm-tree.jar:$GUAVA" \ java -Xmx3G -cp "$SS:$WORK/joptsimple.jar:$WORK/asm.jar:$WORK/asm-commons.jar:$WORK/asm-tree.jar:$GUAVA" \
net.md_5.specialsource.SpecialSource \ net.md_5.specialsource.SpecialSource \
-i "$CLIENT_JAR" -o client-named.jar -m mappings.srg -i "$CLIENT_JAR" -o "$NAMED" -m "$WORK/mappings.srg"
else else
echo "[3/4] client-named.jar 已是最新" echo "[3/5] client-named.jar 已是最新"
fi fi
ls -la client-named.jar
# ── classpath ───────────────────────────────────────────────── # ── 4. classpath(去 asm 重复,Fabric 需要) + natives ────────
find "$STORE/libraries" -name "*.jar" ! -name "*-sources.jar" ! -name "*natives*" 2>/dev/null \ find "$STORE/libraries" -name "*.jar" ! -name "*-sources.jar" ! -name "*natives*" 2>/dev/null \
| grep -vE "minecraftforge|neoforge" > cp.txt | grep -vE "minecraftforge|neoforge" > "$OUT/classpath.txt"
CP="$(paste -sd: cp.txt):$WORK/client-named.jar" NATLIST=$(find "$STORE/libraries" -name "*natives-linux*.jar" 2>/dev/null)
if [ -n "$NATLIST" ]; then
rm -rf "$OUT/natives/linux"; mkdir -p "$OUT/natives"
while read -r j; do [ -f "$j" ] && unzip -o -q "$j" -d "$OUT/natives" 2>/dev/null || true; done <<< "$NATLIST"
fi
echo "[4/5] 库 $(wc -l < "$OUT/classpath.txt") 个, natives $(find "$OUT/natives" -name '*.so' | wc -l) 个"
# ── 5. 编译渲染服务 ───────────────────────────────────────────
echo "[5/5] 编译 $JAVA_SUB ..."
CP="$(paste -sd: "$OUT/classpath.txt"):$NAMED:$OUT/classes"
find "$HERE/$JAVA_SUB" -name "*.java" > "$WORK/srcs.txt"
javac -nowarn -proc:none -cp "$CP" -d "$OUT/classes" @"$WORK/srcs.txt"
# ── 4. 编译脚手架 ─────────────────────────────────────────────
echo "[4/4] 编译脚手架..."
mkdir -p classes
javac -nowarn -proc:none -cp "$CP" -d classes "$HERE"/*.java
echo echo
echo "✅ 构建完成" echo "✅ 构建完成(产物在内容根,不是 /tmp)"
echo " named jar : $WORK/client-named.jar" echo " 内容根 : $STORE"
echo " classes : $WORK/classes" echo " 产物 : $OUT"
echo " named : $OUT/client-named.jar"
echo " classes: $OUT/classes"
echo echo
echo "运行(无头,需 Xvfb):" echo "运行(渲染服务从 stdin 读 NDJSON):"
echo " find $STORE/libraries -name '*natives-linux*.jar' | while read j; do unzip -o -q \$j -d $WORK/nativ; done" echo " DISPLAY=:98 java -cp \"\$(paste -sd: $OUT/classpath.txt):$NAMED:$OUT/classes\" \\"
echo " DISPLAY=:98 java -cp \"$CP:$WORK/classes\" -Djava.library.path=$WORK/nativ RenderProbe" echo " -Djava.library.path=$OUT/natives McfoldRenderServer \\"
echo " --gameDir <实例目录> --assets $STORE/assets --assetIndex $MCVER"
echo
echo "★ 实例目录必须是**实例独占**的那个(mods/config/options 的边界),"
echo " 不要指向别的版本或别的加载器的实例 —— 那正是「多实例共用 mods」的崩因。"

View File

@ -99,7 +99,11 @@ public class McfoldRenderServer {
// stdout 只放协议 JSON,MC 的 INFO/WARN 全部赶到 stderr // stdout 只放协议 JSON,MC 的 INFO/WARN 全部赶到 stderr
System.setOut(new PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.err), true)); 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 assets = arg(args, "--assets", "/home/newqqagent/mcview/store/vanilla-1.20.1/assets");
String assetIdx = arg(args, "--assetIndex", "1.20.1"); String assetIdx = arg(args, "--assetIndex", "1.20.1");
width = Integer.parseInt(arg(args, "--width", "854")); width = Integer.parseInt(arg(args, "--width", "854"));
@ -264,6 +268,161 @@ public class McfoldRenderServer {
emit(id, res); emit(id, res);
break; 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": { case "camera": {
double x = req.get("x").getAsDouble(), y = req.get("y").getAsDouble(), z = req.get("z").getAsDouble(); 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 yaw = req.has("yaw") ? req.get("yaw").getAsFloat() : 0f;
@ -679,6 +838,101 @@ public class McfoldRenderServer {
System.err.println("[mcfold] boot done"); 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 → 注册占位方块 → 挂资源包(外观)。 * 加载 mod:扫 jar → 注册占位方块 → 挂资源包(外观)。
* *
@ -768,15 +1022,48 @@ public class McfoldRenderServer {
var repo = mc.getResourcePackRepository(); var repo = mc.getResourcePackRepository();
java.nio.file.Path rpDir = mc.getResourcePackDirectory(); java.nio.file.Path rpDir = mc.getResourcePackDirectory();
for (File jar : jars) { 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(), if (!dst.isFile()) java.nio.file.Files.copy(jar.toPath(), dst.toPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING); 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(); 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()); 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()) { for (String packId : repo.getAvailableIds()) {
if (packId.contains("zzz-mcfold-blockstate-overlay")) { overlayIds.add(packId); continue; }
if (packId.contains("mcfold-")) { sel.add(packId); packsAdded++; } 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); repo.setSelected(sel);
// 用新 pack 列表重建 resource manager(照官方 createReload 内部做法), // 用新 pack 列表重建 resource manager(照官方 createReload 内部做法),
@ -794,6 +1081,36 @@ public class McfoldRenderServer {
reloadResources(mc); reloadResources(mc);
net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager()); net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager());
mc.levelRenderer.allChanged(); 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) { } catch (Throwable t) {
out.addProperty("packError", String.valueOf(t)); out.addProperty("packError", String.valueOf(t));
System.err.println("[mcfold] 资源包挂载失败: " + t); System.err.println("[mcfold] 资源包挂载失败: " + t);

View File

@ -99,7 +99,11 @@ public class McfoldRenderServer {
// stdout 只放协议 JSON,MC 的 INFO/WARN 全部赶到 stderr // stdout 只放协议 JSON,MC 的 INFO/WARN 全部赶到 stderr
System.setOut(new PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.err), true)); 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 assets = arg(args, "--assets", "/home/newqqagent/mcview/store/vanilla-1.20.1/assets");
String assetIdx = arg(args, "--assetIndex", "1.20.1"); String assetIdx = arg(args, "--assetIndex", "1.20.1");
width = Integer.parseInt(arg(args, "--width", "854")); width = Integer.parseInt(arg(args, "--width", "854"));
@ -264,6 +268,162 @@ public class McfoldRenderServer {
emit(id, res); emit(id, res);
break; 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": { case "camera": {
double x = req.get("x").getAsDouble(), y = req.get("y").getAsDouble(), z = req.get("z").getAsDouble(); 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 yaw = req.has("yaw") ? req.get("yaw").getAsFloat() : 0f;
@ -723,6 +883,101 @@ public class McfoldRenderServer {
System.err.println("[mcfold] boot done"); 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 → 注册占位方块 → 挂资源包(外观)。 * 加载 mod:扫 jar → 注册占位方块 → 挂资源包(外观)。
* *
@ -814,15 +1069,48 @@ public class McfoldRenderServer {
var repo = mc.getResourcePackRepository(); var repo = mc.getResourcePackRepository();
java.nio.file.Path rpDir = mc.getResourcePackDirectory(); java.nio.file.Path rpDir = mc.getResourcePackDirectory();
for (File jar : jars) { 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(), if (!dst.isFile()) java.nio.file.Files.copy(jar.toPath(), dst.toPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING); 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(); 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()); 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()) { for (String packId : repo.getAvailableIds()) {
if (packId.contains("zzz-mcfold-blockstate-overlay")) { overlayIds.add(packId); continue; }
if (packId.contains("mcfold-")) { sel.add(packId); packsAdded++; } 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); repo.setSelected(sel);
// 用新 pack 列表重建 resource manager(照官方 createReload 内部做法), // 用新 pack 列表重建 resource manager(照官方 createReload 内部做法),
@ -840,6 +1128,38 @@ public class McfoldRenderServer {
reloadResources(mc); reloadResources(mc);
net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager()); net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager());
mc.levelRenderer.allChanged(); 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) { } catch (Throwable t) {
out.addProperty("packError", String.valueOf(t)); out.addProperty("packError", String.valueOf(t));
System.err.println("[mcfold] 资源包挂载失败: " + 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);
}
}

View File

@ -41,7 +41,8 @@ def _open_backend():
if not ok: if not ok:
return None, why return None, why
br = Gen_1_20( br = Gen_1_20(
game_dir=str(STORE / "instances" / "default"), # ★ 用测试专用实例目录,不碰生产 default(版本隔离策略)
game_dir=str(STORE / "instances" / "test-mcfold"),
assets=str(STORE / "assets"), asset_index="1.20.1", assets=str(STORE / "assets"), asset_index="1.20.1",
) )
try: try:
@ -184,6 +185,72 @@ def test_set_blocks_render():
br.stop() br.stop()
def test_get_block_info():
"""get_block_info:方块名/属性/未加载区分(Java 侧回答)。
★ 关键判据:**未加载的区块必须 known=false**,不能伪装成空气
(ClientLevel.hasChunk() 恒 true、ChunkSource.hasChunk() 也恒 true,
实测都不顶用;必须用 getChunk(..., requireChunk=false) 判 null)。
"""
br, why = _open_backend()
if br is None:
print(f" - 跳过({why})")
return
try:
cap = br._request({"cmd": "capacity"})
min_s = cap["minSection"]
stone, grass, log = br.resolve_blocks([
"minecraft:stone", "minecraft:grass_block[snowy=false]",
"minecraft:oak_log[axis=y]",
])
assert stone > 0 and grass > 0 and log > 0
br.set_viewport(0, 0, radius=12)
sections = {}
for idx in range(cap["sectionsCount"]):
v = [0] * SECTION_VOLUME
if idx < 4:
v = [stone] * SECTION_VOLUME
elif idx == 4:
v = [grass] * SECTION_VOLUME
elif idx == 5:
v[7 + 7 * 16] = log # x=7,z=7
sections[idx] = v
for cx in range(-1, 2):
for cz in range(-1, 2):
br.set_blocks(cx, cz, sections)
y_grass = (min_s + 4) * 16
y_stone = (min_s + 2) * 16
y_log = (min_s + 5) * 16
g = br.get_block_info(8, y_grass, 8)
assert g["known"] and g["block"] == "minecraft:grass_block", g
assert g["isAir"] is False and g["isSolid"] is True
assert g["properties"].get("snowy") == "false", g
assert g["isMissingTexture"] is False, g
assert g["fromMod"] is False
st = br.get_block_info(8, y_stone, 8)
assert st["known"] and st["block"] == "minecraft:stone", st
lg = br.get_block_info(7, y_log, 7)
assert lg["known"] and lg["block"] == "minecraft:oak_log", lg
assert lg["properties"].get("axis") == "y", lg # 属性必须读出来
air = br.get_block_info(8, 200, 8)
assert air["known"] and air["isAir"] is True, air
# ★ 最重要的一条:未加载 ≠ 空气
far = br.get_block_info(9999, 64, 9999)
assert far["known"] is False, f"未加载区块必须 known=false,得到 {far}"
assert far.get("reason") == "chunk_not_loaded", far
print(f" ✓ get_block_info({g['block']} 属性={g['properties']}, "
f"原木 axis={lg['properties'].get('axis')}, 未加载已区分)")
finally:
br.stop()
def test_real_save_render(): def test_real_save_render():
"""真实存档经新路径(RLE 路由)渲染 —— 最终验收。""" """真实存档经新路径(RLE 路由)渲染 —— 最终验收。"""
ok, why = _available() ok, why = _available()