//! 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. 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, so the resting pose is /// neither the first nor the last frame: it is the one that **dwells /// longest** — the largest gap to the next keyframe's time. A single /// keyframe is its own rest position. pub fn rest(&self) -> Option<&Keyframe> { 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) } } } } /// 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")) }) } /// 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: lname.ends_with("f.rat") || lname.ends_with("f.t32"), animated: lname.contains("loop"), }); } Some(elements) } /// 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 els = fallback_elements(bundle, &records); 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: lname.ends_with("f.rat"), 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], } impl Default for ComposeOptions { fn default() -> Self { Self { include_focus: false, include_animated: false, backdrop: [14, 14, 20, 255], } } } /// 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. /// 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]); } 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`. let order: Vec = measured_paint_order(build).unwrap_or_else(|| (0..build.elements.len()).collect()); 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; } // `kind = 0x4` elements are the repeated instances of a template — the // motion-trail ghosts a wordmark leaves while it flies in. They are NOT // on screen at rest: the title's draw capture shows exactly ONE quad at // each wordmark's position, though the bundle declares three instances // of each, and their keyframe groups end at alpha 0. Drawing them at // their resting keyframe is what put three oversized copies of // PROJECT SYLPHEED across the composite. if el.kind & 0x4 != 0 { continue; } let Some(kf) = el.rest() else { 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 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; let (tr, tg, tb, ta) = ( (kf.tint >> 24) & 0xff, (kf.tint >> 16) & 0xff, (kf.tint >> 8) & 0xff, kf.tint & 0xff, ); 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))); } }