#!/usr/bin/env python3 """P53 proximity-prompt / input-isolation analysis. Reads the `LV_P53_REC=1` frame sequence + /log.txt and writes docs/evidence/p53-recording.txt with per-frame measured behaviour: 1. sequence state from the real game log: far -> prompt -> dialog -> gone, with the explicit resolved target (prompt "E 交谈" => TalkTo (NpcId 0)); 2. pixel proof inside the logged prompt rect: the prompt frame paints there (vs the far frame) and the gone frame restores it; 3. the dialogue frame is a full overlay (differs from the world frames), and the prompt is gone while the layer is open (transient, not a panel). Usage: python3 scripts/analyze-p53.py Exit 0 when checks pass, 1 when they fail, 2 on usage/read error. """ import re import sys from pathlib import Path import numpy as np from PIL import Image REC_RE = re.compile(r"p53-rec=(\S+) panel=(\S+) prompt=(.*?) target=(.*?) rect=(\S+)") FRAMES = ["p53-far", "p53-prompt", "p53-dialog", "p53-gone"] PROMPT_PAINT_MIN = 12.0 GONE_RESTORE_MAX = 6.0 DIALOG_DIFF_MIN = 4.0 def fail(lines, out_txt, message): lines.append(f"FAIL: {message}") lines.append("") lines.append("verdict: FAIL") text = "\n".join(lines) + "\n" if out_txt is not None: out_txt.parent.mkdir(parents=True, exist_ok=True) out_txt.write_text(text, encoding="utf-8") print(text) return 1 def load(path): return np.asarray(Image.open(path).convert("RGB"), dtype=np.float64) def region(img, rect, margin=10): x, y, w, h = rect x0 = max(0, x - margin) y0 = max(0, y - margin) x1 = min(img.shape[1], x + w + margin) y1 = min(img.shape[0], y + h + margin) return img[y0:y1, x0:x1] def main(argv): if len(argv) != 3: sys.stderr.write(__doc__) return 2 record_dir = Path(argv[1]) out_txt = Path(argv[2]) log_path = record_dir / "log.txt" if not log_path.exists(): sys.stderr.write(f"missing {log_path}\n") return 2 lines = ["P53 proximity-prompt / input-isolation analysis (LV_P53_REC=1)", ""] frames = {} for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines(): match = REC_RE.search(line) if match: frames[match.group(1)] = { "panel": match.group(2), "prompt": match.group(3), "target": match.group(4), "rect": match.group(5), } for name in FRAMES: if name not in frames: return fail(lines, out_txt, f"missing log frame {name}") record = frames[name] lines.append(f"{name}: panel={record['panel']} prompt={record['prompt']} target={record['target']} rect={record['rect']}") lines.append("") if frames["p53-far"]["prompt"] != "none": return fail(lines, out_txt, "far frame must not show a prompt") if frames["p53-prompt"]["prompt"] != "E 交谈": return fail(lines, out_txt, "approach frame must show the E 交谈 prompt") if "TalkTo (NpcId 0)" not in frames["p53-prompt"]["target"]: return fail(lines, out_txt, "approach frame must target the explicit village NpcId 0") if frames["p53-dialog"]["panel"] != "dialogue": return fail(lines, out_txt, "interact frame must open the dialogue layer") if frames["p53-dialog"]["prompt"] != "none": return fail(lines, out_txt, "prompt must be gone while the dialogue layer is open") if frames["p53-gone"]["prompt"] != "none": return fail(lines, out_txt, "after leaving, the prompt must be gone") if frames["p53-prompt"]["rect"] == "none": return fail(lines, out_txt, "approach frame must report the prompt rect") images = {} for name in FRAMES: path = record_dir / f"{name}.png" if not path.exists(): return fail(lines, out_txt, f"missing frame {path}") images[name] = load(path) std = float(images[name].std()) lines.append(f"frame {name}: {images[name].shape[1]}x{images[name].shape[0]} std={std:.1f}") if std < 5.0: return fail(lines, out_txt, f"frame {name} looks blank (std {std:.1f})") rect = tuple(int(v) for v in frames["p53-prompt"]["rect"].split(",")) paint_delta = float(np.abs(region(images["p53-prompt"], rect) - region(images["p53-far"], rect)).mean()) restore_delta = float(np.abs(region(images["p53-gone"], rect) - region(images["p53-far"], rect)).mean()) dialog_delta = float(np.abs(images["p53-dialog"] - images["p53-prompt"]).mean()) lines += [ "", f"prompt rect={rect} paint delta (prompt vs far) = {paint_delta:.2f} (>= {PROMPT_PAINT_MIN})", f"prompt rect restore delta (gone vs far) = {restore_delta:.2f} (<= {GONE_RESTORE_MAX})", f"dialogue overlay delta (dialog vs prompt, full frame) = {dialog_delta:.2f} (>= {DIALOG_DIFF_MIN})", "", ] if paint_delta < PROMPT_PAINT_MIN: return fail(lines, out_txt, "prompt rect did not change when the prompt appeared") if restore_delta > GONE_RESTORE_MAX: return fail(lines, out_txt, "prompt rect did not restore after leaving") if dialog_delta < DIALOG_DIFF_MIN: return fail(lines, out_txt, "dialogue overlay frame does not differ from the world frame") lines.append("verdict: PASS") text = "\n".join(lines) + "\n" out_txt.parent.mkdir(parents=True, exist_ok=True) out_txt.write_text(text, encoding="utf-8") print(text) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))