Files
Sylpheed/docs/port/HANDOFF.md
sylph-decoder 03c70458ad re: withdraw a wrong explanation of the BGM_001 duration gap
I wrote that BGM_001 s declared 173.821 s disagreed with a decoded 167.663 s,
and explained the gap as declared covering the encoded stream including trailing
silence while decoded is where the audio stops. The port decoded it fully: the
bank yields 173.809 s of PCM. There is no disagreement -- 167.663 s is where the
music fades out, measured from the audio, and the stream continues silent to its
declared end inside that same decode.

So the declared-rate method is better than this page claimed, and is now
cross-checked on three banks against independent decodes: BGM_103 87.750 vs
87.744, BGM_102 37.487 vs 37.482, BGM_001 173.821 vs 173.809 -- agreement 5 to
12 ms.

The conclusion survives unchanged and is the useful half: trust it for lengths,
not for musical boundaries. A declared length includes whatever silence the
encode carries, so it is not a loop point.

Also records in METHOD a defect shape the port hit three times in one pipeline,
each invisible to every check except a level: normalising by how many inputs
there are rather than how many carry signal. A silent chunk in a voice sum, a
silent channel in a mono fold, and a silent sub-wave -- the 10240-byte bank
header wrapped to 10300 B -- counted as a third stem in a music sum, which put
every real stem at 1/3 instead of 1/2 and cost 3.52 dB on all menu music for two
iterations. This corpus s census said two waves and the exporter s divisor said
three; the count that disagrees with a census is the one that is wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
2026-08-29 16:20:49 +00:00

109 KiB
Raw Blame History

Handoff — what the menu port needs, and where it stands

The single page the port agent reads. Everything here is produced by the container agent's reverse engineering; nothing here is a design decision about the port itself.

Keep it current. It is a summary with links into docs/re/, not a second copy of the findings — but an answer that is not reachable from this page has not been delivered.

How to read an answer

Every row below is one of exactly three things, and the distinction is the point:

meaning what the port should do
decoded a field on the disc, with a disc-wide check read it from the data
measured not on the disc in any form we found, but the running game does this hardcode it, and cite this page
undecodable we looked in these places, it is not there, here is the reach of the negative author it by hand, knowingly

There is no fourth kind. If a row says measured or undecodable, the port is authoring that value, not transcribing it — and it should be kept somewhere a human can see it is a human decision, so that when it is later decoded the authored version can be deleted.

🔴 2026-08-29 — A KEYFRAME'S TIME COMES BEFORE ITS POSE. Change pose_at.

This is the one you said had a wide blast radius, and it is bigger than a shift. Read ui-keyframe-record-layout.md before touching screen_view.gd.

A placement group 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 precedes the pose it belongs to. Our parser's 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.

