Element::rest() picked the keyframe with the largest gap to the next keyframe's
time. That reads a keyframe as a value held until the next one; it is the start
of a ramp toward it. A long gap after keyframe k means the screen spends that
time arriving at k+1, so the settled pose is at the far end of the gap.
The title wordmark zooms in over five frames and 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 against the framebuffer capture of the running title screen, which is a
1:1 crop so frame coordinates map directly (confirmed: the copyright line lands
on row 669 in the capture and in both composites). Edge-correlated over the
wordmark box:
plateau (landed) best 0.4597 at shift (0,0)
longest dwell (old) best 0.1511 at shift (+3,+8), 0.1268 at (0,0)
The old composite scores 3x lower and only peaks after being moved, by about the
(-5,-7) that picking kf4 instead of kf5 predicts.
It also fixes six title elements the old rule rested at alpha 0x00 where the
capture plainly shows them, and pteff00.prm - the full-screen fade quad painted
last - which rested at opaque black. That was the blocker on .prm compositing.
Adds tools/re-capture/align_to_capture.py, which is how this was scored, and
turns the .prm test that deliberately asserted the old defect into a guard on
the fix.
Not settled and now the next item: compose ignores the keyframe fade alpha
entirely (blit modulates by tint only), which is why choosing the wrong keyframe
was invisible until now.
1078 lines
42 KiB
Rust
1078 lines
42 KiB
Rust
//! 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 `<name>.t32`
|
||
//! sprites and `<name>.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<u32>,
|
||
}
|
||
|
||
/// 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<String>,
|
||
/// Parent element index (`+32`), or `None` for `0xffff_ffff`.
|
||
pub parent: Option<usize>,
|
||
/// 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<Keyframe>,
|
||
/// `opt ` link to another record — the focused state of a button.
|
||
pub focus_link: Option<String>,
|
||
/// 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.
|
||
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
|
||
};
|
||
let (mut best_start, mut best_len) = (0usize, 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;
|
||
// `>=` so a later run of equal length wins, matching the in → hold →
|
||
// out reasoning behind the longest-dwell rule's tie-break.
|
||
if len >= 2 && len >= best_len {
|
||
best_start = i;
|
||
best_len = len;
|
||
}
|
||
i = j + 1;
|
||
}
|
||
(best_len >= 2).then(|| &self.keyframes[best_start])
|
||
}
|
||
}
|
||
|
||
/// 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<Element>,
|
||
/// Sprite name → (offset, size) of its `T8aD` child within the bundle.
|
||
pub sprites: HashMap<String, (usize, usize)>,
|
||
/// A guessed context from the sprite naming (e.g. `"tutorial"`), if any.
|
||
pub context_hint: Option<String>,
|
||
/// 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<usize>,
|
||
}
|
||
|
||
/// 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<String> {
|
||
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<String> {
|
||
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<Vec<Element>> {
|
||
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<String> = 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<usize> {
|
||
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<UiBuild> {
|
||
let kids = ratc::parse(bundle)?;
|
||
let mut sprites = HashMap::new();
|
||
let mut records: HashMap<String, (usize, usize)> = 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<String, (usize, usize)>) -> Vec<Element> {
|
||
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<u8>,
|
||
/// Indices of the elements actually drawn, in draw order.
|
||
pub drawn: Vec<usize>,
|
||
/// Elements skipped because their sprite could not be resolved or decoded.
|
||
pub missing: Vec<String>,
|
||
}
|
||
|
||
/// 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<ComposedScreen> {
|
||
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<u32> {
|
||
let sprite = el.sprite.as_ref()?;
|
||
let &(off, size) = build.sprites.get(sprite)?;
|
||
if size < 0x0c {
|
||
return None;
|
||
}
|
||
Some(be32(bundle, off + 8))
|
||
}
|
||
|
||
/// 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.
|
||
fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||
idx.sort_by_key(|&i| {
|
||
(
|
||
sprite_layer_key(build, bundle, &build.elements[i]).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<Vec<usize>> {
|
||
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`.
|
||
// 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<usize> =
|
||
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 };
|
||
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<Keyframe>)]) -> Vec<u8> {
|
||
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)));
|
||
}
|
||
}
|