diff --git a/crates/sylpheed-formats/examples/splash_blend_check.rs b/crates/sylpheed-formats/examples/splash_blend_check.rs new file mode 100644 index 00000000..4b2acd6a --- /dev/null +++ b/crates/sylpheed-formats/examples/splash_blend_check.rs @@ -0,0 +1,75 @@ +//! Out-of-sample test of the `T8aD +0x04` blend bit on the **boot splashes**. +//! +//! `ui-blend-mode-decoded.md` established the field on 35 elements over three +//! screens (GP_TITLE entries 2, 4, 5, 6). The two splashes -- entries 10 and 11 -- +//! were NOT in that sample, and they are the screens the 2026-09-01 play-test +//! says are wrong. +//! +//! The oracle for these two screens is `data/splash-draw-pass-census.txt`: over +//! **1048 draws of frames 4..226**, covering both splashes end to end, the only +//! blend states submitted are `0x00010001` (the clear) and `0x07010701` +//! (source-over). Additive, `0x01010101`, appears **zero** times. +//! +//! PRE-REGISTERED PREDICTION, written before reading the disc: if the bit is the +//! blend selector and it generalises off its training screens, then every sprite +//! in entries 10 and 11 must report `additive = false`. Any `true` is a +//! discrepancy the field has to answer for. +//! +//! cargo run --release -p sylpheed-formats --example splash_blend_check +use std::path::PathBuf; +use sylpheed_formats::{pak::PakArchive, ui_layout}; + +fn main() { + let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC")); + let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE"); + + let mut additive = 0usize; + let mut alpha_over = 0usize; + let mut no_header = 0usize; + + for entry in [10usize, 11] { + let by = ar.read(&ar.entries()[entry]).expect("entry"); + let Some(b) = ui_layout::parse_build(&by) else { + println!("entry {entry}: not a build"); + continue; + }; + println!("\n## GP_TITLE entry {entry} -- {} elements, {} sprites", + b.elements.len(), b.sprites.len()); + + // Every sprite the bundle carries, not only those a top-level element + // names: a focused variant is reached through `focus_link` and would + // otherwise be invisible to this check. + let mut names: Vec<&String> = b.sprites.keys().collect(); + names.sort(); + for n in names { + match ui_layout::blend_additive_by_name(&b, &by, n) { + Some(true) => { additive += 1; + println!(" {n:<26} word04=0x{:08X} ADDITIVE", + ui_layout::header_word_04_by_name(&b, &by, n).unwrap()); } + Some(false) => { alpha_over += 1; + println!(" {n:<26} word04=0x{:08X} alpha-over", + ui_layout::header_word_04_by_name(&b, &by, n).unwrap()); } + None => { no_header += 1; println!(" {n:<26} (no T8aD header)"); } + } + } + } + + println!("\nadditive={additive} alpha-over={alpha_over} no-header={no_header}"); + println!("oracle (splash-draw-pass-census.txt, 1048 draws): additive draws = 0"); + if additive == 0 { + println!("PREDICTION HELD -- the bit agrees with the capture on both splashes"); + } else { + println!("PREDICTION FAILED -- {additive} sprite(s) claim additive, \ + the capture submits none"); + } + + // Control: this harness must be able to REPORT additive, or "additive=0" + // is a property of the harness and not of the splashes. Entry 6 is a screen + // the oracle measures as mixed. + let by6 = ar.read(&ar.entries()[6]).expect("entry 6"); + let b6 = ui_layout::parse_build(&by6).expect("build 6"); + let c_add = b6.sprites.keys() + .filter(|n| ui_layout::blend_additive_by_name(&b6, &by6, n) == Some(true)).count(); + println!("control -- entry 6 through the SAME code path reports additive={c_add} \ + (must be > 0): {}", if c_add > 0 { "PASS" } else { "FAIL" }); +} diff --git a/crates/sylpheed-formats/examples/splash_quad_names.rs b/crates/sylpheed-formats/examples/splash_quad_names.rs new file mode 100644 index 00000000..830ffca9 --- /dev/null +++ b/crates/sylpheed-formats/examples/splash_quad_names.rs @@ -0,0 +1,141 @@ +//! Name the capture's eight anonymous splash quads, from the disc. +//! +//! `data/splash-quad-timeline.txt` recorded eight distinct NDC rects off the +//! guest's vertex stream and could only call them Q0..Q7 -- a draw capture sees +//! geometry, not names. This predicts each rect from the DECLARED position and +//! the DECODED sprite size in `GP_TITLE.pak` entries 10 and 11, and matches. +//! +//! x_ndc = 2*x/1280 - 1 y_ndc = 1 - 2*y/720 (y down on screen) +//! +//! Instruments: the prediction is ⟨disc⟩ -- `parse_build` + `t8ad::parse`, i.e. +//! OUR reader. The target is ⟨capture⟩ -- the oracle's vertex stream. So an +//! agreement here is not a claim resting on the reader: it VALIDATES the reader +//! on the two splash bundles, which is what `REFUTED.md`'s 🟑 `⟨our-reader⟩` +//! entry on the splash timeline asks for. A disagreement would indict the reader. +//! +//! CONTROL: an assignment is only meaningful if the rects are separable, so this +//! reports the runner-up distance for every sprite. If the best and second-best +//! were comparable, "8/8 matched" would be an artefact of eight similar boxes. +//! +//! cargo run --release -p sylpheed-formats --example splash_quad_names +use std::path::PathBuf; +use sylpheed_formats::{pak::PakArchive, t8ad, ui_layout}; + +/// The oracle. Verbatim from the header of `docs/re/data/splash-quad-timeline.txt`, +/// which read them off the vertex buffer. Quantised to 0.01 by that file. +const CAPTURED: &[(&str, f64, f64, f64, f64, usize)] = &[ + // name, x0, x1, y0, y1, draws submitted in + ("Q0", -0.520, 0.520, -0.100, 0.080, 111), + ("Q1", -0.390, 0.390, 0.350, 0.550, 87), + ("Q2", -0.190, 0.190, -0.120, 0.120, 87), + ("Q3", -0.300, 0.300, -0.620, -0.250, 87), + ("Q4", -0.410, 0.410, 0.320, 0.570, 21), + ("Q5", -0.200, 0.210, -0.150, 0.150, 21), + ("Q6", -0.320, 0.310, -0.650, -0.220, 21), + ("Q7", -0.530, 0.540, -0.130, 0.120, 8), +]; + +fn main() { + let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC")); + let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE"); + + // Predict a rect for every sprite-bearing element of the two splash builds. + let mut predicted: Vec<(String, f64, f64, f64, f64)> = Vec::new(); + for entry in [10usize, 11] { + let by = ar.read(&ar.entries()[entry]).expect("entry"); + let b = ui_layout::parse_build(&by).expect("build"); + for el in &b.elements { + let Some(sprite) = el.sprite.as_ref() else { continue }; + let Some(&(off, size)) = b.sprites.get(sprite) else { continue }; + let Some(img) = t8ad::parse(&by[off..off + size]) else { continue }; + // Declared placement is constant across every keyframe on these + // eight elements, so any keyframe gives the same rect; take the first. + let Some(kf) = el.keyframes.first() else { continue }; + let (x, y) = (kf.x as f64, kf.y as f64); + let (w, h) = (img.width as f64, img.height as f64); + predicted.push(( + sprite.clone(), + 2.0 * x / 1280.0 - 1.0, + 2.0 * (x + w) / 1280.0 - 1.0, + 1.0 - 2.0 * (y + h) / 720.0, + 1.0 - 2.0 * y / 720.0, + )); + } + } + + let dist = |p: &(String, f64, f64, f64, f64), c: &(&str, f64, f64, f64, f64, usize)| { + (p.1 - c.1).abs().max((p.2 - c.2).abs()) + .max((p.3 - c.3).abs()).max((p.4 - c.4).abs()) + }; + + // The capture rounds to 0.01, so a correct prediction must land inside half + // a step plus the pixel grid: 0.01 NDC is 6.4 px in x, 3.6 px in y. + const TOL: f64 = 0.010; + println!("{:<26} {:<4} {:>8} {:>9} {:>7} {}", + "sprite (disc)", "quad", "max|d|", "runner-up", "draws", "verdict"); + let (mut ok, mut bad) = (0, 0); + let mut used: Vec<&str> = Vec::new(); + for p in &predicted { + let mut ds: Vec<(f64, &(&str, f64, f64, f64, f64, usize))> = + CAPTURED.iter().map(|c| (dist(p, c), c)).collect(); + ds.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + let (d0, best) = (ds[0].0, ds[0].1); + let d1 = ds[1].0; + let good = d0 <= TOL && !used.contains(&best.0); + if good { ok += 1; used.push(best.0) } else { bad += 1 } + println!("{:<26} {:<4} {:>8.4} {:>9.4} {:>7} {}", + p.0, best.0, d0, d1, best.5, + if good { "OK" } else { "MISMATCH" }); + } + println!("\n{ok} named, {bad} unmatched, of {} predicted / {} captured", + predicted.len(), CAPTURED.len()); + println!("CONTROL: every runner-up above must be far outside the {TOL} tolerance; \ + if it is not, the rects are not separable and the naming is luck."); +} + +/// The second question this data answers, appended as its own pass: what IS an +/// `_eff` companion? `splash-quad-timeline.txt` called them "the same rects +/// scaled slightly larger", which is an inference from four rounded NDC numbers. +/// The disc says otherwise, and the difference matters to anyone drawing them. +#[test] +fn eff_is_a_concentric_outset_not_a_scale() { + let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC")); + let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE"); + for (entry, pairs) in [ + (10usize, &[("palogo_sqex", "palogo_sqex_eff")][..]), + (11, &[("palogo_gamearts", "palogo_gamearts_eff"), + ("palogo_seta", "palogo_seta_eff"), + ("palogo_anima", "palogo_anima_eff")][..]), + ] { + let by = ar.read(&ar.entries()[entry]).expect("entry"); + let b = ui_layout::parse_build(&by).expect("build"); + for (logo, eff) in pairs { + let g = |n: &str| { + let el = b.elements.iter() + .find(|e| e.sprite.as_deref() == Some(&format!("{n}.t32"))).unwrap(); + let &(off, size) = b.sprites.get(&format!("{n}.t32")).unwrap(); + let img = t8ad::parse(&by[off..off + size]).unwrap(); + let kf = el.keyframes.first().unwrap(); + (kf.x as f64, kf.y as f64, img.width as f64, img.height as f64) + }; + let (lx, ly, lw, lh) = g(logo); + let (ex, ey, ew, eh) = g(eff); + // concentric? + let dcx = (ex + ew / 2.0) - (lx + lw / 2.0); + let dcy = (ey + eh / 2.0) - (ly + lh / 2.0); + assert!(dcx.abs() <= 2.0 && dcy.abs() <= 2.0, + "{eff}: centres differ by ({dcx},{dcy}) -- not concentric"); + // uniform outset, NOT a uniform scale? + let (ox, oy) = ((ew - lw) / 2.0, (eh - lh) / 2.0); + assert!((ox - oy).abs() <= 2.0, + "{eff}: outset ({ox},{oy}) is not uniform"); + assert!(ox >= 8.0 && ox <= 12.0, "{eff}: outset {ox} px outside 8..12"); + // and the scale reading it displaces + let (sx, sy) = (ew / lw, eh / lh); + assert!((sx - sy).abs() > 0.05, + "{eff}: scales {sx:.3}/{sy:.3} ARE uniform -- 'scaled larger' would stand"); + println!("{eff:<26} outset {ox:.0}x{oy:.0} px, centre off by \ + ({dcx:.1},{dcy:.1}), scale {sx:.3}/{sy:.3} (NOT uniform)"); + } + } +} diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index e7e1ffc5..80a75094 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -499,6 +499,74 @@ constructed in `sub_8220B610` and released in `sub_821A6470`. A *decoder* betwee `wButtons` and the menus is where repeat timing, edge detection and remapping live. That is the next read. +--- +## βœ…βœ… 2026-09-01 (sixth) β€” **THE BLUR IS A TEXTURE.** `palogo_*_eff.t32` is a baked 10-px glow + +Play-test finding 4 is **answered as a mechanism**, and the answer is on the disc, +so it generalises rather than describing one boot. +[`../re/splash-glow-is-a-baked-texture.md`](../re/splash-glow-is-a-baked-texture.md) + +> **Each logo ships a SECOND texture that IS the blur** β€” the same artwork outset +> by exactly **10 pixels on every side** β€” drawn as its own alpha-over quad, +> concentric with the logo. There is no blur pass, no filter, and nothing to +> compute. Draw the logo alone and you lose the glow completely. + +**Your four missing sprites**, by name: + +``` +palogo_sqex_eff.t32 palogo_gamearts_eff.t32 palogo_seta_eff.t32 palogo_anima_eff.t32 +``` + +### The capture's Q0…Q7 now have names + +I predicted each quad's NDC rect from the DECLARED position and the DECODED +sprite size and matched it against the vertex stream. **8 named, 0 unmatched**, +every match ≀ 0.0061 and every runner-up β‰₯ 0.0272 β€” a 4.5–8.9Γ— margin, which is +the control that stops eight similar boxes matching anything. + +| quad | is | | quad | is | +|---|---|---|---|---| +| Q0 | `palogo_sqex.t32` | | Q4 | `palogo_gamearts_eff.t32` | +| Q7 | `palogo_sqex_eff.t32` | | Q5 | `palogo_seta_eff.t32` | +| Q1 | `palogo_gamearts.t32` | | Q6 | `palogo_anima_eff.t32` | +| Q2 | `palogo_seta.t32` | | Q3 | `palogo_anima.t32` | + +### πŸ”΄ I told you the companions were "the same rects scaled slightly larger". WRONG β€” do not scale + +That was an inference off four rounded NDC numbers. The disc says they are +**concentric 10-px outsets**, and their x/y scale factors differ by up to **0.28**: + +| | logo | `_eff` | Ξ” | scale x / y | +|---|---|---|---|---| +| `sqex` | 666Γ—68 @ (309,330) | 686Γ—89 @ (299,319) | +20Γ—+21 px | 1.030 / **1.309** | +| `gamearts` | 500Γ—71 @ (390,164) | 521Γ—91 @ (379,154) | +21Γ—+20 px | 1.042 / **1.282** | +| `seta` | 240Γ—89 @ (521,316) | 261Γ—110 @ (511,305) | +21Γ—+21 px | 1.087 / **1.236** | +| `anima` | 388Γ—136 @ (446,449) | 407Γ—156 @ (435,440) | +19Γ—+20 px | 1.049 / **1.147** | + +A scaled copy has a border that grows with the sprite. The real one is 10 px +whatever the sprite. **Load the `_eff` texture; do not transform the logo.** + +### And they are source-over, not additive β€” tested OUT of sample + +The blend bit was fitted on entries 2/4/5/6. Entries **10 and 11 were not in that +sample**. Pre-registered: the census finds additive in **0 of 1 048** splash +draws, so all eight must read `additive = false`. **They do β€” 8/8**, with a +control showing the same accessor still reports 9 additive on entry 6. + +**So do not "add a glow" by switching these to additive.** That would be wrong in +a new way. The softness is entirely the texture's own alpha. + +### Timing of the halo + +Declared `0@0 β†’ 255@15 β†’ (255 or 212)@30 β†’ 0@45`, against the logo's +`0@15 β†’ 255@30 β†’ … β†’ 0@210` (developer) / `0@255` (publisher). **The halo flashes +during the entry and is gone for the whole hold** β€” ~45 units β€” which is exactly +the moment the play-test describes as too soft in your build. + +Also new on the formats API and pinned below: `sprite_blend_additive`, +`blend_additive_by_name`, `sprite_header_word_04`, `header_word_04_by_name`, so +you can stop keying a blend map by screen name. + --- ## βœ…βœ… 2026-09-01 β€” **THE SPLASHES HAVE NO POST-PROCESS.** One pass, source-over, alpha in the vertex stream diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 3b8a93db..a9937b78 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -144,6 +144,7 @@ files, which is how the same ground got covered twice. | [`h3-units-per-frame-preregistration.md`](h3-units-per-frame-preregistration.md) | The prediction, committed before the capture was read | βœ… kept as the control on the row above | | [`input-pad-read-path.md`](input-pad-read-path.md) | What the game asks the console for, on the pad | βœ… **decoded from the image, 2026-09-01.** `sub_82457038` is the only function that reads controller *data*, and it compares **every** field of `XINPUT_GAMEPAD` β€” `wButtons` (full 16 bits), both triggers, all four stick axes β€” against the previous state. **14/14** loads verified byte-for-byte against `/image/sylpheed.pe`, database used as an index only. **Two input paths**: the polled state and an 8-byte-record `XamInputGetKeystrokeEx` ring. ⚠️ This is the **superset the game can see, not the per-screen set** β€” reading a field is not acting on it. ❔ Which bits each screen tests is open; footholds are the image's own `C_PAD_DECODER` / `C_PAD_RINGBUF` trace strings (`sub_8220B610` / `sub_821A6470`). | | [`ui-splash-draw-pass.md`](ui-splash-draw-pass.md) | The splash draw pass β€” is there a post-process, and where does the fade come from | βœ… **decoded from GPU state, 2026-09-01.** **No post-process pass exists**: over all 1 048 draws of both splashes, one render target (`rt0=[tile=0…]` 1 048/1 048), one pitch, no MSAA, only kColorDepth/kCopy modes, resolve destinations only the two front buffers, and **no texture base anywhere equals a resolve destination**. The only texture bound is the sprite page. Three trivial pixel shaders; the sprite shader **premultiplies**, so `ONE/ONE_MINUS_SRC_ALPHA` is algebraically **source-over**. Parameters come from **per-vertex `k_8_8_8_8` colour** in a per-frame vertex buffer β€” the shaders read **zero** float constants (`ps_c[n=0]` 1 048/1 048). Ramp reproduces the βœ… 34/frame law. ⚠️ Censusing the whole 600-frame log instead finds the attract movie's 640Γ—360 chroma planes and reads as a half-res blur chain β€” the frame window is what avoids that. | +| [`splash-glow-is-a-baked-texture.md`](splash-glow-is-a-baked-texture.md) | What the splash "blur" actually is, and which quad is which sprite | βœ… **DECODED (disc), confirmed against the oracle 8/8.** Answers play-test finding 4 as a mechanism: **there is no blur pass and no filter β€” each logo ships a SECOND texture that IS the blur.** `palogo__eff.t32` is the same artwork **outset by exactly 10 px on every side**, concentric to ≀ 1.5 px, drawn as its own alpha-over quad. βœ… **The capture's Q0…Q7 are now NAMED**: predicting each NDC rect from the declared position + decoded sprite size matches all eight bijectively, every match ≀ 0.0061 with every runner-up β‰₯ 0.0272 (4.5–8.9Γ— margin β€” the control). πŸ”΄ **Refutes `splash-quad-timeline.txt`'s "the same rects scaled slightly larger"**: x/y scale factors differ by up to 0.28, so a port must **load the `_eff` texture, not transform the logo**. βœ… **Blend bit tested OUT of sample** on entries 10/11 (never in its 35-row fit): pre-registered `additive = false` for all eight against 0 additive draws in 1 048 β€” held 8/8, control still reports 9 additive on entry 6. πŸ“Œ The ⟨our-reader⟩ 🟑 on splash geometry resolves **in the reader's favour** β€” the prediction is ours, the target is the oracle, so agreement is evidence *about* the reader | | [`ui-keyframe-record-layout.md`](ui-keyframe-record-layout.md) | A keyframe's time word comes **before** its pose β€” the placement record, decoded | βœ… CONFIRMED, **decoded**. A group is an 8-byte header then `frames` records of `{u32 time; 36-byte pose}`, so the time precedes the pose; the group's lead-in word at `header+8` is pose 0's time and **every** pose is timed. Disc-wide over 13 991 groups in 33 archives, each test with a control: lead-in prepended is non-decreasing **13 991/13 991**; a non-zero lead-in is strictly below the next time **5 058/5 058** (control 70.9 %); a multi-segment alpha ramp runs at a constant `dΞ±/dt` **857/1 540** against **0/1 042** under the old reading. πŸ”΄ Retires two long-standing corpus claims β€” *"a group's data stops 4 bytes short of its final block's time slot"* and *"the last keyframe carries no time"* β€” both of which were this off-by-one. Adoption is free: all 12 `GP_TITLE` builds render byte-identically, and over 217 builds only two elements pick a different `rest()` pose, both between equally invisible ones. ❔ the executable's own parser was **not** found (the 40/60 stride query is weak, not negative) | | [`structures/ui-composable-bundles.md`](structures/ui-composable-bundles.md) | A screen build is not the only thing `compose` can draw | βœ… CONFIRMED by measurement over the disc, with the artifact to | | [`structures/ui-focus-and-effect-elements.md`](structures/ui-focus-and-effect-elements.md) | `_eff` glow layers are not focused-state records | βœ… CONFIRMED by measurement over all 965 screen builds on the disc, | diff --git a/docs/re/splash-glow-is-a-baked-texture.md b/docs/re/splash-glow-is-a-baked-texture.md new file mode 100644 index 00000000..24ca24a5 --- /dev/null +++ b/docs/re/splash-glow-is-a-baked-texture.md @@ -0,0 +1,163 @@ +# The splash "blur" is a baked glow texture β€” `palogo_*_eff.t32`, a 10-pixel concentric outset + +**Status: βœ… decoded (disc), confirmed against the oracle 8/8.** 2026-09-01. +Instruments: prediction ⟨disc⟩ (`parse_build` + `t8ad::parse` over +`GP_TITLE.pak`); target ⟨capture⟩ (the guest vertex stream in +[`data/splash-quad-timeline.txt`](data/splash-quad-timeline.txt) and +[`data/splash-draw-pass-census.txt`](data/splash-draw-pass-census.txt)). + +Answers play-test finding 4 β€” *"the splash fade/blur is more pronounced in the +game"* β€” at the level the human asked for: **the mechanism, not a curve.** + +--- + +## The answer in one line + +> **There is no blur pass and no blur filter. Each logo ships a second texture +> that IS the blur β€” `palogo__eff.t32`, the same artwork outset by exactly +> 10 pixels on every side β€” and the game draws it as an extra alpha-over quad +> concentric with the logo. Drawing the logo alone loses the glow entirely.** + +[`ui-splash-draw-pass.md`](ui-splash-draw-pass.md) had already excluded a +post-process from GPU state and closed with *"that softness is in the **texture** +or in **which quads are drawn**, not in a pass"*, leaving the two unseparated. +It is **both, and they are the same fact**: a dedicated soft-edged texture, drawn +as its own quad. + +## 1 β€” the capture's eight anonymous quads, named from the disc + +A draw capture sees geometry, not names. `splash-quad-timeline.txt` could only +call them `Q0…Q7`. Predicting each rect from the **declared position** and the +**decoded sprite size** β€” `x_ndc = 2x/1280 βˆ’ 1`, `y_ndc = 1 βˆ’ 2y/720` β€” names all +eight, bijectively: + +| sprite (disc) | quad | max abs edge error | runner-up | draws | +|---|---|---|---|---| +| `palogo_sqex.t32` | Q0 | 0.0056 | 0.0367 | 111 | +| `palogo_sqex_eff.t32` | Q7 | 0.0061 | 0.0339 | 8 | +| `palogo_gamearts.t32` | Q1 | 0.0056 | 0.0272 | 87 | +| `palogo_gamearts_eff.t32` | Q4 | 0.0037 | 0.0306 | 21 | +| `palogo_seta.t32` | Q2 | 0.0050 | 0.0278 | 87 | +| `palogo_seta_eff.t32` | Q5 | 0.0037 | 0.0328 | 21 | +| `palogo_anima.t32` | Q3 | 0.0050 | 0.0272 | 87 | +| `palogo_anima_eff.t32` | Q6 | 0.0056 | 0.0356 | 21 | + +**8 named, 0 unmatched.** The capture quantises to 0.01 NDC, so the tolerance is +0.010; every match lands at ≀ 0.0061 and every **runner-up** sits at β‰₯ 0.0272 β€” +a 4.5Γ— to 8.9Γ— margin. **That margin is the control**: eight similar boxes would +match anything, and these do not. + +Reproduce: `cargo run --release -p sylpheed-formats --example splash_quad_names`. + +πŸ“Œ **This also settles a `⟨our-reader⟩` 🟑 in the right direction.** The +prediction comes from our reader; the target is the oracle's vertex buffer. The +agreement is therefore **evidence about the reader**, not a claim resting on it β€” +`parse_build`'s declared placement and `t8ad`'s decoded dimensions reproduce +what the GPU actually drew, on both splash bundles, to within 4 screen pixels. +A disagreement would have indicted the reader instead. + +## 2 β€” what an `_eff` companion actually is + +`splash-quad-timeline.txt` described the companions as *"the same three rects +**scaled slightly larger**"*. That is an inference from four rounded NDC numbers, +and the disc refutes it: + +``` +palogo_sqex_eff outset 10x10 px, centre off by ( 0.0,-0.5), scale 1.030/1.309 NOT uniform +palogo_gamearts_eff outset 10x10 px, centre off by (-0.5, 0.0), scale 1.042/1.282 NOT uniform +palogo_seta_eff outset 10x10 px, centre off by ( 0.5,-0.5), scale 1.087/1.236 NOT uniform +palogo_anima_eff outset 10x10 px, centre off by (-1.5, 1.0), scale 1.049/1.147 NOT uniform +``` + +| | logo | `_eff` | Ξ” | +|---|---|---|---| +| `sqex` | 666Γ—68 @ (309,330) | 686Γ—89 @ (299,319) | **+20Γ—+21 px, βˆ’10,βˆ’11** | +| `gamearts` | 500Γ—71 @ (390,164) | 521Γ—91 @ (379,154) | **+21Γ—+20 px, βˆ’11,βˆ’10** | +| `seta` | 240Γ—89 @ (521,316) | 261Γ—110 @ (511,305) | **+21Γ—+21 px, βˆ’10,βˆ’11** | +| `anima` | 388Γ—136 @ (446,449) | 407Γ—156 @ (435,440) | **+19Γ—+20 px, βˆ’11,βˆ’9** | + +* **Concentric** β€” the two centres agree to ≀ 1.5 px in every pair. +* **A constant 10-px outset per side**, in *both* axes, on all four pairs. +* **Not a scale.** The x and y scale factors differ by 0.06 to 0.28. A + "slightly larger copy" model predicts they match; they do not, and the + narrower the logo's height the further apart they get β€” exactly what a + *fixed-width* border does and a scale cannot. + +A fixed 10-pixel halo around the same artwork, in a separate texture, is a +**pre-rendered blur**. Nothing computes it at runtime; it was computed by the +artist and shipped. + +Asserted by `eff_is_a_concentric_outset_not_a_scale` in the same example, which +fails if the pair is not concentric, if the outset is not uniform, if it leaves +8…12 px, **or if the scale factors turn out uniform after all** β€” that last +assertion is the one that would reinstate the reading it displaces. + +## 3 β€” the blend, tested out of sample on these exact screens + +[`structures/ui-blend-mode-decoded.md`](structures/ui-blend-mode-decoded.md) +established `T8aD +0x04` bit `0x02` on **35 elements over three screens** +(`GP_TITLE` entries 2, 4, 5, 6). Entries **10 and 11 were not in that sample**, +and they are the screens the play-test says are wrong. + +**Pre-registered before reading the disc:** the census finds `0x01010101` +(additive) in **0 of 1 048** splash draws, so if the bit generalises, all eight +splash sprites must read `additive = false`. + +``` +palogo_sqex.t32 / _eff, palogo_{gamearts,seta,anima}.t32 / _eff + word04 = 0x00008830 -> alpha-over (8 of 8) +additive=0 alpha-over=8 no-header=0 +control -- entry 6 through the SAME code path reports additive=9: PASS +``` + +**Prediction held.** The control matters: the same accessor reports 9 additive +sprites on entry 6, so `additive=0` is a fact about the splashes and not a +harness stuck on one answer. + +`cargo run --release -p sylpheed-formats --example splash_blend_check`. + +πŸ”΄ **So the glow is NOT additive.** It is composited source-over like everything +else, and its softness comes entirely from the texture's own alpha. A port that +"adds a glow" by switching these quads to additive blending will be wrong in a +new way. + +## What this means for the port + +1. **Draw `palogo_sqex_eff.t32`, `palogo_gamearts_eff.t32`, `palogo_seta_eff.t32` + and `palogo_anima_eff.t32` as their own quads**, at their own declared + positions, with their own keyframe alphas. They are not decorations of the + logo element and they are not derivable from it. +2. **Do not scale the logo to produce them.** They are separate art. A scaled + logo has a border that grows with the logo; the real one is 10 px regardless. +3. **Source-over, not additive**, on all eight. +4. **They are short-lived.** Declared `0@0 β†’ 255@15 β†’ (255 or 212)@30 β†’ 0@45` + against the logo's `0@15 β†’ 255@30 β†’ … β†’ 0@210` (developer) / `0@255` + (publisher). The halo flashes during the entry and is gone for the whole hold. + That is the "more pronounced" moment and it is ~45 units long. + +## Refutation attempt, recorded per the adversarial duty + +**Target:** `ui-blend-mode-decoded.md`'s *"`T8aD +0x04` bit `0x02` set β‡’ +additive"*, tested where it was never fitted β€” the two splash bundles, on the +screens under play-test complaint, against 1 048 captured draws. + +**Result: it SURVIVED, out of sample, 8/8, with a passing control.** + +**Second target, same iteration:** `splash-quad-timeline.txt`'s *"the same three +rects scaled slightly larger"*. **REFUTED** β€” Β§2. The companions are concentric +10-px outsets, and their x and y scale factors differ by up to 0.28, which a +uniform scale cannot produce. The conclusion the phrase supported (there are six +quads and an export must draw all six) is unaffected; the *model* of what the +extra three are was wrong, and the wrong model tells a port to scale a sprite. + +## Reach + +⟨disc⟩ for Β§2 and Β§3, so both generalise to every screen that uses the same +structures β€” the `_eff` naming convention and the blend bit are properties of the +shipped data, not of a boot. Β§1's *agreement* is ⟨disc⟩ Γ— ⟨capture⟩ over the two +splash bundles specifically. + +**Not settled here:** whether the `_eff` alpha ramp the port should use is the +declared one or the captured one (they differ; see +[`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)), and the plate-late +finding (play-test 3), which is untouched.