#!/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 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: ") 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())