port: the exporter never opened an element's own .rat leaf -- 45 elements, and the title's 1.82%

The Decoder overturned the elimination I was most confident about. I ruled out
the ptloop sweeps because "399x180 at (441,270), keyframes hold position
constant". That is the PARENT's record. The geometry is in the leaf.

  ptloop01  parent: scale (100,100) rot 0, fixed at (441,270)
            LEAF:   scale (100,600) rot +30, x sweeping -639 -> -39 -> 1521
  ptloop02  parent: scale (100,100) rot 0, fixed at (441,270)
            LEAF:   scale (100,800) rot -45, x sweeping 1721 -> 1111 -> -839

Two ~1080 and ~1440 px quads leaning opposite ways and sweeping across the frame,
against two 400 px sprites drawn upright and static in the middle. That is
exactly the signature I measured -- darker centre-left, brighter right, nearly
cancelling -- and the GPU capture puts their centres at x ~ 467 and 992, the two
cells where my signed difference peaked.

`ui_layout`'s own doc comment said it: "the rotated quads come from its two
nested .rat leaf records, which the census never opened". Neither did this
exporter -- it opened a leaf in exactly one place, `highlight_name`, for focus
records.

IT IS NOT TWO ELEMENTS, IT IS 45: every button on every menu (the benign case,
where screen.rs already knew the leaf duplicates the parent and the parent wins),
the four loading screens' pgloading_loop*, and title_jp's ptlogo_eff2 -- which is
the element DECISIONS has recorded since P1 as the largest render disagreement in
the export, and which has a TWO-element leaf. A lead, not a conclusion.

EMITTED, DELIBERATELY NOT DRAWN. One `read_leaf` closure serves both the new path
and the focus path, because a second copy is how this would go missing again.
ScreenView ignores the data: parent and leaf each carry their own alpha ramp over
a different span (parent 0->255 over t=70..238, leaf 255->0x80->255 over
t=150..600), so how they compose is a decoding question, and drawing on a guess
would replace a visible 1.82% gap with an invisible wrong one. verify-screen
confirms nothing moved.

Additive blending is refuted -- the Decoder tested T8aD +0x04 bit 0x02 and "every
measure worsens", and the export carries no blend field because none has been
found (no RB_BLENDCONTROL in the per-draw capture). My hypothesis from last
iteration is dead.

This makes the port's biggest oracle gap the same item as the rotation question
already standing with the human: sylpheed-cli deliberately does not rotate, which
is why both renderers show it, and MISSION's "Needs a human decision -- rotation"
now has a number: 1.82% of the title's pixels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
This commit is contained in:
Sylpheed port agent
2026-08-29 18:00:31 +00:00
parent e35c9453b7
commit 9ad8f63234
3 changed files with 208 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
//! Probe: does a `.rat` leaf record carry geometry the parent element does not?
//!
//! The GPU capture says the title submits `ptloop01`/`ptloop02` scaled 600 %/800 %
//! and rotated +30.26°/45.28°, while the export writes scale 100 % and rotation
//! 0 for both. `ui_layout`'s own note says the rotated quads come from the
//! **nested `.rat` leaf records**, which is where `export_screen` already looks
//! for focus records and nowhere else.
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let ar = PakArchive::open(format!("{disc}/dat/GP_TITLE.pak")).expect("open");
let e = &ar.entries()[4]; // entry 4 = the English title
let bundle = ar.read(e).expect("read");
let b = ui_layout::parse_build(&bundle).expect("parse");
println!("build has {} elements, {} records", b.elements.len(), b.records.len());
let mut names: Vec<&String> = b.records.keys().collect();
names.sort();
println!("records: {names:?}");
for el in &b.elements {
if !el.name.starts_with("ptloop") { continue; }
let r = el.rest();
println!("\nPARENT {} -> rest scale {:?} rot {:?}", el.name,
r.map(|r| (r.scale_x, r.scale_y)), r.map(|r| r.rotation_deg));
if let Some(&(off, size)) = b.records.get(&el.name) {
match ui_layout::parse_build(&bundle[off..off + size]) {
Some(leaf) => {
println!(" LEAF {} parses: {} element(s)", el.name, leaf.elements.len());
for le in &leaf.elements {
let lr = le.rest();
println!(" {:<20} rest scale {:?} rot {:?} pos {:?}",
le.name,
lr.map(|r| (r.scale_x, r.scale_y)),
lr.map(|r| r.rotation_deg),
lr.map(|r| (r.x, r.y)));
for k in &le.keyframes {
println!(" t={:?} scale=({},{}) rot={} pos=({},{}) fade={:#010x} u4={} u8={}",
k.time, k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y,
k.fade, k.unknown_4, k.unknown_8);
}
}
}
None => println!(" LEAF {} does NOT parse as a build", el.name),
}
} else {
println!(" no record named {}", el.name);
}
}
}

