diff --git a/crates/sylpheed-formats/tests/ui_focus_kind_disc.rs b/crates/sylpheed-formats/tests/ui_focus_kind_disc.rs new file mode 100644 index 0000000..aacdbaa --- /dev/null +++ b/crates/sylpheed-formats/tests/ui_focus_kind_disc.rs @@ -0,0 +1,171 @@ +//! Does anything in the declaration entry mark a **focused** button state, or is +//! the pairing really just a naming convention? +//! +//! `mark_focused_states` pairs `pgmenu_btn00f.t32` with `pgmenu_btn00.t32` by +//! name, and the backlog has carried the obvious suspicion ever since: `kind` is +//! a flags word — `0x10` means untextured primitive, `0x4` a repeated instance, +//! `0x3002` a button record — so a focus bit would be the cheapest possible +//! answer. This sweeps every screen build on the disc and asks the question +//! exactly, rather than plausibly. +//! +//! It is written to be able to FAIL to find anything: the interesting outcome is +//! as likely to be "no bit distinguishes them" as "here is the bit", and either +//! way the number is what goes in the docs. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use sylpheed_formats::{pak::PakArchive, ratc, ui_layout}; + +const DECL_TABLE_AT: usize = 0x20; +const DECL_ENTRY: usize = 60; + +fn disc_root() -> Option { + if let Ok(p) = std::env::var("SYLPHEED_DISC") { + let p = PathBuf::from(p); + if p.join("dat").is_dir() { + return Some(p); + } + } + let default = Path::new( + "/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)", + ); + if default.join("dat").is_dir() { + return Some(default.to_path_buf()); + } + None +} + +fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) { + let mut paks: Vec = std::fs::read_dir(root.join("dat")) + .expect("dat/") + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")) + .collect(); + paks.sort(); + for p in &paks { + let name = p.file_name().unwrap().to_string_lossy().to_string(); + let Ok(arc) = PakArchive::open(p) else { continue }; + for e in arc.entries() { + let Ok(bytes) = arc.read(e) else { continue }; + if ratc::is_ratc(&bytes) { + f(&name, &bytes); + } + } + } +} + +fn word_at(bundle: &[u8], index: usize, off: usize) -> Option { + let at = DECL_TABLE_AT + index * DECL_ENTRY + off; + bundle + .get(at..at + 4) + .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])) +} + +/// Compare every name-paired (focused, base) pair on the disc, word by word. +#[test] +fn focused_and_base_declaration_entries_are_compared_word_by_word() { + let Some(root) = disc_root() else { + eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)"); + return; + }; + + let mut pairs = 0usize; + // For each unread word of the 60-byte entry, how often does it differ? + let mut differs: HashMap = HashMap::new(); + // kind bits that are set on the focused entry and clear on its base. + let mut set_on_focused: HashMap = HashMap::new(); + let mut kind_equal = 0usize; + let mut examples: Vec = Vec::new(); + + for_each_build(&root, |pak, bytes| { + let Some(build) = ui_layout::parse_build(bytes) else { + return; + }; + if build.from_fallback { + return; + } + let index_of: HashMap = build + .elements + .iter() + .enumerate() + .map(|(i, e)| (e.name.to_ascii_lowercase(), i)) + .collect(); + for (i, el) in build.elements.iter().enumerate() { + if !el.focused { + continue; + } + let l = el.name.to_ascii_lowercase(); + let Some((stem, ext)) = l.rsplit_once('.') else { + continue; + }; + let base_name = format!("{}.{}", &stem[..stem.len() - 1], ext); + let Some(&j) = index_of.get(&base_name) else { + continue; + }; + pairs += 1; + let base = &build.elements[j]; + if el.kind == base.kind { + kind_equal += 1; + } else { + let only_focused = el.kind & !base.kind; + for bit in 0..32 { + if only_focused & (1 << bit) != 0 { + *set_on_focused.entry(1 << bit).or_default() += 1; + } + } + } + for off in (0..DECL_ENTRY).step_by(4) { + if off < 28 { + continue; // the name + } + let (a, b) = (word_at(bytes, i, off), word_at(bytes, j, off)); + if a.is_some() && a != b { + *differs.entry(off).or_default() += 1; + } + } + if examples.len() < 6 { + examples.push(format!( + "{pak}: {} kind {:#x} vs base {} kind {:#x}", + el.name, el.kind, base.name, base.kind + )); + } + } + }); + + eprintln!("focused/base pairs on the disc: {pairs}"); + eprintln!("pairs whose `kind` is IDENTICAL: {kind_equal}"); + let mut bits: Vec<_> = set_on_focused.iter().collect(); + bits.sort(); + eprintln!("kind bits set on focused but not base: {bits:?}"); + let mut d: Vec<_> = differs.iter().collect(); + d.sort(); + eprintln!("declaration words that ever differ (offset -> pairs): {d:?}"); + for e in &examples { + eprintln!(" {e}"); + } + + assert!(pairs > 0, "no focused/base pairs found — the sweep is broken"); + + // MEASURED 2026-08-24, and asserted so the answer cannot rot back into a + // suspicion: all 54 pairs on the disc carry the SAME `kind`, no bit is ever + // set on the focused entry and clear on its base, and the only words of the + // 60-byte declaration entry that ever differ are +48 and +52 — the PIVOT, + // i.e. where the sprite sits, not what it is. So the entry does not mark a + // focused state at all, and the naming pairing is not a shortcut around a + // field that exists: there is no field. + assert_eq!(pairs, 54, "the disc has 54 name-paired focused elements"); + assert_eq!(kind_equal, pairs, "some pair's `kind` differs from its base"); + assert!( + set_on_focused.is_empty(), + "a kind bit distinguishes focused from base: {set_on_focused:?}" + ); + let mut offs: Vec = differs.keys().copied().collect(); + offs.sort(); + assert_eq!( + offs, + vec![48, 52], + "a declaration word other than the pivot differs between focused and base" + ); +} diff --git a/docs/re/structures/ui-rat-layout.md b/docs/re/structures/ui-rat-layout.md index 8be7e24..e20159f 100644 --- a/docs/re/structures/ui-rat-layout.md +++ b/docs/re/structures/ui-rat-layout.md @@ -422,3 +422,33 @@ decoded 265×198, declared pivot 19,18), so the rule is not merely a language-inheritance artefact. **Do not derive a texture size from a pivot**; the capture backs the decoded size, not the pivot — `ptcopyright` is 694 px wide in the framebuffer, and `pivot·2` says 618. + +## 🔴 The declaration entry does NOT mark a focused state (2026-08-24) + +The backlog carried this as the cheapest open question about the entry: `kind` is +a flags word — `0x10` is an untextured primitive, `0x4` a repeated instance, +`0x3002` a button record — so a *focus* bit would be the obvious answer, and the +name-pairing in `mark_focused_states` (`pgmenu_btn00f.t32` next to +`pgmenu_btn00.t32`) would be a convention standing in for a real field. + +**It is not standing in for anything.** Swept over every screen build on the disc +(`tests/ui_focus_kind_disc.rs`, asserted so it cannot rot back into a suspicion): + +| | | +|---|---| +| name-paired focused/base pairs on the disc | **54** | +| pairs whose `kind` is **identical** | **54** — all of them, and all `kind = 0x0` | +| `kind` bits set on the focused entry and clear on its base | **none, on any pair** | +| words of the 60-byte entry that ever differ | **`+48` and `+52` only** — the pivot | + +So the two entries differ in *where the sprite sits* and in nothing else. There +is no focus field in the declaration table, and the naming pairing is the only +signal the file gives. + +Worth noting in passing: these buttons carry `kind = 0x0`, so the documented +`0x3002` "button record" belongs to the `.rat` records, not to the `.t32` sprites +that a menu draws for its buttons. + +**Still open:** whether the focused state is marked anywhere *else* — the `.rat` +record, the RATC child stream, or (as with the paint order) only in the game's +code. This closes the declaration table, not the question.