diff --git a/docs/port/MISSION.md b/docs/port/MISSION.md index 5b56e693..c01869ed 100644 --- a/docs/port/MISSION.md +++ b/docs/port/MISSION.md @@ -77,6 +77,37 @@ alongside it. | **Q10** | **What are a music bank's sub-waves?** `BGM_001.slb` is three sub-waves — 10 KB, 4.47 MB, 4.67 MB — and we currently **concatenate them blindly** into one 347 s track. Two near-equal halves could be intro + loop, or two variations, or two halves of one piece. A menu that loops its music needs to know which | The role of each sub-wave, established for at least the menu BGM. "Concatenate" is a decision, not a default — right now it is a default nobody chose | | **S1** | ~~**Ready Room probe.**~~ **DONE 2026-08-28 — [no-go](../re/ready-room-probe.md).** It is 2D and enumerates fine, but the pak is briefing/tactical-map content, not the Ready Room menu | ✅ go/no-go written | +## 🔴 Blocked in this container — a Japanese-locale capture + +Recorded rather than worked around, per "do not improvise around a blocker". + +Two questions have converged on needing **one capture we cannot take**: a running +capture of `GP_TITLE` **build 7**, the Japanese title screen. + +* Q1's keyframe-time association. The shifted reading is favoured 26× by a + calibration-free measurement, and the only render it changes on the whole disc + is build 7 ([`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md)). +* What `rest()` should return for a plateau-less element. The one element that + discriminates, `ptlogo_eff3.t32`, is also in build 7 + ([`structures/ui-resting-pose.md`](../re/structures/ui-resting-pose.md)). + +**Why the container cannot do it:** the console language is not settable. In this +canary tree `user_language` appears only as `DECLARE_int32` — four call sites, +no `DEFINE` — and it is absent from the registered cvars in +`xenia-canary.config.toml`. There is no flag to pass, and passing an unknown one +is specifically dangerous here: `run-canary`'s own header records that xenia +calls `ShowSimpleMessageBox` from `ParseLaunchArguments` *before* logging starts, +so a bad flag blocks forever on an SDL dialog with an empty log. + +Rebuilding canary to add the cvar would be improvising around this, so it has not +been done. **It needs a human decision**: either add the cvar and rebuild, or +accept that both questions stay 🟡 and the port authors those values by hand. + +⚠️ Neither question blocks the five menu screens. Q1's *interpolation law* is +settled and only multi-keyframe absolute timing is open; `rest()` differs from +its alternative on **one** element across all five screens, and the current +answer there is the defensible one. + ## Known unknowns — say so, do not fill them in Some of these may turn out to be undecodable. That is a valid, useful answer, and diff --git a/docs/re/METHOD.md b/docs/re/METHOD.md index cdc115db..8e8a9b60 100644 --- a/docs/re/METHOD.md +++ b/docs/re/METHOD.md @@ -419,3 +419,10 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the diff was then all zeros, because `cmp` had read a half-written PNG. Compare only after the job signals done, or skip the newest file. A "difference" that vanishes on re-examination is usually a race, not a flake. +* **A `screen list` BUILD index is not a pak ENTRY index.** `GP_TITLE` happens to + map 1:1 (16 builds, entries 0–15), which is how the assumption survived; + `GP_DIALOG` does not — its build 0 is **entry 2**. Indexing a pak directly with + a build number silently reads a different bundle and reports a confident wrong + answer: a census control asked for the two rotated elements of `GP_DIALOG` + build 0 and got zero, from a bundle that genuinely has none. `screen list` + prints the mapping; use it rather than assuming identity. diff --git a/docs/re/data/rotation-toplevel-census.txt b/docs/re/data/rotation-toplevel-census.txt new file mode 100644 index 00000000..fb3de71c --- /dev/null +++ b/docs/re/data/rotation-toplevel-census.txt @@ -0,0 +1,32 @@ +CONTROL GP_TITLE build 4 (nested rotations only): 0 top-level rotated (want 0) +CONTROL GP_DIALOG build 0: 2 top-level rotated (want 2) + [4] pceff03.t32 r = [90, 30, 10, 3, 0, 0, 0] + [5] pceff04.t32 r = [90, 30, 10, 3, 0, 0, 0] + +top-level elements with a keyframe group, disc-wide: 15493 + carrying a non-zero rotation: 2152 (13.89 %) + +by archive: + 1868 GP_READY_ROOM.pak + 40 GP_DIALOG.pak + 37 GP_MAIN_GAME_D2D.pak + 37 GP_MAIN_GAME_E2D.pak + 37 GP_MAIN_GAME_F2D.pak + 37 GP_MAIN_GAME_I2D.pak + 37 GP_MAIN_GAME_J2D.pak + 37 GP_MAIN_GAME_S2D.pak + 10 GP_GAMEOVER.pak + 8 GP_DEBRIEFING_PILOTLOG.pak + 4 GP_TITLE.pak + +most common rotated element names: + 444 pbb_destroyer.t32 + 402 pbr_destroyer.t32 + 276 pbr_fighter.t32 + 174 pbb_fighter.t32 + 168 pbb_aircraft.t32 + 84 pb_warp_eff.t32 + 48 pbtab.t32 + 35 gbp012i.t32 + 32 pzeff02.t32 + 30 gbp001i.t32 diff --git a/docs/re/structures/ui-keyframe-rotation.md b/docs/re/structures/ui-keyframe-rotation.md index 041b6b5e..10e469e5 100644 --- a/docs/re/structures/ui-keyframe-rotation.md +++ b/docs/re/structures/ui-keyframe-rotation.md @@ -177,3 +177,51 @@ expressed in degrees rather than of a free angle. 🟡 That is a hypothesis abou * `+4` and `+8` are **not** claimed as X/Y rotation. Three adjacent signed degree-valued words invite that reading; only `+12` has been observed to do anything. + +## ✅ The top-level census, finished (2026-08-28) + +The earlier attempt drove `sylpheed-cli screen info --geometry` in a shell loop +and never finished — it decodes every texture per build. +[`tools/re-capture/rotation_toplevel_census.py`](../../../tools/re-capture/rotation_toplevel_census.py) +walks the top-level placement region the way `ui_layout.rs` does; seconds instead +of hours. Output: [`data/rotation-toplevel-census.txt`](../data/rotation-toplevel-census.txt). + +Both controls pass — `GP_TITLE` build 4 reports **0** (its rotations are the +nested `ptloop` records) and `GP_DIALOG` build 0 reports the **2** expected +`pceff03`/`pceff04` elements. + +| | | +|---|---| +| top-level elements with a keyframe group | 15 493 | +| carrying a non-zero rotation | **2 152 (13.89 %)** | + +By archive: `GP_READY_ROOM` **1 868**, `GP_DIALOG` 40, each of the six +`GP_MAIN_GAME_*2D` 37, `GP_GAMEOVER` 10, `GP_DEBRIEFING_PILOTLOG` 8, +`GP_TITLE` **4**. + +### Two more corroborations, both free + +**The rotated population is tactical-map ship icons.** The most-rotated names are +`pbb_destroyer` (444), `pbr_destroyer` (402), `pbr_fighter` (276), +`pbb_fighter` (174), `pbb_aircraft` (168) — map markers, in the archive that +holds the tactical map. Rotating a ship icon to its heading is what the field is +*for*, and it is the single largest use of it on the disc. + +**`GP_TITLE` entry 7's four rotated elements carry their own control.** Three of +them are the Japanese wordmark's pieces, and they settle to upright from +**alternating** tilts: + +``` +ptlogo3a.t32 r = 0, -14, -4, -1, 0, 0, 0, 0 +ptlogo3b.t32 r = 0, +14, +4, +1, 0, 0, 0, 0 +ptlogo3c.t32 r = 0, -14, -4, -1, 0, 0, 0, 0 +``` + +Same magnitudes, alternating sign, all decaying to 0 — three sibling elements +tilting opposite ways and straightening. A misread field does not produce that. +(The fourth is `ptlogo_eff3.t32`, `r = 0, 0, 80, 150` — the transient bloom +discussed in [ui-resting-pose](ui-resting-pose.md).) + +⚠️ **This is not subtractable from the byte-scan census.** That one counts +*blocks* (12 164 of 83 862 with a non-zero `+12`); this one counts *elements*. +The two are different units and the difference is not "the nested population". diff --git a/tools/re-capture/rotation_toplevel_census.py b/tools/re-capture/rotation_toplevel_census.py new file mode 100644 index 00000000..2b74b3cd --- /dev/null +++ b/tools/re-capture/rotation_toplevel_census.py @@ -0,0 +1,92 @@ +#!/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}")