8 Commits

Author SHA1 Message Date
Sylpheed RE agent
7eeae3006a re(ui): the focus ring SPINS, the game draws it, and the leaf owns the f record
Three things, all from parsing ptbtn0Nf.rat as a build.

1. THE RING SPINS. Its two keyframes differ in exactly one field: rotation_deg
ramps 0 -> 360 with position, scale, alpha and tint all constant. A spin in
place, the same shape as the GP_BUNK example already recorded.

2. THE ORACLE CONFIRMS THE GAME RENDERS IT. In the OPTIONS-focused capture the
ring's bright head sits in a completely different angular position from the
sprite's own -- caught mid-spin. This is a SECOND independent confirmation that
rotation_deg is drawn, now on a different screen and a different element from the
ptloop sweeps, and it raises rotation's priority: it is not a title-only concern
that sits off-screen at rest, it is the main menu's focus marker.
NO ANGLE IS QUOTED. A brightest-region centroid says ~250 deg, but the control
refuses that precision -- rotating the sprite by a known 30/90/180/270 and
re-measuring gives errors up to 19.8 deg. What survives the error bar is that a
<=20 deg error cannot manufacture a ~250 deg displacement.

3. WHICH PLACEMENT WINS -- correcting this page's own earlier caveat, which said
to use the leaf only for elements the parent does not declare. Right for a BASE
record, wrong for an f record: the parent declares NO element for ptbtn0Nf.rat at
all (zero of build 5's 16), so the f record's placement comes from its leaf for
BOTH elements, label included. The label's (-7,-7) is load-bearing -- the f
sprite is 13px larger per axis and -7 keeps them concentric (535+96/2 = 583 vs
542+83/2 = 583.5). Corroborated against the oracle: the focused-minus-unfocused
region is x 505..703, and the leaf predicts a right edge near 707 where the
parent reading predicts 714.

Also exposes UiBuild::records (name -> (offset, size) of a nested .rat leaf).
Nested records were parsed into a PRIVATE map, so a consumer holding a UiBuild
could not locate a leaf's bytes at all -- which is exactly what blocked the port
from reaching the ring.
2026-08-29 08:46:53 +00:00
Sylpheed RE agent
d110cf38c7 media: expose se_wave_riff -- the menu's SE cues, assembled where the format lives
The port is forbidden from reimplementing media assembly and Static.slb is
exactly that case: no RIFF, no seek chunk, no XACT container, just a packed run
of whole 2048-byte XMA1 packets, so a wave is defined only by (offset, packet
count) and the header has to be synthesized. That step now happens once, in the
crate that owns the format, instead of in each consumer.

`slb::xma1_wave_riff` wraps raw packets; `media::se_wave_riff` looks the bank up
and reads just the packets asked for. Both reuse the existing synth_xma1_fmt /
build_riff, which are already byte-identical to what tools/re-capture/
slb_extract_wave.py writes -- so this is exposure, not a second implementation.

It reads a TARGETED range rather than the whole bank, and that is load-bearing:
Static.slb is the ONE entry of sound.pak's 9 519 whose declared extent runs past
the end of the extracted segments -- by exactly 616 768 B -- so reading it whole
fails outright on this extraction. Every cue we need is in the first few hundred
KB. Recorded rather than worked around silently.

Verified as an artifact, not a compile: all three cues decode through ffmpeg to
mono 48 kHz PCM at 0.533 / 0.344 / 1.016 s, non-silent (rms 2085 / 2985 / 4327,
peaks 29813 / 16973 / 32767). The refusal path is exercised in the same run --
an impossible packet count is rejected rather than returning a short stream,
because a truncated XMA decodes to plausible-sounding garbage.

Also adds docs/re/captures/ORACLE-CAPTURES.md: an index of the nine canary
framebuffer captures already in this repo, and a plain statement that THEY are
the reference and `screen render` is not.
2026-08-29 08:41:42 +00:00
Sylpheed RE agent
b21c8e4118 re(ui): the focus ring's position is decoded -- a .rat leaf parses as a build
The port needed ptbtneff01.t32's placement and was about to author it from an
eyeballed PNG measurement. It does not have to: a `.rat` leaf needs no new
reader. Its first 32 bytes have a bundle header's shape -- "RATC", 0x3c
declaration-entry size at +4, element count at +20, design 1280x720 at +24/+28 --
so ui_layout::parse_build reads it unchanged.

The control is the base record, whose position is known independently: the parent
screen reports ptbtn01.rat resting at (542,162), and parsing the leaf alone
returns ptbtn01.t32 at (542,162). It reproduces all five buttons.

Positions are absolute design-space top-left. The ring rests at (500, 156/236/
316/396/476) for buttons 1-5 -- a uniform (-42,-6) from each button's own rest,
identical in the Japanese bundle. The bright label is a uniform (-7,-7).

Two things recorded rather than smoothed over: a leaf's placement DUPLICATES the
parent's rather than being relative to it, and the two copies are not always
byte-equal (ptbtn04's parent says y=401, its leaf says 402) -- the parent is what
compose honours, so the leaf is the source only for elements the parent does not
declare, which is exactly the ring. And `screen render --focus` is blind to the
ring for the same reason the port's exporter was: el.focused is name-based on
top-level elements and neither walks into the leaf.
2026-08-29 08:36:53 +00:00
Sylpheed RE agent
9ca1eb50fd re(ui): answer four of the port's five asks -- splash, fade-out, focus, gamma
1 SPLASH ADDRESSING (was blocking P3). No content predicate exists: design size
fails (every extra composable bundle sampled is 1280x720, like every screen) and
element count fails (fragments run 2..15, the splash halves are 3 and 7). But
GP_TITLE needs none -- `--all` adds exactly four bundles there and all four are
real screens, with the --all index equal to the pak entry index 1:1. And there
are TWO splash screens: 11/14 are the developer logos, 10/13 are the SQUARE ENIX
publisher wordmark, which the port did not have and which the boot shows first.

2 FADE-OUT (was blocking P3). It is (a), and it is bigger than the fade quad.
Every element ends on exactly ONE untimed keyframe, which rules out (b); that
block is where the screen plays out -- quad to a=255, buttons/labels/glows to
a=0, frames hold. (c) is refuted by a null test that discriminates: a black quad
alone holds the button/background brightness ratio constant, and through the
fade it falls 6.50 -> 1.94, 3.4x monotonic.

3 FOCUS (saves P5 rework). Over-vs-instead is unobservable -- the focused sprite
covers the base at 100.0% of base-visible pixels on three pairs once aligned
(true offset (7,7); the centre alignment reads a misleading 78-84%), and
compositing both ways differs by RMSE 1.1 inside the button rect. The real defect
is the focus record's SECOND element: ptbtn0Nf.rat declares ptbtneff01.t32 (a
42x46 glowing ring, focus only) plus the bright label, where the base declares
one sprite. That ring is the marker the port draws nowhere.

5 GAMMA. The capture is not neutral: capture ~ 255*(render/255)^g, g ~ 1.34-1.49,
and the chain says it is a ramp the GAME installed, not a capture artefact. So
RMSE against captures has a floor. Reach stated: the flat patches are all dark
(render ~0-60), so midtones and highlights are unconstrained.

4 ROTATION is a human's call and is recorded in MISSION, not acted on -- the port
rotating while the reference renderer does not would make verify-screen report a
large diff meaning "the port is right". The RE half is answered: rotation is
about the declared pivot, measured against a GPU capture.

The focus record's +20 element-count word is marked 🟡 not  -- read on GP_TITLE's
ten button records only; the disc-wide check is written and still running.
2026-08-29 08:19:23 +00:00
Sylpheed RE agent
9a0ca0d71f docs(method): time the disc mesh suite -- 22 minutes of silence is not a hang
Two runs were killed this session for looking stuck. Measured: 1 318 s serial,
no output while it runs. Also records that `build-reborn test` forces
--workspace and silently ignores a `-p`, so scoping needs the cargo
passthrough (`build-reborn t -p <crate>`).
2026-08-29 07:49:18 +00:00
Sylpheed RE agent
f817dd5939 re(ui): the 60 nameless RATC children are frames, not children -- .tan decoded
Closes the reach caveat the `opt ` name fix left behind: 60 of 18 002 RATC
children carry no `opt ` block, and it was not established whether they lack one
or sit past our 128-byte window.

Neither. They are not children. `examples/ratc_optless_children.rs` re-runs
`ratc::parse`'s own guards over the disc and reports which one fired: all 60 are
"tag beyond the window", none is rejected by length, gap or charset, none is
child #0, and all 60 live in six bundles of one archive. Within a bundle the
distances back to the nearest tag are an exact arithmetic progression, step
60 600 -- ten different records finding the SAME tag, because there is only one.

Reading a bundle directly: children 1..10 are equal-size T8aD blocks under a
single `opt ` name, `pb_f15_eg_anm.tan`. `.tan` is a FRAME SEQUENCE. One block
declares the resource; its payload is a run of T8aD frames.

Disc-wide, over all 18 718 `opt ` names in all 33 paks: a RATC bundle names
exactly six kinds of resource -- `.t32` 14 756, `.rat` 3 311, `.prm` 367,
`.tbm` 224, `.sbo` 54, `.tan` 6. Six `.tan`, ten frames each = 60, the entire
population with nothing left over. The negative is closed, not narrowed.

Consequence recorded but deliberately not fixed: `ratc::parse` over-reports
there, listing a `.tan`'s frames as anonymous children. Nothing in the menu
milestone reads a `.tan` -- it occurs only in GP_READY_ROOM, which S1 ruled
out -- so no screen the port draws changes.

Also a METHOD entry for this container OOM-killing `slb_leading_segment_disc`
under default test parallelism (SIGKILL, no assertion; 8/8 pass with
--test-threads=1).
2026-08-29 07:39:09 +00:00
Sylpheed RE agent
56cc7acfc3 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.
2026-08-29 07:26:56 +00:00
Sylpheed RE agent
49f109deb1 agent: the loop must never stop itself
A run ended with a clean exit 0 while the display title read "Loop interval
optimization", leaving four files uncommitted in the tree. Nothing crashed --
the loop was ended, and ending the loop ends the run: the container exits and
there is no next iteration.

The prompt said the agent did not NEED to arm a wakeup. It never said not to
stop one, and an agent that reads about a pacing control will reasonably try to
use it. Now explicit: do not call ScheduleWakeup at all, and if the cadence is
wrong, say so and leave it to a human -- the interval is set outside the prompt.

Recorded with the date and the symptom, because "the container exited cleanly"
looks like a finished job rather than a self-inflicted stop.
2026-08-29 08:12:42 +02:00
32 changed files with 1687 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,71 @@
//! Does a `.rat` leaf record decode with the same reader as a whole bundle?
//!
//! The port needs the position of `ptbtneff01.t32`, the focus ring, which is
//! declared *inside* the nested `ptbtn0Nf.rat` leaf and is therefore invisible
//! to anything that walks only a bundle's top-level elements.
//!
//! The leaf's first 32 bytes have the same shape as a bundle header --
//! `"RATC"`, `0x3c` declaration-entry size at `+4`, element count at `+20`,
//! design size at `+24`/`+28` -- so the hypothesis is that `parse_build` reads
//! it unchanged. The control is the BASE record `ptbtn0N.rat`, whose single
//! element's position is already known independently: the parent screen's
//! `screen info` reports `ptbtn01.rat` resting at (542,162).
//!
//! cargo run -p sylpheed-formats --example rat_leaf_placement -- <GP_TITLE.pak>
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let path = std::env::args().nth(1).expect("usage: … <pak>");
let ar = pak::PakArchive::open(&path).expect("open pak");
let entries: Vec<_> = ar.entries().to_vec();
for (ei, e) in entries.iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(kids) = ratc::parse(&bytes) else { continue };
// Only the title-family bundles carry ptbtn records.
if !kids.iter().any(|c| c.name == "ptbtn01f.rat") {
continue;
}
println!("=== pak entry {ei}");
for c in &kids {
if c.kind != "RATC" || !c.name.starts_with("ptbtn") {
continue;
}
let leaf = &bytes[c.offset..c.offset + c.size];
match ui_layout::parse_build(leaf) {
None => println!(" {:16} …parse_build says no", c.name),
Some(b) => {
println!(
" {:16} {}x{} {} element(s), fallback={}",
c.name,
b.design_w,
b.design_h,
b.elements.len(),
b.from_fallback
);
for el in &b.elements {
let r = el.rest();
println!(
" [{}] {:18} pivot ({:4},{:4}) rest ({:5},{:5}) kf {}",
el.index,
el.name,
el.pivot_x,
el.pivot_y,
r.map(|k| k.x).unwrap_or(-1),
r.map(|k| k.y).unwrap_or(-1),
el.keyframes.len()
);
for (i, k) in el.keyframes.iter().enumerate() {
println!(
" kf{i} t={:?} pos=({},{}) scale={}%,{}% a={} rot={} tint={:#010x}",
k.time, k.x, k.y, k.scale_x, k.scale_y,
k.fade >> 24, k.rotation_deg, k.tint
);
}
}
}
}
}
println!();
}
}

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

