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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 641 KiB |
BIN
docs/re/captures/ui-layout/title-composited-plateau-rest.png
Normal file
BIN
docs/re/captures/ui-layout/title-composited-plateau-rest.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 613 KiB |
103
docs/re/structures/ui-resting-pose.md
Normal file
103
docs/re/structures/ui-resting-pose.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# A keyframe is the start of a ramp, not a pose that is held
|
||||
|
||||
**Status:** ✅ `CONFIRMED` against the framebuffer capture of the running title
|
||||
screen — the new rule aligns at **zero shift**, the old one had to be moved.
|
||||
🟡 the fallback for groups that never hold is unverified. ❔ interpolation
|
||||
between keyframes is still not implemented, only the resting pose.
|
||||
|
||||
## The rule that was wrong
|
||||
|
||||
`Element::rest()` answers "where is this element when the screen is just sitting
|
||||
there", and every composite the port draws depends on it. It used to pick the
|
||||
keyframe with the **largest gap to the next keyframe's time** — the frame that
|
||||
"dwells longest".
|
||||
|
||||
That reads a keyframe as a value held until the next one. It is not: a keyframe
|
||||
is the **start of a ramp toward the next one**. So a long gap after keyframe *k*
|
||||
means the screen spends that whole time *arriving at* `k+1` — the settled pose is
|
||||
at the **far** end of the gap, not the near one.
|
||||
|
||||
The title wordmark makes it concrete. `ptlogo1.t32` zooms in from off-screen:
|
||||
|
||||
```
|
||||
kf0 150% (-116, -7) a=0x00 t=26 off-screen, invisible
|
||||
kf1 150% (-116, -7) a=0x00 t=34
|
||||
kf2 112% ( 109, 123) a=0x80 t=38
|
||||
kf3 103% ( 165, 171) a=0xc0 t=40
|
||||
kf4 101% ( 179, 186) a=0xe0 t=42 ← old rule picked this
|
||||
kf5 100% ( 184, 193) a=0xff t=251 ← the settled pose
|
||||
kf6 100% ( 184, 193) a=0xff t=264 ← held here
|
||||
kf7 100% ( 184, 193) a=0x00 t=None fades out
|
||||
```
|
||||
|
||||
The gap 42 → 251 is by far the largest, so the old rule picked **kf4** — 1 % too
|
||||
large and 5 px up-left, a frame from mid-zoom. The pose the screen actually holds
|
||||
is kf5–kf6.
|
||||
|
||||
## The rule that is right
|
||||
|
||||
**The resting pose is the hold: the longest run of consecutive keyframes with an
|
||||
identical pose.** Ties go to the later run, matching the in → hold → out shape.
|
||||
Groups that ramp through every frame and never hold fall back to longest-dwell.
|
||||
|
||||
## The measurement
|
||||
|
||||
`docs/re/captures/title-screen-oracle.png` is a framebuffer capture of the
|
||||
running title screen. It is a **1:1 crop** of the 1280×720 frame (1279×675) —
|
||||
verified by the copyright line landing on row 669 in the capture and in both
|
||||
composites — so frame coordinates map directly and a shift is meaningful.
|
||||
|
||||
Edge-correlated over the wordmark box (x 150–1150, y 200–400) with
|
||||
`tools/re-capture/align_to_capture.py`. Gradient magnitude, not colour: the
|
||||
capture's planet is mid-explosion and orange while ours is blue, and the wordmark
|
||||
materials are undecoded, so a pixel diff would measure everything except the
|
||||
question being asked.
|
||||
|
||||
| resting rule | best correlation | at shift | at (0,0) |
|
||||
|---|---|---|---|
|
||||
| **plateau** (landed) | **0.4597** | **(0, 0)** | 0.4597 |
|
||||
| longest dwell (old) | 0.1511 | (+3, +8) | 0.1268 |
|
||||
|
||||
The old composite peaks 3× lower **and only after being moved** — displaced by
|
||||
about the (−5,−7) that kf4-instead-of-kf5 predicts. The new one is already where
|
||||
the game puts it.
|
||||
|
||||
* [composited with the plateau rule](../captures/ui-layout/title-composited-plateau-rest.png)
|
||||
* [composited with the old longest-dwell rule](../captures/ui-layout/title-composited-longest-dwell-rest.png)
|
||||
|
||||
## What else it fixed, on the same screen
|
||||
|
||||
The old rule systematically picked the **invisible** end of a fade-in. On the
|
||||
title screen it rested these at alpha `0x00`, where the capture plainly shows
|
||||
them:
|
||||
|
||||
`ptlogo_tm` (the ™), `ptcopyright`, `ptlogo_back2`, `ptlogo_back2eff`,
|
||||
`ptloop01`/`ptloop02`.
|
||||
|
||||
And `pteff00.prm` — the full-screen fade quad that paints **last** — rested at
|
||||
**opaque black**. That is the whole screen, and it is why `.prm` compositing was
|
||||
blocked ([`ui-prm-primitives.md`](ui-prm-primitives.md)).
|
||||
|
||||
None of that was visible before, because `compose` never applied the `fade`
|
||||
alpha at all: `blit` modulates by `tint` only, and `tint` is `0xffffffff` on
|
||||
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.
|
||||
|
||||
## 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.
|
||||
* 🟡 **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.
|
||||
* ❔ **Interpolation.** Only the resting pose is decoded; nothing tweens. A
|
||||
viewer that animates these screens needs the ramp, and whether it is linear is
|
||||
unknown.
|
||||
* 🟡 One-shot flashes (`ptlogoall_eff`, `ptlogoall_eff2`) ramp 0 → 0x80 → 0x4b →
|
||||
0 and never hold at a visible value, so the plateau rule rests them at alpha 0
|
||||
— invisible. That is *probably* right for a settled screen, but the capture
|
||||
cannot confirm it while `fade` is unapplied.
|
||||
65
tools/re-capture/align_to_capture.py
Executable file
65
tools/re-capture/align_to_capture.py
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Score a composite against a framebuffer capture of the running game.
|
||||
|
||||
Edge-correlates the two over a region and reports the best shift. A composite
|
||||
that is *right* peaks at (0,0); one that is displaced peaks somewhere else and
|
||||
scores lower, which is how the resting-pose rule was settled
|
||||
(docs/re/structures/ui-resting-pose.md).
|
||||
|
||||
Colour is deliberately discarded — the capture and the composite differ in
|
||||
palette (undecoded materials, a different animation moment for the background),
|
||||
so a raw pixel diff is dominated by things the geometry question does not care
|
||||
about. Gradient magnitude keeps the edges, which is where placement lives.
|
||||
|
||||
Both images must share an origin. A capture that is a 1:1 *crop* of the frame
|
||||
is fine; a scaled one is not, and must be resampled first.
|
||||
|
||||
align_to_capture.py CAPTURE COMPOSITE [COMPOSITE...] [--region X0 Y0 X1 Y1]
|
||||
"""
|
||||
import argparse
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def edges(path):
|
||||
a = np.asarray(Image.open(path).convert("L"), dtype=float)
|
||||
gy, gx = np.gradient(a)
|
||||
return np.hypot(gx, gy)
|
||||
|
||||
|
||||
def norm(a):
|
||||
return (a - a.mean()) / (a.std() + 1e-9)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("capture")
|
||||
ap.add_argument("composite", nargs="+")
|
||||
ap.add_argument("--region", nargs=4, type=int, metavar=("X0", "Y0", "X1", "Y1"),
|
||||
default=[150, 200, 1150, 400],
|
||||
help="frame-space box to score over (default: the title wordmark)")
|
||||
ap.add_argument("--radius", type=int, default=14, help="max shift searched, px")
|
||||
args = ap.parse_args()
|
||||
|
||||
x0, y0, x1, y1 = args.region
|
||||
ref = norm(edges(args.capture)[y0:y1, x0:x1])
|
||||
r = args.radius
|
||||
for path in args.composite:
|
||||
img = edges(path)
|
||||
best = (-2.0, 0, 0)
|
||||
for dy in range(-r, r + 1):
|
||||
for dx in range(-r, r + 1):
|
||||
pat = img[y0 + dy:y1 + dy, x0 + dx:x1 + dx]
|
||||
if pat.shape != ref.shape:
|
||||
continue
|
||||
s = float((ref * norm(pat)).mean())
|
||||
if s > best[0]:
|
||||
best = (s, dx, dy)
|
||||
zero = float((ref * norm(img[y0:y1, x0:x1])).mean())
|
||||
print(f"{path}: best {best[0]:.4f} at ({best[1]:+d},{best[2]:+d}) "
|
||||
f"at (0,0): {zero:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user