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:
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
BIN
logs/2026-09-26-1.log.gz
Normal file
Binary file not shown.
BIN
logs/2026-09-26-2.log.gz
Normal file
BIN
logs/2026-09-26-2.log.gz
Normal file
Binary file not shown.
BIN
logs/2026-09-26-3.log.gz
Normal file
BIN
logs/2026-09-26-3.log.gz
Normal file
Binary file not shown.
BIN
logs/2026-09-26-4.log.gz
Normal file
BIN
logs/2026-09-26-4.log.gz
Normal file
Binary file not shown.
@ -1,6 +1,6 @@
|
||||
[19:20:23] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 129 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'
|
||||
[19:20:25] [main/ERROR]: Failed to verify authentication
|
||||
[00:23:03] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 79 milliseconds
|
||||
[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'
|
||||
[00:23:05] [main/ERROR]: Failed to verify authentication
|
||||
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.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 net.minecraft.client.Minecraft.createUserApiService(SourceFile:733) ~[client-named.jar:?]
|
||||
at net.minecraft.client.Minecraft.<init>(SourceFile:442) ~[client-named.jar:?]
|
||||
at McfoldRenderServer.boot(McfoldRenderServer.java:605) ~[classes/:?]
|
||||
at McfoldRenderServer.main(McfoldRenderServer.java:106) ~[classes/:?]
|
||||
at McfoldRenderServer.boot(McfoldRenderServer.java:788) ~[classes/:?]
|
||||
at McfoldRenderServer.main(McfoldRenderServer.java:113) ~[classes/:?]
|
||||
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.get(MinecraftClient.java:48) ~[authlib-4.0.43.jar:?]
|
||||
at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.fetchProperties(YggdrasilUserApiService.java:129) ~[authlib-4.0.43.jar:?]
|
||||
... 6 more
|
||||
[19:20:25] [main/INFO]: Setting user: mcfold_cam
|
||||
[19:20:25] [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.
|
||||
[19:20:25] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it
|
||||
[19:20:26] [main/INFO]: Reloading ResourceManager: vanilla
|
||||
[19:20:26] [Worker-Main-10/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading
|
||||
[19:20:26] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed
|
||||
[19:21:26] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager
|
||||
[19:21:26] [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
|
||||
[19:21:26] [main/ERROR]: Error starting SoundSystem. Turning off sounds & music
|
||||
[00:23:05] [main/INFO]: Setting user: mcfold_cam
|
||||
[00:23:05] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7
|
||||
[00:23:05] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers.
|
||||
[00:23:05] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it
|
||||
[00:23:06] [main/INFO]: Reloading ResourceManager: vanilla
|
||||
[00:23:06] [Worker-Main-3/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading
|
||||
[00:23:06] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed
|
||||
[00:24:06] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager
|
||||
[00:24:06] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play
|
||||
[00:24:06] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break
|
||||
[00:24:06] [main/ERROR]: Error starting SoundSystem. Turning off sounds & music
|
||||
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.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.thenAcceptAsync(CompletableFuture.java:2223) ~[?:?]
|
||||
at net.minecraft.server.packs.resources.SimplePreparableReloadListener.reload(SourceFile:13) ~[client-named.jar:?]
|
||||
at McfoldRenderServer.reloadResources(McfoldRenderServer.java:676) ~[classes/:?]
|
||||
at McfoldRenderServer.boot(McfoldRenderServer.java:609) ~[classes/:?]
|
||||
at McfoldRenderServer.main(McfoldRenderServer.java:106) ~[classes/:?]
|
||||
[19:21:26] [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
|
||||
[19:21:26] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas
|
||||
[19:21:26] [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
|
||||
[19:21:26] [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
|
||||
[19:21:26] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas
|
||||
[19:21:26] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas
|
||||
[19:21:27] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas
|
||||
[19:21:27] [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
|
||||
[19:21:27] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas
|
||||
[19:21:27] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas
|
||||
[19:21:27] [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.
|
||||
[19:21:27] [main/INFO]: [STDERR]: [mcfold] boot done
|
||||
at McfoldRenderServer.reloadResources(McfoldRenderServer.java:1146) ~[classes/:?]
|
||||
at McfoldRenderServer.boot(McfoldRenderServer.java:792) ~[classes/:?]
|
||||
at McfoldRenderServer.main(McfoldRenderServer.java:113) ~[classes/:?]
|
||||
[00:24:06] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading
|
||||
[00:24:06] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas
|
||||
[00:24:06] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas
|
||||
[00:24:06] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas
|
||||
[00:24:06] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas
|
||||
[00:24:06] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas
|
||||
[00:24:06] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas
|
||||
[00:24:06] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas
|
||||
[00:24:06] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas
|
||||
[00:24:06] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas
|
||||
[00:24:07] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program.
|
||||
[00:24:07] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas
|
||||
[00:24:07] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas
|
||||
[00:24:07] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas
|
||||
[00:24:07] [main/INFO]: [STDERR]: [mcfold] resources reloaded
|
||||
[00:24:07] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program.
|
||||
[00:24:07] [main/INFO]: [STDERR]: [mcfold] boot done
|
||||
|
||||
Binary file not shown.
@ -29,6 +29,7 @@ from pathlib import Path
|
||||
# 世代的权威判据:这个类出现在哪个 version,就是那个世代
|
||||
__all__ = ["Gen_1_20", "Gen_1_21", "Gen", "GENERATIONS",
|
||||
"detect_generation", "detect_from_store", "backend_class",
|
||||
"LAUNCHERS", "list_launchers",
|
||||
"backend_for", "list_generations"]
|
||||
|
||||
GEN_MARKERS = {
|
||||
@ -87,16 +88,32 @@ def detect_from_store(store: str | Path) -> Gen | 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:
|
||||
generation = backend_for(client_jar) if client_jar else "gen_1_20"
|
||||
if generation == "gen_1_21":
|
||||
return Gen_1_21
|
||||
return Gen_1_20
|
||||
base = Gen_1_21 if generation == "gen_1_21" else Gen_1_20
|
||||
if not launcher:
|
||||
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:
|
||||
@ -107,6 +124,17 @@ def backend_for(client_jar: str | Path) -> str:
|
||||
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]]:
|
||||
"""(世代名, 版本区间, Java 目录) —— 供诊断/文档用。"""
|
||||
return [
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -87,6 +87,9 @@ class RenderBackend:
|
||||
java_dir: str = ""
|
||||
markers: tuple[str, ...] = ()
|
||||
main_class: str = "McfoldRenderServer"
|
||||
# 启动架构(与世代正交):"" = 裸启动(我们自己的 main)
|
||||
# "fabric" = 经 Fabric 加载器启动(用 mod 与 Iris,但不写我们的 mod)
|
||||
launcher: str = ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -98,6 +101,7 @@ class RenderBackend:
|
||||
java_args: list[str] | None = None,
|
||||
startup_timeout: float = 240.0,
|
||||
cwd: str | None = None,
|
||||
mods_dir: str | None = None,
|
||||
):
|
||||
self.java = java
|
||||
if classpath is None:
|
||||
@ -110,6 +114,8 @@ class RenderBackend:
|
||||
self.asset_index = asset_index
|
||||
self.startup_timeout = startup_timeout
|
||||
self.cwd = cwd
|
||||
# 加载器后端的 mod 目录(launcher="fabric" 时经 -Dfabric.modsFolder 传入)
|
||||
self.mods_dir = mods_dir
|
||||
self.java_args = java_args or [
|
||||
"-Xmx1200M", "-Xms512M", "-Djava.awt.headless=true",
|
||||
]
|
||||
@ -134,7 +140,18 @@ class RenderBackend:
|
||||
f"未提供 classpath。设置 MCFOLD_RENDER_CLASSPATH,"
|
||||
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:
|
||||
cmd += ["--gameDir", self.game_dir]
|
||||
if self.assets:
|
||||
@ -225,6 +242,21 @@ class RenderBackend:
|
||||
return self._request({"cmd": "setblocks", "cx": cx, "cz": cz,
|
||||
"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:
|
||||
"""视野中心 —— 必须在注入前设好,否则区块被 ClientChunkCache 丢弃(实测)。"""
|
||||
req: dict[str, Any] = {"cmd": "viewport", "cx": cx, "cz": cz}
|
||||
@ -275,6 +307,17 @@ class RenderBackend:
|
||||
return []
|
||||
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:
|
||||
return self._request({"cmd": "debug"})
|
||||
|
||||
|
||||
@ -149,13 +149,38 @@ class WorldRenderer:
|
||||
def open(cls, instance: Any = None, *, store: str | None = None,
|
||||
classpath: str | None = None, start: bool = True,
|
||||
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 目录打开渲染器。
|
||||
|
||||
classpath 缺省取环境变量 MCFOLD_RENDER_CLASSPATH;
|
||||
可先跑 mcfold/scaffold/build.sh 生成。
|
||||
"""
|
||||
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:
|
||||
root = getattr(instance, "content_root", None) or getattr(instance, "path", None)
|
||||
if root:
|
||||
@ -171,16 +196,28 @@ class WorldRenderer:
|
||||
assets = str(store_p / "assets")
|
||||
names = sorted(p.stem for p in (store_p / "assets" / "indexes").glob("*.json"))
|
||||
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 的世代选后端
|
||||
# (1.21+ 的 DeltaTracker 在 1.20.x jar 中不存在 ⇒ 必须独立编译的后端)
|
||||
# ★ 变量名不能叫 cls —— 会覆盖 classmethod 的 cls,
|
||||
# 导致下面 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(
|
||||
java=java, classpath=classpath,
|
||||
game_dir=game_dir, assets=assets, asset_index=asset_index,
|
||||
mods_dir=mods_dir,
|
||||
)
|
||||
wr = cls(bridge, instance)
|
||||
if start:
|
||||
@ -239,6 +276,40 @@ class WorldRenderer:
|
||||
|
||||
# ---------------- 方块名 → 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]:
|
||||
"""批量把方块名规格解析成 state id(渲染器侧权威注册表)。"""
|
||||
todo = [s for s in dict.fromkeys(specs) if s not in self._spec_cache]
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,76 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# mcfold scaffold 构建脚本 —— 把「官方客户端 jar」变成「可直接调用的渲染库」
|
||||
# mcfold 构建脚本 —— 把官方客户端 jar 变成「可直接调用的渲染库」。
|
||||
#
|
||||
# 实测链路(2026-09-25 全绿):
|
||||
# 1. 下官方 ProGuard 映射(client_mappings.txt)
|
||||
# 2. 转成 SRG(SpecialSource 只吃 SRG)
|
||||
# 3. 重映射 client.jar → client-named.jar(类名可读,javap/javac 可用)
|
||||
# 4. 编译脚手架
|
||||
# ★ 版本隔离策略(用户 2026-09-25 强调)
|
||||
# 产物写入**内容根** store/<loader>-<version>/.mcfold/,不写 /tmp:
|
||||
# 内容根/.mcfold/client-named.jar 反混淆后的客户端(可只读共享)
|
||||
# 内容根/.mcfold/classes/ 我们的渲染服务
|
||||
# 内容根/.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
|
||||
|
||||
STORE="${1:-/home/newqqagent/mcview/store/vanilla-1.20.1}"
|
||||
MCVER="${2:-1.20.1}"
|
||||
STORE="${1:?用法: build.sh <内容根> <MC版本> [java目录]}"
|
||||
MCVER="${2:?需要 MC 版本}"
|
||||
JAVA_SUB="${3:-}"
|
||||
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"
|
||||
[ -f "$CLIENT_JAR" ] || { echo "找不到客户端 jar: $CLIENT_JAR"; exit 1; }
|
||||
|
||||
# ── 1. 映射文件 ────────────────────────────────────────────────
|
||||
MAPPINGS_URL=$(python3 - "$STORE/versions/$MCVER/$MCVER.json" <<'PY'
|
||||
mkdir -p "$OUT/classes" "$OUT/natives" "$WORK"
|
||||
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
|
||||
d=json.load(open(sys.argv[1]))
|
||||
print(d['downloads']['client_mappings']['url'])
|
||||
print(json.load(open(sys.argv[1]))['downloads']['client_mappings']['url'])
|
||||
PY
|
||||
)
|
||||
if [ ! -s client_mappings.txt ]; then
|
||||
echo "[1/4] 下载官方映射..."
|
||||
curl -sL -o client_mappings.txt "$MAPPINGS_URL"
|
||||
echo "[1/5] 下载官方映射..."
|
||||
curl -sL -o "$WORK/client_mappings.txt" "$URL"
|
||||
fi
|
||||
echo "[1/4] 映射: $(wc -l < client_mappings.txt) 行"
|
||||
echo "[1/5] 映射 $(wc -l < "$WORK/client_mappings.txt") 行"
|
||||
|
||||
# ── 2. ProGuard → SRG ─────────────────────────────────────────
|
||||
echo "[2/4] 转换映射格式..."
|
||||
python3 "$HERE/pg2srg.py" client_mappings.txt mappings.srg
|
||||
echo "[2/5] 转换映射格式..."
|
||||
python3 "$HERE/pg2srg.py" "$WORK/client_mappings.txt" "$WORK/mappings.srg"
|
||||
|
||||
# ── 3. 重映射 jar ─────────────────────────────────────────────
|
||||
if [ ! -f client-named.jar ] || [ "$CLIENT_JAR" -nt client-named.jar ]; then
|
||||
echo "[3/4] 重映射客户端 jar(约 1-2 分钟)..."
|
||||
# ── 3. 重映射客户端 jar ───────────────────────────────────────
|
||||
NAMED="$OUT/client-named.jar"
|
||||
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)
|
||||
[ -n "$SS" ] || { echo "找不到 SpecialSource"; exit 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
|
||||
[ -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
|
||||
[ -f joptsimple.jar ] || curl -sL -o joptsimple.jar \
|
||||
"https://repo1.maven.org/maven2/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"
|
||||
java -Xmx3G -cp "$SS:joptsimple.jar:asm.jar:asm-commons.jar:asm-tree.jar:$GUAVA" \
|
||||
[ -f "$WORK/joptsimple.jar" ] || curl -sL -o "$WORK/joptsimple.jar" \
|
||||
"https://repo1.maven.org/maven2/net/sf/jopt-simple/jopt-simple/5.0.4/joptsimple-5.0.4.jar"
|
||||
java -Xmx3G -cp "$SS:$WORK/joptsimple.jar:$WORK/asm.jar:$WORK/asm-commons.jar:$WORK/asm-tree.jar:$GUAVA" \
|
||||
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
|
||||
echo "[3/4] client-named.jar 已是最新"
|
||||
echo "[3/5] client-named.jar 已是最新"
|
||||
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 \
|
||||
| grep -vE "minecraftforge|neoforge" > cp.txt
|
||||
CP="$(paste -sd: cp.txt):$WORK/client-named.jar"
|
||||
| grep -vE "minecraftforge|neoforge" > "$OUT/classpath.txt"
|
||||
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 " named jar : $WORK/client-named.jar"
|
||||
echo " classes : $WORK/classes"
|
||||
echo "✅ 构建完成(产物在内容根,不是 /tmp)"
|
||||
echo " 内容根 : $STORE"
|
||||
echo " 产物 : $OUT"
|
||||
echo " named : $OUT/client-named.jar"
|
||||
echo " classes: $OUT/classes"
|
||||
echo
|
||||
echo "运行(无头,需 Xvfb):"
|
||||
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 \"$CP:$WORK/classes\" -Djava.library.path=$WORK/nativ RenderProbe"
|
||||
echo "运行(渲染服务从 stdin 读 NDJSON):"
|
||||
echo " DISPLAY=:98 java -cp \"\$(paste -sd: $OUT/classpath.txt):$NAMED:$OUT/classes\" \\"
|
||||
echo " -Djava.library.path=$OUT/natives McfoldRenderServer \\"
|
||||
echo " --gameDir <实例目录> --assets $STORE/assets --assetIndex $MCVER"
|
||||
echo
|
||||
echo "★ 实例目录必须是**实例独占**的那个(mods/config/options 的边界),"
|
||||
echo " 不要指向别的版本或别的加载器的实例 —— 那正是「多实例共用 mods」的崩因。"
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -41,7 +41,8 @@ def _open_backend():
|
||||
if not ok:
|
||||
return None, why
|
||||
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",
|
||||
)
|
||||
try:
|
||||
@ -184,6 +185,72 @@ def test_set_blocks_render():
|
||||
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():
|
||||
"""真实存档经新路径(RLE 路由)渲染 —— 最终验收。"""
|
||||
ok, why = _available()
|
||||
|
||||
Reference in New Issue
Block a user