From a298a95eb8549d5775f721e5fb4a788d0f7086c2 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 18:00:31 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF --- crates/sylpheed-export/examples/rat_leaf.rs | 49 ++++++++++++ crates/sylpheed-export/src/screen.rs | 89 +++++++++++++++++++++ docs/port/DECISIONS.md | 70 ++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 crates/sylpheed-export/examples/rat_leaf.rs diff --git a/crates/sylpheed-export/examples/rat_leaf.rs b/crates/sylpheed-export/examples/rat_leaf.rs new file mode 100644 index 00000000..cf07e222 --- /dev/null +++ b/crates/sylpheed-export/examples/rat_leaf.rs @@ -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); + } + } +} diff --git a/crates/sylpheed-export/src/screen.rs b/crates/sylpheed-export/src/screen.rs index 92735855..18a9d7ea 100644 --- a/crates/sylpheed-export/src/screen.rs +++ b/crates/sylpheed-export/src/screen.rs @@ -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, + + /// 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, /// 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, + missing: &mut Vec| + -> Result> { + 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]), diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index fcb4bf6e..279ee4da 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -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.**