diff --git a/crates/sylpheed-formats/examples/ptloop_leaf_keyframes.rs b/crates/sylpheed-formats/examples/ptloop_leaf_keyframes.rs new file mode 100644 index 00000000..b22a7e77 --- /dev/null +++ b/crates/sylpheed-formats/examples/ptloop_leaf_keyframes.rs @@ -0,0 +1,37 @@ +//! The sweep leaves' RAW keyframes, so a segment rate can be checked not assumed. +//! +//! `sylpheed-port` reports `pteff03` as +4.0000 px/unit over t 0..150 and +4.0000 +//! again over 150..540 -- perfectly linear -- against `pteff03a` at -4.0667 then +//! -4.0625. That asymmetry is what makes their "inversion" observation sharp: my +//! linearity gate fails on the leaf whose source is exactly straight. It is their +//! number from their export, so it is worth deriving independently. +//! +//! cargo run -p sylpheed-formats --example ptloop_leaf_keyframes +use sylpheed_formats::{pak::PakArchive, ui_layout}; +use std::path::PathBuf; + +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 by = ar.read(&ar.entries()[4]).expect("entry 4"); + let b = ui_layout::parse_build(&by).expect("parse"); + for parent in ["ptloop01", "ptloop02"] { + let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else { continue }; + let Some(&(off, size)) = b.records.get(&el.name) else { continue }; + let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else { continue }; + for le in &lb.elements { + println!("\n== {} -> leaf {}", el.name, le.name); + let ks: Vec<_> = le.keyframes.iter().collect(); + for w in ks.windows(2) { + let (a, c) = (w[0], w[1]); + match (a.time, c.time) { + (Some(t0), Some(t1)) if t1 > t0 => println!( + " t {t0:>4} -> {t1:<4} x {:>6} -> {:<6} = {:+.4} px/unit", + a.x, c.x, (c.x - a.x) as f64 / (t1 - t0) as f64), + _ => println!(" t {:?} -> {:?} x {} -> {} (no rate)", a.time, c.time, a.x, c.x), + } + } + } + } + println!("--- END ---"); +} diff --git a/docs/game/navigation.md b/docs/game/navigation.md index 4aeae34c..5f272918 100644 --- a/docs/game/navigation.md +++ b/docs/game/navigation.md @@ -321,7 +321,22 @@ Traps that read as bugs but are not, all measured ✅: if you must use `-9`, `rm -f /tmp/xenia-canary.lock` after. **And assert the emulator is alive before entering any wait loop** — `ps -C xenia_canary` — so the loop cannot spend its whole deadline on nothing. -* 🔴🔴 **THE ENTRY BELOW IS WITHDRAWN — the experiment was confounded.** When it +* ✅ **Ⓐ on a settled boot title DOES reach the main menu — re-run clean, + 2026-08-30, and the withdrawn counter-example below is now explained.** One + trial, **exactly one emulator verified by count**, gated on the plate pulse + (glyph in [500, 2500] held 12 samples) so the press lands on the boot title + rather than the attract loop's. Delivery confirmed (`[file-pad] keystroke + vk=5800 down`/`up`). Glyph after the press: `0, 0` at +2 s and +4 s — the + transition — then **327 steady from +6 s to +39 s**. ⚠️ 327 is a proxy, so the + screen was checked with [`which_title_screen.py`](../../tools/re-capture/which_title_screen.py) + instead: **`main_menu` at RMSE 19.91 and 20.08, margin ~10**, inside the 9.9–11.7 + band its control establishes on four known captures. The `before` frame gives + the "neither" signature (margin 0.10), correctly, since the title is neither. + 📌 **Latency 4–6 s**, which is why a script that presses and looks 0.5 s later + concludes the press was dropped. So the count is now **3 of 3**, and the two + earlier failures were the three-emulator confound, not the game. + +* ~~🔴🔴 **THE ENTRY BELOW IS WITHDRAWN — the experiment was confounded.**~~ When it ran, **three emulators were live at once** (started 15:39, 15:44 and 16:12 on 2026-08-30), all reading the same `/tmp/xenia_pad.txt` and sharing display `:98`. A press written to that file is delivered to **every** instance, and `screenshot` diff --git a/docs/re/main-is-the-stale-era.md b/docs/re/main-is-the-stale-era.md index 99507e92..41c84058 100644 --- a/docs/re/main-is-the-stale-era.md +++ b/docs/re/main-is-the-stale-era.md @@ -128,6 +128,30 @@ build warns them. The port is insulated only because their exporter pins the cost of not doing it: the fix is measured-correct against the oracle, and it is reachable only from a topic branch. +## 🔴 Third structural consequence: HANDOFF itself is not delivered + +`sylpheed-port` raised this and it is the sharpest one yet. **`docs/port/HANDOFF.md` +is the contract — "an answer not reachable from there is not delivered" — and it +lives on an unmerged branch.** Their checkout is `origin/main`, 145 commits behind; +the HANDOFF they read contains none of this week's entries. So findings written +into the contract reach them **only through messages**, which is precisely the +channel the protocol says does not count as delivery. + +Nothing either agent did is wrong. **Writing it in the contract is necessary and +not sufficient when the contract lives on a branch the other party cannot see.** +They have mirrored the load-bearing statuses into their own `BLOCKED.md` and +`DECISIONS.md`, sourced to a sha, so their tree does not depend on a HANDOFF they +cannot read — which is the right local fix and not a substitute for the merge. + +Three consequences now, all recorded rather than worked around: + +1. every published ref except this branch **builds the decoder the game disagrees + with** (the measured cost is above); +2. the port's `Cargo.toml` pin is **load-bearing, not vestigial** — they had been + checking each iteration whether they could drop it, which would have silently + downgraded their exporter; +3. **the delivery contract does not reach the party it exists to serve.** + ## What this does not settle * **Why the `title_jp` measurement disagrees with the port's.** Recorded above with diff --git a/tools/re-capture/a_press_reliability.py b/tools/re-capture/a_press_reliability.py new file mode 100755 index 00000000..2e343823 --- /dev/null +++ b/tools/re-capture/a_press_reliability.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Does Ⓐ on a SETTLED BOOT title reach the main menu? One clean trial. + +navigation.md carried "the boot title accepts a single Ⓐ (2 of 2 runs)". I +reported a counter-example, then WITHDREW it: three emulators were live at once, +all reading /tmp/xenia_pad.txt and sharing display :98, so a press reached every +instance while `screenshot` grabbed whichever window was topmost. That left the +claim unsupported rather than refuted, and this is the clean re-run. + +Gated on the plate pulse (glyph in [500,2500] held 12 samples) so the press lands +on the BOOT title, not the attract loop's -- the attract title is documented as +accepting nothing, and a single `screen_id.py` classification cannot tell them +apart. + +After the press it samples every ~2 s for 40 s and reports the glyph series, so +the outcome AND the latency are visible, and a null result is distinguishable +from a slow one. + + a_press_reliability.py OUTDIR [wait_s] +""" +import subprocess, sys, time, os +import numpy as np +from PIL import Image + +OUT = sys.argv[1]; WAIT = float(sys.argv[2]) if len(sys.argv) > 2 else 900 +W, H = 1280, 720 +NEED, CEIL, HOLD = 500, 2500, 12 + +def _open(): + return subprocess.Popen( + ["ffmpeg","-loglevel","error","-f","x11grab","-draw_mouse","0", + "-video_size",f"{W}x{H}","-i",":98","-r","4","-f","rawvideo", + "-pix_fmt","rgb24","-"], stdout=subprocess.PIPE, bufsize=W*H*3*2) + +def glyph(a): + r,g,b = a[:,:,0],a[:,:,1],a[:,:,2] + return int(((g>130)&(g-r>45)&(g-b>45)).sum()) + +def tap(btn, secs=0.5): + for st in (f"press={btn}",""): + with open("/tmp/xenia_pad.txt.tmp","w") as f: f.write(st) + os.replace("/tmp/xenia_pad.txt.tmp","/tmp/xenia_pad.txt") + if st: time.sleep(secs) + +T0=time.time(); p,n,seg,streak=_open(),W*H*3,time.time(),0 +state="wait"; t_press=None; samples=[] +while time.time()-T0 < WAIT: + if time.time()-seg > 30: p.kill(); p=_open(); seg=time.time() + buf=p.stdout.read(n) + if len(buf)=HOLD: + print(f"[{time.time()-T0:7.1f}s] BOOT TITLE SETTLED (glyph {c}) — pressing A", flush=True) + Image.fromarray(a).save(f"{OUT}/before.png") + tap("A",0.5); t_press=time.time(); state="watch"; last=0 + elif state=="watch": + el=time.time()-t_press + if el-last >= 2.0: + last=el; samples.append((el,c)) + Image.fromarray(a).save(f"{OUT}/after-{int(el):02d}.png") + print(f" +{el:5.1f}s glyph {c}", flush=True) + if el > 40: break +p.kill() +print("\nglyph series after the press:", [f"{e:.0f}s:{g}" for e,g in samples], flush=True) +print("A-PRESS TRIAL DONE", flush=True) diff --git a/tools/re-capture/a_press_session.sh b/tools/re-capture/a_press_session.sh new file mode 100755 index 00000000..374aa434 --- /dev/null +++ b/tools/re-capture/a_press_session.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# One clean trial of Ⓐ on the settled boot title. Exactly one emulator, verified. +set -u +export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98 +SD="$(cd "$(dirname "$0")" && pwd)" +OUT="${1:-/sylph-home/re/apress}"; mkdir -p "$OUT" +. "$SD/ensure_single_emulator.sh" +ensure_single_emulator || exit 3 +( cd "$OUT" && nohup run-canary --mem_watch=false \ + --logged_profile_slot_0_xuid=B13EBABEBABEBABE \ + >"$OUT/canary.stdout" 2>"$OUT/canary.stderr" & ) +sleep 10 +ps -C xenia_canary >/dev/null 2>&1 || { echo "EMULATOR DID NOT START:"; tail -3 "$OUT/canary.stderr"; exit 4; } +echo "emulator alive: $(ps -C xenia_canary --no-headers | wc -l) instance(s)" +timeout 1000 python3 "$SD/a_press_reliability.py" "$OUT" 900 +echo "--- pad delivery, from the emulator's own log ---" +grep -c "RE-INPUT" "$OUT/canary.stdout" 2>/dev/null | sed 's/^/[RE-INPUT] lines: /' +grep -oE "\[file-pad\] keystroke vk=[0-9a-fA-F]+ (down|up)" "$OUT/canary.stdout" 2>/dev/null | tail -4 +echo "A-PRESS SESSION DONE"