From eac80677f4eb27f1e02a9e45f0a3490cbc009c52 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Sun, 30 Aug 2026 11:50:07 +0000 Subject: [PATCH] re: the rest() fallback -- its example dissolved, the question got bigger ui-resting-pose.md built its dwell-fallback section on GP_TITLE build 7's ptlogo_eff3.t32, listing keyframes [46, 61, 103, -] -- the STALE PARSER's output. Corrected they are [0, 46, 61, 103], the longest gap moves from 61->103 to 0->46, and BOTH ends of the new longest gap are a=0. The element no longer selects a visible pose under either indexing, and build 7 renders byte-identical under the corrected and legacy readings (0 px differ). MISSION lists this element as the one case a Japanese capture was needed to discriminate; it is not. But losing an example is not closing a question, so: disc-wide census. The fallback fires on 2 305 of 13 991 elements and returns a VISIBLE pose in 1 697 of them -- 74 %. GP_TITLE is 5 fires, 4 visible, and all four are on the SPLASH screens: palogo_sqex_eff and palogo_anima_eff, each [0:a0 15:a255 30:a212 45:a0], a flash peaking at 15 and dead by 45 where the fallback returns t=30 a=212. Independently converged on from the other side: the port, working from the JP capture and knowing nothing of this census, found ptlogo_back2eff1's rest.t at the peak of its own 4-unit sparkle with six staggered across the logo, so --pose=rest fires every sparkle at once -- a frame the game never shows. Consequence recorded as a rule: a render posed at rest is a legitimate common reference for comparing two DECODERS and is not a frame to score against a capture of the game. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v --- .../examples/rest_fallback_census.rs | 77 +++++++++++++++++++ .../examples/rest_fallback_title.rs | 27 +++++++ docs/re/data/rest-fallback-census.txt | 47 +++++++++++ docs/re/structures/ui-resting-pose.md | 55 ++++++++++++- 4 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 crates/sylpheed-formats/examples/rest_fallback_census.rs create mode 100644 crates/sylpheed-formats/examples/rest_fallback_title.rs create mode 100644 docs/re/data/rest-fallback-census.txt diff --git a/crates/sylpheed-formats/examples/rest_fallback_census.rs b/crates/sylpheed-formats/examples/rest_fallback_census.rs new file mode 100644 index 00000000..f5bd7caf --- /dev/null +++ b/crates/sylpheed-formats/examples/rest_fallback_census.rs @@ -0,0 +1,77 @@ +//! When the resting-pose DWELL FALLBACK actually runs, does it pick a visible pose? +//! +//! `ui-resting-pose.md` argues the fallback is structurally unsound — the gap it +//! maximises is time spent *interpolating*, so neither endpoint is held. Its one +//! worked example, `GP_TITLE` build 7's `ptlogo_eff3.t32`, **no longer +//! discriminates**: under the corrected keyframe-record layout the longest gap +//! moved from `61→103` to `0→46`, and both ends of that are `a = 0`. The page's +//! listing still shows the stale parser's trailing `-`. +//! +//! Losing the example is not the same as closing the question, so: disc-wide, how +//! often does the fallback fire, and when it does, does it return something the +//! player would see? An element resting at `a = 0` is harmless whichever end the +//! rule lands on. +//! +//! cargo run -p sylpheed-formats --example rest_fallback_census + +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 mut paks: Vec = std::fs::read_dir(root.join("dat")) + .expect("dat/") + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == "pak")) + .collect(); + paks.sort(); + + let (mut elements, mut plateau, mut fallback, mut fb_visible) = (0usize, 0, 0, 0); + let mut worst: Vec<(u32, String, String)> = Vec::new(); + let mut per_pak: std::collections::BTreeMap = Default::default(); + for pak in &paks { + let Ok(ar) = PakArchive::open(pak) else { continue }; + let name = pak.file_name().unwrap().to_string_lossy().to_string(); + for (i, e) in ar.entries().iter().enumerate() { + let Ok(by) = ar.read(e) else { continue }; + let Some(b) = ui_layout::parse_build(&by) else { continue }; + for el in &b.elements { + if el.keyframes.len() < 2 { continue } + elements += 1; + // a plateau is two ADJACENT poses that are equal — the same test + // the plateau path makes before the fallback can run + let has_plateau = el.keyframes.windows(2).any(|w| { + w[0].x == w[1].x && w[0].y == w[1].y + && w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y + && w[0].fade == w[1].fade + }); + if has_plateau { plateau += 1; continue } + fallback += 1; + per_pak.entry(name.clone()).or_default().0 += 1; + let Some(r) = el.rest() else { continue }; + let a = (r.fade >> 24) & 0xff; + if a > 0 { + fb_visible += 1; + per_pak.entry(name.clone()).or_default().1 += 1; + worst.push((a, name.clone(), format!("e{i}/{}", el.name))); + } + } + } + } + println!("POPULATION: {elements} elements with >= 2 keyframes, over {} archives", paks.len()); + println!("COVERAGE: {plateau} have a plateau (fallback never runs)"); + println!(" {fallback} have NONE -> the dwell fallback decides"); + println!(" {fb_visible} of those rest at alpha > 0 -- i.e. VISIBLE\n"); + println!("PER ARCHIVE — fallback fires / of those, rests VISIBLE:"); + let mut rows: Vec<_> = per_pak.into_iter().collect(); + rows.sort_by(|a, b| b.1.1.cmp(&a.1.1)); + for (pak, (fires, vis)) in &rows { + println!(" {pak:34} {fires:5} fires {vis:5} visible"); + } + println!(); + worst.sort_by(|a, b| b.0.cmp(&a.0)); + for (a, pak, el) in worst.iter().take(6) { + println!(" a={a:3} {pak} {el}"); + } + println!("\n--- END OF CENSUS (if this line is missing, the run did not finish) ---"); +} diff --git a/crates/sylpheed-formats/examples/rest_fallback_title.rs b/crates/sylpheed-formats/examples/rest_fallback_title.rs new file mode 100644 index 00000000..c526b78a --- /dev/null +++ b/crates/sylpheed-formats/examples/rest_fallback_title.rs @@ -0,0 +1,27 @@ +//! Which `GP_TITLE` elements does the resting-pose dwell fallback decide, and does +//! it hand back a visible pose? The disc-wide census says 5 fires / 4 visible here. +//! cargo run -p sylpheed-formats --example rest_fallback_title +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"); + for (i, e) in ar.entries().iter().enumerate() { + let Ok(by) = ar.read(e) else { continue }; + let Some(b) = ui_layout::parse_build(&by) else { continue }; + for el in &b.elements { + if el.keyframes.len() < 2 { continue } + if el.keyframes.windows(2).any(|w| w[0].x == w[1].x && w[0].y == w[1].y + && w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y + && w[0].fade == w[1].fade) { continue } + let Some(r) = el.rest() else { continue }; + let a = (r.fade >> 24) & 0xff; + let ks: Vec = el.keyframes.iter() + .map(|k| format!("{}:a{}", k.time.map(|v| v.to_string()).unwrap_or("-".into()), (k.fade >> 24) & 0xff)) + .collect(); + println!("entry {i:2} {:24} rest a={a:3} t={:?} [{}]{}", + el.name, r.time, ks.join(" "), + if a > 0 { " <== VISIBLE" } else { "" }); + } + } +} diff --git a/docs/re/data/rest-fallback-census.txt b/docs/re/data/rest-fallback-census.txt new file mode 100644 index 00000000..8a9e5c37 --- /dev/null +++ b/docs/re/data/rest-fallback-census.txt @@ -0,0 +1,47 @@ +# The resting-pose DWELL FALLBACK, disc-wide -- and the element that 'exposed' +# it no longer does. 2026-08-30. +# +# examples/rest_fallback_census.rs + rest_fallback_title.rs +# +POPULATION: 13991 elements with >= 2 keyframes, over 33 archives +COVERAGE: 11686 have a plateau (fallback never runs) + 2305 have NONE -> the dwell fallback decides + 1697 of those rest at alpha > 0 -- i.e. VISIBLE + +# +# GP_TITLE, every element the fallback decides: +# entry 7 ptlogo_eff3.t32 rest a= 0 t=Some(0) [0:a0 46:a0 61:a255 103:a0] +# entry 10 palogo_sqex_eff.t32 rest a=212 t=Some(30) [0:a0 15:a255 30:a212 45:a0] <== VISIBLE +# entry 11 palogo_anima_eff.t32 rest a=212 t=Some(30) [0:a0 15:a255 30:a212 45:a0] <== VISIBLE +# entry 13 palogo_sqex_eff.t32 rest a=212 t=Some(30) [0:a0 15:a255 30:a212 45:a0] <== VISIBLE +# entry 14 palogo_anima_eff.t32 rest a=212 t=Some(30) [0:a0 15:a255 30:a212 45:a0] <== VISIBLE +# +# 1. THE MISSION-FLAGGED DISCRIMINATOR IS GONE. ui-resting-pose.md built its +# fallback section on GP_TITLE build 7's ptlogo_eff3.t32, listing its +# keyframes as [46, 61, 103, -] -- the STALE PARSER's output, times shifted +# by one with an untimed final pose. Corrected: [0, 46, 61, 103]. +# stale gaps 15, 42 -> longest 61->103, one end is a=255 at 200% +# fresh gaps 46, 15, 42 -> longest 0->46, BOTH ends a=0 +# So the element no longer selects a visible pose under either indexing, +# and build 7 renders BYTE-IDENTICAL under the corrected and legacy +# readings (0 pixels differ, max delta 0). MISSION listed this element as +# the one case a Japanese capture was needed to discriminate. It is not. +# +# 2. BUT THE QUESTION IS LIVE AND LARGER. Losing an example is not closing a +# question. Disc-wide the fallback returns a VISIBLE pose 1 697 times out +# of the 2 305 it fires -- 74 %. +# +# 3. GP_TITLE's four are ALL ON THE SPLASH SCREENS: palogo_sqex_eff.t32 and +# palogo_anima_eff.t32 on entries 10/11/13/14, each [0:a0 15:a255 30:a212 +# 45:a0] -- a flash peaking at t=15 and dead by t=45, where the fallback +# returns t=30, a=212. Near the peak of a transient. +# +# 4. INDEPENDENT CONVERGENCE. The port agent, working from the Japanese title +# capture and with no knowledge of this census, found ptlogo_back2eff1's +# rest.t sitting at the peak of its own 4-unit sparkle, and six of them +# staggered across the logo -- so --pose=rest fires every sparkle at once, +# a frame the game never shows. Same phenomenon, opposite direction. +# +# CONSEQUENCE: 'rest' is not a settled pose for these families. A render posed +# at rest is a legitimate common reference for comparing two DECODERS, and is +# NOT a frame to score against a capture of the game. diff --git a/docs/re/structures/ui-resting-pose.md b/docs/re/structures/ui-resting-pose.md index 87eeca9b..e2852ec2 100644 --- a/docs/re/structures/ui-resting-pose.md +++ b/docs/re/structures/ui-resting-pose.md @@ -268,15 +268,40 @@ no pose is held, and the rule is choosing an endpoint of a movement.** ### The element that exposed it -`GP_TITLE` build 7, `ptlogo_eff3.t32` — a transient bloom: +🔴 **This listing is the STALE PARSER's, and the example it supports is dead +(2026-08-30).** The times below are shifted by one with an untimed final pose — +the pre-record-layout-fix reading. See +[CONTAINER-NOTES](../../agents/CONTAINER-NOTES.md) for the trap. + +`GP_TITLE` build 7, `ptlogo_eff3.t32` — a transient bloom, **as it was printed**: ``` 46: (98,42) 100%,100% a=0 61: (108,72) 0%,0% a=0 103: (108,72) 200%,200% a=255 r=80 - -: (108,72) 0%,0% a=0 r=150 + -: (108,72) 0%,0% a=0 r=150 ← stale: shifted, final pose untimed ``` +**and as it actually reads:** + +``` + 0: (98,42) 100%,100% a=0 + 46: (108,72) 0%,0% a=0 + 61: (108,72) 200%,200% a=255 r=80 +103: (108,72) 0%,0% a=0 r=150 +``` + +| | gaps | longest | its two ends | +|---|---|---|---| +| stale | 15, 42 | 61→103 | one is **a=255 at 200 %** — the screen-filling bloom | +| **fresh** | **46, 15, 42** | **0→46** | **both a=0** | + +✅ **So this element no longer discriminates.** `rest()` returns `(98,42) a=0` — +invisible — and build 7 renders **byte-identical** under the corrected and legacy +readings (0 pixels differ, max Δ 0). ⚠️ `MISSION.md` lists this element as the one +case a **Japanese-locale capture** was needed to settle. It is not; that capture was +still worth taking, for the port's `title_jp` question, but not for this. + No two adjacent poses are equal, so there is no plateau. The longest gap is `61 → 103` (42 units), during which the sprite grows from nothing to **200 %** at full alpha while rotating 80°, then collapses again. The rule returns whichever @@ -294,6 +319,32 @@ units brighter. **The element has no resting pose.** It is a flash; after it plays there is nothing. Neither answer is *derived* — one of them is merely harmless. +### 🔴 Losing the example did not close the question — it is larger than one element + +Disc-wide ([`../data/rest-fallback-census.txt`](../data/rest-fallback-census.txt)): + +| | | +|---|---| +| elements with ≥ 2 keyframes | 13 991 | +| have a plateau — the fallback never runs | 11 686 | +| **have none — the fallback decides** | **2 305** | +| **of those, it returns a VISIBLE pose** | **1 697 (74 %)** | + +**`GP_TITLE`: 5 fires, 4 visible** — and all four are on the **splash screens**, +`palogo_sqex_eff.t32` / `palogo_anima_eff.t32` on entries 10/11/13/14. Each reads +`[0:a0 15:a255 30:a212 45:a0]`: a flash peaking at t=15, dead by t=45, and the +fallback returns **t=30, a=212** — near the peak of a transient. + +✅ **Independently converged on from the other side.** The port agent, working from +the Japanese title capture and knowing nothing of this census, found +`ptlogo_back2eff1`'s `rest.t` sitting at the peak of its own **4-unit** sparkle, +with six staggered across the logo — so `--pose=rest` fires every sparkle at once, +a frame the game never shows. + +⚠️ **The consequence, and it is a rule about how a rest render may be used:** a +render posed at `rest` is a legitimate **common reference for comparing two +decoders**, and is **not** a frame to score against a capture of the game. + ### 🔴 What this retracts Last iteration I reported the build 7 render difference as evidence **against**