From 56cc7acfc35d95a289f25b0c2677ebce35811547 Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Sat, 29 Aug 2026 07:26:56 +0000 Subject: [PATCH] re(ui): a RATC child's name is stated, not inferred -- and it was hiding every menu background `ratc::parse` named each child by scanning backwards for the last printable run of bytes before its magic. The format states the name explicitly instead, in an `opt ` block: `"opt " | BE32 len | name | NUL | 3 bytes | magic` -- the same block `ui_layout::opt_link` already read for a button's focus link. The scan agrees with it 17 918 times out of 17 942 and is wrong 24 times, every one the same failure: the 3 trailing payload bytes are themselves printable and beat the real name. For `pteff05.t32` those bytes are `38 41 58` = `8AX`, so the full-resolution background of all five menu screens registered under a name no element declares, resolved to no sprite, and `compose` dropped it through an early `continue` that -- unlike the two arms above it -- records nothing. The screen lost its background and `screen render` still reported "all resolved". `8AX` was never a name. Docs that treated it as one are corrected here. Disc-wide, and the control is the 17 918 the scan already got right: the `opt ` reading reproduces every one of them. Effect on the five screens is the signature of the same art at twice the resolution -- mean brightness unmoved, high-frequency detail x1.15..x1.30 -- which is what the separately-measured `ui-8ax-fullres-background` result said the game draws. Also closes a long-standing dangling reference: `pmbase.t32`, recorded as "on the disc nowhere", is the `GP_STAGE_CLEAR` child the scan called `8AX`. RATC sibling references now resolve 10 148 of 10 148. Verified: 114/114 sylpheed-formats unit tests (including two new ones pinning the `8AX` case byte for byte and the no-block fallback), and every disc-gated integration suite in sylpheed-formats/sylpheed-cli. --- .../examples/decl_word_probe.rs | 67 +++++++++ .../examples/name_resolution.rs | 51 +++++++ .../examples/ratc_child_names.rs | 78 ++++++++++ crates/sylpheed-formats/src/ratc.rs | 82 ++++++++++- crates/sylpheed-formats/src/ui_layout.rs | 7 + docs/port/HANDOFF.md | 25 +++- docs/re/INDEX.md | 2 +- docs/re/METHOD.md | 19 +++ docs/re/REFUTED.md | 12 ++ docs/re/data/ratc-child-name-audit.txt | 52 +++++++ docs/re/data/ratc-name-fix-render-effect.txt | 26 ++++ docs/re/structures/ratc-child-names.md | 137 ++++++++++++++++++ .../structures/ui-8ax-fullres-background.md | 26 +++- docs/re/structures/ui-rat-layout.md | 8 +- 14 files changed, 583 insertions(+), 9 deletions(-) create mode 100644 crates/sylpheed-formats/examples/decl_word_probe.rs create mode 100644 crates/sylpheed-formats/examples/name_resolution.rs create mode 100644 crates/sylpheed-formats/examples/ratc_child_names.rs create mode 100644 docs/re/data/ratc-child-name-audit.txt create mode 100644 docs/re/data/ratc-name-fix-render-effect.txt create mode 100644 docs/re/structures/ratc-child-names.md diff --git a/crates/sylpheed-formats/examples/decl_word_probe.rs b/crates/sylpheed-formats/examples/decl_word_probe.rs new file mode 100644 index 0000000..6176695 --- /dev/null +++ b/crates/sylpheed-formats/examples/decl_word_probe.rs @@ -0,0 +1,67 @@ +//! Does any unread declaration word POINT at the element's T8aD child? +//! +//! `pteff05.t32`/`pteff04.t32` resolve to no sprite because the `T8aD` they want +//! is registered under the name `8AX`. Elimination says `8AX` is the one they +//! mean -- one unresolved element, one unclaimed non-focus-state child, in 6 of +//! 6 title-side builds. Elimination is not a pointer, so: the 60-byte +//! declaration reads name[0..28], parent@32, kind@40, pivot@48/52. The words at +//! +28, +36, +44 and +56 are unread. If one of them indexes the RATC child +//! table, the RESOLVED elements are the control -- their child index is known, +//! so a candidate field must reproduce it for them before it may be believed for +//! the unresolved one. +//! +//! cargo run -p sylpheed-formats --example decl_word_probe -- [entry] +use sylpheed_formats::{pak, ratc, ui_layout}; + +const OFFS: [usize; 4] = [28, 36, 44, 56]; + +fn be32(b: &[u8], o: usize) -> u32 { + if o + 4 > b.len() { return 0; } + u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} + +fn main() { + let path = std::env::args().nth(1).expect("usage: decl_word_probe [entry]"); + let want: Option = std::env::args().nth(2).and_then(|s| s.parse().ok()); + let ar = pak::PakArchive::open(&path).expect("open pak"); + let entries: Vec<_> = ar.entries().to_vec(); + // Per candidate offset, across every build: control hits / control total. + let mut hit = [0usize; 4]; + let mut tot = 0usize; + for (i, e) in entries.iter().enumerate() { + if want.is_some_and(|w| w != i) { continue; } + let Ok(bytes) = ar.read(e) else { continue }; + let Some(build) = ui_layout::parse_build(&bytes) else { continue }; + let Some(kids) = ratc::parse(&bytes) else { continue }; + // Index space to test against: the T8aD children, in child order. + let t8: Vec<&ratc::RatcChild> = kids.iter().filter(|c| c.kind == "T8aD").collect(); + if build.elements.iter().all(|el| el.sprite.is_some() || el.kind & 0x10 != 0) { + continue; + } + println!("== entry {i} ({} elements, {} T8aD children)", build.elements.len(), t8.len()); + for (n, c) in t8.iter().enumerate() { println!(" child[{n:2}] {}", c.name); } + for el in &build.elements { + if el.kind & 0x10 != 0 { continue; } + let d = &bytes[0x20 + el.index * 60..0x20 + (el.index + 1) * 60]; + let words: Vec = OFFS.iter().map(|&o| be32(d, o)).collect(); + // The control: for a RESOLVED element, which T8aD child is it? + let truth = el.sprite.as_ref() + .and_then(|s| t8.iter().position(|c| &c.name == s)); + if let Some(t) = truth { + tot += 1; + for (k, w) in words.iter().enumerate() { + if *w as usize == t { hit[k] += 1; } + } + } + println!( + " [{:2}] {:26} sprite={:?} child={:?} +28={} +36={} +44={} +56={}", + el.index, el.name, el.sprite, truth, + words[0] as i32, words[1] as i32, words[2] as i32, words[3] as i32 + ); + } + } + println!("\nCONTROL: resolved elements whose child index a word reproduces, of {tot}:"); + for (k, o) in OFFS.iter().enumerate() { + println!(" +{o:<3} {:3}/{tot}", hit[k]); + } +} diff --git a/crates/sylpheed-formats/examples/name_resolution.rs b/crates/sylpheed-formats/examples/name_resolution.rs new file mode 100644 index 0000000..e87149a --- /dev/null +++ b/crates/sylpheed-formats/examples/name_resolution.rs @@ -0,0 +1,51 @@ +//! Why does an element's sprite fail to resolve? Dump the two name spaces. +//! +//! `parse_build` resolves an element to a sprite by looking its DECLARED name up +//! in (a) the `.rat` record table, then (b) the `T8aD` child table. `pteff05.t32` +//! is in neither -- the `T8aD` it wants is registered as `8AX` -- so it resolves +//! to None and `compose` drops it without recording it as missing. This prints +//! both spaces, so the link between the two can be CHECKED rather than assumed. +//! +//! cargo run -p sylpheed-formats --example name_resolution -- +use sylpheed_formats::{pak, ui_layout}; + +fn main() { + let path = std::env::args().nth(1).expect("usage: name_resolution [entry]"); + let want: Option = std::env::args().nth(2).and_then(|s| s.parse().ok()); + let ar = pak::PakArchive::open(&path).expect("open pak"); + let entries: Vec<_> = ar.entries().to_vec(); + for (i, e) in entries.iter().enumerate() { + if want.is_some_and(|w| w != i) { + continue; + } + let Ok(bytes) = ar.read(e) else { continue }; + let Some(build) = ui_layout::parse_build(&bytes) else { continue }; + let unresolved: Vec<&ui_layout::Element> = build + .elements + .iter() + .filter(|el| el.sprite.is_none() && el.kind & 0x10 == 0) + .collect(); + let claimed: std::collections::HashSet<&str> = + build.elements.iter().filter_map(|e| e.sprite.as_deref()).collect(); + let unclaimed: Vec<&String> = + build.sprites.keys().filter(|k| !claimed.contains(k.as_str())).collect(); + if want.is_none() && unresolved.is_empty() && unclaimed.is_empty() { + continue; + } + let un: Vec<&str> = unresolved.iter().map(|e| e.name.as_str()).collect(); + let uc: Vec = unclaimed + .iter() + .map(|k| format!("{k}({} B)", build.sprites[*k].1)) + .collect(); + println!( + "entry {i:3} {:2} elements UNRESOLVED {:?} UNCLAIMED {:?}", + build.elements.len(), un, uc + ); + if want.is_some() { + for el in &build.elements { + let mark = if el.sprite.is_none() && el.kind & 0x10 == 0 { " <-- UNRESOLVED" } else { "" }; + println!(" [{:2}] kind {:#06x} {:28} -> {:?}{mark}", el.index, el.kind, el.name, el.sprite); + } + } + } +} diff --git a/crates/sylpheed-formats/examples/ratc_child_names.rs b/crates/sylpheed-formats/examples/ratc_child_names.rs new file mode 100644 index 0000000..77668af --- /dev/null +++ b/crates/sylpheed-formats/examples/ratc_child_names.rs @@ -0,0 +1,78 @@ +//! Is a RATC child's name the printable run before its magic, or the `opt ` block? +//! +//! `ratc::parse` names each child by scanning backwards for the ASCII run that +//! precedes its magic. That is usually right, but it is a HEURISTIC, and the +//! real format states the name explicitly: immediately before each child sits +//! +//! "opt " | BE32 length | name | NUL | 3 bytes | +//! +//! -- the same `opt ` block `ui_layout::opt_link` already decodes for a button's +//! focus link. When those 3 trailing bytes happen to be printable the heuristic +//! reads THEM as the name: the title screens' full-resolution background comes +//! out as `8AX` (bytes 38 41 58) instead of `pteff05.t32`, its element then +//! resolves to no sprite, and `compose` drops the screen's background. +//! +//! This compares the two readings for every RATC child in the paks given. +//! +//! cargo run -p sylpheed-formats --example ratc_child_names -- ... +use sylpheed_formats::{pak, ratc}; + +/// The name stated by the `opt ` block that ends just before `at`. +fn opt_name(buf: &[u8], at: usize) -> Option { + // The block is short; search back a bounded window for the tag. + let lo = at.saturating_sub(128); + let win = &buf[lo..at]; + let pos = lo + win.windows(4).rposition(|w| w == b"opt ")?; + let len = u32::from_be_bytes(buf[pos + 4..pos + 8].try_into().ok()?) as usize; + if len == 0 || len > 64 || pos + 8 + len > at { + return None; + } + let s = String::from_utf8_lossy(&buf[pos + 8..pos + 8 + len]).to_string(); + // It must be THIS child's block: name, NUL, then a short run to the magic. + if at - (pos + 8 + len) > 8 { + return None; + } + (!s.is_empty() && s.chars().all(|c| c.is_ascii_graphic())).then_some(s) +} + +fn main() { + let mut children = 0usize; + let mut with_opt = 0usize; + let mut agree = 0usize; + let mut disagree: Vec<(String, usize, String, String)> = Vec::new(); + for path in std::env::args().skip(1) { + let Ok(ar) = pak::PakArchive::open(&path) else { continue }; + let short = path.rsplit('/').next().unwrap_or(&path).to_string(); + let entries: Vec<_> = ar.entries().to_vec(); + for (i, e) in entries.iter().enumerate() { + let Ok(bytes) = ar.read(e) else { continue }; + let Some(kids) = ratc::parse(&bytes) else { continue }; + for c in &kids { + children += 1; + let Some(o) = opt_name(&bytes, c.offset) else { continue }; + with_opt += 1; + if o == c.name { + agree += 1; + } else { + disagree.push((short.clone(), i, c.name.clone(), o)); + } + } + } + } + println!("RATC children scanned : {children}"); + println!(" with an `opt ` block: {with_opt}"); + println!(" scanned name AGREES : {agree}"); + println!(" scanned name DIFFERS : {}", disagree.len()); + let mut by_pair: std::collections::BTreeMap<(String, String), usize> = Default::default(); + for (_, _, scanned, opt) in &disagree { + *by_pair.entry((scanned.clone(), opt.clone())).or_default() += 1; + } + println!("\ndistinct disagreements (scanned -> opt), with counts:"); + for ((s, o), n) in &by_pair { + println!(" {s:24} -> {o:24} x{n}"); + } + println!("\nfirst 20 occurrences:"); + for (p, i, s, o) in disagree.iter().take(20) { + println!(" {p} entry {i:4} {s:20} -> {o}"); + } +} diff --git a/crates/sylpheed-formats/src/ratc.rs b/crates/sylpheed-formats/src/ratc.rs index 48ba039..67b430b 100644 --- a/crates/sylpheed-formats/src/ratc.rs +++ b/crates/sylpheed-formats/src/ratc.rs @@ -62,7 +62,7 @@ pub fn parse(bytes: &[u8]) -> Option> { for (idx, &(off, kind)) in offs.iter().enumerate() { let next = offs.get(idx + 1).map(|&(o, _)| o).unwrap_or(bytes.len()); children.push(RatcChild { - name: name_before(bytes, off), + name: opt_name(bytes, off).unwrap_or_else(|| name_before(bytes, off)), kind: kind.to_string(), offset: off, size: next.saturating_sub(off), @@ -71,9 +71,46 @@ pub fn parse(bytes: &[u8]) -> Option> { Some(children) } +/// The name a child's own `opt ` block states, if it has one. +/// +/// The real format is explicit. Immediately before each child sits +/// +/// ```text +/// "opt " | BE32 length | name | NUL | 3 bytes | +/// ``` +/// +/// -- the same `opt ` block `ui_layout`'s focus link already reads. Prefer it, +/// because [`name_before`] is a heuristic and those 3 trailing bytes are +/// sometimes printable, in which case the heuristic reads THEM as the name. +/// Measured disc-wide: of 18 002 RATC children, 17 942 carry an `opt ` block, +/// 17 918 of which agree with the scan and **24 do not** -- every one of the 24 +/// a 3-byte tail (`8AX` x22, `'OX` x2) beating a real name. On the title screens +/// that cost the whole background: `pteff05.t32` came out as `8AX`, its element +/// then resolved to no sprite, and `compose` silently dropped it. See +/// `docs/re/structures/ratc-child-names.md`. +fn opt_name(bytes: &[u8], off: usize) -> Option { + let lo = off.saturating_sub(128); + let win = &bytes[lo..off]; + let pos = lo + win.windows(4).rposition(|w| w == b"opt ")?; + let len = u32::from_be_bytes(bytes.get(pos + 4..pos + 8)?.try_into().ok()?) as usize; + if len == 0 || len > 64 || pos + 8 + len > off { + return None; + } + // It must be THIS child's block: the name, its NUL and a short run to the + // magic. Anything further away is a neighbour's block, so fall back. + if off - (pos + 8 + len) > 8 { + return None; + } + let s = String::from_utf8_lossy(&bytes[pos + 8..pos + 8 + len]).to_string(); + (!s.is_empty() && s.chars().all(|c| c.is_ascii_graphic())).then_some(s) +} + /// The nearest name string preceding `off`: the *last* printable run (len β‰₯ 3) /// in the 96 bytes before the child magic. A few record-header bytes usually sit /// between the name and the magic, so an exact-adjacency scan isn't enough. +/// +/// Fallback only -- [`opt_name`] is the stated name. 60 of the disc's 18 002 +/// children have no `opt ` block and still rely on this. fn name_before(bytes: &[u8], off: usize) -> String { let start = off.saturating_sub(96); let window = &bytes[start..off]; @@ -101,6 +138,49 @@ fn name_before(bytes: &[u8], off: usize) -> String { mod tests { use super::*; + /// The `opt ` block wins over a printable tail. + /// + /// This is the `pteff05.t32` / `8AX` case, byte for byte: the name is stated + /// with an explicit length, then a NUL, then three payload bytes that happen + /// to spell `8AX` in ASCII. The old backwards printable-run scan returned + /// `8AX` here, which is what dropped the background from every menu screen. + #[test] + fn opt_block_beats_a_printable_tail() { + let mut b = RATC_MAGIC.to_vec(); + b.extend_from_slice(&[0u8; 28]); + b.extend_from_slice(b"opt "); + b.extend_from_slice(&11u32.to_be_bytes()); // len("pteff05.t32") + b.extend_from_slice(b"pteff05.t32\0"); + b.extend_from_slice(b"8AX"); // payload, printable by accident + let off = b.len(); + b.extend_from_slice(b"T8aD"); + b.extend_from_slice(&[0u8; 16]); + + let kids = parse(&b).expect("parse"); + assert_eq!(kids.len(), 1); + assert_eq!(kids[0].name, "pteff05.t32"); + assert_eq!(kids[0].offset, off); + // And the heuristic on its own really would have said `8AX` -- so this + // test fails for the right reason if the preference is ever reversed. + assert_eq!(name_before(&b, off), "8AX"); + } + + /// No `opt ` block: 60 of the disc's 18 002 children are like this, and they + /// must keep working off the scan. + #[test] + fn falls_back_to_the_scan_without_an_opt_block() { + let mut b = RATC_MAGIC.to_vec(); + b.extend_from_slice(&[0u8; 28]); + b.extend_from_slice(b"plain.t32"); + b.extend_from_slice(&[0x0e, 0x10, 0xa4]); + let off = b.len(); + b.extend_from_slice(b"T8aD"); + b.extend_from_slice(&[0u8; 16]); + + let kids = parse(&b).expect("parse"); + assert_eq!(kids[0].name, "plain.t32"); + } + #[test] fn lists_named_children() { let mut b = RATC_MAGIC.to_vec(); diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index 2b6a9c7..194fca3 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -967,6 +967,13 @@ pub fn compose( continue; } let Some(sprite) = el.sprite.as_ref() else { + // Report it, do not just skip it. This arm used to `continue` + // silently while the two arms below recorded into `missing`, so when + // a name-decoding defect left every menu screen's background + // unresolved, `screen render` still said "sprites that did not + // resolve: none". A diagnostic with a hole in it is worse than none. + // See docs/re/structures/ratc-child-names.md. + missing.push(format!("{} (element declares no resolvable sprite)", el.name)); continue; }; let Some(&(off, size)) = build.sprites.get(sprite) else { diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 89a4758..35f8468 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -261,7 +261,26 @@ authored version can be deleted. setting rather than bake in. [`structures/ui-render-tone-curve.md`](../re/structures/ui-render-tone-curve.md) -* 🟑 **`screen render` silently drops one full-screen element per screen β€” and +* βœ… **FIXED 2026-08-29 β€” the dropped background was a name-decoding defect, and + `screen render` now draws it.** The reference composites for all five screens + changed; regenerate anything you diffed against before that date. Cause: a + RATC child's name is stated by an **`opt ` block** immediately before it + (`"opt " | BE32 len | name | NUL | 3 bytes | magic`), and our parser instead + guessed it from the last printable run of bytes. For this one child the 3 + trailing bytes are `38 41 58` = `"8AX"`, which beat the real name + `pteff05.t32`. **`8AX` was never a name** β€” earlier text on this page treating + it as one was wrong. Disc-wide: 18 002 children, 17 918 already agreed with the + `opt ` reading and **24 did not**, every one the same 3-byte-tail failure. + Effect on your five screens: mean brightness unmoved, high-frequency detail + **Γ—1.15…×1.30** β€” the same art at twice the resolution, which is exactly what + [8AX](../re/structures/ui-8ax-fullres-background.md) said the game draws. + ⚠️ Both backgrounds are now drawn (`ptbase` upscaled, then the full-res one over + it): correct, but wasted fill. Draw only the full-res one, taking its *timing* + from `ptbase`'s element, which carries the keyframes. + [`structures/ratc-child-names.md`](../re/structures/ratc-child-names.md) +
the original entry, kept because its reasoning still stands + + 🟑 **`screen render` silently drops one full-screen element per screen β€” and you must NOT simply draw it.** Auditing what the composer omits on your five screens: everything is accounted for (`kind & 0x4` ghost instances, `.prm` primitives, `loop*` animations) except **`pteff04.t32`** on the title and @@ -287,9 +306,11 @@ authored version can be deleted. full-screen layer over an identical one costs fill and hides later changes; and note `ptbase`'s element is the one carrying the keyframes, so you need its timing with `8AX`'s pixels. - ⚠️ It does not show whether `ptbase` is *also* drawn underneath β€” `8AX` is + ⚠️ It does not show whether `ptbase` is *also* drawn underneath β€” the full-res + background is ~86 % opaque and would hide it either way. [`structures/ui-8ax-fullres-background.md`](../re/structures/ui-8ax-fullres-background.md) +
* βœ… **Paint order: your exposure is two element pairs, on one screen.** We use an order *measured from the running game* where one exists and a derived order diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index a1b4bd7..641575f 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -21,7 +21,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes | IDXD nameless field keys | βœ…/❌ | [idxd-unnamed-keys](structures/idxd-unnamed-keys.md) + [`tools/re-capture/idxd_unnamed_keys.py`](../../tools/re-capture/idxd_unnamed_keys.py) | Census of every field entry whose `name_off` is `0xFFFFFFFF`, disc-wide: **7 750 objects, 2 757 039 field entries, 0 parse failures**, `tag_hash` reproducing **1 271 462/1 271 462** named keys. **7 094 distinct keys are never named β€” and 7 052 of them are not hashes at all**, but author-assigned element ids (equal to the field's own index in 1 404 924 of 1 485 577 cases; `tag_hash("BGM_001")` is `0xC662435B` while the key valued `BGM_001.slb` is `0x000003E9`). ⚠️ **The "504 hash-keyed nameless fields" figure is 504 ENTRIES, not 504 names** β€” 42 distinct keys Γ— 6 language copies Γ— 2 records. All 42 are **ISL script-symbol hashes** in `\script\ID.tbl` (GP_READY_ROOM.pak), the link map built by `PrepareScript`'s "isl script prescanning"; 41 of 42 appear as little-endian call targets inside the `.isb` bytecode, forming a coherent launcher/helper call graph. The hash's own algebra pins the **trailing digits of 30 of the 42 names** (deltas of exactly `+0x01000001` across `stage01..09`, `stage10..16`, `challenge01..06`; `+0x01010000` across `tutorial0101..0601`). ❌ **No name was cracked, and the negative is quantified**: seven attacks up to a 3.5Γ—10⁸ composition space found nothing above the noise floor; exhaustive preimage search recovers `"Stage01"` from its own hash but returns nothing for the real targets at ≀6 characters, and at 7 characters one target already has **1 176** preimages β€” a 24-bit modulus cannot name an 8+ character identifier uniquely | | XPR2 texture + cubemap | 🟑/βœ… | `sylpheed-formats/src/texture.rs` + [colour check](xpr2-colour-check.md) | de-tile + A8R8G8B8 and DXT1. **Channel order βœ… confirmed against the running game**: the Delta Saber's decoded atlas is orange-dominant (median saturated hue 23.3Β°, *zero* cool pixels) and the game renders the same hull at 9.3Β° β€” a red↔blue swap would sit at β‰ˆ200Β°. Exact fidelity (gamma/sRGB curve, premultiplied alpha, per-channel scale) is 🟑 untested, since a hue comparison cannot see it; cubemap face ordering ❔. **2026-08-29, for the UI path only:** our composite is brighter than the emulator's frame by a gamma of **β‰ˆ1.34–1.49** across three screens ([tone curve](structures/ui-render-tone-curve.md)) β€” 🟑 measured, not decoded, constrained only over render values ~0–60, and possibly canary's own `kernel_display_gamma_type = 2` (BT.709) output stage rather than the game's | | T8aD 2D texture | βœ… | `sylpheed-formats/src/t8ad.rs` | **100 % of the disc decodes** (19 216/19 216, measured). The "~15 % deferred variants" were a wrong model, not a variant: a surface is a list of **arbitrary sub-rectangles**, each with a 16-byte header of `dst X, dst Y, width, height`, not a 256Γ—256 grid β€” `0x1c` is the **rectangle count**. Uncovered area stays transparent. **Colours βœ… CONFIRMED** ([k8888](structures/texture-color-k8888.md)) | -| RATC bundle | βœ… | `sylpheed-formats/src/ratc.rs` | child listing confirmed. **"One level deep" is not a limitation β€” there is nothing deeper**: 2 859 bundles hold 18 002 children at depth 1 and **0 at depth 2**, with no parse failures. Nested RATC blobs are **leaf records that reference siblings by name** (`opt `, the sprite name): 3 311 leaves, all embedding sibling names, **10 144 of 10 148 references resolve**. The 4 that do not are one dangling asset β€” `pmbase.rat` β†’ `pmbase.t32` in `GP_STAGE_CLEAR.pak`'s four language builds, and `pmbase.t32` is **on the disc nowhere** | +| RATC bundle | βœ… | `sylpheed-formats/src/ratc.rs` | **βœ… 2026-08-29: a child's NAME is stated by an `opt ` block** (`"opt " | BE32 len | name | NUL | 3 bytes | magic`), not by the printable bytes before it. We had been guessing it from the last printable run, which is right 17 918 times and **wrong 24**, each time because the 3-byte tail is itself printable β€” `8AX` (Γ—22) and `'OX` (Γ—2). `8AX` is **not a name**; it hid `pteff05.t32`/`pteff04.t32`, the full-resolution background of all five menu screens, which `compose` then dropped with no diagnostic ([ratc-child-names](structures/ratc-child-names.md)). 🟑 60 of 18 002 children carry no `opt ` block and still use the scan. Child listing confirmed. **"One level deep" is not a limitation β€” there is nothing deeper**: 2 859 bundles hold 18 002 children at depth 1 and **0 at depth 2**, with no parse failures. Nested RATC blobs are **leaf records that reference siblings by name** (`opt `, the sprite name): 3 311 leaves, all embedding sibling names, **βœ… 10 148 of 10 148 references resolve, as of 2026-08-29.** The 4 that did not were `pmbase.rat` β†’ `pmbase.t32` in `GP_STAGE_CLEAR.pak`'s four language builds, recorded as "`pmbase.t32` is on the disc nowhere". It was on the disc all along, as the child the printable-run scan named `8AX` β€” the `opt ` name fix above resolves it in all four builds (3 686 767 B each). A dangling reference that closes itself when an unrelated decode lands is the corroboration that decode wanted | | LSTA sprite list | βœ… | `sylpheed-formats/src/lsta.rs` | A display list of inline elements: **T8aD sprites and `PRMD` primitives**. The `count` at `0x04` is **exact and counts both** β€” `count == T8aD + PRMD` for **64/64** lists on the disc, which retires the old "a few entries disagree" note (it compared sprites against a total including primitives). **All 1 281 sprite frames decode** after the T8aD rectangle-list fix | | IXUD subtitle + caption text | βœ… | `sylpheed-formats/src/ixud.rs` + `movie_subtitle.rs` ([container](structures/idxd-container.md) Β· [movie link](movie-subtitle-link.md)) | **The IXUD record/field table is decoded and wired in (2026-08-26)** β€” `IxudObject` mirrors `IdxdObject`; uniform 16-byte records, 12-byte fields, every offset in **chars**, and the word at `0x08` is record 0's hash, not a schema id. Verified disc-wide: **1104/1104** objects, **1476/1476** records, **628 165/628 165** named fields reproducing their `ixud_hash` (`tests/ixud_records_disc.rs`). **Caption text: 537 β†’ 8800 lines, which is 8800 of 8800 distinct keys.** Two steps β€” generalising the key parser from `MSG_DEMO_*` to all **eight** families (`ACRO ADAN ADPL BIRD DEMO RHIN TCAF` use `MSG____`, `VOICE` alone inserts a family letter) took 537 β†’ 8074; switching from **token adjacency to record fields** took it to 8800. ⚠️ An earlier "1.3 % of the game's text" figure of mine counted *occurrences across blocks* β€” the honest denominator is **8800 distinct keys**, so the real starting point was 6.1 %. The `DEMO` control shows why the field route matters: token adjacency finds 537 lines there, fields find **541** β€” it was dropping lines in the one family it was written for. | timed cues. **The movie↔subtitle↔voice link is solved β€” statically**, and as of 2026-08-25 read from the IDXD **record table** rather than scraped from the string pool: **104 cutscene slots binding 101 distinct movies**, 99 slots / 96 movies with a subtitle, 99 / 96 with a voice track, 22 / 22 with a telop. ⚠️ The previous counts (94 / 83 / 21) were the numbers of **distinct pool strings** β€” a repeat reference contributes no token, so 13 later `VOICE_D_450..454` references read as "no binding". **All 18 hokyu movies are bound**, not five. 93 of the 94 distinct subtitle members resolve; `SUBTITLE_S12B.tbl` resolves in none of the six languages β€” a dangling reference on the disc. The ~104 **script ids are no longer ❔**: they are literal positional field keys in `BASE_INFO`, each naming its record, and all 104 resolve. `movie_manifest::parse` now reads the record table; CSV regenerated by `examples/movie_map_csv.rs` | | Fonts (ttf/otf/ttc) | βœ… | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser | diff --git a/docs/re/METHOD.md b/docs/re/METHOD.md index 68c6fd8..fc6a4b1 100644 --- a/docs/re/METHOD.md +++ b/docs/re/METHOD.md @@ -838,3 +838,22 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the "measured" covers several kinds of evidence, and the page that recorded it usually says which β€” `ui-screen-runtime.md` said "child slots" in as many words. Read that line before building an argument on top of it. + +* **A heuristic that is right 99.9 % of the time still has a shape to its + failures β€” find it before trusting the field.** RATC child names were read by + scanning backwards for the last printable run of bytes. That agrees with the + format's own `opt ` declaration on 17 918 of 17 942 children, which is the kind + of agreement that stops people looking. The 24 exceptions were not random: all + 24 are the *same* case, a 3-byte binary tail that happens to be printable ASCII + (`8AX`), and one of them was the full-resolution background of every menu + screen we care about. Ask what the format *states* before settling for what a + scan *infers*, especially when the stated version is already decoded elsewhere + in the same file β€” `opt ` was being read for button focus links the whole time. + +* **A `continue` that silently skips is a defect even when the skip is correct.** + `compose` drops an element whose sprite does not resolve. Two arms above it + record the name into `missing` first; the `el.sprite.is_none()` arm does not. + So a screen lost its background and `screen render` still reported "sprites + that did not resolve: none" β€” the diagnostic was structurally unable to see it. + When adding an early-out to a loop that already reports what it discards, make + it report through the same channel, or it becomes a place findings go to die. diff --git a/docs/re/REFUTED.md b/docs/re/REFUTED.md index 40f53e2..8233713 100644 --- a/docs/re/REFUTED.md +++ b/docs/re/REFUTED.md @@ -605,3 +605,15 @@ neighbourhood, not just the line. them regardless. The no-overlap measurement was correct; the inference from it was not. What survives: a capture of this screen can only cross-check the order *within* each half. [`ui-prm-primitives.md`](structures/ui-prm-primitives.md) + +* "`8AX` is the name a `T8aD` is registered under" β†’ **it is not a name at all.** + It is three bytes of the preceding record's payload (`38 41 58`) that happen to + be printable ASCII, which our backwards printable-run scan preferred over the + name the format actually states in its `opt ` block. The claim sat in + `HANDOFF.md` and `ui-8ax-fullres-background.md` as though `8AX` were a real + identifier, and cost every menu screen its full-resolution background. + [`ratc-child-names.md`](structures/ratc-child-names.md) +* "`pmbase.t32` is on the disc nowhere" (the one dangling asset behind + `10 144 of 10 148 references resolve`) β†’ **withdrawn; it is on the disc.** It is + the `GP_STAGE_CLEAR` child the same scan named `8AX`. With the name decoded the + count is **10 148 of 10 148**. [`ratc-child-names.md`](structures/ratc-child-names.md) diff --git a/docs/re/data/ratc-child-name-audit.txt b/docs/re/data/ratc-child-name-audit.txt new file mode 100644 index 0000000..94acad7 --- /dev/null +++ b/docs/re/data/ratc-child-name-audit.txt @@ -0,0 +1,52 @@ +# RATC child names: the printable-run scan vs the `opt ` block, all 33 dat/*.pak +# 2026-08-29, crates/sylpheed-formats/examples/ratc_child_names.rs +# +# RUN 1 was taken BEFORE the fix, when ratc::parse still named children by +# scanning for the last printable run. It is the evidence for the defect. + +RATC children scanned : 18002 + with an `opt ` block: 17942 + scanned name AGREES : 17918 + scanned name DIFFERS : 24 + +distinct disagreements (scanned -> opt), with counts: + 'OX -> po_keys_win1.t32 x2 + 8AX -> pbbg.t32 x12 + 8AX -> pmbase.t32 x4 + 8AX -> pteff04.t32 x2 + 8AX -> pteff05.t32 x4 + +first 20 occurrences: + GP_OPTIONS.pak entry 20 'OX -> po_keys_win1.t32 + GP_OPTIONS.pak entry 22 'OX -> po_keys_win1.t32 + GP_READY_ROOM.pak entry 132 8AX -> pbbg.t32 + GP_READY_ROOM.pak entry 141 8AX -> pbbg.t32 + GP_READY_ROOM.pak entry 211 8AX -> pbbg.t32 + GP_READY_ROOM.pak entry 219 8AX -> pbbg.t32 + GP_READY_ROOM.pak entry 233 8AX -> pbbg.t32 + GP_READY_ROOM.pak entry 234 8AX -> pbbg.t32 + GP_READY_ROOM.pak entry 264 8AX -> pbbg.t32 + GP_READY_ROOM.pak entry 265 8AX -> pbbg.t32 + GP_READY_ROOM.pak entry 967 8AX -> pbbg.t32 + GP_READY_ROOM.pak entry 999 8AX -> pbbg.t32 + GP_READY_ROOM.pak entry 1097 8AX -> pbbg.t32 + GP_READY_ROOM.pak entry 1099 8AX -> pbbg.t32 + GP_STAGE_CLEAR.pak entry 2 8AX -> pmbase.t32 + GP_STAGE_CLEAR.pak entry 4 8AX -> pmbase.t32 + GP_STAGE_CLEAR.pak entry 7 8AX -> pmbase.t32 + GP_STAGE_CLEAR.pak entry 8 8AX -> pmbase.t32 + GP_TITLE.pak entry 4 8AX -> pteff04.t32 + GP_TITLE.pak entry 5 8AX -> pteff05.t32 + +# RUN 2, same command AFTER the fix. ratc::parse now prefers the `opt ` name, so +# the two readings must agree everywhere -- which is the check that the fix is +# complete rather than partial. + +RATC children scanned : 18002 + with an `opt ` block: 17942 + scanned name AGREES : 17942 + scanned name DIFFERS : 0 + +distinct disagreements (scanned -> opt), with counts: + +first 20 occurrences: diff --git a/docs/re/data/ratc-name-fix-render-effect.txt b/docs/re/data/ratc-name-fix-render-effect.txt new file mode 100644 index 0000000..995c977 --- /dev/null +++ b/docs/re/data/ratc-name-fix-render-effect.txt @@ -0,0 +1,26 @@ +# Effect on the five port screens of naming RATC children from the `opt ` +# block. 2026-08-29. `sylpheed-cli screen render --build N GP_TITLE.pak`, +# before vs after the ratc.rs change. The newly-resolved element is the +# full-resolution background (pteff04.t32 on the title, pteff05.t32 on the +# menus), which the old name `8AX` hid. + +build screen mean brightness high-frequency detail + 4 title 72.77 -> 72.81 8.722 -> 10.062 x1.15 pixels changed >2: 30.0% + 5 main menu (JP) 42.73 -> 42.74 3.978 -> 5.051 x1.27 pixels changed >2: 20.0% + 6 EXTRAS (JP) 44.08 -> 44.09 3.953 -> 5.025 x1.27 pixels changed >2: 19.4% + 8 main menu 41.57 -> 41.58 3.830 -> 4.970 x1.30 pixels changed >2: 21.0% + 9 EXTRAS 42.99 -> 43.00 3.867 -> 5.000 x1.29 pixels changed >2: 20.2% + +# Brightness unmoved, detail up ~a quarter = the same artwork at twice the +# resolution replacing a 2x upscale. New CONTENT would move the mean. + +# Covering check: the background is opaque and full-screen and paints 4th +# of 16 on the menu, so it could hide what follows. Standard deviation +# inside each element's resting rect (build 8), before vs after: + ptbtn01 sd 59.98 -> 60.03 + ptbtn03 sd 63.40 -> 63.45 + ptbtn05 sd 66.54 -> 66.58 + ptframe1 sd 51.16 -> 51.20 + ptmsg sd 56.11 -> 56.12 + loop-area sd 50.99 -> 51.04 +# Nothing is covered. diff --git a/docs/re/structures/ratc-child-names.md b/docs/re/structures/ratc-child-names.md new file mode 100644 index 0000000..3fb698f --- /dev/null +++ b/docs/re/structures/ratc-child-names.md @@ -0,0 +1,137 @@ +# A RATC child's name is stated by an `opt ` block, not by the bytes before it + +**Status:** βœ… `DECODED`, with a disc-wide check. This fixes a decoder defect that +silently dropped the **full-resolution background from all five menu screens**. + +## The field + +Immediately before nearly every child of a `RATC` bundle β€” 17 942 of the disc's +18 002 β€” sits an `opt ` block: + +```text +"opt " | BE32 length | name | NUL | 3 bytes | +``` + +Two children of the main menu bundle (`GP_TITLE.pak` entry 8), raw: + +```text +opt 00 00 00 0a p t b a s e . t 3 2 00 0e 10 a4 T8aD +opt 00 00 00 0b p t e f f 0 5 . t 3 2 00 38 41 58 T8aD + ^^^^^^^^ + "8AX" +``` + +This is the **same `opt ` block** `ui_layout::opt_link` already decodes for a +button's focus link. Nothing new had to be discovered to read it β€” only noticed. + +## The defect it fixes + +`ratc::parse` named each child with `name_before`: the last printable run in the +96 bytes before the child's magic. That is a heuristic, and it is *usually* right +β€” `ptbase.t32`'s three trailing bytes are `0e 10 a4`, not printable, so the scan +walks back to the real name. + +But `pteff05.t32`'s trailing three bytes are `38 41 58`, which is `"8AX"` in +ASCII. The scan takes those, the child is registered under the name `8AX`, the +element that declares `pteff05.t32` matches nothing in the sprite table, and +`compose` drops it through + +```rust +let Some(sprite) = el.sprite.as_ref() else { continue }; +``` + +β€” *before* the arm that records a `missing` sprite. So the screen's background +vanished with **no diagnostic at all**: `screen render` reported "all resolved". + +⚠️ `8AX` was carried in `docs/` as though it were a name the game uses β€” the old +text read "the `T8aD` behind its `opt ` link is registered under the name `8AX`". +It is not a name. It is three bytes of the preceding record's payload. + +## The disc-wide check + +[`examples/ratc_child_names.rs`](../../../crates/sylpheed-formats/examples/ratc_child_names.rs) +compares the two readings for every RATC child in all 33 `dat/*.pak` +([data](../data/ratc-child-name-audit.txt)): + +| | | +|---|---| +| RATC children scanned | **18 002** | +| carrying an `opt ` block | 17 942 | +| scanned name **agrees** with it | **17 918** | +| scanned name **differs** | **24** | + +Every one of the 24 is the same failure: a 3-byte printable tail beating a real +name. + +| scanned | actual | count | where | +|---|---|---|---| +| `8AX` | `pbbg.t32` | 12 | `GP_READY_ROOM` | +| `8AX` | `pteff05.t32` | 4 | `GP_TITLE` 5, 6, 8, 9 β€” the menus | +| `8AX` | `pmbase.t32` | 4 | `GP_STAGE_CLEAR` | +| `8AX` | `pteff04.t32` | 2 | `GP_TITLE` 4, 7 β€” the title | +| `'OX` | `po_keys_win1.t32` | 2 | `GP_OPTIONS` | + +⚠️ **What I checked about the 24, precisely.** That the recovered name is the one +the bundle actually wants is verified for the ten `GP_TITLE` and `GP_STAGE_CLEAR` +cases: on the title screens `pteff04.t32`/`pteff05.t32` are *declared elements* +that previously resolved to nothing and now resolve, and `pmbase.t32` is the +target of `GP_STAGE_CLEAR`'s long-standing dangling reference (below). For the 12 +`GP_READY_ROOM` (`pbbg.t32`) and 2 `GP_OPTIONS` (`po_keys_win1.t32`) cases I +checked only that no element in those bundles is left unresolved afterwards β€” +which is consistent with, not proof of, the same story. None of the 14 is on the +five menu screens. + +**The control is the 17 918 the heuristic already got right**: the `opt ` reading +reproduces every one of them. A reading that fixed the 24 but disturbed the rest +would be a different rule, not this one. + +🟑 **Reach.** 60 children (0.3 %) have **no** `opt ` block within 128 bytes and +still fall back to the scan. None of them is on the five menu screens. Whether +they genuinely lack the block or sit past the search window is not established. + +## What it changes in the composite + +Resolving the name makes the element resolve, so `compose` now draws it. On all +five port screens ([before/after](../data/ratc-name-fix-render-effect.txt)): + +| build | screen | mean brightness | high-frequency detail | +|---|---|---|---| +| 4 | title | 72.77 β†’ 72.81 | **Γ—1.15** | +| 5 | main menu (JP) | 42.73 β†’ 42.74 | **Γ—1.27** | +| 6 | `EXTRAS` (JP) | 44.08 β†’ 44.09 | **Γ—1.27** | +| 8 | main menu | 41.57 β†’ 41.58 | **Γ—1.30** | +| 9 | `EXTRAS` | 42.99 β†’ 43.00 | **Γ—1.29** | + +The brightness is unmoved and the detail is up by a quarter β€” which is exactly +the signature of *the same artwork at twice the resolution* replacing a 2Γ— +upscale, and not of new content appearing. That it *should* be the full-res art +was settled separately and against the running game, in +[`ui-8ax-fullres-background.md`](ui-8ax-fullres-background.md); this page only +supplies the name that lets the renderer find it. + +βœ… **Nothing is covered.** The background is opaque and full-screen, and on the +menu it paints 4th of 16, so the worry is real. Measured at each element's +resting rect, before vs after: `ptbtn01` sd 59.98 β†’ 60.03, `ptbtn03` 63.40 β†’ +63.45, `ptbtn05` 66.54 β†’ 66.58, `ptframe1` 51.16 β†’ 51.20, `ptmsg` 56.11 β†’ 56.12. +Everything survives; only the two `loop*` elements paint beneath it, and those +are excluded from the default composite anyway. + +⚠️ **Both backgrounds are now drawn** β€” `ptbase.t32` upscaled 2Γ—, then the +full-res one opaquely over it. Correct output, wasted fill. The port should draw +only the full-res one, and take its *timing* from `ptbase`'s element, which is +the one carrying the keyframes. + +## What is NOT decoded + +❔ **No declaration word points at the child.** Before reading the bytes I tested +whether the 60-byte element declaration indexes the T8aD child table. Its unread +words are `+28`, `+36`, `+44` and `+56`; the control is the resolved elements, +whose child index is known. On the main menu, of 13 controls the words reproduce +the child index **1, 0, 0 and 1** times β€” and both 1s are the trivial index-0 +case. There is no pointer; the association is by name, and the name is the `opt ` +string. [`decl_word_probe.rs`](../../../crates/sylpheed-formats/examples/decl_word_probe.rs) + +βœ… Incidental, from the same probe: **`+44` is a button ordinal.** It is `1…5` on +exactly the five `ptbtn0N.rat` elements of the main menu, in order, and `βˆ’1` on +every other element. Not needed for anything open, and recorded rather than +chased. diff --git a/docs/re/structures/ui-8ax-fullres-background.md b/docs/re/structures/ui-8ax-fullres-background.md index 95fa4c0..8e5e05c 100644 --- a/docs/re/structures/ui-8ax-fullres-background.md +++ b/docs/re/structures/ui-8ax-fullres-background.md @@ -1,8 +1,26 @@ -# 🟑 `pteff04` / `pteff05` are dropped β€” the texture is registered as `8AX` +# βœ… `pteff04` / `pteff05` β€” the full-resolution background, once dropped as `8AX` -**Status:** 🟑 a real name-resolution gap, **currently harmless to look at**, and -the obvious fix would make the render *worse*. Found by auditing what -`screen render` silently omits on the port's five screens. +**Status:** βœ… **RESOLVED 2026-08-29.** This page's two questions are both closed +and it is kept for the evidence, not as an open item. + +* *Which of the two backgrounds does the game draw?* β€” the **full-resolution** + one. Measured against a capture; the section below stands unchanged. +* *Why did ours drop it?* β€” because `8AX` **is not a name.** It is three bytes of + the preceding record's payload (`38 41 58`) that happen to be printable ASCII, + and our backwards printable-run scan preferred them to the name the format + states in its `opt ` block. Decoded, with a disc-wide check, in + [`ratc-child-names.md`](ratc-child-names.md); `ratc::parse` now reads the + stated name and `screen render` draws the background on all five screens. + +⚠️ **Read the rest of this page with that correction in mind.** It was written +while `8AX` was believed to be a name the game uses, and says so in several +places β€” "the texture is registered as `8AX`", "the `T8aD` behind its `opt ` link +is registered under the name `8AX`". The `opt ` link was never the problem; the +`opt ` block was the answer, sitting unread three bytes away. + +Original status line: 🟑 a real name-resolution gap, currently harmless to +look at, and the obvious fix would make the render *worse*. Found by auditing +what `screen render` silently omits on the port's five screens. ## What `screen render` drops, and why diff --git a/docs/re/structures/ui-rat-layout.md b/docs/re/structures/ui-rat-layout.md index 8a607eb..3130eee 100644 --- a/docs/re/structures/ui-rat-layout.md +++ b/docs/re/structures/ui-rat-layout.md @@ -330,8 +330,14 @@ parser limitation. It is not β€” **there is nothing deeper on the disc**: - The children that are themselves RATC (the `.rat` layout records) are **leaf records**: they carry no child list and instead **reference their siblings by name** β€” the sprite they place and, via `opt `, their focused variant. - **3 311** such leaves, every one embedding sibling names, and **10 144 of + **3 311** such leaves, every one embedding sibling names, and **10 148 of 10 148** references resolve to a sibling of the same bundle. + ⚠️ It read **10 144 of 10 148** until 2026-08-29. The 4 misses were + `pmbase.rat` β†’ `pmbase.t32` in `GP_STAGE_CLEAR.pak`, written up as an asset + that is "on the disc nowhere". It was on the disc: it is the child our name + scan called `8AX`. See [ratc-child-names](ratc-child-names.md) β€” the same + defect that hid the menu backgrounds. Nothing about the reference was wrong; + the thing it pointed at had the wrong name in our index. That is the same by-name convention used one level up, where a screen's config names `.prt` components, and one level up again, where the movie table names