@@ -0,0 +1,119 @@
//! The 60 RATC children that carry no `opt ` block — do they lack it, or is our
//! window too small?
//!
//! [`ratc::parse`] now prefers the name a child's own `opt ` block states, and
//! falls back to the old backwards printable-run scan when there is no block
//! within 128 bytes. That fallback fires for 60 of the disc's 18 002 children
//! (0.3 %), and the reach of the finding in `docs/re/structures/ratc-child-names.md`
//! stops there: "whether they genuinely lack the block or sit past the search
//! window is not established".
//!
//! This settles that. For every child with no accepted block it reports
//!
//! * whether an `opt ` tag exists at all further back, and how far;
//! * which guard rejected a tag that WAS in the window (length, gap, charset);
//! * the child's position in its bundle and its magic, in case the opt-less
//! ones are structurally distinct (e.g. always the first child);
//! * the raw bytes before the magic, so the fallback's answer can be judged.
//!
//! cargo run -p sylpheed-formats --example ratc_optless_children -- <pak>...
use sylpheed_formats::{pak, ratc};
/// Why a child has no accepted `opt ` name. Mirrors `ratc::opt_name`'s guards
/// one for one, so a rejection here is the same rejection the parser made.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
enum Why {
/// No `opt ` tag in the 128-byte window, and none anywhere before it either.
NoTagAtAll,
/// No tag in the window, but one exists further back, this many bytes away.
TagBeyondWindow(usize),
/// Tag found, but its BE32 length is 0 or > 64.
BadLength(usize),
/// Tag found, name ends more than 8 bytes before the magic — a neighbour's.
GapTooBig(usize),
/// Tag found, the named bytes are not all printable ASCII.
NotGraphic,
}
/// The parser's own window.
const WINDOW: usize = 128;
fn classify(buf: &[u8], at: usize) -> Option<Why> {
let lo = at.saturating_sub(WINDOW);
let pos = match buf[lo..at].windows(4).rposition(|w| w == b"opt ") {
Some(p) => lo + p,
None => {
// Widen to the whole buffer before the child: is it merely far away?
return Some(match buf[..at].windows(4).rposition(|w| w == b"opt ") {
Some(p) => Why::TagBeyondWindow(at - p),
None => Why::NoTagAtAll,
});
}
};
let len = u32::from_be_bytes(buf.get(pos + 4..pos + 8)?.try_into().ok()?) as usize;
if len == 0 || len > 64 || pos + 8 + len > at {
return Some(Why::BadLength(len));
}
let gap = at - (pos + 8 + len);
if gap > 8 {
return Some(Why::GapTooBig(gap));
}
let s = String::from_utf8_lossy(&buf[pos + 8..pos + 8 + len]);
if s.is_empty() || !s.chars().all(|c| c.is_ascii_graphic()) {
return Some(Why::NotGraphic);
}
None // accepted — this child is not one of the 60
}
fn main() {
let mut total = 0usize;
let mut rows: Vec<(String, usize, usize, String, String, Why)> = Vec::new();
let mut first_child_of_bundle = 0usize;
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 (ei, e) in entries.iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(kids) = ratc::parse(&bytes) else { continue };
for (ci, c) in kids.iter().enumerate() {
total += 1;
let Some(why) = classify(&bytes, c.offset) else { continue };
if ci == 0 {
first_child_of_bundle += 1;
}
let lo = c.offset.saturating_sub(24);
let hex = bytes[lo..c.offset]
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" ");
rows.push((short.clone(), ei, ci, c.name.clone(), hex, why));
}
}
}
println!("RATC children scanned : {total}");
println!(" with NO accepted `opt ` block: {}", rows.len());
println!(" ... of which are child #0 : {first_child_of_bundle}");
let mut by_why: std::collections::BTreeMap<String, usize> = Default::default();
for r in &rows {
let k = match &r.5 {
Why::TagBeyondWindow(_) => "TagBeyondWindow".to_string(),
Why::BadLength(_) => "BadLength".to_string(),
Why::GapTooBig(_) => "GapTooBig".to_string(),
other => format!("{other:?}"),
};
*by_why.entry(k).or_default() += 1;
}
println!("\nwhy, by cause:");
for (k, n) in &by_why {
println!(" {k:20} x{n}");
}
println!("\nevery occurrence (name is what the FALLBACK scan returned):");
for (p, ei, ci, name, hex, why) in &rows {
println!(" {p:28} entry {ei:4} child {ci:3} {name:24} {why:?}");
println!(" 24 bytes before the magic: {hex}");
}
}

