Booted to the ARSENAL and detected the eight teal category chips by colour, then
compared their row centres against the eight prbtn1..8.rat placements parsed from
eng\prmain_scr.prt. All eight fit
screen_Y = placement_Y + pivotY(14) + chrome(45)
with residuals -1,-1,-1,-2,0,-2,-1,-2 px. The spacings are the real signature:
predicted 54,56,55,54,56,55,55 against observed 54,56,54,56,54,56,54 -- an
irregular alternating pattern, not a round number that could match by luck. The
one free parameter is the 45 px emulator window chrome, which is not part of the
game, and the residual is centroid measurement noise.
Confirms three things together: the declaration table is the element set, the
placement region gives real screen coordinates, and the pivot composes additively
(pivotY 14 = half the 28 px chip, so placement is top-left as documented).
Explicitly NOT confirmed: the max-dwell rule for animated elements. These buttons
are static -- every keyframe identical -- which is exactly what makes them a clean
ruler. That rule needs an element captured mid-slide.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
317 lines
18 KiB
Markdown
317 lines
18 KiB
Markdown
# `.rat` — the UI element layout / animation record
|
||
|
||
**Status:** ✅ `CONFIRMED` for placement (2026-07-28). The retail UI can be
|
||
**reassembled from the disc**: the tutorial PAUSE menu rebuilds pixel-accurately from its
|
||
sprites placed at the coordinates in their `.rat` records — no fitting, no manual nudging.
|
||
|
||

