diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/analyze-p53.py | 142 |
1 files changed, 142 insertions, 0 deletions
diff --git a/scripts/analyze-p53.py b/scripts/analyze-p53.py new file mode 100644 index 0000000..69a9a7b --- /dev/null +++ b/scripts/analyze-p53.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""P53 proximity-prompt / input-isolation analysis. + +Reads the `LV_P53_REC=1` frame sequence + <record-dir>/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 <record-dir> <out-txt> + +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)) |