View File

@@ -0,0 +1,27 @@
//! Dump the menu's three SE cues as decodable `RIFF`s, to prove `se_wave_riff`
//! produces something ffmpeg actually accepts.
//!
//! cargo run -p sylpheed-formats --example se_wave_dump -- <outdir>
//! ffmpeg -i <outdir>/move.riff move.wav
use sylpheed_formats::media::{self, DirectorySource};
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "/tmp".into());
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(&disc);
for (name, off, pkts) in [("move", 0x1ec0usize, 4usize), ("back", 0x0ec0, 2), ("confirm", 0x5d6c0, 6)] {
match media::se_wave_riff(&src, "Static.slb", off, pkts, 1, 48000) {
Ok(riff) => {
let p = format!("{out}/{name}.riff");
std::fs::write(&p, &riff).unwrap();
println!("{p}: {} bytes ({pkts} packets at {off:#x})", riff.len());
}
Err(e) => println!("{name}: ERROR {e}"),
}
}
// The refusal path: a packet count the bank cannot satisfy.
match media::se_wave_riff(&src, "Static.slb", 0x1ec0, 1 << 20, 1, 48000) {
Ok(_) => println!("REFUSAL PATH FAILED — returned a short stream"),
Err(e) => println!("refusal path ok: {e}"),
}
}

View File