|
||
|
||
*Left: the running game (Canary screenshot). Right: rebuilt from `GP_PAUSE_MENU.pak` alone.
|
||
The remaining differences are the animated frame/glow sprites (`*eff*`) that were not
|
||
placed, and the live 3D background.*
|
||
|
||
## Screen composition
|
||
|
||
The UI is **one pak per screen** — `GP_TITLE`, `GP_PAUSE_MENU`, `GP_READY_ROOM`,
|
||
`GP_SAVE_LOAD`, `GP_MISSION_SELECT`, `GP_OPTIONS`, `GP_SYSTEM`, `GP_TUTORIAL`,
|
||
`GP_STAGE_CLEAR`, `GP_MOVIE_THEATER`, `GP_DEBRIEFING_PILOTLOG`, `GP_LEADERBOARD`,
|
||
`GP_MISSION_LOG`, `GP_BUNK`, `GP_DIALOG`, `GP_GAMEOVER`, `GP_CHALLENGE`,
|
||
`GP_HANGAR_ARSENAL`.
|
||
|
||
Inside a screen pak, each top-level [RATC](../INDEX.md) bundle is **one (context × language)
|
||
build of that screen**. Its own header is the screen's **element declaration table**, and
|
||
the elements themselves follow as children:
|
||
|
||
| child | what it is |
|
||
|---|---|
|
||
| `<name>.t32` | the sprite ([T8aD](texture-color-k8888.md)) |
|
||
| `<name>.rat` | that sprite's **layout record** (this document) |
|
||
| `<screen>loop1.rat` | a looping sprite animation (see below) |
|
||
|
||
### The bundle header — element declaration table
|
||
|
||
```
|
||
0x14 u32 entry count
|
||
0x20 entry[count], 60 bytes each:
|
||
+0 char[28] element name, NUL-padded ("pgp_ttrl_eff10.t32", "pgp_ttrl_btn10.rat")
|
||
+28 u32 0 on every entry seen
|
||
+32 u32 PARENT element index, or 0xffffffff for "none" ← see below
|
||
+36 u32 0xffffffff on every entry seen
|
||
+40 u32 kind flags 0 for a plain sprite, 0x3002 for a button record,
|
||
1 for an element that has a parent
|
||
+44 u32 0xffffffff, but 0 on button records
|
||
+48 u32 pivot X
|
||
+52 u32 pivot Y
|
||
+56 u32 0
|
||
```
|
||
|
||
**Corrected 2026-08-11:** this was previously written as "`+28` u32 ×4 flags" — it
|
||
is **five** words, and the second of them is not a flag.
|
||
|
||
**`+32` is a parent element index.** In the in-mission pause bundle,
|
||
`pgpeff02a.t32` carries `3` and element 3 is `pgpeff02.t32`; `pgpeff03a.t32`
|
||
carries `5` and element 5 is `pgpeff03.t32` — each `…a` variant naming its base
|
||
element. Verified on two independent language builds of that screen (4 links, no
|
||
out-of-range value anywhere in any bundle), and the tutorial bundle, which has no
|
||
`…a` variants, carries `0xffffffff` throughout.
|
||
|
||
The table lists **both** sprites and `.rat` records — it is the screen's element list.
|
||
`pgp_ttrl` declares 11: six `eff*`, `msg`, and four `.rat`s (`title`, `btn10..12`).
|
||
|
||
✅ **Verified:** for all 7 `.t32` entries the declared pivot is *exactly* half the decoded
|
||
texture's dimensions — `eff10` 408×120 → 204,60; `eff21` 428×360 → 214,180; `msg` 381×38 →
|
||
190,19; and so on, 7/7 with no mismatch (`tools/re-capture/ratc_decls.py`).
|
||
|
||
`GP_PAUSE_MENU.pak`'s six bundles are `{in-mission, tutorial} × {English, Japanese}`, with
|
||
the two in-mission builds each present twice at identical size. The purpose of that
|
||
duplicate is **NEEDS-HUMAN** (resolution or aspect variant?), and where the other four
|
||
shipped languages live is likewise unresolved — this pak holds only EN and JP.
|
||
|
||
Naming is transparent: `pgp` = pause screen, `pgp_ttrl_` = its tutorial context, `btnNN` =
|
||
menu item, `btnNNf` = that item's **focused** sprite, `eff*` = frame/glow decoration,
|
||
`deli*` = the item divider, `title`, `msg`.
|
||
|
||
## Record layout
|
||
|
||
Big-endian u32 throughout (Xbox 360), and **tag-driven**: 4-char ASCII tags (`opt `,
|
||
`PRMD`, `end `) mark sections, so a record is a stream of blocks rather than a fixed struct.
|
||
A minimal record (a static button) is 165 bytes:
|
||
|
||
```
|
||
0x00 "RATC" magic — a RATC bundle reused as a data record
|
||
0x08 u32 payload size
|
||
0x14 u32 entry count (loop1.rat: 3, matching its 3 sprite names)
|
||
0x18 u32 design width = 1280
|
||
0x1c u32 design height = 720
|
||
0x20 char[16] the sprite this record places, e.g. "pgpbtn00.t32"
|
||
0x50 u32 pivot X = texture width / 2
|
||
0x54 u32 pivot Y = texture height / 2
|
||
...
|
||
── placement block ──
|
||
u32 scale X = 100 (percent)
|
||
u32 scale Y = 100
|
||
u32 tint = 0xffffffff (RGBA, white = untinted)
|
||
u32 X ← top-left position
|
||
u32 Y ←
|
||
u32 time (keyframe records only)
|
||
...
|
||
"opt " u32 len char[len] link to another record, e.g. "pgpbtn00f.rat"
|
||
```
|
||
|
||
- **X/Y is the sprite's top-left**, not its centre: compositing at these coordinates
|
||
reproduces the screenshot, which drawing centred on them would not.
|
||
- **The pivot at 0x50/0x54 is half the texture size** — 4 of the 5 plain sprites match
|
||
exactly (`pgpbtn00` 86×42 → 43,21; `pgpbtn05` 221×42 → 110,21; `pgptitle` 202×73 →
|
||
101,36; `pgpbtn04` 172×43 → 86,22). It is a rotation/scale centre, not a draw offset.
|
||
- **Animated elements are a keyframe list.** `pgptitle.rat` (752 B) is the same placement
|
||
block repeated with a varying trailing `time` field — the PAUSE title's fly-in.
|
||
- **`opt `** carries a length-prefixed record name. On `pgpbtn00.rat` it points at
|
||
`pgpbtn00f.rat`, i.e. *normal state → focused state*. Focused records place their sprite
|
||
42 px left and 8 px up of the base, because the focused art includes the selection ring
|
||
that hangs off the left edge; their pivot is a constant (21,25) rather than half-size.
|
||
- `<screen>loop1.rat` is **not** a screen composition — it is a looping sprite animation:
|
||
a 3-name table (`pgpeff34/35/36.t32`) plus ~30 keyframes all at one position.
|
||
- The small (380 B) top-level entries are **`PRMD` primitives**, not sprites: a colour and
|
||
four explicit corner coordinates `(0,0) (1280,0) (0,720) (1280,720)` — the full-screen
|
||
quad that dims the scene behind the pause menu — terminated by `end `.
|
||
|
||
### The records are language-independent
|
||
|
||
`pgpbtn00.rat` is **byte-identical** in the English and Japanese bundles. The layout is
|
||
authored once and only the `.t32` sprites are swapped, which has two consequences:
|
||
|
||
- The baked pivot belongs to *whichever build the record was authored from*, not to the
|
||
sprite actually shipped beside it. That is why `pgpbtn01`'s pivot (113 → a 226 px wide
|
||
texture) matches neither the English sprite (207) nor the Japanese one (148).
|
||
- The split is visible inside a single bundle: in the **English** tutorial build, every
|
||
`.t32` declaration carries the correct English pivot (7/7), while the `.rat` declarations
|
||
carry Japanese-derived ones (`btn10` → 43,21 = 86/2, the *Japanese* sprite). So the
|
||
`.t32` table is regenerated per language and the `.rat` layer is inherited from the
|
||
Japanese master.
|
||
- **Do not infer anything about a language from a texture size** — see the traps below.
|
||
|
||
## Evidence
|
||
|
||
Positions were read out of the records and checked against a screenshot of the running
|
||
game, twice, in that order — the records were never fitted to the picture.
|
||
|
||
1. **Differential.** Across `pgpbtn00/01/04/05.rat`, exactly one field varies and it steps
|
||
`268 → 338 → 408 → 478` — a constant 70 px pitch — while the field before it is 226 in
|
||
all four. A vertical menu: constant X, evenly spaced Y.
|
||
2. **Absolute placement — the decisive test.** The *tutorial* build's records give
|
||
546/288, 546/358, 546/428 and 540/119. Compositing its sprites at exactly those numbers,
|
||
with no offset and no fitting, reproduces the screenshot (image above).
|
||
3. **Pivot.** 4 of 5 plain sprites carry exactly half their texture's dimensions at
|
||
0x50/0x54 (above).
|
||
|
||
> A caution on step 2, because the first pass here got it subtly wrong: the screenshot is
|
||
> of the **tutorial** pause menu, so only the `pgp_ttrl_*` records can be checked against
|
||
> it. The in-mission records (X = 226) also map onto the same screenshot under a single
|
||
> constant offset — but that only works because both builds share the 70 px pitch, and it
|
||
> proves nothing. The in-mission coordinates remain **unverified**: confirming them needs a
|
||
> screenshot of a pause during an actual mission.
|
||
|
||
## It generalizes — the title screen
|
||
|
||
The same method run against `GP_TITLE.pak` reproduces the **main menu**, which is a
|
||
different screen with a different item count and a different pitch:
|
||
|
||

