This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/tools/re-capture/rotation_toplevel_census.py
Sylpheed RE agent 6d246c7a97 re(ui): finish the top-level rotation census; record the JP-capture blocker
Two threads had converged on needing one capture this container cannot
take, so this iteration records that and finishes something reachable.

BLOCKED, written into MISSION.md rather than worked around: Q1's keyframe
time association and the rest() rule for plateau-less elements both now
hinge on a running capture of GP_TITLE build 7, the Japanese title. The
console language is not settable here -- user_language appears only as
DECLARE_int32 at four call sites with no DEFINE anywhere in the tree, and
it is absent from the registered cvars in xenia-canary.config.toml. There
is no flag to pass, and guessing one is specifically unsafe: run-canary's
own header records that xenia calls ShowSimpleMessageBox from
ParseLaunchArguments before logging starts, so a bad flag blocks forever
with an empty log. Rebuilding canary to add the cvar would be improvising
around the blocker; it needs a human decision. Neither question blocks
the five menu screens.

FINISHED: the disc-wide top-level rotation count, left running four
iterations ago as a shell loop over `screen info --geometry` that never
completed (it decodes every texture per build). Walking the placement
region directly takes seconds.

  top-level elements with a keyframe group   15 493
  carrying a non-zero rotation                2 152  (13.89 %)

Both controls pass: GP_TITLE build 4 reports 0 (its rotations are the
nested ptloop records) and GP_DIALOG build 0 reports the expected two.

The control earned its place -- the first version indexed the pak with a
`screen list` BUILD number and got 0 for a screen that has two, because
GP_DIALOG build 0 is entry 2. GP_TITLE maps 1:1, which is how the
assumption survived. METHOD line added.

Two free corroborations of the rotation decode. The rotated population is
dominated by tactical-map ship icons -- pbb_destroyer 444, pbr_destroyer
402, pbr_fighter 276 -- i.e. markers rotated to heading, the single
largest use of the field on the disc. And GP_TITLE entry 7's Japanese
wordmark pieces settle from ALTERNATING tilts:

  ptlogo3a  r = 0, -14, -4, -1, 0, ...
  ptlogo3b  r = 0, +14, +4, +1, 0, ...
  ptlogo3c  r = 0, -14, -4, -1, 0, ...

Same magnitudes, opposite signs, all decaying to upright. A misread field
does not produce that.
2026-08-28 23:46:54 +00:00

93 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""How many TOP-LEVEL elements carry a non-zero keyframe rotation (`+12`)?
The nested-record census (kf_rotation_census.py) scans raw bytes and so counts
rotations wherever they live. This one walks the top-level placement region the
way ui_layout.rs does, so the difference between the two is exactly the
population that lives in nested `.rat` leaf records.
An earlier attempt at this drove `sylpheed-cli screen info --geometry` in a
shell loop; it decodes every texture per build and never finished. Same answer,
seconds instead of hours.
CONTROL: GP_TITLE build 4 (the English title) must report ZERO -- its rotations
are the nested ptloop records -- while GP_DIALOG build 0 must report the two
`pceff03`/`pceff04` elements that ramp 90 -> 0.
⚠ A `screen list` BUILD index is not a pak ENTRY index. GP_TITLE happens to
map 1:1 (16 builds, entries 0..15), GP_DIALOG does not: its build 0 is **entry
2**. Indexing the pak with a build number silently reads a different bundle, and
the first version of this control did exactly that and reported 0 rotations for a
screen that plainly has two. `screen list` prints the mapping.
"""
import struct, zlib, glob, os, sys, 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, (zlib.decompress(st[10:]) if st[:2] == b"Z1" else st)
except Exception: continue
def rotated(d):
"""[(element index, name, [rotations])] for top-level elements with any r != 0."""
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, total = [], DECL_AT + count * DECL_ENTRY, 0
while True:
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
rots = []
for k in range(frames):
blk = first + k * KEYFRAME
if blk + 36 > len(d) or blk + 36 > end: break
rots.append(struct.unpack_from(">i", d, blk + 12)[0])
total += 1
if any(r != 0 for r in rots): out.append((idx, names[idx], rots))
pos = end
return out, total
# ---- CONTROL -------------------------------------------------------------
t = dict(entries("/work/sylph_extract/dat/GP_TITLE"))
g = dict(entries("/work/sylph_extract/dat/GP_DIALOG"))
ct, _ = rotated(t[4]); cg, _ = rotated(g[2]) # build 0 == entry 2, see the note above
print(f"CONTROL GP_TITLE build 4 (nested rotations only): {len(ct)} top-level rotated (want 0)")
print(f"CONTROL GP_DIALOG build 0: {len(cg)} top-level rotated (want 2)")
for i, n, r in cg: print(f" [{i}] {n} r = {r}")
assert len(ct) == 0 and len(cg) == 2, "control failed"
# ---- census --------------------------------------------------------------
tot = rot = 0
per_pak = collections.Counter(); names = collections.Counter()
for pak in sorted(glob.glob("/work/sylph_extract/dat/GP_*.pak")):
for i, d in entries(pak[:-4]):
r = rotated(d)
if not r: continue
hits, n = r
tot += n; rot += len(hits)
if hits:
per_pak[os.path.basename(pak)] += len(hits)
for _, nm, _rr in hits: names[nm] += 1
print(f"\ntop-level elements with a keyframe group, disc-wide: {tot}")
print(f" carrying a non-zero rotation: {rot} ({100*rot/max(tot,1):.2f} %)")
print("\nby archive:")
for k, c in per_pak.most_common(): print(f" {c:5d} {k}")
print("\nmost common rotated element names:")
for k, c in names.most_common(10): print(f" {c:5d} {k}")