//! UI screen layout — reassemble a whole UI screen from one RATC bundle. //! //! A UI screen ships as one pak (`GP_TITLE`, `GP_PAUSE_MENU`, `GP_HANGAR_ARSENAL`, //! …). Inside it, each top-level [RATC](crate::ratc) bundle is one //! *(context × language)* **build** of that screen, holding its `.t32` //! sprites and `.rat` layout records side by side. //! //! # The model //! //! The screen is **not** the set of `.rat` records — that was the first reading, //! and it misses every element that has no `.rat` (the `eff*` glow frames, the //! `deli*` dividers, `msg`). The screen is the bundle's own header: //! //! * the **element declaration table** at `0x20` (`0x14` = entry count, 60 bytes //! per entry) lists every element **in back-to-front draw order**, with its //! parent element index and its pivot; //! * the **placement region** that follows gives each element a keyframe group — //! a header of `(element index, keyframe count)` and then 40-byte keyframes of //! scale / tint / X / Y / time. //! //! Three traps, each of which cost a wrong answer before it was measured (see //! `docs/re/structures/ui-rat-layout.md`): //! //! * **X and Y are signed.** An Arsenal window animates in from X = −516; a //! parser reading them as `u32` throws that element away as out of range. //! * The trailing word of a keyframe is a **time**, not a fourth coordinate. //! * **Neither the first nor the last keyframe is where the element sits.** A //! group is an in → hold → out animation, so the resting position is the //! **max-dwell** keyframe — the one with the longest gap to the next keyframe's //! time. See [`Element::rest`]. //! //! Validated against the running game: the tutorial PAUSE menu and the title main //! menu both rebuild pixel-accurately, and the Arsenal's eight category chips //! land within ±2 px (the only free parameter being emulator window chrome). use crate::{ratc, t8ad}; use std::collections::HashMap; 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]]) } /// Offset of the element declaration table within a build bundle. const DECL_TABLE_AT: usize = 0x20; /// Bytes per declaration entry. const DECL_ENTRY: usize = 60; /// Bytes per placement keyframe. const KEYFRAME: usize = 40; /// The design space every screen is authored in. const DESIGN_W: u32 = 1280; const DESIGN_H: u32 = 720; /// One keyframe of an element's placement animation. /// /// The block is 40 bytes: /// /// ```text /// +0 u32 ARGB fade colour — alpha ramps 0x00 → 0x80 → 0xd5 … over the group /// +4 i32 ⚠️ NOT always 0 — see below /// +8 i32 ⚠️ NOT always 0 — see below /// +12 i32 ⚠️ NOT always 0 — see below /// +16 u32 scale X, percent /// +20 u32 scale Y, percent /// +24 u32 tint (0xffff_ffff on every frame seen) /// +28 i32 X ← signed /// +32 i32 Y ← signed /// +36 u32 time /// ``` /// /// ## `+12` is the screen-plane ROTATION, in degrees (2026-08-28) /// /// ✅ **Measured against the framebuffer, not against our own renderer.** The /// title's two light sweeps are the nested leaf records `ptloop01.rat` / /// `ptloop02.rat`, and their keyframe blocks read `+12` = `30` and `-45`. A /// `log_ui_draws` capture of the live title submits those two quads rotated by /// **+30.26°** and **-45.28°** — magnitude *and* sign, on two different values. /// Positive is clockwise in screen space (Y down). /// /// `+4` and `+8` are 🟡 still unexplained: signed, non-zero in ~4.7 % / 4.6 % /// of blocks disc-wide, dominated by `±180` and `±90`. Plausibly rotation about /// the other two axes, but nothing observed turns on them. /// /// ⚠️ **`rotation_deg` is decoded but NOT rendered.** [`crate::ui_layout`]'s /// blitter draws axis-aligned quads only, so `screen render` still paints a /// rotated element upright. See `docs/re/ui-title-build-map.md`. /// /// ⚠️ The earlier note here — *"every element of `GP_TITLE` build 4 has all /// three at zero"* — was **wrong about reach, not about the bytes**: build 4's /// top-level elements do read zero, but the rotated quads come from its two /// **nested** `.rat` leaf records, which the census never opened. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Keyframe { /// The fade colour, ARGB. Its alpha is what ramps an element in. pub fade: u32, /// Screen-plane rotation in **degrees**, clockwise-positive (`+12`). /// Confirmed against a GPU capture; see the type's docs. Not rendered. pub rotation_deg: i32, /// `+4` / `+8` — signed, meaning unexplained. Carried rather than dropped /// so a consumer can see them instead of assuming they are zero. pub unknown_4: i32, pub unknown_8: i32, /// Scale in percent (100 = 1:1). pub scale_x: u32, pub scale_y: u32, /// RGBA tint (`0xffff_ffff` = untinted). pub tint: u32, /// Top-left position in the design space. **Signed** — off-screen animation /// starts are negative. pub x: i32, pub y: i32, /// The time at which this pose is reached. /// /// ✅ Always `Some` since 2026-08-29. A placement group is an 8-byte header /// followed by `frames` records of `{ u32 time; 36-byte pose }`, so the time /// word **precedes** the pose it belongs to. Our block window starts at the /// pose, so pose `k`'s time is the previous stride's `+36` word, and pose /// 0's is the group's lead-in word at `header + 8`. /// /// ⚠️ The old reading took `+36` as *this* pose's time. That left the final /// pose — the end of every fade-out — untimed, and it is where the "a /// group's data stops 4 bytes short of its final block's time slot" note and /// the stray `time = 1869640736` both came from. There is no short group and /// no missing word; the association was off by one. /// See `docs/re/ui-keyframe-record-layout.md`. /// /// `Option` is retained for the `SYLPHEED_KF_TIME_LEGACY=1` escape hatch. pub time: Option, } /// One element of a screen, in draw order. #[derive(Debug, Clone)] pub struct Element { /// Index into [`UiBuild::elements`] — also the key the placement region uses. pub index: usize, /// Declared name, e.g. `pgp_ttrl_eff10.t32` or `pgp_ttrl_btn10.rat`. pub name: String, /// The `.t32` sprite this element draws, if one resolves. pub sprite: Option, /// Parent element index (`+32`), or `None` for `0xffff_ffff`. pub parent: Option, /// Kind flags (`+40`): `0` plain sprite, `1` has a parent, `0x4` a repeated /// instance of a template, `0x3002` a button record. pub kind: u32, /// Declared pivot — for a `.t32` element this is exactly half the decoded /// texture's dimensions (verified 7/7 on the tutorial bundle). pub pivot_x: u32, pub pivot_y: u32, /// The element's placement keyframes, empty if the region declares none. pub keyframes: Vec, /// `opt ` link to another record — the focused state of a button. pub focus_link: Option, /// This element is itself a focused-state record: its name is another /// element's name plus a trailing `f`, and that other element is present. /// The pairing is required — see `mark_focused_states`. pub focused: bool, /// A `loopN` sprite animation rather than a placed element. pub animated: bool, } impl Element { /// Where the element actually rests on screen. /// /// A keyframe group is an in → hold → out animation, and the resting pose is /// **the hold**: the longest run of consecutive keyframes whose pose is /// identical. A single keyframe is its own rest position. /// /// This replaced a longest-*dwell* rule — pick the keyframe with the largest /// gap to the next keyframe's time — which was wrong for a reason worth /// stating, because it is the whole shape of this format. A keyframe is the /// **start of a ramp toward the next one**, not a value held until it. So a /// long gap after keyframe *k* means the screen spends that time *arriving /// at* `k+1`; the settled pose is at the far end of the gap, not the near /// one. The title wordmark zooms in over 5 frames and then holds at /// `(184,193)` at 100 % from t=251 to t=264; the old rule picked the frame /// before the long gap — `(179,186)` at 101 %, still mid-zoom. /// /// Measured, not argued. Edge-correlating the composite against the /// framebuffer capture of the running title screen /// (`docs/re/captures/title-screen-oracle.png`, which is a 1:1 crop, so /// coordinates map directly): /// /// | rule | best correlation | at shift | /// |---|---|---| /// | plateau (this one) | **0.4597** | **(0, 0)** | /// | longest dwell (old) | 0.1511 | (+3, +8) | /// /// The old composite had to be *moved* to line up with the game. See /// `docs/re/structures/ui-resting-pose.md`. /// /// Falls back to the longest-dwell rule when no two adjacent keyframes /// agree — a group that ramps through every frame and never holds. /// The element's pose at keyframe time `t`, linearly interpolated. /// /// `rest()` returns the last HOLD keyframe **of one element, chosen /// independently of every other element**. That is the wrong pose twice over: /// /// * for anything still moving — the title's light sweeps hold at `x = 1521`, /// off the right edge, so a resting composite deletes them rather than /// settling them; /// * 🔴 and for anything **transient**. `ptlogo_back2eff1` is a two-frame /// flash (`a=0` until t52, `255` at t54–56, `0` again by t58); its last /// hold *is* the flash peak, so `rest()` leaves it burning forever. Five /// such flashes stack on the title and blow the light arc out to pure /// white — see `docs/re/structures/ui-settle-time.md`. /// /// A settled screen is one INSTANT that every element is posed at, which is /// what [`UiBuild::settle_time`] computes and `ComposeOptions::at` applies. /// /// The ramp is linear (`docs/re/ui-keyframe-time-unit.md`), and a group /// **holds** at its last keyframe rather than looping, so `t` past the end /// clamps. pub fn pose_at(&self, t: u32) -> Option { let ks = &self.keyframes; if ks.is_empty() { return None; } let timed: Vec<(u32, &Keyframe)> = ks.iter().filter_map(|k| k.time.map(|tt| (tt, k))).collect(); if timed.is_empty() { return Some(ks[ks.len() - 1]); } if t <= timed[0].0 { return Some(*timed[0].1); } if t >= timed[timed.len() - 1].0 { return Some(*timed[timed.len() - 1].1); } for w in timed.windows(2) { let ((t0, a), (t1, b)) = (w[0], w[1]); if t >= t0 && t <= t1 { if t1 == t0 { return Some(*b); } let f = (t - t0) as f64 / (t1 - t0) as f64; let li = |x: i32, y: i32| x + ((y - x) as f64 * f).round() as i32; let lu = |x: u32, y: u32| (x as f64 + (y as f64 - x as f64) * f).round() as u32; // ARGB / RGBA words interpolate per BYTE, not as integers. let lc = |x: u32, y: u32| { let mut o = 0u32; for sh in [24, 16, 8, 0] { let (cx, cy) = ((x >> sh) & 0xff, (y >> sh) & 0xff); o |= (lu(cx, cy) & 0xff) << sh; } o }; return Some(Keyframe { fade: lc(a.fade, b.fade), rotation_deg: li(a.rotation_deg, b.rotation_deg), unknown_4: li(a.unknown_4, b.unknown_4), unknown_8: li(a.unknown_8, b.unknown_8), scale_x: lu(a.scale_x, b.scale_x), scale_y: lu(a.scale_y, b.scale_y), tint: lc(a.tint, b.tint), x: li(a.x, b.x), y: li(a.y, b.y), time: Some(t), }); } } Some(*timed[timed.len() - 1].1) } pub fn rest(&self) -> Option<&Keyframe> { // `lastall`: the LAST keyframe for every element, bypassing the plateau // rule entirely. // // ⚠️ ITS STATED PURPOSE IS RETIRED (corrected 2026-08-30). This comment // read: "This is what the shifted time reading predicts — under it the // final pose is reached at a definite time and nothing follows, so // 'rest' needs no heuristic. Testing it against the captures is an // independent check on that reading." The shifted reading was **refuted** // by the record-layout fix above, so this override no longer checks // anything about it. It survives only as a plain "take the last // keyframe" diagnostic, alongside the documented `last` and `maxalpha` // (see `docs/re/structures/ui-resting-pose.md`). if std::env::var("SYLPHEED_REST_RULE").as_deref() == Ok("lastall") { return self.keyframes.last(); } if let Some(k) = self.rest_plateau() { return Some(k); } match self.keyframes.len() { 0 => None, 1 => self.keyframes.first(), n => { // ⚠️ EXPERIMENT GATE, default off. Both published alternatives to // the longest-dwell fallback died by argument rather than by // measurement, and `compose` can score a rule against the live // captures — so they are reachable here to be tested. // SYLPHEED_REST_RULE=last -> the final keyframe // SYLPHEED_REST_RULE=maxalpha -> the most opaque keyframe match std::env::var("SYLPHEED_REST_RULE").as_deref() { Ok("last") => return self.keyframes.last(), Ok("maxalpha") => { let mut best = (0usize, 0u32); for (k, f) in self.keyframes.iter().enumerate() { let a = (f.fade >> 24) & 0xff; if a >= best.1 { best = (k, a); } } return self.keyframes.get(best.0); } _ => {} } let mut best = (0usize, 0u32); for k in 0..n - 1 { let (Some(t0), Some(t1)) = (self.keyframes[k].time, self.keyframes[k + 1].time) else { // ⚠️ PRE-FIX COMMENT, corrected 2026-08-30. This read // "the last frame carries no time", which was the rule // BEFORE the record-layout fix directly above. Post-fix // every pose is timed — measured at **0 untimed of // 24 811 keyframes** across 965 builds — so this branch // is unreachable on this disc. Kept as a guard because // `time` is still `Option` and a malformed group // could produce `None`; it is no longer a description of // the format. continue; }; let dwell = t1.saturating_sub(t0); // `>=`, not `>`: on a tie take the LATER frame. A group is // in → hold → out, so when two gaps are equal the second is // the settled pose — `pgpmsg` holds 5 ticks at y=645 on the // way in and 5 more at y=605 where it stays, and `>` picks // the fly-through. if dwell >= best.1 { best = (k, dwell); } } self.keyframes.get(best.0) } } } /// The hold: the longest run of consecutive keyframes whose pose is /// identical. `None` when no two adjacent frames agree. /// /// **A run that ends on the last keyframe is the exit, not the hold.** A /// group carries the screen's entry animation *and* its exit, so the shape /// is pre-roll → ramp in → **hold** → ramp out → post-roll, and the last two /// of those are often plateaus themselves. The pause menu's `pgptitle.rat` /// has three runs of two — invisible, visible, invisible — and taking the /// last one erased the word PAUSE, which the capture of the running game /// plainly shows (`captures/ui-layout/pause-tutorial-real-vs-rebuilt.png`). /// So trailing runs are excluded, and only fall back to if there is nothing /// else. /// /// Among the runs that remain, longest wins and a **later** run breaks a tie: /// the pre-roll comes first and the hold after it. fn rest_plateau(&self) -> Option<&Keyframe> { let n = self.keyframes.len(); if n < 2 { return None; } let same = |a: &Keyframe, b: &Keyframe| { a.fade == b.fade && a.scale_x == b.scale_x && a.scale_y == b.scale_y && a.tint == b.tint && a.x == b.x && a.y == b.y }; // (start, len) of the best run that does not end on the last keyframe, // and separately of the best run overall. let (mut best, mut best_len) = (None, 0usize); let (mut any, mut any_len) = (None, 0usize); let mut i = 0usize; while i < n { let mut j = i; while j + 1 < n && same(&self.keyframes[j], &self.keyframes[j + 1]) { j += 1; } let len = j - i + 1; if len >= 2 { if len >= any_len { any = Some(i); any_len = len; } // A trailing run is normally the EXIT and is excluded (see the // doc comment) — but not always, and the tell is its ALPHA. // // An exit fades the element out, so its final keyframe is // transparent: `pgptitle.rat`'s trailing run is `0x00ffffff`, and // taking it erases the word PAUSE. An element with **no exit** // ends on its hold, which is whatever it looks like on screen — // opaque: `ptframe1`/`ptframe2` on the title main menu end on a // run of three at `0xffffffff`, and excluding it dropped the // bright circuit bracket the capture plainly shows. // // So a trailing run is the hold exactly when it is **visible**. // (The port agent's report proposed "the final untimed keyframe // has the same pose as the last timed one"; that is true of // `pgptitle` too and would erase PAUSE, so it is the alpha and // not the pose-equality that separates the two.) let trailing_is_the_hold = j == n - 1 && (self.keyframes[i].fade >> 24) != 0; if (j != n - 1 || trailing_is_the_hold) && len >= best_len { best = Some(i); best_len = len; } } i = j + 1; } best.or(any).map(|k| &self.keyframes[k]) } } /// A parsed UI build: one screen layout (one context × language). pub struct UiBuild { /// Design-space dimensions, normally 1280×720. pub design_w: u32, pub design_h: u32, /// Every element, in the declaration table's back-to-front draw order. pub elements: Vec, /// Sprite name → (offset, size) of its `T8aD` child within the bundle. pub sprites: HashMap, /// 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, /// A guessed context from the sprite naming (e.g. `"tutorial"`), if any. pub context_hint: Option, /// True when the declaration table was unusable and the build was recovered /// by scanning `.rat` records instead — placements are then per-record and /// `.rat`-less elements are missing. pub from_fallback: bool, /// Element indices in the order the placement region stores their keyframe /// groups. Each group names its element explicitly, so this *could* be a /// second, independent ordering — and therefore a candidate for the paint /// order the title screen needs. It is not: it equals the declaration order /// on every build on the disc. Kept, with /// `placement_region_order_is_never_a_second_ordering` guarding it, so the /// refutation stays checkable rather than remembered. pub placement_order: Vec, } /// Whether `bundle` is a RATC screen build (has at least one `.rat` layout child). pub fn is_build(bundle: &[u8]) -> bool { ratc::is_ratc(bundle) && ratc::parse(bundle).is_some_and(|kids| { kids.iter() .any(|c| c.name.to_ascii_lowercase().ends_with(".rat")) }) } /// Whether `compose` can draw this bundle: it parses a real declaration table /// and at least one element resolves to a `T8aD` sprite the bundle carries. /// /// Strictly wider than [`is_build`], which additionally requires a `.rat` layout /// child. Measured on the disc: 2 859 RATC bundles, 965 pass `is_build`, 2 751 /// pass this — and no bundle passes `is_build` without passing this, so it is a /// superset and not a different rule. /// /// The extra 1 786 are **not** all screens. Most are two-element fragments — a /// button beside its glow (`pvbtnnew.t32` + `pvbtnneweff.t32`) — which is why /// `is_build` stays the default enumeration and this is opt-in. What it does /// unlock is the developer-logo splash, which declares its sprites directly, /// has no `.rat` child, and was therefore impossible to render at all despite /// being one of only two screens whose paint order has been measured off the /// running game. pub fn is_composable(bundle: &[u8]) -> bool { parse_build(bundle).is_some_and(|b| { !b.from_fallback && b.elements .iter() .any(|e| e.sprite.as_ref().is_some_and(|s| b.sprites.contains_key(s))) }) } /// Trim a NUL-padded fixed-width name field. fn fixed_name(b: &[u8]) -> String { let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); String::from_utf8_lossy(&b[..end]).trim().to_string() } /// The `opt ` link inside a `.rat` record: a tag, a length, then the name. fn opt_link(rec: &[u8]) -> Option { let pos = rec.windows(4).position(|w| w == b"opt ")?; let len = be32(rec, pos + 4) as usize; if len == 0 || len > 64 || pos + 8 + len > rec.len() { return None; } let s = fixed_name(&rec[pos + 8..pos + 8 + len]); (!s.is_empty()).then_some(s) } /// A RATC record's animation **loop length** in keyframe units — its `+0x08`. /// /// Works at either level: a nested `.rat` leaf is itself a RATC bundle with the /// same header shape as the one containing it, so this reads a whole screen /// build's length and a single record's length through one path. /// /// **Why it is public.** The loop length is not the largest keyframe time — /// `ptbtn00f.rat`, the `PRESS Ⓐ` plate glow, declares **120** while its last /// keyframe is at **105**, and that 15-unit slack is the plate holding dark /// between cycles. A consumer that infers the period from the keyframes gets /// 105 (1.750 s) against a real pulse measured four times at 2.12–2.34 s. /// Decoded disc-wide: 1 781 records, **0** violations of /// `+0x08 >= largest keyframe time` — see /// `docs/re/structures/ui-record-loop-length.md`. /// /// Returns `None` for anything that is not a RATC record, so it is safe to call /// on an arbitrary slice; callers do not need their own magic guard. pub fn loop_length_units(rec: &[u8]) -> Option { if rec.len() < 0x0c || rec[0..4] != *b"RATC" { return None; } Some(be32(rec, 0x08)) } /// The sprite a `.rat` record places: a NUL-terminated name at `0x20`. /// /// The field is **not** 16 bytes. Capping it there truncates every longer name — /// `pgp_ttrl_title.t32` becomes `pgp_ttrl_title.t`, which then resolves against /// nothing and silently drops the element from the composite. It runs up to the /// pivot words at `0x50`. fn record_sprite(rec: &[u8]) -> Option { if rec.len() < 0x30 || rec[0..4] != *b"RATC" { return None; } let end = 0x50.min(rec.len()); let s = fixed_name(&rec[0x20..end]); (!s.is_empty()).then_some(s) } /// Read the declaration table. `None` when it does not look like one. fn parse_decls(bundle: &[u8]) -> Option> { let count = be32(bundle, 0x14) as usize; // Guard: this parser runs over ~2 900 bundles, most of which are not screen // builds. A count that cannot fit is a mis-read, not a short table. if count == 0 || count > 4096 || DECL_TABLE_AT + count * DECL_ENTRY > bundle.len() { return None; } let mut elements = Vec::with_capacity(count); for i in 0..count { let e = &bundle[DECL_TABLE_AT + i * DECL_ENTRY..DECL_TABLE_AT + (i + 1) * DECL_ENTRY]; let name = fixed_name(&e[..28]); // Every real declaration names something; a table of blanks means we are // reading past the header of a bundle that has no declaration table. if name.is_empty() { return None; } let parent = match be32(e, 32) { u32::MAX => None, p if (p as usize) < count => Some(p as usize), _ => None, }; let lname = name.to_ascii_lowercase(); elements.push(Element { index: i, name, sprite: None, parent, kind: be32(e, 40), pivot_x: be32(e, 48), pivot_y: be32(e, 52), keyframes: Vec::new(), focus_link: None, focused: false, // needs the whole table — see below animated: lname.contains("loop"), }); } mark_focused_states(&mut elements); Some(elements) } /// Flag the elements that are a **focused variant of another element present in /// the same build** — `pgmenu_btn00f.t32` next to `pgmenu_btn00.t32`. /// /// The pairing is not decoration, it is the whole rule. A trailing `f` alone /// flags **2 458** elements on the disc and only **54** of them have a base to /// be the focused version of; all 54 are `pgmenu_btnNNf.t32`. The other 2 404, /// spread over 864 bundles, are `_eff` glow layers whose names merely end in the /// same letter — `pb_name_eff.t32` (1 122 elements), `pbmwindow_eff.t32`, /// `pghud_range_eff.t32`, `palogo_gamearts_eff.t32`. `compose` drops focused /// records by default, so the unpaired rule was deleting a glow layer from /// nearly every screen that has one. /// /// The draw capture settles it independently: on the developer-logo splash the /// three `_eff` glows carry layer key `0xa100` and are **painted**, before their /// logos (`docs/re/structures/ui-paint-order-key.md`). They are not focus states. fn mark_focused_states(elements: &mut [Element]) { let names: std::collections::HashSet = elements .iter() .map(|e| e.name.to_ascii_lowercase()) .collect(); for el in elements.iter_mut() { let l = el.name.to_ascii_lowercase(); if !(l.ends_with("f.rat") || l.ends_with("f.t32")) { continue; } let Some((stem, ext)) = l.rsplit_once('.') else { continue; }; el.focused = names.contains(&format!("{}.{}", &stem[..stem.len() - 1], ext)); } } /// Read the placement region that follows the declaration table, filling in each /// element's keyframe group. fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec { // Escape hatch for the pre-2026-08-29 reading, which mis-associated every // keyframe time by one slot and could not time a group's final pose at all. // See `docs/re/ui-keyframe-record-layout.md`. let legacy_times = std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1"); let count = elements.len(); let mut order = Vec::with_capacity(count); let mut pos = DECL_TABLE_AT + count * DECL_ENTRY; for _ in 0..count { if pos + 8 > bundle.len() { break; } let idx = be32(bundle, pos) as usize; let frames = be32(bundle, pos + 4) as usize; if idx >= count || frames == 0 || frames > 4096 { break; } // The region is `frames` records of 40 bytes, each `{ u32 time; 36-byte // pose }`, after an 8-byte header — so the word at `pos + 8` is the // FIRST pose's time, and each 40-byte stride's `+36` word is the time of // the pose that follows it. Our block window is offset 4 bytes into the // record (it starts at the pose), which is why the pose field offsets // below are right while the times were off by one. let first_time = be32(bundle, pos + 8); let first = pos + 12; // The region is packed so that the next group's header sits 4 bytes // inside the last block — i.e. the group owns `frames * 40 - 4` bytes of // block data, and the final block's time field is not its own. let group_end = first + frames * KEYFRAME - 4; let mut group = Vec::with_capacity(frames); for k in 0..frames { let blk = first + k * KEYFRAME; if blk + 36 > bundle.len() || blk + 36 > group_end { break; } group.push(Keyframe { fade: be32(bundle, blk), rotation_deg: be32(bundle, blk + 12) as i32, unknown_4: be32(bundle, blk + 4) as i32, unknown_8: be32(bundle, blk + 8) as i32, scale_x: be32(bundle, blk + 16), scale_y: be32(bundle, blk + 20), tint: be32(bundle, blk + 24), x: be32(bundle, blk + 28) as i32, y: be32(bundle, blk + 32) as i32, // Pose `k`'s time is the word that PRECEDES it: the group's // lead-in word for `k == 0`, and the previous stride's `+36` // otherwise. Every pose is timed; nothing is missing and nothing // is special-cased. Checked disc-wide — see // `docs/re/ui-keyframe-record-layout.md`. time: if legacy_times { (blk + 40 <= group_end).then(|| be32(bundle, blk + 36)) } else if k == 0 { Some(first_time) } else { Some(be32(bundle, blk - KEYFRAME + 36)) }, }); } elements[idx].keyframes = group; order.push(idx); pos = group_end; } order } /// Parse a build bundle into its elements and sprite table. pub fn parse_build(bundle: &[u8]) -> Option { let kids = ratc::parse(bundle)?; let mut sprites = HashMap::new(); let mut records: HashMap = HashMap::new(); for c in &kids { let end = (c.offset + c.size).min(bundle.len()); if c.kind == "T8aD" { sprites.insert(c.name.clone(), (c.offset, end - c.offset)); } else if c.name.to_ascii_lowercase().ends_with(".rat") { records.insert(c.name.clone(), (c.offset, end - c.offset)); } } let (mut elements, from_fallback, placement_order) = match parse_decls(bundle) { Some(mut els) => { let order = parse_placements(bundle, &mut els); (els, false, order) } // No usable declaration table: recover what the `.rat` records alone can // say. Elements without a record (eff*/deli*/msg) are then missing, so // callers are told via `from_fallback`. None => { let mut els = fallback_elements(bundle, &records); mark_focused_states(&mut els); let order = (0..els.len()).collect(); (els, true, order) } }; // Resolve each element to the sprite it draws, and pick up its focus link. for el in &mut elements { if let Some(&(off, size)) = records.get(&el.name) { let rec = &bundle[off..off + size]; el.sprite = record_sprite(rec); el.focus_link = opt_link(rec); } else if sprites.contains_key(&el.name) { el.sprite = Some(el.name.clone()); } } if elements.is_empty() { return None; } // The design space is stated by any `.rat` record; every screen seen is // 1280×720, which is also the fallback. let (design_w, design_h) = records .values() .find_map(|&(off, size)| { let r = &bundle[off..off + size]; let (w, h) = (be32(r, 0x18), be32(r, 0x1c)); (w > 0 && h > 0 && w <= 8192 && h <= 8192).then_some((w, h)) }) .unwrap_or((DESIGN_W, DESIGN_H)); let context_hint = sprites .keys() .find_map(|n| n.contains("ttrl").then(|| "tutorial".to_string())); Some(UiBuild { design_w, design_h, elements, sprites, records, context_hint, from_fallback, placement_order, }) } /// Recover elements from the `.rat` records when the declaration table is /// unreadable: each record becomes an element with a single keyframe. fn fallback_elements(bundle: &[u8], records: &HashMap) -> Vec { let mut names: Vec<&String> = records.keys().collect(); names.sort_by_key(|n| records[*n].0); // file order stands in for draw order let mut out = Vec::new(); for name in names { let (off, size) = records[name]; let rec = &bundle[off..off + size]; let Some((tint, x, y)) = scan_placement_block(rec) else { continue; }; let lname = name.to_ascii_lowercase(); out.push(Element { index: out.len(), name: name.clone(), sprite: record_sprite(rec), parent: None, kind: 0, pivot_x: be32(rec, 0x50), pivot_y: be32(rec, 0x54), keyframes: vec![Keyframe { fade: 0xffff_ffff, rotation_deg: 0, unknown_4: 0, unknown_8: 0, scale_x: 100, scale_y: 100, tint, x, y, time: Some(0), }], focus_link: opt_link(rec), focused: false, // set by `mark_focused_states` once the set is known animated: lname.contains("loop"), }); } for (i, el) in out.iter_mut().enumerate() { el.index = i; } out } /// Find a `[scaleX=100, scaleY=100, tint, X, Y]` run inside a `.rat` record. /// Records are tag-driven and variable-length, so this anchor beats a fixed /// offset — it is only used on the fallback path. fn scan_placement_block(rec: &[u8]) -> Option<(u32, i32, i32)> { let mut o = 0x58; while o + 20 <= rec.len() { if be32(rec, o) == 100 && be32(rec, o + 4) == 100 { let (tint, x, y) = ( be32(rec, o + 8), be32(rec, o + 12) as i32, be32(rec, o + 16) as i32, ); if x > -4096 && x < 8192 && y > -4096 && y < 8192 { return Some((tint, x, y)); } } o += 4; } None } /// A composited screen image ready to display. pub struct ComposedScreen { pub width: u32, pub height: u32, /// Row-major RGBA8. pub rgba: Vec, /// Indices of the elements actually drawn, in draw order. pub drawn: Vec, /// Elements skipped because their sprite could not be resolved or decoded. pub missing: Vec, } /// What to include when compositing. #[derive(Debug, Clone, Copy)] pub struct ComposeOptions { /// Draw `*f` focused-state records over their base elements. pub include_focus: bool, /// Draw `loop*` sprite animations. pub include_animated: bool, /// Colour the canvas starts at. The default dim slate stands in for the /// PRMD dim-quad plus the live 3D scene behind an in-mission screen; a /// screen that carries its own full-screen background wants **black**, /// which is what the game composites over — comparing a composite against a /// framebuffer capture needs the backdrop to match, or every partially /// transparent pixel is off by the backdrop. pub backdrop: [u8; 4], /// Draw the untextured `.prm` primitives — the fade / dim / flash quads. /// /// **Off by default, and not because they are undecoded.** They are decoded /// (`docs/re/structures/ui-prm-primitives.md`) and drawing them is right on the /// title screen, whose paint order was read off the running game. What is /// unsolved is *where they paint on every other screen*: a primitive has no /// `T8aD` header, so it has no layer key, and the derived order forces the /// keyless to the end. That is wrong — the two measured screens show a /// primitive painting **first** (the splash's black backdrop) and another /// painting **last** (the title's fade-out), so no single default is right. /// Left on with the derived order, an opaque black quad sorts last and wipes /// 32 of `GP_DIALOG`'s builds. pub include_primitives: bool, /// Pose every element at this keyframe time instead of at its resting pose. /// /// `None` keeps the settled composite, which is what every existing caller /// wants. A capture taken mid-animation needs the render posed at the same /// instant — see [`Element::pose_at`]. pub at: Option, } impl Default for ComposeOptions { fn default() -> Self { Self { include_focus: false, include_animated: false, backdrop: [14, 14, 20, 255], include_primitives: false, at: None, } } } /// Composite a build into its screen image, using the default options. pub fn compose_build(bundle: &[u8], include_focus: bool) -> Option { let build = parse_build(bundle)?; Some(compose( &build, bundle, ComposeOptions { include_focus, ..Default::default() }, None, )) } /// Composite a parsed build. /// /// `visible`, when given, selects elements by index — the viewer uses it for /// per-element toggles. Elements are drawn in declaration order, which is the /// screen's own back-to-front order. /// The layer key a sprite carries, from the word at `+0x08` of its `T8aD` /// header — the field the paint order sorts by. /// /// Measured, not guessed: read in the order the game actually paints them, this /// word is non-decreasing on both screens whose order has been captured, with no /// inversion (`docs/re/structures/ui-paint-order-key.md`). On the developer-logo /// splash it explains the whole permutation — the `_eff` glows carry `0xa100` /// and their base logos `0xa110`, so the glows paint first even though the /// declaration table interleaves them. pub fn sprite_layer_key(build: &UiBuild, bundle: &[u8], el: &Element) -> Option { let sprite = el.sprite.as_ref()?; let &(off, size) = build.sprites.get(sprite)?; if size < 0x0c { return None; } Some(be32(bundle, off + 8)) } /// The raw `T8aD` header word at `+0x04` for the sprite this element draws. /// /// Exposed because it carries the **blend mode**, and until now nothing on /// [`Element`] reached it: the struct surfaces `kind` (`+40` of the declaration /// entry), `parent`, the pivot, the keyframes and `focus_link`, all of which come /// from the RATC record rather than from the sprite's own header. /// /// ⚠️ `kind` is **not** this field and does not stand in for it. Tested by the /// port over four screens against a measured additive map: `kind & 0x2` is /// *anti*-correlated — 0 of 14 mapped elements set it, 9 unmapped ones do. They /// are different words in different structures. pub fn sprite_header_word_04(build: &UiBuild, bundle: &[u8], el: &Element) -> Option { let sprite = el.sprite.as_ref()?; header_word_04_by_name(build, bundle, sprite) } /// The same word, addressed by **sprite name** rather than by [`Element`]. /// /// Needed because not every sprite the game blends belongs to a top-level /// element. A button's **focused variant** is reached through `focus_link` /// (`opt `), so `ptbtn00f.t32` is in `build.sprites` while no element carries it /// as `sprite` — and `ptbtn00f` is precisely the sharp case in the oracle: the /// `PRESS Ⓐ` plate and its own highlight sit on one screen in one draw order and /// differ in exactly this bit, one drawn alpha-over and the other additive. /// An accessor that could only reach declared elements would miss it. pub fn header_word_04_by_name(build: &UiBuild, bundle: &[u8], sprite: &str) -> Option { let &(off, size) = build.sprites.get(sprite)?; if size < 0x08 { return None; } Some(be32(bundle, off + 4)) } /// [`sprite_blend_additive`] addressed by sprite name — see /// [`header_word_04_by_name`] for why both spellings exist. pub fn blend_additive_by_name(build: &UiBuild, bundle: &[u8], sprite: &str) -> Option { header_word_04_by_name(build, bundle, sprite).map(|w| w & 0x02 != 0) } /// Whether the game draws this element **additive** — `T8aD +0x04` bit `0x02`. /// /// `Some(true)` ⇒ `RB_BLENDCONTROL0 = 0x01010101`, src `ONE` / dst `ONE`. /// `Some(false)` ⇒ `0x07010701`, src `ONE` / dst `1 − SRC_ALPHA`, which with this /// game's premultiplying pixel shader is ordinary source-over. /// `None` ⇒ the element resolves to no `T8aD` the bundle carries (a `.prm` /// primitive has no header, so it has no blend bit either). /// /// **Decoded**, `docs/re/structures/ui-blend-mode-decoded.md`: 35 elements over /// three screens against `RB_BLENDCONTROL0` read out of the guest command stream, /// **zero errors both ways**, with every bit of the first twelve header words /// tested as a rival and exactly one separating them. An out-of-sample prediction /// on `GP_OPTIONS` named three additive quads of sixteen before the capture and /// found exactly three. /// /// This exists so a consumer can **derive** the blend per element instead of /// transcribing a map keyed by screen name. A name-keyed map cannot answer for a /// screen nobody has driven to — the Japanese menus being the case that raised /// it — while the bit is on the disc for every screen at once. pub fn sprite_blend_additive(build: &UiBuild, bundle: &[u8], el: &Element) -> Option { sprite_header_word_04(build, bundle, el).map(|w| w & 0x02 != 0) } /// Layer keys for elements the bundle gives no key for, **measured** from the /// running game rather than read from a file. /// /// A `.prm` primitive has no `T8aD` header and therefore no layer key, and the /// bundle carries no data for it at all — the menu build declares /// `pteff00.prm`, `pteff02.prm` and `pteff05.t32` and has **zero** RATC children /// for any of them. The four unread words of the 60-byte declaration entry are /// constant across every element (`+28`=0, `+36`=0xffffffff, `+56`=0, and `+44` /// is a button ordinal 1–5), so the key is not there either. It comes from the /// game's own code. /// /// But it is *consistent*, which is what makes a table honest rather than a /// fudge. Bracketing each unkeyed element by its measured neighbours' keys: /// /// | element | title screen | main menu | splash | /// |---|---|---|---| /// | `pteff05.t32` | — | (0x8010, 0x8040) | — | /// | `pteff04.t32` | (0x8010, 0x8040) | — | — | /// | `pteff02.prm` | (0x8010, 0x8040) | (0x8010, 0x8040) | — | /// | `pteff00.prm` | (0x8100, end) | (0x8110, end) | — | /// | `palogo_eff0.prm` | — | — | (start, 0xa100) | /// /// `pteff02.prm` lands in the **same interval on both** screens it appears on; /// the fade quad is past the maximum on both; the splash backdrop is below the /// minimum. So these behave exactly like fixed per-name layers. /// /// Only names whose position has actually been measured are listed. An unlisted /// primitive keeps `u32::MAX` and sorts last, which is where `compose` leaves it /// — and why `ComposeOptions::include_primitives` is still off by default. pub fn implied_layer_key(name: &str) -> Option { Some(match name { // Full-screen backdrops, measured painting FIRST on their screens. // `pfbase.tbm` is not a `.prm` at all — a `.tbm` with no sprite and no // key — but it behaves identically: the save/load screen paints it and // its `kind = 0x3004` instance before everything else. "palogo_eff0.prm" | "pfbase.tbm" => 0x0000, "pteff04.t32" | "pteff05.t32" => 0x8020, "pteff02.prm" => 0x8030, "pteff00.prm" => 0xffff_fffe, _ => return None, }) } /// The paint order derived from the bundle: a stable sort of the elements by /// their sprite's layer key. /// /// Elements with no sprite (the `.prm` primitives) have no key and keep their /// declaration position among themselves; `compose` skips them anyway, so where /// they land does not affect a composite. Ties keep declaration order — the game /// breaks them some other way, which is unexplained and looks harmless because /// tied elements are same-layer. /// Is this element an opaque full-screen quad that **must** sort below everything? /// /// A keyless primitive has no layer key and the game's own code decides where it /// paints ([`implied_layer_key`] records the names measured in the running game). /// For one whole class of them the file settles it without a measurement: an /// element that covers the screen and is **fully opaque** at some instant cannot /// paint above anything visible at that instant, or the screen would be blank. /// Where the elements visible during its opaque span are *all* of them, its /// position is forced to first. /// /// Two controls, both measured in the running game and both reproduced by this /// rule rather than assumed by it: /// /// * `palogo_eff0.prm` is measured painting **first** — and comes out forced /// first (opaque for 211 instants, below 6 of 6). A rule keyed on the *name* /// would get this wrong: it is named like an overlay. /// * `pteff00.prm` is measured painting **last** — and is forced below only 3 of /// 23 elements on the title, because it is opaque for 2 instants at the screen's /// entry and exit, so the rule permits it on top. /// /// Disc-wide: 80 instances forced first, 50 constrained but not forced, 0 /// unconstrained. It also explains the 36 dialog builds that composite to one /// colour — `pzeff00.prm` is forced first in 32 of 32 instances. /// /// ⚠️ Assumes straight alpha-over blending. Blend mode is an open question in /// `docs/re/structures/ui-prm-primitives.md`; an *additive* quad at alpha 255 /// would not occlude, and this rule would then be placing it wrongly. pub fn forced_backdrop(build: &UiBuild, el: &Element) -> bool { // 🔴 Untextured primitives only. A `.t32` sprite's ELEMENT alpha being 255 // says nothing about whether its texture covers the screen — most of it may // be transparent, so it occludes nothing. Applied without this guard the // rule claims 22 textured sprites must sort first, against their own layer // keys: `pneff01.t32` (key 0xd850, paints #8 of 13) and `pbfriendly.t32` // (key 0x9230, #17 of 49). Those disagreements are the rule being wrong, // not the keys. if el.sprite.is_some() { return false; } // The element must have a declared size at all; coverage itself is tested // per instant below, against the SCALED size. if el.pivot_x == 0 || el.pivot_y == 0 { return false; } let tmax = build .elements .iter() .flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)) .max() .unwrap_or(0); if tmax == 0 { return false; } // An instant counts only where the element is BOTH fully opaque AND actually // covering — tested together, because both animate on the same ramp. // // ⚠️ Coverage is the SCALED size, not the declared one, and the test must be // two-sided. A quad scaled down does not cover what its pivot suggests — // `pbafc.prm` declares 844x600 and draws ~17x18 at 2 %/3 %. And a quad scaled // *up* can cover from a smaller declared size, so rejecting on the declared // size would replace one error with its mirror. Checked before adopting: // across 921 keyless elements, **0** cover the screen only via scale, so the // mirror case does not occur on this disc — the per-instant test is in // because it does not need that to stay true. let (dw, dh) = ((el.pivot_x * 2) as u64, (el.pivot_y * 2) as u64); let opaque: Vec = (0..=tmax) .filter(|&t| { el.pose_at(t).is_some_and(|k| { k.fade >> 24 == 255 && dw * k.scale_x as u64 / 100 >= build.design_w as u64 && dh * k.scale_y as u64 / 100 >= build.design_h as u64 }) }) .collect(); if opaque.is_empty() { return false; } let mut others = 0usize; let mut occluded = 0usize; for o in &build.elements { if o.index == el.index { continue; } others += 1; if opaque .iter() .any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0) { occluded += 1; } } others > 0 && occluded == others } pub fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec { let mut idx: Vec = (0..build.elements.len()).collect(); idx.sort_by_key(|&i| { let el = &build.elements[i]; ( sprite_layer_key(build, bundle, el) .or_else(|| implied_layer_key(&el.name)) // An opaque full-screen quad that covers every other element // while it is opaque cannot be on top — see `forced_backdrop`. .or_else(|| forced_backdrop(build, el).then_some(0)) .unwrap_or(u32::MAX), i, ) }); idx } /// Paint orders **measured from the running game**, not derived from the file. /// /// The bundle does not say what order its elements paint in — the game builds a /// second, reordered child list at load time and paints that, and no decoded /// field reproduces it (`docs/re/structures/ui-screen-runtime.md` records the /// search, including everything refuted). Until the ordering is derived, the /// honest thing is to use the orders that HAVE been read off the running game /// and to fall back to declaration order everywhere else — which is what this /// table does. Keying is by element names, because that identifies a build /// across paks and language variants without a pak hash. /// /// Each entry is the paint order as declaration indices, first painted first. fn measured_paint_order(build: &UiBuild) -> Option> { let names: Vec<&str> = build.elements.iter().map(|e| e.name.as_str()).collect(); // GP_TITLE.pak entry 4 (a60fcb85) — the title screen the game actually runs. const TITLE: [&str; 24] = [ "ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32", "ptlogo2.t32", "pteff01.t32", "ptlogo_tm.t32", "pteff00.prm", "ptbase2.t32", "pteff04.t32", "ptloop01.rat", "ptloop02.rat", "pteff02.prm", "ptlogo_back2eff1.t32", "ptlogo_back2eff2.t32", "ptlogo_back2eff3.t32", "ptlogo_back2eff4.t32", "ptlogo_back2eff5.t32", "ptlogo_back2.t32", "ptlogo_back2eff.t32", "ptcopyright.t32", "ptlogoall_eff.t32", "ptlogoall_eff2.t32", ]; // GP_TITLE.pak entries 11/14 — the GAME ARTS / SETA / studio anima splash. const SPLASH: [&str; 7] = [ "palogo_eff0.prm", "palogo_gamearts.t32", "palogo_gamearts_eff.t32", "palogo_seta.t32", "palogo_seta_eff.t32", "palogo_anima.t32", "palogo_anima_eff.t32", ]; if names == TITLE { // background, the rotating pair, the other full-screen layers, the // back2 glow group, the wordmarks, the copyright, the fade. return Some(vec![ 9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, 22, 23, 21, 8, ]); } if names == SPLASH { // the full-screen .prm, then all three glows, then the three logos. return Some(vec![0, 2, 4, 6, 1, 3, 5]); } // GP_TITLE.pak ratc-index 8 — the MAIN MENU (NEW GAME / LOAD GAME / // TUTORIAL / OPTIONS / EXTRAS). Read off the running game 2026-08-19; it is // the third measured permutation and the first that contains TWO // primitives, which is why it matters — see `ui-prm-primitives.md`. const MENU: [&str; 16] = [ "pteff00.prm", "ptbase.t32", "pteff05.t32", "ptloop01.rat", "ptloop02.rat", "pteff02.prm", "ptframe1.t32", "ptframe2.t32", "pteff10.t32", "pteff12.t32", "ptbtn01.rat", "ptbtn02.rat", "ptbtn03.rat", "ptbtn04.rat", "ptbtn05.rat", "ptmsg.t32", ]; if names == MENU { // background, the two loops, the full-screen effect, the DIM quad, // the glows, the frames, the message, the five buttons, and the // screen-transition FADE quad last. return Some(vec![1, 3, 4, 2, 5, 8, 9, 6, 7, 15, 10, 11, 12, 13, 14, 0]); } None } impl UiBuild { /// The instant at which this screen is *settled*, in keyframe time units. /// /// A screen is not settled when each element sits at its own last hold — /// that is what [`Element::rest`] gives, and it is wrong for a transient /// (see the note there). It is settled at one shared instant, and the disc /// says which: gather every keyframe time in the build, and take the /// **longest interval containing none of them**. Inside that gap nothing has /// an inflection, so every element is either holding or on a long linear /// ramp — which is exactly what "the screen has stopped changing" means. /// /// Returns the midpoint of that gap, or `None` when the build has fewer than /// two distinct keyframe times. /// /// ⚠️ **Not every bundle has a settled instant.** Disc-wide over the 1 758 /// composable bundles carrying two or more keyframe times, 30 % have a gap of /// at least half a second and 42 % have one under 10 units — the latter are /// mostly `loop*` animation fragments, which are *meant* to be in motion and /// have no settled pose to find. Check the gap width before trusting the /// midpoint; `settle_window` returns it. pub fn settle_time(&self) -> Option { self.settle_window().map(|(lo, hi)| lo + (hi - lo) / 2) } /// The `[start, end]` of the longest keyframe-free interval — see /// [`UiBuild::settle_time`]. The width `end - start` is how much confidence /// the midpoint deserves. pub fn settle_window(&self) -> Option<(u32, u32)> { let mut ts: Vec = self .elements .iter() .flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)) .collect(); ts.sort_unstable(); ts.dedup(); if ts.len() < 2 { return None; } ts.windows(2) .map(|w| (w[1] - w[0], w[0], w[1])) .max_by_key(|&(d, _, _)| d) .map(|(_, lo, hi)| (lo, hi)) } } pub fn compose( build: &UiBuild, bundle: &[u8], opts: ComposeOptions, visible: Option<&[bool]>, ) -> ComposedScreen { compose_with_order(build, bundle, opts, visible, None) } /// `compose`, with the paint order supplied by the caller. /// /// The only reason this exists is to *measure* what a paint order costs: render /// a screen twice, once with the order `compose` would pick and once with two /// elements swapped, and diff the pixels. `order` is a permutation of element /// indices, first painted first; `None` means "whatever `compose` would use". /// Nothing in the normal render path passes anything but `None`. pub fn compose_with_order( build: &UiBuild, bundle: &[u8], opts: ComposeOptions, visible: Option<&[bool]>, order_override: Option<&[usize]>, ) -> ComposedScreen { let (w, h) = (build.design_w, build.design_h); // A dim backdrop stands in for the PRMD dim-quad + the live 3D scene behind // an in-mission screen. let mut canvas = vec![0u8; (w as usize) * (h as usize) * 4]; for px in canvas.as_chunks_mut::<4>().0 { px.copy_from_slice(&opts.backdrop); } let mut drawn = Vec::new(); let mut missing = Vec::new(); // Measured paint order when one exists for this build, declaration order // otherwise — see `measured_paint_order`. // Measured order when this build is one of the two read off the running // game; otherwise the order DERIVED from the sprites' layer keys. // // ✅ Checked 2026-08-29 (`examples/paint_order_audit.rs`), because the // previous wording here — "reproduces both measured orders up to ties" — // was unmeasured and stale by one: there are THREE measured orders. The // derived order reproduces the main menu and the developer splash EXACTLY // (0 inverted pairs each) and differs on the title by 8 pairs, all of them // same-layer-key ties, two being total occlusions. Of the port's five // screens only `EXTRAS` rests on a derived order with ties: 15 tied pairs, // 2 overlapping. See docs/re/structures/ui-paint-order-derived-check.md. let order: Vec = match order_override { Some(o) => o.to_vec(), None => measured_paint_order(build).unwrap_or_else(|| derived_paint_order(build, bundle)), }; for &ei in &order { let Some(el) = build.elements.get(ei) else { continue; }; if let Some(v) = visible { if !v.get(el.index).copied().unwrap_or(true) { continue; } } if (el.animated && !opts.include_animated) || (el.focused && !opts.include_focus) { continue; } // A `kind = 0x4` element is a repeated instance of a template. On the // title screen those are motion-trail ghosts and are NOT on screen at // rest — the draw capture shows exactly ONE quad at each wordmark's // position though the bundle declares three instances of each, and // drawing them put three oversized PROJECT SYLPHEED copies across the // composite. // // But `0x4` alone does not mean "ghost". 174 elements on the disc are // `0x4` with **no** non-`0x4` element of the same sprite — e.g. // `GP_READY_ROOM` pak entry 75, 56 elements and every one of them `0x4` // (`pbb_destroyer` ×5, `pb_w_line` ×5, …), a list of real icons that a // blanket skip would erase. Measured afterwards, and worth stating // plainly: **none** of those 174 is in a bundle `is_build` accepts, so // today they never reach this function and a blanket skip would have // been harmless in practice. The condition below is therefore a // precaution, not a bug fix — it keeps the rule to the case the capture // actually covers, an instance whose template is also present. if el.kind & 0x4 != 0 && build .elements .iter() .any(|o| o.kind & 0x4 == 0 && o.name == el.name) { continue; } // 🔴 `at` poses LEAVES ONLY, never the top-level elements. // // Posing everything at one global time was tried and is wrong: a // top-level group's final keyframes are its **exit ramp** — the fade-out // played when the screen leaves — and `rest()` deliberately stops at the // last *hold* keyframe before it. Posing the title at t=358 walked every // parent into its exit and drove the render's disagreement with the // capture from 10.92 to 61.74. The leaf is the thing still animating at // that instant, and it runs on its own timeline // (`docs/re/structures/ui-leaf-vs-parent-alpha.md`). let Some(kf) = (match opts.at { Some(t) => el.pose_at(t), None => el.rest().cloned(), }) else { continue; }; let kf = &kf; // An untextured primitive: a solid quad of the keyframe's `fade` colour, // sized by the declared pivot. `kind & 0x10` marks these exactly — see // `docs/re/structures/ui-prm-primitives.md`. They are the screen's // fade-to-black, dim-behind-a-menu and flash layers. if el.kind & 0x10 != 0 && el.sprite.is_none() { if !opts.include_primitives { continue; } if fill_quad(&mut canvas, w, h, kf, el.pivot_x, el.pivot_y) { drawn.push(el.index); } 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 { missing.push(sprite.clone()); continue; }; let Some(img) = t8ad::parse(&bundle[off..off + size]) else { missing.push(sprite.clone()); continue; }; // A nested `.rat` leaf sometimes carries the geometry while the parent // carries none — the title's light sweeps are the case: the parent sits // fixed at (441,270) scale 100 %, and the leaf holds the 600 %/800 % // scale, the +30°/−45° rotation and the whole sweep. Drawing the parent // put the sprite upright in the middle of the screen. // // ⚠️ NOT a blanket rule. A button's base record has a leaf that // DUPLICATES it, and there the parent wins // (`docs/re/structures/ui-button-focus-record.md`). The discriminator is // which record actually carries geometry, so the leaf is used only when // its pose genuinely differs — see `ui-leaf-vs-parent-alpha.md`. let leaf = build.records.get(&el.name).and_then(|&(lo, ls)| { if lo + ls > bundle.len() { return None; } let lb = parse_build(&bundle[lo..lo + ls])?; let pose = |e: &Element| match opts.at { Some(t) => e.pose_at(t), None => e.rest().cloned(), }; let differs = lb.elements.iter().any(|le| { pose(le).is_some_and(|lk| { lk.rotation_deg != 0 || lk.scale_x != kf.scale_x || lk.scale_y != kf.scale_y }) }); if differs { Some(lb) } else { None } }); if let Some(lb) = leaf { let mut any = false; for le in &lb.elements { let lk = match opts.at { Some(t) => le.pose_at(t), None => le.rest().cloned(), }; let Some(lk) = lk else { continue }; // A leaf element resolves no sprite of its own: sprite names are // resolved against the bundle a build was parsed from, and a leaf // is parsed from its own slice. Its NAME is the sprite name, and // the sprite itself lives in the PARENT bundle's table. let lsp = le.sprite.clone().unwrap_or_else(|| le.name.clone()); let Some(&(so, ss)) = build.sprites.get(&lsp) else { continue; }; let Some(limg) = t8ad::parse(&bundle[so..so + ss]) else { continue; }; // 🔴 Only count it as drawn if it CAN draw. `blit` returns early // on a zero scale — "collapsed to nothing", not "unset" — so // setting the flag unconditionally would let a scale-0 leaf // suppress its parent and blank the element outright. // `pgloading_loop5`'s leaf is scale (0,0), and scale-0 is one of // the failures this corpus is already named for. if lk.scale_x == 0 || lk.scale_y == 0 { continue; } blit( &mut canvas, w, h, &limg, &lk, le.pivot_x, le.pivot_y, limg.flags & 0x02 != 0, ); any = true; } if any { drawn.push(el.index); continue; } } blit( &mut canvas, w, h, &img, kf, el.pivot_x, el.pivot_y, img.flags & 0x02 != 0, ); drawn.push(el.index); } ComposedScreen { width: w, height: h, rgba: canvas, drawn, missing, } } /// Alpha-blend an untextured primitive: a solid rectangle of the keyframe's /// `fade` colour, `pivot × 2` in size, placed and scaled exactly as a sprite is. /// /// Returns whether anything was drawn — a primitive resting at alpha 0 (189 of /// the 369 on the disc) contributes nothing and should not be counted as drawn. /// /// The size comes from the pivot because there is no texture to take it from, /// and a `.t32` element's pivot is exactly half its decoded sprite. 361 of the /// 369 primitives are `pivot × 2 == 1280×720`, the design space. fn fill_quad( canvas: &mut [u8], cw: u32, ch: u32, kf: &Keyframe, pivot_x: u32, pivot_y: u32, ) -> bool { let (a, r, g, b) = ( (kf.fade >> 24) & 0xff, (kf.fade >> 16) & 0xff, (kf.fade >> 8) & 0xff, kf.fade & 0xff, ); if a == 0 { return false; } let (sw, sh) = (pivot_x * 2, pivot_y * 2); if sw == 0 || sh == 0 { return false; } // `scale = 0` is COLLAPSED TO NOTHING, not "unset" — see `blit`. let (sx_pct, sy_pct) = (kf.scale_x, kf.scale_y); if sx_pct == 0 || sy_pct == 0 { return false; } let dw = (sw * sx_pct / 100).max(1); let dh = (sh * sy_pct / 100).max(1); let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100; let oy = kf.y - (pivot_y as i32 * (sy_pct as i32 - 100)) / 100; for row in 0..dh { let ty = oy + row as i32; if ty < 0 { continue; } if ty >= ch as i32 { break; } for col in 0..dw { let tx = ox + col as i32; if tx < 0 { continue; } if tx >= cw as i32 { break; } let di = ((ty as u32 * cw + tx as u32) * 4) as usize; for (k, sc) in [r, g, b].into_iter().enumerate() { let dc = canvas[di + k] as u32; canvas[di + k] = ((sc * a + dc * (255 - a)) / 255) as u8; } canvas[di + 3] = 255; } } true } /// Alpha-blend one sprite onto the canvas at a keyframe's placement, with tint /// and scale. Placements may be negative or run off the edge, so both axes clip. /// /// **A keyframe's X/Y is the element's top-left at 1:1, and scale grows it about /// the declared pivot, not about that corner.** Measured against a framebuffer /// capture of the running title screen: `ptbase2.t32` is a 640×360 background /// placed at (320,180) with `scale = 200%` and pivot (320,180). Growing from the /// corner puts it at 320..1600 × 180..900 — a quarter-screen slab. Anchoring the /// pivot gives top-left `(320,180) − (320,180)·(2−1) = (0,0)` and a 1280×720 /// rect, which is what the game draws. At 100 % the pivot cancels, which is why /// every unscaled element — and so every ruler this format was checked against — /// was unaffected. See `docs/re/structures/ui-rat-layout.md`. /// One source sample over one destination sample, in whichever blend the disc /// declares for the element. /// /// Both equations come from the game's own pixel shader, dumped from the running /// guest and disassembled in `docs/re/ui-splash-draw-pass.md`: /// /// ```text /// mul r1.___w, r2.wwww, r0.wwww ; A = tex.a * vcol.a /// mul r0.xyz_, r2.xyzz, r0.xyzz ; rgb = tex.rgb * vcol.rgb /// mul r1.xyz_, r0.xyzz, r1.wwww ; rgb = rgb * A <-- the shader PREMULTIPLIES /// max oC0, r1, r1 ; oC0 = (rgb*A, A) /// ``` /// /// So the shader always emits `src = rgb·A`, and only the blend register differs: /// /// * alpha-over, `RB_BLENDCONTROL0 = 0x07010701` — `ONE / ONE_MINUS_SRC_ALPHA`, /// giving `dst' = rgb·A + dst·(1 − A)`, i.e. ordinary source-over; /// * additive, `0x01010101` — `ONE / ONE`, giving `dst' = rgb·A + dst`. /// /// The additive case therefore **saturates rather than wraps**, and a fully /// transparent or fully black source is the identity in it — both of which the /// control tests pin, because they are arithmetic and not opinions. #[inline] fn combine(additive: bool, sc: u32, sa: u32, dc: u32) -> u8 { if additive { (dc + sc * sa / 255).min(255) as u8 } else { ((sc * sa + dc * (255 - sa)) / 255) as u8 } } // Every argument is a separate axis of one draw call; bundling them into a // struct would only move the list somewhere else. #[allow(clippy::too_many_arguments)] fn blit( canvas: &mut [u8], cw: u32, ch: u32, img: &t8ad::T8adImage, kf: &Keyframe, pivot_x: u32, pivot_y: u32, // `T8aD +0x04` bit `0x02` — the game draws this element ADDITIVE. Decoded, // `docs/re/structures/ui-blend-mode-decoded.md`: 35 elements over three // screens against `RB_BLENDCONTROL0` read out of the guest command stream, // 0 errors, plus an out-of-sample hit on `GP_OPTIONS`. additive: bool, ) { let (sw, sh) = (img.width, img.height); if sw == 0 || sh == 0 { return; } // `scale = 0` means COLLAPSED TO NOTHING, not "unset". This used to coerce // 0 → 100 %, which drew a fully-collapsed element at full size. The disc // settles it: of 15 493 elements with a keyframe group, **2 166 have at // least one zero-scale keyframe and not one has zero on every keyframe** — // and 1 762 of them grow back out of it (`ptlogo_eff3.t32` runs 0 % → 200 %). // An "unset" marker that no element ever uses throughout is not a marker. let (sx_pct, sy_pct) = (kf.scale_x, kf.scale_y); if sx_pct == 0 || sy_pct == 0 { return; } let dw = (sw * sx_pct / 100).max(1); let dh = (sh * sy_pct / 100).max(1); // Keep the pivot point fixed as the element scales. let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100; let oy = kf.y - (pivot_y as i32 * (sy_pct as i32 - 100)) / 100; // The pivot's ABSOLUTE position is invariant under scale, which is the whole // point of the two lines above: at 100 % `ox = kf.x` so the pivot sits at // `kf.x + pivot_x`; at 200 % `ox = kf.x - pivot_x` and the pivot sits at // `ox + 2·pivot_x`, the same place. So rotation turns about it. let (pax, pay) = (kf.x + pivot_x as i32, kf.y + pivot_y as i32); // Two modulate colours multiply into one: `tint` (RGBA, and `0xffffffff` on // essentially every keyframe seen) and `fade` (**ARGB** — the high byte is // the alpha that ramps, the low 24 bits a colour multiply that is `0xffffff` // on 5 276 of the disc's 5 453 resting keyframes). The byte order is not a // guess: it is the high byte that walks 0x00 → 0x80 → 0xc0 → 0xe0 → 0xff // across a fade-in while the low three stay `ffffff`. let (fa, fr, fg, fb) = ( (kf.fade >> 24) & 0xff, (kf.fade >> 16) & 0xff, (kf.fade >> 8) & 0xff, kf.fade & 0xff, ); let (tr, tg, tb, ta) = ( ((kf.tint >> 24) & 0xff) * fr / 255, ((kf.tint >> 16) & 0xff) * fg / 255, ((kf.tint >> 8) & 0xff) * fb / 255, (kf.tint & 0xff) * fa / 255, ); // ---- rotated path ------------------------------------------------------- // `+12` is a screen-plane rotation in DEGREES, clockwise-positive with Y // down (`docs/re/structures/ui-keyframe-rotation.md`). The game submits // rotated quads for it; this used to draw them axis-aligned, which put the // title's two light sweeps upright instead of at +30° / −45° and left at // least two thirds of that screen's disagreement with the capture // (`title-residual-tone-vs-geometry.md`). // // Zero rotation keeps the original forward-mapped path byte for byte, so // the screens that do not rotate cannot regress. A rotated element is drawn // by INVERSE mapping instead: forward-mapping a rotation leaves gaps. let rot = ((kf.rotation_deg % 360) + 360) % 360; if rot != 0 { let th = (rot as f64).to_radians(); let (cs, sn) = (th.cos(), th.sin()); // Axis-aligned bounds of the rotated destination rect. let corners = [ (ox, oy), (ox + dw as i32, oy), (ox + dw as i32, oy + dh as i32), (ox, oy + dh as i32), ]; let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); for (cx, cy) in corners { let (rx, ry) = ((cx - pax) as f64, (cy - pay) as f64); let px = pax as f64 + rx * cs - ry * sn; let py = pay as f64 + rx * sn + ry * cs; x0 = x0.min(px.floor() as i32); y0 = y0.min(py.floor() as i32); x1 = x1.max(px.ceil() as i32); y1 = y1.max(py.ceil() as i32); } for ty in y0.max(0)..=y1.min(ch as i32 - 1) { for tx in x0.max(0)..=x1.min(cw as i32 - 1) { // Rotate the destination pixel BACK to find its source pixel. let (rx, ry) = ((tx - pax) as f64 + 0.5, (ty - pay) as f64 + 0.5); let ux = rx * cs + ry * sn; let uy = -rx * sn + ry * cs; let dx = ux + (pax - ox) as f64; let dy = uy + (pay - oy) as f64; if dx < 0.0 || dy < 0.0 || dx >= dw as f64 || dy >= dh as f64 { continue; } let sxi = ((dx as u32) * sw / dw).min(sw - 1); let syi = ((dy as u32) * sh / dh).min(sh - 1); let si = ((syi * sw + sxi) * 4) as usize; if si + 3 >= img.rgba.len() { continue; } let sr = img.rgba[si] as u32 * tr / 255; let sg = img.rgba[si + 1] as u32 * tg / 255; let sb = img.rgba[si + 2] as u32 * tb / 255; let sa = img.rgba[si + 3] as u32 * ta / 255; if sa == 0 { continue; } let di = ((ty as u32 * cw + tx as u32) * 4) as usize; for (k, sc) in [sr, sg, sb].into_iter().enumerate() { let dc = canvas[di + k] as u32; canvas[di + k] = combine(additive, sc, sa, dc); } canvas[di + 3] = 255; } } return; } // ---- unrotated path (unchanged) ----------------------------------------- for row in 0..dh { let ty = oy + row as i32; if ty < 0 { continue; } if ty >= ch as i32 { break; } let syi = (row * sh / dh).min(sh - 1); for col in 0..dw { let tx = ox + col as i32; if tx < 0 { continue; } if tx >= cw as i32 { break; } let sxi = (col * sw / dw).min(sw - 1); let si = ((syi * sw + sxi) * 4) as usize; if si + 3 >= img.rgba.len() { continue; } let sr = img.rgba[si] as u32 * tr / 255; let sg = img.rgba[si + 1] as u32 * tg / 255; let sb = img.rgba[si + 2] as u32 * tb / 255; let sa = img.rgba[si + 3] as u32 * ta / 255; if sa == 0 { continue; } let di = ((ty as u32 * cw + tx as u32) * 4) as usize; for (k, sc) in [sr, sg, sb].into_iter().enumerate() { let dc = canvas[di + k] as u32; canvas[di + k] = combine(additive, sc, sa, dc); } canvas[di + 3] = 255; } } } #[cfg(test)] mod tests { use super::*; /// A solid opaque rectangle sprite, for exercising `blit` geometry. fn solid(w: u32, h: u32) -> t8ad::T8adImage { t8ad::T8adImage { width: w, height: h, rgba: vec![255u8; (w * h * 4) as usize], flags: 0, } } fn kf_at(x: i32, y: i32, rot: i32) -> Keyframe { Keyframe { fade: 0xff_ff_ff_ff, rotation_deg: rot, unknown_4: 0, unknown_8: 0, scale_x: 100, scale_y: 100, tint: 0xffff_ffff, x, y, time: Some(0), } } fn draw(img: &t8ad::T8adImage, kf: &Keyframe, px: u32, py: u32) -> Vec { let mut c = vec![0u8; 64 * 64 * 4]; blit(&mut c, 64, 64, img, kf, px, py, false); c } fn covered(c: &[u8]) -> Vec<(i32, i32)> { let mut v = Vec::new(); for y in 0..64 { for x in 0..64 { if c[((y * 64 + x) * 4 + 3) as usize] != 0 { v.push((x, y)); } } } v } /// 🔴 CONTROL for the rotated path. An estimator that is wrong on a known /// angle cannot be trusted on an unknown one, so the rotated blit is pinned /// against angles whose answer is arithmetic rather than measured. #[test] fn rotation_control_known_angles() { let img = solid(10, 4); // pivot at the sprite's centre, so rotation turns in place let (px, py) = (5u32, 2u32); let base = draw(&img, &kf_at(20, 30, 0), px, py); // 0° and 360° must be identical to the unrotated path, byte for byte: // the fast path must be exactly the old behaviour. assert_eq!( base, draw(&img, &kf_at(20, 30, 360), px, py), "360 degrees must equal the unrotated path exactly" ); // 90° must turn a 10x4 into a 4x10 about the same centre. let r90 = covered(&draw(&img, &kf_at(20, 30, 90), px, py)); let b = covered(&base); let bw = b.iter().map(|p| p.0).max().unwrap() - b.iter().map(|p| p.0).min().unwrap(); let bh = b.iter().map(|p| p.1).max().unwrap() - b.iter().map(|p| p.1).min().unwrap(); let rw = r90.iter().map(|p| p.0).max().unwrap() - r90.iter().map(|p| p.0).min().unwrap(); let rh = r90.iter().map(|p| p.1).max().unwrap() - r90.iter().map(|p| p.1).min().unwrap(); assert_eq!((bw, bh), (9, 3), "unrotated extent"); assert_eq!((rw, rh), (3, 9), "90 degrees must swap the extents"); // The covered area must be conserved to a few percent -- a rotation that // loses or invents pixels is the forward-mapping bug this path avoids. let (a0, a90) = (b.len() as f64, r90.len() as f64); assert!( (a0 - a90).abs() / a0 < 0.15, "area changed too much under rotation: {a0} -> {a90}" ); // And the centroid must stay on the pivot. let cen = |v: &Vec<(i32, i32)>| { let n = v.len() as f64; ( v.iter().map(|p| p.0 as f64).sum::() / n, v.iter().map(|p| p.1 as f64).sum::() / n, ) }; let (c0, c9) = (cen(&b), cen(&r90)); assert!( (c0.0 - c9.0).abs() < 1.0 && (c0.1 - c9.1).abs() < 1.0, "rotation moved the centroid: {c0:?} -> {c9:?}" ); } /// A synthetic build bundle: RATC magic, entry count at 0x14, a declaration /// table at 0x20, then a placement region. fn synth_build( decls: &[(&str, u32, u32, u32, u32)], groups: &[(usize, Vec)], ) -> Vec { let count = decls.len(); let mut b = vec![0u8; DECL_TABLE_AT + count * DECL_ENTRY]; b[0..4].copy_from_slice(b"RATC"); b[0x14..0x18].copy_from_slice(&(count as u32).to_be_bytes()); for (i, &(name, parent, kind, px, py)) in decls.iter().enumerate() { let at = DECL_TABLE_AT + i * DECL_ENTRY; b[at..at + name.len()].copy_from_slice(name.as_bytes()); b[at + 32..at + 36].copy_from_slice(&parent.to_be_bytes()); b[at + 40..at + 44].copy_from_slice(&kind.to_be_bytes()); b[at + 48..at + 52].copy_from_slice(&px.to_be_bytes()); b[at + 52..at + 56].copy_from_slice(&py.to_be_bytes()); } for (idx, frames) in groups { let start = b.len(); b.extend_from_slice(&(*idx as u32).to_be_bytes()); b.extend_from_slice(&(frames.len() as u32).to_be_bytes()); b.resize(start + 12, 0); // header + one lead-in word for kf in frames { b.extend_from_slice(&kf.fade.to_be_bytes()); b.extend_from_slice(&[0u8; 12]); b.extend_from_slice(&kf.scale_x.to_be_bytes()); b.extend_from_slice(&kf.scale_y.to_be_bytes()); b.extend_from_slice(&kf.tint.to_be_bytes()); b.extend_from_slice(&kf.x.to_be_bytes()); b.extend_from_slice(&kf.y.to_be_bytes()); b.extend_from_slice(&kf.time.unwrap_or(0).to_be_bytes()); } // The next group's header overlaps the last block's time slot. b.truncate(b.len() - 4); } b } fn kf(x: i32, y: i32, time: u32) -> Keyframe { Keyframe { fade: 0xffff_ffff, rotation_deg: 0, unknown_4: 0, unknown_8: 0, scale_x: 100, scale_y: 100, tint: 0xffff_ffff, x, y, time: Some(time), } } #[test] fn reads_declaration_table_in_draw_order() { let b = synth_build( &[ ("pgpeff02.t32", u32::MAX, 0, 204, 60), ("pgpeff02a.t32", 0, 1, 100, 40), ("pgpbtn00.rat", u32::MAX, 0x3002, 64, 16), ], &[], ); let els = parse_decls(&b).unwrap(); assert_eq!(els.len(), 3); assert_eq!(els[0].name, "pgpeff02.t32"); assert_eq!(els[0].parent, None); // `+32` is a parent element index: the `…a` variant names its base. assert_eq!(els[1].parent, Some(0)); assert_eq!(els[1].kind, 1); assert_eq!(els[2].kind, 0x3002); assert_eq!((els[0].pivot_x, els[0].pivot_y), (204, 60)); } #[test] fn placement_x_and_y_are_signed() { // The Arsenal window animates in from X = -516; read as u32 it would be // ~4.29 billion and the element would be discarded. let b = synth_build( &[("aswindow.t32", u32::MAX, 0, 0, 0)], &[(0, vec![kf(-516, 40, 0), kf(120, 40, 30)])], ); let mut els = parse_decls(&b).unwrap(); parse_placements(&b, &mut els); assert_eq!(els[0].keyframes.len(), 2); assert_eq!(els[0].keyframes[0].x, -516); assert_eq!(els[0].keyframes[1].x, 120); } #[test] fn rest_is_the_max_dwell_keyframe_not_the_first_or_last() { // in (t=0) → hold (t=10..90) → out (t=100): the resting pose is the // middle frame, which both "first" and "last" would get wrong. let el = Element { index: 0, name: "x.t32".into(), sprite: None, parent: None, kind: 0, pivot_x: 0, pivot_y: 0, keyframes: vec![kf(-500, 10, 0), kf(226, 268, 10), kf(900, 268, 90)], focus_link: None, focused: false, animated: false, }; let r = el.rest().unwrap(); assert_eq!((r.x, r.y), (226, 268)); } #[test] fn single_keyframe_is_its_own_rest() { let el = Element { index: 0, name: "x.t32".into(), sprite: None, parent: None, kind: 0, pivot_x: 0, pivot_y: 0, keyframes: vec![kf(546, 288, 0)], focus_link: None, focused: false, animated: false, }; assert_eq!(el.rest().map(|k| (k.x, k.y)), Some((546, 288))); } #[test] fn rejects_a_bundle_with_no_declaration_table() { let mut b = vec![0u8; 0x400]; b[0..4].copy_from_slice(b"RATC"); b[0x14..0x18].copy_from_slice(&999_999u32.to_be_bytes()); // cannot fit assert!(parse_decls(&b).is_none()); } #[test] fn opt_link_reads_the_focus_record() { let mut r = vec![0u8; 0x60]; r[0..4].copy_from_slice(b"RATC"); r[0x20..0x20 + 12].copy_from_slice(b"pgpbtn00.t32"); r.extend_from_slice(b"opt "); r.extend_from_slice(&13u32.to_be_bytes()); r.extend_from_slice(b"pgpbtn00f.rat"); assert_eq!(record_sprite(&r).as_deref(), Some("pgpbtn00.t32")); assert_eq!(opt_link(&r).as_deref(), Some("pgpbtn00f.rat")); } #[test] fn scaling_grows_about_the_pivot_not_the_corner() { // Measured against the real title screen: `ptbase2.t32` is 640x360 with // pivot (320,180), placed at (320,180) with scale 200%. The game draws // it as the full-screen background — top-left (0,0), 1280x720. Growing // from the keyframe corner instead paints a quarter-screen slab and // leaves the top-left quadrant bare. See // `docs/re/structures/ui-rat-layout.md`. let img = t8ad::T8adImage { flags: 0, width: 640, height: 360, rgba: vec![255u8; 640 * 360 * 4], }; let k = Keyframe { fade: 0xffff_ffff, rotation_deg: 0, unknown_4: 0, unknown_8: 0, scale_x: 200, scale_y: 200, tint: 0xffff_ffff, x: 320, y: 180, time: None, }; let (w, h) = (1280u32, 720u32); let mut canvas = vec![0u8; (w * h * 4) as usize]; blit(&mut canvas, w, h, &img, &k, 320, 180, false); // Every pixel is covered; the four corners are the cheap witnesses. for (x, y) in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)] { let i = ((y * w + x) * 4) as usize; assert_eq!( canvas[i], 255, "pixel ({x},{y}) not covered — the background is not full-screen" ); } } #[test] fn a_hundred_percent_element_lands_on_its_keyframe_corner() { // The other half of the rule, and the reason the corner reading survived // this long: at 100% the pivot cancels, so no unscaled element moves. // `ptcopyright.t32` is 694x20 at (293,655), and the capture's glyph run // starts at x = 295 — inside that rect, not offset by a pivot. let img = t8ad::T8adImage { flags: 0, width: 694, height: 20, rgba: vec![255u8; 694 * 20 * 4], }; let k = kf(293, 655, 0); let (w, h) = (1280u32, 720u32); let mut canvas = vec![0u8; (w * h * 4) as usize]; blit(&mut canvas, w, h, &img, &k, 309, 10, false); let at = |x: u32, y: u32| canvas[((y * w + x) * 4) as usize]; assert_eq!(at(293, 655), 255, "top-left corner is the keyframe"); assert_eq!(at(986, 674), 255, "bottom-right corner is corner + size"); assert_eq!(at(292, 655), 0, "nothing left of the keyframe X"); assert_eq!(at(293, 654), 0, "nothing above the keyframe Y"); } #[test] fn fallback_scans_records_when_the_table_is_unusable() { // The old `.rat`-only reading, kept as a recovery path. let mut r = vec![0u8; 0x58]; r[0..4].copy_from_slice(b"RATC"); r[0x18..0x1c].copy_from_slice(&1280u32.to_be_bytes()); r[0x1c..0x20].copy_from_slice(&720u32.to_be_bytes()); r[0x20..0x20 + 12].copy_from_slice(b"pgpbtn00.t32"); for v in [100u32, 100, 0xffff_ffff, 226, 268] { r.extend_from_slice(&v.to_be_bytes()); } assert_eq!(scan_placement_block(&r), Some((0xffff_ffff, 226, 268))); } // ---- additive blend: controls ------------------------------------------ // // `T8aD +0x04` bit 0x02 is DECODED against the GPU, but this renderer could // not express it, so `verify-screen` was structurally incapable on 12 of 16 // screens and its allowance quietly excused all of them. These pin the // equation against answers that are ARITHMETIC, following the precedent set // by `rotation_control_known_angles`. // // ⚠️ The last test is the one that matters. A suite that only checks the // additive path passes just as well if the flag is ignored and everything // draws additive; the discriminator has to show the SAME sprite giving TWO // different answers according to the bit. /// Adding zero changes nothing: a black additive source is the identity. #[test] fn additive_control_black_source_is_identity() { let img = t8ad::T8adImage { width: 4, height: 4, rgba: [[0u8, 0, 0, 255]; 16].concat(), flags: 0x02, }; let kf = kf_at(10, 10, 0); let mut c = vec![77u8; 64 * 64 * 4]; let before = c.clone(); blit(&mut c, 64, 64, &img, &kf, 0, 0, true); // alpha is forced to 255 on any touched pixel, so compare colour only for i in (0..c.len()).filter(|i| i % 4 != 3) { assert_eq!( c[i], before[i], "black additive source moved a pixel at {i}" ); } } /// A fully transparent source is the identity in either blend. #[test] fn additive_control_alpha_zero_is_identity() { let img = t8ad::T8adImage { width: 4, height: 4, rgba: [[200u8, 200, 200, 0]; 16].concat(), flags: 0x02, }; let kf = kf_at(10, 10, 0); let mut c = vec![77u8; 64 * 64 * 4]; let before = c.clone(); blit(&mut c, 64, 64, &img, &kf, 0, 0, true); assert_eq!(c, before, "a zero-alpha source is not the identity"); } /// The sum is the sum, and it SATURATES rather than wrapping. #[test] fn additive_control_known_sums() { for (dst, src, want) in [ (40u8, 100u32, 140u8), (200, 100, 255), (0, 255, 255), (250, 10, 255), (7, 8, 15), ] { let img = t8ad::T8adImage { width: 2, height: 2, rgba: [[src as u8, src as u8, src as u8, 255]; 4].concat(), flags: 0x02, }; let kf = kf_at(10, 10, 0); let mut c = vec![dst; 64 * 64 * 4]; blit(&mut c, 64, 64, &img, &kf, 0, 0, true); let di = ((10 * 64 + 10) * 4) as usize; assert_eq!( c[di], want, "additive {dst} + {src} should saturate to {want}" ); } } /// 🔴 THE DISCRIMINATOR. The same sprite, the same pose, the same canvas — /// only the blend differs — must produce two DIFFERENT answers, each equal /// to its own equation. Without this, a `blit` that ignored the flag and /// always drew additive would pass every test above. #[test] fn additive_control_bit_actually_selects() { let img = t8ad::T8adImage { width: 2, height: 2, rgba: [[100u8, 100, 100, 128]; 4].concat(), flags: 0, }; let kf = kf_at(10, 10, 0); let di = ((10 * 64 + 10) * 4) as usize; let mut over = vec![80u8; 64 * 64 * 4]; blit(&mut over, 64, 64, &img, &kf, 0, 0, false); let mut add = vec![80u8; 64 * 64 * 4]; blit(&mut add, 64, 64, &img, &kf, 0, 0, true); // alpha-over: (100*128 + 80*127)/255 = (12800 + 10160)/255 = 90 assert_eq!(over[di], 90, "alpha-over arithmetic changed"); // additive: 80 + 100*128/255 = 80 + 50 = 130 assert_eq!(add[di], 130, "additive arithmetic changed"); assert_ne!( over[di], add[di], "the blend flag selects nothing -- blit ignores it" ); } }