re(ui): Q1 -- the interpolation law holds, the group timeline does not

Q1's gate asks whether the ramp is linear. It is, and that result stands:
it rests on the splash's _eff glows, which reproduce exactly. This adds
the part that does not.

The test is a calibration, not a fit. Fix the clock on
palogo_gamearts_eff -- declared 15-unit fade-in 0@15 -> 255@30 against
captured alphas 34,68,102,136,170,204,238, a constant step of 34, giving
t = 2f - 171 -- then check that against the glow's own next landmark: its
declared hold ends t=45, predicted frame 108.0, observed last full-alpha
frame 107. Then apply it to palogo_gamearts in the same bundle and the
same frames, with no free parameter left:

  declared a=232 at t=206 -> frame 188.5, observed alpha 255
  declared a= 32 at t=210 -> frame 190.5, observed alpha 255

The logo is still at full alpha nine frames after it should read 32; its
fade-out runs ~17 frames late; its declared 80-frame fade-in is never
drawn. Not culling -- the same element is submitted down to a=7 on the
way out. Calibration-free version: the declared fade-out spends 12 of 16
units dropping 23/255 of the alpha, and the capture has no such plateau.

Candidate, offered and NOT adopted: if +36 held the NEXT keyframe's time,
the fade-out shape fits (RMS 4.05 vs 12.13, two elements) and the
decoder's "last block's time is unreadable" special case disappears --
the last block would simply have no successor. Rejected for now because
it explains neither the missing fade-in nor the lateness, and because the
_eff elements cannot discriminate between the readings at all (with four
blocks the shift only relabels the phases). Decoder unchanged.

Also withdrawn, mine, within the iteration: "the _eff glows hold a
constant alpha 33". They ramp 34 -> 255 in steps of 34. I printed the
series minimum and read it as its range, with a "14 distinct colours"
column sitting next to it saying otherwise.
This commit is contained in:
Sylpheed RE agent
2026-08-28 22:56:17 +00:00
parent f5097779e4
commit 8c1a5669b8
6 changed files with 208 additions and 1 deletions

View File

@@ -27,7 +27,7 @@ authored version can be deleted.
| | Question | State | Answer / link |
|---|---|---|---|
| Q1 | keyframe time unit + ramp shape | ✅ answered | ramp is **linear**; **2 units per rendered frame**; **`1 unit = 1/60 s` — settled**, the idle title presents at 28.5 fps so the game is 30 Hz — [`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md) |
| Q1 | keyframe time unit + ramp shape | ✅ answered, 🟡 one gap | ramp is **linear**; **2 units per rendered frame**; **`1 unit = 1/60 s` — settled**, the idle title presents at 28.5 fps so the game is 30 Hz. 🟡 **The interpolation law is settled; the group TIMELINE for multi-keyframe elements is not**`palogo_gamearts` is still at full alpha 9 frames after its declared `a=32`, and its declared 80-frame fade-in never draws — [`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md) |
| Q2 | which build is which screen state | ✅ answered | `GP_TITLE` is **8 screens shipped twice, EN/JP**: 4/7 title art, 2/3 the `PRESS Ⓐ` plate, 5/8 main menu, 6/9 `EXTRAS`, 0/1 and 10/11 two unidentified `DELTASABER` plates — [`ui-title-build-map.md`](../re/ui-title-build-map.md) |
| Q3 | paint order for the six screens | ✅ answered | **decoded**: a `u16` layer key at `+0x0A` of each `T8aD` sprite header, stable-sorted with declaration index; unkeyed elements get an implied key. Confirmed on 5 measured orders + `EXTRAS` vs a capture. One residual: the **tie-break** is unknown and bites on one element of the title — [`structures/ui-paint-order-key.md`](../re/structures/ui-paint-order-key.md) |
| Q4 | button → GamePart | ✅ answered | **measured** which screen all **5** buttons open — `NEW GAME``DIFFICULTY``SELECT DATA`, not a hang. The **GamePart id is still a name match**, not a measurement — [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md) |
@@ -249,6 +249,21 @@ authored version can be deleted.
declared 15-unit fade lands on `round(255·k/15)` for all seven of its samples,
with `k` stepping 2, 4, 6, 8, 10, 12, 14 on seven consecutive submitted frames.
**measured**, not decoded — the disc says `t=30`, it does not say what a `t` is.
🟡 **But a multi-keyframe element's TIMELINE does not reproduce (2026-08-28).**
The linear law and the 2-units-per-frame rate rest on the splash's `_eff`
glows, and those are exact. Applying *their* calibration — with no free
parameter left — to `palogo_gamearts` in the same bundle and the same frames:
the logo is still at `a=255` nine frames after its declared `a=32`, its
fade-out runs ~17 frames late, and its declared 80-frame fade-in is never
drawn at all (and that is not culling — the same element is submitted down to
`a=7` on the way out). Its declared fade-out also spends 12 of 16 units
dropping only 23/255 of the alpha; the capture shows no such plateau.
A candidate — that `+36` holds the *next* keyframe's time — fixes the shape
(RMS 4.05 vs 12.13 on two elements) and removes the decoder's "last block's
time is unreadable" special case, but does **not** explain the missing fade-in
or the lateness, and the `_eff` elements cannot discriminate between the two
readings. **Not adopted; the decoder is unchanged.** Take the interpolation law
as settled and treat a multi-keyframe group's absolute timing as unverified.
**The seconds conversion is settled: `1 unit = 1/60 s`, a 30 Hz title.** The
re-test this page used to name has been run — 300 submitted frames timed on the
**idle** title (nothing loading) came out at **28.8 and 28.3 fps**, the same

