re: the forced-backdrop span -- 256 vs 211 is a bundle mismatch, and the hold
decides 55% of verdicts The port implemented the forced-backdrop rule and reported a discrepancy: palogo_eff0.prm at 256 opaque instants against this corpus's 211. There is no discrepancy. palogo_eff0.prm appears on BOTH splashes -- the publisher (entries 10, 13) runs to t=255, giving 256 instants; the developer (11, 14) runs to t=210, giving 211. Same definition, different bundle. The page now names the entries so it cannot recur. The definition, stated: the span is 0..=max keyframe time over every element in the build, and an element HOLDS its final pose past its own last keyframe -- which is what pose_at does, and which is decoded rather than assumed (a group holds at its last keyframe rather than looping; the declared +0x08 never falls short of the last keyframe, the slack being that hold). The port's instinct that the hold was load-bearing was right. Over the 130 keyless full-screen primitives with an opaque interval: * span = the header's declared +0x08 -> 0 verdicts change * span = the primitive's own last keyframe -> 72 change * elements GONE after their last keyframe -> 72 change So the hold decides 55% of verdicts -- and dropping it is REFUTED by a measured order. palogo_eff0.prm is a single keyframe at t=0: without the hold it is opaque for one instant, no other element is up yet, and the rule calls it free, against a game measured painting it first. Pinned by a new test that spells out the counterfactual rather than importing it. The verdicts that matter are convention-independent: pgloading_eff00.prm is FIRST under all four conventions and pteff00.prm FREE under all four. And the header length is interchangeable with the elements' maximum -- zero disagreements disc-wide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
This commit is contained in:
82
crates/sylpheed-formats/examples/prm_span_sensitivity.rs
Normal file
82
crates/sylpheed-formats/examples/prm_span_sensitivity.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
//! Does `forced_backdrop`'s verdict depend on how the screen's timeline ENDS?
|
||||
//!
|
||||
//! The rule quantifies over "every instant the primitive is opaque" and "every
|
||||
//! element visible then", so both halves depend on where the timeline stops and
|
||||
//! on what an element does after its own last keyframe. The port asked, and it is
|
||||
//! the right question: a verdict that flips with the convention is not a decode.
|
||||
//!
|
||||
//! Four conventions, all applied to the same disc:
|
||||
//! A span = max keyframe time over all elements; elements HOLD their last pose
|
||||
//! (what `forced_backdrop` does, and what the port implements)
|
||||
//! B span = the primitive's OWN last keyframe time; elements hold
|
||||
//! C span = the bundle header `+0x08` (the declared length); elements hold
|
||||
//! D span = max keyframe time; an element is GONE after its own last keyframe
|
||||
//!
|
||||
//! D is the one worth the most: it is the assumption the port flagged as "doing
|
||||
//! real work", and it strictly shrinks the visible set, so it can only turn
|
||||
//! `forced` into `not forced`.
|
||||
use sylpheed_formats::{pak, ratc, ui_layout};
|
||||
|
||||
fn last_t(el: &ui_layout::Element) -> u32 {
|
||||
el.keyframes.iter().filter_map(|k| k.time).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn forced(b: &ui_layout::UiBuild, el: &ui_layout::Element, tmax: u32, hold: bool) -> Option<bool> {
|
||||
if el.sprite.is_some() { return None }
|
||||
if (el.pivot_x * 2) < b.design_w as u32 || (el.pivot_y * 2) < b.design_h as u32 { return None }
|
||||
if tmax == 0 { return None }
|
||||
let alpha = |e: &ui_layout::Element, t: u32| -> u32 {
|
||||
if !hold && t > last_t(e) { return 0 }
|
||||
e.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0)
|
||||
};
|
||||
let op: Vec<u32> = (0..=tmax).filter(|&t| alpha(el, t) == 255).collect();
|
||||
if op.is_empty() { return None }
|
||||
let others: Vec<&ui_layout::Element> = b.elements.iter().filter(|o| o.index != el.index).collect();
|
||||
if others.is_empty() { return None }
|
||||
let below = others.iter().filter(|o| op.iter().any(|&t| alpha(o, t) > 0)).count();
|
||||
Some(below == others.len())
|
||||
}
|
||||
|
||||
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 n, mut a_true) = (0usize, 0usize);
|
||||
let mut flips = [0usize; 3];
|
||||
let mut examples: Vec<String> = Vec::new();
|
||||
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 tall = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
|
||||
.max().unwrap_or(0);
|
||||
let hdr = if by.len() >= 12 { u32::from_be_bytes(by[8..12].try_into().unwrap()) } else { 0 };
|
||||
for el in &b.elements {
|
||||
let Some(va) = forced(&b, el, tall, true) else { continue };
|
||||
n += 1; if va { a_true += 1 }
|
||||
for (k, vb) in [forced(&b, el, last_t(el), true),
|
||||
forced(&b, el, hdr, true),
|
||||
forced(&b, el, tall, false)].into_iter().enumerate() {
|
||||
if vb != Some(va) {
|
||||
flips[k] += 1;
|
||||
if k == 2 && examples.len() < 6 {
|
||||
examples.push(format!("{}:{} {} A={va} D={vb:?}",
|
||||
p.file_name().unwrap().to_string_lossy(), ei, el.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("keyless full-screen primitives with an opaque interval: {n}");
|
||||
println!(" convention A (span = all elements' max, hold) -> forced first: {a_true}\n");
|
||||
println!(" verdicts that CHANGE under:");
|
||||
println!(" B span = the primitive's own last keyframe : {}", flips[0]);
|
||||
println!(" C span = the header's declared length +0x08 : {}", flips[1]);
|
||||
println!(" D elements GONE after their last keyframe : {}", flips[2]);
|
||||
for e in &examples { println!(" {e}") }
|
||||
}
|
||||
@@ -81,6 +81,57 @@ fn the_loading_screens_backdrop_sorts_first() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 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]
|
||||
|
||||
Reference in New Issue
Block a user