summaryrefslogtreecommitdiff
path: root/scripts/analyze-p53.py
blob: 69a9a7b44f97311c8c6454234ff86461fdbc2f2d (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
#!/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))