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.
This commit is contained in:
Sylpheed RE agent
2026-08-29 07:26:56 +00:00
parent 49f109deb1
commit 56cc7acfc3
14 changed files with 583 additions and 9 deletions

View File

@@ -62,7 +62,7 @@ pub fn parse(bytes: &[u8]) -> Option<Vec<RatcChild>> {
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<Vec<RatcChild>> {
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 | <child magic>
/// ```
///
/// -- 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<String> {
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();

View File

@@ -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 {