blit() and fill_quad() both opened with
let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x };
so an element whose pose is collapsed to nothing rendered at FULL SIZE.
I first described this as "a 1-pixel sliver", reading the .max(1) in the
size arithmetic two lines below -- the guard above it meant .max(1) never
saw a zero. Read the whole function, not the lines you went looking for.
Control run before the change: if 0 meant "unset", something on the disc
would use it throughout. Nothing does.
elements with a keyframe group 15 493
at least one keyframe at scale 0 2 166
EVERY keyframe at scale 0 0
zero is a transient the element grows out of 1 762
ptlogo_eff3.t32 runs 0% -> 200%. An "unset" marker no element ever uses
throughout is not a marker.
Both functions now return without drawing when either scale is 0.
Reach: 24 of 24 renders byte-identical across GP_TITLE (all 16 builds),
GP_PAUSE_MENU and GP_OPTIONS -- additive on every screen the port needs.
195 elements have a guessed rest pose at scale 0 and 126 of those have a
non-zero alpha, so the old code painted them; all 126 are in
GP_READY_ROOM.pak, which S1 already declared a no-go. They are
tactical-map sprites at sx=0/sy=100, mid-horizontal-wipe, that the
coercion drew full-width.
So: a real correctness fix with no visible effect on the menu port.
Suite green, 122 passed / 0 failed across 3 suites at commit time.
759 lines
39 KiB
Markdown
759 lines
39 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 — 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:
|
||
|
||

|
||
|
||
`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 `<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.
|
||
|
||
## 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 `<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.
|
||
|
||
## 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 **`<rate:16>.<flag:16>`** — 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`.
|
||
|
||
## ✅ `scale = 0` means COLLAPSED, not "unset" (2026-08-28)
|
||
|
||
`ui_layout::blit` and `fill_quad` both used to open with
|
||
|
||
```rust
|
||
let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x };
|
||
```
|
||
|
||
so an element whose pose is *collapsed to nothing* was drawn at **full size**.
|
||
(The `.max(1)` two lines below never saw a zero — see [METHOD.md](../METHOD.md).)
|
||
|
||
### The control, run before the change
|
||
|
||
If `0` meant "unset", some element would use it throughout. Over the disc
|
||
([`data/zero-scale-census.txt`](../data/zero-scale-census.txt)):
|
||
|
||
| | |
|
||
|---|---|
|
||
| elements with a keyframe group | 15 493 |
|
||
| at least one keyframe at scale 0 | 2 166 |
|
||
| **every** keyframe at scale 0 | **0** |
|
||
| zero is a transient the element grows back out of | **1 762** |
|
||
|
||
Not one element on the disc is zero-scale throughout, and 1 762 grow out of zero
|
||
— `ptlogo_eff3.t32` runs `0 % → 200 %`. **An "unset" marker that nothing ever
|
||
uses throughout is not a marker.** Zero means zero.
|
||
|
||
### The fix, and its reach
|
||
|
||
Both functions now return without drawing when either scale is 0.
|
||
|
||
* **Renders: 24 of 24 byte-identical** across `GP_TITLE` (all 16 builds),
|
||
`GP_PAUSE_MENU` and `GP_OPTIONS`. The change is additive
|
||
on every screen the port needs.
|
||
* 195 elements have a guessed rest pose at scale 0; **126 of those had a non-zero
|
||
alpha**, so the old code painted them at full size.
|
||
* ⚠️ **All 126 are in `GP_READY_ROOM.pak`** — the archive
|
||
[S1](../ready-room-probe.md) already declared a no-go. They are tactical-map
|
||
sprites (`pbacropolis`, `pbFriendly`, `pbr_enemy`) at `sx=0, sy=100`, i.e.
|
||
mid-horizontal-wipe, which the old coercion drew full-width.
|
||
|
||
🟡 So this is a **real correctness fix with no visible effect on the menu port**.
|
||
It matters for anything that later reads `GP_READY_ROOM`, and it removes a case
|
||
where the renderer contradicted the format.
|