View File

@@ -141,6 +141,31 @@ pub struct Element {
/// convention and a consumer may still want the bare highlight texture.
#[serde(skip_serializing_if = "Option::is_none")]
pub focus: Option<Focus>,
/// This element's own `.rat` leaf, when its declared name is itself a
/// record in the bundle.
///
/// 🔴 **DECODED DATA THE EXPORTER USED TO DROP.** `ptloop01`/`ptloop02` on
/// the title declare scale 100 % and rotation 0 at the parent, and their
/// leaves declare **(100, 600) at +30°** and **(100, 800) at 45°** — and
/// the leaves *move*, x from 639 → 1521 and 1721 → 839. `ui_layout`'s own
/// note says so: *"the rotated quads come from its two nested `.rat` leaf
/// records, which the census never opened."* Neither did this exporter: it
/// opened a leaf only for a FOCUS record, via `highlight_name`.
///
/// That omission is measurable. It is the whole of the title's 1.82 %
/// disagreement with the oracle — the port draws two 400 px sprites upright
/// and static at (441, 270) where the game sweeps two ~1080 and ~1440 px
/// quads across the frame at opposite leans.
///
/// ⚠️ **Emitted, not yet drawn.** Parent and leaf each carry their own alpha
/// ramp on a different span — parent 0→255 over t=70…238, leaf
/// 255→0x80→255 over t=150…600 — so how the two compose is a *decoding*
/// question and not the port's to answer. The data is exported so it stops
/// being invisible; `ScreenView` ignores it until the composition rule is
/// known.
#[serde(skip_serializing_if = "Option::is_none")]
pub leaf: Option<Focus>,
/// The raw `opt ` link inside this element's `.rat` record.
///
/// ⚠️ **This is not a focus link.** It was read as one, and that was
@@ -316,6 +341,69 @@ pub fn export_build(
// Contrast with a BASE record, where the leaf duplicates the parent's
// placement and the two can differ by a unit (ptbtn04: parent y=401,
// leaf y=402). There the parent wins. Here there is no parent.
// Reads one record in the bundle as a nested build and returns its
// elements. Used twice: for a FOCUS record (`ptbtn0Nf.rat`) and for an
// element whose OWN declared name is a record (`ptloop01.rat`). One
// implementation, because the second case was missing for eight
// milestones and a second copy is how it would go missing again.
let read_leaf = |rec: &str,
written: &mut std::collections::BTreeMap<String, ()>,
missing: &mut Vec<String>|
-> Result<Option<Focus>> {
let Some(&(off, size)) = b.records.get(rec) else { return Ok(None) };
let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) else {
return Ok(None);
};
let mut fes = Vec::new();
for fe in &leaf.elements {
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
let mut fsprite = None;
if write_from(&sprite_dir, written, sp, &bundle[off..off + size], &leaf.sprites)?
|| write_from(&sprite_dir, written, sp, bundle, &b.sprites)?
{
fsprite = Some(sprite_rel(sp));
} else if sp.ends_with(".t32") {
missing.push(sp.to_string());
}
let Some(r) = fe.rest() else { continue };
fes.push(FocusElement {
id: id_of(&fe.name),
declared: fe.name.clone(),
sprite: fsprite,
pivot: [fe.pivot_x, fe.pivot_y],
rest: Rest {
pos: [r.x, r.y],
scale: [r.scale_x, r.scale_y],
tint_rgba: hex32(r.tint),
fade_argb: hex32(r.fade),
rotation_deg: r.rotation_deg,
t: r.time,
},
keyframes: fe
.keyframes
.iter()
.map(|k| Keyframe {
t: k.time,
pos: [k.x, k.y],
scale: [k.scale_x, k.scale_y],
tint_rgba: hex32(k.tint),
fade_argb: hex32(k.fade),
rotation_deg: k.rotation_deg,
})
.collect(),
});
}
Ok(if fes.is_empty() {
None
} else {
Some(Focus { record: rec.to_string(), elements: fes })
})
};
// An element whose own declared name is a record in this bundle carries
// its geometry THERE, not in its parent entry. See `Element::leaf`.
let leaf = read_leaf(&el.name, &mut written, &mut missing)?;
let mut focus = None;
if let Some(rec) = highlight_name(&el.name) {
if let Some(&(off, size)) = b.records.get(&rec) {
@@ -397,6 +485,7 @@ pub fn export_build(
sprite: sprite_out,
focus_sprite,
focus,
leaf,
opt_link: el.focus_link.clone(),
pivot: [el.pivot_x, el.pivot_y],
size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]),

View File

@@ -3926,3 +3926,73 @@ it is recorded rather than fixed.
shows"* could not be asked otherwise. ⚠️ Its first version set the flag thirty
lines before `view` exists and silently rendered nothing — caught because the
comparison loop found no files, not because anything reported an error.
## 🔴 The exporter dropped nested `.rat` leaf geometry on 45 elements — and it is the title's 1.82 %
The Decoder overturned one of my three eliminations, and it was the one I was
most confident about. I ruled out the `ptloop` sweeps because *"399×180 at
(441, 270), keyframes hold position constant"* — **that is the parent's record.
The geometry is in the leaf, and the exporter never opened it.**
| | parent (what the export shipped) | **leaf `ptloopNN.rat`** |
|---|---|---|
| `ptloop01` | scale (100, 100), rot 0, pos (441, 270) fixed | **scale (100, 600), rot +30°**, x sweeping **639 → 39 → 1521** |
| `ptloop02` | scale (100, 100), rot 0, pos (441, 270) fixed | **scale (100, 800), rot 45°**, x sweeping **1721 → 1111 → 839** |
Two ~1080 and ~1440 px quads leaning opposite ways and sweeping across the
frame, against two 400 px sprites drawn upright and static in the middle. **That
is exactly the signature I measured** — darker centre-left, brighter right,
nearly cancelling — and the Decoder's GPU capture puts their centres at x ≈ 467
and 992, which are the two cells where my signed difference peaked.
`ui_layout`'s own doc comment had said so: *"the rotated quads come from its two
**nested** `.rat` leaf records, which the census never opened."* **Neither did
this exporter.** It opened a leaf in exactly one place — `highlight_name`, for
focus records — and nowhere else.
### It is not two elements. It is 45
| screen | elements with a dropped leaf |
|---|---|
| `main_menu`, `extras`, `press_start` (+ JP twins) | every button — `ptbtn01.rat``ptbtn13.rat`, `ptbtn00.rat` |
| `title`, `extras` | `ptloop01.rat`, `ptloop02.rat` |
| `build_00/01/12/15` | `pgloading_loop1/3/4/5.rat` |
| `title_jp` | `ptlogo_eff2.rat`**two** elements |
⚠️ The buttons are the benign case and `screen.rs` already knew it: *"a BASE
record's leaf duplicates the parent's placement and the two can differ by a unit
(`ptbtn04`: parent y=401, leaf y=402). There the parent wins."* The `ptloop` case
is the opposite — the parent carries **no geometry at all** and the leaf carries
all of it. 🟡 And `title_jp`'s `ptlogo_eff2` is the element `DECISIONS` has
recorded since P1 as the single largest render disagreement in the export, *"the
one drawn element at a scale that is not a whole multiple of 100 %"*. It has a
two-element leaf. That is a lead, not a conclusion.
### Emitted, deliberately not drawn
`Element::leaf` now ships the decoded leaf, and one `read_leaf` closure serves
both it and the focus path — a second copy is how the case would go missing
again.
🔴 **`ScreenView` ignores it**, and that is the honest state. Parent and leaf each
carry their own alpha ramp on a **different span** — parent `0 → 255` over
t=70…238, leaf `255 → 0x80 → 255` over t=150…600 — so **how the two compose is a
decoding question**, and drawing the leaf on a guess would replace a visible
1.82 % gap with an invisible wrong one. `verify-screen` confirms nothing moved:
`title` still max 6 / over3 790, `main_menu` max 4, `title_jp` max 155.
**Additive blending is refuted** — the Decoder tested `T8aD +0x04` bit `0x02`
as an additive selector and *"every measure worsens"*. My blend-mode hypothesis
from last iteration is dead, and the export carries no blend field because **none
has been found**: the per-draw capture records primitive type, index count,
shader hashes, texture bindings and vertex attribute 0, and **no
`RB_BLENDCONTROL`**.
🔵 **And this makes the port's biggest oracle gap the same item as the rotation
question already standing with the human.** `sylpheed-cli screen render`
deliberately does not rotate, which is why *both* renderers show it — the
Decoder measures its own residual as tiles running 38.6 then +33.8 and
cancelling, the same shape as mine. It is a **shared decode gap, not a defect in
my compositor**, and MISSION's *"Needs a human decision — rotation"* now has a
number attached: **1.82 % of the title's pixels, in a signature that can be
recognised.**