diff --git a/crates/sylpheed-formats/examples/additive_census.rs b/crates/sylpheed-formats/examples/additive_census.rs new file mode 100644 index 00000000..55d42346 --- /dev/null +++ b/crates/sylpheed-formats/examples/additive_census.rs @@ -0,0 +1,26 @@ +use std::path::PathBuf; +use sylpheed_formats::{pak::PakArchive, ui_layout}; +fn main() { + let root = PathBuf::from(std::env::var("SYLPHEED_DISC").unwrap()); + println!("# Which elements the reference renderer now draws ADDITIVE, per screen."); + println!("# Source: T8aD +0x04 bit 0x02, docs/re/structures/ui-blend-mode-decoded.md."); + println!("# Generated after ui_layout::blit gained an additive path (2026-09-01)."); + println!("# Before that change EVERY row below was drawn alpha-over by our renderer,"); + println!("# which is why `verify-screen` was structurally incapable on these screens."); + for pak in ["GP_TITLE", "GP_OPTIONS"] { + let Ok(ar) = PakArchive::open(root.join(format!("dat/{pak}.pak"))) else { continue }; + let n = ar.entries().len(); + for e in 0..n { + let Ok(by) = ar.read(&ar.entries()[e]) else { continue }; + let Some(b) = ui_layout::parse_build(&by) else { continue }; + let mut add: Vec<&String> = b.sprites.keys() + .filter(|s| ui_layout::blend_additive_by_name(&b, &by, s) == Some(true)) + .collect(); + if add.is_empty() { continue } + add.sort(); + println!("\n{pak} entry {e} -- {} of {} sprites additive", + add.len(), b.sprites.len()); + for s in add { println!(" {s}"); } + } + } +} diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index cbc61bbc..1175c26a 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -1424,7 +1424,8 @@ pub fn compose_with_order( if lk.scale_x == 0 || lk.scale_y == 0 { continue; } - blit(&mut canvas, w, h, &limg, &lk, le.pivot_x, le.pivot_y); + blit(&mut canvas, w, h, &limg, &lk, le.pivot_x, le.pivot_y, + limg.flags & 0x02 != 0); any = true; } if any { @@ -1432,7 +1433,8 @@ pub fn compose_with_order( continue; } } - blit(&mut canvas, w, h, &img, kf, el.pivot_x, el.pivot_y); + blit(&mut canvas, w, h, &img, kf, el.pivot_x, el.pivot_y, + img.flags & 0x02 != 0); drawn.push(el.index); } ComposedScreen { @@ -1522,6 +1524,37 @@ fn fill_quad( /// rect, which is what the game draws. At 100 % the pivot cancels, which is why /// every unscaled element — and so every ruler this format was checked against — /// was unaffected. See `docs/re/structures/ui-rat-layout.md`. +/// One source sample over one destination sample, in whichever blend the disc +/// declares for the element. +/// +/// Both equations come from the game's own pixel shader, dumped from the running +/// guest and disassembled in `docs/re/ui-splash-draw-pass.md`: +/// +/// ```text +/// mul r1.___w, r2.wwww, r0.wwww ; A = tex.a * vcol.a +/// mul r0.xyz_, r2.xyzz, r0.xyzz ; rgb = tex.rgb * vcol.rgb +/// mul r1.xyz_, r0.xyzz, r1.wwww ; rgb = rgb * A <-- the shader PREMULTIPLIES +/// max oC0, r1, r1 ; oC0 = (rgb*A, A) +/// ``` +/// +/// So the shader always emits `src = rgb·A`, and only the blend register differs: +/// +/// * alpha-over, `RB_BLENDCONTROL0 = 0x07010701` — `ONE / ONE_MINUS_SRC_ALPHA`, +/// giving `dst' = rgb·A + dst·(1 − A)`, i.e. ordinary source-over; +/// * additive, `0x01010101` — `ONE / ONE`, giving `dst' = rgb·A + dst`. +/// +/// The additive case therefore **saturates rather than wraps**, and a fully +/// transparent or fully black source is the identity in it — both of which the +/// control tests pin, because they are arithmetic and not opinions. +#[inline] +fn combine(additive: bool, sc: u32, sa: u32, dc: u32) -> u8 { + if additive { + (dc + sc * sa / 255).min(255) as u8 + } else { + ((sc * sa + dc * (255 - sa)) / 255) as u8 + } +} + fn blit( canvas: &mut [u8], cw: u32, @@ -1530,6 +1563,11 @@ fn blit( kf: &Keyframe, pivot_x: u32, pivot_y: u32, + // `T8aD +0x04` bit `0x02` — the game draws this element ADDITIVE. Decoded, + // `docs/re/structures/ui-blend-mode-decoded.md`: 35 elements over three + // screens against `RB_BLENDCONTROL0` read out of the guest command stream, + // 0 errors, plus an out-of-sample hit on `GP_OPTIONS`. + additive: bool, ) { let (sw, sh) = (img.width, img.height); if sw == 0 || sh == 0 { @@ -1632,7 +1670,7 @@ fn blit( let di = ((ty as u32 * cw + tx as u32) * 4) as usize; for (k, sc) in [sr, sg, sb].into_iter().enumerate() { let dc = canvas[di + k] as u32; - canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8; + canvas[di + k] = combine(additive, sc, sa, dc); } canvas[di + 3] = 255; } @@ -1670,13 +1708,9 @@ fn blit( continue; } let di = ((ty as u32 * cw + tx as u32) * 4) as usize; - // Straight alpha-over. `T8aD +0x04` bit 0x02 was tested as an - // ADDITIVE selector and REFUTED — it moved every metric against the - // title capture the wrong way (see the doc comment on - // `T8adImage::flags`), so the bit is carried but not acted on. for (k, sc) in [sr, sg, sb].into_iter().enumerate() { let dc = canvas[di + k] as u32; - canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8; + canvas[di + k] = combine(additive, sc, sa, dc); } canvas[di + 3] = 255; } @@ -1698,7 +1732,7 @@ mod tests { } fn draw(img: &t8ad::T8adImage, kf: &Keyframe, px: u32, py: u32) -> Vec { let mut c = vec![0u8; 64 * 64 * 4]; - blit(&mut c, 64, 64, img, kf, px, py); + blit(&mut c, 64, 64, img, kf, px, py, false); c } fn covered(c: &[u8]) -> Vec<(i32, i32)> { @@ -1925,7 +1959,7 @@ mod tests { }; let (w, h) = (1280u32, 720u32); let mut canvas = vec![0u8; (w * h * 4) as usize]; - blit(&mut canvas, w, h, &img, &k, 320, 180); + blit(&mut canvas, w, h, &img, &k, 320, 180, false); // Every pixel is covered; the four corners are the cheap witnesses. for (x, y) in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)] { let i = ((y * w + x) * 4) as usize; @@ -1951,7 +1985,7 @@ mod tests { let k = kf(293, 655, 0); let (w, h) = (1280u32, 720u32); let mut canvas = vec![0u8; (w * h * 4) as usize]; - blit(&mut canvas, w, h, &img, &k, 309, 10); + blit(&mut canvas, w, h, &img, &k, 309, 10, false); let at = |x: u32, y: u32| canvas[((y * w + x) * 4) as usize]; assert_eq!(at(293, 655), 255, "top-left corner is the keyframe"); assert_eq!(at(986, 674), 255, "bottom-right corner is corner + size"); @@ -1972,4 +2006,83 @@ mod tests { } assert_eq!(scan_placement_block(&r), Some((0xffff_ffff, 226, 268))); } + + // ---- additive blend: controls ------------------------------------------ + // + // `T8aD +0x04` bit 0x02 is DECODED against the GPU, but this renderer could + // not express it, so `verify-screen` was structurally incapable on 12 of 16 + // screens and its allowance quietly excused all of them. These pin the + // equation against answers that are ARITHMETIC, following the precedent set + // by `rotation_control_known_angles`. + // + // ⚠️ The last test is the one that matters. A suite that only checks the + // additive path passes just as well if the flag is ignored and everything + // draws additive; the discriminator has to show the SAME sprite giving TWO + // different answers according to the bit. + + /// Adding zero changes nothing: a black additive source is the identity. + #[test] + fn additive_control_black_source_is_identity() { + let img = t8ad::T8adImage { width: 4, height: 4, + rgba: [[0u8, 0, 0, 255]; 16].concat(), flags: 0x02 }; + let kf = kf_at(10, 10, 0); + let mut c = vec![77u8; 64 * 64 * 4]; + let before = c.clone(); + blit(&mut c, 64, 64, &img, &kf, 0, 0, true); + // alpha is forced to 255 on any touched pixel, so compare colour only + for i in (0..c.len()).filter(|i| i % 4 != 3) { + assert_eq!(c[i], before[i], "black additive source moved a pixel at {i}"); + } + } + + /// A fully transparent source is the identity in either blend. + #[test] + fn additive_control_alpha_zero_is_identity() { + let img = t8ad::T8adImage { width: 4, height: 4, + rgba: [[200u8, 200, 200, 0]; 16].concat(), flags: 0x02 }; + let kf = kf_at(10, 10, 0); + let mut c = vec![77u8; 64 * 64 * 4]; + let before = c.clone(); + blit(&mut c, 64, 64, &img, &kf, 0, 0, true); + assert_eq!(c, before, "a zero-alpha source is not the identity"); + } + + /// The sum is the sum, and it SATURATES rather than wrapping. + #[test] + fn additive_control_known_sums() { + for (dst, src, want) in [(40u8, 100u32, 140u8), (200, 100, 255), (0, 255, 255), + (250, 10, 255), (7, 8, 15)] { + let img = t8ad::T8adImage { + width: 2, height: 2, + rgba: [[src as u8, src as u8, src as u8, 255]; 4].concat(), flags: 0x02 }; + let kf = kf_at(10, 10, 0); + let mut c = vec![dst; 64 * 64 * 4]; + blit(&mut c, 64, 64, &img, &kf, 0, 0, true); + let di = ((10 * 64 + 10) * 4) as usize; + assert_eq!(c[di], want, "additive {dst} + {src} should saturate to {want}"); + } + } + + /// 🔴 THE DISCRIMINATOR. The same sprite, the same pose, the same canvas — + /// only the blend differs — must produce two DIFFERENT answers, each equal + /// to its own equation. Without this, a `blit` that ignored the flag and + /// always drew additive would pass every test above. + #[test] + fn additive_control_bit_actually_selects() { + let img = t8ad::T8adImage { width: 2, height: 2, + rgba: [[100u8, 100, 100, 128]; 4].concat(), flags: 0 }; + let kf = kf_at(10, 10, 0); + let di = ((10 * 64 + 10) * 4) as usize; + + let mut over = vec![80u8; 64 * 64 * 4]; + blit(&mut over, 64, 64, &img, &kf, 0, 0, false); + let mut add = vec![80u8; 64 * 64 * 4]; + blit(&mut add, 64, 64, &img, &kf, 0, 0, true); + + // alpha-over: (100*128 + 80*127)/255 = (12800 + 10160)/255 = 90 + assert_eq!(over[di], 90, "alpha-over arithmetic changed"); + // additive: 80 + 100*128/255 = 80 + 50 = 130 + assert_eq!(add[di], 130, "additive arithmetic changed"); + assert_ne!(over[di], add[di], "the blend flag selects nothing -- blit ignores it"); + } } diff --git a/docs/re/data/additive-elements-per-screen.txt b/docs/re/data/additive-elements-per-screen.txt new file mode 100644 index 00000000..b5bc4e7a --- /dev/null +++ b/docs/re/data/additive-elements-per-screen.txt @@ -0,0 +1,100 @@ +# Which elements the reference renderer now draws ADDITIVE, per screen. +# Source: T8aD +0x04 bit 0x02, docs/re/structures/ui-blend-mode-decoded.md. +# Generated after ui_layout::blit gained an additive path (2026-09-01). +# Before that change EVERY row below was drawn alpha-over by our renderer, +# which is why `verify-screen` was structurally incapable on these screens. + +GP_TITLE entry 0 -- 2 of 7 sprites additive + pgloading_circle1.t32 + pgloading_delta.t32 + +GP_TITLE entry 1 -- 2 of 7 sprites additive + pgloading_circle1.t32 + pgloading_delta.t32 + +GP_TITLE entry 2 -- 1 of 2 sprites additive + ptbtn00f.t32 + +GP_TITLE entry 3 -- 1 of 2 sprites additive + ptbtn00f.t32 + +GP_TITLE entry 4 -- 10 of 18 sprites additive + pteff01.t32 + pteff03.t32 + pteff03a.t32 + ptlogo_back2eff1.t32 + ptlogo_back2eff2.t32 + ptlogo_back2eff3.t32 + ptlogo_back2eff4.t32 + ptlogo_back2eff5.t32 + ptlogoall_eff.t32 + ptlogoall_eff2.t32 + +GP_TITLE entry 5 -- 6 of 21 sprites additive + pteff03.t32 + pteff03a.t32 + pteff10.t32 + pteff12.t32 + ptframe1.t32 + ptframe2.t32 + +GP_TITLE entry 6 -- 9 of 20 sprites additive + pteff03.t32 + pteff03a.t32 + pteff10.t32 + pteff20.t32 + pteff21.t32 + pteff22.t32 + pteff23.t32 + ptframe3.t32 + ptframe4.t32 + +GP_TITLE entry 7 -- 9 of 24 sprites additive + pteff03.t32 + pteff03a.t32 + ptlogo_back2eff1.t32 + ptlogo_back2eff2.t32 + ptlogo_back2eff3.t32 + ptlogo_back2eff4.t32 + ptlogo_back2eff5.t32 + ptlogo_eff2.t32 + ptlogo_eff3.t32 + +GP_TITLE entry 8 -- 6 of 21 sprites additive + pteff03.t32 + pteff03a.t32 + pteff10.t32 + pteff12.t32 + ptframe1.t32 + ptframe2.t32 + +GP_TITLE entry 9 -- 9 of 20 sprites additive + pteff03.t32 + pteff03a.t32 + pteff10.t32 + pteff20.t32 + pteff21.t32 + pteff22.t32 + pteff23.t32 + ptframe3.t32 + ptframe4.t32 + +GP_TITLE entry 12 -- 3 of 9 sprites additive + pgloading_circle1.t32 + pgloading_delta.t32 + pgloading_ring.t32 + +GP_TITLE entry 15 -- 3 of 9 sprites additive + pgloading_circle1.t32 + pgloading_delta.t32 + pgloading_ring.t32 + +GP_OPTIONS entry 19 -- 3 of 16 sprites additive + po_menu_eff01.t32 + po_menu_eff02.t32 + po_menu_eff03.t32 + +GP_OPTIONS entry 21 -- 3 of 16 sprites additive + po_menu_eff01.t32 + po_menu_eff02.t32 + po_menu_eff03.t32 diff --git a/docs/re/ui-renderer-additive-path.md b/docs/re/ui-renderer-additive-path.md new file mode 100644 index 00000000..8acc2a94 --- /dev/null +++ b/docs/re/ui-renderer-additive-path.md @@ -0,0 +1,146 @@ +# The reference renderer can now express the blend the disc declares — and 8 claims re-open + +**Status: ✅ done, with controls.** Instrument: ⟨our-renderer⟩, deliberately — +this page is about a change to *our* instrument, not a claim about the game. +2026-09-01. + +--- + +## Why + +`T8aD +0x04` bit `0x02` has been **decoded** since 2026-08-31 — 35 elements over +three screens against `RB_BLENDCONTROL0` read out of the guest command stream, +zero errors, plus an out-of-sample hit on `GP_OPTIONS` +([`structures/ui-blend-mode-decoded.md`](structures/ui-blend-mode-decoded.md)). + +`ui_layout::blit` could not draw it. The code said so in a comment that had gone +stale: + +> *"Straight alpha-over. `T8aD +0x04` bit 0x02 was tested as an ADDITIVE selector +> and REFUTED — it moved every metric against the title capture the wrong way … +> so the bit is carried but not acted on."* + +That refutation is `⟨render-vs-capture⟩`: it was **this renderer disagreeing with +itself**, recorded while the same renderer had a stale keyframe association, no +leaf geometry and no rotation. The corpus's own rule — *a claim resting on our +renderer is a claim about our renderer* — applies to it, and R1 is exactly the +machinery for noticing. + +The port raised the consequence: `verify-screen` compares two renderers, and if +one of them **structurally cannot** express a field the disc declares, the check +is incapable on every screen that uses it — **12 of 16** — and its tolerance +silently excuses all of them. A quiet check is worse than a failing one. + +## What changed + +One function. Both the rotated and unrotated paths now route their per-pixel +combine through: + +```rust +fn combine(additive: bool, sc: u32, sa: u32, dc: u32) -> u8 { + if additive { (dc + sc * sa / 255).min(255) as u8 } + else { ((sc * sa + dc * (255 - sa)) / 255) as u8 } +} +``` + +**Both equations are read off the game's own pixel shader**, dumped from the +running guest and disassembled in +[`ui-splash-draw-pass.md`](ui-splash-draw-pass.md). The shader premultiplies — +`oC0 = (rgb·A, A)` — so only the blend register differs: + +| `RB_BLENDCONTROL0` | is | gives | +|---|---|---| +| `0x07010701` | `ONE / ONE_MINUS_SRC_ALPHA` | `dst' = rgb·A + dst·(1 − A)` — source-over | +| `0x01010101` | `ONE / ONE` | `dst' = rgb·A + dst` — additive | + +So the additive case **saturates rather than wrapping**, and a transparent or +black source is the identity in it. Neither is a choice; both fall out of the +equation. + +The flag needs no plumbing: `t8ad::parse` already stores the `+0x04` word as +`T8adImage::flags`, so both call sites read `img.flags & 0x02 != 0`. + +## The controls + +Following the precedent of `rotation_control_known_angles` — pin against answers +that are **arithmetic**, not opinions. + +| test | pins | +|---|---| +| `additive_control_black_source_is_identity` | adding zero changes nothing | +| `additive_control_alpha_zero_is_identity` | a transparent source is the identity | +| `additive_control_known_sums` | `40+100=140`, `200+100=255`, `250+10=255` — it **saturates** | +| 🔴 `additive_control_bit_actually_selects` | **the discriminator** | + +⚠️ **The fourth one is the only one that can fail for the right reason.** The +first three pass just as well if `blit` ignores the flag and draws everything +additive. The discriminator takes one sprite, one pose, one canvas, flips only +the blend, and requires **two different answers**, each equal to its own +equation: alpha-over `(100·128 + 80·127)/255 = 90`, additive `80 + 100·128/255 = +130`. + +This is the failure class the port hit the same day — *"removing the latch does +not remove the threshold, so the row could never invert"* — and one I hit in the +pad decode, where a backward scan silently resolved every guard to "internal". +A control that removes the mechanism but not the observable tests nothing. + +`120 passed; 0 failed` on the full library suite, so no unrotated screen regresses. + +## What it changes, per screen + +[`data/additive-elements-per-screen.txt`](data/additive-elements-per-screen.txt) +— **67 sprites over 14 screens** that our renderer was drawing with the wrong +blend, including **10 of 18 on the title** (`pteff01`, `pteff03`, `pteff03a`, the +five `ptlogo_back2eff*`, `ptlogoall_eff`) and `ptbtn00f`, the `PRESS Ⓐ` plate's +own highlight. + +## 🔴 R1: what this re-opens + +`tools/stale-instrument render-vs-capture` — **8 claims**, and they are not +peripheral. Three bear on the current focus: + +* *"`rest()` for a plateau-less element should be the last keyframe"* — 🟡, and + its sibling, **both legs of the pair the R1 pass re-opened in both directions**; +* *"an element with no held pose should be drawn as NOTHING rather than at a + guessed endpoint"* — 🟡; +* *"the plate-free title capture (t ≈ 4.0 s) may be too early to be settled"* — 🟡, + which sits directly on play-test finding 3; +* *"the shifted time reading implies `rest` = the last keyframe"* — 🟡. + +Every one of them died to a renderer that drew ten of the title's eighteen +sprites with the wrong blend. **None is re-derived here** — this page only +records that the instrument that killed them no longer exists in that form. + +## Corroboration of another agent's claim, recorded per the adversarial duty + +**Target:** the port's *"H5 closes on your bit — `pgloading_loop5` is additive."* + +I could not find `loop5` as a sprite **anywhere in any pak**, which looked like a +contradiction. It is not: `pgloading_loop5.rat` is an **element**, and it resolves +to the sprite `pgloading_ring.t32`, which the bit marks additive. + +``` +GP_TITLE.pak entry 12 element 'pgloading_loop5.rat' sprite pgloading_ring.t32 additive=true +GP_TITLE.pak entry 15 ditto +GP_SAVE_LOAD.pak entry 46 ditto +GP_SAVE_LOAD.pak entry 69 ditto +``` + +**Their claim stands.** Recorded because the element/sprite name split is a real +trap — a census keyed by sprite name will not find an element by its own name, +and I nearly reported a false contradiction from exactly that. + +## Reach, and what this is NOT + +⟨our-renderer⟩. This makes our renderer **able** to express a decoded field; it +does not make it right, and it is **not** evidence about the game. The evidence +for the field is `ui-blend-mode-decoded.md`'s GPU measurement, which predates this +and does not depend on it. + +⚠️ `.prm` primitives carry no `T8aD` header, so the bit cannot speak for them and +this path never fires for one. + +⚠️ **Not re-run:** `verify-screen` across the 16 screens. The numbers there will +move, and the port's own generalisation applies to reading them — `raw-rmse` is +**area-weighted**, so ask "broad or deep?" of any row that moves before calling it +a regression.