From 53f834562a7e80d929bb1277aad8b210564d2f87 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Sat, 29 Aug 2026 21:02:17 +0000 Subject: [PATCH] formats: a keyless primitive that would hide the screen is forced to paint first Partly closes ui-prm-primitives.md's standing blocker, "where an UNMEASURED primitive paints". Raised by the port: build_12/build_15 composite to solid black at every instant of their declared life, because pgloading_eff00.prm -- a full-screen opaque quad -- sorts last. The rule is a constraint read off the file, not 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, 4/4 instances. Two controls, both measured orders from the running game, and the rule has to survive both: * palogo_eff0.prm is measured painting FIRST -- opaque 211 instants, forced below 6 of 6. It is NAMED like an overlay, so a name-based rule sorts it wrong against a measured order. Occlusion gets it right. * pteff00.prm is measured painting LAST -- opaque for 2 instants at its screen's entry and exit, forced below only 3 of 23, so the constraint permits it on top where it belongs. Disc-wide: 80 instances forced first, 50 constrained but not forced, 0 unconstrained. The split runs almost exactly along the names -- every *base* is forced, every *eff00* is not -- with three families crossing it, which is exactly why the name is not the rule. It 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. The rule's real limit was found by its own disc-wide test failing. Applied to any element it claimed 22 .t32 SPRITES must sort first against their own layer keys -- pneff01.t32 (key 0xd850, #8 of 13) and pbfriendly.t32 (0x9230, #17 of 49). A sprite's ELEMENT alpha says nothing about whether its TEXTURE covers the screen, so forced_backdrop is now restricted to untextured primitives, which is also the only case derived_paint_order consults it for. Reach stated: assumes straight alpha-over (blend mode is still open, and an additive quad at alpha 255 would not occlude); it is a lower bound, not an ordering; and there is no new oracle measurement -- both controls are prior ones, and a loading screen is not reachable from the title path. 3 new disc tests; the 13 paint-order tests are green, including the_derived_order_matches_the_measured_ones_up_to_ties. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd --- crates/sylpheed-formats/examples/_keys.rs | 21 +++ .../examples/prm_alpha_census.rs | 50 +++++++ .../examples/prm_forced_first.rs | 58 ++++++++ .../examples/prm_occlusion_check.rs | 73 ++++++++++ crates/sylpheed-formats/src/ui_layout.rs | 76 ++++++++++ .../tests/ui_forced_backdrop_disc.rs | 133 ++++++++++++++++++ docs/port/HANDOFF.md | 53 +++++++ docs/re/INDEX.md | 1 + docs/re/structures/ui-forced-backdrop.md | 110 +++++++++++++++ docs/re/structures/ui-prm-primitives.md | 22 ++- 10 files changed, 592 insertions(+), 5 deletions(-) create mode 100644 crates/sylpheed-formats/examples/_keys.rs create mode 100644 crates/sylpheed-formats/examples/prm_alpha_census.rs create mode 100644 crates/sylpheed-formats/examples/prm_forced_first.rs create mode 100644 crates/sylpheed-formats/examples/prm_occlusion_check.rs create mode 100644 crates/sylpheed-formats/tests/ui_forced_backdrop_disc.rs create mode 100644 docs/re/structures/ui-forced-backdrop.md 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