Files
Sylpheed/crates/sylpheed-formats/tests/ui_prm_primitives_disc.rs
Sylpheed RE agent 451e328ba1 formats/cli: draw the .prm primitives, opt-in, because their paint order is unsolved
fill_quad composites an untextured primitive as a solid rectangle of the
keyframes fade colour, pivot x 2 in size, placed and scaled exactly as a sprite
is. Behind ComposeOptions::include_primitives and screen render --primitives.

On the title screen, whose paint order is ground truth, it is measurably right:
mean luminance 76.30 -> 63.72 against the captures 64.58, i.e. from +18% to
-1.3%, and mean absolute difference 16.07 -> 13.08. The background was ~40% too
bright; pteff02.prm, a 25% black dim, was what was missing. The wordmark is not
dimmed by it because the measured order paints that quad at slot 4, beneath the
logo. Edge correlation moves 0.9538 -> 0.9480, which is not informative here: a
uniform dim scales gradients uniformly so a normalised edge score barely sees it.

OFF BY DEFAULT, and that is the finding. A primitive has no T8aD header, so no
layer key, and derived_paint_order sorts the keyless to the end. GP_DIALOGs
pzeff00.prm is a single keyframe of opaque black at full screen; painted last it
wipes the build. Of the 125 builds that draw a visible primitive, 36 come out
>99% one colour with the derived order.

No constant default works, because the two screens read off the running game
disagree: the splash paints its primitive FIRST (the black backdrop) while the
title paints one at slot 4 and another LAST (the fade-out). Declaration order
fails the title too. A disc test measures the damage rather than asserting the
feature works, so the number moves when the ordering is solved.

Also records a false alarm worth keeping: a first pass reported 36 GP_DIALOG
builds at "100% black", which was a crude near-black pixel threshold and not a
black screen - those dialogs are dimmed 50% and perfectly legible. The genuinely
wiped builds are a different set.
2026-08-19 07:21:32 +00:00

234 lines
9.4 KiB
Rust

//! `.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 rests at its transparent plateau**, which is what makes
/// it drawable at all.
///
/// 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 — and this
/// element is painted **last** in the measured order, so getting it wrong means
/// a full-screen opaque black quad over everything.
///
/// It *was* wrong: the old longest-dwell resting rule picked the opaque
/// endpoint. This test asserted that defect deliberately until the plateau rule
/// replaced it (`docs/re/structures/ui-resting-pose.md`); now it guards the fix.
#[test]
fn the_title_fade_quad_rests_transparent() {
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(0x0000_0000),
"the fade quad must rest at its transparent plateau — an opaque \
answer here paints a full-screen black quad over the title"
);
}
}
assert!(checked >= 3, "found {checked} title fade quads, expected several");
}
/// **Drawing the primitives with the derived order swallows screens** — which
/// is why `ComposeOptions::include_primitives` is off by default.
///
/// A primitive has no `T8aD` header, so it has no layer key, and
/// `derived_paint_order` sorts the keyless to the very end. That is not a
/// harmless default: `GP_DIALOG`'s `pzeff00.prm` is a **single** keyframe of
/// opaque black at full screen, and painted last it wipes the build.
///
/// The ground truth contradicts *both* simple defaults. On the developer-logo
/// splash the game paints `palogo_eff0.prm` **first** — it is the black
/// backdrop. On the title it paints `pteff02.prm` at slot 4, beneath the
/// wordmark, and `pteff00.prm` **last**, as the fade-out. So the order is real,
/// per-element, and not derivable from anything decoded so far.
///
/// This test measures the damage rather than asserting the feature works, so the
/// number stays honest and moves when the ordering is solved.
#[test]
fn the_derived_order_puts_primitives_last_and_that_wipes_screens() {
let Some(root) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
};
let opts = |primitives| ui_layout::ComposeOptions {
backdrop: [0, 0, 0, 255],
include_primitives: primitives,
..Default::default()
};
let flatness = |c: &ui_layout::ComposedScreen| {
let mut hist = std::collections::HashMap::<[u8; 3], usize>::new();
for p in c.rgba.chunks_exact(4) {
*hist.entry([p[0], p[1], p[2]]).or_default() += 1;
}
*hist.values().max().unwrap_or(&0) as f64 / (c.width * c.height) as f64
};
let (mut with_prm, mut wiped_on, mut wiped_off) = (0usize, 0usize, 0usize);
for_each_build(&root, |_pak, bytes| {
if !ui_layout::is_build(bytes) {
return;
}
let Some(b) = ui_layout::parse_build(bytes) else {
return;
};
if b.from_fallback
|| !b.elements.iter().any(|e| {
e.kind & 0x10 != 0
&& e.sprite.is_none()
&& e.rest().map(|k| (k.fade >> 24) & 0xff).unwrap_or(0) != 0
})
{
return;
}
with_prm += 1;
if flatness(&ui_layout::compose(&b, bytes, opts(true), None)) > 0.99 {
wiped_on += 1;
}
if flatness(&ui_layout::compose(&b, bytes, opts(false), None)) > 0.99 {
wiped_off += 1;
}
});
assert!(with_prm > 50, "only {with_prm} builds draw a primitive");
assert_eq!(
wiped_off, 0,
"the DEFAULT composite wipes {wiped_off} builds — primitives are supposed \
to be off unless asked for"
);
assert!(
wiped_on > 0,
"no build is wiped with primitives on — the ordering problem this flag \
exists for may be solved, in which case turn it on by default"
);
eprintln!(
"{with_prm} builds draw a visible primitive; with the derived order \
{wiped_on} of them come out >99% one colour, {wiped_off} by default"
);
}