View File

@@ -358,3 +358,17 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the
the case that makes a term *large* — here 600 % and 800 % scale, worth 450 px —
and check it there. A term you cannot distinguish from zero has not been
verified by any amount of agreement.
* **Printing a series' minimum and reading it as its range.** I summarised a
captured alpha series as "constant α ≈ 33" and built a contradiction on it —
the summary printed `min_alpha` and no maximum, and the series actually ramps
34 → 255 → 33. The tell was there in the same table: the column beside it said
*14 distinct colours*, which a constant series cannot have. When a summary
statistic and a distinct-value count disagree, the summary is wrong.
* **Calibrate on one element, test on another.** Fitting a declared ramp to a
capture has two free parameters (rate and offset) and will "succeed" against
almost anything — my first attempt scored RMS 128/255 and I nearly read the
numbers rather than noticing the search could not reach the ramp at all. The
version that means something: fix the clock from element A's ramp, check that
fix against A's own next landmark, then apply it to element B in the same
frames with **nothing left to tune**. That is what turned "the shapes look
different" into "still at 255 nine frames after it should read 32".

View File

@@ -376,3 +376,12 @@ neighbourhood, not just the line.
* "every IDXD string value is ASCII" → 6 non-ASCII values of 99 328.
* "`文字列` is a dev placeholder" → they are Shift-JIS **type words**.
* "the splash `_eff` glows hold a constant α ≈ 33, contradicting their declared
255 plateau" → **mine, and refuted within the iteration.** They ramp 34 → 255
in exact steps of 34. I had printed the series' minimum and read it as its
range. [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)
* "the declared keyframe timeline reproduces the captured splash" → **refuted for
multi-keyframe elements.** `palogo_gamearts` is still at `a=255` nine frames
after its declared `a=32`, and its declared 80-frame fade-in is never drawn.
The `_eff` glows do reproduce, exactly — so this is about the group timeline,
not about the interpolation law. [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)

View File

@@ -0,0 +1,20 @@
CONTROL — the eff glow's fade-in must be linear at a constant step
alphas [34, 68, 102, 136, 170, 204, 238] steps [34, 34, 34, 34, 34, 34]
=> 2.000 units/frame; t(f) = 2*f - 171
check: declared hold ends t=45 -> predicted frame 108.0; observed last full-alpha frame 107
LOGO — palogo_gamearts, checked against that calibration
observed: first drawn frame 116 at alpha 255; full alpha through 198; fade-out 199..211
declared a= 0 at t= 15 -> frame 93.0 observed alpha NOT DRAWN
declared a= 0 at t= 30 -> frame 100.5 observed alpha NOT DRAWN
declared a=255 at t=190 -> frame 180.5 observed alpha 255
declared a=255 at t=194 -> frame 182.5 observed alpha 255
declared a=232 at t=206 -> frame 188.5 observed alpha 255
declared a= 32 at t=210 -> frame 190.5 observed alpha 255
SHAPE, independent of any calibration:
declared fade-out spans t=194..210 (16 units); of that,
12/16 units drop only 23/255 of the alpha (a near-flat leg),
4/16 units drop 200/255 (a cliff).
captured fade-out: [255, 254, 243, 237, 215, 181, 148, 131, 98, 65, 48, 23, 15, 7]
per-frame drops: [1, 11, 6, 22, 34, 33, 17, 33, 33, 17, 25, 8, 8] -> no near-flat leg

