This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/docs/re/structures/ui-rat-layout.md
Sylpheed RE agent d9a2229217 formats: what opt links, measured - a record-to-record reference, focus is one use of it
Two readings were on record and both were wrong in different directions: the
structure doc called it "normal state -> focused state" from a single example,
and the backlog called it "refuted as focus; unexplained otherwise".

Classified every link reachable from a declaration table: 1467 links, and ALL
1467 resolve to a RATC child of their own bundle, ALL are .rat -> .rat, none
dangle and none self-link. 1076 (73%) match the <stem>f focus pattern; the other
391 are chains between effect records - px_bunk_eff01 -> pjex_eff -> pjex_eff07,
pveff01 -> pjeff02 -> pjeff21 - which also explains why only 227 targets are
themselves declared elements: the middle of a chain is, the end is not.

So `opt ` is a record-to-record reference within the bundle, and focus is its
commonest use rather than its meaning.

Coverage is stated rather than glossed: the bundles hold 18718 raw `opt ` tags
against the 1467 classified, because opt_link reads the first tag of a DECLARED
element's record. Roughly 92% of occurrences sit deeper in the chains (or are
byte coincidences - the scan is unaligned) and are untested. The numbers are
asserted so the answer cannot drift back into an anecdote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-24 04:09:18 +00:00

27 KiB
Raw Blame History

.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.

real vs rebuilt

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 screenGP_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 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)
<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 .rats (title, btn10..12).

Verified — but see the disc-wide correction at the end of this document, which demotes this to a property of this bundle: 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:

main menu real vs rebuilt

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. "Plausible" is as far as that goes — a framebuffer capture of the title screen refutes it on that screen (its background is element 13 and it is drawn behind elements 05), and no other ordering in the bundle replaces it; see BACKLOG.md. 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 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 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) — 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 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 additivelypivotY = 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.

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; a reimplementation should skip a missing sprite rather than treat it as a decode failure.

The pivot is the scale centre — measured against the framebuffer (2026-08-18)

Status: CONFIRMED that a keyframe's scale grows the element about its declared pivot rather than about the keyframe's own corner. 🟡 the pivot and the sprite centre are not told apart by this measurement — see the scope below.

The compositor had been reading a keyframe as top-left = (X,Y) and size = decoded · scale, ignoring the pivot entirely. That is right for every element at 100 %, which is why the pause menu and the ARSENAL rulers above never caught it, and wrong for every element that is scaled.

The measurement

GP_TITLE.pak build 7, element 13:

ptbase2.t32   decoded 640x360   pivot (320,180)   one keyframe: (320,180) 200%,200%

Composited from the corner that is a 1280×720 rect at 320..1600 × 180..900 — a quarter-screen slab hanging off the bottom-right, with the top-left quadrant bare. Anchored at the pivot it is (320,180) (320,180)·(21) = (0,0), i.e. exactly the screen.

The oracle is a framebuffer capture of Canary sitting on the title screen (captures/title-screen-oracle.png, the game surface cropped out of the window at offset (1,45), 1:1, the bottom 45 rows clipped by the display):

  • the background art reaches all four edges, so the corner reading is refuted outright;
  • normalised cross-correlation of the composite's background against the capture, searched over ±40 px in both axes, peaks at (0,0) — 0.90 on the planet limb, 0.72 on the lower-left ship. Not "close": the correct shift is the argmax.

Calibration for that crop is ptcopyright.t32, which is unscaled: 694×20 placed at (293,655), and the capture's glyph run is x 295…986, y 700…718 — inside that rect to the pixel once the 45 px of window chrome is taken off. So at 100 % the keyframe really is the top-left, as this document already said.

Residual, stated rather than glossed: the composite is uniformly brighter than the capture (about +14 in the dark starfield) and less blue in the planet. That is not placement — it is the two full-screen PRMD elements (pteff00.prm, pteff02.prm at fade-alpha ≈ 204) and pteff04.t32 that the compositor does not draw at all. Their geometry is known (a colour and four corners); their blend is not.

Scope, and what this does not settle

Swept over every screen build on the disc (tests/ui_paint_order_disc.rs):