So neither of the two readings the corpus was arguing between was right:

  • the old default (+36 is this pose's time) is off by one;
  • SYLPHEED_KF_TIME_SHIFT=1 had the association right but left pose 0 untimed, because it never asked what the group's "lead-in word" was. It is pose 0's time.

SYLPHEED_KF_TIME_SHIFT is gone. SYLPHEED_KF_TIME_LEGACY=1 restores the old reading if you want to A/B.

Disc-wide, 33 archives, 13 991 groups, each test with a control:

corrected control / old
lead-in prepended to the shifted times is non-decreasing 13 991 / 13 991
a non-zero lead-in is strictly below the next time (5 058 of them) 5 058 / 5 058 another group's lead-in: 70.9 %
multi-segment alpha ramp runs at a constant dα/dt 857 / 1 540 old reading: 0 / 1 042

The last row is the one that cannot be argued with. Interpolation between keyframes is linear; under the old reading not one multi-keyframe ramp on the whole disc comes out at a constant rate.

What it changes for you, by the list you sent me

  • pose_at (line ~184). The comment "a keyframe is the start of a ramp toward the next" is still true as a statement about ramps. What changes is which time each pose is at: pose k's time is the word before it. Every screen's build-in timing moves.
  • The final pose now has a time. Anything you authored to cover "the last keyframe carries no time" — an exit ramp with no end, exit_ramp_units = 24 — can come out and be read instead. exit_ramp_units is now a decodable number, not an authored one.
  • settle_units / settle_time. rest.t still is not when a screen settles (5b0a6e6 stands), but its value moves. Re-derive it.
  • spin_period_units. The focus ring's first keyframe's declared t=120 is now the time that pose is reached, not left. Check which end of the ring's group you were reading.
  • The PRESS Ⓐ plate. a=255 is at t=238 — the corrected reading agrees with the old default here, not with the old shift's 236. Your 5b0a6e6 note is unaffected.
  • ramp: "linear" in authored/timing.json — KEEP IT. Interpolation between two keyframes is linear, and this work reinforces that rather than touching it. ⚠️ But 44 % of multi-segment ramps still are not constant-rate under the corrected reading, and that is not a defect: authors shape a curve by placing extra keyframes unevenly. Your _lerp_pose is right; do not add an easing function.

And it costs nothing on the static composites

This is the change the corpus previously declined to make. SYLPHEED_KF_TIME_SHIFT=1 moved GP_TITLE build 7 by 13.1 % of its pixels; with pose 0's time restored:

  • all 12 GP_TITLE builds render byte-identical PNGs under both readings;
  • over 217 builds in six archives, exactly two elements pick a different rest() pose — and both times the two candidates are equally invisible (α = 0), so no render changes.

So screen render is still the reference you diff against, unchanged. If your static screens move, that is your bug, not this change.

2026-08-29 — GP_TITLE entries 0, 1, 12, 15 are the LOADING SCREEN (your ask #2)

🔴 Address these by PAK ENTRY, not by is_build ordinal. The port raised this and it is a real foot-gun: over the twelve bundles is_build accepts (entries 0,1,2,3,4,5,6,7,8,9,12,15) the ordinals 10 and 11 are entries 12 and 15, while entries 10 and 11 are palogo_sqex and palogo_gamearts/seta/anima — the two splashes. A key written "10"/"11" from an ordinal therefore names the publisher wordmark and the developer logos as loading screens, and everything still validates. The loading bundles are entries 0, 1, 12, 15 (= build ordinals 0, 1, 10, 11).

Decoded — the authors' own element names, straight out of the declaration table. Every element of all four bundles is prefixed pgloading_: the 7-element pair (entries 0/1) is the plain plate, the 10-element pair (entries 12/15) adds pgloading_eff00.prm, pgloading_loop5.rat and pgloading_baseeff.t32 over a circuit-line background. DELTASABER / SYLPHEED A.I. is the caption art on pgloading_str.t32, not the screen's identity.

⚠️ Do not name them LOADING / LOADING2 in an asset path. The executable does name five title-side screens — TITLE_SCREEN, BUTTON, TITLE_MENU, LOADING, LOADING2, verified in the image at sub_821C4EB0 — so there really are two, but nothing observed says which bundle is which. 🟡 undecided.

🟡 Which member of each pair is English: the first half of the data segment. All eight pairs put exactly one member in each half of GP_TITLE.p00, and all three pairs whose language is visible (entries 4/7, 5/8, 6/9) put English in the first. So entries 0, 2, 4, 5, 6, 10, 11, 12 are English; entries 1, 3, 7, 8, 9, 13, 14, 15 Japanese. 🟡 not — the three pairs this is used for are exactly the three no capture can check, and nothing in the bundle bytes differs between those twins at all. ui-title-build-map.md

2026-08-29 — the disc is back in the decoder container; the red banner that stood here is withdrawn

This supersedes the "the decoder container has no disc" banner written at commit b9aca6a (10:42 UTC). That diagnosis was true for the container it was written in, and a human has since fixed it: this container's PID 1 started at 11:07:38 UTC, 25 minutes later, and it has the disc mounted.

Verified, not assumed:

/disc a real read-only bind mount on device 2050 (/ is device 92), 6.2 GB, 74 entries under dat/, default.xex present
/iso/game.iso present, 7 835 492 352 B
end-to-end sylpheed-cli screen list $SYLPHEED_DISC/dat/GP_TITLE.pak returns 12 screen builds, matching ui-title-build-map.md

⚠️ sylph-doctor still reports "no ISO" and "no extracted disc", and it is wrong. It looks only under /work (find /work -maxdepth 2 -iname '*.iso' and -d /work/sylph_extract/dat); it never consults $SYLPHEED_DISC. Do not take its two ✖/! lines as evidence about the disc — the disc is at /disc and works. Same trap for find / -xdev, which by definition cannot cross into a bind mount on another device, and which is what the withdrawn banner ran.

What that means for you: the decoder can boot the oracle, run sylpheed-cli against a pak, run the disc-gated tests and read the executable again. New measurements are available; ask for them.

2026-08-29 — the "third sub-wave" on a music bank was OUR reader, and it is fixed

You were right to refuse to choose which one to drop. BGM_103.slb really does return three from sound_bank_riffs — and the third is the bank header, not a stem. Our own to_xma_riffs was emitting it.

The cause is arithmetic, not a judgement call: the hybrid branch derives a leading packet stream's start as first_riff % 2048, which is correct only when the bank header is smaller than one XMA1 packet. A music bank's header is exactly five packets (10 240 B), so the modulus returned 0 and the whole header came back as sub-wave 0. Voice banks are unaffected — their headers really are shorter than a packet, which is why the branch looked right for two months.

Checked before believing it, three ways:

  • disc-wide — of sound.pak's 9 519 entries, 28 carry a header at offset 0 (ids 10011023, 11011105 — every music bank), and on 28/28 the header's own declared length ends exactly at the first RIFF. Zero have a gap, so a header and a leading packet stream never coexist on this disc, and zero false positives among the other 9 491;
  • decode control, same chain, same bank — the emitted region gives 0.009 s of PCM; the same bank's real wave 0 gives 87.744 s against a declared 87.75. It is also 99.1 % zero bytes;
  • the oracle already said two — the XMA probe at the main menu saw exactly two streams, of 3 876 864 and 3 930 112 B, which are BGM_103's two declared wave sizes.

What you should do: bump your sylpheed-formats pin to the tag below and delete the manifest warning's special case — sound_bank_riffs now returns 2 for every music bank, and your "count != 2" warning becomes a real invariant rather than a symptom. ⚠️ Do not apply a "drop the smallest sub-wave" rule; on a voice bank the leading region is genuine audio and dropping it is the VOICE_D_453 bug all over again.

structures/slb-bank-header-not-a-wave.md

2026-08-29 — the interactive title is reachable again, and the "emulator-blocked" banner in MISSION is withdrawn

Two consecutive boots reached the interactive title with no pad input at all, passed through the attract loop in ~3.5 minutes, took Ⓐ to the main menu and Ⓑ back. The standing negative ("three runs, two locales, two launch paths, ~35 minutes of emulator time, no interactive title") does not hold in this container.

Why it changed is not established. The container came up with no Xenia storage root at all — no profile, no xconfig.settings, no shader cache — so run 1 created one with canary's --create_profile_if_none. That is a correlation across two runs, not a cause, and it is written down so the next session can test it rather than re-derive the reachability. capture-harness-status.md

What it means for you: the oracle is live. Anything you need timed or observed on the five screens can now be asked for and taken, including the two items MISSION parks as emulator-blocked.

2026-08-29 — three answers from one oracle session (the port's asks 1, 2 and 3)

  • 1 — the focus ring SPINS CONTINUOUSLY. Period 2.18 s wall-clock; author it as 120 units = 60 frames = 2.00 s at 30 Hz. It does not ramp once and stop. Measured with no angle estimated anywhere — the angular estimator written for this failed its own control (a synthetic 30° came back as 0°) and was not used. What settles it instead: total annulus brightness is conserved to 0.4 % while individual angular bins swing by 24 — brightness moving around the ring, which excludes a pulse — and the profile's autocorrelation has eight evenly spaced peaks, mean 2.177 s, over nine revolutions. ⚠️ Do not read the committed 20 s mean image as a frame: the spin averages to a uniform circle, which is why it looks headless. Five single frames 4 s apart show the head at five different angles. focus-ring-spin-measured.md · frames

  • And the ring is the ONLY thing that moves on the settled main menu. Temporal std over 20 s untouched is exactly 0.000 on every unfocused button, on the labels and on the ptmsg footer. Static menu + spinning ring draws everything that moves.

  • 3 — the idle timer that made your Ⓑ rule unprovable is REFUTED on the main menu. Held untouched, the menu stayed put for ≥ 60 s (49 samples, menu correlation never leaving 0.92450.9249), against the "~810 s idle returns to the title" this page carried. That timer is real but belongs to the title, not the menu — the corpus had it attached to the wrong screen. 🟡 Ⓑ itself: delivered (Canary logs vk=5801), and in both runs the only input in ≥ 100 s, followed by the title. Ordering measured, timing not — keep Ⓑ→title, now better supported than authored. menu-idle-and-b-2026-08-29.md

  • Your new #1 — the boot title shows build 4 FIRST, and the plate arrives after. It is your third option, not the first two. Green-Ⓐ glyph count on the boot title went 154 → 781, and 154 is the same reading the committed live-title-build4-no-plate.png gives (159) while plate titles give 753/977/1493. So ScreenView does have to draw two builds at once, and your --boot end state is NOT plate-free — that is the structural answer you said this question decides, and it is unchanged. 🔴 But my INSTRUCTION was wrong and you refuted it — do not author a delay at all. I said "when build 4 has settled, wait 2.13 s, composite build 2". Build 2 has a group of its own, and starting that group at settle puts the plate at settle + 2.13 + 3.97 s. Correct instruction: run build 4 and build 2 on ONE clock, started together, and play both groups from their own keyframes. The plate then arrives at its declared t=238 with nothing authored. 🔴 The premise that broke it is yours and it will bite again: rest.t is NOT when a screen settles. It is the last hold keyframe before the exit. ptlogo1 has rest.t = 251 and stops moving at t=42. The title's visible build-in is over at t≈118, where pteff01, pteff02.prm and ptlogoall_eff all end their ramps together — and 238 118 = 120 units = **2.000 s**, which is the 2.13 s I measured. The number was on the disc. ⚠️ If you author a gap anyway, author 120 units, not my 2.13 s. 120 units in 2.135 s is the game presenting at 28.06 / 28.14 fps against a nominal 30 — and the corpus had already measured the idle title at 28.5 fps, independently and before these runs. My wall-clock was this emulator's frame rate baked into a game constant; a port at a true 30 Hz would be visibly late. That it is presentation rate and not the game is checkable in the same two runs: first pixels → settle is 1.643 s and 2.131 s (a 30 % spread) while settle → plate is 2.138 s and 2.132 s. Frames are dropped during the build-in, not during the hold. Pulse the plate at ≈ 2.24 s (four intervals: 2.12 / 2.19 / 2.34 / 2.31), which replicates the corpus's ≈2.3 s rather than replacing it. title-plate-delay-measured.md · figure · run 1 · run 2

  • 🟡 Your black hold survives a real clock — keep 0.170.23 s. Measured on the Ⓐ path in both runs, the frame is pure black (surface mean 0.070) for 0.140.30 s and 0.140.27 s. At an 0.125 s sample interval that is as tight as this instrument goes, and it brackets both your authored value and the file's declared 12 units (0.20 s). It is the one authored constant you ship that a measurement now agrees with.

  • 🔴 The Ⓐ→menu latency is STILL not a number you may have, and now I know why. Both runs contain a frozen frame on the Ⓐ path — 14 frames (1.53 s) and 12 frames (1.39 s) held at surface mean 26.626, agreeing between two independent runs to six decimals. Run 2 had stream restarts disabled for the whole window, so it is not the capture path: the guest starts the fade, re-presents one frame for ~1.4 s, then shows the full title again and fades properly. That is a load stall, and the Ⓑ path — nothing to load — has none. So any Ⓐ→menu figure from this harness is an emulator load time. Your zero-dwell sequencer is the right call; do not add one.

  • 🔴 Four durations I took the same day are WITHDRAWN, including the plate delay. screen_match.classify_array costs 1503 ms/frame; a probe running it per frame drained an 8 fps stream at 0.64 fps, so its frames were stale and increasingly so. It manufactured "plate 24.66 s after the title art", "Ⓑ→title in 15.58 s", "Ⓑ→title in 25.60 s" and "Ⓐ→menu in 20.26 s". The tell: a transition, a press and a fade do not share a duration — a backlog does. A backlog preserves ordering and destroys durations, which is exactly why the sequence results above stand and the timings do not. Fixed (fast=True, 3875 ms, re-controlled 8/8 on both paths, agreeing to ±0.005); the ring's numbers are unaffected and that was checked, not assumed. One of the four is now re-taken properly — the plate delay, above. The probe that took it costs 8.7 ms/frame (173× cheaper) and both runs sampled at 7.97 / 7.98 fps against a requested 8, so there was no backlog to destroy them.

2026-08-29 — the paint-order tie-break costs your screens ZERO pixels (Q3 closed for you)

You challenged the advertised blast radius of the unknown tie-break and were right; the census that came back said 24 overlapping tied pairs and admitted nobody had measured how many of them change a pixel. Measured now.

Overlap was an upper bound and it was loose. Each bundle was rendered twice, once in the derived order and once with one tied pair swapped, and diffed — elements sharing a layer key are contiguous in the derived order, so a swap paints nothing else in between. Every entry ran a control first: a swap of an overlapping pair with different keys, which must move pixels (it moved 36 305 to 771 479 px, max Δ 254). Where no control was available the page says so.

  • On EXTRAS (entries 6/9) and the main menu (5/8): 0 px. The tied ptframe pairs each ink ~3 600 px and share 0. The bounding-box overlap was an artefact of approximating an element's rect as pivot × 2. Layers that never touch the same pixel cannot be mis-ordered under any blend, so this does not depend on our compositor being right.
  • The splashes have no ties; the title and developer splash use a measured order. So across all five screens the tie-break's cost is zero, not "one drawable pair, consistent with a capture".
  • 🔴 A claim of ours is withdrawn. This page's source said a wrong tie-break "can be wrong by a whole layer", from the geometry that ptlogo_back2eff5 fully contains two other glows. Rendered, that swap moves 6 390 px by a maximum of Δ2 out of 255. The glows are near-transparent; containment is not occlusion. Nobody had rendered it before asserting it.
  • 🟡 Outside your set, the worst tie-break cost anywhere in GP_TITLE is Δ3/255 (the Japanese title, entry 7). ⚠️ Those Δ figures do assume our alpha blend — what they rule out is a structural error, a layer appearing or vanishing, not a shading one.

ui-paint-order-derived-check.md · data · tool cargo run -p sylpheed-formats --example tie_break_pixel_cost -- dat/GP_TITLE.pak

🟡 2026-08-29 — your voice-region third chunk is NOT the BGM bank-header case

You asked me to look rather than take your word, so I did, disc-wide rather than on the asset that raised it. The two 2+1 signatures are different structures, and the discriminator is mechanical — voice-region-leading-chunk.md, census at data/voice-region-chunk-census.txt.

Over all 95 English movie-voice regions the manifest binds:

  • 78 open with a bank headerbank_header_len fires, 10 240 B = 5 packets exactly, every time. That is the BGM case and it is already consumed.
  • 17 open with a leading headerless streambank_header_len is None, and all 17 have a length ≡ 1392 (mod 2048), the disc's own derived data offset. That is a whole number of XMA1 packets after a 1392-byte preamble.
  • 0 begin at a RIFF.

⚠️ Counting chunks cannot tell you which case you are in. Eight bank-header regions also yield three chunks. Test bank_header_len, not riffs.len().

🔴 And "it is the previous cue's audio" fails a test. Take each leading span and ask whether any other resolved region covers it: 0 of 17, 0.0 % on every one. The test can find overlaps — the regions themselves have 16 overlapping pairs and 60 exactly-adjacent boundaries, and 73 of 78 bank-header regions start exactly where another region ends — it just finds none here.

RESOLVED, same day, by your own suggestion — and my leading hypothesis was wrong. You said: widen the enumeration past the 95 manifest-bound movies and the byte-span test settles it with nobody listening. It does. Scanning the stream for every trailer descriptor (287 found in a 116.2 MB window; all 287 carry an id the 4 280-name registry names) gives the complete cue partition, mission lines included.

🔴 The leading chunk is the MOVIE'S OWN dialogue — 17 of 17. Every leading span is bracketed by desc(N-1) .. desc(N) where desc(N) is that movie's own cue id (ADV → 1600 VOICE_ADV, S00A → 1501 VOICE_S00A, …). Zero are in-mission VOICE_D_* lines. So "drop it, it is somebody else's audio" is dead.

And the mechanism is a guard in our own resolver. resolve_movie_voice_region takes the predecessor trailer as the region start, guards it with end - start < 1_500_000, and falls back to the .slb anchor when that fails. Cues with a true span ≥ 1.5 MB: 17, of which 17 are stream-opening. Cues under it: 78, of which 0 are. Perfect discrimination both ways. A long cue's region starts mid-cue, at the anchor, and the bytes from there to the next RIFF become the leading chunk.

⚠️ But do NOT turn that into "the export truncates N seconds". Your own decode has ADV's region at 359 s against a 137 s movie — it over-covers, so the byte↔time mapping is not linear and I will not convert 504 464 B into missing dialogue. I have no XMA1 decoder here to check.

CLOSED, later the same day — and here is the confirmation you asked for before acting.

Yes: drop the leading chunk. It is a DUPLICATE, not a truncation. Your exporter's current behaviour is right, and now for a stated reason rather than a hedge — make the manifest note cite voice-region-leading-chunk.md.

Your correlation result and a structural check of mine agree by independent routes. Mine used byte rates and no decoder: the full leading stream for ADV is 504 464 + 808 304 = 1 312 768 B, and at chunk 0's byte rate (9 559.7 B/s) that is 137.323 s against chunk 1's measured 137.324 s — 1 ms over 137 s.

And the byte structure settles the shape disc-wide. Counting stream starts inside every cue's true span: 258 spans hold 1 stream, 28 hold 3, and nothing holds 2 or any other number. All 20 spans ≥ 1.5 MB are 3-stream. So:

your 95 regions cue shape chunks you get
70 1-stream 1
8 3-stream, short 3 (bank header)
17 3-stream, long — our guard clips it 3 (headerless leading)

359 s = 84.55 + 137.32 + 137.32. The 2.6× is three presentations of one take, one clipped by our own guard. That was the last open one and it is shut.

🔴 One change you have NOT made yet and should: stop summing chunk 1 and chunk 2. They are the same take, not two stems — ADV chunk 2 is 0.60 × chunk 1 (residual 26.8 dB down), S00A chunk 2 is digital silence. Summing a take with a scaled copy of itself adds ~4 dB and colours it. Take one stream.

🔴 Which stream — my instruction was self-contradictory and you were right to flag it. I wrote "the highest-rate, highest-gain one (chunk 1)". Those two criteria select different streams: on ADV chunk 1 is 0.0 dBFS at 1 118 268 B and chunk 2 is 8.3 dBFS at 1 171 516 B. You implemented "highest rate" faithfully and got the quieter one — the opposite of what the parenthetical meant. Withdrawn; keep it as an authored 🟡 exactly as you have it.

What I can decode is the rate, and only the rate. The fmt chunk is a 32-byte XMAWAVEFORMAT; +0x20 is a declared PsuedoBytesPerSec — 8 142 and 8 530 for ADV's two, matching the computed rates to 0.02 %, and 48 000 Hz at +0x24. But wEncodeOptions (0x10d6), channel count and channel mask are byte-identical across the presentations. Nothing in the header ranks them. Your "more bytes is consistent with a better encode and also with the opposite" is exactly right and the file will not adjudicate it.

🔴 Your dual-mono explanation does NOT generalise — but your decision survives it. Checked disc-wide over all 28 three-stream cues (data/voice-three-stream-sizes.txt): if stream 3 were systematically the same take with its channel duplicated, its size ratio to stream 2 would be tight. It runs min 0.0778, median 1.2565, max 2.9163, sd 0.5057, with only 12 of 28 within 15 % of 1.0 — a 37× spread. Declared rates scatter with them (S06A 5 661 vs 16 513 B/s). Your ADV channel measurement stands and your choice of "loudest" is untouched, because it is a per-asset content measurement rather than a structural rule. What must not harden is the explanation: "more bytes means a duplicated channel" is true of ADV and is not a fact about the format. ⚠️ Two curiosities: S12B's three streams are byte-size identical (14 396 each), and BIRD_224 is 3-stream while being a non-movie cue.

⚠️ Also: do not trust sylpheed-cli audio info on these. Its "16 channels / 4310 Hz / 2-bit" is wBitsPerSample, wEncodeOptions and the channel fields read at wrong offsets. Its reader is misaligned for XMA1.

Why the disc stores three presentations is not answered.

⚠️ On your offer to convert the 504 464 B constant: don't spend the decode. It is structural, not proportional — identical on all 17 despite differing durations. ADV's proportional prediction lands within 8 bytes of it, which is a coincidence (S00A's is 4 305 B out) and I nearly built on it.

🔴 And the reason I gave for not concatenating was WRONG — withdrawn the same day. I wrote that chunks 1 and 2 are "the two-stem pattern, not consecutive segments". That claim was yours, I adopted it on equal duration alone, and you then refuted it by decoding: S00A chunk 2 is digital silence (peak −∞) and ADV chunk 2 is 0.60 × chunk 1, residual 26.8 dB down. Equal duration was a shape match, and Q10's music census should not have been carried across to voice on it. Do not concatenate — that part survives, measured (359 s against a 137 s movie) — but not for the stated reason, and what ADV chunk 2 actually is stays open and is mine.

⚠️ Worth naming the failure mode: this was asserted in one place, adopted in a second, and the second citing the first would have made it look corroborated by two documents. It was caught only because you measured your own claim.

2026-08-29 — settle_time() MEASURED. Stop pacing off rest.t.

Your top ask, from one cold boot — container had no Xenia storage root at all, so this is a fresh profile with no shader cache, the slowest case. boot-settle-times-measured.md · frame log · tools/re-capture/settle_analyse.py

Classification: measured. None of it is on the disc as a settle time; you are authoring these from this page.

measured
title build-in (first ink → art fully drawn) 0.23 s 1.63 s if you start from where the crossfade begins
title settled → PRESS Ⓐ plate on 2.247 s matches the disc's declared 120 units
plate pulse period ≈2.37 s
main menu build-in 0.531 s
Ⓑ → title 0.482 s
Ⓐ → menu 3.763 s 🔴 do not author — contains a 1.53 s emulator load stall

🔴 rest.t is confirmed to be the wrong landmark, with the number you need. The title's rest.t is 251 units = 4.183 s; its art is finished at ~2 s and the plate is on at 2.247 s. Your sequencer holds the title about twice as long as the game does. That is the defect you described, measured.

Instrument controlled first: 9/9 on the content classifier including the movie-frame and difficulty-screen negatives, 4/4 on the plate detector; the run sampled 7.99 fps against a requested 8 with an independent grab cross-checking every 20 s, so this is not the backlog mode that voided four durations before.

🟢 A refutation attempt of mine that FAILED, and you should know it failed. The probe's own marks gave a plate delay of 3.203 s against the corpus's 2.13 s — a 50 % disagreement I expected to be a real cold-cache effect. It was my instrument. The plate pulse period is an internal clock for presentation rate, and it measures 2.369 s here against the corpus's ≈2.3 s, so the run is not slowed; re-measured from content, the delay is 2.247 s. ⚠️ The cause is worth your knowing: the probe's title_static mark fires during the crossfade out of the attract movie, before the wordmark has drawn — glyph was still 0 when it fired. Do not use title_static for a duration.

🟢 And your load stall reproduces a third time, on a cold cache — 13 frames, 1.53 s, surface mean 26.631, against the earlier 14/1.53 s and 12/1.39 s at 26.626. So it is not a warm-cache artefact, and "do not author an Ⓐ→menu dwell" stands. ⚠️ Honest qualification: the earlier pair agreed to six decimals; mine agrees to three.

⚠️ Reach: one run. The plate delay and the load stall are cross-checked against independent prior evidence. The menu build-in and Ⓑ→title rest on this run alone. 🟢 Re-take offered and declined — you author neither, you are within ~0.1 s of both from the disc's own keyframes, and a one-run measurement over a decoded value gains nothing. Left provisional deliberately.

⚠️ And the generalisation is narrower than your red flag was. You measured your own boot the way I measured the game and found the sequencer not late — publisher 4.25 s against 4.297 / 4.604 / 4.370, developer 3.50 s against 3.508 / 3.503 / 3.366. What this page supports is rest.t is the wrong landmark for the title, where it overstates 4.183 s against ~2 s. It does not support "everything paced off it is late", and the 0.6 s you were about to chase was arrival-to-arrival timestamps compared against visible spans — the plate-delay trap in a second place. Recorded on my side too.

🔴 2026-08-29 — THE GAME DECODES ALL THREE VOICE STREAMS AT ONCE. "Take one" is withdrawn.

This overturns an instruction of mine that you implemented. Full page: voice-three-streams-are-concurrent.md · probe log

Booted with --xma_param_probe=true — the cvar whose own comment says it exists to reveal which sub-wave of a movie's .slb the game decodes. It does not decode one. It opens three XMA contexts and decodes all three concurrently:

ctx packets byte_size ch rate disc stream
0 632 1 294 336 2 48 000 ADV stream 1
1 546 1 118 208 2 48 000 ADV stream 2
2 572 1 171 456 2 48 000 ADV stream 3

Byte-exact against the disc (RIFF size 60). Only those three contexts appear.

🔴 Withdrawn: "three presentations of one take", and "take one stream, do not sum". A consumer picking one discards two thirds of what the game mixes. ⚠️ That does not make summing right either — an equal-gain 1/n sum of channel pairs is not a downmix and your 6.02 dB complaint was real. Neither rule is established. You are authoring, and the manifest should say so.

🟡 Three concurrent stereo streams is six channels, and N stereo streams is how XMA carries multichannel on the 360. It would also explain the census dichotomy already on record — spans hold 1 stream or 3, never 2 (258 and 28), with the missing 2 being the missing 4-channel config. ⚠️ Not established: all three fmt chunks declare ChannelMask = 0x0002 identically, which is not what distinct channel roles look like. Hypothesis, with its counter-evidence.

Everything byte-level survives — the leading chunk being stream 1 clipped by our 1.5 MB guard, the 70 + 8 + 17 decomposition, the bank-header discriminator. Those are about bytes and did not depend on the framing. Your S00A silent-stream and ADV 0.60× measurements survive too, and now read as measurements of channels.

⚠️ Reach: one cue, one boot. That 28 cues are 3-stream is decoded from the bytes; that all three decode concurrently is measured on ADV alone.

2026-08-29 — the fifth and sixth streams were BGM_102, and you can now get BGM durations off the header

bgm-102-decoded-during-boot.md

The take-2 boot decoded five XMA streams and I could only account for three. The other two — 1 150 976 and 1 269 760 B — are BGM_102.slb's two stems, found by searching every sound.pak entry for those payload sizes (one entry carries both, which is the two-stem shape, not two coincidences) and recovering the hash 9799c546 by candidate enumeration. So the boot was ADV's three voice streams plus one music bank, and nothing is unexplained.

🟡 Do not read that as "BGM_102 is the attract music." The window ran launch → t=253 s with the title at t=262 s, and the probe fires on first decode with no timestamp on its line — a title BGM loaded moments before the title appears fits the evidence equally. Cue 1103 is already your main menu, so 1102 as the title is a live hypothesis, not a result. If you ever want it settled, say so and I will timestamp the probe.

Useful to you now: sylpheed-cli audio info gives BGM durations with no decoder, via the corrected XMA1 PsuedoBytesPerSec. Both stems of a bank agree:

bank stem 0 stem 1
BGM_102 37.487 s 37.487 s
BGM_103 (your menu track) 87.750 s 87.749 s
BGM_001 173.821 s 173.821 s

🔴 My explanation of the BGM_001 gap was wrong and you corrected it. I said declared 173.821 s vs decoded 167.663 s meant "declared includes trailing silence, decoded is where the audio stops". A full decode yields 173.809 s of PCM — so declared and decoded agree to 12 ms, and 167.663 s is the fade-out, sitting inside the decode. Cross-checked on three banks now: BGM_103 87.750 declared / 87.744 decoded, BGM_102 37.487 / 37.482, BGM_001 173.821 / 173.809 — 512 ms.

⚠️ The conclusion is unchanged and is the useful part: trust it for lengths, not for musical boundaries. A declared length includes whatever silence the encode carries, so it is not a loop point. For BGM_001 the music stops at 167.663 s, 6.1 s before the stream ends — measure a loop point from the audio.

🟢 Refutation attempt on this page's own BGM_103 claim: it survived. The two declared waves read 3 876 864 / 3 930 112 B off the disc — exact.

Status

Question State Answer / link
Q1 keyframe time unit + ramp shape answered ramp is linear; 2 units per rendered frame; 1 unit = 1/60 s — measured, the idle title presents at 28.5 fps so the game is 30 Hz — ui-keyframe-time-unit.md. The group timeline is now DECODED too (2026-08-29) and the gap is closed: a placement group is frames records of {u32 time; 36-byte pose} after an 8-byte header, so a pose's time is the word before it, pose 0's time is the group's lead-in word, and every pose is timed — including the last, which nothing could time before. Disc-wide over 13 991 groups with controls; the old reading makes 0 of 1 042 multi-segment alpha ramps constant-rate against 857 of 1 540. SYLPHEED_KF_TIME_SHIFT is retired (it had the association right but left pose 0 untimed, which is the whole reason it appeared to cost 13.1 % of build 7). Static renders are byte-identical — ui-keyframe-record-layout.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, and pak entries 0/1 and 12/15 are the LOADING screen — two variants, plain and dressed, decoded from their pgloading_* element names (2026-08-29). ⚠️ Read that in ENTRY space. This row said "0/1 and 10/11" until 2026-08-29; that is true only of screen list's ordinals, where 10→entry 12 and 11→entry 15. In entry space 10/11 are the publisher and developer splashes (palogo_sqex, palogo_gamearts). The port caught it; see METHOD. 🟡 which of the two is LOADING vs LOADING2 is undecided; 🟡 the English member of a pair is the one in the first half of the data segment — ui-title-build-map.md
Q3 paint order for the six screens answered, tie-break 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. ⚠️ The key does not fully order a screen: elements sharing a key are tied, and the tie-break is undecodable from the bundle — declaration table, T8aD header (exhaustive: every offset 0x000x7f at u8/u16/u32, both directions, 0 fields match the measured order against 64 for the control) and the RATC child order all give the same order the game does not use. Your exposure is now measured at ZERO PIXELS (2026-08-29). The 2 overlapping tied pairs on EXTRAS are ptframe3×ptframe4 (the other is a loop* you never draw), and rendering the screen with that pair swapped changes 0 px — because the two sprites put ink on ~3 600 pixels each and share none of them; the 102×132 "overlap" was a bounding-box artefact. Same on the main menu's ptframe1×ptframe2. This is blend-independent: layers that never touch the same pixel cannot be ordered wrongly. Nothing about the tie-break can change a pixel on any of your five screensstructures/ui-paint-order-derived-check.md
Q4 button → GamePart answered measured which screen all 5 buttons open, by pressing each one and reading the screen's own title off the framebuffer. In the form you need it: exactly ONE main-menu button opens a GP_TITLE entry. EXTRASentry 6 (EN) / 9 (JP). The other four leave the archive: NEW GAMEDIFFICULTYSELECT DATA; LOAD GAME → the save-slot list; TUTORIAL → the lesson list; OPTIONS → GAME/CONTROL/SOUND/SCREEN SETTINGS. None of those four is a GP_TITLE build — so a menu→submenu→back cycle inside this archive is main menu ↔ EXTRAS and nothing else. The GamePart id is still a name match, not a measurement, and 🔴 the "cheap way to measure it" this page used to point at is a dead route (the guest words are monotonic counters, not a screen id) — menu-navigation-semantics.md
Q5 navigation semantics answered measured: initial focus varies boot to boot (2× TUTORIAL, 2× NEW GAME); ⬆⬇ one step, wraps both ends; ⬅➡ do nothing; Ⓑ returns to the parent with focus restored; Ⓑ on the main menu → title; Ⓑ on the title → nothing — menu-navigation-semantics.md
Q6 boot sequence + what drives it answered sequence measured end to end; the driver is code, not data — four search spaces closed, so the port authors the sequence — boot-config-and-gamepart-registry.md
Q7 transitions answered a fade through black, drawn by the screen's own last-painting .prm quad. Fade-in ramp is decoded from its keyframes; the ~0.4 s fade-out is measured (not in the file) — screen-transitions.md
Q8 menu audio bindings answered cue vocabulary + bank decoded; event binding is a name match (the authors' own event names). You CAN have the SE audio⚠️ an earlier version of this row said it was "undecodable from the disc"; that was retracted and the row was stale. Three cues are located in Static.slb and decode to PCM: d-pad move 0x1ec0 (4 packets), Ⓑ back 0x0ec0 (2), Ⓐ confirm 0x5d6c0 (6), all mono 48 kHz. The bank is a packed run of XMA waves with no delimiter, so a wave is only (offset, packet count) — and ⚠️ the file order is not cue-id order, so the index cannot be counted out — menu-audio-cues.md
Q9 video binding + playback rules answered decoded from the movie manifest: ADVERTISE_MOVIEADV.wmv (boot intro and attract are one asset), MS00AS00A.wmv is the new-game intro, STAFF_ROLL→the credits reel. one Ⓐ skips a movie (title at 57 s vs a 193 s baseline) — movie-binding.md
Q10 music-bank sub-wave roles (intro+loop?) answered two stems of one performance, played together — sample-synchronous, equal duration, 32/32 banks. Concatenating is wrong. Not a seamless loop either — structures/bgm-two-stems.md. ⚠️ Our reader said three until 2026-08-29 — the extra one was the bank header, emitted by to_xma_riffs; fixed, with a 28/28 disc-wide check and two regression tests — structures/slb-bank-header-not-a-wave.md
S1 Ready Room go/no-go no-go it is 2D and enumerates fine (60 builds), but GP_READY_ROOM.pak holds briefing/tactical-map content, not the six-button Ready Room menu — ready-room-probe.md

Already settled — the port can rely on these today

⬅ Answers to the port's five asks (2026-08-29)

  • 1 — how to recognise the splash. No content rule exists; you are authoring this. Design size fails (every extra composable bundle sampled is 1280×720, same as every screen) and element count fails (the fragments run 2…15 elements, the splash halves have 3 and 7 — the ranges overlap). But GP_TITLE needs no rule. There, --all adds exactly four bundles and all four are real screens — no fragments at all — and the --all index equals the pak entry index 1:1 across all 16, so addressing by entry index does not mean something different from elsewhere. 🔴 And there are TWO splash screens; you have one. Entries 11/14 are the developer logos (GAME ARTS / SETA / studio anima). Entries 10/13 are the SQUARE ENIX publisher wordmark — the first thing the boot shows — and you do not have them. The pairs are region twins ( on 10, ® on 13). All four draw every element they declare. ui-splash-addressing.md · render grid

  • 2 — the ~0.4 s fade-out is (a), and it is bigger than the fade quad. Every element of a screen ends on exactly one untimed keyframe — so there is one unknown duration per screen, which rules out (b). That final block is where the screen plays out: the quad goes a=255 (black) while the buttons, ptmsg and the glows go a=0 and the two frames hold. (c) is refuted by a null test on the capture: a black quad alone keeps the button÷background brightness ratio constant, and measured through the fade it falls 6.50 → 1.94, a 3.4× monotonic drop. So: write one authored constant (~0.4 s / ~24 units) and play the group to its end on every element — do not fade a black rectangle over a frozen screen. screen-transitions.md

  • 3 — focus: your choice is fine, and it is not your bug. The focused sprite completely covers the base — f alpha ≥ base alpha at 100.0 % of base-visible pixels on three pairs across both languages, once aligned properly (the true offset is (7,7), and at the centre alignment it reads a misleading 7884 %). Compositing both ways differs by RMSE 1.1 inside the button rectangle, max 12/255 on ~25 px — unmeasurable at frame level. 🔴 What you are actually missing is the focus record's SECOND element. ptbtn0Nf.rat declares two sprites — ptbtneff01.t32 (a 42×46 glowing ring, focus only) then ptbtn0Nf.t32 (the bright label) — where the base record declares one. That ring is the marker you say you draw nowhere. ⚠️ Note the small dot-in-circle at each underline's left end is not it: that is on every button all the time, part of the base art. structures/ui-button-focus-record.md

  • 4 — rotation: not mine to decide alone. Raised with the human; see MISSION. What I can say without a decision: the two sub-questions are not equally open. Rotation is about the declared pivot — that anchor is measured, not assumed: the title's two ptloop sweeps scale 600 %/800 % vertically, where the pivot term is worth 450 and 630 px, and the GPU capture puts both quad centres at y 359.1/360.0 against the pivot formula's 360.0; top-left anchoring predicts 810/990 and centre-as-position 270. So if you draw rotation, rotate about the declared pivot. ⚠️ It changes nothing on your five screens at rest — they have zero top-level rotations, and the title's two nested ones sit entirely off-screen at rest. structures/ui-keyframe-rotation.md

  • 5 — the capture is not gamma-neutral, and RMSE against it has a floor. Measured on flat patches (16×16, both images std < 8): capture ≈ 255·(render/255)^γ with γ ≈ 1.49 (main menu), 1.49 (EXTRAS), 1.34 (title). The chain says this is a ramp the game installed, not a capture-path artefact: canary's swap-path gamma stage is a pure 256-entry LUT that defaults to identity, and the game is measured calling VdGetCurrentDisplayGamma once at video init. ⚠️ Reach: the flat patches are almost all dark (render ~060), so nothing here constrains midtones or highlights — which is where γ 1.4 does its visible work. So: yes, there is a floor; a γ ≈ 1.4 darkening gets closer and is authored, best applied where it was measured rather than extrapolated. Do not chase RMSE below it. structures/ui-render-tone-curve.md

  • GP_TITLE.pak is eight screens, each shipped twice — English and Japanese. Build 4 is the English title art and 7 its Japanese twin; 2/3 are the PRESS Ⓐ BUTTON plate, a build of their own composited over the title and faded in a beat later; 5/8 are the five-button main menu; 6/9 are the EXTRAS submenu — the only submenu inside this archive. Builds 0/1 and 10/11 are a DELTASABER / SYLPHEED A.I. plate that was never seen running, in the boot path, any title-side screen, or the attract loop. measured against live captures for the four English screens the boot path shows; ui-title-build-map.md. Withdrawn: the earlier "builds 6/8/9 are submenus" — 8 is the Japanese main menu. The other four main-menu buttons leave the archive, and where each one goes is measured — see the button-destination bullet below.

  • Buttons are identifiable as data. Element kind 0x3002 = button, 0x0 = decoration, 0x10 = primitive. decoded for the title-side screens. ⚠️ 0x3002 is one member of a 0x3000 family with sub-bits, and it is not the only button kind on the disc: GP_READY_ROOM's 902 bundles contain zero 0x3002 and use 0x3000 / 0x3004 / 0x300c / 0x3008 instead. Nothing in this milestone changes — every screen in scope is GP_TITLE — but do not ship kind == 0x3002 as a general button test. (The kind is the 4th u32 of the 60-byte declaration entry, at +40.)

  • The title's settled pose is rest — and always pass --primitives. Against a plate-free capture of the real screen, screen render --build 4 --black edge-correlates 0.9163 at (0,0), so the geometry of rest is the arrived pose; the timeline is not needed for the title. ⚠️ But without --primitives the whole frame is +13.14 too bright (R +12.35, G +13.58, B +13.48); with them, +0.55. The missing element is pteff02.prm, the 25 % dim, and --primitives is off by default. That is the "washed-out cyan slab" — a dim that should be there and isn't, not a glow that shouldn't. Because the art is blue-dominant, the shortfall reads cyan. 🔴 A second, separate defect — and it is the "slab". With the dim in place the residual is localised to one band (y ≈ 112225): the game draws the logo's Z swoosh thin with a pink/magenta edge, our render draws it thick and solid white. Crop: title-swoosh-capture-vs-render.png. The elements are ptlogo_back2.t32, ptlogo_back2eff.t32 and ptlogo_back2eff1…5. 🟡 Those five are also the group with the known unsolved paint-order tie-break (key 0x8083) — same screen, same elements — but a blend-order swap explains white-instead-of-pink poorly. 🔴 The pivot mismatch is NOT the cause — checked and refuted. These elements' declared pivots really do belong to the other language's sprite (ptlogo_back2's (500,117) is exactly half the Japanese 1000×234, not its own English 1118×262; 24 of 109 title elements are off by > 8 px). But it cannot affect this render: blit sizes a sprite from its texture, and applies the pivot only as kf.x pivot·(scale100)/100 — and all seven swoosh elements are scale (100,100) at every keyframe, so the term is zero. That formula is now MEASURED, not just implemented (2026-08-28). The title's two ptloop sweeps scale 600 % and 800 % vertically, where the pivot term is worth 450 and 630 px, and the GPU capture puts both quad centres at y 359.1 and 360.0 — against the formula's 360.0 for both. Top-left anchoring predicts 810 and 990; treating the position as the centre predicts 270. Horizontally the same term makes the group's t solved from position agree with t solved from vertex alpha to 0.33 / 0.65 units, versus ~8 units without it. So: anchor scaling on the pivot, not on the top-left corner. 🟡 It does not prove interpolation is linear — both fields were inverted through the same linear map, so a shared easing curve would cancel. It does show position and alpha ride one shared parameter. ⚠️ It would bite ptlogo1/ptlogo2, which scale 100 → 150 during the build-in. Not at rest, and not on the swoosh. 🔴 Ruled out for the COLOUR: every swoosh keyframe's fade is 0x??ffffff (white RGB, alpha only), no tint is non-white, and the texture decodes blue-leaning (175,174,198). 🟡 What is left is the BLEND. Size and position are the texture's own and match; fade, tint and texture colour are all ruled out. Seven overlapping mostly-transparent sprites (ptlogo_back2 5.4 % opaque, its glow 10.3 %, the five eff segments 1023 %, all white or warm) stacked with plain alpha-over saturate to opaque white — which is exactly what we draw, and would read as "thicker" against the game's thin coloured stroke. 🟡 And there is a candidate field for it. The T8aD header word at +0x04 splits the title's sprites exactly along effect-vs-normal: pteff01, pteff03a, ptlogo_back2eff1…5, ptlogoall_eff/_eff2 are 0x8832; ptlogo1/2, ptlogo_tm, ptbase2, ptlogo_back2, ptcopyright and ptlogo_back2eff are 0x8830. One bit — 0x02. Disc-wide it is a real independent flag: 19 216 sprites, 18 distinct values, bit 0x02 set in 27.1 %, and it toggles against otherwise-identical words (0x8830/0x8832, 0x0830/0x0832, 0x0810/0x0812, 0x0030/0x0032). ⚠️ Correlation only — untested. Nothing yet shows it means additive; the test is to blend bit-0x02 sprites additively and re-correlate the title against the capture. Note ptlogo_back2eff is 0x8830 despite its name, so this is a field and not a naming pattern. Not diagnosed — but two candidate meanings are now dead: not "the name contains eff" — and not even the one-way "bit ⇒ eff name", which held 10/10 on the title but fails on 2 657 of 4 995 bit-set sprites disc-wide (P(eff|set) = 0.468). What survives is a 3.3× association, and the counterexamples are rings, glows and lights — effect-like art without the naming convention and not "the element is transient" (pteff03/pteff03a carry the bit and persist to t=250). 🛑 Parked — four candidate meanings are now dead (additive blend; eff name, both directions; transient element; premultiplied alpha, refuted because flagged sprites violate RGB ≤ A more than unflagged, 55.5 % vs 33.7 %) and none produced a positive account. It blocks nothing — your screens composite at 0.947 correlation against a capture without it. ⚠️ Practical note for an asset pipeline: T8aD headers appear in RATC child order (18/18 verified on build 4 against decoded dimensions), which is the only sound way to attribute a header to a name when two sprites share a size — and two here do. ⚠️ The rotated draw is NOT the swoosh. It was identified as the swoosh by elimination; that is refuted — its quads span y 209…925 in screen space, the swoosh is a band at y 126…360. It is the two ptloop sweeps (ptloop01.ratpteff03.t32, ptloop02.ratpteff03a.t32), confirmed by edge length: 400 × 1076 and 400 × 1444 against 399×180 at the two elements' different declared scales, 600 % (= 1080) and 800 % (= 1440). What stands from the old bullet: the game does submit rotated quads this compositor cannot draw, and vertex colours are white. SOLVED 2026-08-28 by draw capture — it is the GEOMETRY. The game submits the swoosh as two rotated parallelograms (edges (0.54,0.56) and (0.44,0.79), ~45° and ~61°, extending to y=±1.81 NDC). ui_layout::blit draws axis-aligned rectangles only, so it blits the sprite upright — right on average, right in position, wrong in shape, which is the measured signature exactly. A port that blits upright rects will have the same defect. 🔴 Vertex colour is refuted with it: every colour in the capture is <alpha>FFFFFF, white RGB. CLOSED 2026-08-28 — the rotation IS on the disc, at keyframe +12. It is a signed angle in degrees, clockwise-positive in screen space (Y down), and ui_layout::Keyframe now carries it as rotation_deg. Confirmed against the framebuffer, not against our own renderer: the two ptloop records declare +12 = 30 and 45, and the GPU capture submits their quads at +30.26° and 45.28° — magnitude and sign, on two different values. Corroborated separately by shape: GP_BUNK entry 117ca14f holds a group whose +12 ramps 0 → 360 with position, scale and alpha all constant — a spin in place. Disc-wide +12 is non-zero in 14.50 % of 83 862 keyframe blocks. ⚠️ Two things the port must know about it. (1) Rotation lives in BOTH the top-level table and nested .rat leaf records. ⚠️ I told you one iteration ago that it looked nested-only; that was three archives' worth of pattern and it is refutedGP_DIALOG and GP_DEBRIEFING_PILOTLOG rotate top-level elements. The actionable half stands: the title's rotations are nested, so a composer reading only the declaration table gets zero rotation on exactly the elements that move there. The clearest examples are top-level and show up in screen info --build 0 --geometry dat/GP_DIALOG.pak: pceff03.t32 and pceff04.t32 ramp r= 90 → 30 → 10 → 3 → 0 while their alpha ramps 0 → 255 and they slide into place — a swing-in that settles upright; and build 6's pzeff02.t32 ramps 43 → 61 → 75 → 90 while scaling 112 % → 200 % and fading to 0 — a spin-out burst. (2) sylpheed-cli screen render still does not rotate. The field is decoded, not rendered; ui_layout::blit is axis-aligned only. So the reference renderer and the port will both draw these upright until a rotating blit exists — and per your own rule, the two of them agreeing about it means nothing. Checked 2026-08-29: this does NOT affect your five screens at rest. Title, main menu, EXTRAS and both splash halves have zero top-level elements with a non-zero rotation. The only rotations on any of them are the title's two nested ptloop records (r = 30 and 45), and at rest those sit at x = 1521 and x = 839 — a 399-wide sprite entirely off both edges of a 1280 screen. So a static composite is unaffected; the caveat applies only if you animate the title's build-in, where the sweeps cross the screen rotated. 🟡 The neighbouring words +4 and +8 are still unexplained: signed, non-zero in ~4.8 % / 4.6 % of blocks, almost entirely ±180/±90. That distribution looks like a flip flag rather than a free angle, but nothing observed turns on them — do not transcribe them as X/Y rotation. ui-keyframe-rotation.md ⚠️ The earlier "pink versus white" reading compared two differently-shaped renderings and should be re-checked after geometry, not carried as a separate defect. Classified: undecodable from the disc, with reach. Seven candidates eliminated — pivot (inert at scale 100 and no measured displacement), fade, tint, texture colour, additive blend via +0x04 bit 0x02, and capture-not-settled (the band is identical from t = 4.0 s to t = 21.5 s). The residual is stable and modest: band mean +1.83, edge-corr 0.70 vs ≈ 0.92 frame-wide. A next attempt should use a per-draw GPU capture of the running guest — not another field — but ⚠️ that capture records prim/indices/shader hashes/texture bindings/vertex attributes and no blend state, so it can test a per-draw vertex colour today and would need a Canary change to dump RB_BLENDCONTROL. And the plate-free capture is sound; use it. 🔴 Refuted: it is not that our dim covers the whole frame instead of sitting beneath the UI — the logo reads +2.36 against a background of 0.74, so the paint order is being honoured.

  • The title's motion, decoded and attributed. After building in, the title art is essentially static — a 22 s capture measures the wordmark region at sd 0.06 and the bottom-right corner at sd 0.003. Two things do move:

    • Two slow light sweeps in build 4. ptloop01.rat (an opt -linked RATC at 0xbb5966) sweeps pteff03.t32 left→right over 450 units = 7.5 s; ptloop02.rat sweeps pteff03a.t32 right→left over 570 units = 9.5 s. Ordinary 40-byte keyframe blocks from +0x68, three keyframes each.
    • The PRESS Ⓐ BUTTON plate pulses, and it is the loudest thing on screen — a capture's per-tile amplitude map puts sd 7.65 in the band x ≈ 318954, y ≈ 560672 against 0.06 on the wordmark. It is ptbtn00f.rat, the plate's highlight variant (build 2, not build 4), whose alpha ramps 0x00 → 0x06 → 0x4a → 0x50 (hold) → 0x4a → 0x06 → 0x00 over eight keyframes at t = 6, 29, 35, 50, 58, 97, 105, ? — a glow that fades in and back out, closing on fully transparent, so it is a complete cycle rather than a one-shot ramp. 🟡 Its cycle length is not readable, and this is now observed rather than assumed: the eighth block's time slot literally contains the ASCII terminator end , so the record ends there and the value does not exist. Declared span is therefore ≥ 105 units = 1.75 s against a measured ≈ 2.3 s — which would need a final step of ≈ 33 units. That number is fitted to the measurement, not read; the port should take ≈ 2.3 s as measured. ⚠️ An earlier version of this bullet said "the title screen loops at ≈ 2.2 s" and attributed it to ptloop01/02. Both halves were wrong: it is the plate, and it is a different build.
  • 🟡 Our composite is brighter than the emulator's frame — measured, and you would be authoring if you apply it. Alignment is exact (best offset dy=0 dx=0, correlation 0.9466), so only the tone differs. Fitting on 16×16 patches that are flat in both images: capture ≈ 255·(render/255)^γ with γ = 1.491 (main menu), 1.493 (EXTRAS), 1.338 (title). ⚠️ Narrow reach. Those flat patches span only render values ~060, where a gamma and a plain scale are nearly indistinguishable — on both menus the errors are 0.28 vs 0.34 and 0.22 vs 0.28. Only the title separates them (1.08 vs 9.02). Nothing here constrains midtones or highlights. 🔴 The held-out control failed to discriminate: the splash's 2 918 flat patches are pure black (render 04), so every model scores ≈ 0. 🔴 My "it may be the emulator" caveat is withdrawn. I said canary applies kernel_display_gamma_type = 2 (BT.709) on output. It does not — that cvar is a value a kStub getter reports to the guest (VdGetCurrentDisplayGamma), which the game uses to build its own ramp; canary then applies the guest's ramp from the DC_LUT registers in the swap path (apply_gamma_table.ps / apply_gamma_pwl.ps). So there is no emulator post-process to subtract, and any gamma in a capture is one the game installed. Measured 2026-08-29: the game DOES query the display gamma. Booted with Kernel logging on (--log_mask=12 --log_level=3, which changes nothing about the output), VdGetCurrentDisplayGamma is called once at video init, between VdGetSystemCommandBuffer and VdSetDisplayMode — the moment a ramp-builder would ask. Control in the same log: 359 VdRetrainEDRAM lines, so an absent call would have shown. 🟡 That it then writes the ramp is inferred, not observed — but the chain is closed: canary's swap-path gamma stage is a pure 256-entry LUT with no other transfer (apply_gamma_table.xesli), and that LUT defaults to identity (CommandProcessor::Initialize, whose comment says the linear default is "what games set when starting with the sRGB return value"). Identity cannot produce the measured γ ≈ 1.4, so a non-identity ramp was written. ⚠️ The weak joint is that this assumes our composite reproduces the pre-ramp framebuffer; what carries it is that a systematic ~1.4 across three screens is not the shape of a compositor bug. A fixed sRGB stage does not fit either direction (encode brightens; decode darkens far more). Practical upshot for you is unchanged: the darkening is the game's own display ramp, so it belongs in a port as a display profile, not baked in. ⚠️ Note the ramp depends on the display type the game is told; canary hard-codes TV/BT.709, which on hardware is a console setting. So this is a display profile, not a fixed property of the game — reasonable to expose as a setting rather than bake in. structures/ui-render-tone-curve.md

  • FIXED 2026-08-29 — the dropped background was a name-decoding defect, and screen render now draws it. The reference composites for all five screens changed; regenerate anything you diffed against before that date. Cause: a RATC child's name is stated by an opt block immediately before it ("opt " | BE32 len | name | NUL | 3 bytes | magic), and our parser instead guessed it from the last printable run of bytes. For this one child the 3 trailing bytes are 38 41 58 = "8AX", which beat the real name pteff05.t32. 8AX was never a name — earlier text on this page treating it as one was wrong. Disc-wide: 18 002 children, 17 918 already agreed with the opt reading and 24 did not, every one the same 3-byte-tail failure. Effect on your five screens: mean brightness unmoved, high-frequency detail ×1.15…×1.30 — the same art at twice the resolution, which is exactly what 8AX said the game draws. ⚠️ Both backgrounds are now drawn (ptbase upscaled, then the full-res one over it): correct, but wasted fill. Draw only the full-res one, taking its timing from ptbase's element, which carries the keyframes. structures/ratc-child-names.md And the fix has no remaining hole. 60 of the disc's 18 002 RATC children still have no opt block; all 60 are now accounted for and none is on your screens. They are the ten frames of the disc's only .tan frame sequence (pb_f15_eg_anm.tan, six language copies of one GP_READY_ROOM bundle), where a single opt block names the whole run — so a name-resolution miss is not hiding anything else the way 8AX was. ⚠️ Two notes if you ever read outside GP_TITLE: ratc::parse over-reports there, listing a .tan's frames as anonymous children; and a RATC bundle names exactly six kinds of resource — .t32 (14 756), .rat (3 311), .prm (367), .tbm (224), .sbo (54), .tan (6). structures/ratc-tan-frame-sequence.md

    the original entry, kept because its reasoning still stands

    🟡 screen render silently drops one full-screen element per screen — and you must NOT simply draw it. Auditing what the composer omits on your five screens: everything is accounted for (kind & 0x4 ghost instances, .prm primitives, loop* animations) except pteff04.t32 on the title and pteff05.t32 on both menus. Those are kind 0x0, one keyframe, rest a = 255, pivot (640,360) — full-screen and opaque. Cause: the element declares pteff05.t32, but the T8aD behind its opt link is registered under the name 8AX, so the sprite lookup misses and a silent continue drops it. It does not currently show, because ptbase.t32 (640×360, drawn at 200 %) is the same artwork at half resolution — its 2× upscale differs from 8AX by mean 2.05, and our background is pixel-identical to 8AX in every patch sampled. SETTLED 2026-08-29 — the game draws the full-res 8AX, so use it. Previously parked as "needs a per-draw capture"; it did not. The two carry the same art at two resolutions, so what separates them is the detail 8AX has that an upscale cannot. Correlating the capture's departure-from-upscale against the 8AX-only detail (both first mapped through the measured gamma): main menu +0.0475 vs controls +0.0032 / 0.0075, title +0.0634 vs +0.0095 / +0.0086 — two screens, both 68 % of the theoretical ceiling, 715× their matched controls. So: resolve the name and draw 8AX at 1:1. Upscaling the 640×360 ptbase 2× is wrong, not merely softer. ⚠️ Do not draw both — an opaque full-screen layer over an identical one costs fill and hides later changes; and note ptbase's element is the one carrying the keyframes, so you need its timing with 8AX's pixels. ⚠️ It does not show whether ptbase is also drawn underneath — the full-res background is ~86 % opaque and would hide it either way. structures/ui-8ax-fullres-background.md

  • Paint order: your exposure is two element pairs, on one screen. We use an order measured from the running game where one exists and a derived order (a sort on each sprite's layer key) elsewhere. Checked, rather than assumed: the derived order reproduces the measured one exactly on the main menu (0 inverted pairs) and the developer splash (0). On the title it differs by 8 pairs — all same-layer-key ties — and two of those are total occlusions (back2eff5 is 1133×280 and fully contains back2eff3 and back2eff4; derived puts it on top, the game puts it underneath). The title is unaffected in practice because it has a measured order. Per screen: title measured, main menu measured, developer splash measured, publisher splash derived but with 0 ties (fully determined), and EXTRAS derived with 15 tied pairs of which only 2 overlap. That narrows again to ONE, and the capture is consistent with it. Of the two overlapping pairs, ptloop01×ptloop02 are loop* animations that compose skips by default, so their tie is unreachable. The remaining pair is ptframe3×ptframe4, overlapping 102×132 px — and against live-extras.png that contested region correlates +0.9622, better than the whole frame (+0.9440) and inside the range of regions where order cannot matter (+0.8502 / +0.9903). 🟡 Consistent with, not proof — correlation cannot see a swap between locally similar art. 15 → 2 → 1 → consistent is the whole paint-order risk on your five screens. structures/ui-paint-order-derived-check.md

  • How good are the five screens, actually? One page with the numbers: five-screens-acceptance.md. Rendered and correlated against the live captures — title 0.9500, main menu 0.9460, EXTRAS 0.9440, publisher splash 0.9600, developer splash 0.9643, and every one aligns at exactly dy=0 dx=0 over a ±2 px search, so placement and scale are right and the residual is tone and detail rather than geometry. Every undrawn element is accounted for (ghost instances, .prm primitives, loop* animations, and the 8AX name mismatch whose art is on screen anyway) — the counts add up exactly, with nothing unexplained. The residual, ranked: tone (γ≈1.4, the game's own ramp), 8AX resolution, one EXTRAS paint-order tie, and rotation-not-rendered which does not affect these five at rest. ⚠️ Reach: static composites at rest against single frames — this says nothing about animation.

  • A static composite is only meaningful for a screen that SETTLES — the two splashes are animations. Measured with its control: suppressing the _eff glows takes the publisher splash 0.9604 → 0.9982 and the developer splash 0.9659 → 0.9980, while the same edit makes the title 0.002, the main menu 0.092 and EXTRAS 0.107 worse. The draw log says why — on the developer splash the glows draw on frames 94115 and the logos on 116211, so at the captured moment every glow is already finished, including the two with plateaus that rest_plateau renders visible. So rest_plateau is right where a screen settles and over-draws where it does not. There is no "resting pose" for the splashes — they play through and leave, and a static composite of them is a picture of one arbitrary frame (≈0.998 for the frame these captures hold). Play the timeline for the two splashes; composite statically for title / main menu / EXTRAS. structures/ui-resting-pose.md

  • A group's DURATION is in the data; its START TIME is not. Testing whether the splash timeline played reproduces the capture: each element is on screen for its declared span to within 2 % (glows 44 units observed vs 45 declared; logos 192 vs 195). But every glow declares the same times 15,30,45 and every logo the same 15,30,190,194,206,210, so on one clock they would overlap almost entirely — and they do not overlap at all. The glows run frames 94115, the logos 116211, strictly sequential. 🔴 The obvious candidate is dead: each group header carries an undecoded lead-in word, and it is 0x00000000 for all seven elements. Not the keyframe times, not that word, not declaration order, not the RATC child order. Here is the sequence to author, measured (1 frame = 1/30 s): publisher palogo_sqex 3.00 s+ (a floor — it is already at full alpha on the capture's first frame), a 0.10 s gap with nothing drawn, then the developer glows for 0.73 s, then the developer logos for 3.20 s, the glow→logo switch being a single frame boundary with no overlap. Each phase is within 2 % of its element's declared span, so the durations come from the bundle and only the ordering is authored. ⚠️ One capture, one run; and palogo_anima/_eff get 0 draws in all 214 frames, so a third pair's phase is not in this measurement. data/splash-phase-timeline.txt ⚠️ So you must author the sequencing. The observed order on the developer splash — both glows, then both logos — is measured for one screen, not a decoded rule. Verified against its own refutation: across all 235 captured frames, zero contain both a glow and a logo; the switch is one clean boundary with two sprites either side. 🔴 And it goes further than start times — a bundle's declared elements are not what gets drawn. And the mechanism is now established, by elimination. The competing story — "two compositions shown in sequence" — needs a bundle declaring the glows without the logos, and no such bundle exists: only four entries in GP_TITLE carry palogo elements, and each developer entry (11, 14) declares all six logos and glows. So whichever bundle was active, only a subset of its elements was drawn at a time. (An earlier texture-base test could not separate the two — it failed its own control, since the publisher splash is a different bundle and shares the base 0x11C30000, a reused upload slot rather than an identity.) Entry 11 declares three logo/glow pairs; only two are ever drawn. palogo_anima gets 0 frames while palogo_gamearts gets 95, from byte-identical keyframe times. (Reach: the capture covers frames 1214, so "never in the window".) Compositing every element of a bundle does not reproduce what the game shows over time — it is right for a static screen that settles, and it is not a timeline. structures/ui-group-start-time.md

  • The splash's backdrop is an opaque black .prm from the bundle, painted first. Measured: every frame opens with the clear and then an untextured 1280×720 quad, vertex colour FF000000 in all 212 frames — matching palogo_eff0.prm's declaration (kind 0x10, pivot 640×360, one keyframe, a=255). That is why a splash render needs --black rather than the default backdrop. ⚠️ Not a general rule: the main menu's measured order puts one .prm at position 4 and the other last (the transition fade). The splash's recorded paint order [0,2,4,6,1,3,5] is a real depth order — read off the runtime child array, and independently backed by the static layer key (glows 0xa100 sort before logos 0xa110; paint_order_audit reports derived == measured, 0 ties). A same-day claim on this page that it was "a temporal sequence, not depth" was withdrawn; see the section in the linked page for why, if you read it before the fix. ⚠️ Separately and still true — and it is an activation fact, not a paint-order one: the splash's glows and logos are never on screen together (glows f94115, logos f116211, timeline). Compositing all seven at once reproduces no frame of the real screen. Depth says what covers what; it does not say what is up. structures/ui-prm-primitives.md

  • Menu order is geometric. Buttons sorted top-to-bottom by resting Y. This is correct for a vertical menu and is not a decoded neighbour graph — the disc's real navigation structure is unknown, and opt is not a focus link (measured and refuted, see ui-focus-and-effect-elements.md).

  • Highlighted states pair by nameptbtn01.ratptbtn01f.rat. 🟡 a naming convention that holds for all 54 real pairs, not a decoded field.

  • rest() was wrong for elements with no exit animation — fixed 2026-08-28. A trailing run of identical keyframes was always treated as the exit and excluded; on an element that has no exit it is the hold, and rest() fell back to the element's first keyframe — off-position and transparent. Six elements on the main menu were affected, including ptframe1/ptframe2, the bright circuit bracket around the menu, which both the port's composite and sylpheed-cli screen render were dropping. The rule now: a trailing run is the hold exactly when it is visible (alpha ≠ 0). ⚠️ Not the pose-equality test the report proposed — pgptitle.rat's trailing run also matches its last timed keyframe, and adopting that would erase the word PAUSE. Oracle correlation over the bracket region improved 0.9596 → 0.9748; the PAUSE control is unchanged. Disc-wide: over 2 859 bundles / 13 991 elements, rest moves for 30 (0.21 %) — 4 invisible → visible, 0 visible → invisible. Tests green (131 passed). Back to the port agent: on the English main menu exactly two elements satisfy your pose-equality condition (ptframe1/ptframe2), so your six span the whole export. If any of the other four have a transparent trailing run, this rule leaves them alone on purpose. Which screens are they on, and does a capture show any of them drawn? If so the alpha rule is incomplete. This also closes the old on ptframe1/ptframe2 "resting at alpha 0 but the capture shows the frame plainly".

  • The resting pose is the hold, not the first, last or longest-dwell keyframe; a keyframe is the start of a ramp. ui-resting-pose.md.

  • That ramp is linear, and it runs at 2 keyframe time units per rendered frame. Measured frame-by-frame off the running game's own draw stream: a 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. 🔵 Followed up, and the candidate is now strongly favoured — but not adopted. That +36 holds the next pose's time is supported by a calibration-free measurement: the observed full-alpha hold : fade-out ratio is 83 : 13 frames = 6.38, the shifted reading predicts 8.00, and the current reading predicts 0.25 — off by 26×. With the glow's 2 units/frame fixed and nothing else free, the current reading says the logo holds full alpha for 2.0 frames; the game holds it for 83. The shift also makes rest()'s plain dwell rule pick the visible hold instead of a fully transparent pose, and removes the decoder's "last block's time is unreadable" special case. 🔴 What stops it:GP_TITLE build 7 … twins should match in brightness … Withdrawn 2026-08-28. That render difference is not evidence about the time association. It is one element — ptlogo_eff3.t32, a transient bloom that grows 0 % → 200 % at full alpha while rotating, then collapses — and it has no resting pose at all. rest() falls through to its dwell fallback and returns whichever end of that movement the indexing lands on: the invisible frame as decoded, the 200 % peak shifted. An 896×389 sprite at 200 % is larger than the screen, which is the whole 13.1 % and the whole luminance gap. I was comparing a heuristic, not a decode. 🔴 And that is a real defect you should know about: rest()'s dwell rule is unsound whenever it runs. A dwell gap is time spent interpolating from pose k to pose k+1; neither is held unless they are equal — which is a plateau, and the plateau path has already returned by then. So any element with no two adjacent identical poses has a guessed rest pose, in our renderer and in anything built from it. Measured disc-wide: 2 305 of 15 493 elements (14.88 %)⚠️ corrected 2026-08-29 down from a published 3 807 (24.57 %), which counted 1 502 single-keyframe elements as guesses; those have one pose and it is unambiguously their rest. Of the real 2 305, 195 get a degenerate scale = 0 % pose, and the two candidate rules agree only 50.2 % of the time. This does not block you — your exposure is TWO elements, both on the splashes. The fallback is reached only by an element that is plateau-less and multi-keyframe; a single-keyframe element short-circuits. Title, main menu and EXTRAS reach it zero times, which is why three different fallback rules render them to identical correlations. The publisher and developer splashes reach it once each — and there, SYLPHEED_REST_RULE=last (the final keyframe) scores +0.9982 and +0.9758 against the current rule's +0.9600 and +0.9643. ⚠️ Measured against single frames of a transient animation, so it fixes which pose matches those captures, not which is canonically at rest. Default unchanged — it is better on both screens where it fires and identical on the other three, but it would move 2 305 elements disc-wide on two measurements. And the picture is now coherent. Applying the last keyframe to every element (not just the plateau-less ones) collapses all five screens — title 0.9500→0.6819, main menu 0.9460→0.6416, EXTRAS 0.9440→0.5745, and both splashes render blank. The reason is the model: a group is entry → hold → exit, and the exit is the screen's dismissal. A displayed screen is sitting at the hold, not at its final pose — so rest_plateau is right, and the last keyframe is the post-exit state. It is right for a transient element precisely because a transient's settled state is "gone". The draw capture agrees independently: on the developer splash the _eff glows draw on frames 94115 and the logos on 116211, so the glows are already over when the logos are up. Three independent observables — animation timing, static composites and the per-frame draw log — all support: plateau where there is one, last keyframe where there is not. 📊 Structurally, last is defensible for 2 293 of the 2 305 (99.5 %): 1 618 end invisible (a transient, gone at rest), 675 end at maximum alpha (faded in and stopped — the final pose is settled), and only 12 end visible below full alpha, which are genuinely unclear. The dwell rule returns a mid-movement frame by construction. ⚠️ The 1 618 rest on an assumption worth seeing: that such an element's animation has finished by the time the screen settles — shown by the draw log for the two splash glows, unshown for the rest. 📊 The disc-wide blast radius, for whoever decides: the rules differ on 82.3 % of those 2 305, so "either is fine" is not available — and the current rule returns a zero-scale (collapsed, pre-roll) pose for 195 of them against 43 under last, a 4.5× reduction in provably-degenerate results. That argues the same way as the captures, from the data's own structure. The single disagreement is palogo_anima_eff.t32 on the developer splash, and the current answer is the defensible one there — see below. Still worth flagging plateau-less elements in an export rather than silently inheriting our guess; it is one pass over the keyframes. One concrete part of it is fixed (2026-08-28): scale = 0 no longer renders at full size. blit/fill_quad coerced scale == 0 to 100 %, so an element collapsed to nothing was drawn full-size. The disc settles the reading: 2 166 elements have a zero-scale keyframe, not one is zero throughout, and 1 762 grow back out of zero — so 0 means collapsed, not "unset". Renders are 24/24 byte-identical on GP_TITLE (all 16 builds), GP_PAUSE_MENU and GP_OPTIONS, so nothing you rely on moves; the 126 elements the old code actually painted are all in GP_READY_ROOM.pak, already a no-go. structures/ui-rat-layout.md 🔴 "rest = last keyframe" is refuted as the fix. The splash has three sibling glows with identical structure and times, differing in one byte (a=212 vs a=255 at t=45); that rule would make anima_eff 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. structures/ui-resting-pose.md 🟡 The shift is still not adopted, now for a different reason: it flips this element to the visibly wrong answer, so it and a decision about plateau-less elements have to land together, and neither half has a capture to verify against. Default unchanged, experiment reachable via SYLPHEED_KF_TIME_SHIFT=1. What this means for you: the interpolation law is settled (linear, 2 units/frame); a multi-keyframe group's timing is not — do not expect a 2-frame hold where the game holds 83. 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 rate as the 27.6 fps measured during the loading splash. The competing 60 Hz reading is excluded: it needs the emulator at 47 % of real time while idling on a screen that costs ~5 draws per frame. A second, independent line agrees — the transition quad is declared black for 12 units (0.20 s under this conversion) and a capture measured the pure-black plateau at 0.170.23 s (screen-transitions.md). ⚠️ Still measured, not decoded: no field on the disc says "sixtieths of a second".

  • The paint order is derivable from the file. Each T8aD sprite header carries a u16 layer key at +0x0A (the upper half of the 32-bit word at +0x08 is zero in all 21 184 sprites on the disc). Paint order is that key, stable-sorted so equal keys keep declaration order; elements with no sprite (.prm primitives, .tbm) have no key and take an implied one — the backdrop and dim quads sort early, the screen-transition fade (pteff00.prm, pfeff00.prm) sorts last. decoded. Checked against five paint orders read off the running game, one of them (GP_SAVE_LOAD's slot-list header, 6 instances) exact and independent of the screens the rule was fitted to, and against a fresh EXTRAS capture. It reorders 341 of the disc's 965 builds, so it is not a no-op dressed as a rule. 🟡 The one residual: ties. Where two elements share a key the game sometimes paints them in an order nothing predicts — eight candidates refuted, including declaration order, RATC child order, keyframe times, resting X/Y and kind. Measured cost: on the three screens with ground truth it changes the blend of one element on one screen (a title glow). Take the stable sort and accept that.

  • Ⓑ returns to the title, and that title still works. Ⓐ on the Ⓑ-returned title opens the main menu — measured, with Ⓐ on the boot title as the control in the same run. (Only the attract-returned title is inert, which is an emulator- harness curiosity, not a port concern.)

  • Menu movement, measured off the running game. ⬆⬇ move one item per press and wrap at both ends (5-item main menu and 3-item EXTRAS both). ⬅➡ do nothing. Ⓑ goes up one level and restores focus to the item you came from; Ⓑ on the main menu returns to the title; Ⓑ on the title does nothing. Initial focus is not stable: four boots of the same script gave TUTORIAL, TUTORIAL, NEW GAME, NEW GAME. Do not hardcode it; pick one and say you picked it. All measured, none of it on the disc.

  • Each button's destination is measured; its GamePart id is not. NEW GAMEDIFFICULTY (EASY/NORMAL/HARD/BACK, opening on NORMAL) SELECT DATA — it does not hang; the run then hits the already-documented sub_823070B0 cache crash, which is not a menu problem. LOAD GAME → the save-slot list, TUTORIAL → the lesson list, OPTIONS → the settings menu, EXTRASGP_TITLE build 6, EXTRAS ▸ MISSION SELECT → the stage list. The GamePart ids (3, 25, 8, 5, 7) are the entries of the decoded id table whose names match the screens seen; that binding is authored, not measured.

  • A screen change is a fade through black. Each screen carries a full-screen black .prm quad that paints last (pteff00.prm / pfeff00.prm) whose keyframe group is the transition: black at T0, clear by T1, clear until T2, then back to black on exit. decoded, with a disc-wide check — and in GP_TITLE exactly the six screen builds carry it while the six overlays do not. The fade-in length is T1 T0 and is read from the file (0.87 s for EXTRAS, 0.97 s main menu, 4.08 s title). The fade-OUT length is not on the disc — the last keyframe has no time slot; measured ~0.4 s, twice. The black hold measures 0.170.23 s. ⚠️ Do not time the fade-in off a capture's brightness: the incoming screen's own element animations dominate it and run much longer than the quad.

  • The movie manifest names every boot-side video by role. dat/tables.pak entry 0x5b983a08: LOGO1LOGO4logo1.wmvlogo4.wmv (not on the disc — this is why the splash is a screen), ADVERTISE_MOVIEADV.wmv, STAFF_ROLLSYLPH_HD720p_8M-CBR_2ch.wmv, MS00AS00A.wmv (the new-game intro, 93.9 s, with subtitle + VOICE_S00A + a text overlay), MS01AS01A.wmv. decoded — and S00A.wmv is now also measured: matched off the running game at 0.961.000 with a strictly monotone playhead over 25 consecutive 0.5 s samples. It starts ~4.5 s after Ⓐ on the save slot. The boot intro and the attract movie are the SAME asset — there is no separate boot slot, and 15 of 19 captured attract frames match ADV.wmv with a monotonically advancing playhead ending at its full 137 s. One video, not two. The attract movie plays to its end; nothing cuts it short. A movie is skippable with a single Ⓐ. Measured: one tap ~45 s into the boot brought the title at ~57 s against a ~193 s no-input baseline over three boots, with Canary's own keystroke counter proving exactly one press was delivered — and the skipped-to title is fully functional (PRESS Ⓐ plate present, Ⓐ opens the main menu). What breaks the boot is hammering: 88 presses left a permanent black screen. One press is fine.

  • The menu's sound events are named on the disc. tables.pak's SOUNDS record carries 322 SE_* cues, and the low block is the UI vocabulary — named after the event: SE_UI_CURSOR (2), SE_UI_DECIDE (3), SE_UI_CANSEL (4), SE_UI_IMPOSI (5, the error), SE_UI_SUB_WIN_OPN/_CLS (8/9), SE_UI_SPLASH_IN/_OUT (12/13). decoded, full list in data/se-ui-cues.txt. They all live in one bank — BANK_SE is a single field naming Static.slb, and 0 of the 322 has its own FILES entry. 🟡 Which event fires which cue is a name match, not a measurement — strong, because these are the authors' own event names, but nobody has watched the game emit cue 2 on a d-pad press. The port is authoring it. The SE audio IS extractable — by playing it. (This corrects an earlier "cannot be extracted" on this page.) The disc carries no index: Static.slb has 0 RIFF/seek/WAVE and there is no XACT container anywhere (0 × XGSF/SDBK/WBND in 1.08 GB of sound.pak; no XACT string in the executable — those extensions are the authoring tool's). But Canary's --xma_param_probe=true logs every stream's head bytes, and searching them in Static.slb locates the wave exactly: d-pad move → 0x1ec0 (4 pkts / 8 192 B, 0.533 s); Ⓐ confirm → 0x5d6c0 (6 pkts / 12 288 B, 1.016 s); Ⓑ back → 0x0ec0 (2 pkts / 4 096 B, 0.344 s) — every cue the five screens need. Move and back reproduce with identical head bytes across two independent boots. Each matched at one offset only, and the first two are contiguous (0x0ec0 + 4096 = 0x1ec0) — the bank is a packed run of whole 2 048-byte packets with no delimiters, which is why nothing could be scanned for. ⬅ and ➡ play nothing distinct — no new stream on either, so leave them silent (the probe dedups, so this excludes a distinct invalid cue, not a quiet replay of an already-heard one). 🟡 The Ⓐ press both confirms and opens a screen, so whether its wave is the cue named SE_UI_DECIDE or SE_UI_SUB_WIN_OPN is not separated. ⚠️ The order is not cue-id order, so the index must be observed per cue, not counted. The slices decode: 0.533 s, 0.344 s and 1.016 s of mono 48 kHz audio with the attack-and-decay shape of UI blips, via tools/re-capture/slb_extract_wave.py — whose wrapper reproduces a known-good BGM_001 decode to the same 173.808875 s, so it is verified, not assumed.

  • The boot sequence is not data-driven — the port authors it. Four places were checked and the order is in none: config.ini's [SYSTEM] is empty, the movie manifest carries assets not transitions, the requested GamePart id lives only as a stack argument in flight (no persistent field, no literal store), and the string GP_ADVERTISE_DEMO has zero xrefs. A transition is a call with an id argument, chosen by code. 🟡 But the states have names, and the transition uses them. sub_821C6458, the title part's state function, calls sub_821CC860(…, "<NAME>", 0) with TITLE_SCREEN, TITLE_MENU and LOADING — the three states measured off the game, in the game's own words — and installs the result. So a transition is a call with a name argument, which is also why GP_ADVERTISE_DEMO has no xrefs: at this level the screen graph is name-keyed, not id-keyed. The argument is now decoded at 46 of that function's 48 call sites (28 distinct names). ⚠️ It is a generic name-keyed lookup, not a screen factory — its arguments include BG, BLACK, FADE, FILE, KEY, PAD, SOUND, GAMMA_RGB. An earlier version of this page claimed DIFFICULTY and EXTRA_MENU as corroborated screen names; neither is ever the argument, and only TUTORIAL_MENU survives. And the state machine itself is decoded: state = this+136, ten states dispatched through a jump table at 0x821C6498, with 18 transitions each a literal li/stw. States 0, 2, 8 install TITLE_SCREEN, TITLE_MENU, LOADING. 4 → 0 is the only edge back to the title, reached from 2 → 4 — which matches Ⓑ-returns-to-title as measured. Full graph in data/title-state-machine.txt. ⚠️ All of that is ONE PHASE. GamePart_Title dispatches on an outer phase field at this+132 (five values) before reaching any of it: phase 0 is the developer splash (sub_821C5690, the same function the corpus fingered independently), phase 4 is the title/menu machine. The ten states and eighteen edges above live inside phase 4 alone. State 4's edge conditions are an event codesub_821C6458's third argument. State 4 is the input-waiting state (reached straight after the menu is installed) and handles 6 of 26 events: 0 → title, 3, 5, 8, 25LOADING, 10 → state 5. 🟡 What the event numbers mean is not decoded — button id, menu row, or message id — so the port still takes the button→destination map from measurement. The one-to-title / four-to-loading shape matches the five-item menu with Ⓑ, but that is a count-match, not a mapping. The sequence itself is fully measured: splash → ADV.wmv → title + PRESS Ⓐ → (idle ~810 s → ADV.wmv in full → title) → Ⓐ → main menu, with Ⓑ from the main menu returning to the title.

  • config.ini picks the language, and that is what picks the EN/JP build. The disc's only config file (400 bytes, at the root; one find over the whole extract). Its [LANGUAGE] section maps the console's XC_LANGUAGE_* value to eng/jpn/deu/fra/esp/ita, defaulting to eng — that code selects GP_TITLE's English or Japanese build and the <lang>.pak families. decoded. Its [SYSTEM] section — which the file's own comment says holds what the game and every game part share — is empty, so the boot order is not in disc-side configuration at all.

  • Five GameParts are named but never registered. Pulling every RegisterToFactory<N, class silph::GamePart_X> diagnostic string binds 24 of the 29 ids to a C++ class (data/gamepart-class-ids.txt). Ids 1, 2, 16, 18, 28 have no registration site — including GP_ADVERTISE_DEMO (1), which agrees with the measurement that the attract loop is the title replaying ADV.wmv rather than a separate part. 🟡 an argument from a diagnostic string, not from the code. Also: 3 and 4 are both GamePart_SaveLoad — one part, two ids.

  • The GamePart id table — 29 entries at .rdata 0x820A1630, confirmed by the executable's own registration strings. This is the screen vocabulary; which button reaches which entry is Q4 and is not part of it.

  • The logo splash is a screen, not a video, and it renders. logo1logo4 are manifest-bound with no .wmv on the disc. The screen is two bundles in GP_TITLE.pak, each shipped twice: entries 10/13 the white SQUARE ENIX publisher logo, entries 11/14 GAME ARTS/SETA/studio anima. ⚠️ They are invisible to the default screen list/renderis_build rejects them for having no .rat child. Pass --all, which renumbers --build. So the first of the five screens does have a reference composite. And a capture: both halves edge-correlate to their renders at 0.91 and 0.98 at zero shift, each rejected by the other (0.03, 0.03). Its timing is DECODED, not just measured. The splash fades both ways and the bundles carry the keyframes: SQUARE ENIX [15 30 235 239 251 255], developer logos [15 30 190 194 206 210], each with an _eff glow child on [15 30 45]. Under 1 unit = 1/60 s that is a 0.25 s ramp in, a 3.42 s / 2.67 s hold, and a 0.33 s fade out — and a 10 fps capture measures ≈ 3.5 s / ≈ 2.4 s holds and ≈ 0.3 s fade-outs. The visible brightness overshoot on the way in is the _eff glow ramping after the logo, not a rendering artifact. Wall-clock: logos ≈ 0.44.7 s and ≈ 4.98.4 s, then ADV.wmv from ≈ 8.9 s. ⚠️ The cyan SQUARE ENIX at ≈ 9.5 s is the movie's opening, not a third splash screen.

  • Sprites carry their own labels. No font rendering or localisation is needed for this milestone. — and the localisation is already baked in: the Japanese screens are separate builds in the same pak, not a text swap.

Facts the port will trip over

  • ADV.wmv is WMV3 video + WMA Pro audio, 1280×720 at 30 fps, 137 s. Godot 4 plays only Ogg Theora natively. How to handle that is the port's decision, not ours — but it is not optional.
  • The disc holds 3.3 GB of video, and exactly two files are in scope: dat/movie/ADV.wmv (the boot intro and the attract loop — one asset) and dat/movie/S00A.wmv (the new-game intro, 93.9 s). Named from the movie manifest, decoded.
  • Static.slb over-declares its size by 616 768 bytes — it is the highest-offset entry in sound.pak and its size field is an allocation size. A reader must allow a short read there and only there.
  • Voice downmixes to mono, music does not. The left-channel downmix is correct for spoken lines and discards half a music mix.
  • A music bank is TWO STEMS THAT PLAY TOGETHER — do not concatenate. The "10 KB + 4.47 MB + 4.67 MB" reading was wrong: the 10 KB is the bank header, and a bank is exactly two waves of identical duration (32/32 banks on the disc). BGM_001's two are sample-synchronous — transient correlation peaks at lag 0.00 s over ±5 s, and both stop at the same millisecond, 167.663 s. Wave 1 is quieter, far more L/R-decorrelated and has almost no bass, so it reads as a surround-rear pair or a second intensity layer — 🟡 which of those is unsettled, and ChannelMask is 0x0002 on both, so the file will not say. Today's 347 s concatenation plays the piece twice, the second time as a bass-less stem. And it is not a seamless loop: BGM_001 fades out at 167.663 s and is followed by 6.15 s of silence, with no loop-point field identified. A menu loop is authored. The menu's music is BGM_103. The cue table cannot say — its BGM entries are numeric — but GamePart_Title's sub_821C5580 plays cue 1103, and BGM_103.slb's two declared waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA probe saw decoding at the main menu. Static code, disc census and runtime all agree. The port does not have to choose a track.
  • JNGL_001.slb does not decode. One bank in 9 519; its payload is not a whole number of XMA1 packets from any known data offset.

What is still open

Every question above is answered, so this is the honest residue rather than a work queue. None of it blocks the five screens. (Q1's seconds conversion was here until 2026-08-28 and is now settled.)

what why it is stuck
🟡 cue NAME → event binding (Q8) event→wave is measured for move/confirm/back; that the cursor's wave is the cue named SE_UI_CURSOR is still read off the authors' identifiers
the other ~319 SE cues (Q8) located one at a time by triggering them; only the three the menu needs have been done
🟡 the paint-order tie-break (Q3) Its COST is now measured and it is zero on all five port screens (2026-08-29) — the tied ptframe pairs share no ink, and the worst tie-break effect anywhere in GP_TITLE is Δ3/255. What stays open is only why the game orders ties as it does. Eight candidates refuted. 🔴 The cost was understated and is corrected 2026-08-29 — the port challenged it and was right. Over all 16 GP_TITLE entries: 5 use a measured order and carry no tie risk; the other 11 fall back to the derived order and 7 of them have overlapping ties, 24 pairs in total — 16 of those on the Japanese title (entry 7) alone, 2 each on EXTRAS EN/JP, 1 each on the four loading bundles. ⚠️ Overlap bounds where a wrong tie-break could show; nobody has measured how many actually change a pixel — ui-paint-order-derived-check.md · census
🟡 GamePart ids behind the buttons (Q4) the screens are measured; the ids are a name match onto the executable's class names
🟡 the boot transitions in code (Q6) both levels decoded — phase at this+132 (entry→2, 2→0, 2→3, 3→4, 4→2) and state at this+136 inside phase 4. Phase 0 = splash (LOGO), phase 2 = title + PRESS Ⓐ, phase 4 = menu. Unknown: what the event numbers mean
🟡 Ⓑ leaving the main menu (Q5) upgraded 2026-08-29 (later). The idle half of this objection is refuted: the main menu does not self-return for ≥ 60 s untouched, and the ~810 s idle belongs to the title. Ⓑ is delivered (Canary logs vk=5801) and is the only input in ≥ 100 s before the return, so the ordering is measured; the latency is not (a backlogged probe void). The footer point stands — the main menu is still the only screen not advertising Ⓑ — menu-navigation-semantics.md
🟡 which loading bundle is LOADING and which LOADING2 (Q2) the pair is identified — they are the loading screen, decoded from pgloading_* element names, and the executable names exactly two. What is open is only the assignment, and nothing observed maps a name to a bundle. ⚠️ The old row here said the pair was unidentified DELTASABER plates never seen running; that is withdrawn — a loading screen is not supposed to appear on the title path

(An earlier version of this table called the audio items blocked on "an emulator whose audio path can be observed". That was wrong — this build already has --xma_param_probe, and using it settled both.)

⚠️ A container caveat that bounds all of the above: the emulator has twice been killed mid-run with no crash line in its own log (at ~50 s and ~145 s), on a box sitting at ~1 GB free with swap exhausted. Dynamic experiments here have to fit in roughly two minutes of guest time, which is why several of these residuals are unfinished rather than unattempted.

The player's-eye map of the menus

docs/game/navigation.md is the screen-by-screen walk through the game from the chair — every label, what the cursor does, what each footer offers. It was filled in on 2026-08-29 from the committed oracle frames, and it is the page to read if you want to know what a screen looks like rather than how its bundle is laid out. Every there is a capture, and what is still is what no capture answers.

Reference data

Committed alongside the findings, so the port can be built without a disc in the loop during development:

  • sylpheed-cli screen info --build <n> GP_TITLE.pak — the element table, per build, with pivots, kinds, focus links, keyframes and resting poses.
  • sylpheed-cli screen render — the reference composite. When the port draws a screen, this is what it should be diffed against; where they disagree, one of them is wrong and the disagreement is worth reporting back.
  • docs/re/captures/ — framebuffer captures of the real screens, for anything that has to be checked against the game rather than against our renderer.