diff --git a/crates/sylpheed-formats/examples/ui_screen.rs b/crates/sylpheed-formats/examples/ui_screen.rs new file mode 100644 index 0000000..6e9e137 --- /dev/null +++ b/crates/sylpheed-formats/examples/ui_screen.rs @@ -0,0 +1,55 @@ +//! Scratch analysis: inventory one UI screen's pak โ€” every entry, and for RATC +//! entries the children they bundle. Optionally write an entry's decompressed +//! bytes out for hex inspection. +//! +//! Run: cargo run -p sylpheed-formats --example ui_screen -- [--dump 0xHASH out.bin] + +use sylpheed_formats::pak::{self, PakArchive}; +use sylpheed_formats::ratc; + +fn main() { + let mut args = std::env::args().skip(1); + let pak = args.next().expect("usage: ui_screen [--dump 0xHASH out.bin]"); + let arc = PakArchive::open(&pak).expect("open pak"); + + let rest: Vec = args.collect(); + if rest.first().map(|s| s == "--dump").unwrap_or(false) { + let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap(); + let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress"); + std::fs::write(&rest[2], &bytes).unwrap(); + println!("wrote {} bytes to {}", bytes.len(), rest[2]); + return; + } + + if rest.first().map(|s| s == "--child").unwrap_or(false) { + // --child 0xHASH : carve one RATC child out by its + // listed offset/size, so a 165-byte layout record can be hexdumped alone. + let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap(); + let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress"); + let kids = ratc::parse(&bytes).expect("ratc"); + let k = kids.iter().find(|k| k.name == rest[2]).expect("child not found"); + std::fs::write(&rest[3], &bytes[k.offset..k.offset + k.size]).unwrap(); + println!("wrote {} B ({} @ {:#x}) to {}", k.size, k.name, k.offset, rest[3]); + return; + } + + println!("{pak}: {} entries", arc.len()); + for e in arc.entries() { + let Ok(bytes) = arc.read(e) else { + println!(" {:08x} ", e.name_hash); + continue; + }; + let label = pak::inner_format_label(&bytes); + println!(" {:08x} {:>9} B {}", e.name_hash, bytes.len(), label); + if ratc::is_ratc(&bytes) { + if let Some(kids) = ratc::parse(&bytes) { + for k in &kids { + println!(" - {:<40} {:>9} B {}", k.name, k.size, k.kind); + } + if kids.is_empty() { + println!(" (no children found)"); + } + } + } + } +} diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 0586166..9bbee68 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -22,6 +22,7 @@ Promote to a prose `structures/โ€ฆmd` file when a format needs behavioural notes | XBG7 mesh | ๐ŸŸก/โ” | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). Quantized hero bodies (DeltaSaber `f004`) still declined | | Capital-ship part placement | ๐ŸŸก | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | hull placement static-exact; external parts approximate statically. **Runtime capture** (Canary F10 โ†’ VS-constant WorldView) gives ground truth โ€” validated on `e106` destroyer; not yet baked into the viewer | | Weapon fields defaulted on disc | โœ…/๐ŸŸก | [runtime DATA SHEET](weapon-datasheet-runtime.md) | The Arsenal Gallery-Mode panel reads the live record: `Ammo Capacity` = `LoadingCount` โœ…, `Max. Lock Ons` = `TriggerShotCount` โœ…. Recovers `TriggerShotCount` for `wep_05`/`wep_60` (both **4**); brackets defaulted `Power`/`MaximumRange` via the letter classes. 9 of ~61 weapons reachable at 5 % save progress | +| UI screen layout (`.rat`) | โœ…/๐ŸŸก | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context ร— language) build; every `.t32` sprite has a `.rat` **layout record** (BE u32; 1280ร—720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu rebuilds pixel-accurately from the disc.** `loop1.rat` (screen-level draw order) not yet decoded | ## Functions / code paths diff --git a/docs/re/captures/ui-layout/pause-mission-rebuilt.png b/docs/re/captures/ui-layout/pause-mission-rebuilt.png new file mode 100644 index 0000000..036a221 Binary files /dev/null and b/docs/re/captures/ui-layout/pause-mission-rebuilt.png differ diff --git a/docs/re/captures/ui-layout/pause-tutorial-real-vs-rebuilt.png b/docs/re/captures/ui-layout/pause-tutorial-real-vs-rebuilt.png new file mode 100644 index 0000000..2fef733 Binary files /dev/null and b/docs/re/captures/ui-layout/pause-tutorial-real-vs-rebuilt.png differ diff --git a/docs/re/structures/ui-rat-layout.md b/docs/re/structures/ui-rat-layout.md new file mode 100644 index 0000000..7bb236f --- /dev/null +++ b/docs/re/structures/ui-rat-layout.md @@ -0,0 +1,105 @@ +# `.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**, and its children come in pairs: + +| child | what it is | +|---|---| +| `.t32` | the sprite ([T8aD](texture-color-k8888.md)) | +| `.rat` | that sprite's **layout record** (this document) | +| `loop1.rat` | a screen-level record (larger; not yet decoded) | + +`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). A minimal record (a static button) is 165 bytes: + +``` +0x00 "RATC" magic โ€” a RATC bundle reused as a data record +0x08 u32 payload size +0x18 u32 design width = 1280 +0x1c u32 design height = 720 +0x20 char[] the sprite this record places, e.g. "pgpbtn00.t32" +... + โ”€โ”€ placement block โ”€โ”€ + u32 scale X = 100 (percent) + u32 scale Y = 100 + u32 tint = 0xffffffff (RGBA, white = untinted) + u32 X โ† position + u32 Y โ† + u32 time (keyframe records only) +... + "opt " u32 len char[len] link to another record, e.g. "pgpbtn00f.rat" +``` + +- **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*. +- Two fields (at 0x50/0x54 in the button records) vary per button and are **not identified**. + +## 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. **Against the game.** The three visible items measured at screenshot y-centres + 346.0 / 415.5 / 485.5 โ€” spacing 70.0 and 70.0, and a *single* constant offset maps all + three onto 268 / 338 / 408 to within 0.5 px. +3. **Absolute placement.** The tutorial build's records give 546/288, 546/358, 546/428 and + 540/119, which are framebuffer coordinates directly: compositing the sprites there + reproduces the screenshot (image above). + +## 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 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 + +- `loop1.rat` (1320 B) is the screen-level record โ€” decoding it should give the + draw order and the `eff*`/`deli*` placements that are missing from the rebuild above. +- 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).