P9:Python 包装层 —— 真实存档 → 官方渲染器 → 出图
新增两模块: mcfold/anvil.py 存档读取(region/NBT)+ 区块编解码工具 mcfold/render_bridge.py 桥:常驻 Java 渲染器进程,NDJSON 协议 mcfold/render.py 对外入口:WorldRenderer(存档→画面) scaffold/McfoldRenderServer.java Java 侧服务端(常驻,NDJSON) 实测(真实存档 mc-test,DataVersion 3465=1.20.1): injected=81 unknownBlocks=0 uniqueSpecs=111 loadedChunks=81 chunksRendered=60 distinctColors=11585 画面:草地/沙滩/树/水/天空/太阳/HUD(sample-real-save-render.png) ★ 分工落在实处(附录 D 的结论变成代码): 存档 palette 是**方块名**(minecraft:oak_log[axis=y]),网络载荷要 **state id**, 名字→id 的权威映射只有客户端注册表有 ⇒ 新增 resolve 命令, Java 侧用 BlockStateParser.parseForBlock 解析(实测 111 种全中,0 unknown) 踩坑(本轮新发现): 1. level.dat 有两种布局(8字节头+zlib / 直接 gzip)—— 实测本机是后者 2. section 索引 ≠ 绝对 Y:存档用 -4..19,载荷用 0..23(少这步 → readerIndex 越界) 3. 注入前必须 set_viewport,否则 ClientChunkCache.inRange 丢弃区块 4. setPos 不会更新实体区块归属,要 setPosRaw + updateViewCenter 5. 生物群系只影响着色,用统一 plains 不影响「能渲染出世界」 6. camera 回包要用 JsonArray,字符串会被 Python tuple() 按字符拆开 性能:启动 66s(GL+资源重载),之后每帧渲染 ~1s(含 100 帧 tick)
This commit is contained in:
BIN
mcfold/__pycache__/anvil.cpython-312.pyc
Normal file
BIN
mcfold/__pycache__/anvil.cpython-312.pyc
Normal file
Binary file not shown.
BIN
mcfold/__pycache__/render.cpython-312.pyc
Normal file
BIN
mcfold/__pycache__/render.cpython-312.pyc
Normal file
Binary file not shown.
BIN
mcfold/__pycache__/render_bridge.cpython-312.pyc
Normal file
BIN
mcfold/__pycache__/render_bridge.cpython-312.pyc
Normal file
Binary file not shown.
300
mcfold/anvil.py
Normal file
300
mcfold/anvil.py
Normal file
@ -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 解析交给调用方(需注册表)
|
||||
365
mcfold/render.py
Normal file
365
mcfold/render.py
Normal file
@ -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)
|
||||
353
mcfold/render_bridge.py
Normal file
353
mcfold/render_bridge.py
Normal file
@ -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 <store> <mcver>"
|
||||
)
|
||||
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"),
|
||||
}
|
||||
Reference in New Issue
Block a user