INDEX read as if the parser stopped at one level. Measurement says there is nothing deeper: 2859 top-level bundles hold 18002 children at depth 1 and ZERO at depth 2, with no blob failing to parse. The children that are themselves RATC -- the .rat layout records -- are leaves. They carry no child list and reference their siblings BY NAME (the sprite they place, and via "opt " their focused variant): 3311 such leaves, every one embedding sibling names, and 10144 of 10148 references resolve within the same bundle. That is the same by-name convention used one level up (a screen config naming .prt components) and one level up again (the movie table naming <lang>.pak+<member>). The 4 unresolved references are a disc defect, not a parse gap: pmbase.rat in GP_STAGE_CLEAR.pak's four language builds places pmbase.t32, and that sprite exists nowhere -- not a pak member, not a child of those bundles. Second such dangling asset after SUBTITLE_S12B.tbl, so a reimplementation should skip a missing sprite rather than call it a decode failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
341 lines
19 KiB
Markdown
341 lines
19 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).
|
||
|
||
## RATC nesting, measured
|
||
|
||
`INDEX` carried the bundle format as 🟡 "one level deep", which reads like a
|
||
parser limitation. It is not — **there is nothing deeper on the disc**:
|
||
|
||
- **2 859** top-level RATC bundles hold **18 002** children at depth 1 and
|
||
**0** at depth 2, with **no** blob failing to parse.
|
||
- The children that are themselves RATC (the `.rat` layout records) are **leaf
|
||
records**: they carry no child list and instead **reference their siblings by
|
||
name** — the sprite they place and, via `opt `, their focused variant.
|
||
**3 311** such leaves, every one embedding sibling names, and **10 144 of
|
||
10 148** references resolve to a sibling of the same bundle.
|
||
|
||
That is the same by-name convention used one level up, where a screen's config
|
||
names `.prt` components, and one level up again, where the movie table names
|
||
`<lang>.pak+<member>`.
|
||
|
||
**The 4 unresolved references are a real defect on the disc**, not a parse gap:
|
||
`pmbase.rat` in `GP_STAGE_CLEAR.pak` (four language builds) places `pmbase.t32`,
|
||
and that sprite exists **nowhere** — not as a pak member, not as a child of those
|
||
bundles. It is the second such dangling asset found, after
|
||
[`SUBTITLE_S12B.tbl`](../movie-subtitle-link.md); a reimplementation should skip
|
||
a missing sprite rather than treat it as a decode failure.
|