# `.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](captures/ui-layout/pause-tutorial-real-vs-rebuilt.png) *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 | |---|---| | `.t32` | the sprite ([T8aD](texture-color-k8888.md)) | | `.rat` | that sprite's **layout record** (this document) | | `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 — 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. - `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](captures/ui-layout/title-mainmenu-real-vs-rebuilt.png) `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 0–5), and no other ordering in the bundle replaces it; see [`BACKLOG.md`](../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](../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 `+` 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 `.pak+`. **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. ## 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)·(2−1) = (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`](../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. ## 🟡 What `opt ` links — measured over the whole disc (2026-08-24) 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 `f.` | **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. ## The 32-byte bundle header — swept (2026-08-24) The backlog asked what makes a bundle a **screen** rather than a fragment, and the obvious suspect was the bundle header: six words besides the `RATC` magic and the entry count at `0x14`, none of them read by anything. Swept over all **2 859** composable bundles with a real declaration table (`tests/ui_screen_vs_fragment_disc.rs`): | offset | distinct values | reading | |---|---|---| | `+0x04` | **3** — `0x3C0000` ×2 843, `0x1E0000` ×12, `0x3C0001` ×4 | 🟡 **frame rate in 16.16**: `0x3C0000` is exactly `60.0`, `0x1E0000` exactly `30.0` | | `+0x08` | 22 — 30, 1200, 120, 60, … | ✅ the **animation length**, in the same unit as a keyframe's `time` — see below | | `+0x0c` | 179 as a u32 — but it is **two u16 fields**, see below | ✅ structure, 🟡 meaning | | `+0x10` | zero `u16` then a 16-bit flag word at `+0x12` | ✅ structure, ❔ bit meanings | | `+0x18` | **2** — `1280` ×2 829 | ✅ **design width** | | `+0x1c` | **3** — `720` ×2 823 | ✅ **design height** | So the header is not dead space. `+0x18`/`+0x1c` are the design resolution at bundle level — the same pair the parser already reads out of a `.rat` record — and the two words before the count look like a frame rate and a duration, which would fit a format whose records are keyframe lists. 🟡 The rate/duration reading is from the **values alone** and is not verified against an animation; the resolution one is asserted. ### 🔴 But no bit of it says "screen" Cross-tabulating every bit of `+0x10` against the two shapes a screen would have: ``` bundles 2859 with a full-screen (1280x720) element 365 with >=10 elements 464 bit 15: 2605 set, 347 full-screen <- set on 91% of everything bit 12: 2013 set, 299 full-screen bit 13: 403 set, 179 full-screen <- the best enrichment: 44% vs a 12.8% base ``` The best any bit manages is **44 %** against a **12.8 %** base, and the most common bit is set on nine bundles in ten. That is enrichment, not a label, and the test asserts it so a future pass does not re-litigate it from one example. ### What a "screen" looks like, since the file will not say Element counts over those 2 859 bundles: **min 1, p25 1, median 2, p75 5, p95 23, max 56**, and only **365** carry a full-screen element. The population really is mostly fragments, and the separation is **shape** — or which bundle references which, which the PAK cannot answer directly because its entries are name-hashed. ### ✅ `+0x08` is the animation length — checked against the keyframe times The rate/duration reading came from the values alone, so it was checked against something the file states independently: the `time` field of the keyframes in the placement region (`tests/ui_header_time_disc.rs`). Over the **2 313** bundles that have both keyframe times and a non-zero `+0x08`: | | | |---|---| | largest keyframe time **≤** `+0x08` | **2 313 — every one** | | largest keyframe time **>** `+0x08` | **0** | | reaches it **exactly** | **444** | and the ratio `max_time / +0x08` peaks at **1.0** (520 bundles) rather than near zero: ``` tenths: 0.0→92 0.1→128 0.2→201 0.3→198 0.4→92 0.5→110 0.6→194 0.7→159 0.8→293 0.9→326 1.0→520 ``` That histogram is the refutation attempt, and it is why the bound is not vacuous: a large unrelated constant would also bound every time, but the ratios would then pile up near zero. They peak at exactly 1. **Stated precisely, because the units are a separate claim:** what is proven is that `+0x08` is the animation's **length in the same unit as the keyframe `time` field**. That the unit is *frames* still rests on the values themselves (30 / 60 / 120 / 1200) and on the 16.16 reading of `+0x04`. 🟡 **`+0x04` stays amber, and looking closer WEAKENED it.** The twelve bundles at `0x1E0000` are not twelve witnesses: they are **two** bundles repeated across six language PAKs — ``` pghud_range_main_em.t32 + pghud_range_main_emeff.t32 dur 30 pghud_range_nose_em.t32 + pghud_range_nose_emeff.t32 dur 30 ``` — so the whole "30.0 bundles are shorter" observation rests on two authored assets, and a 30-frame flash is equally consistent with 0.5 s at 60 and 1 s at 30. 🔴 **And the four `0x3C0001` bundles argue against a clean 16.16 value.** As fixed-point that is 60.0000152 fps, which nobody authors; the four are `py_ranking_jump`/`py_ranking_next` dialogs in `GP_DIALOG.pak`, all with `dur = 60`. The better reading is **`.`** — a rate-like number in the high half (60 or 30) and a small field in the low half that is 0 on 2 843 bundles and 1 on four. What that low field means is ❔. Nothing here measures a wall-clock duration, and **nothing in this container can**: the emulator runs on software Vulkan far from real time, so timing an animation would measure lavapipe, not the game. ## ✅ `+0x0c` is two `u16`s forming an ordered interval **2026-08-26.** Read as one `u32`, `+0x0c` looks meaningless — 179 distinct values ranging to 248 581 842. Read as **two big-endian `u16`s** it is highly structured. The tell is in the raw values: `0x0007000F`, `0x000F001A`, `0x003C0064` — small numbers in both halves. Over all **2 985** RATC bundles on the disc: | | | |---|---| | `high < low` | **2 985 / 2 985 (100 %)** | | `high == low` | 0 | | `high > low` | 0 | | `high ≤` animation length `+0x08` | **2 985 / 2 985** | | `low ≤` animation length `+0x08` | **2 985 / 2 985** | `high` is 0 in 34.9 % and ranges 0–3 793; `low` ranges 1–3 794; the span `low − high` runs 1–1 200 and clusters on 1, 10, 30, 8, 20. `low` equals the animation length exactly in 4.0 %. So the field is **an ordered pair bounded by the animation length, in the same unit as `+0x08` and the keyframe times** — a time interval. A strict ordering holding 2 985 times with no exceptions is not a coincidence, and it rules out the field being flags or a packed count. 🟡 **Which interval is not settled.** A playback range, a loop region and an active window all fit an ordered pair inside the animation, and nothing measured here separates them. ### ❌ The derived-summary reading is refuted — the interval is authored Tested as promised, by parsing every bundle's keyframe times (all **2 985** parse) and comparing against `(high, low)`: | | | |---|---| | `high == min` keyframe time | 1 022 (34.2 %) | | `low == max` keyframe time | **6 (0.2 %)** | | **both** — the derived reading | **6 (0.2 %)** | **And the 34.2 % is a coincidence of zeros.** The minimum keyframe time is 0 in **96 %** of bundles, and `high` is 0 in 34.9 % — the 1 022 "matches" are exactly the cases where both are zero. It is not evidence of anything. (This is why the `high == 0` share was worth *not* treating as support last iteration: it turned out to be the confound, not the signal.) So `(high, low)` is **authored, not derived**. It is also *narrow*: the ratio `(low − high) / (max − min)` has a **median of 0.019** — the interval typically covers about **2 %** of the bundle's keyframe span. Where it sits relative to the animation: inside the keyframe range 2 645 (88.6 %) entirely after the last keyframe 174 entirely before the first 68 A short authored window, usually inside the animation but not always, is not the shape of a playback range or a loop region over the whole animation. 🟡 What it *is* remains open; what is now excluded is that it is a cached extent of the keyframes. `+0x10` is untouched by this and remains ❔. ## ✅ `+0x10` is also a `u16` pair — and no bit is a clean predicate **2026-08-26.** Like `+0x0c`, this is not one 32-bit value. The `u16` at `+0x10` is **0 in all 2 985 bundles**; the whole content is a 16-bit flag word at **`+0x12`** with 83 distinct values (`0x9400`, `0x9200`, `0x8000`, `0x8212`, …). Reading it as a `u32` inflates the field and hides that the header is built from `u16` pairs — the same shape `+0x0c` turned out to have. Every one of the 16 bits is used, with shares from 1.4 % (bit 2) to 91.5 % (bit 15). **❌ What the bits are not.** I cross-tabulated all 16 bits against four properties measurable from the bundle itself — whether it has more than one declared element, whether it is animated at all, whether its `+0x0c` window starts at 0, and whether it runs at 30 fps rather than 60: | bit | set | multi-element | animated | window at 0 | 30 fps | |---|---|---|---|---|---| | 15 | 91.5 % | 0.57 / 0.81 | 0.95 / 0.86 | 0.32 / 0.69 | 0.05 / 0.00 | | 12 | 67.4 % | 0.65 / 0.48 | 0.98 / 0.86 | 0.35 / 0.35 | 0.00 / 0.14 | | 10 | 23.5 % | 0.36 / 0.67 | 0.96 / 0.94 | **0.79 / 0.21** | 0.00 / 0.06 | | 0 | 8.6 % | 0.91 / 0.56 | 1.00 / 0.94 | 0.08 / 0.37 | 0.00 / 0.05 | Each cell is share-when-set / share-when-clear; a clean predicate would read 1.00 / 0.00. **Nothing comes close.** The strongest is bit 10 against "window starts at 0" at 0.79 / 0.21 — a real association but nowhere near a rule, and the kind of moderate split that would be easy to over-read. ❔ So the bit meanings stay open, but four candidate readings are now excluded rather than untried, and the field is at least correctly *sized*. Assigning meanings almost certainly needs the game observed with individual bundles loaded, not more static correlation — every property visible in the file has now been tried. ## A leaf record's last keyframe time is the chunk terminator (2026-08-28) The build placement region's "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" has a second form in **leaf `.rat` records**, where there is no next group. `ptbtn00f.rat` (`GP_TITLE` build 2, `RATC` at `0x04115e`) holds 8 keyframe blocks of the ordinary 40-byte layout starting at `+0x68`. The eighth block's time slot at `+0x1a4` contains **`end `** — the record's ASCII terminator. So the last keyframe's time is not merely unread, it is **not present**, and any animation's full cycle length has to come from measurement. ❔ The word at `+0x004` is **not** the keyframe count: `0x003c0000` (60) with 8 keyframes here, `0x001e0000` (30) with 3 in `ptloop01.rat`/`ptloop02.rat`.