Files
Sylpheed/tools/re-capture/plateau_census.py
Sylpheed RE agent c2c69b02be re(ui): size the rest() guess disc-wide, and refute my own proposed fix
Follows up the defect found last iteration: rest()'s dwell fallback is
guessing whenever it runs. Two things were open -- how big it is, and
whether "rest = the last keyframe" is the fix. Both are now answered, and
the second is answered no.

plateau_census.py walks the placement regions directly instead of going
through `screen info --geometry`, which decodes every texture and cannot
do a disc-wide pass in reasonable time. Its control reproduces GP_TITLE
build 7's three fallback elements and names ptlogo_eff3.t32 among them
before counting anything.

  elements with a keyframe group      15 493
  no plateau -> rest pose is guessed   3 807  (24.57 %)
    ... current rule returns invisible 1 711  (44.9 %)
    ... current rule returns scale=0     195  ( 5.1 %)
  the two candidate rules agree        1 911  (50.2 %)

195 elements get a rest pose with scale 0%, which is not a pose. And
disc-wide the choice of rule is not cosmetic: the candidates agree half
the time.

But the port's exposure is one element. Across main menu, EXTRAS, title
and the developer splash, 14 elements are plateau-less and the two rules
agree on 13. The single disagreement is palogo_anima_eff.t32.

And "last keyframe" loses there, on a control that needed no new capture:
the splash carries three sibling glows with identical structure and
identical times --

  palogo_gamearts_eff  15:a=0 30:a=255 45:a=255 -:a=0  plateau -> visible
  palogo_seta_eff      15:a=0 30:a=255 45:a=255 -:a=0  plateau -> visible
  palogo_anima_eff     15:a=0 30:a=255 45:a=212 -:a=0  no plateau

-- differing in one byte. "Last keyframe" makes anima alone invisible
while its two siblings stay lit. The capture agrees weakly: box-mean
ratios capture/render are gamearts 0.717, seta 0.723, anima 0.772, and a
glow we drew that the game does not would put anima below its siblings,
not above.

So the defect is measured and the fix is still undecided. Nothing in the
decoder changed.
2026-08-28 23:23:55 +00:00

139 lines
6.2 KiB
Python

