formats: apply the keyframe fade alpha, and fix the resting rule it exposed
blit modulated by tint only, which is 0xffffffff on essentially every keyframe, so the fade word was decoded, stored and then discarded. Applying it as an ARGB modulate takes the title composite's edge correlation against the framebuffer capture of the running game from 0.4597 to 0.9538, both at zero shift. The white wordmark with its blue outline, the trademark, the copyright and the orange exploding planet all appear -- the planet because a full-screen blue effect that rests at alpha 0 had been painting over it at full opacity. ARGB is measured: across a fade-in the high byte walks 0x00 -> 0x80 -> 0xc0 -> 0xe0 -> 0xff while the low three stay ffffff, and the low 24 bits are 0xffffff on 5276 of the disc's 5453 resting keyframes. A modulate can only remove pixels, so the risk is a blank screen. Measured: no-op on 4060 of 5200 sprite elements, partial on 453, hides 687 -- transient HUD indicators that should not be lit at rest -- and NO build is left with nothing visible. A disc test asserts that, and that the no-op share stays high, which also guards the resting rule against drifting onto ramp frames. Both changes are in one commit because the second is invisible without the first. Applying fade erased the word PAUSE, which the running-game capture plainly shows: pgptitle.rat has three runs of two identical keyframes -- pre-roll, hold, exit -- and the 'later run wins' tie-break grabbed the exit. A group carries the screen's entry animation AND its exit, so a run ending on the last keyframe is now excluded unless it is the only one. The title correlation is unchanged.
This commit is contained in:
@@ -185,6 +185,19 @@ impl Element {
|
||||
|
||||
/// The hold: the longest run of consecutive keyframes whose pose is
|
||||
/// identical. `None` when no two adjacent frames agree.
|
||||
///
|
||||
/// **A run that ends on the last keyframe is the exit, not the hold.** A
|
||||
/// group carries the screen's entry animation *and* its exit, so the shape
|
||||
/// is pre-roll → ramp in → **hold** → ramp out → post-roll, and the last two
|
||||
/// of those are often plateaus themselves. The pause menu's `pgptitle.rat`
|
||||
/// has three runs of two — invisible, visible, invisible — and taking the
|
||||
/// last one erased the word PAUSE, which the capture of the running game
|
||||
/// plainly shows (`captures/ui-layout/pause-tutorial-real-vs-rebuilt.png`).
|
||||
/// So trailing runs are excluded, and only fall back to if there is nothing
|
||||
/// else.
|
||||
///
|
||||
/// Among the runs that remain, longest wins and a **later** run breaks a tie:
|
||||
/// the pre-roll comes first and the hold after it.
|
||||
fn rest_plateau(&self) -> Option<&Keyframe> {
|
||||
let n = self.keyframes.len();
|
||||
if n < 2 {
|
||||
@@ -198,7 +211,10 @@ impl Element {
|
||||
&& a.x == b.x
|
||||
&& a.y == b.y
|
||||
};
|
||||
let (mut best_start, mut best_len) = (0usize, 0usize);
|
||||
// (start, len) of the best run that does not end on the last keyframe,
|
||||
// and separately of the best run overall.
|
||||
let (mut best, mut best_len) = (None, 0usize);
|
||||
let (mut any, mut any_len) = (None, 0usize);
|
||||
let mut i = 0usize;
|
||||
while i < n {
|
||||
let mut j = i;
|
||||
@@ -206,15 +222,19 @@ impl Element {
|
||||
j += 1;
|
||||
}
|
||||
let len = j - i + 1;
|
||||
// `>=` so a later run of equal length wins, matching the in → hold →
|
||||
// out reasoning behind the longest-dwell rule's tie-break.
|
||||
if len >= 2 && len >= best_len {
|
||||
best_start = i;
|
||||
best_len = len;
|
||||
if len >= 2 {
|
||||
if len >= any_len {
|
||||
any = Some(i);
|
||||
any_len = len;
|
||||
}
|
||||
if j != n - 1 && len >= best_len {
|
||||
best = Some(i);
|
||||
best_len = len;
|
||||
}
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
(best_len >= 2).then(|| &self.keyframes[best_start])
|
||||
best.or(any).map(|k| &self.keyframes[k])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,11 +832,23 @@ fn blit(
|
||||
// Keep the pivot point fixed as the element scales.
|
||||
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;
|
||||
// Two modulate colours multiply into one: `tint` (RGBA, and `0xffffffff` on
|
||||
// essentially every keyframe seen) and `fade` (**ARGB** — the high byte is
|
||||
// the alpha that ramps, the low 24 bits a colour multiply that is `0xffffff`
|
||||
// on 5 276 of the disc's 5 453 resting keyframes). The byte order is not a
|
||||
// guess: it is the high byte that walks 0x00 → 0x80 → 0xc0 → 0xe0 → 0xff
|
||||
// across a fade-in while the low three stay `ffffff`.
|
||||
let (fa, fr, fg, fb) = (
|
||||
(kf.fade >> 24) & 0xff,
|
||||
(kf.fade >> 16) & 0xff,
|
||||
(kf.fade >> 8) & 0xff,
|
||||
kf.fade & 0xff,
|
||||
);
|
||||
let (tr, tg, tb, ta) = (
|
||||
(kf.tint >> 24) & 0xff,
|
||||
(kf.tint >> 16) & 0xff,
|
||||
(kf.tint >> 8) & 0xff,
|
||||
kf.tint & 0xff,
|
||||
((kf.tint >> 24) & 0xff) * fr / 255,
|
||||
((kf.tint >> 16) & 0xff) * fg / 255,
|
||||
((kf.tint >> 8) & 0xff) * fb / 255,
|
||||
(kf.tint & 0xff) * fa / 255,
|
||||
);
|
||||
for row in 0..dh {
|
||||
let ty = oy + row as i32;
|
||||
|
||||
@@ -617,3 +617,60 @@ fn the_developer_logo_splash_composes_with_its_glows() {
|
||||
}
|
||||
assert!(seen >= 2, "found {seen} splash bundles, expected the language pair");
|
||||
}
|
||||
|
||||
/// Applying the keyframe's `fade` alpha must not gut the corpus.
|
||||
///
|
||||
/// It is a modulate, and it can only ever *remove* pixels, so the risk it
|
||||
/// carries is a screen going blank. Measured: it is a no-op on 4 060 of the
|
||||
/// 5 200 sprite elements (alpha `0xff`), partial on 453, and hides 687 — which
|
||||
/// are transient HUD indicators (`pb_emergency`, `pb_refilling`, the target
|
||||
/// arrows) that should not be lit on a resting screen. **No build is left with
|
||||
/// nothing visible.**
|
||||
#[test]
|
||||
fn applying_the_fade_alpha_blanks_no_screen() {
|
||||
skip_without_disc!(root);
|
||||
let (mut sprite_els, mut hidden, mut noop) = (0usize, 0usize, 0usize);
|
||||
let (mut builds, mut blank) = (0usize, 0usize);
|
||||
for_each_build(&root, |pak, bytes| {
|
||||
let Some(b) = ui_layout::parse_build(bytes) else {
|
||||
return;
|
||||
};
|
||||
if b.from_fallback {
|
||||
return;
|
||||
}
|
||||
builds += 1;
|
||||
let mut visible = 0usize;
|
||||
for el in &b.elements {
|
||||
if el.sprite.is_none() {
|
||||
continue;
|
||||
}
|
||||
let Some(k) = el.rest() else { continue };
|
||||
sprite_els += 1;
|
||||
match (k.fade >> 24) & 0xff {
|
||||
0 => hidden += 1,
|
||||
0xff => {
|
||||
noop += 1;
|
||||
visible += 1;
|
||||
}
|
||||
_ => visible += 1,
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
visible > 0 || b.elements.iter().all(|e| e.sprite.is_none()),
|
||||
"{pak}: every element of this build is hidden by its fade alpha"
|
||||
);
|
||||
if visible == 0 {
|
||||
blank += 1;
|
||||
}
|
||||
});
|
||||
assert!(sprite_els > 5000, "only {sprite_els} elements — sweep did not run");
|
||||
assert_eq!(blank, 0, "{blank} of {builds} builds render nothing");
|
||||
// The modulate must stay overwhelmingly a no-op. If a future change to the
|
||||
// resting rule pushes many elements onto a ramp frame, this catches it.
|
||||
assert!(
|
||||
noop * 4 > sprite_els * 3,
|
||||
"the fade alpha is a no-op on only {noop}/{sprite_els} elements — the \
|
||||
resting rule is probably picking ramp frames instead of holds"
|
||||
);
|
||||
eprintln!("fade alpha: {noop} no-op, {hidden} hidden, 0 blank builds of {builds}");
|
||||
}
|
||||
|
||||
@@ -84,13 +84,65 @@ essentially every keyframe. The wrong keyframes were being chosen and then their
|
||||
one distinguishing field was ignored. That is worth stating as its own finding —
|
||||
see below.
|
||||
|
||||
## The `fade` alpha, applied (same day)
|
||||
|
||||
`blit` modulated by `tint` only — `0xffffffff` on essentially every keyframe — so
|
||||
the `fade` word was decoded, stored, and then thrown away. Applying it as an
|
||||
**ARGB** modulate on top of `tint` is what turns the resting pose from an
|
||||
academic result into a picture:
|
||||
|
||||
| composite | best correlation vs the capture | at shift |
|
||||
|---|---|---|
|
||||
| plateau rest, `fade` applied | **0.9538** | (0, 0) |
|
||||
| plateau rest, `fade` ignored | 0.4597 | (0, 0) |
|
||||
| longest-dwell rest | 0.1511 | (+3, +8) |
|
||||
|
||||
0.95 against a framebuffer capture of the running game.
|
||||
[The composite](../captures/ui-layout/title-composited-fade-applied.png) now has
|
||||
the white wordmark with its blue outline, the ™, the copyright, and the orange
|
||||
exploding planet — the last of which appeared because a full-screen blue effect
|
||||
that rests at alpha 0 had been painting over it at full opacity.
|
||||
|
||||
**ARGB is measured, not assumed.** Across a fade-in the *high* byte walks
|
||||
`0x00 → 0x80 → 0xc0 → 0xe0 → 0xff` while the low three stay `ffffff`, and the low
|
||||
24 bits are `0xffffff` on 5 276 of the disc's 5 453 resting keyframes (with
|
||||
`0x000000` on 165 — the `.prm` blacks — and `0x5dc9ff` on 12).
|
||||
|
||||
Risk checked, because a modulate can only ever remove pixels: it is a **no-op on
|
||||
4 060 of 5 200** sprite elements, partial on 453, and hides 687 — which are
|
||||
transient HUD indicators (`pb_emergency`, `pb_refilling`, `pbcm1_arrow1`) that
|
||||
should not be lit on a resting screen. **No build is left with nothing visible**,
|
||||
and a disc test asserts it.
|
||||
|
||||
### And it caught a bug in the resting rule
|
||||
|
||||
With `fade` applied, the pause menu lost the word **PAUSE**, which the capture of
|
||||
the running game plainly shows
|
||||
([`pause-tutorial-real-vs-rebuilt.png`](../captures/ui-layout/pause-tutorial-real-vs-rebuilt.png)).
|
||||
|
||||
`pgptitle.rat` has **three** runs of two identical keyframes — invisible,
|
||||
visible, invisible:
|
||||
|
||||
```
|
||||
kf0 a=0x00 t=5 kf1 a=0x00 t=13 pre-roll
|
||||
kf2 a=0xff t=23 kf3 a=0xff t=28 the hold
|
||||
kf4 a=0x00 t=30 kf5 a=0x00 t=None the exit
|
||||
```
|
||||
|
||||
A group carries the screen's entry animation **and its exit**. The tie-break
|
||||
"later run wins" grabbed the exit. Fixed: a run ending on the last keyframe is
|
||||
excluded unless it is the only one. The title's correlation is unchanged at
|
||||
0.9538, and [PAUSE is back](../captures/ui-layout/pause-composited-fade-applied.png).
|
||||
|
||||
This is why the two changes landed together: the trailing-run defect is
|
||||
invisible — in the literal sense — until `fade` is applied.
|
||||
|
||||
## What is not settled
|
||||
|
||||
* ❔ **`compose` ignores the keyframe `fade` alpha entirely.** `blit` uses
|
||||
`tint`. Applying `fade` is the obvious next step and is what makes the resting
|
||||
pose visible rather than academic — but it changes every screen and needs its
|
||||
own A/B against the capture. It is also the prerequisite for drawing `.prm`
|
||||
quads, whose entire content is that alpha.
|
||||
* ❔ **Blend mode.** Everything above is straight alpha-over. The near-white
|
||||
flash quads and the coloured ones may well be additive, and nothing has been
|
||||
measured; the title capture cannot separate the two because its resting
|
||||
elements are all `0xffffff`.
|
||||
* 🟡 **The fallback.** Groups with no two adjacent keyframes alike still use
|
||||
longest-dwell. How many there are, and whether the correct answer for them is
|
||||
the *last* keyframe instead, is unmeasured.
|
||||
|
||||
Reference in New Issue
Block a user