View File

@@ -161,3 +161,82 @@ the competing reading is ruled out.
`_sqex_eff` absence is explainable (the capture joined the SQUARE ENIX logo
mid-hold); the missing studio-anima logo is not, and is left as an open
observation for Q2 rather than a claim.
---
## 🟡 Open: the `palogo_*` LOGO elements do not play their declared timeline
**Added 2026-08-28.** Everything above stands — it rests on the `_eff` glows, and
they reproduce exactly. What follows is a **different element in the same bundle
and the same capture**, and it does not.
Reproduce with
[`tools/re-capture/splash_ramp_check.py`](../../tools/re-capture/splash_ramp_check.py);
output committed at [`data/splash-ramp-check.txt`](data/splash-ramp-check.txt).
### Why this is a test and not a fit
The clock is calibrated on `palogo_gamearts_eff` — its declared 15-unit fade-in
`0@15 → 255@30` against its captured alphas 34, 68, 102, 136, 170, 204, 238, a
constant step of exactly 34. That gives `t = 2f 171`, and the calibration
checks itself: the glow's declared hold ends at `t=45`, predicted **frame 108.0**,
and the observed last full-alpha frame is **107**.
That calibration is then applied to `palogo_gamearts` — same bundle, same frames,
**no free parameter left**:
| declared | predicted frame | observed alpha |
|---|---|---|
| `a=0` at `t=15` | 93.0 | not drawn |
| `a=0` at `t=30` | 100.5 | not drawn |
| `a=255` at `t=190` | 180.5 | 255 |
| `a=232` at `t=206` | 188.5 | **255** |
| `a=32` at `t=210` | 190.5 | **255** |
The logo is still at full alpha nine frames after it should have been at `a=32`.
Its fade-out actually runs frames **199211**, some 17 frames late, and its
declared 80-frame fade-in (`t=30→190`) is **not drawn at all** — the element's
first appearance, frame 116, is already at 255.
⚠️ "Not drawn" is not a culling artefact: the same element is submitted all the
way down to `a=7` on the way out, so low-alpha quads plainly do reach the GPU.
### The shape mismatch, which needs no calibration at all
The declared fade-out spends **12 of its 16 units** dropping only **23/255** of
the alpha — a near-flat leg — then **200/255** in the remaining 4. The captured
per-frame drops are `1, 11, 6, 22, 34, 33, 17, 33, 33, 17, 25, 8, 8`. There is no
near-flat leg.
### 🟡 A candidate, offered as one and NOT adopted
If the word at `+36` were the time of the **next** keyframe rather than of its own
block, the logo's fade-out would read `255@190 → 232@194 → 32@206 → 0@210`:
slow, fast, slow — which is the captured shape. Fitting both readings to the
captured fade-out gives RMS alpha error **4.05** shifted against **12.13** as
decoded, on two elements independently (`gamearts` and `sqex`).
It also removes a special case. The decoder currently notes that a group's data
"stops 4 bytes short of its final block's time slot", so the last block's time is
unreadable. Under the shifted reading the last block simply *has no successor*,
so it has no time word — the same bytes, no special case.
**Not adopted, for three reasons:**
1. It does not explain the missing fade-in, which is the larger anomaly.
2. It does not fix the 17-frame lateness of the fade-out.
3. The `_eff` elements **do not discriminate** between the two readings — I
checked: with four blocks the shift merely relabels which phase is which, and
both reproduce the observed fade-in / hold / fade-out. So the entire case for
the shift rests on one element's fade-out shape.
**The decoder is unchanged.** 🟡 What a port should take from this: the
interpolation *law* is settled (linear, 2 units per frame), and the *group
timeline* for multi-keyframe elements is not.
### 🔴 One thing I got wrong in the course of this
I first reported the `_eff` glows as holding "a constant α ≈ 33" and read that as
contradicting the declared 255 plateau. They do no such thing — they ramp 34 →
255 in exact steps of 34. I had printed only the series' **minimum** and read it
as its range. Withdrawn; the trap is in [METHOD.md](METHOD.md).

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Do the splash's DECLARED keyframe ramps reproduce the captured ones?
Calibrate the animation clock on one element, then apply that calibration to a
DIFFERENT element in the same bundle and the same frames. No free parameter is
left for the second element, so it is a real test rather than a fit.
The `_eff` glows calibrate it (they are the basis of Q1's "linear, 2 units per
frame" result). The `palogo_*` logos are then checked against it.
splash_ramp_check.py docs/re/captures/ui-timing/splash-build-draws.log
"""
import csv, subprocess, sys, collections, tempfile, os
LOG = sys.argv[1] if len(sys.argv) > 1 else \
"docs/re/captures/ui-timing/splash-build-draws.log"
here = os.path.dirname(os.path.abspath(__file__))
csvf = tempfile.NamedTemporaryFile(suffix=".csv", delete=False).name
subprocess.run([sys.executable, os.path.join(here, "kf_time_probe.py"), LOG,
"--csv", csvf], check=True, capture_output=True)
series = collections.defaultdict(dict)
for r in csv.DictReader(open(csvf)):
if r["col"]:
series[(r["w"], r["h"])][int(r["frame"])] = int(r["col"][:2], 16)
EFF, LOGO = ("525", "90"), ("499", "72") # gamearts_eff, gamearts
# --- calibration, from the eff element's declared 15-unit fade-in 0@15 -> 255@30
eff = series[EFF]
ramp = [(f, a) for f, a in sorted(eff.items()) if a < 255][:7]
steps = [ramp[i + 1][1] - ramp[i][1] for i in range(len(ramp) - 1)]
print("CONTROL — the eff glow's fade-in must be linear at a constant step")
print(f" alphas {[a for _, a in ramp]} steps {steps}")
assert len(set(steps)) == 1, "eff fade-in is not a constant step; calibration void"
k_per_frame = steps[0] / (255 / 15) # alpha step -> units per frame
f0 = ramp[0][0]
t_at = lambda f: 15 + k_per_frame * (f - f0) + k_per_frame
print(f" => {k_per_frame:.3f} units/frame; t(f) = {k_per_frame:.0f}*f - "
f"{k_per_frame * f0 - 15 - k_per_frame:.0f}")
# the calibration's own check: the eff's declared hold ends at t=45
end_hold = max(f for f, a in eff.items() if a == 255)
print(f" check: declared hold ends t=45 -> predicted frame "
f"{f0 + (45 - 15) / k_per_frame - 1:.1f}; observed last full-alpha frame {end_hold}")
# --- now the logo, with no freedom left
decl = [(15, 0), (30, 0), (190, 255), (194, 255), (206, 232), (210, 32)]
logo = series[LOGO]
first, last = min(logo), max(logo)
print("\nLOGO — palogo_gamearts, checked against that calibration")
print(f" observed: first drawn frame {first} at alpha {logo[first]}; "
f"full alpha through {max(f for f,a in logo.items() if a==255)}; "
f"fade-out {first if logo[first]<255 else min(f for f,a in sorted(logo.items()) if a<255)}..{last}")
for t, a in decl:
fr = f0 + (t - 15) / k_per_frame - 1
got = logo.get(round(fr))
print(f" declared a={a:3d} at t={t:3d} -> frame {fr:6.1f} observed alpha "
f"{got if got is not None else 'NOT DRAWN'}")
print("\nSHAPE, independent of any calibration:")
span = decl[-1][0] - decl[3][0]
print(f" declared fade-out spans t={decl[3][0]}..{decl[-1][0]} ({span} units); of that,")
print(f" {decl[4][0]-decl[3][0]}/{span} units drop only {decl[3][1]-decl[4][1]}/255 of the alpha (a near-flat leg),")
print(f" {decl[5][0]-decl[4][0]}/{span} units drop {decl[4][1]-decl[5][1]}/255 (a cliff).")
fo = [(f, a) for f, a in sorted(logo.items()) if f >= 198]
print(f" captured fade-out: {[a for _, a in fo]}")
d = [fo[i][1] - fo[i+1][1] for i in range(len(fo)-1)]
print(f" per-frame drops: {d} -> no near-flat leg")
os.unlink(csvf)