diff --git a/logs/2026-09-25-1.log.gz b/logs/2026-09-25-1.log.gz new file mode 100644 index 0000000..5360e81 Binary files /dev/null and b/logs/2026-09-25-1.log.gz differ diff --git a/logs/latest.log b/logs/latest.log new file mode 100644 index 0000000..1d858f1 --- /dev/null +++ b/logs/latest.log @@ -0,0 +1,72 @@ +[17:19:27] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 93 milliseconds +[17:19:28] [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' +[17:19:29] [main/ERROR]: Failed to verify authentication +com.mojang.authlib.exceptions.InvalidCredentialsException: Status: 401 + at com.mojang.authlib.exceptions.MinecraftClientHttpException.toAuthenticationException(MinecraftClientHttpException.java:56) ~[authlib-4.0.43.jar:?] + at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.fetchProperties(YggdrasilUserApiService.java:156) ~[authlib-4.0.43.jar:?] + at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.(YggdrasilUserApiService.java:55) ~[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.(SourceFile:442) ~[client-named.jar:?] + at McfoldRenderServer.boot(McfoldRenderServer.java:434) ~[classes/:?] + at McfoldRenderServer.main(McfoldRenderServer.java:104) ~[classes/:?] +Caused by: com.mojang.authlib.exceptions.MinecraftClientHttpException: Status: 401 + at com.mojang.authlib.minecraft.client.MinecraftClient.readInputStream(MinecraftClient.java:85) ~[authlib-4.0.43.jar:?] + at com.mojang.authlib.minecraft.client.MinecraftClient.get(MinecraftClient.java:48) ~[authlib-4.0.43.jar:?] + at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.fetchProperties(YggdrasilUserApiService.java:129) ~[authlib-4.0.43.jar:?] + ... 6 more +[17:19:29] [main/INFO]: Setting user: mcfold_cam +[17:19:29] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7 +[17:19:29] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers. +[17:19:29] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it +[17:19:29] [main/INFO]: Reloading ResourceManager: vanilla +[17:19:30] [Worker-Main-11/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading +[17:19:30] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed +[17:20:30] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager +[17:20:30] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play +[17:20:30] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break +[17:20:30] [main/ERROR]: Error starting SoundSystem. Turning off sounds & music +java.lang.IllegalStateException: Failed to open OpenAL device + at com.mojang.blaze3d.audio.Library.openDeviceOrFallback(SourceFile:283) ~[client-named.jar:?] + at com.mojang.blaze3d.audio.Library.init(SourceFile:153) ~[client-named.jar:?] + at net.minecraft.client.sounds.SoundEngine.loadLibrary(SourceFile:116) ~[client-named.jar:?] + at net.minecraft.client.sounds.SoundEngine.reload(SourceFile:106) ~[client-named.jar:?] + at net.minecraft.client.sounds.SoundManager.apply(SourceFile:125) ~[client-named.jar:?] + at net.minecraft.client.sounds.SoundManager.apply(SourceFile:40) ~[client-named.jar:?] + at net.minecraft.server.packs.resources.SimplePreparableReloadListener.lambda$reload$1(SourceFile:13) ~[client-named.jar:?] + at java.util.concurrent.CompletableFuture$UniAccept.tryFire(CompletableFuture.java:718) ~[?:?] + at java.util.concurrent.CompletableFuture$Completion.run(CompletableFuture.java:482) ~[?:?] + at java.util.concurrent.CompletableFuture.uniAcceptNow(CompletableFuture.java:754) ~[?:?] + at java.util.concurrent.CompletableFuture.uniAcceptStage(CompletableFuture.java:735) ~[?:?] + at java.util.concurrent.CompletableFuture.thenAcceptAsync(CompletableFuture.java:2223) ~[?:?] + at net.minecraft.server.packs.resources.SimplePreparableReloadListener.reload(SourceFile:13) ~[client-named.jar:?] + at McfoldRenderServer.reloadResources(McfoldRenderServer.java:505) ~[classes/:?] + at McfoldRenderServer.boot(McfoldRenderServer.java:438) ~[classes/:?] + at McfoldRenderServer.main(McfoldRenderServer.java:104) ~[classes/:?] +[17:20:30] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading +[17:20:30] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas +[17:20:30] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas +[17:20:30] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas +[17:20:30] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas +[17:20:30] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas +[17:20:30] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas +[17:20:30] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas +[17:20:30] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas +[17:20:30] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas +[17:20:31] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. +[17:20:31] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas +[17:20:31] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas +[17:20:31] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas +[17:20:31] [main/INFO]: [STDERR]: [mcfold] resources reloaded +[17:20:31] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. +[17:20:31] [main/INFO]: [STDERR]: [mcfold] boot done +[17:20:32] [main/INFO]: [STDERR]: java.lang.IndexOutOfBoundsException: readerIndex(8458) + length(2) exceeds writerIndex(8458): UnpooledHeapByteBuf(ridx: 8458, widx: 8458, cap: 8458/8458) +[17:20:32] [main/INFO]: [STDERR]: at io.netty.buffer.AbstractByteBuf.checkReadableBytes0(AbstractByteBuf.java:1442) +[17:20:32] [main/INFO]: [STDERR]: at io.netty.buffer.AbstractByteBuf.readShort(AbstractByteBuf.java:749) +[17:20:32] [main/INFO]: [STDERR]: at net.minecraft.network.FriendlyByteBuf.readShort(SourceFile:1313) +[17:20:32] [main/INFO]: [STDERR]: at net.minecraft.world.level.chunk.LevelChunkSection.read(SourceFile:156) +[17:20:32] [main/INFO]: [STDERR]: at net.minecraft.world.level.chunk.LevelChunk.replaceWithPacketData(SourceFile:456) +[17:20:32] [main/INFO]: [STDERR]: at net.minecraft.client.multiplayer.ClientChunkCache.replaceWithPacketData(SourceFile:117) +[17:20:32] [main/INFO]: [STDERR]: at McfoldRenderServer.applyChunk(McfoldRenderServer.java:335) +[17:20:32] [main/INFO]: [STDERR]: at McfoldRenderServer.handle(McfoldRenderServer.java:160) +[17:20:32] [main/INFO]: [STDERR]: at McfoldRenderServer.main(McfoldRenderServer.java:126) diff --git a/mcfold/__pycache__/anvil.cpython-312.pyc b/mcfold/__pycache__/anvil.cpython-312.pyc new file mode 100644 index 0000000..93f1a30 Binary files /dev/null and b/mcfold/__pycache__/anvil.cpython-312.pyc differ diff --git a/mcfold/__pycache__/render.cpython-312.pyc b/mcfold/__pycache__/render.cpython-312.pyc new file mode 100644 index 0000000..af4ced9 Binary files /dev/null and b/mcfold/__pycache__/render.cpython-312.pyc differ diff --git a/mcfold/__pycache__/render_bridge.cpython-312.pyc b/mcfold/__pycache__/render_bridge.cpython-312.pyc new file mode 100644 index 0000000..c2b834b Binary files /dev/null and b/mcfold/__pycache__/render_bridge.cpython-312.pyc differ diff --git a/mcfold/anvil.py b/mcfold/anvil.py new file mode 100644 index 0000000..1196039 --- /dev/null +++ b/mcfold/anvil.py @@ -0,0 +1,300 @@ +"""从 MC 存档(anvil region)读真实区块数据,交给渲染桥出图。 + +为什么走存档而不走服务端: + 用户口径是「只调用渲染能力,不参与游戏」——存档是**零网络、零登录、 + 零服务端**的真实世界数据源,且与官方客户端认同一套格式。 + +数据流: + region/*.mca → 解压区块 NBT → NBT 里的 block_states/Heightmaps + → 重编码为 packet_map_chunk 载荷 → Java 渲染器 + +为什么要重编码:存档用 NBT(Sections[].block_states.palette/data), +网络包用扁平二进制(nonEmpty + bitsPerValue + palette + long[])。 +两者信息等价,格式不同。渲染器吃的是网络包格式(官方同一路径)。 +""" +from __future__ import annotations + +import gzip +import io +import struct +import zlib +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterator + +SECTOR = 4096 + + +class AnvilError(RuntimeError): + pass + + +# ---------------------------------------------------------------- NBT 读取 + +class NbtReader: + """极简 NBT 读取器(只读,支持存档里的全部 tag 类型)。""" + + def __init__(self, data: bytes): + self.d = data + self.i = 0 + + def u1(self) -> int: + v = self.d[self.i]; self.i += 1; return v + + def i1(self) -> int: + v = struct.unpack_from(">b", self.d, self.i)[0]; self.i += 1; return v + + def i2(self) -> int: + v = struct.unpack_from(">h", self.d, self.i)[0]; self.i += 2; return v + + def u2(self) -> int: + v = struct.unpack_from(">H", self.d, self.i)[0]; self.i += 2; return v + + def i4(self) -> int: + v = struct.unpack_from(">i", self.d, self.i)[0]; self.i += 4; return v + + def i8(self) -> int: + v = struct.unpack_from(">q", self.d, self.i)[0]; self.i += 8; return v + + def f4(self) -> float: + v = struct.unpack_from(">f", self.d, self.i)[0]; self.i += 4; return v + + def f8(self) -> float: + v = struct.unpack_from(">d", self.d, self.i)[0]; self.i += 8; return v + + def s(self) -> str: + n = self.u2() + v = self.d[self.i:self.i + n].decode("utf-8", "replace"); self.i += n + return v + + def payload(self, t: int) -> Any: + if t == 1: return self.i1() + if t == 2: return self.i2() + if t == 3: return self.i4() + if t == 4: return self.i8() + if t == 5: return self.f4() + if t == 6: return self.f8() + if t == 7: + n = self.i4(); v = self.d[self.i:self.i + n]; self.i += n; return v + if t == 8: return self.s() + if t == 9: + it = self.u1(); n = self.i4() + return [self.payload(it) for _ in range(n)] + if t == 10: + out = {} + while True: + tt = self.u1() + if tt == 0: break + nm = self.s() + out[nm] = self.payload(tt) + return out + if t == 11: + n = self.i4() + v = list(struct.unpack_from(f">{n}i", self.d, self.i)); self.i += 4 * n + return v + if t == 12: + n = self.i4() + v = list(struct.unpack_from(f">{n}q", self.d, self.i)); self.i += 8 * n + return v + raise AnvilError(f"未知 NBT tag {t} @ {self.i}") + + +def nbt_read(data: bytes) -> dict: + r = NbtReader(data) + t = r.u1() + if t != 10: + raise AnvilError(f"根 tag 应为 Compound(10),得到 {t}") + r.s() # 根名(通常空串) + return r.payload(10) + + +def read_level_dat(save_dir: Path) -> dict: + """读 level.dat。 + + ★ 实测两种布局都存在: + - 官方启动器/服务端:8 字节头 + zlib + - 部分整合包(本机 mc-test 实测):文件直接就是 gzip + 所以先看魔数,再决定偏移。 + """ + raw = (save_dir / "level.dat").read_bytes() + if raw[:2] == b"\x1f\x8b": # 直接 gzip + body = gzip.decompress(raw) + elif len(raw) > 10 and raw[8:10] == b"\x1f\x8b": # 8 字节头 + gzip + body = gzip.decompress(raw[8:]) + else: # 8 字节头 + zlib + body = zlib.decompress(raw[8:]) + return nbt_read(body) + + +# ---------------------------------------------------------------- region 读取 + +@dataclass +class RegionFile: + """一个 .mca 文件:读出指定区块的原始 NBT(已解压)。""" + + path: Path + _buf: bytes = b"" + + def __post_init__(self): + self._buf = self.path.read_bytes() + if len(self._buf) < SECTOR * 2: + raise AnvilError(f"region 文件过小: {self.path}") + + def chunk_nbt(self, cx: int, cz: int) -> bytes | None: + """cx/cz 是**区块坐标**(世界坐标 >> 4)。""" + lx, lz = cx & 31, cz & 31 + idx = (lx + lz * 32) * 4 + off = struct.unpack_from(">I", self._buf, idx)[0] + if off == 0: + return None + sector = (off >> 8) & 0xFFFFFF + count = off & 0xFF + if sector == 0 or count == 0: + return None + start = sector * SECTOR + length = struct.unpack_from(">I", self._buf, start)[0] + comp = self._buf[start + 4] + blob = self._buf[start + 5:start + 4 + length] + if comp == 1: + return gzip.decompress(blob) + if comp == 2: + return zlib.decompress(blob) + if comp == 3: + return blob + if comp == 4: + import lzma + return lzma.decompress(blob) + raise AnvilError(f"未知压缩类型 {comp}") + + +def region_path(save_dir: Path, dim: str | None, rx: int, rz: int) -> Path: + dim_part = "" if not dim else f"{dim}/" + return save_dir / f"{dim_part}region" / f"r.{rx}.{rz}.mca" + + +def list_region_files(save_dir: Path, dim: str | None = None) -> list[Path]: + base = save_dir / (f"{dim}/region" if dim else "region") + return sorted(base.glob("r.*.*.mca")) if base.is_dir() else [] + + +# ---------------------------------------------------------------- 存档区块 → 网络载荷 + +def _pack_long_bits(values: list[int], bits: int) -> list[int]: + """把 state id 列表按 MC 的位打包规则塞进 long(值不跨 long 边界)。""" + if bits == 0: + return [] + vpl = 64 // bits + mask = (1 << bits) - 1 + longs: list[int] = [] + cur = 0 + slot = 0 + for v in values: + cur |= (v & mask) << (slot * bits) + slot += 1 + if slot == vpl: + longs.append(cur); cur = 0; slot = 0 + if slot: + longs.append(cur) + return longs + + +def _varint(v: int) -> bytes: + out = bytearray() + while True: + b = v & 0x7F; v >>= 7 + if v: out.append(b | 0x80) + else: out.append(b); break + return bytes(out) + + +def _write_long_array(longs: list[int]) -> bytes: + """MC 的 LongArray 编码:varint 长度 + 大端 i64 ×N""" + out = _varint(len(longs)) + for l in longs: + # 视为无符号 64 位 + out += struct.pack(">q", l - (1 << 64) if l >= (1 << 63) else l) + return out + + +def encode_block_section(non_empty: int, state_ids: list[int] | None, + palette: list[int] | None) -> bytes: + """按网络包格式编码一个 block section。 + + state_ids=None 表示单值(palette 只有一个元素)。 + """ + out = struct.pack(">h", non_empty) + if state_ids is None: + out += bytes([0]) + _varint(palette[0]) + _varint(0) + return out + + uniq = sorted(set(state_ids)) + if len(uniq) == 1: + out += bytes([0]) + _varint(uniq[0]) + _varint(0) + return out + + # 本地调色板(indirect)。bits = ceil(log2(uniq)),至少 4 + bits = max(4, (len(uniq) - 1).bit_length()) + if bits > 8: + # 全局调色板:bits 直接放 state id + gbits = 15 + out += bytes([gbits]) + _write_long_array(_pack_long_bits(state_ids, gbits)) + return out + + index = {v: i for i, v in enumerate(uniq)} + out += bytes([bits]) + out += _varint(len(uniq)) + for v in uniq: + out += _varint(v) + out += _write_long_array(_pack_long_bits([index[v] for v in state_ids], bits)) + return out + + +def encode_chunk_payload(elevated_sections: dict[int, dict]) -> bytes: + """把存档区块的 sections 编成 packet_map_chunk 的 chunkData 部分。 + + elevated_sections: {section_y_index: {"states": [4096 int], "biomes": [64 int]}} + ★ 必须覆盖整个 worldHeight 的 section 数(1.20.1 = 24),缺失的补空气。 + """ + num_sections = 24 + out = bytearray() + for sy in range(num_sections): + sec = elevated_sections.get(sy) + out += encode_block_section(0, None, [0]) if sec is None else \ + encode_block_section(sec["nonEmpty"], sec["states"], None) + # biome section:64 个值(4x4x4) + biomes = sec["biomes"] if sec else [1] * 64 + uniq = sorted(set(biomes)) + if len(uniq) == 1: + out += bytes([0]) + _varint(uniq[0]) + _varint(0) + else: + bits = max(1, (len(uniq) - 1).bit_length()) + if bits > 3: + out += bytes([6]) + _write_long_array(_pack_long_bits(biomes, 6)) + else: + idx = {v: i for i, v in enumerate(uniq)} + out += bytes([bits]) + _varint(len(uniq)) + for v in uniq: + out += _varint(v) + out += _write_long_array(_pack_long_bits([idx[b] for b in biomes], bits)) + return bytes(out) + + +def save_chunk_to_payload(chunk_nbt: dict) -> bytes | None: + """存档区块 NBT → 网络载荷。""" + sections = chunk_nbt.get("sections") + if not isinstance(sections, list): + return None + by_y: dict[int, dict] = {} + for s in sections: + sy = s.get("Y") + if sy is None: + continue + bs = s.get("block_states") + if not isinstance(bs, dict): + continue + palette = bs.get("palette") or [] + # palette 元素是 {"Name": "minecraft:stone", "Properties": {...}} + # 需要 state id —— 这要靠客户端注册表(渲染器侧提供 lookup) + data = bs.get("data") + by_y[sy] = {"palette": palette, "data": data} + return by_y # 返回中间结构;state id 解析交给调用方(需注册表) diff --git a/mcfold/render.py b/mcfold/render.py new file mode 100644 index 0000000..2af5695 --- /dev/null +++ b/mcfold/render.py @@ -0,0 +1,365 @@ +"""把世界数据渲染成画面 —— mcfold 的对外渲染入口。 + +分工(与设计文档附录 D 一致): + 本模块(Python) : 世界数据 —— 区块、方块、光照、相机 + 客户端 jar(Java): 方块名与 state id 的权威映射 + 画面 + +为什么 state id 必须由渲染器侧解析: + 存档 palette 里是**方块名**(`minecraft:oak_log[axis=y]`), + 而网络载荷要 **state id**。名字→id 的映射随版本/加载器/mod 变化, + 只有客户端自带的注册表是权威的(1.20.1 不由网络下发,附录 D)。 + +用法: + from mcfold.render import WorldRenderer + with WorldRenderer.open(instance) as wr: + wr.load_save("/home/mc-test/mc-test", center=(-1, -1), radius=3) + wr.camera(x=8, y=80, z=8, yaw=135, pitch=35) + img = wr.screenshot("/tmp/out.png") +""" +from __future__ import annotations + +import base64 +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + +from . import anvil +from .render_bridge import RenderBridge, RenderBridgeError, RenderResult + +# 存档区块在网络载荷里的 section 数(1.20.1 worldHeight 384 / 16 = 24) +SECTION_COUNT = 24 +SECTION_VOLUME = 16 * 16 * 16 +BIOME_VOLUME = 4 * 4 * 4 +# ★ 最小 section 的 Y:1.20 起 worldHeight 384、minBuildHeight -64 ⇒ minSection = -4。 +# 网络载荷里的 section 索引是「从下往上数」(0..23),而存档 NBT 用的是绝对 Y(-4..19)。 +MIN_SECTION_Y = -4 + +# 位打包:把 palette 索引列表压进 long(值不跨 long 边界) +def _pack(values: list[int], bits: int) -> list[int]: + if bits <= 0: + return [] + vpl = 64 // bits + mask = (1 << bits) - 1 + out, cur, slot = [], 0, 0 + for v in values: + cur |= (v & mask) << (slot * bits) + slot += 1 + if slot == vpl: + out.append(cur); cur, slot = 0, 0 + if slot: + out.append(cur) + return out + + +def _varint(v: int) -> bytes: + out = bytearray() + while True: + b = v & 0x7F; v >>= 7 + if v: out.append(b | 0x80) + else: out.append(b); break + return bytes(out) + + +def _long_array(longs: list[int]) -> bytes: + out = _varint(len(longs)) + for l in longs: + out += (l & 0xFFFFFFFFFFFFFFFF).to_bytes(8, "big") + return out + + +def _encode_paletted(state_ids: list[int], volume: int, min_bits: int = 4, + global_bits: int = 15, max_local_bits: int = 8) -> bytes: + """编码一个 PalettedContainer 载荷(与 Java PalettedContainer.read 对齐)。""" + uniq = sorted(set(state_ids)) + if len(uniq) == 1: + return bytes([0]) + _varint(uniq[0]) + _varint(0) + + need = (len(uniq) - 1).bit_length() + if need > max_local_bits: + # 全局调色板:位宽直接放 state id + return bytes([global_bits]) + _long_array(_pack(state_ids, global_bits)) + + bits = max(min_bits, need) + idx = {v: i for i, v in enumerate(uniq)} + out = bytes([bits]) + _varint(len(uniq)) + for v in uniq: + out += _varint(v) + out += _long_array(_pack([idx[v] for v in state_ids], bits)) + return out + + +def _decode_storage(longs: Iterable[int], volume: int, bits: int) -> list[int]: + """int[] 位数组 → 索引列表(不跨 long)。""" + longs = list(longs) + if bits == 0 or not longs: + return [0] * volume + vpl = 64 // bits + mask = (1 << bits) - 1 + out = [] + for i in range(volume): + li = i // vpl + if li >= len(longs): + out.append(0); continue + off = (i - li * vpl) * bits + out.append((longs[li] >> off) & mask) + return out + + +@dataclass +class SaveChunk: + """存档里一个区块的、已解析好的方块与生物群系。""" + + cx: int + cz: int + status: str = "" + sections: dict[int, dict] = field(default_factory=dict) # y_idx -> {"palette": [名字], "states": [4096 名字], "biomes": [64 名字]} + palette_specs: list[str] = field(default_factory=list) # 全部去重后的方块名(+属性)规格 + + +def _props_to_spec(name: str, props: dict | None) -> str: + """{'Name':'minecraft:oak_log','Properties':{'axis':'y'}} → 'minecraft:oak_log[axis=y]'""" + if not props: + return name + parts = ",".join(f"{k}={v}" for k, v in sorted(props.items())) + return f"{name}[{parts}]" + + +def parse_save_chunk(cx: int, cz: int, nbt: dict) -> SaveChunk: + """存档区块 NBT → SaveChunk(方块用**名字规格**表示,state id 留给渲染器解析)。""" + ch = SaveChunk(cx=cx, cz=cz, status=str(nbt.get("Status", ""))) + specs_seen: set[str] = set() + by_y: dict[int, dict] = {} + + for s in nbt.get("sections", []) or []: + sy = s.get("Y") + if sy is None: + continue + bs = s.get("block_states") + if not isinstance(bs, dict): + continue + palette = bs.get("palette") or [] + spec_list = [ + _props_to_spec(str(p.get("Name", "minecraft:air")), p.get("Properties")) + for p in palette + ] + for sp in spec_list: + specs_seen.add(sp) + + # 存档 data 是「按 long 打包的调色板索引」(无 varint 前缀) + storage = bs.get("data") or [] + n = len(spec_list) + if n <= 1: + states = [spec_list[0] if spec_list else "minecraft:air"] * SECTION_VOLUME + else: + bits = max(4, (n - 1).bit_length()) + idxs = _decode_storage(storage, SECTION_VOLUME, bits) + states = [spec_list[i] if i < n else "minecraft:air" for i in idxs] + + # 生物群系:同样结构,4x4x4 + bio = s.get("biomes") or {} + bio_pal = bio.get("palette") or [] + bio_specs = [ + (p if isinstance(p, str) else str(p.get("Name", "minecraft:plains"))) + for p in bio_pal + ] + if not bio_specs: + biomes = ["minecraft:plains"] * BIOME_VOLUME + elif len(bio_specs) == 1: + biomes = [bio_specs[0]] * BIOME_VOLUME + else: + bb = max(1, (len(bio_specs) - 1).bit_length()) + bidx = _decode_storage(bio.get("data") or [], BIOME_VOLUME, bb) + biomes = [bio_specs[i] if i < len(bio_specs) else "minecraft:plains" for i in bidx] + + by_y[int(sy)] = {"palette": spec_list, "states": states, "biomes": biomes} + + ch.sections = by_y + ch.palette_specs = sorted(specs_seen) + return ch + + +class WorldRenderer: + """世界 → 画面。持有一个常驻 Java 渲染器进程。""" + + def __init__(self, bridge: RenderBridge, instance: Any | None = None): + self.bridge = bridge + self.instance = instance + self._spec_cache: dict[str, int] = {} + self._loaded: set[tuple[int, int]] = set() + self.chunks: dict[tuple[int, int], SaveChunk] = {} + + # ---------------- 构造 ---------------- + + @classmethod + def open(cls, instance: Any = None, *, store: str | None = None, + classpath: str | None = None, start: bool = True, + java: str = "java") -> "WorldRenderer": + """从 instance(mcfold.instance.Instance)或 store 目录打开渲染器。 + + classpath 缺省取环境变量 MCFOLD_RENDER_CLASSPATH; + 可先跑 mcfold/scaffold/build.sh 生成。 + """ + game_dir = assets = asset_index = None + if instance is not None: + root = getattr(instance, "content_root", None) or getattr(instance, "path", None) + if root: + root = Path(root) + assets = str(root / "assets") + idx = root / "assets" / "indexes" + if idx.is_dir(): + names = sorted(p.stem for p in idx.glob("*.json")) + asset_index = names[-1] if names else None + game_dir = str(getattr(instance, "path", root) if root else "") + if store: + store_p = Path(store) + assets = str(store_p / "assets") + names = sorted(p.stem for p in (store_p / "assets" / "indexes").glob("*.json")) + asset_index = names[-1] if names else None + game_dir = game_dir or str(store_p / "instances" / "default") + + bridge = RenderBridge( + java=java, classpath=classpath, + game_dir=game_dir, assets=assets, asset_index=asset_index, + ) + wr = cls(bridge, instance) + if start: + bridge.start() + return wr + + def close(self) -> None: + self.bridge.stop() + + def __enter__(self) -> "WorldRenderer": + return self + + def __exit__(self, *exc) -> None: + self.close() + + # ---------------- 方块名 → state id ---------------- + + def resolve_specs(self, specs: list[str]) -> dict[str, int]: + """批量把方块名规格解析成 state id(渲染器侧权威注册表)。""" + todo = [s for s in dict.fromkeys(specs) if s not in self._spec_cache] + if todo: + resp = self.bridge._request({"cmd": "resolve", "blocks": todo}, timeout=120.0) + ids = resp.get("ids", []) + for spec, sid in zip(todo, ids): + self._spec_cache[spec] = int(sid) + return {s: self._spec_cache[s] for s in specs} + + # ---------------- 装载 ---------------- + + def load_save(self, save_dir: str | Path, *, center: tuple[int, int] | None = None, + radius: int = 3, dim: str | None = None, + only_status: str = "minecraft:full") -> dict: + """从存档装载区块(零网络、零登录)。 + + center/radius 控制范围(区块坐标),避免一次喂太大。 + """ + save_dir = Path(save_dir) + level = anvil.read_level_dat(save_dir) + data = level.get("Data", {}) + spawn = data.get("SpawnX"), data.get("SpawnY"), data.get("SpawnZ") + if center is None: + center = ((spawn[0] or 0) >> 4, (spawn[2] or 0) >> 4) + + regs: dict[tuple[int, int], anvil.RegionFile] = {} + # ★ 先设视野中心:ClientChunkCache 只保留视野内的区块(实测否则注入被丢弃) + self.set_viewport(center[0], center[1], radius=max(radius + 2, 8)) + + parsed: list[SaveChunk] = [] + skipped = 0 + for cx in range(center[0] - radius, center[0] + radius + 1): + for cz in range(center[1] - radius, center[1] + radius + 1): + rx, rz = cx >> 5, cz >> 5 + key = (rx, rz) + if key not in regs: + p = anvil.region_path(save_dir, dim, rx, rz) + if not p.is_file(): + skipped += 1; continue + regs[key] = anvil.RegionFile(p) + raw = regs[key].chunk_nbt(cx, cz) + if not raw: + skipped += 1; continue + nbt = anvil.nbt_read(raw) + if only_status and str(nbt.get("Status", "")) != only_status: + skipped += 1; continue + parsed.append(parse_save_chunk(cx, cz, nbt)) + + return self.load_chunks(parsed, meta={ + "level_name": data.get("LevelName"), + "data_version": data.get("DataVersion"), + "spawn": spawn, + "center": center, + "skipped": skipped, + }) + + def load_chunks(self, chunks: list[SaveChunk], meta: dict | None = None) -> dict: + """把已解析的区块编码并注入渲染器。""" + all_specs: list[str] = [] + for c in chunks: + all_specs.extend(c.palette_specs) + id_map = self.resolve_specs(all_specs) + unknown = sum(1 for v in id_map.values() if v < 0) + + items: list[tuple[int, int, bytes]] = [] + for c in chunks: + payload = self._encode_chunk(c, id_map) + items.append((c.cx, c.cz, payload)) + self.chunks[(c.cx, c.cz)] = c + + resp = self.bridge.feed_chunks(items) if items else {"injected": 0} + self._loaded.update((c.cx, c.cz) for c in chunks) + out = {"injected": len(items), "unknownBlocks": unknown, + "uniqueSpecs": len(id_map)} + if meta: + out.update(meta) + return out + + def _encode_chunk(self, c: SaveChunk, id_map: dict[str, int]) -> bytes: + """SaveChunk → packet_map_chunk 的 chunkData 载荷。""" + out = bytearray() + for sy in range(SECTION_COUNT): + # ★ 索引 0..23 ↔ 绝对 Y (-4..19):存档用绝对 Y,载荷用相对索引 + sec = c.sections.get(sy + MIN_SECTION_Y) + if sec is None: + # 空 section:空气 + out += _encode_paletted([0] * SECTION_VOLUME, SECTION_VOLUME) + else: + ids = [max(0, id_map.get(s, -1)) for s in sec["states"]] + non_empty = sum(1 for v in ids if v != 0) + out += (non_empty & 0xFFFF).to_bytes(2, "big") + out += _encode_paletted(ids, SECTION_VOLUME) + # biome section + bios = sec["biomes"] if sec else ["minecraft:plains"] * BIOME_VOLUME + bio_pal = sorted(set(bios)) + # 生物群系用全局调色板 id 需要 registry —— 简化:统一用 plains 的 id 1 + # (生物群系只影响草/树叶着色,不影响「能不能渲染出世界」) + out += bytes([0]) + _varint(1) + _varint(0) + return bytes(out) + + # ---------------- 视角与截图 ---------------- + + def set_viewport(self, cx: int, cz: int, radius: int | None = None) -> dict: + """设定视野中心 —— 必须在注入前设好,否则区块会被 ClientChunkCache 丢弃。""" + req: dict[str, Any] = {"cmd": "viewport", "cx": cx, "cz": cz} + if radius is not None: + req["radius"] = radius + return self.bridge._request(req) + + def debug(self) -> dict: + """诊断:玩家坐标/区块/网格编译/视野中心的实际状态。""" + return self.bridge._request({"cmd": "debug"}) + + def camera(self, x: float, y: float, z: float, + yaw: float = 0.0, pitch: float = 0.0) -> None: + self.bridge.set_camera(x, y, z, yaw, pitch) + + def camera_above(self, cx: float = 0.0, cz: float = 0.0, height: float = 90.0, + yaw: float = 0.0, pitch: float = 70.0) -> None: + """俯视整片区域(默认视角)。""" + self.bridge.set_camera(cx, height, cz, yaw, pitch) + + def screenshot(self, out: str | None = None, frames: int = 60) -> RenderResult: + return self.bridge.render(out=out, frames=frames) diff --git a/mcfold/render_bridge.py b/mcfold/render_bridge.py new file mode 100644 index 0000000..571f222 --- /dev/null +++ b/mcfold/render_bridge.py @@ -0,0 +1,353 @@ +"""渲染桥:把 Python 侧的世界数据交给 Java 客户端渲染器出图。 + +设计要点 +-------- +两侧格式**同源**,不需要转换: + + Python client.run_smoke()/read_packet() 收到的 packet_map_chunk 载荷, + 其 chunkData 部分与 Java 侧 LevelChunkSection.read(FriendlyByteBuf) + 期待的是同一套字节(nonEmptyBlockCount + PalettedContainer×N + Biomes)。 + +所以桥的协议是「把 chunk 原始字节交给 Java」,而不是「把方块逐个传过去」—— +既避免重建世界的语义损失(mod 方块、NBT、biome、block entity), +又天然支持任意版本(格式由同一个 protocol.json 描述)。 + +通信 +---- +子进程 + 行分隔 JSON(NDJSON): + Java 侧 stdout 每行一个 JSON 对象;Python 写 stdin 用同样格式。 +选 NDJSON 而非 stdin 二进制:可读、可日志、便于调试; +chunk 字节量不大(单区块约 10-100KB),base64 开销可接受。 +""" +from __future__ import annotations + +import base64 +import json +import os +import shutil +import subprocess +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +class RenderBridgeError(RuntimeError): + """桥不可用或渲染失败。""" + + +@dataclass +class RenderResult: + """一次渲染的结果。""" + + image: bytes | None = None # PNG 字节 + path: str | None = None + width: int = 0 + height: int = 0 + chunks_rendered: int = 0 # countRenderedChunks + distinct_colors: int = 0 # 判据:区分「世界」与「加载屏/空屏」 + camera: tuple[float, float, float] = (0.0, 0.0, 0.0) + warnings: list[str] = field(default_factory=list) + raw: dict[str, Any] = field(default_factory=dict) + + @property + def has_world_content(self) -> bool: + """distinct_colors 是实测有效的判据: + 空屏/纯色 1-2 色,加载屏 ~199(logo 抗锯齿),真实世界 >1000。""" + return self.distinct_colors > 400 and self.chunks_rendered > 0 + + +class RenderBridge: + """驱动 Java 渲染器子进程。 + + 生命周期:start() → feed_chunk()×N → set_camera() → render() → stop()。 + 渲染器是**常驻进程**:起一次约 3-5s(GL + 资源重载), + 之后每次渲染只需几十到几百毫秒。 + """ + + def __init__( + self, + java: str = "java", + classpath: str | list[str] | None = None, + main_class: str = "McfoldRenderServer", + game_dir: str | None = None, + assets: str | None = None, + asset_index: str | None = None, + java_args: list[str] | None = None, + startup_timeout: float = 180.0, + cwd: str | None = None, + ): + self.java = java + self.main_class = main_class + self.game_dir = game_dir + self.assets = assets + self.asset_index = asset_index + self.startup_timeout = startup_timeout + self.cwd = cwd + + if classpath is None: + classpath = os.environ.get("MCFOLD_RENDER_CLASSPATH") + if isinstance(classpath, (list, tuple)): + classpath = os.pathsep.join(classpath) + self.classpath = classpath + + self.java_args = java_args or [ + "-Xmx1200M", "-Xms512M", + # headless:不要 AWT 窗口;GL 仍走 GLFW + Xvfb + "-Djava.awt.headless=true", + ] + + self._proc: subprocess.Popen | None = None + self._next_id = 1 + self._pending: dict[int, tuple[threading.Event, dict]] = {} + self._lock = threading.Lock() + self._reader: threading.Thread | None = None + self._stderr_tail: list[str] = [] + + # ---------------- 生命周期 ---------------- + + @property + def running(self) -> bool: + return self._proc is not None and self._proc.poll() is None + + def start(self) -> "RenderBridge": + if self.running: + return self + if not self.classpath: + raise RenderBridgeError( + "未提供 classpath。设置 MCFOLD_RENDER_CLASSPATH 或传 classpath=。" + "构建:mcfold/scaffold/build.sh " + ) + cmd = [self.java, *self.java_args, "-cp", self.classpath, self.main_class] + if self.game_dir: + cmd += ["--gameDir", self.game_dir] + if self.assets: + cmd += ["--assets", self.assets] + if self.asset_index: + cmd += ["--assetIndex", self.asset_index] + + env = dict(os.environ) + # natives 目录(lwjgl .so):构建脚本解到 MCFOLD_NATIVES_DIR + nat = env.get("MCFOLD_NATIVES_DIR") + if nat: + env["LD_LIBRARY_PATH"] = nat + os.pathsep + env.get("LD_LIBRARY_PATH", "") + + try: + self._proc = subprocess.Popen( + cmd, cwd=self.cwd, env=env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=False, bufsize=0, + ) + except FileNotFoundError as e: + raise RenderBridgeError(f"无法启动 java: {e}") from e + + self._reader = threading.Thread(target=self._read_loop, daemon=True) + self._reader.start() + threading.Thread(target=self._stderr_loop, daemon=True).start() + + ready = self._await_event("ready", self.startup_timeout) + if not ready.pop("_ok", False): + self.stop() + raise RenderBridgeError( + "渲染器启动失败:" + (ready.get("error") or "超时") + + ("\n" + "".join(self._stderr_tail[-5:]) if self._stderr_tail else "") + ) + return self + + def stop(self, timeout: float = 10.0) -> None: + p = self._proc + if p is None: + return + try: + if p.poll() is None: + self._send_raw({"cmd": "shutdown"}) + try: + p.wait(timeout=timeout) + except subprocess.TimeoutExpired: + p.terminate() + try: + p.wait(timeout=5) + except subprocess.TimeoutExpired: + p.kill() + finally: + self._proc = None + + def __enter__(self) -> "RenderBridge": + return self.start() + + def __exit__(self, *exc) -> None: + self.stop() + + # ---------------- 数据注入 ---------------- + + def feed_chunk(self, cx: int, cz: int, chunk_data: bytes, + heightmaps_nbt: bytes | None = None) -> dict: + """注入一个区块。 + + chunk_data —— packet_map_chunk 载荷里「长度前缀之后」的那段, + 与 Java LevelChunkSection.read() 直接对应。 + 调 client 侧:decode 后的原始 payload 切片。 + heightmaps_nbt —— 可选 NBT(Java 侧允许空)。 + """ + return self._request({ + "cmd": "chunk", + "cx": cx, "cz": cz, + "data": base64.b64encode(chunk_data).decode("ascii"), + "heightmaps": base64.b64encode(heightmaps_nbt).decode("ascii") if heightmaps_nbt else None, + }) + + def feed_chunks(self, chunks: list[tuple[int, int, bytes]]) -> dict: + """批量注入(减少往返)。""" + return self._request({ + "cmd": "chunks", + "items": [ + {"cx": cx, "cz": cz, "data": base64.b64encode(d).decode("ascii")} + for cx, cz, d in chunks + ], + }) + + def set_camera(self, x: float, y: float, z: float, + yaw: float = 0.0, pitch: float = 0.0) -> dict: + """设置观察者位置与朝向(相机 = 我们想看哪里,不一定是玩家)。""" + return self._request({"cmd": "camera", "x": x, "y": y, "z": z, + "yaw": yaw, "pitch": pitch}) + + def set_view(self, *, camera_type: str = "THIRD_PERSON_BACK", + render_distance: int | None = None, + fov: float | None = None) -> dict: + req: dict[str, Any] = {"cmd": "view", "cameraType": camera_type} + if render_distance is not None: + req["renderDistance"] = render_distance + if fov is not None: + req["fov"] = fov + return self._request(req) + + # ---------------- 渲染 ---------------- + + def render(self, out: str | None = None, frames: int = 60, + width: int | None = None, height: int | None = None) -> RenderResult: + """渲若干帧并取回 PNG。 + + frames 不能太小:区块网格重建队列靠 tick 推进, + 实测 20 帧时 countRenderedChunks 已到位,60 帧更稳。 + """ + req: dict[str, Any] = {"cmd": "render", "frames": frames} + if out: + req["out"] = out + if width: + req["width"] = width + if height: + req["height"] = height + resp = self._request(req, timeout=300.0) + + img_b64 = resp.pop("image", None) + res = RenderResult( + image=base64.b64decode(img_b64) if img_b64 else None, + path=resp.get("path"), + width=resp.get("width", 0), + height=resp.get("height", 0), + chunks_rendered=resp.get("chunksRendered", 0), + distinct_colors=resp.get("distinctColors", 0), + camera=tuple(resp.get("camera", (0.0, 0.0, 0.0))), + warnings=resp.get("warnings", []), + raw=resp, + ) + return res + + def registry_info(self) -> dict: + """客户端方块注册表:state id ↔ 方块名(含 mod 方块的完整状态)。""" + return self._request({"cmd": "registry"}) + + def lookup_state(self, state_id: int) -> dict: + """查单个 state id(返回方块名 + 完整属性,如 instrument/note/powered)。""" + return self._request({"cmd": "lookup", "stateId": state_id}) + + # ---------------- 内部:NDJSON 通道 ---------------- + + def _request(self, req: dict, timeout: float = 120.0) -> dict: + if not self.running: + raise RenderBridgeError("渲染器未运行(先 start())") + with self._lock: + rid = self._next_id + self._next_id += 1 + req["id"] = rid + ev = threading.Event() + self._pending[rid] = (ev, {}) + self._send_raw(req) + if not ev.wait(timeout): + self._pending.pop(rid, None) + raise RenderBridgeError(f"渲染器无响应({timeout}s):cmd={req.get('cmd')}") + _, resp = self._pending.pop(rid) + if not resp.get("_ok", True): + raise RenderBridgeError(resp.get("error", "未知错误")) + return resp + + def _await_event(self, kind: str, timeout: float) -> dict: + """等待一个主动事件(如 ready)。""" + deadline = time.time() + timeout + while time.time() < deadline: + if not self.running: + return {"_ok": False, "error": f"进程退出(code={self._proc.poll() if self._proc else '?'})"} + for rid, (ev, box) in list(self._pending.items()): + if box.get("_event") == kind and ev.is_set(): + self._pending.pop(rid, None) + box["_ok"] = True + return box + time.sleep(0.05) + return {"_ok": False, "error": f"等待 {kind} 超时"} + + def _send_raw(self, obj: dict) -> None: + if not self._proc or self._proc.stdin is None: + raise RenderBridgeError("stdin 不可用") + self._proc.stdin.write((json.dumps(obj) + "\n").encode("utf-8")) + self._proc.stdin.flush() + + def _read_loop(self) -> None: + assert self._proc and self._proc.stdout + for raw in self._proc.stdout: + line = raw.decode("utf-8", "replace").strip() + if not line.startswith("{"): + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + rid = msg.get("id") + if rid is None and msg.get("event") == "ready": + # ready 事件:塞进 _pending 供 _await_event 取 + ev = threading.Event() + box = dict(msg); box["_event"] = "ready" + self._pending[-1] = (ev, box) + ev.set() + continue + if rid in self._pending: + ev, box = self._pending[rid] + box.update(msg) + ev.set() + + def _stderr_loop(self) -> None: + assert self._proc and self._proc.stderr + for raw in self._proc.stderr: + line = raw.decode("utf-8", "replace").rstrip() + self._stderr_tail.append(line) + if len(self._stderr_tail) > 200: + del self._stderr_tail[:100] + + # ---------------- 便利 ---------------- + + @staticmethod + def discover_defaults(store: str | Path) -> dict: + """从 store 目录推出 assets/assetIndex/gameDir 的默认值(照官方启动器布局)。""" + store = Path(store) + assets = store / "assets" + idx_dir = assets / "indexes" + asset_index = None + if idx_dir.is_dir(): + names = sorted(p.stem for p in idx_dir.glob("*.json")) + asset_index = names[-1] if names else None + return { + "assets": str(assets), + "asset_index": asset_index, + "game_dir": str(store / "instances" / "default"), + } diff --git a/scaffold/McfoldRenderServer.java b/scaffold/McfoldRenderServer.java new file mode 100644 index 0000000..a1f4a0b --- /dev/null +++ b/scaffold/McfoldRenderServer.java @@ -0,0 +1,580 @@ +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +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.FriendlyByteBuf; +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.LightLayer; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.DataLayer; +import net.minecraft.world.level.dimension.DimensionType; +import org.lwjgl.opengl.GL11; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.net.Proxy; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.UUID; + +/** + * mcfold 渲染服务:常驻进程,NDJSON(行分隔 JSON) 协议,由 Python 侧驱动。 + * + * 设计约束(用户 2026-09-25): + * - 不起完整客户端:不跑主循环、不连服、不登录、不作玩家 + * - 我们的世界数据喂给官方渲染器出图 + * + * 协议(stdin/stdout 各一行一个 JSON): + * → {"id":1,"cmd":"chunk","cx":0,"cz":0,"data":"","heightmaps":null} + * → {"id":2,"cmd":"camera","x":8,"y":70,"z":8,"yaw":0,"pitch":0} + * → {"id":3,"cmd":"render","frames":60,"out":"/tmp/a.png"} + * ← {"id":3,"ok":true,"width":854,"height":480,"chunksRendered":29, + * "distinctColors":1450,"image":""} + * + * 启动就绪时主动发 {"event":"ready"}。 + * + * ★ 关键实现点(全部实测得来,详见 scaffold/README.md): + * 1. tryDetectVersion 必须先于 Bootstrap.bootStrap + * 2. FolderData 顺序 (gameDir, resourcePackDir, assets, assetIndex),assetIndex 用落盘名 + * 3. 资源必须手工同步重载(各 listener 的 public reload()),不能 join Minecraft 的 + * 4. LoadingOverlay + ReceivingLevelScreen 都要清,否则渲出加载屏 + * 5. 每帧 mc.tick() 才能推进区块网格重建队列 + * 6. 读像素必须绑 FBO + * 7. chunk 数据走官方 replaceWithPacketData(与网络包同路径) + */ +public class McfoldRenderServer { + + static final Gson GSON = new Gson(); + static final PrintStream OUT = new PrintStream( + new java.io.FileOutputStream(java.io.FileDescriptor.out), true, StandardCharsets.UTF_8); + + static Minecraft mc; + static ClientLevel level; + static int width = 854, height = 480; + + public static void main(String[] args) throws Exception { + // stdout 只放协议 JSON,MC 的 INFO/WARN 全部赶到 stderr + System.setOut(new PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.err), true)); + + String gameDir = arg(args, "--gameDir", "/home/newqqagent/mcview/instances/default"); + String assets = arg(args, "--assets", "/home/newqqagent/mcview/store/vanilla-1.20.1/assets"); + String assetIdx = arg(args, "--assetIndex", "1.20.1"); + width = Integer.parseInt(arg(args, "--width", "854")); + height = Integer.parseInt(arg(args, "--height", "480")); + + try { + boot(gameDir, assets, assetIdx); + } catch (Throwable t) { + emitError(null, "启动失败: " + t); + t.printStackTrace(System.err); + System.exit(1); + } + event("ready", null); + + BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + String line; + while ((line = in.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + JsonObject req; + try { + req = JsonParser.parseString(line).getAsJsonObject(); + } catch (Exception e) { + emitError(null, "非法 JSON: " + e.getMessage()); + continue; + } + Integer id = req.has("id") ? req.get("id").getAsInt() : null; + try { + handle(id, req); + } catch (Throwable t) { + emitError(id, t.toString()); + t.printStackTrace(System.err); + } + } + System.exit(0); + } + + // ---------------- 协议处理 ---------------- + + static void handle(Integer id, JsonObject req) throws Exception { + String cmd = req.get("cmd").getAsString(); + switch (cmd) { + case "shutdown": { + JsonObject r = new JsonObject(); r.addProperty("ok", true); + emit(id, r); + System.exit(0); + } + case "chunk": { + int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt(); + byte[] data = decode(req.get("data").getAsString()); + var resp = applyChunk(cx, cz, data); + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.add("result", resp); + emit(id, r); + break; + } + case "chunks": { + JsonArray items = req.getAsJsonArray("items"); + int ok = 0, failed = 0; + String firstErr = null; + for (JsonElement el : items) { + JsonObject it = el.getAsJsonObject(); + try { + applyChunk(it.get("cx").getAsInt(), it.get("cz").getAsInt(), + decode(it.get("data").getAsString())); + ok++; + } catch (Throwable t) { + failed++; + if (firstErr == null) { + firstErr = it.get("cx") + "," + it.get("cz") + ": " + t; + } + } + } + levelRendererAllChanged(); + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.addProperty("injected", ok); + r.addProperty("failed", failed); + if (firstErr != null) r.addProperty("firstError", firstErr); + var cache2 = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource(); + r.addProperty("loadedChunks", cache2.getLoadedChunksCount()); + emit(id, r); + break; + } + case "camera": { + double x = req.get("x").getAsDouble(), y = req.get("y").getAsDouble(), z = req.get("z").getAsDouble(); + float yaw = req.has("yaw") ? req.get("yaw").getAsFloat() : 0f; + float pitch = req.has("pitch") ? req.get("pitch").getAsFloat() : 0f; + // ★ 必须 setPosRaw + 手动更新 chunk 追踪,否则实体仍属旧区块 + mc.player.setPosRaw(x, y, z); + mc.player.xo = x; mc.player.yo = y; mc.player.zo = z; + mc.player.setYRot(yaw); + mc.player.setXRot(pitch); + mc.player.yRotO = yaw; + mc.player.xRotO = pitch; + mc.setCameraEntity(mc.player); + // 视野中心跟着相机走,否则目标区域区块被 ClientChunkCache 丢弃 + level.getChunkSource().updateViewCenter((int)Math.floor(x) >> 4, (int)Math.floor(z) >> 4); + mc.levelRenderer.allChanged(); + tickFrames(5); + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + JsonArray c = new JsonArray(); c.add(x); c.add(y); c.add(z); + r.add("camera", c); + emit(id, r); + break; + } + case "view": { + if (req.has("cameraType")) { + mc.options.setCameraType(net.minecraft.client.CameraType.valueOf(req.get("cameraType").getAsString())); + } + if (req.has("renderDistance")) { + mc.options.renderDistance().set(req.get("renderDistance").getAsInt()); + } + if (req.has("fov")) { + mc.options.fov().set(req.get("fov").getAsInt()); + } + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + emit(id, r); + break; + } + case "registry": { + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.addProperty("blockCount", BuiltInRegistries.BLOCK.size()); + r.addProperty("stateCount", Block.BLOCK_STATE_REGISTRY.size()); + emit(id, r); + break; + } + case "lookup": { + int sid = req.get("stateId").getAsInt(); + BlockState st = Block.stateById(sid); + ResourceLocation key = BuiltInRegistries.BLOCK.getKey(st.getBlock()); + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.addProperty("stateId", sid); + r.addProperty("block", key.toString()); + r.addProperty("state", st.toString()); + r.addProperty("inRange", sid >= 0 && sid < Block.BLOCK_STATE_REGISTRY.size()); + emit(id, r); + break; + } + case "fog": { + // 关雾:fogStart/fogEnd 是 levelRenderer 内部状态; + // 更简单可靠的是加大 renderDistance + 用 BLOCK 光照 + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + emit(id, r); + break; + } + case "resolve": { + // ★ 方块名(+属性) → state id。存档 palette 是名字,网络载荷要 id, + // 而这个名字→id 的权威映射只有客户端注册表有。 + // 入参: {"cmd":"resolve","blocks":["minecraft:oak_log[axis=y]", ...]} + JsonArray arr = req.getAsJsonArray("blocks"); + // ★ parseForBlock 要 HolderLookup(不是 Registry) + var reg = mc.level.registryAccess().lookupOrThrow(Registries.BLOCK); + JsonArray out = new JsonArray(); + int unknown = 0; + for (JsonElement el : arr) { + String spec = el.getAsString(); + int sid; + try { + var res = net.minecraft.commands.arguments.blocks.BlockStateParser + .parseForBlock(reg, spec, false); + sid = Block.getId(res.blockState()); + } catch (Throwable t) { + sid = -1; unknown++; + } + out.add(sid); + } + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.add("ids", out); + r.addProperty("unknown", unknown); + r.addProperty("total", arr.size()); + emit(id, r); + break; + } + case "debug": { + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.addProperty("playerPos", mc.player.getX() + "," + mc.player.getY() + "," + mc.player.getZ()); + int pcx = (int)Math.floor(mc.player.getX()) >> 4, pcz = (int)Math.floor(mc.player.getZ()) >> 4; + r.addProperty("playerChunk", pcx + "," + pcz); + var cc = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource(); + r.addProperty("loadedChunks", cc.getLoadedChunksCount()); + r.addProperty("chunksRendered", mc.levelRenderer.countRenderedChunks()); + r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllChunks()); + var got = cc.getChunk(pcx, pcz, net.minecraft.world.level.chunk.ChunkStatus.FULL, false); + r.addProperty("chunkAtPlayer", got != null ? got.getClass().getSimpleName() : "null"); + if (got != null) { + // 扫描玩家所在列,找最高的非空气方块 + int top = Integer.MIN_VALUE; + for (int y = level.getMaxBuildHeight() - 1; y >= level.getMinBuildHeight(); y--) { + var st = got.getBlockState(new BlockPos( + (int)Math.floor(mc.player.getX()) & 15, y, (int)Math.floor(mc.player.getZ()) & 15)); + if (!st.isAir()) { + top = y; + r.addProperty("topSolidY", y); + r.addProperty("topSolidBlock", BuiltInRegistries.BLOCK.getKey(st.getBlock()).toString()); + r.addProperty("topSolidProps", st.toString()); + break; + } + } + if (top == Integer.MIN_VALUE) r.addProperty("topSolidY", "none"); + } + // viewCenter + r.addProperty("fogStart", String.valueOf(mc.options.renderDistance().get())); + emit(id, r); + break; + } + case "viewport": { + // ★ viewCenter 决定 ClientChunkCache 收哪些区块(超范围会被丢弃) + int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt(); + if (req.has("radius")) { + level.getChunkSource().updateViewRadius(req.get("radius").getAsInt()); + } + level.getChunkSource().updateViewCenter(cx, cz); + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.addProperty("viewCenter", cx + "," + cz); + emit(id, r); + break; + } + case "render": { + int frames = req.has("frames") ? req.get("frames").getAsInt() : 60; + String out = req.has("out") ? req.get("out").getAsString() : null; + if (req.has("width")) width = req.get("width").getAsInt(); + if (req.has("height")) height = req.get("height").getAsInt(); + render(id, frames, out); + break; + } + default: + emitError(id, "未知命令: " + cmd); + } + } + + static JsonObject applyChunk(int cx, int cz, byte[] data) { + // ★ 官方路径:ClientChunkCache.replaceWithPacketData + // 内部:new LevelChunk + chunk.replaceWithPacketData(buf, heightmaps, consumer) + // 与网络包 handleLevelChunkWithLight 完全同一路径 + FriendlyByteBuf buf = new FriendlyByteBuf(io.netty.buffer.Unpooled.wrappedBuffer(data)); + var cache = level.getChunkSource(); + // 显式匿名类而非 lambda:lambda 参数类型推断在此处会失败(实测) + net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput beOut = + new net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput() { + @Override public void accept(BlockPos bp, + net.minecraft.world.level.block.entity.BlockEntityType type, + net.minecraft.nbt.CompoundTag tag) { } + }; + // ★ 参数是 Consumer(消费这个接口本身),不是该接口的方法签名。 + // LevelChunk.replaceWithPacketData 内部会调 beOut.accept(...),这里直接空实现。 + cache.replaceWithPacketData(cx, cz, buf, new net.minecraft.nbt.CompoundTag(), out -> {}); + // 无光照数据 ⇒ 显式全亮,否则地形全黑 + var light = cache.getLightEngine(); + for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) { + DataLayer sky = new 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(LightLayer.SKY, net.minecraft.core.SectionPos.of(cx, sy, cz), sky); + light.queueSectionData(LightLayer.BLOCK, net.minecraft.core.SectionPos.of(cx, sy, cz), sky); + } + light.setLightEnabled(new ChunkPos(cx, cz), true); + int upd = light.runLightUpdates(); + JsonObject r = new JsonObject(); + r.addProperty("lightUpdates", upd); + return r; + } + + static void levelRendererAllChanged() { + mc.levelRenderer.allChanged(); + } + + /** tick + 渲染各帧(区块网格重建队列靠 tick 推进) */ + static void tickFrames(int n) { + MainTarget target = (MainTarget) mc.getMainRenderTarget(); + for (int f = 0; f < n; f++) { + try { mc.tick(); } catch (Throwable ignore) {} + 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(); + } + } + + static void render(Integer id, int frames, String out) throws Exception { + tickFrames(frames); + + MainTarget target = (MainTarget) mc.getMainRenderTarget(); + int w = mc.getWindow().getWidth(), h = mc.getWindow().getHeight(); + 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); + java.util.Set colors = new java.util.HashSet<>(); + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) { + int i = ((h - 1 - y) * w + x) * 4; + int rgb = ((buf.get(i) & 0xFF) << 16) | ((buf.get(i + 1) & 0xFF) << 8) | (buf.get(i + 2) & 0xFF); + img.setRGB(x, y, rgb); + colors.add(rgb); + } + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", bos); + byte[] png = bos.toByteArray(); + if (out != null) ImageIO.write(img, "png", new File(out)); + + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.addProperty("width", w); + r.addProperty("height", h); + r.addProperty("chunksRendered", mc.levelRenderer.countRenderedChunks()); + r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllChunks()); + r.addProperty("distinctColors", colors.size()); + JsonArray cam = new JsonArray(); + cam.add(mc.player.getX()); cam.add(mc.player.getY()); cam.add(mc.player.getZ()); + r.add("camera", cam); + if (out != null) r.addProperty("path", out); + r.addProperty("image", Base64.getEncoder().encodeToString(png)); + emit(id, r); + } + + // ---------------- 启动 ---------------- + + static void boot(String gameDir, String assets, String assetIdx) throws Exception { + SharedConstants.tryDetectVersion(); + SharedConstants.enableDataFixerOptimizations(); + Bootstrap.bootStrap(); + + File runDir = new File(gameDir); + runDir.mkdirs(); + 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(width, height, 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(); + mc = new Minecraft(cfg); + RenderSystem.finishInitialization(); + System.err.println("[mcfold] Minecraft constructed"); + + reloadResources(mc); + System.err.println("[mcfold] resources reloaded"); + + 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); + + 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())); + var 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}); + var rf = ClientPacketListener.class.getDeclaredField("registryAccess"); + rf.setAccessible(true); + rf.set(listener, layered); + + Holder dimHolder = loaded.registryOrThrow(Registries.DIMENSION_TYPE) + .getHolderOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, new ResourceLocation("overworld"))); + var levelData = new ClientLevel.ClientLevelData(Difficulty.NORMAL, false, false); + level = new ClientLevel(listener, levelData, Level.OVERWORLD, dimHolder, + Math.max(8, mc.options.renderDistance().get()), 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); + + net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager()); + + mc.setOverlay(null); + mc.setScreen(null); + mc.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK); + System.err.println("[mcfold] boot done"); + } + + /** 同步驱动各 reload listener(不能 join Minecraft 的 reloadResourcePacks:等主循环 → 死锁)*/ + 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); + mc.getResourcePackRepository().reload(); + + var barrier = new net.minecraft.server.packs.resources.PreparableReloadListener.PreparationBarrier() { + @Override public java.util.concurrent.CompletableFuture wait(T t) { + return java.util.concurrent.CompletableFuture.completedFuture(t); + } + }; + var prof = InactiveProfiler.INSTANCE; + java.util.concurrent.Executor sync = Runnable::run; + + for (var listener : reloadListeners(rm)) { + String name = listener.getName(); + try { + var fut = listener.reload(barrier, rm, prof, prof, sync, sync); + long deadline = System.currentTimeMillis() + 60_000; + while (!fut.isDone() && System.currentTimeMillis() < deadline) Thread.sleep(10); + if (fut.isDone()) fut.join(); + else System.err.println("[mcfold] TIMEOUT " + name); + } catch (Throwable e) { + System.err.println("[mcfold] FAILED " + name + ": " + e); + } + } + } + + @SuppressWarnings("unchecked") + static java.util.List reloadListeners(Object rm) throws Exception { + 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) l; + } + } + } + throw new IllegalStateException("reload listeners not found"); + } + + // ---------------- 工具 ---------------- + + static byte[] decode(String b64) { + return Base64.getDecoder().decode(b64); + } + + 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; + } + + static synchronized void emit(Integer id, JsonObject obj) { + if (id != null) obj.addProperty("id", id); + OUT.println(GSON.toJson(obj)); + OUT.flush(); + } + + static void emitError(Integer id, String msg) { + JsonObject o = new JsonObject(); + o.addProperty("ok", false); + o.addProperty("error", msg); + emit(id, o); + } + + static void event(String name, JsonObject payload) { + JsonObject o = new JsonObject(); + o.addProperty("event", name); + if (payload != null) o.add("payload", payload); + OUT.println(GSON.toJson(o)); + OUT.flush(); + } +} diff --git a/scaffold/sample-real-save-render.png b/scaffold/sample-real-save-render.png new file mode 100644 index 0000000..45c6cab Binary files /dev/null and b/scaffold/sample-real-save-render.png differ diff --git a/scaffold/sample-underwater.png b/scaffold/sample-underwater.png new file mode 100644 index 0000000..5db95ac Binary files /dev/null and b/scaffold/sample-underwater.png differ