summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/analyze-p53.py142
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))