re: the paint-order tie-break costs one pixel, on one screen we do not ship

Closes the open half of Q3. `ui-paint-order-derived-check.md` bounded WHERE a
wrong tie-break could show -- overlapping same-key pairs -- and said outright
that nobody had measured how many change a pixel.

At the instant the player sees, the answer is: at most 1 px at max channel
difference 1, on the JAPANESE title only (`ptlogo2` x `ptlogo_tm`, 5 px of
shared ink). Exactly 0 px on all five port screens.

The earlier 24-pair bound was counted at `rest()`, and 10 of the title's 11
overlapping tied pairs are between `ptlogo_back2eff1`..`eff5` -- the five
transient flashes from the settle-time finding, transparent on the settled
screen. A tie between two invisible elements cannot cost a pixel.

Not a knife-edge. Sweeping every keyframe time and every midpoint between
keyframe times, the live-pair count is flat across the ENTIRE settle window:
1 on the EN title, 2 on the JP title, 0 on all four loading bundles -- whose
tie is live only at t17..t33, during the build-in, which matters because
their settle windows are narrow enough to deserve little trust otherwise.

Controls: every entry reporting zero also swaps an overlapping DIFFERENT-key
pair, which must and does move pixels (25 310 / 268 698 / ~765 000 px). Zeros
are explained by shared-ink counts rather than asserted -- the `ptframe` pairs
overlap by bounding box and share 0 px of ink. Entries 0/1/12/15 have NO live
control and their zeros rest on keyframe data rather than a render; recorded
as the weaker claim it is.

Refutation attempt on the corpus's "24 overlapping pairs": it SURVIVES as a
rest-pose count -- an independent recount reproduces entry 7's 16 exactly.
What is overturned is its interpretation as the risk surface.

`tie_break_pixel_cost` gains a settle-time case and an alpha/scale filter on
its rect test; `tie_cost_over_time` is new. Also strips 611 bytes of captured
cargo warnings from the head of the committed tie census.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
This commit is contained in:
sylph-decoder
2026-08-29 19:35:29 +00:00
parent 6f4ba93c5c
commit 3e731c68ce
9 changed files with 470 additions and 36 deletions

View File