"""Disc-wide: which elements reach rest()'s dwell fallback, i.e. have no plateau?
Reimplements the placement-region walk from ui_layout.rs::parse_placements so the
census runs in seconds instead of via `screen info --geometry` (which decodes
every texture). CONTROL: it must reproduce the three known fallback elements of
GP_TITLE build 7 (indices 8, 13, 14) and find ptlogo_eff3 among them.
"""
import struct, zlib, glob, os, collections
DECL_AT, DECL_ENTRY, KEYFRAME = 0x20, 60, 40
def entries(base):
stub = open(base + ".pak", "rb").read()
if stub[:4] != b"IPFB": return
n = struct.unpack_from(">I", stub, 4)[0]
segs = sorted(glob.glob(base + ".p[0-9][0-9]"))
if not segs: return
blob = b"".join(open(s, "rb").read() for s in segs)
for i in range(n):
h, off, sz = struct.unpack_from(">III", stub, 0x10 + 12 * i)
st = blob[off:off + sz]
if len(st) < 10: continue
try: yield i, h, (zlib.decompress(st[10:]) if st[:2] == b"Z1" else st)
except Exception: continue
def elements(d):
"""(index, name, [poses]) per element with a keyframe group."""
if d[:4] != b"RATC": return []
count = struct.unpack_from(">I", d, 0x14)[0]
if not (0 < count < 4096): return []
names = []
for i in range(count):
o = DECL_AT + i * DECL_ENTRY
if o + DECL_ENTRY > len(d): return []
names.append(d[o:o+28].split(b"\0")[0].decode("ascii", "replace"))
out, pos = [], DECL_AT + count * DECL_ENTRY
for _ in range(count):
if pos + 8 > len(d): break
idx, frames = struct.unpack_from(">II", d, pos)
if idx >= count or frames == 0 or frames > 4096: break
first = pos + 12
end = first + frames * KEYFRAME - 4
poses = []
for k in range(frames):
blk = first + k * KEYFRAME
if blk + 36 > len(d) or blk + 36 > end: break
poses.append(struct.unpack_from(">IiiIIi i", d, blk)[:1] +
struct.unpack_from(">II", d, blk + 16) +
struct.unpack_from(">ii", d, blk + 28))
if poses: out.append((idx, names[idx], poses))
pos = end
return out
def has_plateau(poses):
return any(poses[i] == poses[i+1] for i in range(len(poses) - 1))
# ---- CONTROL -------------------------------------------------------------
ctl = {i: (n, p) for i, n, p in elements(
dict((i, d) for i, h, d in entries("/work/sylph_extract/dat/GP_TITLE"))[7])}
fb = sorted(i for i, (n, p) in ctl.items() if not has_plateau(p))
print(f"CONTROL GP_TITLE build 7: fallback elements {fb} (want [8, 13, 14])")
print(f" index 8 is {ctl[8][0]!r} (want ptlogo_eff3.t32)")
assert fb == [8, 13, 14] and ctl[8][0].startswith("ptlogo_eff3"), "control failed"
# ---- census --------------------------------------------------------------
tot = fb_n = 0
by_name = collections.Counter()
worst = []
for pak in sorted(glob.glob("/work/sylph_extract/dat/GP_*.pak")):
base = pak[:-4]
for i, h, d in entries(base):
for idx, name, poses in elements(d):
tot += 1
if not has_plateau(poses):
fb_n += 1
by_name[name] += 1
worst.append((os.path.basename(base), i, idx, name, len(poses)))
print(f"\nelements with a keyframe group, disc-wide: {tot}")
print(f" no plateau -> rest pose is GUESSED: {fb_n} ({100*fb_n/max(tot,1):.2f} %)")
print(f"\nmost common guessed elements:")
for n, c in by_name.most_common(12): print(f" {c:5d} {n}")
# ---- candidate rules for a plateau-less element --------------------------
# pose tuple = (fade, scale_x, scale_y, x, y); times read separately.
def dwell_pick(times, poses):
best = (0, -1)
for k in range(len(times) - 1):
if times[k] is None or times[k + 1] is None: continue
d = times[k + 1] - times[k]
if d >= best[1]: best = (k, d)
return best[0]
def elements_t(d):
if d[:4] != b"RATC": return []
count = struct.unpack_from(">I", d, 0x14)[0]
if not (0 < count < 4096): return []
names = []
for i in range(count):
o = DECL_AT + i * DECL_ENTRY
if o + DECL_ENTRY > len(d): return []
names.append(d[o:o+28].split(b"\0")[0].decode("ascii", "replace"))
out, pos = [], DECL_AT + count * DECL_ENTRY
for _ in range(count):
if pos + 8 > len(d): break
idx, frames = struct.unpack_from(">II", d, pos)
if idx >= count or frames == 0 or frames > 4096: break
first = pos + 12; end = first + frames * KEYFRAME - 4
poses, times = [], []
for k in range(frames):
blk = first + k * KEYFRAME
if blk + 36 > len(d) or blk + 36 > end: break
poses.append(struct.unpack_from(">I", d, blk) +
struct.unpack_from(">II", d, blk + 16) +
struct.unpack_from(">ii", d, blk + 28))
times.append(struct.unpack_from(">i", d, blk + 36)[0] if blk + 40 <= end else None)
if poses: out.append((idx, names[idx], times, poses))
pos = end
return out
alpha = lambda p: (p[0] >> 24) & 0xff
stats = collections.Counter()
for pak in sorted(glob.glob("/work/sylph_extract/dat/GP_*.pak")):
for i, h, d in entries(pak[:-4]):
for idx, name, times, poses in elements_t(d):
if has_plateau(poses): continue
cur = poses[dwell_pick(times, poses)]
last = poses[-1]
stats["n"] += 1
stats["cur_invisible"] += alpha(cur) == 0
stats["last_invisible"] += alpha(last) == 0
stats["cur_zero_scale"] += (cur[1] == 0 or cur[2] == 0)
stats["agree"] += cur == last
n = stats["n"]
print(f"\n--- the {n} plateau-less elements, by what each candidate rule returns")
print(f" current rule (longest dwell) returns an INVISIBLE pose : {stats['cur_invisible']:5d} ({100*stats['cur_invisible']/n:5.1f} %)")
print(f" current rule returns a ZERO-SCALE (degenerate) pose : {stats['cur_zero_scale']:5d} ({100*stats['cur_zero_scale']/n:5.1f} %)")
print(f" 'rest = last keyframe' returns an INVISIBLE pose : {stats['last_invisible']:5d} ({100*stats['last_invisible']/n:5.1f} %)")
print(f" the two rules agree : {stats['agree']:5d} ({100*stats['agree']/n:5.1f} %)")