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

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细渲染+拍照:全部通过")