diff --git a/crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs b/crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs new file mode 100644 index 0000000..01d8e28 --- /dev/null +++ b/crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs @@ -0,0 +1,160 @@ +//! `.prm` elements are untextured quads, and the declaration table says so. +//! +//! Every composite the port draws is missing them: they carry no sprite, so +//! `compose` skips them. What they are was settled statically on 2026-08-19 — +//! see `docs/re/structures/ui-prm-primitives.md`. This pins the three facts the +//! decoding rests on, and is disc-gated like the rest of the corpus. + +use std::path::{Path, PathBuf}; + +use sylpheed_formats::{pak::PakArchive, ratc, ui_layout}; + +fn disc_root() -> Option { + if let Ok(p) = std::env::var("SYLPHEED_DISC") { + let p = PathBuf::from(p); + if p.join("dat").is_dir() { + return Some(p); + } + } + let default = Path::new( + "/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)", + ); + if default.join("dat").is_dir() { + return Some(default.to_path_buf()); + } + None +} + +fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) { + let mut paks: Vec = std::fs::read_dir(root.join("dat")) + .expect("dat/") + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")) + .collect(); + paks.sort(); + for p in &paks { + let name = p.file_name().unwrap().to_string_lossy().to_string(); + let Ok(arc) = PakArchive::open(p) else { continue }; + for e in arc.entries() { + let Ok(bytes) = arc.read(e) else { continue }; + if ratc::is_ratc(&bytes) { + f(&name, &bytes); + } + } + } +} + +/// **Bit `0x10` of `kind` means "untextured primitive", and it is exact.** +/// +/// This is the point of the whole item: the primitive-ness is a *decoded field*, +/// not a guess from the `.prm` file extension. Over every screen build on the +/// disc, `kind & 0x10` and a `.prm` name agree with **zero** exceptions in +/// either direction — so a port can classify without parsing names. +/// +/// Also pinned here because it is the same sweep: no `.prm` element has a RATC +/// child of its own name. All 369 of them are payload-less, which is what +/// "untextured" means concretely — there is nothing in the bundle to draw. +#[test] +fn kind_bit_0x10_means_prm_exactly_and_prm_carries_no_payload() { + let Some(root) = disc_root() else { + eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)"); + return; + }; + let (mut prm, mut prm_without_bit, mut bit_without_prm, mut with_child) = (0, 0, 0, 0); + let (mut full_screen, mut sized) = (0, 0); + for_each_build(&root, |pak, bytes| { + let Some(build) = ui_layout::parse_build(bytes) else { + return; + }; + if build.from_fallback { + return; + } + let kids = ratc::parse(bytes).unwrap_or_default(); + for el in &build.elements { + let is_prm = el.name.to_ascii_lowercase().ends_with(".prm"); + let has_bit = el.kind & 0x10 != 0; + if is_prm && !has_bit { + prm_without_bit += 1; + eprintln!("{pak}: {} is .prm without bit 0x10", el.name); + } + if has_bit && !is_prm { + bit_without_prm += 1; + eprintln!("{pak}: {} has bit 0x10 but is not .prm", el.name); + } + if !is_prm { + continue; + } + prm += 1; + if kids.iter().any(|c| c.name == el.name) { + with_child += 1; + eprintln!("{pak}: {} has a payload child", el.name); + } + sized += 1; + if (el.pivot_x * 2, el.pivot_y * 2) == (1280, 720) { + full_screen += 1; + } + } + }); + assert!(prm > 300, "only {prm} `.prm` elements — the sweep did not run"); + assert_eq!(prm_without_bit, 0, "a `.prm` element without kind bit 0x10"); + assert_eq!(bit_without_prm, 0, "kind bit 0x10 on something that is not `.prm`"); + assert_eq!(with_child, 0, "a `.prm` element with a payload child"); + // 361 of 369 are exactly the design space; the handful that are not are + // small coloured quads (844x600, and two degenerate 0x720). + assert!( + full_screen * 10 > sized * 9, + "only {full_screen}/{sized} `.prm` elements are full-screen" + ); + eprintln!("{prm} `.prm` elements: all kind&0x10, none with a payload, {full_screen} full-screen"); +} + +/// **A `.prm` fade quad cannot be drawn at `Element::rest()`** — the pose that +/// rule picks would black out the screen. +/// +/// The title's `pteff00.prm` is a screen transition: opaque black at t=12, +/// transparent by t=64, held transparent to t=74, opaque black again on the way +/// out. What the title screen sits at is the transparent plateau. `rest()` picks +/// by longest dwell to the next keyframe's time, which lands on an *endpoint* of +/// the fade — an opaque black full-screen quad, painted last in the measured +/// order, i.e. the whole screen. +/// +/// This test states the defect rather than hiding it: it asserts the shape of +/// the group (so the reasoning stays checkable) and that `rest()` returns the +/// opaque frame (so that when the resting rule is fixed, this test fails and +/// someone reads the note above instead of rediscovering it). +#[test] +fn the_title_fade_quad_is_a_transition_and_rest_picks_the_wrong_end() { + let Some(root) = disc_root() else { + eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)"); + return; + }; + let arc = PakArchive::open(root.join("dat").join("GP_TITLE.pak")).expect("GP_TITLE.pak"); + let mut checked = 0usize; + for e in arc.entries() { + let Ok(bytes) = arc.read(e) else { continue }; + let Some(build) = ui_layout::parse_build(&bytes) else { + continue; + }; + for el in &build.elements { + if el.name != "pteff00.prm" || el.keyframes.len() != 4 { + continue; + } + checked += 1; + let a: Vec = el.keyframes.iter().map(|k| k.fade >> 24).collect(); + assert_eq!( + a, + vec![0xff, 0x00, 0x00, 0xff], + "the fade group is opaque → transparent → transparent → opaque" + ); + assert_eq!( + el.rest().map(|k| k.fade), + Some(0xff00_0000), + "rest() still picks an endpoint of the fade — if this now returns \ + the transparent plateau the resting rule has been fixed and the \ + note on this test is stale" + ); + } + } + assert!(checked >= 3, "found {checked} title fade quads, expected several"); +} diff --git a/docs/re/structures/ui-prm-primitives.md b/docs/re/structures/ui-prm-primitives.md new file mode 100644 index 0000000..fbcca00 --- /dev/null +++ b/docs/re/structures/ui-prm-primitives.md @@ -0,0 +1,96 @@ +# `.prm` elements are untextured full-screen quads, and `kind` bit `0x10` says so + +**Status:** ✅ `CONFIRMED` statically across all 965 screen builds on the disc. +🔴 **Drawing them at `Element::rest()` is refuted** — it would black out the +title screen. ❔ the resting rule for a fade group is unsolved, and that is the +blocker on actually compositing them. + +## What they are + +Every composite the port produces is missing its `.prm` elements: they resolve to +no sprite, so `compose` skips them. 369 of them exist. Swept over the disc: + +| | | +|---|---| +| `.prm` elements in screen builds | **369** | +| …with a RATC child of their own name (a payload) | **0** | +| …with `kind & 0x10` set | **369** | +| non-`.prm` elements with `kind & 0x10` set | **0** | +| …with `pivot × 2 == 1280×720` (the design space) | **361** | +| …resting at scale 100 %, position (0,0) | 361 / 356 | + +Three things follow, none of them inferred: + +1. **They carry no texture.** Not one has a payload child; there is nothing in + the bundle for them to draw. `.prm` is a primitive, not a sprite. +2. **The format marks them.** `kind & 0x10` and the `.prm` extension agree with + **zero exceptions in either direction**. A port can classify an element as a + primitive from the declaration table alone, without parsing filenames — which + is what a decoded field is for. +3. **They are screen-sized colour fills.** 361 of 369 are exactly the design + space at 1:1 in the corner. The 8 that are not are small coloured quads + (844×600 at (291,60), and two degenerate 0×720). + +## Where the colour is + +The keyframe's `fade` word, ARGB — the field named for the alpha ramp it drives +on a sprite. On a `.prm` there is no sprite to modulate, so it *is* the fill. +Resting values across the disc: + +``` + 189 0x00000000 (transparent — nothing on screen) + 102 0xff000000 (opaque black) + 32 0x7f000000 6 0x60ff0000 (red) 6 0x0000ffd4 (cyan) + 20 other blacks 6 0x008000ff (violet) 2 0xf0ffffff (near-white flash) +``` + +Overwhelmingly **black at some alpha**: these are the screen's fade-to-black, +dim-behind-a-menu and flash layers. That matches the standing note on +`ComposeOptions::backdrop`, which describes the compositor's dim slate as a +stand-in for "the PRMD dim-quad" — this is that quad, and it is in the file. + +## Refuted: you cannot just draw them at rest + +The obvious next step — treat a `.prm` as a quad and blit it at `rest()` — is +wrong, and would have been a visible disaster rather than a subtle one. + +The title screen's `pteff00.prm`: + +``` +kf0 fade=0xff000000 t=12 opaque black +kf1 fade=0x00000000 t=64 transparent +kf2 fade=0x00000000 t=74 transparent +kf3 fade=0xff000000 t=None opaque black +``` + +That is a **transition**: the screen fades up out of black, sits clear, and fades +back down on the way out. What the title screen *shows* is the transparent +plateau, kf1–kf2. + +`Element::rest()` picks the keyframe with the largest gap to the next keyframe's +time — 12→64 is the biggest gap, so it picks **kf0**, opaque black. And in the +measured paint order `pteff00.prm` is painted **last** on the title screen. Drawn +at `rest()`, it is a full-screen opaque black quad over everything. + +The rule's assumption is the problem: it treats a keyframe as a pose that is +*held* until the next one. For a fade it is the *start of a ramp*. The pose a +screen actually rests at is the **plateau** — a run of consecutive keyframes with +equal values — which for this group is kf1–kf2, transparent. + +This is pinned by `the_title_fade_quad_is_a_transition_and_rest_picks_the_wrong_end`, +which asserts the current (wrong) answer on purpose, so that fixing the resting +rule fails the test and leads whoever does it here. + +## What is not settled + +* ❔ **The resting rule.** "Longest plateau of equal consecutive keyframes" + is the candidate, and it is *not* `.prm`-specific — it would change `rest()` + for every element on the disc, including the ones checked against the title + framebuffer capture. It has to be A/B'd against those before it can land. That + is the next step and the reason `.prm` compositing is not in this change. +* ❔ **Blend mode.** A dim quad at `0x7f000000` is presumably straight alpha over + what is beneath, but the flash (`0xf0ffffff`) and the coloured ones + (`0x60ff0000`) may well be additive. Nothing measured. +* ❔ **The 8 non-full-screen ones**, including two with a zero dimension. +* 🟡 `kind = 0x3010` (38 elements) is `0x10` plus `0x3000`, the button-record + bits — a primitive that is part of a button. Unexamined.