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.
This commit is contained in:
@@ -181,6 +181,12 @@ enum ScreenCommands {
|
||||
/// what a framebuffer capture must be compared against.
|
||||
#[arg(long)]
|
||||
black: bool,
|
||||
/// Draw the untextured `.prm` primitives (fade / dim / flash quads).
|
||||
/// Off by default: they are decoded, but where they paint on a screen
|
||||
/// without a measured order is unsolved — see
|
||||
/// `docs/re/structures/ui-prm-primitives.md`.
|
||||
#[arg(long)]
|
||||
primitives: bool,
|
||||
/// Widen the list from screen builds to **every composable bundle** —
|
||||
/// including the ones with no `.rat` layout child, such as the
|
||||
/// developer-logo splash. 2 859 RATC bundles on the disc compose; only
|
||||
@@ -346,9 +352,10 @@ async fn main() -> Result<()> {
|
||||
animated,
|
||||
black,
|
||||
all,
|
||||
} => {
|
||||
cmd_screen_render(&pak, &output, build, focus, animated, black, all)
|
||||
}
|
||||
primitives,
|
||||
} => cmd_screen_render(
|
||||
&pak, &output, build, focus, animated, black, all, primitives,
|
||||
),
|
||||
},
|
||||
Commands::Save { cmd } => match cmd {
|
||||
SaveCommands::Info { file, all } => cmd_save_info(&file, all),
|
||||
@@ -568,6 +575,7 @@ fn cmd_screen_render(
|
||||
animated: bool,
|
||||
black: bool,
|
||||
all: bool,
|
||||
primitives: bool,
|
||||
) -> Result<()> {
|
||||
use sylpheed_formats::ui_layout::{self, ComposeOptions};
|
||||
let builds = screen_builds(pak, all)?;
|
||||
@@ -585,6 +593,7 @@ fn cmd_screen_render(
|
||||
} else {
|
||||
ComposeOptions::default().backdrop
|
||||
},
|
||||
include_primitives: primitives,
|
||||
},
|
||||
None,
|
||||
);
|
||||
|
||||
@@ -604,6 +604,19 @@ pub struct ComposeOptions {
|
||||
/// framebuffer capture needs the backdrop to match, or every partially
|
||||
/// transparent pixel is off by the backdrop.
|
||||
pub backdrop: [u8; 4],
|
||||
/// Draw the untextured `.prm` primitives — the fade / dim / flash quads.
|
||||
///
|
||||
/// **Off by default, and not because they are undecoded.** They are decoded
|
||||
/// (`docs/re/structures/ui-prm-primitives.md`) and drawing them is right on the
|
||||
/// title screen, whose paint order was read off the running game. What is
|
||||
/// unsolved is *where they paint on every other screen*: a primitive has no
|
||||
/// `T8aD` header, so it has no layer key, and the derived order forces the
|
||||
/// keyless to the end. That is wrong — the two measured screens show a
|
||||
/// primitive painting **first** (the splash's black backdrop) and another
|
||||
/// painting **last** (the title's fade-out), so no single default is right.
|
||||
/// Left on with the derived order, an opaque black quad sorts last and wipes
|
||||
/// 32 of `GP_DIALOG`'s builds.
|
||||
pub include_primitives: bool,
|
||||
}
|
||||
|
||||
impl Default for ComposeOptions {
|
||||
@@ -612,6 +625,7 @@ impl Default for ComposeOptions {
|
||||
include_focus: false,
|
||||
include_animated: false,
|
||||
backdrop: [14, 14, 20, 255],
|
||||
include_primitives: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -777,6 +791,19 @@ pub fn compose(
|
||||
continue;
|
||||
}
|
||||
let Some(kf) = el.rest() else { continue };
|
||||
// An untextured primitive: a solid quad of the keyframe's `fade` colour,
|
||||
// sized by the declared pivot. `kind & 0x10` marks these exactly — see
|
||||
// `docs/re/structures/ui-prm-primitives.md`. They are the screen's
|
||||
// fade-to-black, dim-behind-a-menu and flash layers.
|
||||
if el.kind & 0x10 != 0 && el.sprite.is_none() {
|
||||
if !opts.include_primitives {
|
||||
continue;
|
||||
}
|
||||
if fill_quad(&mut canvas, w, h, kf, el.pivot_x, el.pivot_y) {
|
||||
drawn.push(el.index);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(sprite) = el.sprite.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
@@ -800,6 +827,69 @@ pub fn compose(
|
||||
}
|
||||
}
|
||||
|
||||
/// Alpha-blend an untextured primitive: a solid rectangle of the keyframe's
|
||||
/// `fade` colour, `pivot × 2` in size, placed and scaled exactly as a sprite is.
|
||||
///
|
||||
/// Returns whether anything was drawn — a primitive resting at alpha 0 (189 of
|
||||
/// the 369 on the disc) contributes nothing and should not be counted as drawn.
|
||||
///
|
||||
/// The size comes from the pivot because there is no texture to take it from,
|
||||
/// and a `.t32` element's pivot is exactly half its decoded sprite. 361 of the
|
||||
/// 369 primitives are `pivot × 2 == 1280×720`, the design space.
|
||||
fn fill_quad(
|
||||
canvas: &mut [u8],
|
||||
cw: u32,
|
||||
ch: u32,
|
||||
kf: &Keyframe,
|
||||
pivot_x: u32,
|
||||
pivot_y: u32,
|
||||
) -> bool {
|
||||
let (a, r, g, b) = (
|
||||
(kf.fade >> 24) & 0xff,
|
||||
(kf.fade >> 16) & 0xff,
|
||||
(kf.fade >> 8) & 0xff,
|
||||
kf.fade & 0xff,
|
||||
);
|
||||
if a == 0 {
|
||||
return false;
|
||||
}
|
||||
let (sw, sh) = (pivot_x * 2, pivot_y * 2);
|
||||
if sw == 0 || sh == 0 {
|
||||
return false;
|
||||
}
|
||||
let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x };
|
||||
let sy_pct = if kf.scale_y == 0 { 100 } else { kf.scale_y };
|
||||
let dw = (sw * sx_pct / 100).max(1);
|
||||
let dh = (sh * sy_pct / 100).max(1);
|
||||
let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100;
|
||||
let oy = kf.y - (pivot_y as i32 * (sy_pct as i32 - 100)) / 100;
|
||||
for row in 0..dh {
|
||||
let ty = oy + row as i32;
|
||||
if ty < 0 {
|
||||
continue;
|
||||
}
|
||||
if ty >= ch as i32 {
|
||||
break;
|
||||
}
|
||||
for col in 0..dw {
|
||||
let tx = ox + col as i32;
|
||||
if tx < 0 {
|
||||
continue;
|
||||
}
|
||||
if tx >= cw as i32 {
|
||||
break;
|
||||
}
|
||||
let di = ((ty as u32 * cw + tx as u32) * 4) as usize;
|
||||
for (k, sc) in [r, g, b].into_iter().enumerate() {
|
||||
let dc = canvas[di + k] as u32;
|
||||
canvas[di + k] = ((sc * a + dc * (255 - a)) / 255) as u8;
|
||||
}
|
||||
canvas[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Alpha-blend one sprite onto the canvas at a keyframe's placement, with tint
|
||||
/// and scale. Placements may be negative or run off the edge, so both axes clip.
|
||||
///
|
||||
|
||||
@@ -155,3 +155,79 @@ fn the_title_fade_quad_rests_transparent() {
|
||||
}
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
BIN
docs/re/captures/ui-layout/dialog-difficulty-with-primitives.png
Normal file
BIN
docs/re/captures/ui-layout/dialog-difficulty-with-primitives.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
BIN
docs/re/captures/ui-layout/title-composited-with-primitives.png
Normal file
BIN
docs/re/captures/ui-layout/title-composited-with-primitives.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 664 KiB |
@@ -49,45 +49,81 @@ 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
|
||||
## Drawing them (2026-08-19)
|
||||
|
||||
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.
|
||||
`fill_quad` composites a primitive as a solid rectangle of the keyframe's `fade`
|
||||
colour, `pivot × 2` in size, placed and scaled exactly as a sprite is. It is
|
||||
behind `ComposeOptions::include_primitives` / `screen render --primitives`, and
|
||||
**off by default** — for a reason that is itself the result of this iteration.
|
||||
|
||||
The title screen's `pteff00.prm`:
|
||||
On the title screen, whose paint order is ground truth, it is measurably right:
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
| composite | mean luminance | vs capture | mean abs diff |
|
||||
|---|---|---|---|
|
||||
| primitives off | 76.30 | **+18 %** | 16.07 |
|
||||
| primitives on | **63.72** | **−1.3 %** | **13.08** |
|
||||
|
||||
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.
|
||||
(capture mean 64.58). The composite was ~40 % too bright in the background
|
||||
regions; `pteff02.prm`, a 25 % black dim, is what was missing. The wordmark is
|
||||
*not* dimmed by it — the measured order paints that quad at slot 4, beneath the
|
||||
logo — and the fraction of wordmark pixels above 200 moves 29.9 % → 28.2 %
|
||||
against the capture's 26.8 %.
|
||||
[The composite](../captures/ui-layout/title-composited-with-primitives.png).
|
||||
|
||||
`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.
|
||||
Edge correlation goes 0.9538 → 0.9480, which sounds like a loss and is not
|
||||
informative: a uniform dim scales gradients uniformly, so a normalised edge
|
||||
correlation barely sees it. Brightness is the metric that discriminates here, and
|
||||
it moves 20 percentage points toward the capture.
|
||||
|
||||
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.
|
||||
## Refuted twice, and the second one is the blocker
|
||||
|
||||
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.
|
||||
**Refuted: you cannot draw them at `Element::rest()`** — as the old longest-dwell
|
||||
rule computed it. The title's `pteff00.prm` runs opaque → transparent →
|
||||
transparent → opaque, a screen transition whose resting pose is the transparent
|
||||
plateau, and it paints **last**; the old rule picked the opaque endpoint, i.e.
|
||||
the whole screen. That is fixed — [`ui-resting-pose.md`](ui-resting-pose.md).
|
||||
|
||||
**Refuted: the derived paint order does not place them.** This is what keeps the
|
||||
flag off. A primitive has no `T8aD` header, so it has **no layer key**, and
|
||||
`derived_paint_order` sorts the keyless to the very end. `GP_DIALOG`'s
|
||||
`pzeff00.prm` is a *single* keyframe of opaque black at full screen; painted last
|
||||
it wipes the build. Measured: of the 125 builds that draw a visible primitive,
|
||||
**36 come out more than 99 % one colour** with the derived order.
|
||||
|
||||
And no simple default fixes it, because the two screens read off the running game
|
||||
disagree with each other:
|
||||
|
||||
* the developer-logo splash paints `palogo_eff0.prm` **first** — it is the black
|
||||
backdrop the logos sit on;
|
||||
* the title paints `pteff02.prm` at slot **4**, beneath the wordmark, and
|
||||
`pteff00.prm` **last**, as the fade-out.
|
||||
|
||||
So a primitive's position is real, per-element, and not derivable from anything
|
||||
decoded so far. "Primitives first" would break the title's fade-out; "primitives
|
||||
last" wipes 36 builds; "keep declaration position" was checked against the title
|
||||
and fails there too — `pteff00.prm` is element 8, declared among the wordmarks,
|
||||
and the game paints it 23rd.
|
||||
|
||||
A disc test measures the damage (36 / 125 / 0-by-default) rather than asserting
|
||||
the feature works, so the number stays honest and changes when the ordering is
|
||||
solved.
|
||||
|
||||
Worth seeing anyway: the DIFFICULTY dialog
|
||||
([capture](../captures/ui-layout/dialog-difficulty-with-primitives.png)) renders
|
||||
legibly with its 50 % dim — an early false alarm said 36 `GP_DIALOG` builds went
|
||||
"100 % black", which was a crude near-black pixel threshold, not a black screen.
|
||||
The genuinely wiped ones are a different set, wiped by an *opaque* quad.
|
||||
|
||||
## 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.
|
||||
## What is not settled
|
||||
|
||||
* ❔ **Where a primitive paints.** The blocker, described above. It has no layer
|
||||
key and the two measured screens rule out every constant default. The cheapest
|
||||
next step is a third measured order from a screen that carries a primitive —
|
||||
the `GP_DIALOG` DIFFICULTY box is reachable from the main menu and has exactly
|
||||
one, so a runtime child-list read there would say whether its dim is first or
|
||||
last.
|
||||
* ❔ **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.
|
||||
|
||||
Reference in New Issue
Block a user