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:
247
mcfold/fine.py
Normal file
247
mcfold/fine.py
Normal 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
212
mcfold/photo.py
Normal 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"),
|
||||
}
|
||||
@ -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
47
plan.md
@ -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
459
tests/test_photo.py
Normal 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细渲染+拍照:全部通过")
|
||||
Reference in New Issue
Block a user