diff --git a/crates/sylpheed-formats/examples/decl_entry_diff.rs b/crates/sylpheed-formats/examples/decl_entry_diff.rs new file mode 100644 index 00000000..93667f30 --- /dev/null +++ b/crates/sylpheed-formats/examples/decl_entry_diff.rs @@ -0,0 +1,48 @@ +//! Does a textured element's declaration carry anything that distinguishes the +//! two FRAME elements from every other element on the main menu? +//! +//! `sylpheed-port` asks for the blend/alpha mode of `ptframe1`/`ptframe2`. Prior +//! work is on `.prm` PRIMITIVES (`ui-prm-blend-mode.md`, undecodable with reach — +//! no field, the declaration words are constant) and on a refuted `T8aD +0x04` +//! bit. Neither covers a `.t32` element's own declaration entry, which is 60 +//! bytes and mostly unread. +//! +//! This dumps every declaration entry on the menu and reports, per 4-byte word, +//! whether the two frames share a value that no other element has. A word that +//! separates exactly those two is a candidate; one that does not, is not. +//! +//! cargo run -p sylpheed-formats --example decl_entry_diff +use sylpheed_formats::{pak::PakArchive, ui_layout}; +use std::path::PathBuf; + +const AT: usize = 0x20; +const N: usize = 60; + +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 by = ar.read(&ar.entries()[5]).expect("entry 5"); + let b = ui_layout::parse_build(&by).expect("build"); + let names: Vec = b.elements.iter().map(|e| e.name.clone()).collect(); + let frames: Vec = names.iter().enumerate() + .filter(|(_, n)| n.starts_with("ptframe")).map(|(i, _)| i).collect(); + println!("{} elements; frames at indices {:?}", names.len(), frames); + + let word = |i: usize, w: usize| -> u32 { + let o = AT + i * N + w * 4; + u32::from_be_bytes([by[o], by[o + 1], by[o + 2], by[o + 3]]) + }; + println!("\nper-word: does a value separate EXACTLY the two frames?"); + for w in 0..N / 4 { + let fv: Vec = frames.iter().map(|&i| word(i, w)).collect(); + let same_in_frames = fv.windows(2).all(|p| p[0] == p[1]); + let others: Vec = (0..names.len()).filter(|i| !frames.contains(i)) + .map(|i| word(i, w)).collect(); + let unique = same_in_frames && !others.contains(&fv[0]); + let distinct = { let mut v: Vec = (0..names.len()).map(|i| word(i, w)).collect(); + v.sort_unstable(); v.dedup(); v.len() }; + println!(" +0x{:02X} frames {:?} distinct values {distinct:2}{}", + w * 4, fv.iter().map(|v| format!("{v:08X}")).collect::>(), + if unique { " <- SEPARATES THE FRAMES" } else { "" }); + } +} diff --git a/crates/sylpheed-formats/examples/decl_flag_words.rs b/crates/sylpheed-formats/examples/decl_flag_words.rs new file mode 100644 index 00000000..4e684b95 --- /dev/null +++ b/crates/sylpheed-formats/examples/decl_flag_words.rs @@ -0,0 +1,32 @@ +//! Which elements carry which values at the low-cardinality declaration words? +//! +//! `decl_entry_diff` found nothing separating `ptframe1`/`ptframe2` except their +//! NAME — +0x00 and +0x08 are the name string ("ptfr", ".t32"), so those two hits +//! are a false positive of that test, not a field. +//! +//! The remaining candidates for a per-element mode flag are the words with few +//! distinct values: +0x28 (3) and +0x2C (6). This prints who has what. +//! +//! cargo run -p sylpheed-formats --example decl_flag_words +use sylpheed_formats::{pak::PakArchive, ui_layout}; +use std::path::PathBuf; + +const AT: usize = 0x20; +const N: usize = 60; + +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 by = ar.read(&ar.entries()[5]).expect("entry 5"); + let b = ui_layout::parse_build(&by).expect("build"); + let w = |i: usize, off: usize| -> u32 { + let o = AT + i * N + off; + u32::from_be_bytes([by[o], by[o + 1], by[o + 2], by[o + 3]]) + }; + println!("{:<22} {:>10} {:>10} {:>10} {:>10}", "element", "+0x28", "+0x2C", "+0x34", "kind"); + for (i, e) in b.elements.iter().enumerate() { + let mark = if e.name.starts_with("ptframe") { " <- FRAME" } else { "" }; + println!("{:<22} {:>10} {:>10} {:>10} {:>#10x}{mark}", + e.name, w(i, 0x28), w(i, 0x2C) as i32, w(i, 0x34), e.kind); + } +} diff --git a/crates/sylpheed-formats/examples/t8ad_header_compare.rs b/crates/sylpheed-formats/examples/t8ad_header_compare.rs new file mode 100644 index 00000000..3caa8c99 --- /dev/null +++ b/crates/sylpheed-formats/examples/t8ad_header_compare.rs @@ -0,0 +1,48 @@ +//! Does a `.t32` sprite's own T8aD header carry a per-sprite blend/alpha mode? +//! +//! The declaration entry does not: `ptframe1`/`ptframe2` are kind 0, identical to +//! every other plain sprite on the menu. The remaining place a mode could live is +//! the sprite's own T8aD child. ⚠️ `REFUTED.md` already kills one reading of it — +//! "`T8aD +0x04` bit `0x02` selects an additive blend" — so this is not that +//! claim; it asks whether ANY header word separates the two frames from the +//! sprites the port measures as ordinary. +//! +//! cargo run -p sylpheed-formats --example t8ad_header_compare +use sylpheed_formats::{pak::PakArchive, ui_layout}; +use std::path::PathBuf; + +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 by = ar.read(&ar.entries()[5]).expect("entry 5"); + let b = ui_layout::parse_build(&by).expect("build"); + let mut names: Vec<&String> = b.sprites.keys().collect(); + names.sort(); + println!("{:<20} {:>8} first 12 header words", "sprite", "size"); + let mut rows: Vec<(String, Vec)> = Vec::new(); + for n in names { + let (off, size) = b.sprites[n]; + let s = &by[off..(off + size).min(by.len())]; + if s.len() < 48 { continue } + let ws: Vec = (0..12) + .map(|k| u32::from_be_bytes([s[k*4], s[k*4+1], s[k*4+2], s[k*4+3]])) + .collect(); + let mark = if n.starts_with("ptframe") { " <- FRAME" } else { "" }; + println!("{n:<20} {size:>8} {}{mark}", + ws.iter().map(|v| format!("{v:08X}")).collect::>().join(" ")); + rows.push((n.clone(), ws)); + } + // which words take a value the two frames share and nobody else does? + let fr: Vec<&(String, Vec)> = rows.iter().filter(|(n, _)| n.starts_with("ptframe")).collect(); + if fr.len() == 2 { + println!("\nwords where BOTH frames agree and no other sprite has that value:"); + let mut any = false; + for w in 0..12 { + let a = fr[0].1[w]; let bb = fr[1].1[w]; + if a != bb { continue } + if rows.iter().any(|(n, v)| !n.starts_with("ptframe") && v[w] == a) { continue } + println!(" word {w} (+0x{:02X}) = {a:08X}", w * 4); any = true; + } + if !any { println!(" NONE"); } + } +} diff --git a/crates/sylpheed-formats/examples/t8ad_word8_census.rs b/crates/sylpheed-formats/examples/t8ad_word8_census.rs new file mode 100644 index 00000000..9bfdb466 --- /dev/null +++ b/crates/sylpheed-formats/examples/t8ad_word8_census.rs @@ -0,0 +1,50 @@ +//! Is T8aD `+0x08` a per-sprite MODE, or a texture FORMAT word? +//! +//! On the main menu the two frames share `+0x08 = 0x8050` and no other sprite has +//! it — a candidate for the blend/alpha mode `sylpheed-port` asked for. Before +//! offering it, refute it: if 0x8050 is common disc-wide on ordinary sprites, it +//! is not frame-specific and not a mode. +//! +//! cargo run -p sylpheed-formats --example t8ad_word8_census +use sylpheed_formats::{pak::PakArchive, ui_layout}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +fn main() { + let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC")); + let mut paks: Vec<_> = std::fs::read_dir(root.join("dat")) + .expect("dat") + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false)) + .collect(); + paks.sort(); + let mut hist: BTreeMap = BTreeMap::new(); + let mut frames_like: BTreeMap = BTreeMap::new(); + let mut examples: BTreeMap> = BTreeMap::new(); + for p in &paks { + let Ok(ar) = PakArchive::open(p) else { continue }; + for e in ar.entries() { + let Ok(by) = ar.read(e) else { continue }; + let Some(b) = ui_layout::parse_build(&by) else { continue }; + for (n, (off, size)) in &b.sprites { + let s = &by[*off..(*off + *size).min(by.len())]; + if s.len() < 48 { continue } + let w = u32::from_be_bytes([s[8], s[9], s[10], s[11]]); + *hist.entry(w).or_default() += 1; + if n.contains("frame") { *frames_like.entry(w).or_default() += 1 } + let ex = examples.entry(w).or_default(); + if ex.len() < 3 && !ex.contains(n) { ex.push(n.clone()) } + } + } + } + let total: usize = hist.values().sum(); + println!("{total} sprites disc-wide; distinct +0x08 values: {}\n", hist.len()); + println!("{:>10} {:>8} {:>10} examples", "value", "count", "of which 'frame'"); + for (v, c) in hist.iter().filter(|(_, c)| **c >= 20) { + println!("{:>#10x} {c:>8} {:>10} {}", v, frames_like.get(v).copied().unwrap_or(0), + examples[v].join(", ")); + } + println!("\n0x8050 specifically: {} sprites, {} of them named *frame*", + hist.get(&0x8050).copied().unwrap_or(0), + frames_like.get(&0x8050).copied().unwrap_or(0)); +} diff --git a/docs/re/structures/t32-blend-mode-not-on-disc.md b/docs/re/structures/t32-blend-mode-not-on-disc.md new file mode 100644 index 00000000..df254b6e --- /dev/null +++ b/docs/re/structures/t32-blend-mode-not-on-disc.md @@ -0,0 +1,59 @@ +# A textured element carries no blend/alpha mode — ❔ undecodable, with reach + +**Answers `sylpheed-port`'s ask**: what blend/alpha mode do `ptframe1` and +`ptframe2` use on the main menu? They measure those two elements as the only ones +whose residual is **higher on flat pixels than on edges** (25.41/16.54 against +19.85/9.82), signed one direction, `ptframe1` rendering at 88.4 against the +capture's 129.1 with 0.1 % of pixels render-brighter — a body-intensity difference, +not a geometric one. + +## The answer: it is not on the disc, and the port is authoring + +⚠️ Prior work covers **`.prm` primitives** +([`ui-prm-blend-mode.md`](ui-prm-blend-mode.md), ❔ undecodable with reach) and a +**refuted** `T8aD +0x04` bit. Neither covers a `.t32` element, which is what was +asked. This does. + +### Reach 1 — the declaration entry (60 bytes, all 15 words read) + +| words | what they are | +|---|---| +| `+0x00`–`+0x0B` | the **name** string (`ptfr`, `ame1`, `.t32`) | +| `+0x0C`–`+0x1F` | **constant 0** across every element on the screen | +| `+0x20`, `+0x24` | constant `FFFFFFFF` | +| `+0x28` | **kind** — 0 plain, `0x10` primitive, `0x3002` button | +| `+0x2C` | **focus/nav index** — `−1` for non-buttons, 1…5 for the five buttons | +| `+0x30`, `+0x34` | position / pivot | +| `+0x38` | constant 0 | + +🔴 **The two frames are `kind 0` — identical to `ptbase`, `pteff05`, `pteff10`, +`pteff12` and `ptmsg`.** Nothing in the declaration distinguishes them. + +⚠️ A first pass reported `+0x00` and `+0x08` as "separating the frames". **False +positive of my own test**: those words are the name, and the frames share a prefix +and a suffix no other element has. + +### Reach 2 — the sprite's own `T8aD` header, and a candidate I refuted + +`+0x08` is the one word where both frames agree on a value no other menu sprite +has: **`0x8050`**. Tested disc-wide before offering it: + +* **38 sprites carry `0x8050`, only 8 named `*frame*`** — not frame-specific; +* the **high byte tracks the archive** — `0x80xx` in `GP_TITLE`, `0xb1xx` in + `GP_OPTIONS`, `0xd8xx` in `GP_GAMEOVER`, `0xf0xx` in `GP_DIALOG`. + +❌ So `+0x08` is an atlas/format word, **not a mode**. The candidate is dead. + +## What this means for the port + +**Any blend the port picks for these elements is authored, not transcribed**, and +must be labelled that way. ✅ Their refusal to brighten the frames until they match +is the right call for the reason they gave: a blend invented on their side is +indistinguishable from a decoded one in a month — and this page is the evidence +that there is nothing to decode it *from*. + +⚠️ **Reach of the negative**: one screen's declaration table read exhaustively, the +`T8aD` header's low words compared across every sprite on that screen and one +candidate word censused disc-wide. **Not looked at**: the executable's draw path, +which is where a mode selected in *code* rather than *data* would live. That is a +route, and it is the one left.