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:
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user