//! 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 u32 0 /// +8 u32 0 /// +12 u32 0 /// +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 /// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Keyframe { /// The fade colour, ARGB. Its alpha is what ramps an element in. pub fade: u32, /// 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, /// Keyframe time, or `None` for the group's **last** frame. /// /// A group's data stops 4 bytes short of its final block's time slot — that /// word is already the next group's element index. Reading it anyway is /// where a stray `time = 1869640736` comes from, and it silently corrupts /// the max-dwell pick in [`Element::rest`]. 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. pub fn rest(&self) -> Option<&Keyframe> { if let Some(k) = self.rest_plateau() { return Some(k); } match self.keyframes.len() { 0 => None, 1 => self.keyframes.first(), n => { 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 { continue; // the last frame carries no time }; 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; } if j != n - 1 && 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, /// 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) } /// 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 { 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; } // Group header is (index, count) then one lead-in word; blocks follow. 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), 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, // Only a block wholly inside the group carries a time. time: (blk + 40 <= group_end).then(|| be32(bundle, blk + 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, 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, 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, } impl Default for ComposeOptions { fn default() -> Self { Self { include_focus: false, include_animated: false, backdrop: [14, 14, 20, 255], include_primitives: false, } } } /// 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)) } /// 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. 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)) .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 } pub fn compose( build: &UiBuild, bundle: &[u8], opts: ComposeOptions, visible: Option<&[bool]>, ) -> 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.chunks_exact_mut(4) { 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, which // reproduces both measured orders up to ties. let order: Vec = 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; } let Some(kf) = el.rest() else { continue }; // 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 { 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; }; blit(&mut canvas, w, h, &img, kf, el.pivot_x, el.pivot_y); 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; } let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x }; let sy_pct = if kf.scale_y == 0 { 100 } else { kf.scale_y }; 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`. fn blit( canvas: &mut [u8], cw: u32, ch: u32, img: &t8ad::T8adImage, kf: &Keyframe, pivot_x: u32, pivot_y: u32, ) { let (sw, sh) = (img.width, img.height); if sw == 0 || sh == 0 { return; } let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x }; let sy_pct = if kf.scale_y == 0 { 100 } else { kf.scale_y }; 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; // 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, ); 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] = ((sc * sa + dc * (255 - sa)) / 255) as u8; } canvas[di + 3] = 255; } } } #[cfg(test)] mod tests { use super::*; /// 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, 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 { width: 640, height: 360, rgba: vec![255u8; 640 * 360 * 4], }; let k = Keyframe { fade: 0xffff_ffff, 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); // 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 { 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); 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))); } }