Follow-up on last iteration's unadopted candidate (+36 holds the NEXT pose's time, not its own). Two new results, pointing opposite ways, and both are reported. FOR, and calibration-free: the observed full-alpha hold : fade-out ratio on palogo_gamearts is 83 : 13 frames = 6.38. The shifted reading predicts 8.00. The current reading predicts 0.25 -- off by 26x. With the glow's 2 units/frame fixed and nothing else free, the current reading says the logo holds full alpha for 2.0 frames; the capture holds it for 83. This is no longer the shape argument the candidate rested on. Also for: rest()'s plain max-dwell fallback picks a=0 -- a transparent pose, for a publisher logo -- under the current reading, and the visible a=255 hold under the shift. Only the rest_plateau special case rescues the render today, and that is the case the port agent reported a bug in. AGAINST: rendering every build of six UI paks under both readings, 10 of 11 compared are byte-identical and one changes -- GP_TITLE build 7, the Japanese twin of build 4, by 13.1% of pixels. Build 4, the one verified against a live capture, is unchanged either way, so the single build the shift moves is the one with no capture to adjudicate it. The proxy goes against the shift: language twins are the same artwork, and build 7 reads 70.94 mean luminance as decoded against build 4's 71.41, but 76.32 shifted. Correlation does not separate them (0.6206 vs 0.6201). These constrain different things -- timing versus pose selection -- and rest() is a heuristic layered on the times, so moving the times moves its tie-breaks. Adopting the shift means revisiting that heuristic in the same change, with no build-7 capture to verify against. Default UNCHANGED. Experiment reachable via SYLPHEED_KF_TIME_SHIFT=1. Shifted-mode suite: 122 passed, 0 failed across 3 suites at commit time.
1337 lines
55 KiB
Rust
1337 lines
55 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 i32 ⚠️ NOT always 0 — see below
|
||
/// +8 i32 ⚠️ NOT always 0 — see below
|
||
/// +12 i32 ⚠️ NOT always 0 — see below
|
||
/// +16 u32 scale X, percent
|
||
/// +20 u32 scale Y, percent
|
||
/// +24 u32 tint (0xffff_ffff on every frame seen)
|
||
/// +28 i32 X ← signed
|
||
/// +32 i32 Y ← signed
|
||
/// +36 u32 time
|
||
/// ```
|
||
///
|
||
/// ## `+12` is the screen-plane ROTATION, in degrees (2026-08-28)
|
||
///
|
||
/// ✅ **Measured against the framebuffer, not against our own renderer.** The
|
||
/// title's two light sweeps are the nested leaf records `ptloop01.rat` /
|
||
/// `ptloop02.rat`, and their keyframe blocks read `+12` = `30` and `-45`. A
|
||
/// `log_ui_draws` capture of the live title submits those two quads rotated by
|
||
/// **+30.26°** and **-45.28°** — magnitude *and* sign, on two different values.
|
||
/// Positive is clockwise in screen space (Y down).
|
||
///
|
||
/// `+4` and `+8` are 🟡 still unexplained: signed, non-zero in ~4.7 % / 4.6 %
|
||
/// of blocks disc-wide, dominated by `±180` and `±90`. Plausibly rotation about
|
||
/// the other two axes, but nothing observed turns on them.
|
||
///
|
||
/// ⚠️ **`rotation_deg` is decoded but NOT rendered.** [`crate::ui_layout`]'s
|
||
/// blitter draws axis-aligned quads only, so `screen render` still paints a
|
||
/// rotated element upright. See `docs/re/ui-title-build-map.md`.
|
||
///
|
||
/// ⚠️ The earlier note here — *"every element of `GP_TITLE` build 4 has all
|
||
/// three at zero"* — was **wrong about reach, not about the bytes**: build 4's
|
||
/// top-level elements do read zero, but the rotated quads come from its two
|
||
/// **nested** `.rat` leaf records, which the census never opened.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct Keyframe {
|
||
/// The fade colour, ARGB. Its alpha is what ramps an element in.
|
||
pub fade: u32,
|
||
/// Screen-plane rotation in **degrees**, clockwise-positive (`+12`).
|
||
/// Confirmed against a GPU capture; see the type's docs. Not rendered.
|
||
pub rotation_deg: i32,
|
||
/// `+4` / `+8` — signed, meaning unexplained. Carried rather than dropped
|
||
/// so a consumer can see them instead of assuming they are zero.
|
||
pub unknown_4: i32,
|
||
pub unknown_8: i32,
|
||
/// Scale in percent (100 = 1:1).
|
||
pub scale_x: u32,
|
||
pub scale_y: u32,
|
||
/// RGBA tint (`0xffff_ffff` = untinted).
|
||
pub tint: u32,
|
||
/// Top-left position in the design space. **Signed** — off-screen animation
|
||
/// starts are negative.
|
||
pub x: i32,
|
||
pub y: i32,
|
||
/// 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.
|
||
///
|
||
/// **A run that ends on the last keyframe is the exit, not the hold.** A
|
||
/// group carries the screen's entry animation *and* its exit, so the shape
|
||
/// is pre-roll → ramp in → **hold** → ramp out → post-roll, and the last two
|
||
/// of those are often plateaus themselves. The pause menu's `pgptitle.rat`
|
||
/// has three runs of two — invisible, visible, invisible — and taking the
|
||
/// last one erased the word PAUSE, which the capture of the running game
|
||
/// plainly shows (`captures/ui-layout/pause-tutorial-real-vs-rebuilt.png`).
|
||
/// So trailing runs are excluded, and only fall back to if there is nothing
|
||
/// else.
|
||
///
|
||
/// Among the runs that remain, longest wins and a **later** run breaks a tie:
|
||
/// the pre-roll comes first and the hold after it.
|
||
fn rest_plateau(&self) -> Option<&Keyframe> {
|
||
let n = self.keyframes.len();
|
||
if n < 2 {
|
||
return None;
|
||
}
|
||
let same = |a: &Keyframe, b: &Keyframe| {
|
||
a.fade == b.fade
|
||
&& a.scale_x == b.scale_x
|
||
&& a.scale_y == b.scale_y
|
||
&& a.tint == b.tint
|
||
&& a.x == b.x
|
||
&& a.y == b.y
|
||
};
|
||
// (start, len) of the best run that does not end on the last keyframe,
|
||
// and separately of the best run overall.
|
||
let (mut best, mut best_len) = (None, 0usize);
|
||
let (mut any, mut any_len) = (None, 0usize);
|
||
let mut i = 0usize;
|
||
while i < n {
|
||
let mut j = i;
|
||
while j + 1 < n && same(&self.keyframes[j], &self.keyframes[j + 1]) {
|
||
j += 1;
|
||
}
|
||
let len = j - i + 1;
|
||
if len >= 2 {
|
||
if len >= any_len {
|
||
any = Some(i);
|
||
any_len = len;
|
||
}
|
||
// A trailing run is normally the EXIT and is excluded (see the
|
||
// doc comment) — but not always, and the tell is its ALPHA.
|
||
//
|
||
// An exit fades the element out, so its final keyframe is
|
||
// transparent: `pgptitle.rat`'s trailing run is `0x00ffffff`, and
|
||
// taking it erases the word PAUSE. An element with **no exit**
|
||
// ends on its hold, which is whatever it looks like on screen —
|
||
// opaque: `ptframe1`/`ptframe2` on the title main menu end on a
|
||
// run of three at `0xffffffff`, and excluding it dropped the
|
||
// bright circuit bracket the capture plainly shows.
|
||
//
|
||
// So a trailing run is the hold exactly when it is **visible**.
|
||
// (The port agent's report proposed "the final untimed keyframe
|
||
// has the same pose as the last timed one"; that is true of
|
||
// `pgptitle` too and would erase PAUSE, so it is the alpha and
|
||
// not the pose-equality that separates the two.)
|
||
let trailing_is_the_hold = j == n - 1 && (self.keyframes[i].fade >> 24) != 0;
|
||
if (j != n - 1 || trailing_is_the_hold) && len >= best_len {
|
||
best = Some(i);
|
||
best_len = len;
|
||
}
|
||
}
|
||
i = j + 1;
|
||
}
|
||
best.or(any).map(|k| &self.keyframes[k])
|
||
}
|
||
}
|
||
|
||
/// A parsed UI build: one screen layout (one context × language).
|
||
pub struct UiBuild {
|
||
/// Design-space dimensions, normally 1280×720.
|
||
pub design_w: u32,
|
||
pub design_h: u32,
|
||
/// Every element, in the declaration table's back-to-front draw order.
|
||
pub elements: Vec<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> {
|
||
// Experiment gate, default off; see the `time` field below.
|
||
let shift_times = std::env::var("SYLPHEED_KF_TIME_SHIFT").as_deref() == Ok("1");
|
||
let count = elements.len();
|
||
let mut order = Vec::with_capacity(count);
|
||
let mut pos = DECL_TABLE_AT + count * DECL_ENTRY;
|
||
for _ in 0..count {
|
||
if pos + 8 > bundle.len() {
|
||
break;
|
||
}
|
||
let idx = be32(bundle, pos) as usize;
|
||
let frames = be32(bundle, pos + 4) as usize;
|
||
if idx >= count || frames == 0 || frames > 4096 {
|
||
break;
|
||
}
|
||
// 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),
|
||
rotation_deg: be32(bundle, blk + 12) as i32,
|
||
unknown_4: be32(bundle, blk + 4) as i32,
|
||
unknown_8: be32(bundle, blk + 8) as i32,
|
||
scale_x: be32(bundle, blk + 16),
|
||
scale_y: be32(bundle, blk + 20),
|
||
tint: be32(bundle, blk + 24),
|
||
x: be32(bundle, blk + 28) as i32,
|
||
y: be32(bundle, blk + 32) as i32,
|
||
// Only a block wholly inside the group carries a time.
|
||
//
|
||
// ⚠️ Which block a time word BELONGS TO is under test — see
|
||
// `docs/re/ui-keyframe-time-unit.md`. Set `SYLPHEED_KF_TIME_SHIFT=1`
|
||
// to read `W[k-1]` as block `k`'s time ("the word is the time the
|
||
// NEXT pose is reached") instead of `W[k]`. Default is unchanged.
|
||
time: if shift_times {
|
||
(k >= 1).then(|| be32(bundle, blk - KEYFRAME + 36))
|
||
} else {
|
||
(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,
|
||
rotation_deg: 0,
|
||
unknown_4: 0,
|
||
unknown_8: 0,
|
||
scale_x: 100,
|
||
scale_y: 100,
|
||
tint,
|
||
x,
|
||
y,
|
||
time: Some(0),
|
||
}],
|
||
focus_link: opt_link(rec),
|
||
focused: false, // set by `mark_focused_states` once the set is known
|
||
animated: lname.contains("loop"),
|
||
});
|
||
}
|
||
for (i, el) in out.iter_mut().enumerate() {
|
||
el.index = i;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Find a `[scaleX=100, scaleY=100, tint, X, Y]` run inside a `.rat` record.
|
||
/// Records are tag-driven and variable-length, so this anchor beats a fixed
|
||
/// offset — it is only used on the fallback path.
|
||
fn scan_placement_block(rec: &[u8]) -> Option<(u32, i32, i32)> {
|
||
let mut o = 0x58;
|
||
while o + 20 <= rec.len() {
|
||
if be32(rec, o) == 100 && be32(rec, o + 4) == 100 {
|
||
let (tint, x, y) = (
|
||
be32(rec, o + 8),
|
||
be32(rec, o + 12) as i32,
|
||
be32(rec, o + 16) as i32,
|
||
);
|
||
if x > -4096 && x < 8192 && y > -4096 && y < 8192 {
|
||
return Some((tint, x, y));
|
||
}
|
||
}
|
||
o += 4;
|
||
}
|
||
None
|
||
}
|
||
|
||
/// A composited screen image ready to display.
|
||
pub struct ComposedScreen {
|
||
pub width: u32,
|
||
pub height: u32,
|
||
/// Row-major RGBA8.
|
||
pub rgba: Vec<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],
|
||
/// 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<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))
|
||
}
|
||
|
||
/// 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<u32> {
|
||
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<usize> {
|
||
let mut idx: Vec<usize> = (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<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]);
|
||
}
|
||
// 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<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 };
|
||
// 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;
|
||
// Straight alpha-over. `T8aD +0x04` bit 0x02 was tested as an
|
||
// ADDITIVE selector and REFUTED — it moved every metric against the
|
||
// title capture the wrong way (see the doc comment on
|
||
// `T8adImage::flags`), so the bit is carried but not acted on.
|
||
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,
|
||
rotation_deg: 0,
|
||
unknown_4: 0,
|
||
unknown_8: 0,
|
||
scale_x: 100,
|
||
scale_y: 100,
|
||
tint: 0xffff_ffff,
|
||
x,
|
||
y,
|
||
time: Some(time),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn reads_declaration_table_in_draw_order() {
|
||
let b = synth_build(
|
||
&[
|
||
("pgpeff02.t32", u32::MAX, 0, 204, 60),
|
||
("pgpeff02a.t32", 0, 1, 100, 40),
|
||
("pgpbtn00.rat", u32::MAX, 0x3002, 64, 16),
|
||
],
|
||
&[],
|
||
);
|
||
let els = parse_decls(&b).unwrap();
|
||
assert_eq!(els.len(), 3);
|
||
assert_eq!(els[0].name, "pgpeff02.t32");
|
||
assert_eq!(els[0].parent, None);
|
||
// `+32` is a parent element index: the `…a` variant names its base.
|
||
assert_eq!(els[1].parent, Some(0));
|
||
assert_eq!(els[1].kind, 1);
|
||
assert_eq!(els[2].kind, 0x3002);
|
||
assert_eq!((els[0].pivot_x, els[0].pivot_y), (204, 60));
|
||
}
|
||
|
||
#[test]
|
||
fn placement_x_and_y_are_signed() {
|
||
// The Arsenal window animates in from X = -516; read as u32 it would be
|
||
// ~4.29 billion and the element would be discarded.
|
||
let b = synth_build(
|
||
&[("aswindow.t32", u32::MAX, 0, 0, 0)],
|
||
&[(0, vec![kf(-516, 40, 0), kf(120, 40, 30)])],
|
||
);
|
||
let mut els = parse_decls(&b).unwrap();
|
||
parse_placements(&b, &mut els);
|
||
assert_eq!(els[0].keyframes.len(), 2);
|
||
assert_eq!(els[0].keyframes[0].x, -516);
|
||
assert_eq!(els[0].keyframes[1].x, 120);
|
||
}
|
||
|
||
#[test]
|
||
fn rest_is_the_max_dwell_keyframe_not_the_first_or_last() {
|
||
// in (t=0) → hold (t=10..90) → out (t=100): the resting pose is the
|
||
// middle frame, which both "first" and "last" would get wrong.
|
||
let el = Element {
|
||
index: 0,
|
||
name: "x.t32".into(),
|
||
sprite: None,
|
||
parent: None,
|
||
kind: 0,
|
||
pivot_x: 0,
|
||
pivot_y: 0,
|
||
keyframes: vec![kf(-500, 10, 0), kf(226, 268, 10), kf(900, 268, 90)],
|
||
focus_link: None,
|
||
focused: false,
|
||
animated: false,
|
||
};
|
||
let r = el.rest().unwrap();
|
||
assert_eq!((r.x, r.y), (226, 268));
|
||
}
|
||
|
||
#[test]
|
||
fn single_keyframe_is_its_own_rest() {
|
||
let el = Element {
|
||
index: 0,
|
||
name: "x.t32".into(),
|
||
sprite: None,
|
||
parent: None,
|
||
kind: 0,
|
||
pivot_x: 0,
|
||
pivot_y: 0,
|
||
keyframes: vec![kf(546, 288, 0)],
|
||
focus_link: None,
|
||
focused: false,
|
||
animated: false,
|
||
};
|
||
assert_eq!(el.rest().map(|k| (k.x, k.y)), Some((546, 288)));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_a_bundle_with_no_declaration_table() {
|
||
let mut b = vec![0u8; 0x400];
|
||
b[0..4].copy_from_slice(b"RATC");
|
||
b[0x14..0x18].copy_from_slice(&999_999u32.to_be_bytes()); // cannot fit
|
||
assert!(parse_decls(&b).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn opt_link_reads_the_focus_record() {
|
||
let mut r = vec![0u8; 0x60];
|
||
r[0..4].copy_from_slice(b"RATC");
|
||
r[0x20..0x20 + 12].copy_from_slice(b"pgpbtn00.t32");
|
||
r.extend_from_slice(b"opt ");
|
||
r.extend_from_slice(&13u32.to_be_bytes());
|
||
r.extend_from_slice(b"pgpbtn00f.rat");
|
||
assert_eq!(record_sprite(&r).as_deref(), Some("pgpbtn00.t32"));
|
||
assert_eq!(opt_link(&r).as_deref(), Some("pgpbtn00f.rat"));
|
||
}
|
||
|
||
#[test]
|
||
fn scaling_grows_about_the_pivot_not_the_corner() {
|
||
// Measured against the real title screen: `ptbase2.t32` is 640x360 with
|
||
// pivot (320,180), placed at (320,180) with scale 200%. The game draws
|
||
// it as the full-screen background — top-left (0,0), 1280x720. Growing
|
||
// from the keyframe corner instead paints a quarter-screen slab and
|
||
// leaves the top-left quadrant bare. See
|
||
// `docs/re/structures/ui-rat-layout.md`.
|
||
let img = t8ad::T8adImage {
|
||
flags: 0,
|
||
width: 640,
|
||
height: 360,
|
||
rgba: vec![255u8; 640 * 360 * 4],
|
||
};
|
||
let k = Keyframe {
|
||
fade: 0xffff_ffff,
|
||
rotation_deg: 0,
|
||
unknown_4: 0,
|
||
unknown_8: 0,
|
||
scale_x: 200,
|
||
scale_y: 200,
|
||
tint: 0xffff_ffff,
|
||
x: 320,
|
||
y: 180,
|
||
time: None,
|
||
};
|
||
let (w, h) = (1280u32, 720u32);
|
||
let mut canvas = vec![0u8; (w * h * 4) as usize];
|
||
blit(&mut canvas, w, h, &img, &k, 320, 180);
|
||
// Every pixel is covered; the four corners are the cheap witnesses.
|
||
for (x, y) in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)] {
|
||
let i = ((y * w + x) * 4) as usize;
|
||
assert_eq!(
|
||
canvas[i], 255,
|
||
"pixel ({x},{y}) not covered — the background is not full-screen"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_hundred_percent_element_lands_on_its_keyframe_corner() {
|
||
// The other half of the rule, and the reason the corner reading survived
|
||
// this long: at 100% the pivot cancels, so no unscaled element moves.
|
||
// `ptcopyright.t32` is 694x20 at (293,655), and the capture's glyph run
|
||
// starts at x = 295 — inside that rect, not offset by a pivot.
|
||
let img = t8ad::T8adImage {
|
||
flags: 0,
|
||
width: 694,
|
||
height: 20,
|
||
rgba: vec![255u8; 694 * 20 * 4],
|
||
};
|
||
let k = kf(293, 655, 0);
|
||
let (w, h) = (1280u32, 720u32);
|
||
let mut canvas = vec![0u8; (w * h * 4) as usize];
|
||
blit(&mut canvas, w, h, &img, &k, 309, 10);
|
||
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)));
|
||
}
|
||
}
|