formats: the resting pose is the hold, not the longest dwell
Element::rest() picked the keyframe with the largest gap to the next keyframe's
time. That reads a keyframe as a value held until the next one; it is the start
of a ramp toward it. A long gap after keyframe k means the screen spends that
time arriving at k+1, so the settled pose is at the far end of the gap.
The title wordmark zooms in over five frames and holds at (184,193) at 100% from
t=251 to t=264. The old rule picked the frame before the long gap: (179,186) at
101%, still mid-zoom.
Measured against the framebuffer capture of the running title screen, which is a
1:1 crop so frame coordinates map directly (confirmed: the copyright line lands
on row 669 in the capture and in both composites). Edge-correlated over the
wordmark box:
plateau (landed) best 0.4597 at shift (0,0)
longest dwell (old) best 0.1511 at shift (+3,+8), 0.1268 at (0,0)
The old composite scores 3x lower and only peaks after being moved, by about the
(-5,-7) that picking kf4 instead of kf5 predicts.
It also fixes six title elements the old rule rested at alpha 0x00 where the
capture plainly shows them, and pteff00.prm - the full-screen fade quad painted
last - which rested at opaque black. That was the blocker on .prm compositing.
Adds tools/re-capture/align_to_capture.py, which is how this was scored, and
turns the .prm test that deliberately asserted the old defect into a guard on
the fix.
Not settled and now the next item: compose ignores the keyframe fade alpha
entirely (blit modulates by tint only), which is why choosing the wrong keyframe
was invisible until now.
This commit is contained in:
@@ -124,11 +124,39 @@ pub struct Element {
|
||||
impl Element {
|
||||
/// Where the element actually rests on screen.
|
||||
///
|
||||
/// A keyframe group is an in → hold → out animation, so the resting pose is
|
||||
/// neither the first nor the last frame: it is the one that **dwells
|
||||
/// longest** — the largest gap to the next keyframe's time. A single
|
||||
/// keyframe is its own rest position.
|
||||
/// A keyframe group is an in → hold → out animation, and the resting pose is
|
||||
/// **the hold**: the longest run of consecutive keyframes whose pose is
|
||||
/// identical. A single keyframe is its own rest position.
|
||||
///
|
||||
/// This replaced a longest-*dwell* rule — pick the keyframe with the largest
|
||||
/// gap to the next keyframe's time — which was wrong for a reason worth
|
||||
/// stating, because it is the whole shape of this format. A keyframe is the
|
||||
/// **start of a ramp toward the next one**, not a value held until it. So a
|
||||
/// long gap after keyframe *k* means the screen spends that time *arriving
|
||||
/// at* `k+1`; the settled pose is at the far end of the gap, not the near
|
||||
/// one. The title wordmark zooms in over 5 frames and then holds at
|
||||
/// `(184,193)` at 100 % from t=251 to t=264; the old rule picked the frame
|
||||
/// before the long gap — `(179,186)` at 101 %, still mid-zoom.
|
||||
///
|
||||
/// Measured, not argued. Edge-correlating the composite against the
|
||||
/// framebuffer capture of the running title screen
|
||||
/// (`docs/re/captures/title-screen-oracle.png`, which is a 1:1 crop, so
|
||||
/// coordinates map directly):
|
||||
///
|
||||
/// | rule | best correlation | at shift |
|
||||
/// |---|---|---|
|
||||
/// | plateau (this one) | **0.4597** | **(0, 0)** |
|
||||
/// | longest dwell (old) | 0.1511 | (+3, +8) |
|
||||
///
|
||||
/// The old composite had to be *moved* to line up with the game. See
|
||||
/// `docs/re/structures/ui-resting-pose.md`.
|
||||
///
|
||||
/// Falls back to the longest-dwell rule when no two adjacent keyframes
|
||||
/// agree — a group that ramps through every frame and never holds.
|
||||
pub fn rest(&self) -> Option<&Keyframe> {
|
||||
if let Some(k) = self.rest_plateau() {
|
||||
return Some(k);
|
||||
}
|
||||
match self.keyframes.len() {
|
||||
0 => None,
|
||||
1 => self.keyframes.first(),
|
||||
@@ -154,6 +182,40 @@ impl Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hold: the longest run of consecutive keyframes whose pose is
|
||||
/// identical. `None` when no two adjacent frames agree.
|
||||
fn rest_plateau(&self) -> Option<&Keyframe> {
|
||||
let n = self.keyframes.len();
|
||||
if n < 2 {
|
||||
return None;
|
||||
}
|
||||
let same = |a: &Keyframe, b: &Keyframe| {
|
||||
a.fade == b.fade
|
||||
&& a.scale_x == b.scale_x
|
||||
&& a.scale_y == b.scale_y
|
||||
&& a.tint == b.tint
|
||||
&& a.x == b.x
|
||||
&& a.y == b.y
|
||||
};
|
||||
let (mut best_start, mut best_len) = (0usize, 0usize);
|
||||
let mut i = 0usize;
|
||||
while i < n {
|
||||
let mut j = i;
|
||||
while j + 1 < n && same(&self.keyframes[j], &self.keyframes[j + 1]) {
|
||||
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;
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
(best_len >= 2).then(|| &self.keyframes[best_start])
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed UI build: one screen layout (one context × language).
|
||||
|
||||
@@ -109,22 +109,20 @@ fn kind_bit_0x10_means_prm_exactly_and_prm_carries_no_payload() {
|
||||
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.
|
||||
/// **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. `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.
|
||||
/// 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.
|
||||
///
|
||||
/// 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).
|
||||
/// 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_is_a_transition_and_rest_picks_the_wrong_end() {
|
||||
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;
|
||||
@@ -149,10 +147,9 @@ fn the_title_fade_quad_is_a_transition_and_rest_picks_the_wrong_end() {
|
||||
);
|
||||
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"
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user