From 958eca6b6b4d22468bcb700abacbdeddf36caf32 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Tue, 1 Sep 2026 18:18:47 +0000 Subject: [PATCH] formats: expose the T8aD blend bit on the public API, with a control `ui-blend-mode-decoded.md` established the field but nothing on `Element` reached it, so the exporter could only key a blend map by SCREEN NAME -- which asserted-by-omission that the Japanese menus blend differently from the English ones. Adds `sprite_header_word_04`, `header_word_04_by_name`, `sprite_blend_additive` and `blend_additive_by_name`, plus `examples/blend_api_check.rs`: the accessor the exporter will actually call, checked against the same 35 oracle rows read out of the guest command stream. 22/22 agree, 0 mismatched. The control requires entry 6 to report BOTH values (additive=9, alpha-over=7), so an accessor stuck at one answer fails rather than scoring 100%. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t --- .../examples/blend_api_check.rs | 64 +++++++++++++++++++ crates/sylpheed-formats/src/ui_layout.rs | 62 ++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 crates/sylpheed-formats/examples/blend_api_check.rs diff --git a/crates/sylpheed-formats/examples/blend_api_check.rs b/crates/sylpheed-formats/examples/blend_api_check.rs new file mode 100644 index 00000000..55fbcf80 --- /dev/null +++ b/crates/sylpheed-formats/examples/blend_api_check.rs @@ -0,0 +1,64 @@ +//! Control for the new public accessor `ui_layout::sprite_blend_additive`. +//! +//! `blend_vs_t8ad_bit` established the field by reading the `T8aD` header inline. +//! The exporter cannot do that — `Element` exposed nothing at `+0x04`, which is +//! why a blend map keyed by SCREEN NAME had to be authored, and why the Japanese +//! menus were being asserted-by-omission to blend differently from the English +//! ones. This checks the accessor the exporter will actually call, against the +//! same 35 oracle rows, so a later refactor cannot silently change the field. +//! +//! cargo run -p sylpheed-formats --example blend_api_check +use std::path::PathBuf; +use sylpheed_formats::{pak::PakArchive, ui_layout}; + +/// (build entry, sprite, measured additive?) — from `data/blend-bit-vs-oracle.txt`, +/// every label an `RB_BLENDCONTROL0` value read out of the guest command stream. +const MEASURED: &[(usize, &str, bool)] = &[ + (4, "ptbase2.t32", false), (4, "ptlogo1.t32", false), (4, "ptlogo2.t32", false), + (4, "ptlogo_tm.t32", false), (4, "ptcopyright.t32", false), + (4, "ptlogo_back2.t32", false), (4, "ptlogo_back2eff.t32", false), + (2, "ptbtn00.t32", false), (2, "ptbtn00f.t32", true), + (5, "ptbase.t32", false), (5, "ptmsg.t32", false), (5, "ptbtn01f.t32", false), + (5, "ptbtneff01.t32", false), (5, "pteff10.t32", true), (5, "pteff12.t32", true), + (6, "pteff21.t32", true), (6, "pteff22.t32", true), (6, "pteff23.t32", true), + (6, "ptframe3.t32", true), (6, "ptframe4.t32", true), + (6, "pteff03.t32", true), (6, "pteff03a.t32", true), +]; + +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 ok, mut bad, mut missing) = (0, 0, 0); + println!("{:<7} {:<22} {:<10} {:<10} {}", "entry", "sprite", "expected", "accessor", ""); + for e in [2usize, 4, 5, 6] { + let by = ar.read(&ar.entries()[e]).expect("entry"); + let b = ui_layout::parse_build(&by).expect("build"); + for &(oe, name, additive) in MEASURED { + if oe != e { continue } + // Prefer the Element accessor; fall back to the by-name one for + // focused variants, which are reached through `opt ` and carry no + // top-level element of their own. + let got = b.elements.iter().find(|x| x.sprite.as_deref() == Some(name)) + .and_then(|el| ui_layout::sprite_blend_additive(&b, &by, el)) + .or_else(|| ui_layout::blend_additive_by_name(&b, &by, name)); + if got.is_none() { println!("{e:<7} {name:<22} {additive:<10} {:<10} MISSING", "-"); missing += 1; continue } + match got { + Some(g) if g == additive => { ok += 1; + println!("{e:<7} {name:<22} {additive:<10} {g:<10} OK"); } + other => { bad += 1; + println!("{e:<7} {name:<22} {additive:<10} {other:?} MISMATCH"); } + } + } + } + println!("\n{ok} agree, {bad} mismatched, {missing} not found (of {})", MEASURED.len()); + // The control that removes the test's own subject: the accessor must also + // report a MIX. An accessor stuck at one value would pass every `false` row. + let by = ar.read(&ar.entries()[6]).expect("entry"); + let b = ui_layout::parse_build(&by).expect("build"); + let add = b.elements.iter().filter(|e| ui_layout::sprite_blend_additive(&b, &by, e) == Some(true)).count(); + let over = b.elements.iter().filter(|e| ui_layout::sprite_blend_additive(&b, &by, e) == Some(false)).count(); + println!("control -- entry 6 must report BOTH values: additive={add} alpha-over={over}"); + assert!(add > 0 && over > 0, "accessor is not discriminating"); + assert_eq!(bad, 0, "the public accessor disagrees with the committed oracle"); + println!("PASS"); +} diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index 9b8510c3..cbc61bbc 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -910,6 +910,68 @@ pub fn sprite_layer_key(build: &UiBuild, bundle: &[u8], el: &Element) -> Option< Some(be32(bundle, off + 8)) } +/// The raw `T8aD` header word at `+0x04` for the sprite this element draws. +/// +/// Exposed because it carries the **blend mode**, and until now nothing on +/// [`Element`] reached it: the struct surfaces `kind` (`+40` of the declaration +/// entry), `parent`, the pivot, the keyframes and `focus_link`, all of which come +/// from the RATC record rather than from the sprite's own header. +/// +/// ⚠️ `kind` is **not** this field and does not stand in for it. Tested by the +/// port over four screens against a measured additive map: `kind & 0x2` is +/// *anti*-correlated — 0 of 14 mapped elements set it, 9 unmapped ones do. They +/// are different words in different structures. +pub fn sprite_header_word_04(build: &UiBuild, bundle: &[u8], el: &Element) -> Option { + let sprite = el.sprite.as_ref()?; + header_word_04_by_name(build, bundle, sprite) +} + +/// The same word, addressed by **sprite name** rather than by [`Element`]. +/// +/// Needed because not every sprite the game blends belongs to a top-level +/// element. A button's **focused variant** is reached through `focus_link` +/// (`opt `), so `ptbtn00f.t32` is in `build.sprites` while no element carries it +/// as `sprite` — and `ptbtn00f` is precisely the sharp case in the oracle: the +/// `PRESS Ⓐ` plate and its own highlight sit on one screen in one draw order and +/// differ in exactly this bit, one drawn alpha-over and the other additive. +/// An accessor that could only reach declared elements would miss it. +pub fn header_word_04_by_name(build: &UiBuild, bundle: &[u8], sprite: &str) -> Option { + let &(off, size) = build.sprites.get(sprite)?; + if size < 0x08 { + return None; + } + Some(be32(bundle, off + 4)) +} + +/// [`sprite_blend_additive`] addressed by sprite name — see +/// [`header_word_04_by_name`] for why both spellings exist. +pub fn blend_additive_by_name(build: &UiBuild, bundle: &[u8], sprite: &str) -> Option { + header_word_04_by_name(build, bundle, sprite).map(|w| w & 0x02 != 0) +} + +/// Whether the game draws this element **additive** — `T8aD +0x04` bit `0x02`. +/// +/// `Some(true)` ⇒ `RB_BLENDCONTROL0 = 0x01010101`, src `ONE` / dst `ONE`. +/// `Some(false)` ⇒ `0x07010701`, src `ONE` / dst `1 − SRC_ALPHA`, which with this +/// game's premultiplying pixel shader is ordinary source-over. +/// `None` ⇒ the element resolves to no `T8aD` the bundle carries (a `.prm` +/// primitive has no header, so it has no blend bit either). +/// +/// **Decoded**, `docs/re/structures/ui-blend-mode-decoded.md`: 35 elements over +/// three screens against `RB_BLENDCONTROL0` read out of the guest command stream, +/// **zero errors both ways**, with every bit of the first twelve header words +/// tested as a rival and exactly one separating them. An out-of-sample prediction +/// on `GP_OPTIONS` named three additive quads of sixteen before the capture and +/// found exactly three. +/// +/// This exists so a consumer can **derive** the blend per element instead of +/// transcribing a map keyed by screen name. A name-keyed map cannot answer for a +/// screen nobody has driven to — the Japanese menus being the case that raised +/// it — while the bit is on the disc for every screen at once. +pub fn sprite_blend_additive(build: &UiBuild, bundle: &[u8], el: &Element) -> Option { + sprite_header_word_04(build, bundle, el).map(|w| w & 0x02 != 0) +} + /// Layer keys for elements the bundle gives no key for, **measured** from the /// running game rather than read from a file. ///