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
|
[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
|
||||||
|
|||||||
Binary file not shown.
@ -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 [
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -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"})
|
||||||
|
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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
|
#!/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」的崩因。"
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
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:
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user