count of 5 130 resting placements with a decoded sprite
scaled ≠ 100 % — i.e. moved by this fix 865 17 %
of those, where "about the pivot" and "about the sprite centre" differ by ≥2 px 213 4 %

ptbase2's pivot is half its size, so the capture proves only that scaling is not about the keyframe corner. Pivot-anchoring is chosen because that is the field the format carries and this document already called it "a rotation/scale centre"; the 213 placements that could tell the two apart are unmeasured, and a capture of any one of them mid-animation would settle it.

Correction: "pivot = half the texture" does not generalise

The above — "for all 7 .t32 entries the declared pivot is exactly half the decoded texture's dimensions, 7/7 with no mismatch" — is a property of the tutorial pause bundle, not of the disc. Across all 5 130 placements:

  • 2 521 (49 %) have pivot·2 equal to the decoded size within 1 px;
  • 1 884 (37 %) are off by more than 16 px.

Some of that is already explained here — the .rat layer is inherited from the Japanese master, so its baked pivot belongs to another language's sprite. But it also happens on .t32 declarations (pgpeff02.t32 in the in-mission pause build: decoded 265×198, declared pivot 19,18), so the rule is not merely a language-inheritance artefact. Do not derive a texture size from a pivot; the capture backs the decoded size, not the pivot — ptcopyright is 694 px wide in the framebuffer, and pivot·2 says 618.

🔴 The declaration entry does NOT mark a focused state (2026-08-24)

The backlog carried this as the cheapest open question about the entry: kind is a flags word — 0x10 is an untextured primitive, 0x4 a repeated instance, 0x3002 a button record — so a focus bit would be the obvious answer, and the name-pairing in mark_focused_states (pgmenu_btn00f.t32 next to pgmenu_btn00.t32) would be a convention standing in for a real field.

It is not standing in for anything. Swept over every screen build on the disc (tests/ui_focus_kind_disc.rs, asserted so it cannot rot back into a suspicion):

name-paired focused/base pairs on the disc 54
pairs whose kind is identical 54 — all of them, and all kind = 0x0
kind bits set on the focused entry and clear on its base none, on any pair
words of the 60-byte entry that ever differ +48 and +52 only — the pivot

So the two entries differ in where the sprite sits and in nothing else. There is no focus field in the declaration table, and the naming pairing is the only signal the file gives.

Worth noting in passing: these buttons carry kind = 0x0, so the documented 0x3002 "button record" belongs to the .rat records, not to the .t32 sprites that a menu draws for its buttons.

Still open: whether the focused state is marked anywhere else — the .rat record, the RATC child stream, or (as with the paint order) only in the game's code. This closes the declaration table, not the question.

Two readings were on record and both were wrong in different directions: this file called it normal state → focused state from a single example (pgpbtn00.rat → pgpbtn00f.rat), and the backlog called it "refuted as focus; unexplained otherwise". Classifying every link reachable from a declaration table (tests/ui_opt_link_disc.rs) gives a distribution instead:

opt links classified 1 467
resolve to a RATC child of the same bundle 1 467 — all of them
.rat.rat 1 467 — all of them
match the focus pattern <stem>f.<ext> 1 076 (73 %)
whose target is also a declared element 227
link to themselves 0

So opt is a record→record reference within the bundle: a record naming another record it uses. It never dangles, never points at a sprite, and never points at itself. Focus is the commonest use of that mechanism, not its meaning.

What the other 27 % are: chains. The non-f targets are effect records referring to further effect records, and following them shows depth:

px_bunk_eff01.rat → pjex_eff.rat  → pjex_eff07.rat
px_bunk_eff01.rat → pjnet_bg.rat  → pjnet_loop1.rat
pveff01.rat       → pjeff02.rat   → pjeff21.rat
pjeff03.rat       → pjeff03_sub.rat

Note the middle name in each chain is a declared element while the third is not — which is exactly why only 227 targets are elements.

⚠️ Coverage, stated because it bounds all of the above. The bundles contain 18 718 raw opt byte-tags against the 1 467 links classified here. opt_link() reads the first tag of the record belonging to a declared element, so roughly 92 % of occurrences sit on records deeper in the chain (or are byte coincidences in binary data — the scan is unaligned). What those say is untested. The claims above are about the links a screen's element table can reach, which is what a compositor follows; they are not a statement about every opt in the file.