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 bada97e989
commit 0ed33bcd38
14 changed files with 583 additions and 9 deletions

View File

@@ -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 -- <pak> [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 <pak> [entry]");
let want: Option<usize> = 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<u32> = 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]);
}
}

View File

@@ -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 -- <pak> <entry>
use sylpheed_formats::{pak, ui_layout};
fn main() {
let path = std::env::args().nth(1).expect("usage: name_resolution <pak> [entry]");
let want: Option<usize> = 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<String> = 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);
}
}
}
}

View File

@@ -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 | <child magic>
//!
//! -- 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 -- <pak>...
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<String> {
// 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}");
}
}

View File

@@ -62,7 +62,7 @@ pub fn parse(bytes: &[u8]) -> Option<Vec<RatcChild>> {
for (idx, &(off, kind)) in offs.iter().enumerate() { for (idx, &(off, kind)) in offs.iter().enumerate() {
let next = offs.get(idx + 1).map(|&(o, _)| o).unwrap_or(bytes.len()); let next = offs.get(idx + 1).map(|&(o, _)| o).unwrap_or(bytes.len());
children.push(RatcChild { children.push(RatcChild {
name: name_before(bytes, off), name: opt_name(bytes, off).unwrap_or_else(|| name_before(bytes, off)),
kind: kind.to_string(), kind: kind.to_string(),
offset: off, offset: off,
size: next.saturating_sub(off), size: next.saturating_sub(off),
@@ -71,9 +71,46 @@ pub fn parse(bytes: &[u8]) -> Option<Vec<RatcChild>> {
Some(children) 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) /// 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 /// 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. /// 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 { fn name_before(bytes: &[u8], off: usize) -> String {
let start = off.saturating_sub(96); let start = off.saturating_sub(96);
let window = &bytes[start..off]; let window = &bytes[start..off];
@@ -101,6 +138,49 @@ fn name_before(bytes: &[u8], off: usize) -> String {
mod tests { mod tests {
use super::*; 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] #[test]
fn lists_named_children() { fn lists_named_children() {
let mut b = RATC_MAGIC.to_vec(); let mut b = RATC_MAGIC.to_vec();

View File

@@ -967,6 +967,13 @@ pub fn compose(
continue; continue;
} }
let Some(sprite) = el.sprite.as_ref() else { 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; continue;
}; };
let Some(&(off, size)) = build.sprites.get(sprite) else { let Some(&(off, size)) = build.sprites.get(sprite) else {

View File

@@ -261,7 +261,26 @@ authored version can be deleted.
setting rather than bake in. setting rather than bake in.
[`structures/ui-render-tone-curve.md`](../re/structures/ui-render-tone-curve.md) [`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)
<details><summary>the original entry, kept because its reasoning still stands</summary>
🟡 **`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 you must NOT simply draw it.** Auditing what the composer omits on your five
screens: everything is accounted for (`kind & 0x4` ghost instances, `.prm` screens: everything is accounted for (`kind & 0x4` ghost instances, `.prm`
primitives, `loop*` animations) except **`pteff04.t32`** on the title and 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 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 note `ptbase`'s element is the one carrying the keyframes, so you need its
timing with `8AX`'s pixels. 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. ~86 % opaque and would hide it either way.
[`structures/ui-8ax-fullres-background.md`](../re/structures/ui-8ax-fullres-background.md) [`structures/ui-8ax-fullres-background.md`](../re/structures/ui-8ax-fullres-background.md)
</details>
***Paint order: your exposure is two element pairs, on one screen.** We use ***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 an order *measured from the running game* where one exists and a derived order

View File

@@ -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 `<lang>\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 | | 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 `<lang>\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.341.49** across three screens ([tone curve](structures/ui-render-tone-curve.md)) — 🟡 measured, not decoded, constrained only over render values ~060, and possibly canary's own `kernel_display_gamma_type = 2` (BT.709) output stage rather than the game's | | 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.341.49** across three screens ([tone curve](structures/ui-render-tone-curve.md)) — 🟡 measured, not decoded, constrained only over render values ~060, 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)) | | 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 | | 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_<FAM>_<id>_<page>_<line>`, `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` | | 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_<FAM>_<id>_<page>_<line>`, `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 | | Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |

View File

@@ -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 "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 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. 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.

View File

@@ -605,3 +605,15 @@ neighbourhood, not just the line.
them regardless. The no-overlap measurement was correct; the inference from it 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 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) *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)

View File

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

View File

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

View File

@@ -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 | <child magic>
```
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.

View File

@@ -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 **Status:** **RESOLVED 2026-08-29.** This page's two questions are both closed
the obvious fix would make the render *worse*. Found by auditing what and it is kept for the evidence, not as an open item.
`screen render` silently omits on the port's five screens.
* *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.
<sub>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.</sub>
## What `screen render` drops, and why ## What `screen render` drops, and why

View File

@@ -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 - The children that are themselves RATC (the `.rat` layout records) are **leaf
records**: they carry no child list and instead **reference their siblings by records**: they carry no child list and instead **reference their siblings by
name** — the sprite they place and, via `opt `, their focused variant. 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. 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 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 names `.prt` components, and one level up again, where the movie table names