@@ -87,6 +87,62 @@ pub fn sound_bank_riffs<S: DiscSource + ?Sized>(
Ok(riffs_of(&bytes))
}
/// One sound-effect wave out of a **delimiter-less** bank, as a decodable `RIFF`.
///
/// `Static.slb` — where the menu's cues live — has no `RIFF`, no `seek` chunk and
/// no XACT container: it is a packed run of whole 2048-byte XMA1 packets. So
/// [`sound_bank_riffs`] finds nothing to split on, and a wave is defined *only*
/// by `(offset, packet_count)`. Both come from the running game, not from the
/// file: launch Canary with `--xma_param_probe=true`, trigger the sound, and the
/// log prints the stream's packet count and first 32 bytes; searching those bytes
/// in the bank gives the offset. ⚠️ The file order is **not** cue-id order, so the
/// index cannot be counted out — see `docs/re/menu-audio-cues.md`.
///
/// The three cues a menu needs, all mono 48 kHz:
///
/// | event | offset | packets |
/// |---|---|---|
/// | d-pad move | `0x1ec0` | 4 |
/// | Ⓑ back | `0x0ec0` | 2 |
/// | Ⓐ confirm | `0x5d6c0` | 6 |
///
/// Returns an error rather than a short stream if the bank does not actually
/// hold `packet_count` whole packets at `offset` — a truncated XMA stream decodes
/// to plausible-sounding garbage, which is the failure worth refusing.
pub fn se_wave_riff<S: DiscSource + ?Sized>(
source: &S,
bank: &str,
offset: usize,
packet_count: usize,
channels: u8,
rate: u32,
) -> Result<Vec<u8>, String> {
let len = packet_count * crate::slb::XMA1_PACKET;
// Read only the packets asked for, not the whole bank. That is not just an
// efficiency point: `Static.slb` is 8.97 MB and is the ONE entry in
// `sound.pak` whose declared extent runs past the end of the extracted
// segments (by 616 768 B), so reading it whole fails outright on a disc
// extraction that is short at the tail. Every cue we need sits in the first
// few hundred KB. See `docs/re/menu-audio-cues.md`.
let toc = source.read_file("dat/sound.pak")?;
let entries = PakArchive::parse_toc(&toc).map_err(|e| e.to_string())?;
let hash = crate::hash::name_hash(bank);
let idx = entries
.binary_search_by_key(&hash, |e| e.name_hash)
.map_err(|_| format!("{bank}: not present in sound.pak"))?;
let e = &entries[idx];
if offset + len > e.comp_size as usize {
return Err(format!(
"{bank}: {packet_count} packets at {offset:#x} need {len} bytes, \
but the bank declares only {} bytes",
e.comp_size
));
}
let packets =
source.read_segment_range("dat/sound", e.offset as u64 + offset as u64, len)?;
Ok(crate::slb::xma1_wave_riff(&packets, channels, rate))
}
/// The XMA `RIFF`s of a continuous byte region of the voice stream, as returned
/// by [`resolve_movie_voice_region`].
pub fn voice_region_riffs<S: DiscSource + ?Sized>(

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,51 @@ 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, and all 60 are
/// accounted for: they are the ten frames of the disc's only `.tan` frame
/// sequence, in six language copies of one `GP_READY_ROOM` bundle, where a
/// single `opt ` block names the whole run. See
/// `docs/re/structures/ratc-tan-frame-sequence.md` -- and note that this means
/// `parse` OVER-reports there, listing frames as children.
fn name_before(bytes: &[u8], off: usize) -> String {
let start = off.saturating_sub(96);
let window = &bytes[start..off];
@@ -101,6 +143,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

@@ -535,6 +535,28 @@ pub fn to_xma_riff_best(slb: &[u8]) -> Option<Vec<u8>> {
(!data.is_empty()).then(|| build_riff(&synth_xma1_fmt(2, 2, 48000), data))
}
/// Wrap a run of **raw XMA1 packets** as a standalone, decodable `RIFF/WAVE`.
///
/// For a bank with no internal delimiters — `Static.slb` is a packed run of whole
/// 2048-byte packets with no `RIFF`, no `seek` and no `WAVE` — a wave is defined
/// *only* by `(offset, packet count)`, both of which come from the running game
/// (`--xma_param_probe`). There is nothing in the file to parse, so the header
/// has to be synthesized, and that is the step worth doing exactly once, here,
/// rather than in each consumer.
///
/// `packets` must be a whole number of [`XMA1_PACKET`] bytes; anything else is a
/// short read and produces a stream the decoder will run off the end of.
/// The `channel_mask` follows the same convention as the rest of this module:
/// `1` for mono, `2` for stereo.
///
/// The three menu cues in `docs/re/menu-audio-cues.md` are
/// `(0x1ec0, 4)` d-pad move, `(0x0ec0, 2)` Ⓑ back and `(0x5d6c0, 6)` Ⓐ confirm,
/// all mono 48 kHz.
pub fn xma1_wave_riff(packets: &[u8], channels: u8, rate: u32) -> Vec<u8> {
let mask = if channels == 1 { 1 } else { 2 };
build_riff(&synth_xma1_fmt(channels, mask, rate), packets)
}
/// A minimal `fmt ` chunk carrying an XMA1 `XMAWAVEFORMAT` (one stream).
fn synth_xma1_fmt(channels: u8, channel_mask: u16, rate: u32) -> Vec<u8> {
let mut fmt = Vec::with_capacity(40);

View File

@@ -322,6 +322,20 @@ pub struct UiBuild {
pub elements: Vec<Element>,
/// Sprite name → (offset, size) of its `T8aD` child within the bundle.
pub sprites: HashMap<String, (usize, usize)>,
/// Record name → (offset, size) of its nested `.rat` **leaf** within the
/// bundle, e.g. `ptbtn01f.rat`.
///
/// Exposed because a leaf is where a focused button's extra elements live —
/// `ptbtn0Nf.rat` declares the focus ring `ptbtneff01.t32` **and** the bright
/// label, and the parent bundle declares no element for the `f` record at
/// all. A consumer that walks only top-level elements cannot see either.
///
/// A leaf is itself readable by [`parse_build`]: its first 32 bytes have the
/// same shape as a bundle header, so
/// `parse_build(&bundle[off..off + size])` returns its elements with
/// absolute design-space placements. See
/// `docs/re/structures/ui-button-focus-record.md`.
pub records: HashMap<String, (usize, usize)>,
/// A guessed context from the sprite naming (e.g. `"tutorial"`), if any.
pub context_hint: Option<String>,
/// True when the declaration table was unusable and the build was recovered
@@ -599,6 +613,7 @@ pub fn parse_build(bundle: &[u8]) -> Option<UiBuild> {
design_h,
elements,
sprites,
records,
context_hint,
from_fallback,
placement_order,
@@ -967,6 +982,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 {

View File

@@ -120,3 +120,13 @@ turn ends — but sequential tool calls within a turn are fine.
The loop runs on a fixed interval set by the harness, so you do **not** need to
arm the next wakeup yourself. Spend that attention on the write-up instead.
**And never stop it.** Do not call `ScheduleWakeup` at all — not to re-pace the
loop, not to tidy up, and above all not with `stop`. Ending the loop ends the
run: the container exits, and the next iteration never happens. If the cadence
is genuinely wrong, say so in your reply and leave it to a human — the interval
is set outside this prompt and is not yours to optimise.
This is not hypothetical. A run ended at 2026-08-29 04:0x with a clean exit 0
while the display title read "Loop interval optimization", leaving four files of
work uncommitted in the tree.

View File

@@ -41,6 +41,76 @@ authored version can be deleted.
## Already settled — the port can rely on these today
### ⬅ Answers to the port's five asks (2026-08-29)
* **1 — how to recognise the splash.** ❔ **No content rule exists; you are
authoring this.** Design size fails (every extra composable bundle sampled is
1280×720, same as every screen) and element count fails (the fragments run
2…15 elements, the splash halves have 3 and 7 — the ranges overlap).
**But `GP_TITLE` needs no rule.** There, `--all` adds exactly **four**
bundles and all four are real screens — no fragments at all — and the `--all`
index equals the pak **entry** index 1:1 across all 16, so addressing by entry
index does not mean something different from elsewhere.
🔴 **And there are TWO splash screens; you have one.** Entries **11/14** are the
developer logos (GAME ARTS / SETA / studio anima). Entries **10/13** are the
**SQUARE ENIX publisher** wordmark — the *first* thing the boot shows — and you
do not have them. The pairs are region twins (`™` on 10, `®` on 13). All four
draw every element they declare.
[`ui-splash-addressing.md`](../re/ui-splash-addressing.md) ·
[render grid](../re/captures/title-builds/splash-both-halves-rendered.png)
* **2 — the ~0.4 s fade-out is (a)**, and it is bigger than the fade quad.
Every element of a screen ends on **exactly one** untimed keyframe — so there
is one unknown duration per screen, which rules out (b). That final block is
where the screen plays out: the quad goes `a=255` (black) while the buttons,
`ptmsg` and the glows go `a=0` and the two frames hold. (c) is refuted by a
null test on the capture: a black quad alone keeps the button÷background
brightness **ratio constant**, and measured through the fade it falls
**6.50 → 1.94, a 3.4× monotonic drop**. So: write one authored constant
(~0.4 s / ~24 units) and **play the group to its end on every element** — do
not fade a black rectangle over a frozen screen.
[`screen-transitions.md`](../re/screen-transitions.md)
* **3 — focus: your choice is fine, and it is not your bug.** ✅ The focused
sprite **completely covers** the base — `f` alpha ≥ base alpha at **100.0 %** of
base-visible pixels on three pairs across both languages, once aligned properly
(the true offset is **(7,7)**, and at the centre alignment it reads a
misleading 7884 %). Compositing both ways differs by **RMSE 1.1 inside the
button rectangle**, max 12/255 on ~25 px — unmeasurable at frame level.
🔴 **What you are actually missing is the focus record's SECOND element.**
`ptbtn0Nf.rat` declares **two** sprites — `ptbtneff01.t32` (a 42×46 **glowing
ring**, focus only) then `ptbtn0Nf.t32` (the bright label) — where the base
record declares one. That ring is the marker you say you draw nowhere. ⚠️ Note
the small dot-in-circle at each underline's left end is *not* it: that is on
every button all the time, part of the base art.
[`structures/ui-button-focus-record.md`](../re/structures/ui-button-focus-record.md)
* **4 — rotation: not mine to decide alone.** Raised with the human; see
MISSION. What I can say without a decision: the two sub-questions are not
equally open. Rotation is about the **declared pivot** — that anchor is
*measured*, not assumed: the title's two `ptloop` sweeps scale 600 %/800 %
vertically, where the pivot term is worth 450 and 630 px, and the GPU capture
puts both quad centres at y **359.1**/**360.0** against the pivot formula's
**360.0**; top-left anchoring predicts 810/990 and centre-as-position 270. So
if you draw rotation, rotate about the declared pivot.
⚠️ It changes nothing on your five screens **at rest** — they have zero
top-level rotations, and the title's two nested ones sit entirely off-screen at
rest. [`structures/ui-keyframe-rotation.md`](../re/structures/ui-keyframe-rotation.md)
* **5 — the capture is not gamma-neutral, and RMSE against it has a floor.**
Measured on flat patches (16×16, both images `std < 8`):
`capture ≈ 255·(render/255)^γ` with **γ ≈ 1.49** (main menu), **1.49**
(`EXTRAS`), **1.34** (title). The chain says this is a ramp **the game
installed**, not a capture-path artefact: canary's swap-path gamma stage is a
pure 256-entry LUT that defaults to identity, and the game is measured calling
`VdGetCurrentDisplayGamma` once at video init. ⚠️ **Reach: the flat patches are
almost all dark (render ~060), so nothing here constrains midtones or
highlights** — which is where γ 1.4 does its visible work. So: yes, there is a
floor; a γ ≈ 1.4 darkening gets closer and is **authored**, best applied where
it was measured rather than extrapolated. Do not chase RMSE below it.
[`structures/ui-render-tone-curve.md`](../re/structures/ui-render-tone-curve.md)
* **`GP_TITLE.pak` is eight screens, each shipped twice — English and Japanese.**
Build 4 is the English title art and 7 its Japanese twin; **2/3 are the
`PRESS Ⓐ BUTTON` plate, a build of their own** composited over the title and
@@ -261,7 +331,37 @@ 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)
**And the fix has no remaining hole.** 60 of the disc's 18 002 RATC children
still have no `opt ` block; all 60 are now accounted for and **none is on your
screens**. They are the ten frames of the disc's only `.tan` **frame sequence**
(`pb_f15_eg_anm.tan`, six language copies of one `GP_READY_ROOM` bundle), where
a single `opt ` block names the whole run — so a name-resolution miss is not
hiding anything else the way `8AX` was. ⚠️ Two notes if you ever read outside
`GP_TITLE`: `ratc::parse` **over-reports** there, listing a `.tan`'s frames as
anonymous children; and a RATC bundle names exactly six kinds of resource —
`.t32` (14 756), `.rat` (3 311), `.prm` (367), `.tbm` (224), `.sbo` (54),
`.tan` (6).
[`structures/ratc-tan-frame-sequence.md`](../re/structures/ratc-tan-frame-sequence.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
screens: everything is accounted for (`kind & 0x4` ghost instances, `.prm`
primitives, `loop*` animations) except **`pteff04.t32`** on the title and
@@ -287,9 +387,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)
</details>
***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

View File

@@ -208,6 +208,28 @@ settled and only multi-keyframe absolute timing is open; `rest()` differs from
its alternative on **one** element across all five screens, and the current
answer there is the defensible one.
## 🔵 Needs a human decision — rotation (raised 2026-08-29)
The port agent asks whether it should **render** `rotation_deg` (decoded at
keyframe `+12`) when `sylpheed-cli screen render` deliberately does not. Its own
framing is the reason this is not mine to settle: if the port rotates and the
reference renderer does not, then `verify-screen` reports a large title diff that
means *"the port is right"* — a silently inverted signal.
The RE half is answered and is in HANDOFF: rotation is about the **declared
pivot** (measured against a GPU capture, not assumed), and it changes nothing on
the five screens **at rest**.
What needs a decision is which way the divergence gets closed:
* teach `ui_layout::blit` a rotating path, so the two renderers stay comparable
and the diff keeps meaning "someone is wrong" — costs work in the reference
renderer, which is otherwise not on the port's critical path; or
* let the port render rotation and mark the title as a known-divergent screen in
`verify-screen`, accepting a check that no longer guards the title.
Recorded rather than chosen, per "do not improvise around a blocker".
## Known unknowns — say so, do not fill them in
Some of these may turn out to be undecodable. That is a valid, useful answer, and

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 |
| 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)) |
| 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 all 60 are explained: they are the ten frames of the disc's only `.tan` **frame sequence** (`pb_f15_eg_anm.tan`, six language copies of one `GP_READY_ROOM` bundle, 6 × 10 = 60), where one `opt ` block names the whole run — so `parse` over-reports frames as children there. A census of all **18 718** `opt ` names shows a bundle names exactly six kinds of resource: `.t32` 14 756, `.rat` 3 311, `.prm` 367, `.tbm` 224, `.sbo` 54, `.tan` 6 ([tan-frame-sequence](structures/ratc-tan-frame-sequence.md)). 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_<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 |

View File

@@ -838,3 +838,45 @@ 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.
* **"It has no name" can mean "it is not a thing that gets named."** Sixty RATC
children had no `opt ` name block and the open question was whether the block
was absent or merely outside our search window. It was neither: the sixty are
*frames*, ten each of six copies of one `.tan` animation, and one `opt ` block
names the whole run. The give-away was in the data before any hypothesis was —
the distances back to the nearest tag were an exact arithmetic progression
(`213 + n·60600`), i.e. ten different records finding the *same* tag. When a
negative result's measurements come out evenly spaced, the thing you are
counting is probably not the thing the format counts.
* **This container OOM-kills `slb_leading_segment_disc` under default test
parallelism.** It dies with `signal: 9, SIGKILL` and no assertion — eight
threads each holding a slice of a ~1.1 GB bank. It is not a regression and not
a flake, and it reproduces when run alone. `-- --test-threads=1` passes 8/8 in
20 s. Before believing a SIGKILL in this repo, re-run the suite serially;
before believing a *pass*, check nothing else heavy was sharing the box.
⚠️ And budget for `mesh_consistency_disc`: it takes **22 minutes** (1 318 s
measured, serial) and produces no output while it runs, so `build-reborn test`
looks hung for a third of an hour. It is not. Two runs were killed for looking
stuck before it was timed. If the change under test is not in the mesh path,
`build-reborn t -p <crate>` scopes around it — note that `build-reborn test`
itself passes `--workspace` and will ignore a `-p`.

View File

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

View File

@@ -0,0 +1,53 @@
# The oracle frames — what to verify a render against
**These are framebuffer captures of the real game running under Xenia Canary.**
They are the reference. `sylpheed-cli screen render` is **not** — Reborn is a GUI
explorer and extraction CLI built to check that our *decoding* is right, and it
may very well be wrong. Where a render and a capture disagree, the capture wins,
and the render is the thing to go and fix.
⚠️ Two renderers agreeing proves nothing: they share our assumptions. This corpus
has been bitten by exactly that three times — the dropped `pteff05` background,
the scale-0 rect, and `rest()`. Each was invisible to any render-vs-render diff
and visible immediately against a capture.
## The frames
All are **1279×675**, top-left aligned, cropped to the game surface by the
screenshot tool (the guest renders 1280×720; the missing row/column is the crop,
not a scale).
| screen | capture |
|---|---|
| publisher splash (SQUARE ENIX) | [`title-builds/live-splash-publisher.png`](title-builds/live-splash-publisher.png) |
| developer splash (GAME ARTS / SETA / anima) | [`title-builds/live-splash-developer.png`](title-builds/live-splash-developer.png) |
| title, **without** the `PRESS Ⓐ` plate | [`title-builds/live-title-build4-no-plate.png`](title-builds/live-title-build4-no-plate.png) |
| title, **with** the plate | [`title-builds/live-title-press-a.png`](title-builds/live-title-press-a.png) |
| main menu | [`title-builds/live-main-menu.png`](title-builds/live-main-menu.png) · [`main-menu-oracle.png`](main-menu-oracle.png) |
| main menu, **`OPTIONS` focused** | [`title-builds/live-main-menu-options-focused.png`](title-builds/live-main-menu-options-focused.png) |
| `EXTRAS` | [`title-builds/live-extras.png`](title-builds/live-extras.png) |
| title (alternate) | [`title-screen-oracle.png`](title-screen-oracle.png) |
| a screen transition, 13 frames | [`transitions/transition-filmstrip.png`](transitions/transition-filmstrip.png) + [`transition-luminance.csv`](transitions/transition-luminance.csv) |
The **focused** pair is the useful one for button states: the same screen with a
different button highlighted, so the difference isolates what focus changes.
## ⚠️ Before you compute an RMSE against one
* **They are not gamma-neutral.** `capture ≈ 255·(render/255)^γ` with γ ≈ 1.341.49,
and that is a ramp **the game installed**, not a capture-path artefact. So RMSE
against these has a floor and chasing it below that floor is chasing the ramp.
[`../structures/ui-render-tone-curve.md`](../structures/ui-render-tone-curve.md)
* **Geometry is sound**: cross-correlating a render against `live-main-menu.png`
over ±6 px puts the best alignment at exactly (0,0), correlation 0.9466. So a
positional disagreement is real, not a crop artefact.
* **A capture is one moment.** Several of these screens are still animating; the
title's two `ptloop` sweeps move continuously. Compare settled poses, or
compare regions you know are at rest.
## What is NOT here
No capture of the interactive title reached mid-run without a pad press — three
runs across two locales and two launch paths never reached it in ~35 minutes.
See [`../capture-harness-status.md`](../capture-harness-status.md). And no
`GP_READY_ROOM` capture; S1 ruled it out of scope.

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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,58 @@
# The 60 RATC children with no `opt ` block — probe output
```
$ cargo run -p sylpheed-formats --example ratc_optless_children -- $SYLPHEED_DISC/dat/*.pak
RATC children scanned : 18002
with NO accepted `opt ` block: 60
... of which are child #0 : 0
why, by cause:
TagBeyondWindow x60
every occurrence (name is what the FALLBACK scan returned):
GP_READY_ROOM.pak entry 26 child 1 TagBeyondWindow(213)
24 bytes before the magic: 00 00 00 07 00 00 00 28 00 00 00 08 00 00 00 28 00 00 00 09 00 00 00 28
GP_READY_ROOM.pak entry 26 child 2 TagBeyondWindow(60813)
24 bytes before the magic: 0a eb 61 00 08 eb 61 00 06 eb 61 00 05 eb 61 00 03 eb 61 00 01 eb 61 00
... (the remaining 54 rows are the same five bundles' children 1..10;
every distance is 213 + n*60600, i.e. the SAME tag)
```
## Reading bundle 26 directly — the ten are frames of one `.tan`
```
entry 26: 15 children, payload 706609 bytes
0 @0x00000444 T8aD size 1933 opt@-40 name='pbf15_energie_generator2.t32'
1 @0x00000bd1 T8aD size 60600 opt@-213 name='pb_f15_eg_anm.tan'
2 @0x0000f889 T8aD size 60600 opt@-60813 name='pb_f15_eg_anm.tan'
3 @0x0001e541 T8aD size 60600 opt@-121413 name='pb_f15_eg_anm.tan'
4 @0x0002d1f9 T8aD size 60600 opt@-182013 name='pb_f15_eg_anm.tan'
5 @0x0003beb1 T8aD size 60600 opt@-242613 name='pb_f15_eg_anm.tan'
6 @0x0004ab69 T8aD size 60600 opt@-303213 name='pb_f15_eg_anm.tan'
7 @0x00059821 T8aD size 60600 opt@-363813 name='pb_f15_eg_anm.tan'
8 @0x000684d9 T8aD size 60600 opt@-424413 name='pb_f15_eg_anm.tan'
9 @0x00077191 T8aD size 60600 opt@-485013 name='pb_f15_eg_anm.tan'
10 @0x00085e49 T8aD size 60632 opt@-545613 name='pb_f15_eg_anm.tan'
11 @0x00094b21 T8aD size 17523 opt@-32 name='pbf15_pd_inside2.t32'
12 @0x00098f94 T8aD size 75068 opt@-35 name='pbenergie_generator.t32'
13 @0x000ab4d0 T8aD size 4697 opt@-28 name='pbf15_eg_eff.t32'
14 @0x000ac729 RATC size 264 opt@-25 name='pb_s15_eg.rat'
```
## Disc-wide: every `opt ` name, by extension
```
`opt ` blocks disc-wide: 18718
by extension:
.t32 x14756
.rat x3311
.prm x367
.tbm x224
.sbo x54
.tan x6
.tan resources with >=1 child: 6
GP_READY_ROOM.pak pb_f15_eg_anm.tan frames= 10 sizes=[60600, 60632] x6 bundles
```

View File

@@ -0,0 +1,17 @@
# se_wave_riff — the three menu cues, decoded end to end
$ cargo run -p sylpheed-formats --example se_wave_dump -- /tmp/se
/tmp/se/move.riff: 8252 bytes (4 packets at 0x1ec0)
/tmp/se/back.riff: 4156 bytes (2 packets at 0xec0)
/tmp/se/confirm.riff: 12348 bytes (6 packets at 0x5d6c0)
refusal path ok: Static.slb: 1048576 packets at 0x1ec0 need 2147483648 bytes, but the bank declares only 8970240 bytes
$ ffmpeg -i <cue>.riff <cue>.wav # then measure the PCM
move 48000 Hz mono 0.533 s rms 2084.7 peak 29813 non-quiet 47.5%
back 48000 Hz mono 0.344 s rms 2984.7 peak 16973 non-quiet 95.1%
confirm 48000 Hz mono 1.016 s rms 4327.0 peak 32767 non-quiet 92.5%
Non-silent, plausible envelopes, durations consistent with a UI blip.
The refusal path is exercised in the same run: an impossible packet count is
rejected rather than returning a short stream.

View File

@@ -115,3 +115,56 @@ screen's build-in is a separate, longer thing.
quad);
* the ~0.4 s fade-out and the black hold are **authored from this page** — the
disc does not carry them.
## ✅ Which quantity the ~0.4 s is — measured 2026-08-29
Asked by the port: is 0.4 s **(a)** the ramp from the hold to the exit pose, i.e.
exactly the missing duration of that untimed keyframe, **(b)** several keyframes'
worth, or **(c)** something the game does independently of the group?
**It is (a)** — and it is bigger than the fade quad. Two facts.
**1. There is exactly one untimed keyframe, and every element has it.** Reading
`screen info --build 5 --geometry` for the main menu, all 16 elements end on a
single timeless block; none has two. So there is one unknown duration per screen,
not a chain of them — which rules out (b) outright. And that final block is not
idle: it is where the screen *plays out*.
| elements | final untimed block | what it does |
|---|---|---|
| `pteff00.prm` (the fade quad) | `a = 255` | goes **black** |
| `pteff10`, `pteff12`, `ptbtn01…05`, `ptmsg` | `a = 0` | **fade out** |
| `ptframe1`, `ptframe2` | `a = 255` | hold, and get covered |
| `ptbase`, `pteff05`, `ptloop*`, `pteff02.prm` | single keyframe | hold |
**2. The capture shows the content fading, not just a black quad arriving.** This
has a null hypothesis that discriminates: under (c) — the game blackens the frame
independently — every region is scaled by the same `1 α`, so the **ratio**
between a button region and a background region is *constant* through the
fade-out. Under (a) it must fall, because the buttons ramp to `a = 0` while the
background elements hold at 255 and are only dimmed.
Measured on [`transition-filmstrip.png`](captures/transitions/transition-filmstrip.png),
button column ÷ upper-right background art, frame by frame through the fade-out:
```
frame 0 1 2 3 4 5
ratio 6.495 5.574 3.105 2.125 1.935 (black)
```
**A 3.4× monotonic fall.** Constant is refuted. The buttons really are fading
independently of the overall dim, exactly as their declared final block says.
(The incoming screen runs it in reverse, 2.22 → 3.47 over frames 712.)
⚠️ **Reach.** The filmstrip is downsampled and the "button" region unavoidably
contains some background, so the ratio is a direction, not a clean alpha
measurement. It refutes the constant-ratio null decisively; it does not by itself
pin the 0.4 s to ±0.05 s. And it is measured on **one** transition pair.
### For the port
Write **one** authored constant — the duration of the final untimed keyframe,
~0.4 s / ~24 units — and **play the group to its end on every element**. Do not
model the exit as a black rectangle fading over a frozen screen: the buttons and
labels ramp to transparent at the same time, and that difference is visible.

View File

@@ -0,0 +1,141 @@
# 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 — closed 2026-08-29.** 60 children (0.3 %) have **no** `opt ` block
within 128 bytes and fall back to the scan. **They are not children.** They are
the ten frames of the disc's only `.tan` resource, `pb_f15_eg_anm.tan`, in the
six language copies of one `GP_READY_ROOM` bundle — 6 × 10 = 60, the whole
population with nothing left over. One `opt ` block names the whole run, which is
why nine of the ten find no block of their own. None is on the five menu screens.
[`ratc-tan-frame-sequence.md`](ratc-tan-frame-sequence.md)
## 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

@@ -0,0 +1,120 @@
# `.tan` — one name over a run of frames, and the 60 "nameless" children it explains
**Status:**`DECODED`, with a disc-wide check. This closes the 🟡 reach caveat
left open by [`ratc-child-names.md`](ratc-child-names.md): *"60 children have no
`opt ` block within 128 bytes and still fall back to the scan — whether they
genuinely lack the block or sit past the search window is not established."*
**Neither.** They are not children. They are the **ten frames of a single `.tan`
resource**, and the one `opt ` block that names the whole run sits up to 545 KB
behind the last of them.
## What was measured
[`examples/ratc_optless_children.rs`](../../../crates/sylpheed-formats/examples/ratc_optless_children.rs)
re-runs `ratc::parse`'s own guards over every child on the disc and reports, for
each rejection, *which* guard fired and whether a tag exists further back
([data](../data/ratc-tan-frame-sequence.txt)):
| | |
|---|---|
| RATC children scanned | 18 002 |
| with no accepted `opt ` block | **60** |
| of those, rejected by **length**, **gap** or **charset** | **0** |
| of those, rejected because the only tag is **beyond the 128-byte window** | **60** |
| of those, that are child #0 of their bundle | **0** |
| archives involved | **1**`GP_READY_ROOM.pak` |
| bundles involved | **6** — entries 26, 30, 159, 160, 1029, 1050, each exactly 706 609 B |
| children involved | **1…10 of each**, never 0 and never 11+ |
The distances are the tell. Within one bundle they are
```text
213, 60 813, 121 413, 182 013, 242 613, 303 213, 363 813, 424 413, 485 013, 545 613
```
— an exact arithmetic progression, step **60 600**. Ten different children all
find the **same** `opt ` tag, because there is only one. Nine of them do not have
a block that is merely far away; they have no block.
## What they are
Reading bundle 26 directly, without the Rust parser, the 15 "children" resolve:
| # | offset | kind | size | `opt ` at | name |
|---|---|---|---|---|---|
| 0 | `0x000444` | T8aD | 1 933 | 40 | `pbf15_energie_generator2.t32` |
| **1…9** | `0x000bd1` … | T8aD | **60 600** each | 213 … 485 013 | **`pb_f15_eg_anm.tan`** |
| **10** | `0x085e49` | T8aD | **60 632** | 545 613 | **`pb_f15_eg_anm.tan`** |
| 11 | `0x094b21` | T8aD | 17 523 | 32 | `pbf15_pd_inside2.t32` |
| 12 | `0x098f94` | T8aD | 75 068 | 35 | `pbenergie_generator.t32` |
| 13 | `0x0ab4d0` | T8aD | 4 697 | 28 | `pbf15_eg_eff.t32` |
| 14 | `0x0ac729` | RATC | 264 | 25 | `pb_s15_eg.rat` |
So the format is doing something perfectly ordinary that our scan had no concept
of: **`.tan` is a frame sequence.** One `opt ` block declares the resource, and
its payload is a run of equal-size `T8aD` blocks, one per frame. The name is on
the disc and always was. What was missing was the idea that one name can cover
more than one block.
`anm` in `pb_f15_eg_anm` is the authors' own abbreviation, and it agrees.
## The disc-wide check
Every `opt ` block in every RATC bundle in all 33 `dat/*.pak`, by the extension
it names
([`tools/re-capture/ratc_opt_name_census.py`](../../../tools/re-capture/ratc_opt_name_census.py)):
| extension | count | what it is |
|---|---|---|
| `.t32` | 14 756 | a `T8aD` sprite |
| `.rat` | 3 311 | a nested RATC leaf |
| `.prm` | 367 | a primitive |
| `.tbm` | 224 | — |
| `.sbo` | 54 | — |
| **`.tan`** | **6** | **a frame sequence** |
| | **18 718** | |
A RATC bundle names exactly six kinds of resource, and **`.tan` occurs six times
on the whole disc** — all of them `pb_f15_eg_anm.tan`, one per language copy of
the same bundle, each holding **10 frames**.
**6 × 10 = 60.** That is the entire population of opt-less children, with nothing
left over. The negative is closed, not narrowed.
## ⚠️ What this says about `ratc::parse`
The child list **over-reports**. `parse` finds children by scanning for the four
child magics, so a `.tan`'s ten frames are listed as ten anonymous children of the
bundle rather than as one named resource with ten frames. The disc's "18 002
children" is therefore 18 002 *magic-delimited blocks*, of which 60 are frames.
**Not changed here**, deliberately: nothing in the menu milestone reads a `.tan`,
and a rewrite of the child model is a bigger change than the one fact it would
buy. Recorded so that a later consumer of `.tan` knows the shape it needs.
## ❔ Not established
* **The frame timing.** Ten frames of the same size is a sequence; nothing here
shows the rate, whether it loops, or whether the frames are equal-duration. No
field was looked for.
* **The pixel layout of a 60 600-byte frame.** They decode as `T8aD` like any
other sprite as far as the magic goes; their dimensions were not read.
* **What `.tbm` and `.sbo` are.** They surfaced from the same census and are
recorded above as counts only.
* **The two `opt ` totals do not reconcile exactly** and are not forced to.
This census counts **18 718** blocks; the Rust audit in
[`ratc-child-names.md`](ratc-child-names.md) counts **17 942** children *with* a
block. They apply different guards — the Rust one additionally requires the
named thing to be one of the four child magics and to follow within 8 bytes,
which `.prm` / `.tbm` / `.sbo` (645 blocks) never satisfy. That accounts for
most of the 776 difference but not all of it, and the remainder was not chased.
Each number is reported as what its own script measured.
## Scope
`GP_READY_ROOM.pak` is **out of scope** for the menu milestone ([S1 is a
no-go](../ready-room-probe.md)), and `.tan` occurs in no other archive. **None of
the five menu screens contains a `.tan`**, so nothing the port draws changes.
This closes a caveat on a decode the port *does* depend on, rather than adding a
capability.

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

View File

@@ -0,0 +1,203 @@
# ✅ A focused button is a **two-element record**, and the second element is the ring
**Status:** 🟡 `DECODED for GP_TITLE`, disc-wide check **not yet run**; the
sprite contents and the ring are ✅ `MEASURED` against a live capture. Answers the
port's *"focus: drawn over the base element, or instead of it?"*
⚠️ The element-count field at `+20` is read here on **`GP_TITLE`'s ten button
records only** (five buttons × two language bundles). A disc-wide check —
"does `+20` equal the number of resource names the leaf embeds, for every `.rat`
leaf on the disc?" — is written and was still running when this was committed.
Until it lands, treat `+20` as a **strong local reading, not a decoded field**:
the *observable* claim below (the focus record carries a second sprite,
`ptbtneff01.t32`, and the port must draw it) rests on the embedded names and the
capture, not on that word.
**Short answer: it does not matter, and that is not the bug.** The focused sprite
covers the base completely, so over-vs-instead is worth at most 12/255 on ~25
pixels. What *is* missing is the focus record's **second element** — a glowing
ring that only exists when focused.
## The record
A button's base and focused variants are both `.rat` leaves, and they are not the
same shape:
```text
ptbtn02.rat 164 B strings: RATC, ptbtn02.t32, opt ptbtn02f.rat
ptbtn02f.rat 312 B strings: RATC, ptbtneff01.t32, ptbtn02f.t32, opt ptbtn02b.rat
```
The header says so explicitly. A `.rat` leaf begins:
```text
+0 "RATC"
+16 u32 flags 0x00008110 (base) 0x00008112 (focused)
+20 u32 ELEMENT COUNT 1 2
+32 first element name
```
So the focused record declares **two** elements — `ptbtneff01.t32` first, then
`ptbtn0Nf.t32` — where the base declares one. Both `f` records of both language
bundles read the same way, and the flag word differs only in bit `0x02`
alongside the count.
⚠️ The `opt ` link on a `.rat` leaf is **not** simply "my focused variant". The
chain runs `ptbtn01.rat → ptbtn01f.rat → ptbtn02.rat → ptbtn02f.rat →
ptbtn02b.rat`, i.e. it threads base and focused records together in order. The
corpus calls it the focus link and for a *base* record that reading works; do not
generalise it to the `f` records.
## The three sprites
Decoded with `sylpheed-cli pak textures`, shown over a checkerboard in
[`button-base-focus-ring-sprites.png`](../captures/ui-layout/button-base-focus-ring-sprites.png):
| sprite | size | what it is |
|---|---|---|
| `ptbtn02.t32` | 117×43 | the **dim** label, its underline, and a small dot-in-circle at the underline's left end |
| `ptbtn02f.t32` | 130×56 | the **same label, bright and glowing** — a complete replacement, 13 px larger in each axis |
| `ptbtneff01.t32` | 42×46 | a **glowing ring**, focus only |
The small dot-in-circle is on **every** button all the time; the large ring is
the focus marker. They are different things and sit side by side on the focused
row — visible in
[`live-main-menu-options-focused.png`](../captures/title-builds/live-main-menu-options-focused.png)
against
[`live-main-menu.png`](../captures/title-builds/live-main-menu.png).
## Over or instead? Measured, and the answer is "unobservable"
Aligning base and focused by normalised cross-correlation of their alpha masks —
the true offset is **(7,7)**, not the (6,6) that centring predicts —
| pair | base-visible px | `f` alpha ≥ base alpha there |
|---|---|---|
| `ptbtn02` / `f` (bundle `b58a0fe6`) | 1 444 | **100.0 %** |
| `ptbtn01` / `f` (bundle `b58a0fe6`) | 1 898 | **100.0 %** |
| `ptbtn02` / `f` (bundle `a715f485`) | 3 720 | **100.0 %** |
⚠️ **At the centre alignment it reads 7884 %, and that number is an artefact.**
A 1 px shift on strokes this thin manufactures a fifth of a sprite's worth of
"the focused art is thinner here". Solve the alignment before trusting a coverage
figure.
Coverage is not the same as hiding, though, because `f` is not fully opaque
everywhere. Compositing both ways over the menu's own background colour:
| pair | max channel difference | px > 8/255 | RMSE over the button rect |
|---|---|---|---|
| `ptbtn02` | 12.5 | 23 | 1.09 |
| `ptbtn01` | 12.2 | 27 | 1.08 |
| `ptbtn02` (JP) | 12.2 | 27 | 1.05 |
So the two hypotheses differ by **~1.1 RMSE inside the button rectangle**, on a
couple of dozen pixels — below the ≈ γ 1.4 tone gap
([tone curve](ui-render-tone-curve.md)) and far below any frame-level RMSE this
corpus can resolve. **Either choice is defensible; neither is measurable.**
Replacing is the cheaper one and is what the file's structure suggests, since
`ptbtn0Nf.t32` is a whole label rather than an overlay.
## ✅ Where the ring is placed — DECODED 2026-08-29, no authoring needed
This page previously said the per-element placement inside a `.rat` leaf was not
decoded and that a consumer should eyeball it off a capture. **That was wrong by
omission**: a leaf needs no new reader. Its first 32 bytes have the same shape as
a bundle header — `"RATC"`, `0x3c` declaration-entry size at `+4`, element count
at `+20`, design size `1280x720` at `+24`/`+28` — so `ui_layout::parse_build`
reads it **unchanged**.
**The control is the base record**, whose position is known independently: the
parent screen reports `ptbtn01.rat` resting at `(542,162)`, and parsing the leaf
on its own returns `ptbtn01.t32` at `(542,162)`. It reproduces all five.
Positions are **absolute design-space top-left**, not offsets
([`examples/rat_leaf_placement.rs`](../../../crates/sylpheed-formats/examples/rat_leaf_placement.rs)):
| button | base | ring `ptbtneff01.t32` | Δ | label `ptbtn0Nf.t32` | Δ |
|---|---|---|---|---|---|
| `ptbtn01` | (542,162) | **(500,156)** | (42,6) | (535,155) | (7,7) |
| `ptbtn02` | (542,242) | **(500,236)** | (42,6) | (535,235) | (7,7) |
| `ptbtn03` | (542,322) | **(500,316)** | (42,6) | (535,315) | (7,7) |
| `ptbtn04` | (542,402) | **(500,396)** | (42,6) | (535,395) | (7,7) |
| `ptbtn05` | (542,482) | **(500,476)** | (42,6) | (535,475) | (7,7) |
The offset is **uniform**: `(42,6)` for the ring and `(7,7)` for the label on
every button, and identical in the Japanese bundle (pak entry 8).
⚠️ **Which placement wins — and an earlier version of this page was misleading.**
It said "the parent is what `compose` honours; treat the leaf's as the source only
for elements the parent does not declare". That is right for a **base** record and
wrong for an **`f`** record, because *the parent declares no element for
`ptbtn0Nf.rat` at all* — checked, zero of build 5's 16 elements name it. So the
`f` record's placement can only come from its own leaf, for **both** its elements,
the bright label included.
The `(7,7)` on the label is real and load-bearing: `ptbtn0Nf.t32` is 13 px
larger in each axis than the base, and 7 keeps the two **concentric**
(`535 + 96/2 = 583` against `542 + 83/2 = 583.5`). Drawing the `f` label at the
base element's position would push it 7 px down-right.
**Checked against the oracle.** Differencing the `OPTIONS`-focused capture
against the unfocused one, the changed region is x **505…703**, y **397…446**.
The leaf predicts ink starting inside the ring's box at x ≥ 500 (measured 505, a
5 px art inset) and the label's right edge near 707 (measured 703); the
parent-position reading predicts 714. Both the right edge and the bottom edge
favour the leaf by ~7 px. ⚠️ Ink-inset reasoning is soft — the decisive argument
is the structural one above, that there is no parent element to inherit from.
The one thing that *is* duplicated is the **base** record: `ptbtn04`'s parent
element rests at y **401** while its own leaf says **402** (and in the JP bundle
the leaf says 401 against a parent 401). For a base record the parent is what
`compose` honours.
## ✅ The ring SPINS — and the oracle confirms the game draws the rotation
The ring's two keyframes differ in exactly one field:
```text
ptbtneff01.t32 kf0 t=120 pos=(500,156) scale=100%,100% a=255 rot=0
kf1 t=None pos=(500,156) scale=100%,100% a=255 rot=360
```
Position, scale, alpha and tint are all constant; only `rotation_deg` ramps,
**0 → 360**. That is a spin in place — the same shape as the `GP_BUNK` example
already recorded in [`ui-keyframe-rotation.md`](ui-keyframe-rotation.md).
**And it is actually rendered.** In the oracle's `OPTIONS`-focused frame the
ring's bright head sits in a completely different angular position from the
sprite's own — the game caught it mid-spin:
[`focus-ring-oracle-vs-sprite.png`](../captures/ui-layout/focus-ring-oracle-vs-sprite.png)
(left: the oracle at the ring's declared box; right: the sprite, unrotated).
🔴 **Do not quote an angle from this.** A brightest-region centroid puts the
displacement near 250°, but the **control refuses it**: rotating the sprite by a
known 30/90/180/270° and re-measuring gives errors up to **19.8°**, so the
estimator is not trustworthy at that precision. What survives is the part the
error bar cannot touch — a ≤20° error cannot manufacture a ~250° displacement, so
**the ring is drawn substantially rotated**, and the exact angle is one frame of a
continuous spin and not a stable quantity anyway.
⚠️ **This raises rotation's priority for a port.** It is not a title-only concern
that sits off-screen at rest: the main menu's focus marker spins, so a renderer
that ignores `rotation_deg` draws a static ring with its highlight in the wrong
place, on the screen the player looks at most.
🟡 The spin's **period** is not established. `t=120` is the first keyframe's time,
and what the untimed second one means for a *leaf* — as opposed to a screen, where
it is the ~0.4 s exit ramp — was not tested.
## ⚠️ `screen render --focus` is blind to the ring, and so was this page
`el.focused` is name-based on **top-level** elements, and a screen's buttons are
`.rat` records whose focused twin is not itself a top-level element — so
rendering build 5 with and without `--focus` produces an identical image. The
reference renderer has the same blind spot the port reported, for the same
reason: neither walks into the leaf. Fixing it is a renderer change, not a
format question; the format is decoded above.
## ❔ Not established
* **`ptbtn02b.t32`** — a third variant, `b`, exists for button 02 only, same size
as the base. Not seen on any capture. Not chased.
* Whether a **non-title** archive uses the same two-element convention. Checked
on `GP_TITLE` only.

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

View File

@@ -0,0 +1,70 @@
# ❔ How to address the developer-logo splash — no content rule, but `GP_TITLE` needs none
**Status:****undecodable as a general predicate, with reach** — and ✅
**exact for `GP_TITLE`**, which is the only archive in scope. Answers the port's
*"I need a predicate, not an index."*
## The ask
The splash has no `.rat` layout child, so `is_build` rejects it and it is
reachable only through `--all` / `is_composable`, which disc-wide also admits
~1 786 non-screen bundles. The port wanted a rule that admits the splash and not
those.
## ✅ In `GP_TITLE` the problem does not arise
```
$ sylpheed-cli screen list GP_TITLE.pak 12 builds — entries 0-9, 12, 15
$ sylpheed-cli screen list --all GP_TITLE.pak 16 bundles — entries 0-15
```
`--all` adds exactly **four** bundles: entries **10, 11, 13, 14**. All four are
real screens, and **not one fragment appears**. So within this archive
`is_composable` *is* the predicate — it is exact, with no filtering needed.
⚠️ And the renumbering worry is moot here: in `GP_TITLE` the `--all` index equals
the pak entry index **1:1 across all 16**. That is an accident of this archive
having 16 composable bundles at entries 0…15, not a general property — but it
means the port can address these by entry index without the index meaning
something different from elsewhere.
## ✅ And there are TWO splash screens, not one
Rendered with `screen render --all --build <n> --primitives`
([grid](captures/title-builds/splash-both-halves-rendered.png)):
| entry | elements | what it draws |
|---|---|---|
| **10** | 3 | **SQUARE ENIX** publisher wordmark, `™` |
| **11** | 7 | **GAME ARTS** / **SETA** / **studio anima** developer logos |
| **13** | 3 | SQUARE ENIX, `®` — the twin of 10 |
| **14** | 7 | the twin of 11 |
⚠️ **The port has 11/14 and is missing 10/13** — the publisher half, which is the
*first* thing the boot sequence shows. The two halves differ only by the
trademark glyph (`™` on 10, `®` on 13), which is the region/language split this
archive uses everywhere else.
All four draw every element they declare (3/3 and 7/7), so nothing is silently
dropped.
## ❔ The general rule: looked for, not found
Two candidate predicates, both dead:
* **Design size.** Every extra composable bundle sampled is **1280×720** — the
same as every screen. It does not separate anything.
* **Element count.** The "two-element fragment" story holds in
`GP_MISSION_SELECT` (10 extras, all 2 elements) and **fails elsewhere**:
`GP_OPTIONS`'s extras have 5 and 15 elements, `GP_SAVE_LOAD`'s have 4, 5, 6, 9
and 13. The splash's own halves have 3 and 7. The ranges overlap, so no
threshold separates them.
Reach: three archives sampled beyond `GP_TITLE`, chosen because they have
non-build composables. Not a disc-wide sweep. A rule may exist in a field not
looked at — the `.prm` fade quad is one candidate
([transitions](screen-transitions.md) shows overlays lack it), untested here.
**So: the port is authoring this.** Locating the splash by entry index is the
honest description, and `name_source` should say it was located by index and not
by a rule.

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Every `opt ` name in every RATC bundle on the disc, by extension -- and the
`.tan` frame sequences among them.
Written to close the reach caveat in docs/re/structures/ratc-child-names.md:
60 of 18 002 RATC children carry no `opt ` block of their own. They are not
children. They are the ten frames of the disc's only `.tan` resource, and one
`opt ` block names the whole run.
python3 tools/re-capture/ratc_opt_name_census.py
Reads $SYLPHEED_DISC/dat/*.pak directly (IPFB TOC + Z1/zlib entries), so it does
not depend on the Rust parser it is checking. Takes a few minutes.
"""
import struct, zlib, glob, os, collections, bisect
MAG = (b"T8aD", b"RATC", b"ttcf", b"\x89PNG")
ext = collections.Counter()
tan_sites = []
opt_total = 0
DISC = os.environ.get("SYLPHEED_DISC", "/work/sylph_extract")
for pakpath in sorted(glob.glob(f"{DISC}/dat/*.pak")):
base = pakpath[:-4]
pak = open(pakpath, "rb").read()
if pak[:4] != b"IPFB": continue
n = struct.unpack_from(">I", pak, 4)[0]
toc = [struct.unpack_from(">III", pak, 0x10 + 12*i) for i in range(n)]
segs = sorted(glob.glob(base + ".p[0-9][0-9]"))
if not segs: continue
data = b"".join(open(s, "rb").read() for s in segs)
for ei, (h, off, cs) in enumerate(toc):
raw = data[off:off+cs]
try:
b = zlib.decompress(raw[10:]) if raw[:2] == b"Z1" else raw
except Exception:
continue
if b[:4] != b"RATC": continue
names = []
p = b.find(b"opt ")
while p >= 0:
ln = struct.unpack_from(">I", b, p+4)[0] if p+8 <= len(b) else 0
if 0 < ln <= 64 and p+8+ln <= len(b):
nm = b[p+8:p+8+ln].decode('latin1', 'replace')
if nm and all(32 < ord(c) < 127 for c in nm):
names.append((p, nm)); opt_total += 1
ext[os.path.splitext(nm)[1].lower()] += 1
p = b.find(b"opt ", p+4)
offs, i = [], 4
while i + 4 <= len(b):
if b[i:i+4] in MAG:
offs.append(i); i += 4
else: i += 1
if not names or not offs: continue
npos = [p for p, _ in names]
# each child -> index of the nearest preceding opt
owner = collections.defaultdict(list)
for k, o in enumerate(offs):
j = bisect.bisect_left(npos, o) - 1
if j >= 0: owner[j].append(k)
for j, (p, nm) in enumerate(names):
if not nm.lower().endswith(".tan"): continue
ks = owner.get(j, [])
if not ks: continue
sizes = sorted({(offs[k+1] if k+1 < len(offs) else len(b)) - offs[k] for k in ks})
tan_sites.append((os.path.basename(pakpath), ei, nm, len(ks), sizes))
print(f"`opt ` blocks disc-wide: {opt_total}")
print("\nby extension:")
for e, c in ext.most_common(25):
print(f" {e or '(none)':10} x{c}")
print(f"\n.tan resources with >=1 child: {len(tan_sites)}")
seen = collections.Counter()
for t in tan_sites:
seen[(t[0], t[2], t[3], tuple(t[4]))] += 1
for (pk, nm, fr, sz), c in sorted(seen.items()):
print(f" {pk:24} {nm:30} frames={fr:3} sizes={list(sz)} x{c} bundles")