From e35c56a5ede2d42b0cfbe429fd16e9d11c14afe0 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Sun, 30 Aug 2026 22:05:03 +0000 Subject: [PATCH] re: refutation attempt on Q2's 'shipped twice' -- it survives, with one real caveat The doubt was my own artefact: the entry dump printed only the first two sprite names in HashMap order, making 11 and 14 look like different studios. Full sets are identical. 7 of 8 pairs declare identical sprite sets, control included. 4/7 does not: entry 7 carries nine sprites entry 4 lacks, including ptlogo_jp and ptlogo_jpeff, so the Japanese title is a different element inventory rather than the same screen localised. That matches the JP capture work from the other side. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v --- .../examples/gp_title_pair_check.rs | 42 ++++ docs/re/data/gp-title-pair-check.txt | 57 +++++ tools/re-capture/extras_focus_persistence.py | 207 +++++++++--------- 3 files changed, 201 insertions(+), 105 deletions(-) create mode 100644 crates/sylpheed-formats/examples/gp_title_pair_check.rs create mode 100644 docs/re/data/gp-title-pair-check.txt diff --git a/crates/sylpheed-formats/examples/gp_title_pair_check.rs b/crates/sylpheed-formats/examples/gp_title_pair_check.rs new file mode 100644 index 00000000..66b853d3 --- /dev/null +++ b/crates/sylpheed-formats/examples/gp_title_pair_check.rs @@ -0,0 +1,42 @@ +//! Is `GP_TITLE.pak` really "8 screens shipped twice, EN/JP"? +//! +//! The Q2 headline says each screen appears twice. The entry dump raised a +//! doubt: entry 11 shows `palogo_gamearts` and entry 14 shows `palogo_seta`, +//! which are different studios, not a language pair. If the two halves of a +//! "pair" declare different sprites, "shipped twice" is the wrong description of +//! at least that pair. +//! +//! CONTROL: a pair known to be a real EN/JP pair must come out as matching. 2/3 +//! (the PRESS Ⓐ plate) is byte-identical in size and is the control. +//! +//! cargo run -p sylpheed-formats --example gp_title_pair_check +use sylpheed_formats::{pak::PakArchive, ui_layout}; +use std::collections::BTreeSet; +use std::path::PathBuf; + +fn sprites(ar: &PakArchive, i: usize) -> BTreeSet { + let Ok(by) = ar.read(&ar.entries()[i]) else { return BTreeSet::new() }; + ui_layout::parse_build(&by) + .map(|b| b.sprites.keys().cloned().collect()) + .unwrap_or_default() +} + +fn main() { + let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC")); + let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak"); + let pairs = [(0, 1, "loading plain"), (2, 3, "PRESS (A) plate [CONTROL]"), + (4, 7, "title art"), (5, 8, "main menu"), (6, 9, "EXTRAS"), + (10, 13, "publisher splash"), (11, 14, "developer splash"), + (12, 15, "loading dressed")]; + for (a, b, what) in pairs { + let (sa, sb) = (sprites(&ar, a), sprites(&ar, b)); + let only_a: Vec<_> = sa.difference(&sb).cloned().collect(); + let only_b: Vec<_> = sb.difference(&sa).cloned().collect(); + let shared = sa.intersection(&sb).count(); + let verdict = if only_a.is_empty() && only_b.is_empty() { "IDENTICAL SET" } + else { "DIFFERS" }; + println!("\n{a:2}/{b:<2} {what:26} {shared:3} shared {verdict}"); + if !only_a.is_empty() { println!(" only in {a}: {only_a:?}"); } + if !only_b.is_empty() { println!(" only in {b}: {only_b:?}"); } + } +} diff --git a/docs/re/data/gp-title-pair-check.txt b/docs/re/data/gp-title-pair-check.txt new file mode 100644 index 00000000..23763f27 --- /dev/null +++ b/docs/re/data/gp-title-pair-check.txt @@ -0,0 +1,57 @@ +# Does "GP_TITLE is 8 screens shipped twice, EN/JP" hold pair by pair? +# 2026-08-30. Refutation attempt on sylpheed-port's Q2 headline. IT SURVIVES. +# +# THE DOUBT: the entry dump showed entry 11 with `palogo_gamearts` and entry 14 +# with `palogo_seta` -- different studios, not a language pair. If the halves of +# a pair declare different sprites, "shipped twice" is the wrong description. +# +# ❌ THE DOUBT WAS MY OWN ARTEFACT. That dump printed only the FIRST TWO sprite +# names per entry, in HashMap order, which is not stable. Comparing the full +# sets, 11 and 14 are an IDENTICAL SET of 6 -- one screen listing all three +# studios. A truncated listing in unspecified order is not evidence of anything. +# +# CONTROL: 2/3, the PRESS (A) plate, is a known real EN/JP pair and must come out +# matching. It does. +# +# tools: crates/sylpheed-formats/examples/gp_title_pair_check.rs + + 0/1 loading plain 7 shared IDENTICAL SET + + 2/3 PRESS (A) plate [CONTROL] 2 shared IDENTICAL SET + + 4/7 title art 15 shared DIFFERS + only in 4: ["pteff01.t32", "ptlogoall_eff.t32", "ptlogoall_eff2.t32"] + only in 7: ["ptlogo3a.t32", "ptlogo3b.t32", "ptlogo3c.t32", "ptlogo_all_eff.t32", "ptlogo_back1.t32", "ptlogo_eff2.t32", "ptlogo_eff3.t32", "ptlogo_jp.t32", "ptlogo_jpeff.t32"] + + 5/8 main menu 21 shared IDENTICAL SET + + 6/9 EXTRAS 20 shared IDENTICAL SET + +10/13 publisher splash 2 shared IDENTICAL SET + +11/14 developer splash 6 shared IDENTICAL SET + +12/15 loading dressed 9 shared IDENTICAL SET + +# ------------------------------------------------------------------------------ +# ✅ THE HEADLINE SURVIVES: 7 of 8 pairs declare IDENTICAL sprite sets, including +# both splashes and the control. +# +# 🟡 BUT ONE PAIR IS NOT A PAIR IN THE SAME SENSE. 4/7, the title art, DIFFERS: +# only in 4: pteff01, ptlogoall_eff, ptlogoall_eff2 +# only in 7: ptlogo3a, ptlogo3b, ptlogo3c, ptlogo_all_eff, ptlogo_back1, +# ptlogo_eff2, ptlogo_eff3, ptlogo_jp, ptlogo_jpeff +# +# Entry 7 carries NINE sprites entry 4 does not, including ptlogo_jp and +# ptlogo_jpeff. So the Japanese title is a different ELEMENT INVENTORY, not the +# same screen with different text -- which is the same fact the JP capture work +# already recorded from the other side ("the katakana subtitle, and a crystalline +# burst BEHIND the wordmark, which the English title lacks"). +# +# ⚠️ So "8 screens shipped twice" is right as a COUNT and as a structure, and the +# port's entry map agrees. It is misleading only if read as "the two halves are +# the same screen localised", which is false for exactly one pair -- and that +# pair is the title, the screen most of this corpus's capture work is about. +# +# REACH: sprite-name sets only. Two entries could share every sprite name and +# still place them differently; this does not check placements or keyframes. diff --git a/tools/re-capture/extras_focus_persistence.py b/tools/re-capture/extras_focus_persistence.py index f51aded1..fb63a1c2 100644 --- a/tools/re-capture/extras_focus_persistence.py +++ b/tools/re-capture/extras_focus_persistence.py @@ -1,27 +1,23 @@ #!/usr/bin/env python3 """Does the EXTRAS submenu remember its cursor across leave -> re-enter? -sylpheed-port's contract-check asserted that EXTRAS does NOT persist. Nothing -measured that. The corpus has EXTRAS' initial focus as MISSION SELECT from a -SINGLE entry, and Ⓑ-from-a-submenu restoring the PARENT's focus 4/4 -- neither -says what a submenu's own cursor does on re-entry. Their label -`initial_focus: ptbtn11, kind: "measured"` is exposed to the same history problem -raised for the main menu: taken on one entry, it may be reading history. +sylpheed-port's contract-check asserts that EXTRAS does NOT persist. Nothing +measured that: the corpus has EXTRAS' initial focus from a SINGLE entry, and +Ⓑ-from-a-submenu restoring the PARENT's focus 4/4 -- neither says what a +submenu's own cursor does on re-entry. The main menu was measured to PERSIST, so +the question is live in both directions, and their `kind: "measured"` label on +`initial_focus: ptbtn11` turns on it. -The main menu was measured to PERSIST (focus-persists-across-title.txt), so the -question is live in both directions. +🔴 RUN 1 (2026-08-30) WAS VOID AND ITS FAILURES SHAPE THIS FILE: + * it navigated to OPTIONS believing it was EXTRAS -- the focus reader used + design-space rows against x11grab frames (menu-focus-reader-offset.txt); + * its screen detector could not tell the main menu from a submenu, because + both sit inside the glyph 250..420 window (main menu 327, OPTIONS 317); + * its control checked only that the ring MOVED, which a wrong origin passes. -⚠️ NO HAND-GUESSED GEOMETRY. menu_focus.py's ys are the five-item main menu and -do not transfer to a three-item submenu, and the only EXTRAS capture in the -corpus is a 562x182 crop. So the ring is located by DIFFERENCE instead: the -pixels that change when the cursor moves ARE the ring's footprint. Persistence is -then decided inside that mask, which needs no coordinates at all. - -🔴 CONTROL, and the run stops on it: the E1->E2 difference must be ONE COMPACT -CLUSTER. If moving the cursor changes the whole frame, the mask is not the ring -and nothing after it may be read. (sylpheed-port measured one 46x44 cluster, -0.155 % of frame, across a full ring cycle on their renderer -- an independent -expectation for what this should look like.) +So this run: absolute row checks against a measured calibration, screen identity +against a REFERENCE FRAME captured in this same run, and raw ring ROWS compared +inside the submenu -- no submenu geometry is assumed or needed. extras_focus_persistence.py LOG OUTDIR [wait_s] """ @@ -29,13 +25,13 @@ import os, re, subprocess, sys, time import numpy as np from PIL import Image +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from ring_row import ring_row, main_menu_item, NAMES, ROW0, SPACING + LOG, OUT = sys.argv[1], sys.argv[2] WAIT = float(sys.argv[3]) if len(sys.argv) > 3 else 600 W, H = 1280, 720 -MENU_LO, MENU_HI, MENU_HOLD = 250, 420, 6 PAD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pad.py") -NAMES = ["NEW GAME", "LOAD GAME", "TUTORIAL", "OPTIONS", "EXTRAS"] -YS = [166, 241, 315, 390, 465] T0 = time.time() @@ -56,7 +52,7 @@ def press(btn, vk, tries=5): if deliveries(vk) > before: print(f"[{time.time()-T0:7.1f}s] {btn} delivered", flush=True) return True - print(f"[{time.time()-T0:7.1f}s] {btn} NOT delivered (attempt {k+1})", flush=True) + print(f"[{time.time()-T0:7.1f}s] 🔴 {btn} NEVER delivered", flush=True) return False @@ -69,117 +65,118 @@ def _open(): def fresh(): - """A CURRENT frame. Reading the pipe after a sleep returns a buffered one.""" q = _open(); a = None for _ in range(3): - buf = q.stdout.read(W * H * 3) - if len(buf) == W * H * 3: - a = np.frombuffer(buf, np.uint8).reshape(H, W, 3).astype(int) + b = q.stdout.read(W * H * 3) + if len(b) == W * H * 3: + a = np.frombuffer(b, np.uint8).reshape(H, W, 3).astype(int) q.kill() return a +def img(a): + return Image.fromarray(a.astype(np.uint8)) + + +def differs(a, b): + return float((np.abs(a - b).max(axis=2) > 24).mean()) + + def glyph(a): - r, g, b = a[:, :, 0], a[:, :, 1], a[:, :, 2] - return int(((g > 130) & (g - r > 45) & (g - b > 45)).sum()) + r, g, bl = a[:, :, 0], a[:, :, 1], a[:, :, 2] + return int(((g > 130) & (g - r > 45) & (g - bl > 45)).sum()) -def mainfocus(a): - g = np.asarray(Image.fromarray(a.astype(np.uint8)).convert("L"), dtype=float) - v = [g[y - 20:y + 20, 500:542].max() for y in YS] - return int(np.argmax(v)), v - - -def wait_menu(limit=90): - t = time.time(); streak = 0 +def wait_until(pred, what, limit=60): + t = time.time() while time.time() - t < limit: a = fresh() - if a is None: - continue - if MENU_LO <= glyph(a) <= MENU_HI: - streak += 1 - if streak >= 2: - return a - else: - streak = 0 + if a is not None and pred(a): + print(f"[{time.time()-T0:7.1f}s] {what}", flush=True) + return a + print(f"[{time.time()-T0:7.1f}s] 🔴 TIMEOUT waiting for {what}", flush=True) return None os.makedirs(OUT, exist_ok=True) -# ---- 1. on the main menu, walk to EXTRAS ------------------------------------ -a = wait_menu() -if a is None: - sys.exit("never saw the main menu") -f, v = mainfocus(a) -print(f"[{time.time()-T0:7.1f}s] MAIN MENU focus = {NAMES[f]} ring " - + " ".join(f"{x:5.0f}" for x in v), flush=True) -need = (4 - f) % 5 -print(f" {need} DOWN to reach EXTRAS", flush=True) -for _ in range(need): +# ---- 1. main menu: reference frame + calibrated focus ------------------------ +MAIN = wait_until(lambda a: main_menu_item(ring_row(img(a))) is not None + and 250 <= glyph(a) <= 420, "MAIN MENU reference captured", 120) +if MAIN is None: + sys.exit("never identified the main menu") +img(MAIN).save(f"{OUT}/0-main-ref.png") +f = main_menu_item(ring_row(img(MAIN))) +print(f" focus = {NAMES[f]} (ring y {ring_row(img(MAIN))})", flush=True) + +# ---- 2. walk to EXTRAS, checking the ABSOLUTE row after every press ---------- +for step in range((4 - f) % 5): if not press("DOWN", "5811"): sys.exit("a DOWN was never delivered") - time.sleep(0.8) -time.sleep(1.5) -a = fresh(); f2, v2 = mainfocus(a) -print(f" now on {NAMES[f2]} ring " + " ".join(f"{x:5.0f}" for x in v2), flush=True) -if f2 != 4: - sys.exit(f"CONTROL FAILED: wanted EXTRAS, on {NAMES[f2]}") -print("✅ on EXTRAS", flush=True) + time.sleep(1.2) + a = fresh(); y = ring_row(img(a)); i = main_menu_item(y) + want = (f + step + 1) % 5 + print(f" step {step+1}: ring y {y} -> {NAMES[i] if i is not None else '??'} " + f"(want {NAMES[want]})", flush=True) + if i != want: + sys.exit(f"🔴 CONTROL FAILED: after {step+1} DOWN the ring is not on {NAMES[want]}") +print("✅ on EXTRAS, verified by absolute row after every press", flush=True) -# ---- 2. enter EXTRAS, capture E1, move, capture E2 -------------------------- +# ---- 3. enter EXTRAS -- and prove we LEFT the main menu ---------------------- if not press("A", "5800"): sys.exit("A never delivered") -time.sleep(6.0) -E1 = fresh(); Image.fromarray(E1.astype(np.uint8)).save(f"{OUT}/E1.png") -print(f"[{time.time()-T0:7.1f}s] IN EXTRAS (glyph {glyph(E1)}) — E1 captured", flush=True) +E1 = wait_until(lambda a: differs(a, MAIN) > 0.20, "left the main menu", 40) +if E1 is None: + sys.exit("A did not change the screen") +time.sleep(3.0) +E1 = fresh(); img(E1).save(f"{OUT}/E1.png") +y1 = ring_row(img(E1)) +print(f"[{time.time()-T0:7.1f}s] E1 in the submenu: ring y = {y1}, " + f"glyph {glyph(E1)}, {100*differs(E1, MAIN):.1f}% from main", flush=True) +# ---- 4. move the cursor, control on an ABSOLUTE change ---------------------- if not press("DOWN", "5811"): - sys.exit("a DOWN was never delivered inside EXTRAS") + sys.exit("a DOWN was never delivered inside the submenu") time.sleep(2.5) -E2 = fresh(); Image.fromarray(E2.astype(np.uint8)).save(f"{OUT}/E2.png") -print(f"[{time.time()-T0:7.1f}s] E2 captured after 1 DOWN", flush=True) +E2 = fresh(); img(E2).save(f"{OUT}/E2.png") +y2 = ring_row(img(E2)) +print(f"[{time.time()-T0:7.1f}s] E2 after 1 DOWN: ring y = {y2}", flush=True) +if y1 is None or y2 is None: + sys.exit("🔴 CONTROL FAILED: no ring found in the submenu — this reader does not work here") +if abs(y2 - y1) < 20: + sys.exit(f"🔴 CONTROL FAILED: the ring did not move ({y1} -> {y2})") +print(f"✅ CONTROL PASSED: the ring moved {y1} -> {y2} ({abs(y2-y1):.1f} px)", flush=True) -# ---- 3. CONTROL: the difference must be one compact cluster ----------------- -d = (np.abs(E1 - E2).max(axis=2) > 24) -frac = d.mean() -ys, xs = np.nonzero(d) -if len(ys) == 0: - sys.exit("🔴 CONTROL FAILED: the cursor did not move at all (E1 == E2)") -bbox = (int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())) -bw, bh = bbox[2] - bbox[0] + 1, bbox[3] - bbox[1] + 1 -dens = len(ys) / (bw * bh) -print(f" diff: {100*frac:.3f}% of frame, bbox {bw}x{bh} at ({bbox[0]},{bbox[1]}), " - f"fill {100*dens:.0f}%", flush=True) -if frac > 0.05: - sys.exit(f"🔴 CONTROL FAILED: {100*frac:.1f}% of the frame changed — that is not a ring") -print("✅ CONTROL PASSED: the move changed one localised region", flush=True) - -# ---- 4. leave, re-enter, capture E3 ---------------------------------------- +# ---- 5. leave, prove we are back on the main menu, re-enter ----------------- if not press("B", "5801"): sys.exit("B never delivered") -a = wait_menu() -if a is None: - sys.exit("B did not return to the main menu") -f3, _ = mainfocus(a) -print(f"[{time.time()-T0:7.1f}s] back on the MAIN MENU, focus = {NAMES[f3]}", flush=True) +back = wait_until(lambda a: differs(a, MAIN) < 0.15, "back on the MAIN MENU (vs reference)", 60) +if back is None: + sys.exit("🔴 B did not return to the main menu — refusing to read E3") if not press("A", "5800"): sys.exit("A never delivered on re-entry") -time.sleep(6.0) -E3 = fresh(); Image.fromarray(E3.astype(np.uint8)).save(f"{OUT}/E3.png") -print(f"[{time.time()-T0:7.1f}s] RE-ENTERED EXTRAS — E3 captured", flush=True) +E3 = wait_until(lambda a: differs(a, MAIN) > 0.20, "left the main menu again", 40) +if E3 is None: + sys.exit("re-entry did not change the screen") +time.sleep(3.0) +E3 = fresh(); img(E3).save(f"{OUT}/E3.png") +y3 = ring_row(img(E3)) +same_screen = differs(E3, E1) < 0.15 +print(f"[{time.time()-T0:7.1f}s] E3 on re-entry: ring y = {y3}, " + f"{100*differs(E3, E1):.1f}% from E1 — same screen: {same_screen}", flush=True) -# ---- 5. decide, inside the ring mask only ---------------------------------- -m = d -d1 = float(np.abs(E3 - E1).max(axis=2)[m].mean()) -d2 = float(np.abs(E3 - E2).max(axis=2)[m].mean()) -print(f"\n inside the ring mask ({int(m.sum())} px):") -print(f" mean |E3 - E1| = {d1:6.2f} (E1 = where EXTRAS opened)") -print(f" mean |E3 - E2| = {d2:6.2f} (E2 = where I left it)") -if d2 < d1 * 0.5: - print("\n=> EXTRAS PERSISTS: re-entry matches where I left the cursor") -elif d1 < d2 * 0.5: - print("\n=> EXTRAS RESETS: re-entry matches where it first opened") +# ---- 6. decide ------------------------------------------------------------ +print(f"\n E1 (opened on) ring y = {y1}") +print(f" E2 (left it on) ring y = {y2}") +print(f" E3 (re-entered) ring y = {y3}") +if not same_screen: + print("\n=> VOID: re-entry is not the same screen; nothing measured") +elif y3 is None: + print("\n=> VOID: no ring on re-entry") +elif abs(y3 - y2) < 20 and abs(y3 - y1) >= 20: + print("\n=> EXTRAS PERSISTS: re-entry is where I left the cursor") +elif abs(y3 - y1) < 20 and abs(y3 - y2) >= 20: + print("\n=> EXTRAS RESETS: re-entry is where it first opened") else: - print(f"\n=> UNDECIDED: {d1:.2f} vs {d2:.2f} are too close to separate") + print(f"\n=> UNDECIDED: |E3-E1|={abs(y3-y1):.1f} |E3-E2|={abs(y3-y2):.1f}") print("EXTRAS FOCUS RUN DONE", flush=True)