diff --git a/crates/sylpheed-formats/examples/_keys.rs b/crates/sylpheed-formats/examples/_keys.rs new file mode 100644 index 00000000..a92c7a2e --- /dev/null +++ b/crates/sylpheed-formats/examples/_keys.rs @@ -0,0 +1,21 @@ +use sylpheed_formats::{pak, ui_layout}; +fn main(){ + let mut a=std::env::args().skip(1); + let pk=a.next().unwrap(); + let ar=pak::PakArchive::open(pk).unwrap(); + for t in a { + let i:usize=t.parse().unwrap(); + let by=ar.read(&ar.entries()[i]).unwrap(); + let b=ui_layout::parse_build(&by).unwrap(); + println!("=== entry {i} ==="); + let order=ui_layout::derived_paint_order(&b,&by); + for e in &b.elements { + let k=ui_layout::sprite_layer_key(&b,&by,e); + let pos=order.iter().position(|&x|x==e.index); + println!(" [{}] {:<24} kind=0x{:<5x} sprite={:<24} key={:<12} paint#{:?}", + e.index, e.name, e.kind, + e.sprite.clone().unwrap_or_else(||"".into()), + k.map(|v|format!("0x{v:08x}")).unwrap_or_else(||"NONE".into()), pos); + } + } +} diff --git a/crates/sylpheed-formats/examples/prm_alpha_census.rs b/crates/sylpheed-formats/examples/prm_alpha_census.rs new file mode 100644 index 00000000..21b26b02 --- /dev/null +++ b/crates/sylpheed-formats/examples/prm_alpha_census.rs @@ -0,0 +1,50 @@ +//! Does a primitive's alpha AT t=0 predict the layer it paints on? +//! +//! `implied_layer_key` is a measured per-name table. The four entries in it, read +//! against their own keyframes, suggest a rule derived from the file instead: +//! +//! * `palogo_eff0.prm` measured FIRST (0x0000) -- alpha at t=0 = ? +//! * `pfbase.tbm` measured FIRST (0x0000) -- alpha at t=0 = ? +//! * `pteff02.prm` measured MIDDLE (0x8030) -- alpha at t=0 = ? +//! * `pteff00.prm` measured LAST -- alpha at t=0 = ? +//! +//! ⚠️ The rule was invented AFTER seeing three of those answers, so it is fitted +//! on them and only `pfbase.tbm` is out of sample. This prints all four plus a +//! disc-wide census, so the fit and its reach are visible together. +use sylpheed_formats::{pak, ratc, ui_layout}; +use std::collections::BTreeMap; + +fn main() { + let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into()); + let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/") + .flatten().map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect(); + paks.sort(); + // name -> (count, set of t=0 alphas, set of "starts at max" flags) + let mut byname: BTreeMap)> = BTreeMap::new(); + for p in &paks { + let Ok(ar) = pak::PakArchive::open(p) else { continue }; + for e in ar.entries() { + let Ok(by) = ar.read(e) else { continue }; + if !ratc::is_ratc(&by) { continue } + let Some(b) = ui_layout::parse_build(&by) else { continue }; + for el in &b.elements { + // primitives and the keyless: anything with no layer key + if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue } + let Some(k0) = el.keyframes.first() else { continue }; + let a0 = k0.fade >> 24; + let ent = byname.entry(el.name.clone()).or_default(); + ent.0 += 1; + *ent.1.entry(a0).or_default() += 1; + } + } + } + println!("{:>28} {:>7} alpha at t=0 (count)", "keyless element", "n"); + let known = ["palogo_eff0.prm", "pfbase.tbm", "pteff02.prm", "pteff00.prm"]; + for (n, (c, a)) in &byname { + let tag = if known.contains(&n.as_str()) { " <- IN THE MEASURED TABLE" } else { "" }; + if *c < 4 && tag.is_empty() { continue } + let al: Vec = a.iter().map(|(k, v)| format!("{k}x{v}")).collect(); + println!(" {n:>26} {c:>7} {}{tag}", al.join(" ")); + } +} diff --git a/crates/sylpheed-formats/examples/prm_forced_first.rs b/crates/sylpheed-formats/examples/prm_forced_first.rs new file mode 100644 index 00000000..385e83c6 --- /dev/null +++ b/crates/sylpheed-formats/examples/prm_forced_first.rs @@ -0,0 +1,58 @@ +//! Which keyless primitives have their paint position FORCED by occlusion? +//! +//! An opaque full-screen quad must sort below every element visible at any +//! instant it is opaque. Where that set is *every* other element, its position is +//! forced to first — derived from the file, not analogised from a neighbour. +//! +//! Controls, both measured in the running game and both reproduced here: +//! * `palogo_eff0.prm` is measured painting FIRST — and comes out forced first. +//! * `pteff00.prm` is measured painting LAST — and is forced below only a +//! handful, so the constraint permits it on top. +//! +//! ⚠️ Assumes straight alpha-over blending. Blend mode is ❔ in +//! `ui-prm-primitives.md`; an additive quad at alpha 255 would not occlude. +use sylpheed_formats::{pak, ratc, ui_layout}; + +fn main() { + let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into()); + let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/") + .flatten().map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect(); + paks.sort(); + let (mut forced, mut partial, mut free) = (0usize, 0usize, 0usize); + let mut names: std::collections::BTreeMap = Default::default(); + for p in &paks { + let Ok(ar) = pak::PakArchive::open(p) else { continue }; + for e in ar.entries() { + let Ok(by) = ar.read(e) else { continue }; + if !ratc::is_ratc(&by) { continue } + let Some(b) = ui_layout::parse_build(&by) else { continue }; + let tmax = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)) + .max().unwrap_or(0); + if tmax == 0 { continue } + for el in &b.elements { + if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue } + // full-screen only: a quad that does not cover cannot occlude + if el.pivot_x * 2 < 1280 || el.pivot_y * 2 < 720 { continue } + let op: Vec = (0..=tmax) + .filter(|&t| el.pose_at(t).map(|k| k.fade >> 24) == Some(255)).collect(); + if op.is_empty() { continue } + let others: Vec<&ui_layout::Element> = + b.elements.iter().filter(|o| o.index != el.index).collect(); + if others.is_empty() { continue } + let below = others.iter().filter(|o| + op.iter().any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0)).count(); + let ent = names.entry(el.name.clone()).or_default(); + ent.1 += 1; + if below == others.len() { forced += 1; ent.0 += 1 } + else if below > 0 { partial += 1 } else { free += 1 } + } + } + } + println!("keyless FULL-SCREEN primitives with an opaque interval:"); + println!(" position FORCED FIRST (below every other element) : {forced}"); + println!(" forced below SOME but not all : {partial}"); + println!(" occludes nothing : {free}"); + println!("\nby name — instances forced first / total:"); + for (n, (f, t)) in &names { println!(" {n:>24} {f:>4} / {t}"); } +} diff --git a/crates/sylpheed-formats/examples/prm_occlusion_check.rs b/crates/sylpheed-formats/examples/prm_occlusion_check.rs new file mode 100644 index 00000000..cdbcf96d --- /dev/null +++ b/crates/sylpheed-formats/examples/prm_occlusion_check.rs @@ -0,0 +1,73 @@ +//! An OPAQUE full-screen primitive cannot paint on top of elements that are +//! visible at the same time — the screen would be blank. +//! +//! That is a constraint read off the file, not a preference. For each keyless +//! primitive this computes the interval over which it is opaque, and the interval +//! over which any OTHER element is visible, and reports the overlap. +//! +//! The falsifier: `pteff00.prm` is MEASURED painting last on the title and the +//! main menu. If any of its instances is opaque while content is up, the +//! constraint is wrong and this whole line is dead. +use sylpheed_formats::{pak, ratc, ui_layout}; + +/// Interval(s) where alpha >= `thr`, sampled at every half unit. +fn opaque_span(el: &ui_layout::Element, thr: u32, tmax: u32) -> Vec<(f64, f64)> { + let mut out = Vec::new(); + let mut cur: Option = None; + let mut t = 0.0; + while t <= tmax as f64 { + let a = el.pose_at(t as u32).map(|k| k.fade >> 24).unwrap_or(0); + if a >= thr { + if cur.is_none() { cur = Some(t) } + } else if let Some(s) = cur.take() { + out.push((s, t)); + } + t += 0.5; + } + if let Some(s) = cur { out.push((s, tmax as f64)) } + out +} + +fn main() { + let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into()); + let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/") + .flatten().map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect(); + paks.sort(); + let want = ["pteff00.prm", "pgloading_eff00.prm", "palogo_eff0.prm", + "pfbase.tbm", "pteff02.prm", "pzeff00.prm", "pceff00.prm", "pdeff00.prm"]; + println!("{:>22} {:>5} {:>16} {:>18} overlap", "primitive", "entry", "opaque while", "content visible"); + for p in &paks { + let Ok(ar) = pak::PakArchive::open(p) else { continue }; + for (ei, e) in ar.entries().iter().enumerate() { + let Ok(by) = ar.read(e) else { continue }; + if !ratc::is_ratc(&by) { continue } + let Some(b) = ui_layout::parse_build(&by) else { continue }; + let tmax = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)) + .max().unwrap_or(0); + if tmax == 0 { continue } + for el in &b.elements { + if !want.contains(&el.name.as_str()) { continue } + if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue } + let op = opaque_span(el, 255, tmax); + if op.is_empty() { continue } + // when is any OTHER element visible? + let mut cmin = f64::MAX; let mut cmax = f64::MIN; + for o in &b.elements { + if o.index == el.index { continue } + for (s, t) in opaque_span(o, 1, tmax) { cmin = cmin.min(s); cmax = cmax.max(t) } + } + if cmin > cmax { continue } + // overlap of the primitive's opaque span with the content span + let ov: f64 = op.iter() + .map(|&(s, t)| (t.min(cmax) - s.max(cmin)).max(0.0)).sum(); + let opd: String = op.iter().map(|&(s,t)| format!("{s:.0}-{t:.0}")).collect::>().join(","); + let flag = if ov > 2.0 { " 🔴 CANNOT BE ON TOP" } else { "" }; + println!("{:>22} {:>5} {:>16} {:>18} {ov:6.1}{flag}", + el.name, format!("{}:{}", p.file_name().unwrap().to_string_lossy() + .trim_end_matches(".pak").trim_start_matches("GP_"), ei), + opd, format!("{cmin:.0}-{cmax:.0}")); + } + } + } +} diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index abd14cc3..23cd87a3 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -922,6 +922,79 @@ pub fn implied_layer_key(name: &str) -> Option { /// they land does not affect a composite. Ties keep declaration order — the game /// breaks them some other way, which is unexplained and looks harmless because /// tied elements are same-layer. +/// Is this element an opaque full-screen quad that **must** sort below everything? +/// +/// A keyless primitive has no layer key and the game's own code decides where it +/// paints ([`implied_layer_key`] records the names measured in the running game). +/// For one whole class of them the file settles it without a measurement: an +/// element that covers the screen and is **fully opaque** at some instant cannot +/// paint above anything visible at that instant, or the screen would be blank. +/// Where the elements visible during its opaque span are *all* of them, its +/// position is forced to first. +/// +/// Two controls, both measured in the running game and both reproduced by this +/// rule rather than assumed by it: +/// +/// * `palogo_eff0.prm` is measured painting **first** — and comes out forced +/// first (opaque for 211 instants, below 6 of 6). A rule keyed on the *name* +/// would get this wrong: it is named like an overlay. +/// * `pteff00.prm` is measured painting **last** — and is forced below only 3 of +/// 23 elements on the title, because it is opaque for 2 instants at the screen's +/// entry and exit, so the rule permits it on top. +/// +/// Disc-wide: 80 instances forced first, 50 constrained but not forced, 0 +/// unconstrained. It also explains the 36 dialog builds that composite to one +/// colour — `pzeff00.prm` is forced first in 32 of 32 instances. +/// +/// ⚠️ Assumes straight alpha-over blending. Blend mode is an open question in +/// `docs/re/structures/ui-prm-primitives.md`; an *additive* quad at alpha 255 +/// would not occlude, and this rule would then be placing it wrongly. +pub fn forced_backdrop(build: &UiBuild, el: &Element) -> bool { + // 🔴 Untextured primitives only. A `.t32` sprite's ELEMENT alpha being 255 + // says nothing about whether its texture covers the screen — most of it may + // be transparent, so it occludes nothing. Applied without this guard the + // rule claims 22 textured sprites must sort first, against their own layer + // keys: `pneff01.t32` (key 0xd850, paints #8 of 13) and `pbfriendly.t32` + // (key 0x9230, #17 of 49). Those disagreements are the rule being wrong, + // not the keys. + if el.sprite.is_some() { + return false; + } + if (el.pivot_x * 2) < build.design_w as u32 || (el.pivot_y * 2) < build.design_h as u32 { + return false; + } + let tmax = build + .elements + .iter() + .flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)) + .max() + .unwrap_or(0); + if tmax == 0 { + return false; + } + let opaque: Vec = (0..=tmax) + .filter(|&t| el.pose_at(t).map(|k| k.fade >> 24) == Some(255)) + .collect(); + if opaque.is_empty() { + return false; + } + let mut others = 0usize; + let mut occluded = 0usize; + for o in &build.elements { + if o.index == el.index { + continue; + } + others += 1; + if opaque + .iter() + .any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0) + { + occluded += 1; + } + } + others > 0 && occluded == others +} + pub fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec { let mut idx: Vec = (0..build.elements.len()).collect(); idx.sort_by_key(|&i| { @@ -929,6 +1002,9 @@ pub fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec { ( sprite_layer_key(build, bundle, el) .or_else(|| implied_layer_key(&el.name)) + // An opaque full-screen quad that covers every other element + // while it is opaque cannot be on top — see `forced_backdrop`. + .or_else(|| forced_backdrop(build, el).then_some(0)) .unwrap_or(u32::MAX), i, ) diff --git a/crates/sylpheed-formats/tests/ui_forced_backdrop_disc.rs b/crates/sylpheed-formats/tests/ui_forced_backdrop_disc.rs new file mode 100644 index 00000000..abd2e5be --- /dev/null +++ b/crates/sylpheed-formats/tests/ui_forced_backdrop_disc.rs @@ -0,0 +1,133 @@ +//! An opaque full-screen primitive cannot paint above what it would hide. +//! +//! A keyless primitive has no layer key, and `implied_layer_key` records the +//! handful whose position was measured in the running game. For one class the +//! file settles it without a measurement: an element covering the screen and +//! fully opaque at some instant cannot paint above anything visible then, or the +//! screen is blank. Where that set is *every* other element, the position is +//! forced to first. +//! +//! The port found this by contradiction on `build_12`/`build_15`, which its +//! renderer composited to solid black at every instant of their declared life. +//! +//! Argument, census and reach: `docs/re/structures/ui-forced-backdrop.md`. + +use std::path::PathBuf; + +use sylpheed_formats::{pak::PakArchive, ratc, ui_layout}; + +fn disc_root() -> Option { + let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?); + p.join("dat").is_dir().then_some(p) +} + +fn build(ar: &PakArchive, i: usize) -> (Vec, ui_layout::UiBuild) { + let by = ar.read(&ar.entries()[i]).expect("entry"); + let b = ui_layout::parse_build(&by).expect("parse"); + (by, b) +} + +fn el<'a>(b: &'a ui_layout::UiBuild, name: &str) -> &'a ui_layout::Element { + b.elements.iter().find(|e| e.name == name).expect(name) +} + +/// The two controls are measured orders from the running game. The rule has to +/// reproduce one and permit the other, or it is not measuring occlusion. +#[test] +fn the_rule_reproduces_both_measured_primitives() { + let Some(root) = disc_root() else { + eprintln!("SYLPHEED_DISC unset — skipping"); + return; + }; + let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak"); + + // `palogo_eff0.prm` is MEASURED painting first. Named like an overlay, so a + // name-based rule gets it wrong; occlusion gets it right. + let (_, splash) = build(&ar, 11); + assert!( + ui_layout::forced_backdrop(&splash, el(&splash, "palogo_eff0.prm")), + "the developer splash's backdrop is measured FIRST and must come out forced" + ); + + // `pteff00.prm` is MEASURED painting last. It is opaque only at its screen's + // entry and exit, so the rule must NOT force it down. + for entry in [4usize, 5] { + let (_, b) = build(&ar, entry); + assert!( + !ui_layout::forced_backdrop(&b, el(&b, "pteff00.prm")), + "entry {entry}: pteff00.prm is measured painting LAST and must stay permitted on top" + ); + } +} + +/// The case that prompted it: the loading screens. +#[test] +fn the_loading_screens_backdrop_sorts_first() { + let Some(root) = disc_root() else { + eprintln!("SYLPHEED_DISC unset — skipping"); + return; + }; + let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak"); + for entry in [12usize, 15] { + let (by, b) = build(&ar, entry); + let prim = el(&b, "pgloading_eff00.prm"); + assert!(ui_layout::forced_backdrop(&b, prim), "entry {entry}"); + let order = ui_layout::derived_paint_order(&b, &by); + assert_eq!( + order.first().copied(), + Some(prim.index), + "entry {entry}: the backdrop must be painted first, not last" + ); + } +} + +/// Disc-wide: the rule must fire on a real population and never on something it +/// cannot occlude. +#[test] +fn forced_backdrops_are_full_screen_and_plentiful() { + let Some(root) = disc_root() else { + eprintln!("SYLPHEED_DISC unset — skipping"); + return; + }; + let mut paks: Vec = std::fs::read_dir(root.join("dat")) + .expect("dat/") + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")) + .collect(); + paks.sort(); + let mut forced = 0usize; + for p in &paks { + let Ok(ar) = PakArchive::open(p) else { continue }; + for e in ar.entries() { + let Ok(by) = ar.read(e) else { continue }; + if !ratc::is_ratc(&by) { + continue; + } + let Some(b) = ui_layout::parse_build(&by) else { continue }; + for element in &b.elements { + if !ui_layout::forced_backdrop(&b, element) { + continue; + } + forced += 1; + // 🔴 Untextured only. This assertion caught the rule's real + // limit: applied to `.t32` sprites it claimed 22 of them must + // sort first, against their own layer keys — a sprite's element + // alpha says nothing about its texture's coverage. + assert!( + element.sprite.is_none(), + "{}: a textured sprite cannot be judged to occlude by element alpha", + element.name + ); + assert!( + element.pivot_x * 2 >= b.design_w as u32 + && element.pivot_y * 2 >= b.design_h as u32, + "{}: a quad that does not cover the screen cannot occlude it", + element.name + ); + } + } + } + assert!(forced > 50, "expected a real population, got {forced}"); + eprintln!("{forced} keyless primitives have their position forced to first"); +} diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 9c7354fb..d42d1fb6 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -2203,3 +2203,56 @@ denominator of the 9-unit hold you now ship. crashed the guest (a double Ⓐ tap, now guarded), one drifted to a flight screen. The title answer stands; the menu is unmeasured. +## 2026-08-29 — a keyless primitive's position, where the file forces it + +✅ **decoded — and it answers your `build_12`/`build_15` contradiction. Sort a +layerless element FIRST when it is an opaque full-screen quad; your reading was +right.** + +The rule, and it is a constraint rather than a preference: + +> An element that covers the screen and is **fully opaque** at some instant cannot +> paint above anything visible at that instant. Where the elements visible during +> its opaque span are **all** of them, its position is forced to first. + +`pgloading_eff00.prm` is opaque for **39** instants and all **9** other elements +are visible inside that span → **forced first**, in 4/4 instances. + +**Two controls, both measured orders from the running game, and the first is the +one that matters:** + +| primitive | measured | opaque instants | forced below | rule | +|---|---|---|---|---| +| `palogo_eff0.prm` | **FIRST** | 211 | **6 of 6** | ✅ forced first | +| `pteff00.prm` | **LAST** | 2 | 3 of 23 | ✅ permitted on top | + +🔴 `palogo_eff0.prm` is *named like an overlay*. **A rule that sorts by name gets +it wrong against a measured order; occlusion gets it right.** So do not implement +this as "`*base*` first, `*eff*` last" — that heuristic matches 77 of 80 and fails +exactly on the three families that cross it, `palogo_eff0`, `pgloading_eff00` and +`pzeff00`. + +⚠️ **`pteff00.prm` must stay on top.** It is opaque for only two instants, at its +screen's entry and exit — it is the fade cover. The constraint never binds it, and +its position is still a *measured* per-name entry, not a decoded one. + +✅ **This also explains 36 builds the corpus had recorded as "coming out one +colour" with no cause**: `pzeff00.prm` is forced first in 32 of 32 instances, so +they were wiped by our own sort rather than by the game. + +🔴 **One limit, found when the rule's own disc-wide test failed.** Applied to +`.t32` sprites it claimed 22 must sort first *against their own layer keys* — +`pneff01.t32` (key `0xd850`, #8 of 13), `pbfriendly.t32` (`0x9230`, #17 of 49). A +sprite's **element** alpha says nothing about whether its **texture** covers the +screen. It is now restricted to untextured primitives. If you implement this, +apply the same restriction. + +⚠️ **Reach:** assumes straight alpha-over — blend mode is still ❔, and an additive +quad at alpha 255 would not occlude. It is a lower bound, not an ordering: it +settles the 80 forced cases and says nothing about the 50 that are opaque only +part of the time. And there is **no new oracle measurement** here — both controls +are prior measurements, and a draw capture of a loading screen would confirm it +directly, but the loading screens are not reachable from the title path. + +Detail: [`docs/re/structures/ui-forced-backdrop.md`](../re/structures/ui-forced-backdrop.md). + diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index a66968c0..f4cf7853 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -173,3 +173,4 @@ files, which is how the same ground got covered twice. | [`structures/ui-focus-record-pulse-census.md`](structures/ui-focus-record-pulse-census.md) | Every focus record whose glow pulses, and where `rest()` puts it | ✅ **decoded**, disc-wide: **1 130** focus records, **2 664** timed elements, **210 with a varying alpha** — of which **202** have `rest()` == the **peak** (burns bright forever) and **8** land **mid-ramp**. By pak: `PILOTLOG` 116, `MOVIE_THEATER` 54, `HANGAR_ARSENAL` 30, `LEADERBOARD` 8, **`GP_TITLE` 2**. 🟡 Bounds rather than refutes the port's "34 in the export, 2 varying, nothing to fix" — correct, and correct *because* `GP_TITLE` has 2; the pathology sits in the screens a wider port needs next. 🔴 The 8 mid-ramp ones are the worse mode: `py_ranking_btn01f` swings 255→127→255 and `rest()` returns **244**, neither extreme, which looks entirely plausible and nothing reports it. ✅ Control: `ptbtn01f` is genuinely constant (255 throughout) and is **not** flagged; two hits verified keyframe by keyframe. ⚠️ A pulsing element has no resting pose — the question is malformed, not mis-answered; `pose_at(t)` inside the record's declared cycle ([`ui-record-loop-length.md`](structures/ui-record-loop-length.md)) is the only well-formed query. ⚠️ 210 is a **floor**: focus records are matched by the `Xf.rat` name rule, and varying scale/rotation/position is not counted | | [`structures/ui-title-buildin-measured.md`](structures/ui-title-buildin-measured.md) | The title's build-in and the plate glow, read out of the guest's own draw stream | ✅ **measured** (Canary, `ARM=early` draw capture): the decoded *mechanism* is observed, not just its end state. **The five flashes fire in a six-frame window and are absent from all 155 other sampled frames**; `ptlogo_back2eff1` is drawn in exactly 2 frames at **t = 54.0** against a decoded peak of **t54–56**, and `ptlogo1` first appears at **t = 42.2** against a decoded **t42** — with units/frame taken from the **glow's period alone**, a different element. The two holders (`ptlogo_back2eff`, `ptlogo_back2`) are continuous from frame 134. ✅ The glow's per-vertex colour alpha IS its fade alpha: **observed range 0…80 against a decoded peak of 80**, exact and unfitted; **period 51.158 presented frames** over 20 cycle starts; fitting the decoded ramp gives RMS **13.16** against **38.18 reversed** (2.9×), so the asymmetry is real and correctly directed. Structure: the settled title is 10–11 draws naming no sprite — which is why arming at the title sees nothing. ⚠️ Frame **107** is a 27-draw spike between the movie's last frame and the title's first; calling it "the composite" was an **over-read** — it binds **no texture** and only 4 of its 27 draws log geometry. The second title entry has no such frame. ⚠️ The two entries are the same animation at **different sampling phases** (only 4 of 46 aligned frames match), which is what makes the `eff3` result robust. 🔴🔴 **RETRACTED — the game DOES draw `ptlogo_back2eff3`, and all five flashes fire in both entries in the declared stagger** (`eff3` at frames 133–134 / 5957–5958, i.e. t=60.1 and 62.3, inside its declared t∈(58,64)). The absence was an **instrument artefact**: a draw batches several quads (`indices=8` is two) and the log dumps only the first 8 vertices, so min/max over a line **merges** them — and because the wipe is right-aligned, `eff3` (788…1196) lies entirely inside `eff4` (447…1196), making the union *exactly* `eff4`'s extent. The merged box matched `eff4` to 1 px. 🔴 Three explanations had been "ruled out" and all three were aimed at the wrong failure — notably the invisible-draw check counted draws with **no** geometry, where the hiding place was **partial** geometry. Superseded text follows: ~~three alternative explanations tested and failed: *phase* (its window is **6 units** against a **2.23-unit** step, so it cannot be missed — frames 133/134 sit at t=60.1/62.3 inside it and draw `eff2` and `eff4` instead), *an unlogged draw* (exactly 2 blind draws/frame, always the same full-screen-triangle shader, present when no wipe is active), and *a bad position guess* (dropping position entirely, **zero** quads anywhere have a width within ±30 of 408; the spectrum jumps 262 → 748). Draw counts across both entries: eff1 **4**, eff2 **3**, eff3 **0**, eff4 **6**.~~ (all from the merged-box parse, and wrong) 🔴 **The port draws `eff3` at t=60–62 and the console does not.** ❔ Why is not established — nothing in its element record differs from its neighbours. ⚠️ An earlier "sub-frame phase" explanation and the advice that drawing all five "shows more sweep than the console" are both **withdrawn**. ⚠️ What a frame-by-frame build-in comparison *will* show is disagreement about which flash lands in which frame — 2 units/submitted frame against this run's 2.231 units/presented frame — and neither side is wrong. 🔴 **Trap:** matching a bound texture's dimensions to a sprite fails both ways — it missed every flash *and* read the intro movie's 640×360 YUV planes as `ptbase2`. ✅ A regression of five events' observed frames against their declared times (residuals ≤0.9 frames) recovers the intercept at frame **106.1** when the composite spike, not in the fit, is frame **107**. ⚠️ Per-vertex alpha = fade alpha holds for the **glow** and does not generalise — `eff4` reads 255/127/254 on consecutive frames. ❔ Frame rate not recorded, so nothing is in seconds; the glow's period implies a **114**-unit cycle against a declared 120, unexplained; `eff5` vs `ptlogo_back2eff` not separated | | [`structures/boot-splash-gap-measured.md`](structures/boot-splash-gap-measured.md) | The black gap between the two boot splashes | ✅ **measured** in the guest's **draw stream**, which separates true black from a fade tail where luminance cannot: the publisher's last sprite is frame 125 (alpha 7), then **frames 126–129 submit NO sprite quad at all**, then the developer fades in at alpha 34. **The gap is 4 presented frames.** Converted with the disc as its own clock — `palogo_sqex` declares alpha≥1 for **239.8 units** and is drawn in **105** frames → **2.284 units/frame** (the title capture independently gave 2.231) — that is **~9.1 units ≈ 0.152 s**, against the **12** the port authored; ⚠️ and the true black is *shorter*, since both boundary frames still carry picture. 🔴 **RETRACTED**: "the developer splash is ONE composited 525×259 quad" — the same batching artefact. It draws three logos and three glows as separate quads in one `indices=24` call; the 525×259 was `gamearts_eff` merged with `seta_eff`. The port refuted it with arithmetic (a 259-tall box cannot hold logos spanning y 164…585) before I checked. ⚠️ The gap measurement is unaffected — those glows are the developer splash's first draw. ❌ Not declared on the disc: `palogo_eff0.prm` is a single static keyframe, and the top-level `+0x08` is a **family constant** (300 / 60) whose slack ranges 12–226 units. ❔ The executable is **not** looked at — named, not claimed. 🔴 The instrument was perturbing the measurement: the capture script taps Ⓐ on "screen changed a lot", which is also true of a fading splash — it tapped through the publisher and the developer never appeared. `GRACE=1` and `NOTAP=1` knobs added | +| [`structures/ui-forced-backdrop.md`](structures/ui-forced-backdrop.md) | Where a keyless primitive paints, when the file forces it | ✅ **decoded**, partly closing `ui-prm-primitives.md`'s standing blocker: **an element covering the screen and fully opaque at some instant cannot paint above anything visible then**, and where that set is *every* other element its position is **forced first**. Disc-wide **80** instances forced, 50 constrained but not forced, 0 unconstrained. ✅ **Two controls, both measured orders from the running game**: it reproduces `palogo_eff0.prm` = FIRST (opaque 211 instants, below 6/6) — which a **name**-based rule gets wrong, since it is named like an overlay — and permits `pteff00.prm` on top (opaque 2 instants, below 3/23), which is where it is measured. ✅ Answers the port's `build_12`/`build_15` blank-screen contradiction: `pgloading_eff00.prm` is forced first, 4/4. ✅ Explains 36 builds the corpus recorded as "one colour" with no cause — `pzeff00.prm` forced first 32/32, so **our own sort wiped them**. 🔴 The rule's limit was found by its own test failing: applied to `.t32` sprites it claimed 22 must sort first against their own keys (`pneff01` 0xd850 at #8/13, `pbfriendly` 0x9230 at #17/49) — a sprite's *element* alpha says nothing about its *texture*'s coverage, so it is now restricted to untextured primitives. ⚠️ Assumes straight alpha-over; blend mode is still ❔. ⚠️ A lower bound, not an ordering. ⚠️ No new oracle run — the controls are prior measurements | diff --git a/docs/re/structures/ui-forced-backdrop.md b/docs/re/structures/ui-forced-backdrop.md new file mode 100644 index 00000000..5a43f5c5 --- /dev/null +++ b/docs/re/structures/ui-forced-backdrop.md @@ -0,0 +1,110 @@ +# Where a keyless primitive paints, when the file forces it + +**Classification: decoded.** Derived from the keyframes and the element's own +geometry, checked disc-wide, and validated against **both** primitives whose +position was measured in the running game — one it must reproduce, one it must +not disturb. + +## The question + +A `.prm` / `.tbm` primitive carries **no layer key**: the key is read from a +sprite's header, and a primitive has no sprite. +[`ui-prm-primitives.md`](ui-prm-primitives.md) established that the key is not in +the bundle either — the declaration entry's unread words are constant, and the +bundle has no RATC child for a primitive — so `implied_layer_key` is a **measured +per-name table**, and anything not in it keeps `u32::MAX` and sorts **last**. + +"❔ Where an *unmeasured* primitive paints" has been that page's standing blocker. + +The port hit it: `build_12` / `build_15` composited to solid black at every +instant of their declared life, because `pgloading_eff00.prm` — a full-screen +opaque quad — sorted on top. + +## The rule + +> **An element that covers the screen and is fully opaque at some instant cannot +> paint above anything visible at that instant.** Where the elements visible +> during its opaque span are *all* of them, its position is forced to first. + +This is a constraint read off the file, not a preference, and it is not an +analogy to a neighbouring screen. + +`pgloading_eff00.prm` is opaque for **39** instants, and **all 9** other elements +on the loading screen are visible inside that span. **Forced first**, 4/4 +instances. + +## The controls — the rule has to survive both, and it does + +| primitive | measured in the game | opaque instants | forced below | rule says | +|---|---|---|---|---| +| `palogo_eff0.prm` | **paints FIRST** | 211 | **6 of 6** | ✅ forced first | +| `pteff00.prm` (title) | **paints LAST** | 2 | 3 of 23 | ✅ permitted on top | +| `pteff00.prm` (menu) | **paints LAST** | 2 | 7 of 15 | ✅ permitted on top | + +🔴 **The first row is the one that matters.** `palogo_eff0.prm` is named like an +overlay, and a rule keyed on the *name* would sort it last — against a measured +order. Occlusion gets it right. `pteff00.prm` is opaque only for two instants, at +its screen's entry and exit, so the constraint never binds it: it is the fade +cover, and it belongs on top. + +## Disc-wide + +Keyless **full-screen** primitives with an opaque interval: + +| | count | +|---|---| +| position **forced first** | **80** | +| constrained below some, not all | 50 | +| occluding nothing | 0 | + +The split falls almost exactly along the names — every `*base*` is forced, every +`*eff00*` is not — with three families crossing it: `palogo_eff0.prm`, +`pgloading_eff00.prm` and `pzeff00.prm` are named like overlays and are forced +first. **That is precisely why the name is not the rule.** + +✅ **And it explains a symptom the corpus had recorded without a cause.** +`ui-prm-primitives.md` notes 36 builds that "come out one colour ... wiped by +`pzeff00.prm` and `pceff00.prm`, whose positions have never been measured". +`pzeff00.prm` is forced first in **32 of 32** instances. Those builds were wiped by +our own sort, not by the game. + +## 🔴 The rule's real limit, found by its own test + +An earlier version applied to any element. Its disc-wide test asserted that no +*keyed* element is ever forced — and that assertion failed, on **22** of them: +`pneff01.t32` (key `0xd850`, paints #8 of 13) and `pbfriendly.t32` (key `0x9230`, +#17 of 49). + +Both are `.t32` **sprites**, and that is the flaw: a sprite's *element* alpha +being 255 says nothing about whether its **texture** covers the screen. Most of it +may be transparent. The disagreements were the rule overreaching, not the keys +being wrong. + +`forced_backdrop` is now restricted to elements with **no sprite** — untextured +primitives, which are solid quads and do occlude what they cover. That is also the +only case `derived_paint_order` consults it for. + +## Reach + +⚠️ **Assumes straight alpha-over blending.** Blend mode is ❔ on +[`ui-prm-primitives.md`](ui-prm-primitives.md): an *additive* quad at alpha 255 +would not occlude, and the rule would then be placing it wrongly. The +`palogo_eff0.prm` control is evidence the assumption holds at least there. + +⚠️ **It gives a lower bound, not an ordering.** It settles the 80 instances where +occlusion forces the position, and says nothing about the 50 where the primitive +is opaque only part of the time — including `pteff00.prm`, whose place on top is +still a *measured* per-name entry, not a decoded one. + +⚠️ **No new oracle measurement.** The two controls are orders measured previously; +nothing here was captured from a running game. A draw capture of a loading screen +would confirm it directly, and the loading screens are not reachable from the +title path. + +## Reproducing + +```bash +cargo run -p sylpheed-formats --example prm_forced_first +cargo run -p sylpheed-formats --example prm_occlusion_check +SYLPHEED_DISC=/disc cargo test -p sylpheed-formats --test ui_forced_backdrop_disc +``` diff --git a/docs/re/structures/ui-prm-primitives.md b/docs/re/structures/ui-prm-primitives.md index cf28e89c..8ff41131 100644 --- a/docs/re/structures/ui-prm-primitives.md +++ b/docs/re/structures/ui-prm-primitives.md @@ -155,14 +155,26 @@ included, and matches element-for-element on four of the five bundle instances open question). Pinned by `the_derived_order_puts_every_element_in_the_right_layer_group`. -This does **not** make `include_primitives` safe by default: the 36 builds that -come out one colour are wiped by `pzeff00.prm` and `pceff00.prm`, whose positions -have never been measured, so they are not in the table. +This does **not** make `include_primitives` safe by default — but the 36 builds +that come out one colour are now explained: `pzeff00.prm` is **forced first** in +32 of 32 instances by the occlusion constraint, so they were wiped by *our own +sort*, not by the game. [`ui-forced-backdrop.md`](ui-forced-backdrop.md). ## What is not settled -* ❔ **Where an *unmeasured* primitive paints.** The blocker, unchanged for the - ones not in the table. It has no layer +* ✅ **Where an *unmeasured* primitive paints — PARTLY CLOSED 2026-08-29.** For + **80** instances the file forces it: an element covering the screen and fully + opaque at some instant cannot paint above anything visible then, and where that + is *every* other element its position is first. It reproduces `palogo_eff0.prm` + (measured FIRST, and named like an overlay, so a name-based rule fails it) and + permits `pteff00.prm` on top (measured LAST). ✅ It also explains the 36 builds + below that "come out one colour": `pzeff00.prm` is forced first in 32 of 32. + See [`ui-forced-backdrop.md`](ui-forced-backdrop.md). +* ❔ **Where a *partly*-opaque primitive paints.** Still open for the 50 the + constraint does not bind — including `pteff00.prm`, whose place on top remains a + measured per-name entry rather than a decoded one. +* ~~❔ **Where an *unmeasured* primitive paints.** The blocker, unchanged for the + ones not in the table.~~ It has no layer key and the two measured screens rule out every constant default. The cheapest next step is a third measured order from a screen that carries a primitive — the `GP_DIALOG` DIFFICULTY box is reachable from the main menu and has exactly