M8/M9:细渲染接服务端真状态 + capture_photo 拍照用例

新增 mcfold/fine.py:把 M3 状态层的区块灌进细渲染器
- chunks_to_sections:铺平顺序 = (y<<8)|(z<<4)|x(与 Java PalettedContainer 一致)
  ★ 错一位不报错、只是地形静默错位 ⇒ 判据用构造数据打这个点
- 缺失的 section **省略**而非填 0(0=空气,把「没数据」渲染成空洞)
- render_from_state:单次调用,失败全部返回 ok:false + why + how_to

新增 mcfold/photo.py:capture_photo
- propose_shots 用**粗渲染观察**生成机位候选(正面/斜侧/俯视/远景/当前位置),
  每个都带「为什么选它」的依据;候选阶段不出图(省时省钱)
- 构图好坏是审美判断=决策,工具不替 agent 选

★ 关键发现:桥接比预想的干净
原以为要把 state id 反算成 name[props],但 Java 侧 set_blocks 收的
**本来就是 state id**(走官方 LevelChunk API),而 state id 本身含属性
⇒ 无损、无需转换。

修的真 bug:
1. ★★ HUD 没隐藏:桥层 RenderBackend.set_view 早有 hide_gui
   (→ 官方 options.hideGui),但 WorldRenderer **没有这个入口**
   ⇒ AttributeError 被 except: pass 吞掉 ⇒ 出图带生命条/饥饿/物品栏,
   而返回值仍写 hide_gui: True(自相矛盾)
   教训:桥层有能力 ≠ 上层有入口;except: pass 让「以为设了」与
   「实际没设」无法区分。现在 hide_gui 设不上就显式失败。
2. 俯视机位朝向不自洽(水平 yaw 退化却配写死的 pitch)→ 3D dot=0.82。
   改为由位置向量反算 yaw/pitch;正面/斜侧/远景同样修正。
3. inject_world 的 ok 自相矛盾(无区块时 ok=True 却说「没有可渲染的区块」)
4. 缺失 section 被填 0 冒充空气

实测出图:854×480、1902 种颜色,地形/天空/月亮/草地纹理正确。

判据 17 项;2 项变异全被抓。
★ 「机位朝向」判据我第一版只比水平分量 ⇒ 俯视机位恒为 0 会判红,
  改为完整 3D 点积(dot > 0.99)。

200 项全通过。
This commit is contained in:
JianFeeeee
2026-09-26 13:52:33 +08:00
parent 93243165bf
commit 4980d6e81f
5 changed files with 979 additions and 2 deletions

247
mcfold/fine.py Normal file
View File