|
||
|
||
`ptbtn01..05.rat` give X = 542 for all five and Y = 162 / 242 / 322 / 402 / 482 — an
|
||
**80 px** pitch, where the pause menu used 70. Measured against the screenshot, the sprite
|
||
tops land at a constant **+46 px** for all five (one reads 45, a 1-px edge-detection
|
||
wobble), and 46 is exactly the 45 px of Xenia window chrome plus one. So the record's Y is
|
||
the sprite's top edge in the guest framebuffer, to the pixel, on a second screen.
|
||
|
||
`GP_TITLE.pak` also splits by sub-screen the way the pause pak splits by context:
|
||
`ptbtn00` alone (the `PRESS Ⓐ BUTTON` prompt), `ptbtn01..05` (main menu), `ptbtn11..13`
|
||
(the EXTRAS submenu), plus `pgloading_*` for the loading screen.
|
||
|
||
## Two traps this caught
|
||
|
||
Both were mistakes made during this analysis, caught by comparing against the real game —
|
||
recording them because a static-only reading would have shipped them:
|
||
|
||
- **Texture width does not identify a language.** English `RESUME` (166 px) and Japanese
|
||
`再開` (86 px) differ hugely, but Japanese `通信ログ` (148 px) is within a few px of an
|
||
English label. The first language assignment made here was wrong; rendering the sprites
|
||
is the only reliable check. (The records being language-independent makes this worse:
|
||
a record's baked pivot implies a texture width that matches *no* shipped sprite.)
|
||
- **The in-mission and tutorial pause menus are different sprite sets, not one set
|
||
re-packed.** In-mission is `RESUME / RADIO LOG / OPTIONS / BACK TO TITLE` (4 items,
|
||
`pgpbtnNN`); the tutorial is `RESUME / OPTIONS / BACK TO MENU` (3 items,
|
||
`pgp_ttrl_btn1N`). Matching the 3-item screenshot against the 4-item set suggests a
|
||
runtime slot-packing rule that does not exist.
|
||
|
||
## Next
|
||
|
||
- **Half of this gap is now closed.** The `eff*` / `deli*` / `msg` sprites have no
|
||
`.rat` of their own, and `loop1.rat` is an animation, so the question was where the
|
||
screen's draw list lives. **It is the bundle's own declaration table** (above): that
|
||
table is not the child listing — the children are grouped by type (every `.t32`, then
|
||
every `.rat`), while the declaration table names *elements*, in a plausible back-to-front
|
||
order, and it is exactly the missing set that appears there. For the in-mission pause
|
||
menu it lists 23: `eff11/12/10`, `eff02(+a)`, `eff03(+a)`, `eff01`, `title.rat`,
|
||
`btn00/01/04/05.rat`, `msg`, `deli1…4`, `eff30…33`, `loop1.rat`. Note what it does
|
||
**not** list: the focused button variants, which are reached through each base record's
|
||
`opt ` link — so the table is the screen's element set, not a resource inventory.
|
||
- **And the position is there too — the gap is closed.** Immediately after the
|
||
declaration table the bundle carries a **placement region, one group per element**:
|
||
|
||
```
|
||
group header: u32 element index (0,1,2… — the correspondence is stated, not inferred)
|
||
u32 keyframe count
|
||
then `count` keyframes, 40 bytes apart, each holding:
|
||
u32 scale X = 100 u32 scale Y = 100
|
||
u32 tint = 0xffffffff
|
||
i32 X i32 Y ← SIGNED
|
||
u32 time u32 flags/terminator
|
||
```
|
||
|
||
**Three things here are easy to get wrong, and were:**
|
||
|
||
- **X and Y are signed.** Off-screen animation starts are negative — the Arsenal's
|
||
weapon-list window begins at **X = −516**. Read as unsigned it is 4 294 966 780,
|
||
which a naïve implementation would happily draw four billion pixels to the right.
|
||
- **The trailing word is a time**, and a group is an **in → hold → out**
|
||
animation. `prselect_win1` runs `t=4:−516 → 6:−71 → 7:81 → 8:127 → 23:134 →
|
||
24:134 → 25:127 → 27:81 → 31:−71 → 1:−516`.
|
||
- **So neither the first nor the last keyframe is where the element sits.** Both
|
||
are off-screen for an animated element. The resting position is the
|
||
**max-dwell** keyframe — the one with the longest gap to the next time — which
|
||
for that window is **(127,155)…(134,155)**, matching where the list panel
|
||
actually appears. `screen_layout.rs` reports that, not `final`.
|
||
|
||
For the tutorial pause bundle this yields 11 groups for 11 elements, and every
|
||
group's header index and count match the blocks actually present. The values are
|
||
self-evidently right: the three menu buttons sit at X=546, **70 px apart**
|
||
(288 / 358 / 428) — static, every keyframe identical, which is why their reading
|
||
was unaffected by the first/last mistake above — the title at (540,119), the
|
||
message at (451,545), and the `eff*` sprites carry multi-position fly-ins.
|
||
|
||
**Cross-checked against the records themselves:** `pgp_ttrl_btn10.rat` places its
|
||
sprite at **(546,288)** — identical to its inline group. So the inline region is
|
||
the same placement data, and it covers the `eff*` / `deli*` / `msg` elements that
|
||
have no record of their own.
|
||
|
||
A screen is therefore fully reconstructible from its bundle alone: **element list
|
||
and order** (declaration table) + **placement and animation** (this region) +
|
||
sprites, with `opt ` supplying focused states.
|
||
- The same method should now unroll the other screens directly; `GP_HANGAR_ARSENAL.pak`
|
||
(789 T8aD + 510 RATC) is the big one, and the ARSENAL `DATA SHEET` panel documented in
|
||
[weapon-datasheet-runtime.md](../weapon-datasheet-runtime.md) is a ready-made oracle for it.
|
||
- Tooling: `crates/sylpheed-formats/examples/ui_screen.rs` (inventory a screen pak, carve a
|
||
named RATC child), `sylpheed-cli pak textures` (decode every sprite).
|
||
|
||
## It generalises: the ARSENAL screen, and how a multi-component screen composes
|
||
|
||
**2026-08-11.** [`examples/screen_layout.rs`](../../../crates/sylpheed-formats/examples/screen_layout.rs)
|
||
dumps a bundle's declaration table and placement region together. It reproduces
|
||
the tutorial pause menu exactly, and it reads the ARSENAL the same way
|
||
([capture](../captures/arsenal-main-screen-layout.txt)) — 23 elements that match
|
||
the running game:
|
||
|
||
- **Eight buttons** `prbtn1…8.rat` at **X=242**, Y `166, 220, 276, 331, 385, 441,
|
||
496, 551` — evenly spaced, and eight is exactly what the screen's own config
|
||
declares with `WEAPON_CATEGORIES = 8` (GUN, BEAM, LASER, MPM, ASM, B/R, CANNON,
|
||
SPECIAL, the list photographed in the Arsenal).
|
||
- **`prexp3.t32` declared seven times** at X=726, 34 px apart (`381 … 585`) — the
|
||
DATA SHEET's rows.
|
||
- `prexp1.t32` animates `(726,143) → (1286,143)`, sliding off the right edge, with
|
||
`prexp1a.t32` **parented to it** (`parent = 13`).
|
||
- `prmsg.t32` at (151,645) — the description line along the bottom.
|
||
|
||
### Two things this adds
|
||
|
||
- **`kind = 0x4` marks a repeated instance.** `prexp3.t32` appears once with
|
||
`kind 0x0` and then six more times with `0x4`, each with its own placement — the
|
||
game repeats one row template rather than shipping seven sprites. So the element
|
||
*name* is not a key; the declaration index is.
|
||
- **A screen is composed of named components.** The pause menu is one bundle, but
|
||
`GP_HANGAR_ARSENAL.pak` holds **510** RATC entries because its screens are built
|
||
from the `.prt` components its config names (`Menu = prmain_scr.prt`,
|
||
`Select_Window = prselect_scr.prt`, `Detail_Window_Known = prselect_win3.prt`, …).
|
||
Those resolve **under the config's own `PATH` prefix**: `prmain_scr.prt` is not
|
||
present, `eng\prmain_scr.prt` is — the same `<lang>+<member>` convention the
|
||
[movie table](../movie-subtitle-link.md) uses. A sub-component reads identically:
|
||
`psselect_win1` declares 4 elements, three of them **parented to element 0**,
|
||
which is the parent-index field doing real work on an independent pak.
|
||
|
||
### Validated against the running game, to ±2 px
|
||
|
||
The layout above is a static parse; this is the check that it predicts what the
|
||
game actually draws. Booting to the ARSENAL and detecting the eight teal category
|
||
chips by colour gives their row centres, against the eight `prbtn1…8.rat`
|
||
placements from `eng\prmain_scr.prt`:
|
||
|
||
```
|
||
placement Y + pivotY(14) + chrome(45) observed centre diff
|
||
166 225 224 -1
|
||
220 279 278 -1
|
||
276 335 334 -1
|
||
331 390 388 -2
|
||
385 444 444 +0
|
||
441 500 498 -2
|
||
496 555 554 -1
|
||
551 610 608 -2
|
||
```
|
||
|
||
The spacings are the real signature: **54, 56, 55, 54, 56, 55, 55** predicted
|
||
against **54, 56, 54, 56, 54, 56, 54** observed — an irregular alternating pattern,
|
||
not a round number that could match by luck. The single free parameter is the
|
||
**45 px emulator window chrome** (title bar + menu bar), which is not part of the
|
||
game; the ±2 px residual is the centroid measurement, since a thresholded chip
|
||
centre is not exact.
|
||
|
||
This confirms three things at once: the **declaration table is the element set**,
|
||
the **placement region gives real screen coordinates**, and the **pivot composes
|
||
additively** — `pivotY = 14` is half the 28 px chip, so placement is the top-left
|
||
and pivot carries the centre offset, exactly as the record layout says.
|
||
|
||
**Not** confirmed by this: the max-dwell rule for *animated* elements. These eight
|
||
buttons are static (every keyframe identical), which is precisely why they make a
|
||
clean ruler. Validating the animation rule needs an element captured mid-slide.
|
||
|
||
Evidence: [`captures/arsenal-layout-validation.png`](../captures/arsenal-layout-validation.png).
|