A disc-wide census of the ARGB that keyless elements carry. Every full-screen *eff00* PRIMITIVE is pure black at its various alphas (ff000000, 7f000000, 40000000, b2000000, cc000000, d4000000, 00000000). Black at alpha a over content is exactly an alpha-over dim or fade, and an additive black quad would be a no-op nobody would author -- so this narrows the open blend question a long way. The only non-black primitive on the disc is pbafc.prm, RGB 00e8e0 cyan at alphas up to ff, and it is 844x600, NOT full-screen, so it sits outside forced_backdrop's geometry guard. It is now the sole additive candidate. The census also refutes my own argument for nearly half its verdicts. Of the 80 forced-first instances only 42 are .prm; 38 are .tbm carrying fade ffffffff. A SOLID white quad at alpha 255 painted first would make the screen white, and no screen is white -- so a .tbm is a white modulation on a texture, and element alpha does not establish its coverage. That is the .t32 error one file extension further out. I guarded that with el.sprite.is_some(), which fixed the symptom and not the cause: an element's alpha is not its texture's opacity, and only an untextured primitive makes the two the same fact. So 42 verdicts stay decoded and 38 drop to inferred -- still almost certainly right, since all are named *base*, all are full-screen, and pfbase.tbm's first position is measured in the running game, but that is a name-and-role argument which this page elsewhere calls the weaker kind. The code is deliberately unchanged. Restricting forced_backdrop to .prm would send eleven screens' backgrounds back to u32::MAX -- last -- which is the blank-screen bug the rule was written to fix. Downgrading the status is honest; reverting the position would be wrong. The 42/38 split is pinned by a test so anyone tightening the rule sees what it costs. Separately, on the port's black_hold_units ask: four more no-input boots yielded one usable log, which armed late and missed the publisher splash, so the sample is still two runs spanning 3 and 4 frames. Their 6.5-9.2 range stands. And a reason it may not be resolvable this way: the draw log DROPS frame numbers -- in the 3-frame run, frames 121 and 124 are absent entirely, so "frames with no sprite" and "span of frame numbers" are different quantities. Their statistical correction is taken: at n=3 the sample SD (3.893) is the estimator, not the population SD (3.179), making my run 1.88 sigma from the corpus mean rather than 2.31. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
198 lines
8.6 KiB
Rust
198 lines
8.6 KiB
Rust
//! 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<PathBuf> {
|
|
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<u8>, 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"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 🔴 The rule quantifies over "every instant the primitive is opaque" and "every
|
|
/// element visible then", so both halves depend on where the timeline ends and on
|
|
/// what an element does after its own last keyframe. **The hold is not a
|
|
/// convenience: a measured order requires it.**
|
|
///
|
|
/// `palogo_eff0.prm` is a SINGLE keyframe at t=0. If an element counted as *gone*
|
|
/// after its last keyframe, the splash's backdrop would exist for one instant, no
|
|
/// other element would be up yet, and the rule would call it free — against the
|
|
/// order measured in the running game, which paints it first.
|
|
///
|
|
/// Disc-wide the choice decides **72 of 130** verdicts, so this is the load-bearing
|
|
/// half of the rule. (Using the header's declared `+0x08` as the span instead of
|
|
/// the elements' maximum changes **0**.)
|
|
#[test]
|
|
fn the_hold_after_a_final_keyframe_is_required_by_a_measured_order() {
|
|
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 [10usize, 11] {
|
|
let (_, b) = build(&ar, entry);
|
|
let prim = el(&b, "palogo_eff0.prm");
|
|
assert_eq!(prim.keyframes.len(), 1, "entry {entry}: the case rests on it being static");
|
|
|
|
// With the hold — what `pose_at` does, and what the game does.
|
|
assert!(
|
|
ui_layout::forced_backdrop(&b, prim),
|
|
"entry {entry}: measured painting FIRST, so the rule must force it"
|
|
);
|
|
|
|
// Without it, spelled out here rather than imported, so the test states
|
|
// the counterfactual it is pinning.
|
|
let tmax = b.elements.iter()
|
|
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)).max().unwrap_or(0);
|
|
let last = |e: &ui_layout::Element| e.keyframes.iter().filter_map(|k| k.time).max().unwrap_or(0);
|
|
let alpha_no_hold = |e: &ui_layout::Element, t: u32| -> u32 {
|
|
if t > last(e) { 0 } else { e.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) }
|
|
};
|
|
let opaque: Vec<u32> = (0..=tmax).filter(|&t| alpha_no_hold(prim, t) == 255).collect();
|
|
let others: Vec<_> = b.elements.iter().filter(|o| o.index != prim.index).collect();
|
|
let below = others.iter()
|
|
.filter(|o| opaque.iter().any(|&t| alpha_no_hold(o, t) > 0)).count();
|
|
assert_ne!(
|
|
below, others.len(),
|
|
"entry {entry}: without the hold this element would come out FREE — which is \
|
|
why the hold is load-bearing rather than incidental"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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<PathBuf> = 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, mut prm, mut tbm) = (0usize, 0usize, 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;
|
|
if element.name.ends_with(".prm") { prm += 1 }
|
|
else if element.name.ends_with(".tbm") { tbm += 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");
|
|
|
|
// 🔴 Pin the split, so anyone tightening this rule sees what it would cost.
|
|
// Only the `.prm` half is DECODED: a solid colour quad's fade IS its pixel, so
|
|
// opacity and coverage are the same fact. Every `.tbm` in the set carries fade
|
|
// `ffffffff` — a white SOLID quad painted first would make the screen white, so
|
|
// they are textured, and element alpha does not establish their coverage.
|
|
// Their verdicts are kept because restricting to `.prm` would send eleven
|
|
// screens' backgrounds back to last, which is the bug this rule fixed.
|
|
assert!(prm >= 40 && tbm >= 30,
|
|
"expected roughly 42 .prm / 38 .tbm forced instances, got {prm} / {tbm} — \
|
|
if this moved, re-read the self-refutation section of ui-forced-backdrop.md");
|
|
}
|