"""M8:把**服务端真状态**喂给细渲染器(细渲染单次调用,出图)。 ## 与 M6(粗渲染)的关系 plan §1d 的分工:粗渲染是**常驻感知**(纯 Python、无 JVM、毫秒级), 细渲染是**按需出图**(起 JVM + 官方渲染器 + 光影,20-40s)。 **两者不做像素级互相预测**(plan §4.5 明确:那不是刚需)。 它们的唯一交集是 M9 的拍照用例 —— 那里是**用法**上的配合,不是正确性依赖。 ## 为什么 M8 现在才做 细渲染早就能跑(Iris 光影 + 地形,`colors=113546` 实测), 但那一直是**注入假数据**。M2/M3 通了之后,真状态可以直接灌进去: M3 的 `Chunk` 手里就是 state id,而 Java 侧 `set_blocks` 收的也正是 state id (走官方 `LevelChunk` API)⇒ **不需要在 Python 反算 `name[props]`**。 ## 一条硬纪律 细渲染的失败**必须对 agent 可读**: JVM 起不来、显存不够、chunk 没加载、认不出的方块 —— 都要返回 `{ok:false, why, how_to}`,而不是抛异常或给一张空图。 """ from __future__ import annotations import os from typing import Any from .chunk import Chunk from .state import WorldState # ---------- 状态层 → 渲染器 ---------- def chunks_to_sections(ch: Chunk) -> dict[int, list[int]]: """M3 的 `Chunk` → 渲染器要的 `{sectionIndex: [4096 个 state id]}`。 ## 索引顺序 Java 侧按 **(y, z, x)** 线性序遍历 4096 格(`idx = (y<<8)|(z<<4)|x`), 与 MC 的 PalettedContainer 布局一致(x 最快、y 最慢)。 我方必须**按同一顺序**铺平,否则地形会错位(表现为"地形乱了")。 ## 边界 - `bits_per_block == 0` 的 section(单一值)会被 `state_id_at` 正常处理, 不用特殊分支。 - 区块高度不足 384 时缺失的 section **不产出**(由 Java 侧留空), 不要填 0(0 是空气,会把"没数据"渲染成"空气")。 """ sections: dict[int, list[int]] = {} for sec in ch.sections: if sec is None: continue idx = sec.y_index if idx is None or idx < 0: continue cells: list[int] = [] for y in range(16): for z in range(16): for x in range(16): cells.append(sec.state_id_at(x, y, z)) sections[idx] = cells return sections def inject_world( renderer, world: WorldState, *, columns: list[tuple[int, int]] | None = None, ) -> dict[str, Any]: """把状态层已加载的区块灌进渲染器。 返回 `{ok, injected, skipped, ...}`。**不抛异常** —— 渲染失败对 agent 必须可读。 """ cols = columns if columns is not None else sorted(world.world.columns()) injected: list[tuple[int, int]] = [] skipped: list[dict[str, Any]] = [] empty = 0 for cx, cz in cols: ch = world.world.chunks.get((cx, cz)) if ch is None: continue try: secs = chunks_to_sections(ch) except Exception as e: skipped.append({"column": [cx, cz], "why": f"{type(e).__name__}: {e}"}) continue if not secs: empty += 1 continue try: renderer.bridge.set_blocks(cx, cz, secs) renderer.chunks[(cx, cz)] = ch renderer._loaded.add((cx, cz)) injected.append((cx, cz)) except Exception as e: skipped.append({"column": [cx, cz], "why": f"写入失败 {type(e).__name__}: {e}"}) # ★ ok 的含义要统一:「这一批区块是否成功灌进去了」。 # 我先前写成 `bool(injected) or not cols` ⇒ **一个区块都没有时 # 返回 ok=True**,而 note 却在说「没有可渲染的区块」——自相矛盾 # (agent 会以为成功,拿到 0 张图)。 return { "ok": bool(injected), "injected": len(injected), "requested": len(cols), "columns": [list(c) for c in injected], "skipped": skipped, "empty": empty, "note": ("区块已灌入渲染器" if injected else "没有可渲染的区块:先在服务端附近走动让区块加载"), } # ---------- 细渲染(单次调用) ---------- #: 默认的细渲染环境变量(可用 env 覆盖) _ENV = { "MCFOLD_RENDER_CLASSPATH": None, # 由 store 自动拼 "MCFOLD_NATIVES_DIR": None, "DISPLAY": ":98", } def _prepare_env(store: str) -> dict[str, str]: """把渲染所需的环境变量准备好(不覆盖已有设置)。""" env = dict(os.environ) dot = os.path.join(store, ".mcfold") cp_file = os.path.join(dot, "classpath.txt") if not env.get("MCFOLD_RENDER_CLASSPATH") and os.path.exists(cp_file): with open(cp_file, "r", encoding="utf-8") as f: cp = f.read().strip().split("\n") parts = [p for p in cp if p] parts.append(os.path.join(dot, "client-named.jar")) parts.append(os.path.join(dot, "classes")) env["MCFOLD_RENDER_CLASSPATH"] = ":".join(parts) if not env.get("MCFOLD_NATIVES_DIR"): natives = os.path.join(dot, "natives") if os.path.isdir(natives): env["MCFOLD_NATIVES_DIR"] = natives env.setdefault("DISPLAY", ":98") return env def render_from_state( world: WorldState, *, store: str, camera: tuple[float, float, float, float, float] | None = None, out: str | None = None, hide_gui: bool = True, frames: int = 40, render_distance: int = 6, timeout: float = 240.0, ) -> dict[str, Any]: """【M8】用**服务端真状态**出一张图(单次调用)。 这是 M9 `capture_photo` 的底座,也可单独调用。 ## 为什么「单次调用」而不是常驻 JVM 启停成本高(20-40s),但细渲染是**按需**的 (要图 / 要精细判断时才用)⇒ 起→渲→关最合适。 日常感知用 M6 粗渲染(毫秒级、常驻)。 ## 失败必须可读 起不来 JVM / 显存不足 / 没有区块 —— 全部返回 `{ok:false, why, how_to}`,不抛异常(agent 看不到 traceback)。 """ os.environ.update(_prepare_env(store)) from .render import WorldRenderer # 延迟导入:没渲染环境也不影响其它功能 cols = sorted(world.world.columns()) if not cols: return {"ok": False, "why": "没有任何已加载区块,出图会是一张空图", "how_to": "先在服务端附近走动(pump 几秒)让区块加载,再试"} if camera is None: p = world.player camera = (p.x, p.y + 1.62, p.z, p.yaw, p.pitch) notes: list[str] = [] try: renderer = WorldRenderer.open() except Exception as e: return {"ok": False, "why": f"渲染器启动失败:{type(e).__name__}: {e}", "how_to": "检查 store 下的 .mcfold 产物(classpath.txt / natives)与 DISPLAY"} try: inj = inject_world(renderer, world, columns=cols) if not inj["injected"]: return {"ok": False, "why": inj["note"], "how_to": inj.get("skipped") or "确认状态层有区块", "inject": inj} try: renderer.set_render_distance(render_distance) except Exception as e: # 不是致命(只是渲染距离可能不对),但**要说出来** —— # 静默 pass 会让调用方以为设成功了。 notes.append(f"render_distance 未生效:{type(e).__name__}") # ★ HUD 必须关(plan 明确要求截图隐藏 HUD)。 # 这里**故意不吞异常**:hide_gui 是截图质量的硬要求, # 设不上就该让调用方知道(早先我用 except: pass 掩盖了 # 「方法根本不存在」这个真 bug,出图带着 HUD 还报告 hide_gui=True)。 if hide_gui: try: renderer.set_view(hide_gui=True) except Exception as e: return {"ok": False, "why": f"无法隐藏 HUD(截图模式要求隐藏):{type(e).__name__}: {e}", "how_to": "确认渲染后端支持 view 命令的 hide_gui;" "或传 hide_gui=False 接受带 HUD 的图"} cx = int(world.player.x) >> 4 cz = int(world.player.z) >> 4 try: renderer.set_viewport(cx, cz, radius=render_distance) except Exception as e: notes.append(f"viewport 未设置:{type(e).__name__}") renderer.camera(*camera) result = renderer.screenshot(out=out, frames=frames) info: dict[str, Any] = { "ok": True, "notes": notes, "image": getattr(result, "path", None) or out, "columns_rendered": inj["injected"], "camera": list(camera), "hide_gui": hide_gui, } try: info["debug"] = renderer.debug() except Exception: pass return info except Exception as e: return {"ok": False, "why": f"渲染过程失败:{type(e).__name__}: {e}", "how_to": "看 runtime 日志;也可先用 M6 粗渲染确认地形存在"} finally: try: renderer.close() except Exception: pass