The placement region is `frames` records of `{u32 time; 36-byte pose}` after an
8-byte header, so the time word PRECEDES the pose it belongs to. Our parser's
40-byte window opened at the pose, four bytes into the record, and then read the
word at its `+36` as that pose's time -- which is the NEXT pose's. Every pose
field was right; only the time association slipped by one.
Two things the corpus has carried for weeks are that off-by-one and nothing
else: "a group's data stops 4 bytes short of its final block's time slot", and
"the last keyframe carries no time". The group is not short (8 + frames*40 is
exact) and no time is missing -- the first pose's time is the lead-in word at
`header + 8` that `parse_placements` skipped without asking what it was.
Disc-wide, 33 archives, 13 991 groups, each test with a control:
A lead-in prepended to the shifted times is non-decreasing 13991/13991
B a non-zero lead-in is strictly below the next time 5058/5058
control (another group's lead-in, same bundle) 70.9%
C multi-segment alpha ramp at a constant rate, corrected 857/1540
the same, under the old reading 0/1042
C is the one that cannot be argued with: interpolation between keyframes is
linear, and under the old reading not one multi-keyframe ramp on the disc comes
out at a constant rate.
Adoption is free on every static composite, which is what the corpus previously
declined it over. `SYLPHEED_KF_TIME_SHIFT=1` moved GP_TITLE build 7 by 13.1% of
its pixels because it left pose 0 untimed; with the lead-in restored, all 12
GP_TITLE builds render byte-identically, and across 217 builds in six archives
only two elements pick a different rest pose -- both times between two poses
that are equally invisible.
`SYLPHEED_KF_TIME_SHIFT` is gone; `SYLPHEED_KF_TIME_LEGACY=1` restores the old
reading for A/B work.
ui_header_time_disc needed one line: 546 bundles whose every group is a single
static pose now report max_time = 0 where they previously reported no time at
all. Excluding them, the result it guards strengthened -- the bound holds over
2 859 bundles instead of 2 313, still with zero violations.
Not established: the executable's own parser. Reach is written down.
docs/re/ui-keyframe-record-layout.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsxw1A9JseUw99Yw1ZRQzY
215 lines
7.2 KiB
Python
Executable File
215 lines
7.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Disc-wide test of the UI placement-group record layout.
|
|
|
|
A placement group in a screen bundle is an 8-byte header `{u32 element_index,
|
|
u32 frame_count}` followed by `frame_count` records of 40 bytes, each
|
|
`{u32 time; 36-byte pose}`. The time word therefore **precedes** the pose it
|
|
belongs to.
|
|
|
|
Our parser's block window starts at the pose, so under the old reading a
|
|
block's `+36` word was taken as *its own* time — off by one, which left the
|
|
group's final pose (the end of every fade-out) untimed and made the group look
|
|
4 bytes short.
|
|
|
|
This script tests the corrected reading against the whole disc, with controls:
|
|
|
|
A. monotonicity — prepending the lead-in word to the shifted time series must
|
|
give a non-decreasing sequence, for every group.
|
|
B. the non-zero lead-ins — a lead-in that is a *time* must be strictly less
|
|
than the next time. Control: swap in another group's lead-in from the
|
|
same bundle.
|
|
C. ramp linearity — within a monotone alpha ramp of >=3 segments, is
|
|
d(alpha)/d(time) constant? Keyframe interpolation is linear
|
|
(`docs/re/ui-keyframe-time-unit.md`), so a correct time assignment should
|
|
make multi-keyframe ramps come out at a constant rate far more often than
|
|
an incorrect one.
|
|
|
|
Usage: python3 tools/re-capture/kf_record_census.py "$SYLPHEED_DISC"/dat/*.pak
|
|
"""
|
|
import collections
|
|
import os
|
|
import random
|
|
import struct
|
|
import sys
|
|
import zlib
|
|
|
|
|
|
def be32(b, o):
|
|
return struct.unpack_from(">I", b, o)[0]
|
|
|
|
|
|
def load_pak(pak_path):
|
|
"""Entries of an IPFB archive, transparently un-Z1'd. See sylpheed-formats::pak."""
|
|
idx = open(pak_path, "rb").read()
|
|
if idx[:4] != b"IPFB":
|
|
return []
|
|
n = be32(idx, 4)
|
|
data = b""
|
|
base = os.path.splitext(pak_path)[0]
|
|
for i in range(100):
|
|
p = f"{base}.p{i:02d}"
|
|
if not os.path.exists(p):
|
|
break
|
|
data += open(p, "rb").read()
|
|
out = []
|
|
for k in range(n):
|
|
_h, off, sz = struct.unpack_from(">III", idx, 0x10 + 12 * k)
|
|
blob = data[off : off + sz]
|
|
if blob[:2] == b"Z1":
|
|
try:
|
|
blob = zlib.decompress(blob[0x0A:])
|
|
except Exception:
|
|
blob = b""
|
|
out.append(blob)
|
|
return out
|
|
|
|
|
|
def elem_name(b, o):
|
|
s = b[o : o + 28]
|
|
z = s.find(b"\0")
|
|
return (s[:z] if z >= 0 else s).decode("latin1")
|
|
|
|
|
|
def groups(bundle):
|
|
"""(element_index, name, frames, lead_in, W, alphas) per placement group.
|
|
|
|
`W[k]` is the word at the k-th 40-byte stride's `+36`; `W[-1]` is None
|
|
because it lies outside the group. Under the corrected reading the pose
|
|
times are `[lead_in] + W[:-1]`.
|
|
"""
|
|
n = be32(bundle, 0x14)
|
|
names = [elem_name(bundle, 0x20 + i * 60) for i in range(n)]
|
|
pos = 0x20 + n * 60
|
|
out = []
|
|
for _ in range(n):
|
|
if pos + 8 > len(bundle):
|
|
break
|
|
idx, frames = be32(bundle, pos), be32(bundle, pos + 4)
|
|
if idx >= n or frames == 0 or frames > 4096:
|
|
break
|
|
lead_in = be32(bundle, pos + 8)
|
|
first = pos + 12
|
|
end = first + frames * 40 - 4
|
|
if end > len(bundle):
|
|
break
|
|
W, alphas = [], []
|
|
for k in range(frames):
|
|
blk = first + k * 40
|
|
W.append(be32(bundle, blk + 36) if blk + 40 <= end else None)
|
|
alphas.append(be32(bundle, blk) >> 24)
|
|
out.append((idx, names[idx], frames, lead_in, W, alphas))
|
|
pos = end
|
|
return out
|
|
|
|
|
|
def is_build(raw):
|
|
if raw[:4] != b"RATC" or len(raw) < 0x20:
|
|
return False
|
|
n = be32(raw, 0x14)
|
|
return 0 < n <= 4096 and 0x20 + n * 60 <= len(raw)
|
|
|
|
|
|
def monotone_ramps(alphas, min_segments=3):
|
|
out, i, n = [], 0, len(alphas)
|
|
while i < n - 1:
|
|
if alphas[i] == alphas[i + 1]:
|
|
i += 1
|
|
continue
|
|
d = 1 if alphas[i + 1] > alphas[i] else -1
|
|
j = i + 1
|
|
while j < n - 1 and (alphas[j + 1] - alphas[j]) * d > 0:
|
|
j += 1
|
|
if j - i >= min_segments:
|
|
out.append((i, j))
|
|
i = j
|
|
return out
|
|
|
|
|
|
def constant_rate(times, alphas, a, b, tol=0.06):
|
|
rates = []
|
|
for k in range(a, b):
|
|
dt = times[k + 1] - times[k]
|
|
if dt <= 0:
|
|
return None
|
|
rates.append(abs(alphas[k + 1] - alphas[k]) / dt)
|
|
mean = sum(rates) / len(rates)
|
|
if mean == 0:
|
|
return None
|
|
return max(abs(r - mean) for r in rates) / mean <= tol
|
|
|
|
|
|
def main(paks):
|
|
rnd = random.Random(20260829)
|
|
per_bundle = collections.defaultdict(list)
|
|
total = mono = 0
|
|
ramp_new = [0, 0]
|
|
ramp_old = [0, 0]
|
|
for p in paks:
|
|
for ei, raw in enumerate(load_pak(p)):
|
|
if not is_build(raw):
|
|
continue
|
|
try:
|
|
gs = groups(raw)
|
|
except Exception:
|
|
continue
|
|
for _idx, nm, _frames, lead_in, W, alphas in gs:
|
|
rest = W[:-1]
|
|
if not rest or any(w is None for w in rest):
|
|
continue
|
|
total += 1
|
|
t_new = [lead_in] + rest
|
|
if all(t_new[i] <= t_new[i + 1] for i in range(len(t_new) - 1)):
|
|
mono += 1
|
|
per_bundle[(os.path.basename(p), ei)].append((lead_in, rest, nm))
|
|
for a, b in monotone_ramps(alphas):
|
|
r = constant_rate(t_new, alphas, a, b)
|
|
if r is not None:
|
|
ramp_new[1] += 1
|
|
ramp_new[0] += r
|
|
# old reading: `+36` is the block's own time, last pose untimed
|
|
a_old = alphas[:-1]
|
|
for a, b in monotone_ramps(a_old):
|
|
r = constant_rate(rest, a_old, a, b)
|
|
if r is not None:
|
|
ramp_old[1] += 1
|
|
ramp_old[0] += r
|
|
|
|
nz = nz_ok = ctrl = ctrl_ok = 0
|
|
gaps = collections.Counter()
|
|
for rows in per_bundle.values():
|
|
pool = [l for l, _, _ in rows]
|
|
for lead_in, rest, _nm in rows:
|
|
if lead_in == 0:
|
|
continue
|
|
nz += 1
|
|
nz_ok += lead_in < rest[0]
|
|
gaps[rest[0] - lead_in] += 1
|
|
for _ in range(10):
|
|
ctrl += 1
|
|
ctrl_ok += rnd.choice(pool) < rest[0]
|
|
|
|
pct = lambda a, b: f"{100 * a / b:.3f}%" if b else "n/a"
|
|
print(f"paks scanned : {len(paks)}")
|
|
print(f"placement groups : {total}")
|
|
print()
|
|
print("A. lead-in prepended to the shifted times is non-decreasing")
|
|
print(f" {mono}/{total} = {pct(mono, total)}")
|
|
print()
|
|
print("B. non-zero lead-in is strictly less than the next time")
|
|
print(f" {nz_ok}/{nz} = {pct(nz_ok, nz)}")
|
|
print(f" control (another group's lead-in, same bundle): "
|
|
f"{ctrl_ok}/{ctrl} = {pct(ctrl_ok, ctrl)}")
|
|
print(f" gap to the next time, most common: {gaps.most_common(8)}")
|
|
print()
|
|
print("C. constant d(alpha)/d(time) across a multi-segment ramp")
|
|
print(f" corrected (time precedes pose): {ramp_new[0]}/{ramp_new[1]} = "
|
|
f"{pct(ramp_new[0], ramp_new[1])}")
|
|
print(f" old (+36 is own time) : {ramp_old[0]}/{ramp_old[1]} = "
|
|
f"{pct(ramp_old[0], ramp_old[1])}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
sys.exit(__doc__)
|
|
main(sys.argv[1:])
|