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:
49
crates/sylpheed-export/examples/rat_leaf.rs
Normal file
49
crates/sylpheed-export/examples/rat_leaf.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
|
||||
@@ -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.**
|
||||
|
||||
Reference in New Issue
Block a user