diff --git a/crates/sylpheed-export/src/screen.rs b/crates/sylpheed-export/src/screen.rs index 50953594..8147dc93 100644 --- a/crates/sylpheed-export/src/screen.rs +++ b/crates/sylpheed-export/src/screen.rs @@ -254,6 +254,35 @@ pub struct Screen { /// **Geometric, not a decoded neighbour graph** — right for a vertical menu /// and not to be trusted for anything else. pub buttons: Vec, + + /// The instant every element of this screen is settled at, and the width of + /// the interval it was taken from — `[start, end, midpoint]` in keyframe + /// units, absent when the screen has fewer than two keyframe times. + /// + /// 🔴 **A SETTLED SCREEN IS ONE INSTANT, AND THE DISC SAYS WHICH.** Posing + /// each element at its own `rest()` is right for anything that ends the + /// screen settled and **exactly wrong for a transient**: the title's + /// `ptlogo_back2eff1` is a two-frame flash — 0 until t52, 255 at t54–56, 0 + /// again by t58 — so its last *hold* is the flash peak and `rest()` leaves + /// it burning forever. There are five of these, and `rest()` draws all five + /// at once, saturating the light arc. + /// + /// The window is the **longest interval containing no keyframe time**, over + /// this bundle's TOP-LEVEL elements only. Nested leaves are excluded, and + /// that exclusion is what reproduces the Decoder's independently computed + /// `[160, 236]` for the title: including the `ptloop` leaves gives + /// `[269, 540]` instead. + /// + /// ⚠️ **Emitted for every screen; USABLE only where it is wide.** Across this + /// export the widths split with nothing in between — `press_start` 214, + /// `publisher_logo` 190, `developer_logos` 145, `title` 76, then + /// `main_menu` 12, `extras` 12, the loading screens 8 and 4. A 12-unit + /// "settle" on a menu that builds in until t=70 is not a settled pose, it is + /// a gap between staggered ramps. The Decoder's disc-wide census agrees on + /// the shape: only 30 % of bundles have a window ≥ 30 units and 42 % have + /// one under 10, the latter mostly `loop*` fragments meant to be in motion. + #[serde(skip_serializing_if = "Option::is_none")] + pub settle_window: Option<[i64; 3]>, /// What this file does not answer. A consumer needing one of these must get /// it from `authored/`. pub unresolved: Vec<&'static str>, @@ -553,6 +582,7 @@ pub fn export_build( .collect(); buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + let window = settle_window(&elements); let screen = Screen { format: "sylpheed.screen/3", exporter: exporter.to_string(), @@ -569,6 +599,7 @@ pub fn export_build( elements, paint_order: ui_layout::derived_paint_order(&b, bundle), buttons: buttons.into_iter().map(|(_, n)| n).collect(), + settle_window: window, unresolved: vec![ // The time unit is measured off the running game, not on the disc. "keyframe_time_unit", @@ -599,3 +630,25 @@ pub fn export_build( missing, }) } + + +/// The longest interval containing no keyframe time, over TOP-LEVEL elements. +/// +/// See [`Screen::settle_window`] for why this is the settled instant and why +/// nested leaves are excluded. Returns `[start, end, midpoint]`. +fn settle_window(elements: &[Element]) -> Option<[i64; 3]> { + let mut times: Vec = elements + .iter() + .flat_map(|e| e.keyframes.iter().filter_map(|k| k.t.map(i64::from))) + .collect(); + times.sort_unstable(); + times.dedup(); + if times.len() < 2 { + return None; + } + let (a, b) = times + .windows(2) + .map(|w| (w[0], w[1])) + .max_by_key(|(a, b)| b - a)?; + Some([a, b, (a + b) / 2]) +} diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 883d8660..30610493 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -4421,3 +4421,79 @@ focus-record elements in the export, and only 2 have a varying alpha** — both which is precisely the pathology described. The port does not hit it because the plate is drawn through the loop path, and the other **32 are constant-alpha, so `rest()` is safe for them**. Bounded, not hoped. + +## ✅ A settled screen is ONE instant, and it collapsed three residuals at once + +The Decoder's finding, applied: **`rest()` returns each element's last hold +keyframe chosen independently of every other element.** That is right for +anything that ends the screen settled and **exactly wrong for a transient** — +the title's `ptlogo_back2eff1` is a two-frame flash (0 until t52, 255 at t54–56, +0 by t58), so its last hold *is* the flash peak and `rest()` left it burning. +There are five of them, and `rest()` drew all five at once. + +The settled instant is **the longest interval containing no keyframe time**, over +a bundle's **top-level** elements. Reproduced independently here before adopting: +title `[160, 236]`, midpoint **198** — the Decoder's number to the unit. ⚠️ The +top-level restriction is what makes it match: including the `ptloop` leaves gives +`[269, 540]` instead. + +### Against the oracle + +| screen | before | after | +|---|---|---| +| **`title`** | 20.92 RMSE, **1.81 %** | **14.61 RMSE, 0.26 %** | +| **`publisher_logo`** | 9.05, **0.75 %** | **2.17, 0.01 %** | +| **`developer_logos`** | 8.86, **0.33 %** | **3.05, 0.01 %** | +| `main_menu` | 0.08 % | 0.08 % — unchanged, window too narrow | +| `extras` | 0.19 % | 0.19 % — unchanged, window too narrow | + +**Seven times fewer differing pixels on the title, seventy-five times fewer on +the publisher splash**, whose differing region is now a **13×18 box**. This is +the largest correctness gain the port has had, and none of it is mine: it is a +decode, computed from the keyframe table with no reference to any capture. + +### ⚠️ It is applied only where the window is wide, and that bar is not invented + +The widths in this export split with **nothing in between**: `press_start` 214, +`publisher_logo` 190, `developer_logos` 145, `title` 76 — then `main_menu` 12, +`extras` 12, the loading screens 8 and 4. A 12-unit "settle" on a menu that +builds in until t=70 is a gap between staggered ramps, not a settled pose. + +The bar is **30 units**: the Decoder's disc-wide census puts the knee there (30 % +of bundles ≥ 30, 42 % under 10, the latter mostly `loop*` fragments meant to be +in motion), and this export's own screens sit **4× either side of it with nothing +between 12 and 46**. Two independent populations agreeing on where to cut is what +makes it a bar rather than a preference. + +Checked unbroken: the boot pacing is unmoved (`developer_logos@4.26`, +`title@7.91`, developer agrees) and the scripted walk still runs end to end with +focus restored. + +## Their census, and a framing of mine they sharpened + +I reported *"34 focus-record elements in the export, only 2 with a varying +alpha"*. Disc-wide it is **210 varying, 202 with `rest()` at the peak**, across +1 130 focus records — 116 in `GP_DEBRIEFING_PILOTLOG`, 54 in `GP_MOVIE_THEATER`, +30 in `GP_HANGAR_ARSENAL`, 8 in `GP_LEADERBOARD`, and **2 in `GP_TITLE`**. + +**My 2 is right because `GP_TITLE` has 2.** ⚠️ But *"only 2 have a varying alpha"* +reads as a fact about the format and is a fact about one pak — and the pathology +sits in exactly the screens a wider port reaches next. The sentence was true as +measured and false as remembered, which is the failure this corpus keeps +finding, and it was mine this time. + +⚠️ **And they corrected a framing I had:** I called `rest.alpha == peak` "the +pathology". It is worse than that — **a pulsing element has no resting pose at +all.** The question `rest()` answers is *malformed* rather than mis-answered, +because the element's state is a phase, not a value. `pose_at(t)` with `t` inside +the record's own declared cycle is the only well-formed query on one. + +🔴 Worth carrying for whenever this port grows: `GP_LEADERBOARD`'s +`py_ranking_btn01f` swings 255 → 127 → 255 with no two adjacent keyframes equal, +so `rest()` falls through to its longest-dwell rule and returns **244** — neither +peak nor trough. **A glow stuck at its peak is visibly wrong; one stuck at 244 of +a 127–255 range looks entirely plausible, and nothing reports it.** + +✅ And a free second instance of the loop-length decode from a pak neither of us +was looking at: `py_ranking_btn01f`'s ramp ends at **t=90 inside a declared 120** +— 30 units of hold, the same shape as the plate's 105-in-120. diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd index 26980d67..24d22e8c 100644 --- a/port/scripts/screen_view.gd +++ b/port/scripts/screen_view.gd @@ -56,6 +56,27 @@ var exit_ramp_units: float = 24.0 ## copyright notice pulse. Narrowed to focus records it matches exactly one ## distinct element, and a rule justified by n=1 is a special case wearing a ## rule's clothes. +## The one instant a settled screen is posed at, in keyframe units, or -1. +## +## 🔴 Replaces per-element `rest()` while `holding`, where the export gives a +## wide enough window. `rest()` returns each element's last HOLD keyframe chosen +## independently of every other element -- right for anything that ends the +## screen settled, and exactly wrong for a **transient**. The title's +## `ptlogo_back2eff1` is a two-frame flash (0 until t52, 255 at t54-56, 0 by +## t58), so its last hold IS the flash peak and `rest()` leaves it burning. There +## are five of them, and `rest()` draws all five at once. +## +## ⚠️ **Only where the window is wide.** Across this export the widths split with +## nothing in between: `press_start` 214, `publisher_logo` 190, +## `developer_logos` 145, `title` 76 -- then `main_menu` 12, `extras` 12, the +## loading screens 8 and 4. A 12-unit "settle" on a menu that builds in until +## t=70 is a gap between staggered ramps, not a settled pose. The bar is 30 +## units: the Decoder's disc-wide census puts the knee there (30 % of bundles +## have a window >= 30, 42 % have one under 10), and this export's own screens +## sit 4x either side of it with nothing between 12 and 46. +var settle_instant: float = -1.0 +const SETTLE_WINDOW_MIN := 30.0 + var looping_focus: Dictionary = {} ## Element ids whose nested `.rat` leaf the runtime actually draws. @@ -99,6 +120,10 @@ func load_screen(t: ExportTree, name: String) -> bool: ProjectSettings.get_setting("display/window/size/viewport_height")) if Vector2i(int(design[0]), int(design[1])) != viewport: push_warning("screen %s is authored at %sx%s, viewport is %s" % [name, design[0], design[1], viewport]) + var w: Array = screen.get("settle_window", []) + settle_instant = -1.0 + if w.size() == 3 and float(w[1]) - float(w[0]) >= SETTLE_WINDOW_MIN: + settle_instant = float(w[2]) _load_textures() queue_redraw() return true @@ -185,7 +210,10 @@ func pose_at(element: Dictionary, t: float) -> Dictionary: # While holding, stop at the hold: past it the group is ramping out, and a # screen that has arrived and is sitting there is not leaving. if holding: - t = minf(t, settle_units(element)) + # One instant for the whole screen where the disc gives a wide enough + # window; otherwise each element's own hold, which is what this port did + # everywhere until 2026-08-29. + t = settle_instant if settle_instant >= 0.0 else minf(t, settle_units(element)) # The exit. The final keyframe carries no `t` -- the disc has no slot for one # -- so it is given a synthetic time `exit_ramp_units` after the last timed # frame and then interpolated like any other. That keeps one code path: the