formats: settle what .prm elements are — untextured quads marked by kind bit 0x10
369 .prm elements exist in the disc's screen builds and every composite is
missing them. Swept statically:
* none of the 369 has a RATC child of its own name — no payload, nothing to
draw, so .prm is a primitive and not a sprite;
* kind & 0x10 and a .prm name agree with ZERO exceptions in either direction
over all 965 builds, so the format marks primitives as a decoded field and a
port need not parse filenames;
* 361 of 369 are exactly 1280x720 at scale 100% in the corner, and their
keyframe 'fade' ARGB is overwhelmingly black at some alpha — these are the
fade-to-black, dim-behind-menu and flash layers, i.e. the PRMD dim-quad the
compositor's backdrop has been standing in for.
Refuted before believing: drawing them at Element::rest() is wrong. The title's
pteff00.prm is opaque -> transparent -> transparent -> opaque, a transition whose
resting pose is the transparent plateau; rest() picks by longest dwell and lands
on the opaque endpoint, which is painted LAST on that screen and would black out
the title. A test asserts that wrong answer deliberately so that fixing the
resting rule fails it and leads to the note.
No compositing change: the resting rule is not .prm-specific and has to be A/B'd
against the title framebuffer capture first.
This commit is contained in:
160
crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs
Normal file
160
crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs
Normal file
@@ -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<PathBuf> {
|
||||
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<PathBuf> = 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<u32> = 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");
|
||||
}
|
||||
96
docs/re/structures/ui-prm-primitives.md
Normal file
96
docs/re/structures/ui-prm-primitives.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user