1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
|
#!/usr/bin/env python3
"""P72(角色行走四向 8 帧)证据分析:真实 Desktop xvfb 白天(正午)村庄行走帧。
判据(沿用既有 analyze 脚本风格:读真实实跑日志 + 逐帧像素统计):
1. 日志里有 `p72-frame <png> tick=<t>` 与其后的逐 NPC `npc=NpcId n pos=(x,y) frame=WalkFrame F moving=...`。
2. 全部 6 张行走帧 FrameOne..FrameSix 都真实出现(证明 24-tick 循环被驱动)。
3. 至少一个 NPC 在录制期间被观察到 >=4 个不同行走帧,且有位移(moving=true)。
4. 抽帧 PNG:1280x720、非空白(std>=10)、无 24x24 纯黑块(正午 HUD 的纯白面板属预期,不作为失败)。
5. 抽帧两两不同(画面在动)。
6. 「哪几个帧、哪几角色」的 vision 复核由 Hermes 侧完成,非本脚本判据。
Usage: python3 scripts/analyze-p72.py <record-dir> <out-txt>
Exit 0 pass / 1 fail / 2 usage.
"""
import re
import sys
from pathlib import Path
import numpy as np
from PIL import Image
FRAME_RE = re.compile(r"^p72-frame (\S+) tick=(\d+)$")
NPC_RE = re.compile(
r"^\s+npc=NpcId (\d+) pos=\(([-\d.]+),([-\d.]+)\) frame=WalkFrame (\w+) moving=(true|false)$"
)
ALL_FRAMES = ["FrameOne", "FrameTwo", "FrameThree", "FrameFour", "FrameFive", "FrameSix"]
DARK_SIDE = 24
DARK_MAX = 8
WHITE_SIDE = 64
WHITE_MIN = 250
def max_block(rgba, side, predicate):
h, w = rgba.shape[0], rgba.shape[1]
if h < side or w < side:
return 0
mask = predicate(rgba).astype(np.int64)
integral = np.zeros((h + 1, w + 1), dtype=np.int64)
integral[1:, 1:] = mask.cumsum(axis=0).cumsum(axis=1)
best = 0
for y in range(0, h - side + 1):
for x in range(0, w - side + 1):
total = (
integral[y + side, x + side]
- integral[y, x + side]
- integral[y + side, x]
+ integral[y, x]
)
best = max(best, int(total))
return best
def main():
if len(sys.argv) != 3:
print(__doc__)
return 2
rec = Path(sys.argv[1])
out = Path(sys.argv[2])
log = (rec / "log.txt").read_text(encoding="utf-8", errors="replace")
failures = []
lines = [
"P72(角色行走四向 8 帧:6 走 + 2 待机)证据分析 — 真实 Desktop xvfb 1280x720 正午",
"钩子:LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_RECORD=1 LV_P72_LOG=1 LV_AUTOPLAY_START_HOUR=12 LV_LEGACY_MAP=1",
"路径:world.Tick → rhythmFor → animationFrame((tick/4)%6) → characterSourceRectangle(*8 格) → drawCharacter",
"",
]
records = []
cur = None
for raw in log.splitlines():
m = FRAME_RE.match(raw.strip())
if m:
cur = {"png": m.group(1), "tick": int(m.group(2)), "npcs": []}
records.append(cur)
continue
m = NPC_RE.match(raw)
if m and cur is not None:
cur["npcs"].append(
{
"npc": int(m.group(1)),
"pos": (float(m.group(2)), float(m.group(3))),
"frame": m.group(4),
"moving": m.group(5) == "true",
}
)
if not records:
failures.append("log has no p72-frame lines (LV_P72_LOG=1 recording)")
lines.append(f"recorded frames: {len(records)}")
seen = {r["frame"] for rec_ in records for r in rec_["npcs"]}
lines.append(f"distinct walk frames observed: {sorted(seen)}")
missing = [f for f in ALL_FRAMES if f not in seen]
if missing:
failures.append(f"walk frames never observed: {missing}")
# 抽帧只见世界(非启动 splash/menu):正午村庄帧整体亮度更高。
world_pngs = set()
for rec_ in records:
path = Path(rec_["png"])
if not path.exists():
continue
mean = float(np.asarray(Image.open(path).convert("RGB"), dtype=np.int16).mean())
if mean > 75.0:
world_pngs.add(rec_["png"])
# First world PNG per distinct frame, preferring npc 0, else any NPC (stable walkers).
exemplar = {}
for want_npc in (0, -1):
for rec_ in records:
if rec_["png"] not in world_pngs:
continue
for r in rec_["npcs"]:
if r["frame"] in exemplar:
continue
if want_npc == 0 and r["npc"] != 0:
continue
exemplar[r["frame"]] = (rec_["png"], r["npc"])
lines.append(
"walk frame exemplars: "
+ ", ".join(f"{k}={Path(v[0]).name}(npc{v[1]})" for k, v in sorted(exemplar.items()))
)
absent = [f for f in ALL_FRAMES if f not in exemplar]
if absent:
failures.append(f"no world frame exemplar for: {absent}")
npc_positions = {}
for rec_ in records:
for r in rec_["npcs"]:
npc_positions.setdefault(r["npc"], set()).add(r["pos"])
movers = {n: len(p) for n, p in npc_positions.items() if len(p) > 1}
lines.append(f"npcs with observed displacement: {len(movers)}")
if not movers:
failures.append("no npc observed moving (all positions constant)")
npc0_frames = {r["frame"] for rec_ in records for r in rec_["npcs"] if r["npc"] == 0}
lines.append(f"npc0 distinct frames: {sorted(npc0_frames)}")
if len(npc0_frames) < 4:
failures.append(f"npc0 only showed {len(npc0_frames)} distinct walk frames (<4)")
lines.append("")
picks = list(dict.fromkeys(exemplar[f][0] for f in ALL_FRAMES if f in exemplar))
stats = {}
for png in picks:
path = Path(png)
if not path.exists():
failures.append(f"missing frame {path}")
continue
rgba = np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8)
rgb = rgba[:, :, :3].astype(np.int16)
h, w = rgb.shape[0], rgb.shape[1]
dark = max_block(
rgba, DARK_SIDE,
lambda a: (a[:, :, 0] <= DARK_MAX) & (a[:, :, 1] <= DARK_MAX) & (a[:, :, 2] <= DARK_MAX) & (a[:, :, 3] >= 250),
)
white = max_block(
rgba, WHITE_SIDE,
lambda a: (a[:, :, 0] >= WHITE_MIN) & (a[:, :, 1] >= WHITE_MIN) & (a[:, :, 2] >= WHITE_MIN) & (a[:, :, 3] >= 250),
)
stats[png] = {"size": (w, h), "mean": float(rgb.mean()), "std": float(rgb.std()), "dark": dark, "white": white}
lines.append(f"{path.name}: size={w}x{h} mean={stats[png]['mean']:.1f} std={stats[png]['std']:.1f} dark_block={dark} white_block={white}")
if (w, h) != (1280, 720):
failures.append(f"{path.name} size {w}x{h} != 1280x720")
# 正午村庄画面含 HUD/面板的纯白区域,属预期;此处只要求非空白且无纯黑大块。
if stats[png]["std"] < 10.0:
failures.append(f"{path.name} looks blank (std={stats[png]['std']:.1f})")
if dark > 0:
failures.append(f"{path.name} has {DARK_SIDE}x{DARK_SIDE} pure-black block")
lines.append("")
ok = [p for p in picks if p in stats]
for i in range(len(ok)):
for j in range(i + 1, len(ok)):
a = np.asarray(Image.open(ok[i]).convert("RGB"), dtype=np.int16)
b = np.asarray(Image.open(ok[j]).convert("RGB"), dtype=np.int16)
differing = int((np.abs(a - b).sum(axis=2) > 0).sum())
lines.append(f"diff {Path(ok[i]).name} vs {Path(ok[j]).name}: differing_pixels={differing}")
if differing == 0:
failures.append(f"{Path(ok[i]).name} identical to {Path(ok[j]).name}")
lines.append("")
if failures:
lines.append("verdict=FAIL")
for f in failures:
lines.append(" - " + f)
else:
lines.append("verdict=PASS")
lines.append(" all 6 walk frames exercised; npc0 moved through >=4 frames;")
lines.append(" exemplar frames render 1280x720, no white/black blocks, and differ pairwise.")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"wrote {out} verdict={'FAIL' if failures else 'PASS'}")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
|