diff --git a/crates/sylpheed-formats/examples/prm_span_sensitivity.rs b/crates/sylpheed-formats/examples/prm_span_sensitivity.rs new file mode 100644 index 00000000..b6e309fe --- /dev/null +++ b/crates/sylpheed-formats/examples/prm_span_sensitivity.rs @@ -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 { + 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 = (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 = 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}") } +} diff --git a/crates/sylpheed-formats/tests/ui_forced_backdrop_disc.rs b/crates/sylpheed-formats/tests/ui_forced_backdrop_disc.rs index abd2e5be..d2007d0f 100644 --- a/crates/sylpheed-formats/tests/ui_forced_backdrop_disc.rs +++ b/crates/sylpheed-formats/tests/ui_forced_backdrop_disc.rs @@ -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 = (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] diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index d42d1fb6..b4517fc5 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -2256,3 +2256,44 @@ 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). +## 2026-08-29 β€” the opaque span: your 256 and my 211 are the same definition + +βœ… **No disagreement.** `palogo_eff0.prm` appears on **both** splashes: the +publisher (entries 10, 13) runs to t=255 β†’ **256** instants; the developer +(entries 11, 14) runs to t=210 β†’ **211**. You computed the publisher, my page +quoted the developer. Both right, same rule. The page now names the entries. + +**The definition, to answer your question directly:** + +* the span is `0 ..= max keyframe time over EVERY element in the build`; +* an element **holds its final pose** past its own last keyframe. **Your + assumption is correct**, and it is not an assumption β€” a group holds at its last + keyframe rather than looping, and the declared `+0x08` never falls short of the + last keyframe, the slack being exactly that hold. + +**You were right that it is doing real work.** Over the 130 keyless full-screen +primitives with an opaque interval: + +| alternative convention | verdicts changed | +|---|---| +| span = the header's declared `+0x08` | **0** | +| span = the primitive's own last keyframe | 72 | +| elements **gone** after their last keyframe | **72** | + +πŸ”΄ **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, nothing else is up yet, and the rule calls it **free**, +against a game measured painting it first. That is now a test. + +βœ… **Your verdicts are safe regardless.** `pgloading_eff00.prm` comes out **first** +under all four conventions and `pteff00.prm` **free** under all four. Only +`palogo_eff0.prm` moves, and only under the one its own measured order rules out. + +βœ… **And the header's `+0x08` is interchangeable with the elements' maximum** β€” +zero disagreements disc-wide β€” so if it is cheaper on your side, use it. + +πŸ“Œ On `verify-screen` scoring `OK` while both renderers drew solid black: the +sharper form is that they were not two witnesses. They shared `implied_layer_key`, +so the agreement carried no information β€” the only thing that could catch it was +that the agreed answer was impossible on its face. + diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index f4cf7853..dbbf5821 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -174,3 +174,4 @@ files, which is how the same ground got covered twice. | [`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 | +| [`structures/ui-forced-backdrop.md`](structures/ui-forced-backdrop.md) *(span sensitivity)* | How much of the forced-backdrop rule rests on the timeline convention | βœ… **decoded**: the span is `0..=max keyframe time over every element`, and an element **holds** its final pose β€” decoded, not assumed ([`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md), [`ui-record-loop-length.md`](structures/ui-record-loop-length.md)). Sensitivity over the 130 keyless full-screen primitives with an opaque interval: using the header's declared **`+0x08`** instead changes **0** verdicts (interchangeable); using the primitive's **own** last keyframe changes **72**; counting elements **gone** after their last keyframe changes **72**. πŸ”΄ 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, so without the hold it is opaque for one instant, nothing else is up, and the rule calls it *free* against a game measured painting it first. βœ… The verdicts that matter are convention-independent β€” `pgloading_eff00.prm` is FIRST under all four, `pteff00.prm` FREE under all four. ⚠️ The port's **256 vs 211** was a **bundle mismatch, not a definitional one**: `palogo_eff0.prm` runs to t=255 on the publisher splash (entries 10/13) and t=210 on the developer (11/14) | diff --git a/docs/re/structures/ui-forced-backdrop.md b/docs/re/structures/ui-forced-backdrop.md index 5a43f5c5..8e49f6ad 100644 --- a/docs/re/structures/ui-forced-backdrop.md +++ b/docs/re/structures/ui-forced-backdrop.md @@ -37,7 +37,8 @@ instances. | primitive | measured in the game | opaque instants | forced below | rule says | |---|---|---|---|---| -| `palogo_eff0.prm` | **paints FIRST** | 211 | **6 of 6** | βœ… forced first | +| `palogo_eff0.prm` (entry 11, developer) | **paints FIRST** | 211 | **6 of 6** | βœ… forced first | +| `palogo_eff0.prm` (entry 10/13, publisher) | **paints FIRST** | 256 | 2 of 2 | βœ… 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 | @@ -47,6 +48,50 @@ order. Occlusion gets it right. `pteff00.prm` is opaque only for two instants, a its screen's entry and exit, so the constraint never binds it: it is the fade cover, and it belongs on top. +## The span, and what it costs to get wrong + +The rule quantifies over "every instant the primitive is opaque" and "every +element visible then", so it depends on where a screen's timeline ends and on what +an element does after its own last keyframe. The port asked, having got **256** +opaque instants for `palogo_eff0.prm` where this page said 211. + +⚠️ **That pair was a bundle mismatch, not a definitional one** β€” `palogo_eff0.prm` +appears on both splashes, and the publisher (entries 10, 13) runs to t=255 while +the developer (11, 14) runs to t=210. 256 and 211 are both right, for their own +screen. The definitions already agreed. + +**The definition:** 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 it is decoded rather than assumed: a group holds at its +last keyframe rather than looping +([`ui-keyframe-time-unit.md`](../ui-keyframe-time-unit.md)), and +[`ui-record-loop-length.md`](ui-record-loop-length.md) shows the declared length +never falls short of the last keyframe, the slack being exactly that hold. + +**How much rests on it β€” 130 keyless full-screen primitives with an opaque +interval, and how many verdicts change:** + +| alternative convention | verdicts changed | +|---|---| +| span = the bundle header's declared `+0x08` | **0** | +| span = the primitive's **own** last keyframe | 72 | +| elements counted **gone** after their last keyframe (no hold) | **72** | + +πŸ”΄ **The hold decides 55 % of the verdicts, and dropping it is refuted by a +measured order.** `palogo_eff0.prm` is a *single* keyframe at t=0. Without the +hold it would be opaque 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. Pinned by +`the_hold_after_a_final_keyframe_is_required_by_a_measured_order`. + +βœ… **And the header length is interchangeable with the elements' maximum**: zero +disagreements disc-wide. Either may be used. + +βœ… **The verdicts that matter are convention-independent.** +`pgloading_eff00.prm` comes out **first** under all four conventions; +`pteff00.prm` comes out **free** under all four. Only `palogo_eff0.prm` moves, and +only under the convention its own measured order rules out. + ## Disc-wide Keyless **full-screen** primitives with an opaque interval: