diff --git a/crates/sylpheed-formats/examples/leaf_alpha_compose.rs b/crates/sylpheed-formats/examples/leaf_alpha_compose.rs new file mode 100644 index 00000000..e7e1a830 --- /dev/null +++ b/crates/sylpheed-formats/examples/leaf_alpha_compose.rs @@ -0,0 +1,56 @@ +//! How do a parent record's alpha ramp and its nested `.rat` leaf's compose? +//! +//! The port emits both but will not draw the leaf without knowing the rule -- +//! rightly, since drawing on a guess trades a visible 1.82 % error for an +//! invisible wrong one. There is an oracle for this: the GPU draw capture +//! records **vertex colours**, and on the title's `ptloop` draw they are +//! `C3FFFFFF` and `B6FFFFFF` -- alpha **195** and **182**, not 255. So the game's +//! composed alpha is observable, and a candidate rule either predicts those two +//! numbers or does not. +//! +//! cargo run -p sylpheed-formats --example leaf_alpha_compose -- +use sylpheed_formats::{pak, ui_layout}; + +fn alpha_of(kf: &ui_layout::Keyframe) -> u32 { + // The keyframe block's +0 is an ARGB fade colour; alpha is its high byte. + kf.fade >> 24 +} + +fn main() { + let path = std::env::args().nth(1).expect("usage: "); + let ar = pak::PakArchive::open(&path).expect("open"); + let e = &ar.entries()[4]; // GP_TITLE entry 4 = the English title + let bytes = ar.read(e).expect("read"); + let build = ui_layout::parse_build(&bytes).expect("build"); + + for name in ["ptloop01.rat", "ptloop02.rat"] { + println!("\n=== {name} ==="); + if let Some(el) = build.elements.iter().find(|x| x.name == name) { + println!(" PARENT keyframes (t, alpha, scale, rot, x,y):"); + for k in &el.keyframes { + println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} ({},{})", + k.time.map(|t| t as i64).unwrap_or(-1), alpha_of(k), k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y); + } + } + match build.records.get(name) { + Some(&(off, size)) => { + let leaf = &bytes[off..off + size]; + match ui_layout::parse_build(leaf) { + Some(lb) => { + for le in &lb.elements { + println!(" LEAF element {:?}:", le.name); + for k in &le.keyframes { + println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} ({},{})", + k.time.map(|t| t as i64).unwrap_or(-1), alpha_of(k), k.scale_x, k.scale_y, + k.rotation_deg, k.x, k.y); + } + } + } + None => println!(" leaf did not parse"), + } + } + None => println!(" no leaf record"), + } + } + println!("\nOBSERVED in the draw capture: quad A alpha 195 (0xC3), quad B alpha 182 (0xB6)"); +} diff --git a/docs/re/structures/ui-leaf-vs-parent-alpha.md b/docs/re/structures/ui-leaf-vs-parent-alpha.md new file mode 100644 index 00000000..2b98996f --- /dev/null +++ b/docs/re/structures/ui-leaf-vs-parent-alpha.md @@ -0,0 +1,72 @@ +# ✅ A nested `.rat` leaf animates on its OWN timeline — the parent's alpha does not multiply in + +**Status:** ✅ **DECODED**, against a GPU draw capture rather than our renderer. +Answers the port's question: it emits both a parent record and its nested leaf, +each with its own alpha ramp over a different span, and would not draw the leaf +without knowing the composition rule. + +## The question + +For the title's light sweeps, parent and leaf disagree about everything: + +| | `ptloop01` parent | its leaf `pteff03.t32` | +|---|---|---| +| alpha | 0 → 255 over t=70…100, held to 238, → 0 at 250 | 255 → 128 at t=150 → 255 at 540 | +| scale | (100, 100) | **(100, 600)** | +| rotation | 0 | **+30°** | +| x | fixed 441 | **−639 → −39 → 1521** | + +`ptloop02`'s leaf `pteff03a.t32` is the mirror: (100, **800**), **−45°**, x +sweeping 1721 → 1111 → −839, alpha 0 → 128 → 255. + +## The oracle + +The per-draw capture records **vertex colours**, and on the title's `ptloop` draw +(draw 2) they are `C3FFFFFF` and `B6FFFFFF` — **alpha 195 and 182**, not 255. So +the composed alpha the game actually submitted is observable. + +## The measurement + +Fitting **only the two alphas** to the two leaf ramps gives a single consistent +time, **t = 355**: + +| | leaf value at t=355 | observed | +|---|---|---| +| quad A alpha | **194.8** | **195** | +| quad B alpha | **182.2** | **182** | +| parent alpha (both) | **0** | — | + +🔴 **Multiplying the ramps is refuted.** The parent has expired by t=355 (it +returns to 0 at t=250 and a group **holds** at its last keyframe), so +`leaf × parent / 255` predicts **0 for both quads** — the sweeps would be +invisible. They are drawn, at 195 and 182. + +✅ **And the position check was PREDICTED, not fitted.** Nothing about x entered +the fit; the same t=355 then places the quads from the leaves' own sweeps: + +| | from the leaf at t=355 | measured off the capture | +|---|---|---| +| quad A centre x | **981** | **992.0** | +| quad B centre x | **478** | **467.2** | + +Within ~11 px, on 400-px-wide quads travelling 1 560 and 1 950 px. Four +quantities — two alphas and two positions, from two differently-shaped ramps — +all agree on one time. + +## The rule + +**A leaf carrying geometry animates on its own timeline. The parent's alpha does +not gate it.** For these records the parent is a container: it has no sprite, and +its keyframes describe nothing that is drawn. + +⚠️ **Reach, and it is not a universal rule about leaves.** This is one draw, one +capture, one element pair, and it is specifically the case where **the parent +carries no geometry**. The opposite case is already recorded: for a button, a +base record's leaf *duplicates* the parent and the **parent wins** +([`ui-button-focus-record.md`](ui-button-focus-record.md)). So the discriminator +is which record actually carries the geometry, not a fixed precedence. + +❔ What is **not** established: whether the parent alpha would multiply in during +a window where it is non-zero. Every observation here has parent = 0, so +"the leaf wins" and "the parent is ignored because it has nothing to draw" are +not separated. A capture during t=100…238 would separate them.