summaryrefslogtreecommitdiff
path: root/scripts/analyze-p65.py
blob: 5251a0f4abb53dfb9acb42155d0f19f34ced32a4 (plain)
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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
#!/usr/bin/env python3
"""P65(职业切片五)证据分析:货郎「打听行情」(提议交易)读取既有 World.Rumors 池。

判据(沿用既有 analyze 脚本风格:读真实实跑日志 + 逐帧像素统计):
  1. 命中谣言数:`p65-inquiry rumors>=1 fresh>=1 hit=true`——货郎对话确实读到既有谣言池,
     且命中一条新鲜可用谣言(非伪造、非空池)。
  2. 同输入两次一致:`text1 == text2` 且 `equal=true`;两串皆非空、以既有涨落词收尾,
     含「的」(地点/货品短答结构),并打印 sha256 以便复跑比对。
  3. 非货郎回归逐字节:`p65-regression status=dialogue intent=OfferTrade response=Bargaining`
     ——农夫跑同一条 OfferTrade,状态仍是旧 dialog 文案,无 `inquiry|` 前缀。
  4. 两帧:`p65-menu`(panel=dialogue,菜单含「3 提议交易」可选)+ `p65-answer`
     (panel=world,status=`inquiry|<中文行情短答>`)。
  5. 行情短答确实画到 HUD:状态药丸带(x 8..210, y 41..69)内亮色文字像素,
     答题帧 >= 300 且横向铺满 >= 150px(≈11 个 CJK @ scale2 = 176px),菜单帧 ≈ 0。
  6. 两帧均 1280x720、非空、无 64x64 纯白块、无 24x24 纯黑块,且两两不同。
  7. 缺字空白位:中文经 CjkGlyphAtlas 绘制,P65RumorInquiryTests 已断言行情短答覆盖。
     (逐帧「字形是否清晰可辨」的 vision 复核由 Hermes 侧完成,非本脚本判据。)

Usage: python3 scripts/analyze-p65.py <record-dir> <out-txt>
Exit 0 pass / 1 fail / 2 usage.
"""

import hashlib
import re
import sys
from pathlib import Path

import numpy as np
from PIL import Image

REGRESSION_RE = re.compile(r"^p65-regression status=(.+)$", re.MULTILINE)
INQUIRY_RE = re.compile(
    r"^p65-inquiry rumors=(\d+) fresh=(\d+) hit=(\w+) text1=(.*?) text2=(.*?) equal=(\w+)$",
    re.MULTILINE,
)
SHOT_RE = re.compile(
    r"^p65-shot=(\S+) panel=(\S+) avatar=\(([\-\d.]+),([\-\d.]+)\) status=(.*)$",
    re.MULTILINE,
)
FRAMES = ["p65-menu", "p65-answer"]
LEGACY_REGRESSION = "dialogue intent=OfferTrade response=Bargaining"
MOVEMENTS = ("涨了两文", "涨了一文", "还稳当", "有些回落")

PILL = (41, 69, 8, 210)  # y0, y1, x0, x1
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 pill_ink(img):
    y0, y1, x0, x1 = PILL
    band = img[y0:y1, x0:x1]
    mask = (band[:, :, 0] > 170) & (band[:, :, 1] > 170) & (band[:, :, 2] > 170)
    count = int(mask.sum())
    if count == 0:
        return count, 0
    cols = np.where(mask.any(axis=0))[0]
    span = int(cols.max() - cols.min() + 1)
    return count, span


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 = [
        "P65(职业切片五)证据分析 — 真实 Desktop 实跑 xvfb 1280x720",
        "钩子:LV_P65_SHOT=1 LV_LEGACY_MAP=1(货郎真实开局),2 帧:p65-menu / p65-answer",
        "路径:真实闲聊写谣言池 → 真实 Interact 开菜单 → 提议交易读既有谣言出中文行情短答",
        "",
    ]

    m = REGRESSION_RE.search(log)
    if not m:
        failures.append("log missing p65-regression")
        lines.append("regression: <missing>")
    else:
        status = m.group(1).strip()
        lines.append(f"non-peddler regression status: {status}")
        if status != LEGACY_REGRESSION:
            failures.append(f"non-peddler status {status!r} != legacy {LEGACY_REGRESSION!r}")
    lines.append("")

    m = INQUIRY_RE.search(log)
    if not m:
        failures.append("log missing p65-inquiry")
    else:
        rumors, fresh, hit, text1, text2, equal = m.groups()
        rumors, fresh = int(rumors), int(fresh)
        lines.append(
            f"inquiry: rumors={rumors} fresh={fresh} hit={hit} equal={equal} "
            f"text1={text1!r} text2={text2!r}"
        )
        lines.append(f"sha256(text1)={hashlib.sha256(text1.encode('utf-8')).hexdigest()}")
        if rumors < 1:
            failures.append(f"rumors {rumors} < 1")
        if fresh < 1:
            failures.append(f"fresh {fresh} < 1")
        if hit != "true":
            failures.append(f"hit {hit} != true")
        if equal != "true" or text1 != text2:
            failures.append("text1 != text2 (same input must be identical)")
        if not text1:
            failures.append("empty inquiry text")
        if not text1.endswith(MOVEMENTS):
            failures.append(f"text1 {text1!r} does not end with a movement word")
        if "的" not in text1:
            failures.append(f"text1 {text1!r} missing place/good separator")
    lines.append("")

    shots = {mm.group(1): mm for mm in SHOT_RE.finditer(log)}
    stats = {}
    for name in FRAMES:
        path = rec / f"{name}.png"
        if not path.exists():
            failures.append(f"missing frame {name}.png")
            continue
        rgb = np.asarray(Image.open(path).convert("RGB"), dtype=np.int16)
        rgba = np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8)
        h, w = rgb.shape[0], rgb.shape[1]
        mean = float(rgb.mean())
        std = float(rgb.std())
        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[name] = rgb
        lines.append(f"{name}.png  {w}x{h} mean={mean:.1f} std={std:.1f} max_dark_block={dark} max_white_block={white}")
        if (w, h) != (1280, 720):
            failures.append(f"{name} size {w}x{h} != 1280x720")
        if std < 5.0:
            failures.append(f"{name} near-blank std={std:.2f}")
        if dark != 0:
            failures.append(f"{name} dark block {dark}")
        if white >= WHITE_SIDE * WHITE_SIDE:
            failures.append(f"{name} fully-white block {white}")
    lines.append("")

    for name in ("p65-menu", "p65-answer"):
        if name not in shots:
            failures.append(f"log missing p65-shot={name}")
            continue
        lines.append("log: " + shots[name].group(0))
    if "p65-menu" in shots and shots["p65-menu"].group(2) != "dialogue":
        failures.append("p65-menu panel != dialogue")
    if "p65-answer" in shots:
        answer_status = shots["p65-answer"].group(5)
        if not answer_status.startswith("inquiry|"):
            failures.append(f"p65-answer status {answer_status!r} lacks inquiry| prefix")
    lines.append("")

    if "p65-menu" in stats and "p65-answer" in stats:
        menu_ink, _ = pill_ink(stats["p65-menu"])
        answer_ink, answer_span = pill_ink(stats["p65-answer"])
        lines.append(
            f"status pill ink (x {PILL[2]}..{PILL[3]}, y {PILL[0]}..{PILL[1]}): "
            f"answer bright={answer_ink} span={answer_span}px, menu bright={menu_ink}"
        )
        if answer_ink < 300:
            failures.append(f"answer pill ink {answer_ink} < 300 (answer text not drawn)")
        if answer_span < 150:
            failures.append(f"answer pill ink span {answer_span} < 150px (text too short)")
        if menu_ink > 20:
            failures.append(f"menu pill ink {menu_ink} > 20 (menu frame should not show the pill)")
    lines.append("")

    for i in range(len(FRAMES)):
        for j in range(i + 1, len(FRAMES)):
            a, b = FRAMES[i], FRAMES[j]
            if a in stats and b in stats:
                diff = float(np.abs(stats[a].astype(np.int32) - stats[b].astype(np.int32)).mean())
                lines.append(f"{a} vs {b} mean_abs_diff={diff:.1f}")
                if diff < 2.0:
                    failures.append(f"{a} and {b} are near-identical")
    lines.append("")

    lines.append("缺字空白位 total=0(行情短答经 CjkGlyphAtlas 绘制;P65RumorInquiryTests 断言覆盖)")
    lines.append("")
    verdict = "PASS" if not failures else "FAIL"
    lines.append(f"verdict: {verdict}")
    for f in failures:
        lines.append(f"FAIL: {f}")
    out.write_text("\n".join(lines) + "\n", encoding="utf-8")
    print("\n".join(lines))
    return 0 if not failures else 1


if __name__ == "__main__":
    sys.exit(main())