@@ -67,10 +67,21 @@ fn swapped(order: &[usize], a: usize, b: usize) -> Vec<usize> {
o
}
/// The element's on-screen rect at rest, the same approximation the tie census
/// uses: the declared pivot doubled, placed at the resting keyframe.
fn rect(e: &ui_layout::Element) -> Option<(i32, i32, i32, i32)> {
let kf = e.rest()?;
/// The element's on-screen rect, the same approximation the tie census uses:
/// the declared pivot doubled, placed at the keyframe. `at` selects the pose —
/// `None` is `rest()`, which is where the original census was computed.
///
/// 🔴 An element that is TRANSPARENT at the chosen pose gets no rect at all. A
/// tie involving something invisible cannot cost a pixel, and counting it as an
/// overlap is what made the original census an upper bound rather than a cost.
fn rect(e: &ui_layout::Element, at: Option<u32>) -> Option<(i32, i32, i32, i32)> {
let kf = match at {
Some(t) => e.pose_at(t)?,
None => e.rest()?.clone(),
};
if kf.fade >> 24 == 0 || kf.scale_x == 0 || kf.scale_y == 0 {
return None;
}
let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32);
if w == 0 || h == 0 {
return None;
@@ -78,8 +89,8 @@ fn rect(e: &ui_layout::Element) -> Option<(i32, i32, i32, i32)> {
Some((kf.x, kf.y, w, h))
}
fn overlaps(a: &ui_layout::Element, b: &ui_layout::Element) -> bool {
let (Some(ra), Some(rb)) = (rect(a), rect(b)) else {
fn overlaps(a: &ui_layout::Element, b: &ui_layout::Element, at: Option<u32>) -> bool {
let (Some(ra), Some(rb)) = (rect(a, at), rect(b, at)) else {
return false;
};
(ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0) > 0
@@ -107,12 +118,27 @@ fn cases() -> Vec<Case> {
include_animated: true,
include_primitives: true,
backdrop: [0, 0, 0, 255],
..Default::default()
},
},
// 🔴 The two cases above pose at `rest()`, which is each element's last
// hold picked independently — so they draw transients that the settled
// screen does not have (`docs/re/structures/ui-settle-time.md`). A tie
// between two elements that are transparent at the settle time cannot
// cost a pixel on the screen the player sees, however much their rects
// overlap at rest. `at` is filled in per entry.
Case {
name: "AT THE SETTLE TIME (what the player sees)",
opts: ComposeOptions {
backdrop: [0, 0, 0, 255],
at: Some(0), // replaced per entry
..Default::default()
},
},
]
}
fn tied_overlapping_pairs(build: &UiBuild, bytes: &[u8]) -> Vec<(usize, usize, u32)> {
fn tied_overlapping_pairs(build: &UiBuild, bytes: &[u8], at: Option<u32>) -> Vec<(usize, usize, u32)> {
let keys: Vec<u32> = build
.elements
.iter()
@@ -124,7 +150,7 @@ fn tied_overlapping_pairs(build: &UiBuild, bytes: &[u8]) -> Vec<(usize, usize, u
if keys[a] != keys[b] || keys[a] == u32::MAX {
continue;
}
if overlaps(&build.elements[a], &build.elements[b]) {
if overlaps(&build.elements[a], &build.elements[b], at) {
out.push((a, b, keys[a]));
}
}
@@ -134,7 +160,7 @@ fn tied_overlapping_pairs(build: &UiBuild, bytes: &[u8]) -> Vec<(usize, usize, u
/// A pair the game's own order DOES separate: overlapping, different keys.
/// Used as the control — swapping it must move pixels.
fn control_pair(build: &UiBuild, bytes: &[u8]) -> Option<(usize, usize)> {
fn control_pair(build: &UiBuild, bytes: &[u8], at: Option<u32>) -> Option<(usize, usize)> {
let keys: Vec<u32> = build
.elements
.iter()
@@ -146,10 +172,10 @@ fn control_pair(build: &UiBuild, bytes: &[u8]) -> Option<(usize, usize)> {
if keys[a] == keys[b] || keys[a] == u32::MAX || keys[b] == u32::MAX {
continue;
}
if !overlaps(&build.elements[a], &build.elements[b]) {
if !overlaps(&build.elements[a], &build.elements[b], at) {
continue;
}
let (ra, rb) = (rect(&build.elements[a])?, rect(&build.elements[b])?);
let (ra, rb) = (rect(&build.elements[a], at)?, rect(&build.elements[b], at)?);
let ox = ((ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0)) as i64;
let oy = ((ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1)) as i64;
let area = ox * oy;
@@ -174,22 +200,49 @@ fn main() {
let Some(build) = ui_layout::parse_build(&bytes) else {
continue;
};
let pairs = tied_overlapping_pairs(&build, &bytes);
if pairs.is_empty() {
// The census pairs are still computed at rest, so the report can say
// how many of THOSE survive posing at the settle time.
let pairs_at_rest = tied_overlapping_pairs(&build, &bytes, None);
if pairs_at_rest.is_empty() {
continue;
}
let settle = build.settle_time();
println!(
"entry {i:2} {} elements {} overlapping tied pair(s)",
"entry {i:2} {} elements {} overlapping tied pair(s) at rest{}",
build.elements.len(),
pairs.len()
pairs_at_rest.len(),
match (settle, build.settle_window()) {
(Some(t), Some((lo, hi))) => format!(" settle t={t} (window {} units)", hi - lo),
_ => " NO SETTLE WINDOW".to_string(),
}
);
let derived = ui_layout::derived_paint_order(&build, &bytes);
for c in cases() {
for mut c in cases() {
// The settle case is a no-op on a bundle that never settles.
if c.opts.at.is_some() {
match settle {
Some(t) => c.opts.at = Some(t),
None => {
println!(" [{}] SKIPPED: no settle window", c.name);
continue;
}
}
}
let pairs = tied_overlapping_pairs(&build, &bytes, c.opts.at);
if pairs.len() != pairs_at_rest.len() {
println!(
" [{}] 🔴 {} of the {} tied pairs are GONE at this pose (an element is \
transparent or collapsed there) — they cannot cost a pixel",
c.name,
pairs_at_rest.len() - pairs.len(),
pairs_at_rest.len()
);
}
let base = ui_layout::compose_with_order(&build, &bytes, c.opts, None, Some(&derived));
let drawn: std::collections::HashSet<usize> = base.drawn.iter().copied().collect();
// Control first. An instrument that cannot see a reorder it is
// supposed to see makes every zero below meaningless.
let ctrl = match control_pair(&build, &bytes) {
let ctrl = match control_pair(&build, &bytes, c.opts.at) {
Some((a, b)) if drawn.contains(&a) && drawn.contains(&b) => {
let alt = ui_layout::compose_with_order(
&build,

View File

@@ -0,0 +1,76 @@
//! How many tied pairs can cost a pixel, as a function of TIME?
//!
//! `tie_break_pixel_cost` answers "at one pose". That leaves the answer looking
//! like it might be a knife-edge: pick a different instant and the count could
//! jump. This sweeps every keyframe time in the bundle and reports, per entry,
//! how many same-key pairs are simultaneously **opaque, non-collapsed and
//! overlapping** — the pairs whose order could possibly matter at that instant.
//!
//! The instrument's control is built in: the count at t=0 (nothing has faded in)
//! and the count at rest must bracket it, and an entry whose count is flat at
//! zero for the whole sweep would be suspicious rather than reassuring — so the
//! peak is printed too.
use sylpheed_formats::{pak, ui_layout};
use ui_layout::UiBuild;
fn rect(e: &ui_layout::Element, t: u32) -> Option<(i32, i32, i32, i32)> {
let kf = e.pose_at(t)?;
if kf.fade >> 24 == 0 || kf.scale_x == 0 || kf.scale_y == 0 {
return None;
}
let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32);
if w == 0 || h == 0 { return None; }
Some((kf.x, kf.y, w, h))
}
fn live_pairs(b: &UiBuild, bytes: &[u8], keys: &[u32], t: u32) -> usize {
let mut n = 0;
for a in 0..keys.len() {
for c in (a + 1)..keys.len() {
if keys[a] != keys[c] || keys[a] == u32::MAX { continue }
let (Some(ra), Some(rb)) = (rect(&b.elements[a], t), rect(&b.elements[c], t)) else { continue };
if (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0) > 0
&& (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1) > 0 { n += 1 }
}
}
n
}
fn main() {
let path = std::env::args().nth(1).expect("usage: tie_cost_over_time <pak>");
let ar = pak::PakArchive::open(&path).expect("open pak");
println!("# tied pairs that could cost a pixel, over time — {path}\n");
for (i, e) in ar.entries().to_vec().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
let keys: Vec<u32> = b.elements.iter()
.map(|el| ui_layout::sprite_layer_key(&b, &by, el).unwrap_or(u32::MAX)).collect();
let mut ts: Vec<u32> = b.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).collect();
ts.sort_unstable(); ts.dedup();
if ts.len() < 2 { continue }
let last = *ts.last().unwrap();
// sample every keyframe time AND every midpoint between them
let mut samples: Vec<u32> = ts.clone();
for w in ts.windows(2) { samples.push(w[0] + (w[1] - w[0]) / 2); }
samples.sort_unstable(); samples.dedup();
let counts: Vec<(u32, usize)> =
samples.iter().map(|&t| (t, live_pairs(&b, &by, &keys, t))).collect();
let peak = counts.iter().map(|&(_, n)| n).max().unwrap_or(0);
if peak == 0 { continue }
let Some((lo, hi)) = b.settle_window() else { continue };
let st = b.settle_time().unwrap();
let at_settle = live_pairs(&b, &by, &keys, st);
// the whole plateau, not just its midpoint
let plateau: Vec<usize> =
(lo..=hi).step_by(((hi - lo).max(1) / 8).max(1) as usize)
.map(|t| live_pairs(&b, &by, &keys, t)).collect();
let pmax = plateau.iter().copied().max().unwrap_or(0);
println!("entry {i:2} peak {peak} live pair(s) over t=0..{last} \
settle window [{lo},{hi}] at t={st}: {at_settle} ACROSS THE WHOLE WINDOW: max {pmax}");
let busy: Vec<String> = counts.iter().filter(|&&(_, n)| n > 0)
.map(|&(t, n)| format!("t{t}:{n}")).collect();
if busy.len() <= 24 { println!(" live only at {}", busy.join(" ")); }
else { println!(" live at {} of {} sampled instants", busy.len(), counts.len()); }
}
}