@ -0,0 +1,247 @@
"""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

212
mcfold/photo.py Normal file
View File

@ -0,0 +1,212 @@
"""M9 第一个用例:`capture_photo()` —— 粗搜机位 → 细出图。
## 这个用例验证什么
plan §5 的原始动机(用户场景):
> 「我和某个景物的合照」—— agent 先用**粗渲染**找好角度,
> 再用**细渲染**出真实照片。
它之所以排在最后,是因为它**同时验证两个子系统**:
- 粗渲染(M6)能不能给出**可用的机位候选**(不是拍脑袋的坐标)
- 细渲染(M8)能不能把**服务端真状态**渲成图
## 关键设计:机位从哪来
**不是**让 agent 拍脑袋报坐标,而是:
1. 先问 M6:目标可见吗?周围能站吗?地面在哪?
2. 由这些**观察**生成若干机位候选(正面/侧面/俯视/远景…)
3. 逐个(或挑最优的)用细渲染出图
4. 把「机位是怎么来的」一并返回 —— agent 能判断可不可信
## 为什么不追求「自动选最优机位」
构图好坏是**审美判断**,属决策。按 mcfold 纪律(工具不决策),
本模块给出**结构化的机位候选 + 每个机位的观察依据**,
让 agent 自己选(可以 `shot_index` 指定,或全都要)。
"""
from __future__ import annotations
import math
import os
from typing import Any
from . import rough
from .state import WorldState
def _facing_yaw(tx: float, tz: float, fx: float, fz: float) -> float:
"""从机位看向目标所需的 yaw(MC 约定:0=+Z)。"""
dx, dz = tx - fx, tz - fz
return (math.degrees(math.atan2(dz, dx)) - 90.0) % 360.0
def propose_shots(
world: WorldState,
target: tuple[float, float, float],
*,
radius: int = 3,
max_shots: int = 6,
) -> dict[str, Any]:
"""用**粗渲染的观察**生成机位候选(不渲染,只观察)。
每个候选都带**为什么选它**(依据),便于 agent 判断与追问。
"""
p = world.player
tx, ty, tz = target
ground = world.block_at(int(tx), int(ty) - 1, int(tz))["name"]
base_y = ty - 0.6 # 略微下蹲的机位高度,比站在格心自然
cands: list[dict[str, Any]] = []
def add(name: str, fx: float, fy: float, fz: float, pitch: float,
why: str, dist: float | None = None) -> None:
d = dist if dist is not None else math.dist((fx, fy, fz), (tx, ty, tz))
cands.append({
"index": len(cands),
"name": name,
"camera": [round(fx, 2), round(fy, 2), round(fz, 2),
round(_facing_yaw(tx, tz, fx, fz), 1), round(pitch, 1)],
"distance": round(d, 2),
"why": why,
})
def add_at(name: str, fx: float, fy: float, fz: float,
why: str, dist: float | None = None) -> None:
"""**由位置反算** yaw/pitch,保证真的看向目标。
★ 为什么需要它:俯视机位在目标**正上方 4 格**,此时
水平方向的 yaw 已经退化(dx/dz ≈ 0,方位无意义),
真正的方向是「几乎垂直向下」。
我先前写死 `pitch=-35` 配水平算出的 yaw ⇒ 两者**不自洽**
(实测 dot=0.82,相机没真正对着目标)。
⇒ 只要机位不在目标同一水平面,就必须由向量反算朝向。
"""
dx, dy, dz = tx - fx, ty - fy, tz - fz
horiz = math.hypot(dx, dz)
yaw = (math.degrees(math.atan2(dz, dx)) - 90.0) % 360.0
pitch = -math.degrees(math.atan2(dy, horiz)) if (horiz or dy) else 0.0
d = dist if dist is not None else math.sqrt(dx * dx + dy * dy + dz * dz)
cands.append({
"index": len(cands), "name": name,
"camera": [round(fx, 2), round(fy, 2), round(fz, 2),
round(yaw, 1), round(pitch, 1)],
"distance": round(d, 2), "why": why,
})
# ① 目标正前方一格(经典正面照)
dx, dz = tx - p.x, tz - p.z
n = math.hypot(dx, dz) or 1.0
ux, uz = dx / n, dz / n
fx, fz = tx - ux * 3.0, tz - uz * 3.0
fx, fz = int(round(fx)), int(round(fz))
if world.world.registry is not None and world.block_at(fx, int(ty) - 1, fz)["name"]:
add_at("正面", fx, base_y, fz,
f"站在目标正前方 3 格({fx},{int(ty)-1},{fz} 有 {ground} 支撑)")
else:
add_at("正面(悬空)", fx, base_y, fz, "目标正前方 3 格(脚下无支撑,可能悬空)")
# ② 侧面 45°
sx, sz = int(round(tx - uz * 3.0 + ux * 3.0)), int(round(tz + ux * 3.0 + uz * 3.0))
if world.block_at(sx, int(ty) - 1, sz)["name"]:
add_at("斜侧", sx, base_y, sz,
f"侧 45°、距离 3 格({sx},{int(ty)-1},{sz} 有支撑)")
else:
add_at("斜侧(悬空)", sx, base_y, sz, "侧 45°(脚下无支撑)")
# ③ 俯视(看全景)—— 用 add_at:机位在目标上方,水平朝向退化
add_at("俯视", tx, ty + 4.0, tz + 0.01,
"目标正上方 4 格俯视(朝向由位置反算,保证真的对着目标)")
# ④ 远景(若视线通)
far_x, far_z = int(round(tx - ux * 8.0)), int(round(tz - uz * 8.0))
vis = rough.can_see(world, (fx, base_y, fz))
add_at("远景", far_x, base_y, far_z,
f"8 格外远景(近处视线:{vis['reason']})")
# ⑤ 玩家当前机位(零成本:就在这儿)
add("当前位置", p.x, p.y + 1.62, p.z, p.pitch,
f"就用你现在的视角({p.yaw:.0f}°/{p.pitch:.0f}°)")
return {
"ok": True,
"target": list(target),
"shots": cands[:max_shots],
"count": min(len(cands), max_shots),
"target_ground": ground,
"source": "粗渲染观察(M6 raycast/can_see/block_at),未出图",
"note": "机位是从**观察**推出的候选,不是构图判断;"
"用 shot_index 选一个,或对每个都出图再挑",
}
def capture_photo(
world: WorldState,
target: tuple[float, float, float] | None = None,
*,
store: str,
shot_index: int | None = None,
out_dir: str | None = None,
name: str = "photo",
hide_gui: bool = True,
frames: int = 40,
render_distance: int = 6,
) -> dict[str, Any]:
"""【M9】拍照:粗搜机位 → 细出图。
## 参数
- `target` 目标坐标;省略则以**玩家朝向的前方**为目标
- `shot_index` 选第几个机位;省略则**每个机位都出一张**
- `store` 内容根(含 `.mcfold` 渲染产物)
## 返回
```python
{"ok": True, "shots": [ {...,"image": "...", "ok": True}, ... ],
"shot_options": [...], "target": [...]}
```
出图失败的机位**不丢弃**(带上 `ok:false, why`),
这样 agent 能知道「哪些角度失败、为什么」。
"""
from .fine import render_from_state # 延迟导入
p = world.player
if target is None:
# 用当前朝向的前方当目标(粗渲染的 raycast 就是这么算的)
v = rough._look_vector(p.yaw, p.pitch)
d = 6.0
target = (p.x + v[0] * d, p.y + 1.62 + v[1] * d, p.z + v[2] * d)
prop = propose_shots(world, target)
if not prop["shots"]:
return {"ok": False, "why": "没能提出任何机位候选",
"how_to": "确认状态层有区块(pump 几秒)", "target": list(target)}
out_dir = out_dir or os.path.join(
os.environ.get("MCFOLD_RUNTIME", "/tmp"), "photos")
os.makedirs(out_dir, exist_ok=True)
idxs = [shot_index] if shot_index is not None else \
[s["index"] for s in prop["shots"]]
results: list[dict[str, Any]] = []
for i in idxs:
if not (0 <= i < len(prop["shots"])):
return {"ok": False, "why": f"shot_index {i} 越界(共 {len(prop['shots'])} 个机位)",
"how_to": f"可用 index:0..{len(prop['shots'])-1}",
"shot_options": prop["shots"]}
s = prop["shots"][i]
out = os.path.join(out_dir, f"{name}_{i}.png")
r = render_from_state(
world, store=store, camera=tuple(s["camera"]), out=out,
hide_gui=hide_gui, frames=frames, render_distance=render_distance)
results.append({**s, **r})
ok_any = any(r.get("ok") for r in results)
return {
"ok": ok_any,
"target": list(target),
"shots": results,
"shot_options": prop["shots"],
"target_ground": prop["target_ground"],
"out_dir": out_dir,
"note": (prop["note"] if ok_any else
"所有机位都出图失败;见每个 shot 的 why"),
}

View File

@ -428,6 +428,22 @@ class WorldRenderer:
req["radius"] = radius
return self.bridge._request(req)
def set_view(self, **kw) -> dict:
"""视觉设置(camera_type / render_distance / fov / hide_gui / …)。
★ 这个方法原先**缺失**,而桥层(`RenderBackend.set_view`)早就实现了
完整的 `view` 命令(含 `hide_gui` → 官方 `options.hideGui`)。
结果 M8 里我调 `renderer.set_view(hide_gui=True)` 时
撞上 AttributeError,又被 `except: pass` 静默吞掉
⇒ **出图带着 HUD**,而返回值里还写着 `hide_gui: True`(自相矛盾)。
⇒ 两个教训:
1. 桥层有能力 ≠ 上层有入口(中间这层要一起补);
2. `except: pass` 掩盖了真实错误 —— 修 HUD 时必须让它**显式失败**,
否则「以为设了」与「实际没设」无法区分。
"""
return self.bridge.set_view(**kw)
def debug(self) -> dict:
"""诊断:玩家坐标/区块/网格编译/视野中心的实际状态。"""
return self.bridge._request({"cmd": "debug"})

47
plan.md
View File

@ -568,8 +568,8 @@ shot.rough → ASCII + 线索(给 agent 看它为什么选了这个角度)
| **M5** | **操作层**:chat / look / move / dig / place(能改世界) | M2, M3 | ✅ |
| **M6** | **粗渲染**(常驻感知):基于 M3 的真状态 | M3 | ✅ |
| **M7** | **LLM 友好的 API 形状**(§1d):自省 / 可读返回 / 限量 | M3–M6 | ✅ |
| **M8** | **细渲染**(单次调用):出图,带光影 | M2 | ⬜ |
| **M9** | 第一个用例:`capture_photo()`(粗搜机位 → 细出图) | M6, M8 | ⬜ |
| **M8** | **细渲染**(单次调用):出图,带光影 | M2 | ✅ |
| **M9** | 第一个用例:`capture_photo()`(粗搜机位 → 细出图) | M6, M8 | ✅ |
### M2 实测结论(2026-09-26)
@ -830,6 +830,49 @@ dig(x=..,y=..,z=..) ok=True → events 收到 block_changed
**判据必须打在「真正承载语义的那段代码」上,否则测的是冗余。**
### M8 / M9 实测结论(2026-09-26)
新增 `mcfold/fine.py`(细渲染接真状态)+ `mcfold/photo.py`(拍照用例)。
**关键发现:桥接比预想的干净。**
我原以为要把 M3 的 state id 反算成 `name[props]`,但查 Java 侧
`set_blocks` 发现它收的**本来就是 state id**(走官方 `LevelChunk` API),
而 state id 本身已含属性 ⇒ **无损、无需转换**。
**M9 真实出图**(854×480,1902 种颜色,地形/天空/月亮/草地纹理都对):
```
机位候选(纯粗渲染观察,0 出图成本):
[0] 正面 依据:站在目标正前方 3 格(0,-61,10 有 grass_block 支撑)
[1] 斜侧 依据:侧 45°、距离 3 格(6,-61,12 有支撑)
[2] 俯视 依据:目标正上方 4 格(朝向由位置反算)
[3] 远景 依据:8 格外(近处视线:blocked)
[4] 当前位置 依据:就用你现在的视角
出图:columns_rendered=9 hide_gui=True
```
**本轮修的真 bug(4 个)**:
1. **HUD 没隐藏**(plan 明确要求截图隐藏 HUD):
桥层 `RenderBackend.set_view` 早就实现了 `hide_gui` → 官方 `options.hideGui`,
但 **`WorldRenderer` 根本没有这个入口** ⇒ 我调它时撞 AttributeError,
又被 `except: pass` 吞掉 ⇒ **出图带着生命条/饥饿/物品栏**,
而返回值还写着 `hide_gui: True`(自相矛盾,没人发现)。
教训:**桥层有能力 ≠ 上层有入口**;且 `except: pass` 会把「以为设了」
与「实际没设」变成无法区分。现在 hide_gui 设不上就**显式失败**。
2. **俯视机位的朝向不自洽**:机位在目标正上方 4 格,水平 yaw 已退化
(dx,dz≈0),我却写死 `pitch=-35` 配水平算出的 yaw
⇒ 实测 3D dot=0.82(相机没真对着目标)。改为**由位置向量反算** yaw/pitch。
顺带修了正面/斜侧/远景(高度不同但 pitch 写死 0)。
3. `inject_world` 的 `ok` 自相矛盾:一个区块都没有时返回 `ok=True`,
而 note 却在说「没有可渲染的区块」。统一为「有成功注入」。
4. 缺失的 section 被填 0(=空气)⇒ 把「没数据」渲染成空洞。
改为**省略**(Java 侧留空)。
**判据**:`tests/test_photo.py` 17 项;2 项变异全被抓
(删掉 set_view / hide_gui 又被静默吞)。
★ 其中「机位朝向」那条判据我第一版**只比水平分量** ⇒ 对俯视机位恒为 0,
会把正确实现判红。改为**完整 3D 点积**(dot > 0.99)。
**建议顺序:M0 → M1 → M2**。
- M0/M1 是解阻断(M1 是我已经查到根因的那个 classpath bug)
- **M2 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。

459
tests/test_photo.py Normal file
View File

@ -0,0 +1,459 @@
"""M8/M9 判据:fine.py(细渲染接真状态)+ photo.py(拍照用例)。
## 判据分两层
**可离线判的**(不需要 JVM,默认套件里跑):
- `chunks_to_sections` 的索引顺序(错一位地形就乱)
- 机位候选的**依据**是否成立(不能凭空给坐标)
- 失败必须可读(不抛异常)
- 空世界/未加载必须明确失败,不返回空图
**需要渲染环境的**(`--with-render`):
- 真出图并检查 distinctColors 等
## 为什么索引顺序要单独判
Java 侧按 `(y<<8)|(z<<4)|x` 遍历 4096 格。
铺平顺序错一位,**不报错**,只是地形整体错位(画出来是乱的)。
这类"静默错位"必须用构造数据判:给某坐标一个独特值,看它落在哪个下标。
"""
import sys, os, math
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from mcfold.chunk import Chunk, Section, BitArray
from mcfold.fine import chunks_to_sections, inject_world, render_from_state
from mcfold.photo import propose_shots, _facing_yaw
from mcfold.state import WorldState, load_vanilla_registry
DATA_ROOT = ('/home/newqqagent/plugin_data/mc/runtime/node_modules/'
'minecraft-data/minecraft-data/data')
_REG = load_vanilla_registry('1.21.1', DATA_ROOT)
GRASS = _REG.by_name["grass_block"]["minStateId"]
STONE = _REG.by_name["stone"]["minStateId"]
AIR = _REG.by_name["air"]["minStateId"]
class _ChunkLike:
def __init__(self, fn):
self.fn = fn
def state_id_at(self, x, y, z):
return self.fn(x, y, z)
class _FakeBridge:
"""记录 set_blocks 调用的假桥(不启 JVM)。"""
def __init__(self, fail_on=None):
self.calls: list[tuple[int, int, dict]] = []
self.fail_on = fail_on or set()
def set_blocks(self, cx, cz, sections):
if (cx, cz) in self.fail_on:
raise RuntimeError("模拟写入失败")
self.calls.append((cx, cz, sections))
return {"ok": True}
class _FakeRenderer:
def __init__(self, fail_on=None):
self.bridge = _FakeBridge(fail_on)
self.chunks: dict = {}
self._loaded: set = set()
def _real_chunk(fn, y_index=0):
"""造一个**真 Chunk**(inject_world 需要 .sections/state_id_at)。
★ 用假区块(只有 state_id_at)测不了 inject_world ——
它按 section 遍历,假对象没有 .sections ⇒ 会被记进 skipped,
于是测的是「假对象不支持」而不是「注入逻辑对错」。
这是判据 fixture 的坑:fixture 形状必须匹配被测代码的真实输入。
"""
return Chunk(x=0, z=0, sections=[Section(y_index=y_index, single_value=fn(0, -1, 0))])
def _scene(fn, chunks=((0, 0),)):
ws = WorldState(registry=_REG, num_sections=24)
for cx, cz in chunks:
ch = _real_chunk(fn)
ch.x, ch.z = cx, cz
ws.world.chunks[(cx, cz)] = ch
ws.player.x, ws.player.y, ws.player.z = 0.5, 0.0, 0.5
return ws
# ---------- ① 索引顺序(静默错位是最危险的) ----------
def test_section_linear_index_matches_java_layout():
"""★★ 铺平顺序必须是 `idx = (y<<8)|(z<<4)|x`(与 Java 的 PalettedContainer 一致)。
错一位**不报错**,只是地形整体错位(画出来是乱的)——
属于"静默错位",必须用构造数据判。
"""
# 单值 section:全 4096 格都是 42
s = Section(y_index=0, single_value=42)
secs = chunks_to_sections(Chunk(x=0, z=0, sections=[s]))
assert list(secs.keys()) == [0], secs.keys()
assert len(secs[0]) == 4096, len(secs[0])
assert all(v == 42 for v in secs[0]), "单值 section 应全为该值"
# 位数组 section:只把 (x=1,y=0,z=0) 设为 1 ⇒ 它必须落在下标 1
bits = BitArray([0b10], capacity=4096, bits_per_value=1)
s2 = Section(y_index=1, bits_per_block=1, palette=[7, 9], bits=bits)
secs2 = chunks_to_sections(Chunk(x=0, z=0, sections=[s2]))
c = secs2[1]
assert c[1] == 9, f"(x=1,y=0,z=0) 应落在下标 1,实得 {c[1]}"
assert c[0] == 7, f"(x=0,y=0,z=0) 应是 7,实得 {c[0]}"
# 多点验证:(x=2,y=0,z=1) ⇒ idx = (0<<8)|(1<<4)|2 = 18
# BitArray 没有 set(),直接构造 data:1 bit/value ⇒ 每 long 64 格,
# 偏移 = (index % 64) * 1
bits2 = BitArray([0] * 8, capacity=4096, bits_per_value=1)
bits2.data[18 // 64] |= 1 << ((18 % 64) * 1)
assert bits2.get(18) == 1, "先确认构造正确"
s3 = Section(y_index=2, bits_per_block=1, palette=[7, 9], bits=bits2)
c3 = chunks_to_sections(Chunk(x=0, z=0, sections=[s3]))[2]
assert c3[18] == 9, f"(x=2,z=1) 应落在下标 18,实得 {c3[18]}"
assert sum(1 for v in c3 if v == 9) == 1, "只应有一个格子被置位"
print(" ★★ 铺平索引 = (y<<8)|(z<<4)|x(与 Java 一致)")
def test_chunks_to_sections_uses_section_index():
"""★ section 的 y_index 决定它落在哪个下标(不能顺序错位)。"""
s_a = Section(y_index=0, single_value=GRASS)
s_b = Section(y_index=5, single_value=STONE)
secs = chunks_to_sections(Chunk(x=0, z=0, sections=[s_a, s_b]))
assert sorted(secs.keys()) == [0, 5], secs.keys()
assert secs[0][0] == GRASS and secs[5][0] == STONE
print(" ★ section 按下标归位(0 与 5)")
def test_missing_sections_are_omitted_not_zeroed():
"""★★ 缺失的 section **不产出**,不能填 0。
0 是空气 ⇒ 把「没数据」渲染成「空气」是撒谎
(本该是实心的地方变成空洞,看起来像被挖空了)。
"""
s = Section(y_index=3, single_value=STONE)
secs = chunks_to_sections(Chunk(x=0, z=0, sections=[s]))
assert list(secs.keys()) == [3], "只该有 y_index=3 这一个 section"
assert 0 not in secs and 1 not in secs, "缺失的 section 不该被填 0"
print(" ★★ 缺失 section 被省略(不填 0 冒充空气)")
# ---------- ② inject_world:不抛异常、如实报告 ----------
def test_inject_world_reports_counts():
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = _FakeRenderer()
out = inject_world(r, ws)
assert out["ok"] is True
assert out["injected"] == 1, out
assert out["skipped"] == []
assert r.bridge.calls and r.bridge.calls[0][0] == 0
print(f" ✓ inject_world 报告注入数({out['injected']})")
def test_inject_world_survives_write_failure():
"""★ 写入失败要记进 skipped 并继续(不是整批崩掉)。"""
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR, chunks=((0, 0), (1, 0)))
r = _FakeRenderer(fail_on={(1, 0)})
out = inject_world(r, ws)
assert out["injected"] == 1, out
assert len(out["skipped"]) == 1, out
assert out["skipped"][0]["column"] == [1, 0]
assert "模拟写入失败" in out["skipped"][0]["why"]
print(" ★ 单列写入失败:记入 skipped 且其余继续")
def test_inject_world_empty_is_honest():
"""★ 没有区块时 **ok=False 且说明原因**,不假装成功。"""
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR, chunks=())
r = _FakeRenderer()
out = inject_world(r, ws)
assert out["injected"] == 0
assert out["ok"] is False, out
assert "走动" in out["note"] or "加载" in out["note"], out["note"]
print(" ✓ 无区块:ok=False + 可执行建议")
# ---------- ③ render_from_state:失败必须可读(不抛异常) ----------
def test_render_from_state_empty_world_is_readable_failure():
"""★ 空世界出图 ⇒ 结构化失败 + how_to(不是 traceback)。"""
ws = _scene(lambda x, y, z: AIR, chunks=())
r = render_from_state(ws, store="/nonexistent-store")
assert r["ok"] is False, r
assert r.get("why"), r
assert r.get("how_to"), r
assert "空图" in r["why"] or "区块" in r["why"], r["why"]
print(f" ✓ 空世界:可读失败({r['why'][:40]}…)")
def test_render_from_state_bad_store_is_readable_failure():
"""★ store 缺产物 ⇒ 结构化失败(不抛异常)。"""
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = render_from_state(ws, store="/tmp/no-such-store-xyz")
assert r["ok"] is False, r
assert r.get("why") and r.get("how_to"), r
print(f" ✓ 坏 store:可读失败({r['why'][:44]}…)")
# ---------- ④ 机位候选:依据必须成立 ----------
def test_propose_shots_gives_reasons():
"""★ 每个机位都要说明**为什么选它**(不能凭空给坐标)。"""
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = propose_shots(ws, (5.0, 0.0, 0.0))
assert r["ok"] is True
assert r["count"] >= 3, r["count"]
for s in r["shots"]:
assert s["why"], f"机位 {s['name']} 没给依据"
assert len(s["camera"]) == 5, s # x,y,z,yaw,pitch
assert s["distance"] >= 0
assert "未出图" in r["source"] or "粗渲染" in r["source"]
print(f" ★ {r['count']} 个机位都带依据")
def test_propose_shots_yaw_points_at_target():
"""★★ 机位朝向必须**真的看向目标**(算错的症状是拍到别处)。
★ 判据要用**完整 3D 方向**,不能只看水平分量:
俯视机位在目标正上方 ⇒ 水平方向退化(dx,dz≈0),
只比水平的话 dot 恒为 0/无意义 ⇒ 会把正确的实现判红
(我第一版就犯了这个错)。
正确做法:把 yaw/pitch 还原成 3D 单位向量,与「机位→目标」向量比点积。
"""
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
target = (5.0, 0.0, 0.0)
r = propose_shots(ws, target)
for s in r["shots"]:
# ★ 「当前位置」是**故意**用玩家当前朝向的(它代表「就用你现在的视角」),
# 不保证对准给定目标 ⇒ 排除它,其余必须严格对准。
if s["name"] == "当前位置":
continue
fx, fy, fz, yaw, pitch = s["camera"]
# yaw/pitch → 3D 单位向量(MC 约定:yaw 0=+Z,pitch 负=向下)
y = math.radians(yaw)
p = math.radians(pitch)
vx = -math.sin(y) * math.cos(p)
vy = -math.sin(p)
vz = math.cos(y) * math.cos(p)
# 机位 → 目标
tx, ty, tz = target[0] - fx, target[1] - fy, target[2] - fz
n = math.sqrt(tx * tx + ty * ty + tz * tz)
if n < 1e-6:
continue # 与目标重合,无需朝向
dot = (vx * tx + vy * ty + vz * tz) / n
assert dot > 0.99, \
f"机位 {s['name']} 朝向没对准目标(3D dot={dot:.3f},camera={s['camera']})"
print(" ★★ 所有机位的 3D 朝向都指向目标(dot > 0.99)")
def test_facing_yaw_convention():
"""★ yaw 约定:0=+Z(南) 90=-X(西) 180=-Z(北) 270=+X(东)。"""
# 站在原点看向 +Z ⇒ yaw 应为 0
assert abs(_facing_yaw(0.0, 1.0, 0.0, 0.0) - 0.0) < 1e-6
# 看向 +X ⇒ 270
assert abs(_facing_yaw(1.0, 0.0, 0.0, 0.0) - 270.0) < 1e-6
# 看向 -Z ⇒ 180
assert abs(_facing_yaw(0.0, -1.0, 0.0, 0.0) - 180.0) < 1e-6
print(" ✓ 朝向计算符合 MC 约定")
def test_propose_shots_empty_world_still_returns_options():
"""★ 没有区块时也要给候选(但要如实说明脚下没支撑)。"""
ws = _scene(lambda x, y, z: AIR, chunks=())
r = propose_shots(ws, (5.0, 0.0, 0.0))
assert r["ok"] is True
# 至少要给「当前位置」这个零成本机位
assert any(s["name"] == "当前位置" for s in r["shots"]), r["shots"]
print(" ✓ 空世界仍给候选(含当前机位)")
# ---------- ⑤ 两个子系统的分工(不互相预测) ----------
def test_photo_uses_rough_for_candidates_not_rendering():
"""★ 机位候选来自**粗渲染观察**(M6),不是细渲染出图 —— 省时省钱。
plan §4.5:两者不做像素级互相预测。
"""
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = propose_shots(ws, (5.0, 0.0, 0.0))
# 候选阶段没有 image 字段(说明没出图)
assert all("image" not in s for s in r["shots"]), "候选阶段不该出图"
assert "未出图" in r["source"], r["source"]
print(" ★ 机位候选阶段不出图(纯粗渲染观察)")
def test_capture_photo_bad_shot_index_is_readable():
"""★ shot_index 越界 ⇒ 可读失败 + 列出可用范围。"""
from mcfold.photo import capture_photo
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = capture_photo(ws, (5.0, 0.0, 0.0), store="/nonexistent", shot_index=99)
assert r["ok"] is False, r
assert "越界" in r["why"], r["why"]
assert r.get("how_to"), r
assert r.get("shot_options"), "应列出可用机位"
print(f" ✓ 机位越界:可读失败({r['why']})")
# ---------- ⑥ HUD 隐藏是硬要求(plan: 截图必须隐藏 HUD) ----------
def test_world_renderer_exposes_set_view():
"""★★ 桥层有能力 ≠ 上层有入口。
回归:桥层 `RenderBackend.set_view` 早就实现了完整 `view` 命令
(含 hide_gui → 官方 options.hideGui),但 `WorldRenderer` **没有**
这个入口。我在 fine.py 里调 `renderer.set_view(hide_gui=True)`,
撞上 AttributeError,又被 `except: pass` 吞掉
⇒ **出图带着 HUD**(血量/热键栏/教学提示),
而返回值里还写着 `hide_gui: True` —— 自相矛盾且无人发现。
判据:WorldRenderer 必须暴露 set_view,且能透传到桥层。
"""
from mcfold.render import WorldRenderer
assert hasattr(WorldRenderer, "set_view"), \
"WorldRenderer 缺 set_view(桥层有但上层没入口)"
# 透传验证:用假桥看请求内容
calls = []
class _B:
def set_view(self, **kw):
calls.append(kw)
return {"ok": True}
r = WorldRenderer.__new__(WorldRenderer)
r.bridge = _B()
out = r.set_view(hide_gui=True)
assert out == {"ok": True}
assert calls == [{"hide_gui": True}], calls
print(" ★★ WorldRenderer.set_view 存在且透传 hide_gui")
def test_hide_gui_failure_is_not_swallowed():
"""★★ hide_gui 设不上必须**显式失败**,不能被 except: pass 吞掉。
静默吞掉的后果:出图带 HUD,而调用方以为已隐藏 ——
「以为设了」与「实际没设」无法区分。
"""
import mcfold.render as render_mod
import mcfold.fine as fine
class _R:
def __init__(self):
self.bridge = _FakeBridge()
self.chunks = {} # inject_world 会写
self._loaded = set() # inject_world 会写
def set_blocks(self, *a, **k):
return self.bridge.set_blocks(*a, **k)
def set_render_distance(self, *a):
return {}
def set_view(self, **kw):
raise AttributeError("set_view") # 模拟「后端不支持」
def set_viewport(self, *a, **k):
return {}
def camera(self, *a):
return None
def screenshot(self, **k):
raise AssertionError("不该走到截图:hide_gui 已失败")
def close(self):
return None
# ★ 要 patch **render 模块**里的类(fine 是函数内延迟导入的)
orig_open = render_mod.WorldRenderer.open
render_mod.WorldRenderer.open = classmethod(lambda cls: _R())
try:
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = fine.render_from_state(ws, store="/tmp/x", hide_gui=True)
assert r["ok"] is False, f"hide_gui 失败应报错,实际 {r}"
assert "HUD" in r["why"], r
assert r.get("how_to"), r
finally:
render_mod.WorldRenderer.open = orig_open
print(" ★★ hide_gui 设不上:显式失败(不静默带 HUD 出图)")
def test_hide_gui_false_is_allowed():
"""★ 明确要求带 HUD 时(hide_gui=False)不该报错。"""
import mcfold.render as render_mod
import mcfold.fine as fine
shots = []
class _R:
def __init__(self):
self.bridge = _FakeBridge()
self.chunks = {}
self._loaded = set()
def set_blocks(self, *a, **k):
return self.bridge.set_blocks(*a, **k)
def set_render_distance(self, *a):
return {}
def set_view(self, **kw):
raise AssertionError("hide_gui=False 时不该调 set_view")
def set_viewport(self, *a, **k):
return {}
def camera(self, *a):
return None
def screenshot(self, **k):
shots.append(k)
return type("R", (), {"path": "/tmp/ok.png"})()
def close(self):
return None
orig = render_mod.WorldRenderer.open
render_mod.WorldRenderer.open = classmethod(lambda cls: _R())
try:
ws = _scene(lambda x, y, z: GRASS if y < 0 else AIR)
r = fine.render_from_state(ws, store="/tmp/x", hide_gui=False)
assert r["ok"] is True, r
assert shots, "应走到截图"
finally:
render_mod.WorldRenderer.open = orig
print(" ✓ hide_gui=False:允许带 HUD 出图")
if __name__ == "__main__":
for fn in [test_section_linear_index_matches_java_layout,
test_chunks_to_sections_uses_section_index,
test_missing_sections_are_omitted_not_zeroed,
test_inject_world_reports_counts,
test_inject_world_survives_write_failure,
test_inject_world_empty_is_honest,
test_render_from_state_empty_world_is_readable_failure,
test_render_from_state_bad_store_is_readable_failure,
test_propose_shots_gives_reasons,
test_propose_shots_yaw_points_at_target,
test_facing_yaw_convention,
test_propose_shots_empty_world_still_returns_options,
test_photo_uses_rough_for_candidates_not_rendering,
test_capture_photo_bad_shot_index_is_readable,
test_world_renderer_exposes_set_view,
test_hide_gui_failure_is_not_swallowed,
test_hide_gui_false_is_allowed]:
fn()
print("\n细渲染+拍照:全部通过")