Compare commits

...

21 Commits

Author SHA1 Message Date
sylph-decoder
d71a74f938 re: index and cross-references for the record layout and the loading screens 2026-08-29 14:06:10 +00:00
sylph-decoder
2e04e24ce9 handoff: the keyframe record layout, and the loading screens are named 2026-08-29 14:05:19 +00:00
sylph-decoder
3e4864b7fa re: the DELTASABER plates are the loading screen, and the pak is packed EN-then-JP 2026-08-29 14:04:42 +00:00
sylph-decoder
43f61a8d93 re: the splash timing table was already reading the corrected pairing 2026-08-29 14:04:04 +00:00
sylph-decoder
5744f379b2 formats: a keyframe's time comes before its pose, and none of them was missing
The placement region is `frames` records of `{u32 time; 36-byte pose}` after an
8-byte header, so the time word PRECEDES the pose it belongs to. Our parser's
40-byte window opened at the pose, four bytes into the record, and then read the
word at its `+36` as that pose's time -- which is the NEXT pose's. Every pose
field was right; only the time association slipped by one.

Two things the corpus has carried for weeks are that off-by-one and nothing
else: "a group's data stops 4 bytes short of its final block's time slot", and
"the last keyframe carries no time". The group is not short (8 + frames*40 is
exact) and no time is missing -- the first pose's time is the lead-in word at
`header + 8` that `parse_placements` skipped without asking what it was.

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

  A  lead-in prepended to the shifted times is non-decreasing  13991/13991
  B  a non-zero lead-in is strictly below the next time         5058/5058
     control (another group's lead-in, same bundle)            70.9%
  C  multi-segment alpha ramp at a constant rate, corrected    857/1540
     the same, under the old reading                             0/1042

C is the one that cannot be argued with: interpolation between keyframes is
linear, and under the old reading not one multi-keyframe ramp on the disc comes
out at a constant rate.

Adoption is free on every static composite, which is what the corpus previously
declined it over. `SYLPHEED_KF_TIME_SHIFT=1` moved GP_TITLE build 7 by 13.1% of
its pixels because it left pose 0 untimed; with the lead-in restored, all 12
GP_TITLE builds render byte-identically, and across 217 builds in six archives
only two elements pick a different rest pose -- both times between two poses
that are equally invisible.

`SYLPHEED_KF_TIME_SHIFT` is gone; `SYLPHEED_KF_TIME_LEGACY=1` restores the old
reading for A/B work.

ui_header_time_disc needed one line: 546 bundles whose every group is a single
static pose now report max_time = 0 where they previously reported no time at
all. Excluding them, the result it guards strengthened -- the bound holds over
2 859 bundles instead of 2 313, still with zero violations.

Not established: the executable's own parser. Reach is written down.

docs/re/ui-keyframe-record-layout.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsxw1A9JseUw99Yw1ZRQzY
2026-08-29 14:01:39 +00:00
sylph-decoder
b7104c6668 Merge remote-tracking branch 'origin/main' into auto/no-disc-and-menu-captures 2026-08-29 13:24:03 +00:00
sylph-decoder
aafd7c1b6f Merge remote-tracking branch 'origin/main' into auto/no-disc-and-menu-captures 2026-08-29 13:18:09 +00:00
sylph-decoder
14fced07b6 re: I cannot measure this emulator's clock -- and that answers the port's 2%
The port put two of my pages against each other: settle->plate 2.135 s and one
focus-ring revolution 2.177 s, both a declared 120 units during a static hold,
2% apart against a 6 ms run-to-run agreement. Fair challenge.

The resolution is that the question assumes a stable wall clock. Same interval,
same container, same day: 2.138, 2.132, and 2.549 s -- a 19% swing, caused by
adding --log_ui_draws=true. The 2% is a fifth of that. The two pages were never
in conflict about the game; they are three readings of one declared quantity
through a clock that moves. What settles the quantity is the disc.

Wall clock cannot separate the hypotheses, so I tried to measure frames instead.
Both instruments are recorded as failures rather than published as numbers:

  * Canary's own [UI-CAP] counter -- the one that produced the corpus's 28.5 fps
    -- costs a third of the frame rate. 300 frames in 16.567 s = 18.11 fps on a
    screen that gives ~28 without it. That reclassifies 28.5 as a load-dependent
    lower bound; it does not overturn it.
  * A distinct-frame counter over the spinning ring FAILED its decisive control:
    15.88 fps against the game's own 17.59 in the same window, 10% low, so the
    ring does not change on every presented frame. Its static control also read
    2.63 instead of ~0. Dead, not tuneable, per METHOD.md.

The rule that follows, and it applies to everything I hand the port: a measured
interval landing near a round number of declared units almost certainly IS that
number of units. Ship the units.

Also recovered here, because the same question needed it: the static PPC route.
Four tools open /work/xenia-rs/sylpheed.db and nothing in this repository builds
it -- no disassembler, no PPC decoder, and default.xex is encrypted (zero
plaintext "GamePart"). Xenia decompresses the image at load, so dump_image.py
reads it out of guest memory and validates it against the corpus's own landmarks:
the 29-entry GamePart id table at 0x820A1630 and the Xbox 360 D3D runtime
strings. String search and table dumps work again; instruction-level work does
not, and the present interval I wanted is an immediate, not a string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 13:16:02 +00:00
sylph-decoder
f24304248c re: the plate delay was on the disc all along -- my instruction is refuted
The port caught this with arithmetic off the disc, and it was right: build 2 has
a keyframe group of its own, so "when build 4 has settled, wait 2.13 s, then
composite build 2" puts the plate at settle + 2.13 + 3.97 s. Confirmed build 2's
group here independently of their message: `ptbtn00.t32` reaches a=255 at t=238.

The reconciliation needs no free parameter. Both builds run on ONE clock, started
together, and the premise that fails is `rest.t`:

  rest.t is NOT when a screen settles. It is the last HOLD keyframe before the
  exit. ptlogo1 rests at t=251 and stops moving at t=42.

The title's visible build-in ends at t=118, where pteff01, pteff02.prm and
ptlogoall_eff end their ramps together. 238 - 118 = 120 units = 2.000 s, against
a measured 2.138 and 2.132. So the interval the two runs agreed on to 6 ms was a
DECLARED one and I handed over a wall-clock reading of it.

That reading is 6.7% long, and the corpus already knew why: 120 units in 2.135 s
is the game presenting at 28.06 / 28.14 fps against a nominal 30, and the idle
title was independently measured at 28.5 fps before these runs. Corroborated from
inside the same two runs -- first pixels -> settle is 1.643 s and 2.131 s, a 30%
spread, while settle -> plate is 2.138 and 2.132. Frames are dropped during the
build-in, not during the hold, which a change in the game's own timing could not
do.

So the port authors nothing here. What is unchanged: ScreenView still has to draw
two builds at once and the boot's end state is still not plate-free.

Not settled, and said so on the page: which reading of the keyframe times is
right (it moves the plate by 2 units and I cannot separate them from these
traces), and my settle landmark to better than +/-5 units.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:43:41 +00:00
sylph-decoder
f34d24941d formats: a music bank's third sub-wave was its own header
The port hit `sound_bank_riffs("BGM_103.slb")` returning three against a census
that says two, and refused to guess which to drop. It was our reader.

`to_xma_riffs`'s hybrid branch derives a leading packet stream's start as
`first_riff % XMA1_PACKET`. That is right only when the bank header is smaller
than one 2048-byte packet -- true of the voice banks the branch was written for
(1392/1468/1600/1728), false of a music bank, whose header is exactly five
packets. The modulus returned 0 and the whole 10 240-byte header was emitted as
sub-wave 0.

The header states its own length, so the guard needs no threshold: BE u32 0x800
at +0x18 with the bank id repeated at +0x00 and +0x20, header length in blocks at
+0x24. Disc-wide over sound.pak's 9 519 entries, 28 match at offset 0 -- every
music bank, ids 1001-1023 and 1101-1105 -- and on 28/28 the declared header ends
EXACTLY at the first RIFF. Zero have a gap, so a header and a leading packet
stream never coexist here; zero false positives among the other 9 491.

Controlled rather than argued: decoding the emitted region through the same
chain, on the same bank, in the same run gives 0.009 s of PCM where the bank's
real wave 0 gives 87.744 s against a declared 87.75. The region is also 99.1%
zero bytes. And the oracle had already said two -- the XMA probe at the main menu
saw exactly two streams, at BGM_103's two declared wave sizes.

BGM_106-109 are deliberately NOT in the 28: their entries start mid-bank, so they
have no header at offset 0 and their leading region is real audio. The
VOICE_D_453 recovery is untouched and its tests still pass, 10/10 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:30:16 +00:00
sylph-decoder
5fcc89be55 re: the PRESS-A plate arrives 2.13 s after the title settles, measured twice
The one number the port said decides a structural question on its side: whether
`ScreenView` has to draw two builds at once. It does. The boot title shows
build 4 alone, and 2.13 s after build 4 stops animating it composites build 2
over it. Two independent boots agree to 6 ms (2.138 / 2.132), which is under one
sample interval.

Measure from SETTLED, not from first pixels. "First drawn -> plate" is 3.78 s in
one run and 4.26 s in the other, because the build-in animation itself ran 1.64 s
and 2.13 s -- that spread is the emulator's frame pacing, and it is exactly the
kind of number that looks like a measurement.

Ruled out before believing it: that the plate was pulsing all along, too dim for
a thresholded glyph counter. The counter reads EXACTLY 154 -- the committed
no-plate title's own value -- for every frame of a plateau nearly one full pulse
period long, with zero variation, and the surface mean is flat to +/-0.03 across
it. A cycling overlay moves both.

Also settled, and also not:

  * the black hold between two screens is 0.14-0.30 s, which brackets the port's
    authored 0.17-0.23 s and the file's declared 12 units. Their constant stands.
  * the (A)->menu latency is STILL not available, and now the reason is known.
    Both runs freeze one frame for ~1.4 s at surface mean 26.626 -- agreeing
    between runs to six decimals, and reproduced in run 2 with stream restarts
    disabled, so it is not the capture path. It is a guest load stall: the (B)
    path, which loads nothing, has no freeze at all. Any figure from it would be
    an emulator load time.

Refutation attempt, recorded whether or not it survived: navigation.md's "the
title is not input-ready for about ten seconds, and even then (A) registers
roughly half the time". At 7.29 s and 7.28 s after the title settled, (A) was
accepted first press in both runs, as was (B) on the menu. n=2 only makes "half
the time" unlikely (p ~ 0.25); it contradicts the ten seconds outright.

And the standing red banner is withdrawn: the interactive title IS reachable in
this container, twice, with no pad input, in ~3.5 minutes. Why it changed is NOT
established -- this container came up with no Xenia storage root at all, so run 1
created a profile -- and that is written as a correlation for the next session to
test rather than as a cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:15:22 +00:00
sylph-decoder
3ee1a25f47 tools: a title timing probe that costs 8.7 ms a frame, not 1503
The four durations withdrawn yesterday were produced by a classifier costing
1503 ms/frame draining an 8 fps x11grab at 0.64 fps -- a backlog, which
preserves ordering and destroys durations. This is the instrument for retaking
them.

What makes it cheap: every committed capture aligns at exactly dy=0 dx=0
(five-screens-acceptance), so the +/-8 px offset search screen_match does at
full resolution is 25 ZNCCs buying nothing on this path. Decimate 4x, do one
ZNCC per reference. Measured 8.7 ms per frame including the glyph count -- 173x.

Controls, run before the measurement and not after it:
  * 9/9 content controls, including the two committed movie frames that are the
    class this oracle exists to reject;
  * 4/4 on the plate detector itself, which is a threshold on the green-glyph
    counter and so needs its own control (no-plate title 159, plate title 753,
    movie frames 0).

And three things learned from run 1, folded back in:
  * do NOT restart the stream once the measurement is under way. Run 1's restart
    landed 0.25 s after the (A) press and its stale frames straddled exactly the
    interval being timed;
  * press INLINE, not through pad.py's subprocess -- an interpreter start plus
    the 0.25 s hold sat between the press and the timestamp;
  * count the longest run of byte-identical surface means and report it. That is
    the freeze signature, and it is how run 2 showed the 26.626 hold is the
    guest rather than the capture path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:15:06 +00:00
sylph-decoder
dd4f30a79f tools: fade_quads could not run -- two pre-monorepo paths
`screen-transitions.md` cites `fade_quads.py` as the command behind its decoded
fade ramp, and the command had been dead since the monorepo migration: it read
regn_decode.py from /work/Syplheed-Reborn and defaulted its pak to
/work/sylph_extract, neither of which exists. Resolve the helper beside the
script and default the pak under $SYLPHEED_DISC, the way run-canary and
sylpheed-cli already do.

A cited command that no longer runs is a citation nobody can check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014voBspJ6kFncNErZJuZcLw
2026-08-29 12:15:06 +00:00
sylph-decoder
06c32a0fd4 Merge remote-tracking branch 'origin/main' into auto/no-disc-and-menu-captures 2026-08-29 11:53:43 +00:00
sylph-decoder
7a4a74f8d7 re: the main menu has no idle self-return -- and four of my own durations were a backlog
Two results and one retraction, all from the same session.

REFUTED: 'an ~8-10 s idle returns to the title' does not apply to the main menu.
Held untouched it stayed put for >= 60 s, correlation never leaving
0.9245-0.9249. That timer is real but belongs to the TITLE. It was the only
reason 'B leaves the main menu' was classed as authored, so Q5's B rule is
upgraded to measured-ordering: B is delivered (canary logs vk=5801) and is the
only input in >= 100 s before the return.

The PRESS (A) plate: the boot title presents build 4 WITHOUT the plate first --
green-glyph 154, against 159 on the committed no-plate capture and 753/977/1493
on plate titles -- and the plate arrives after. That is the port's third option.

RETRACTED: four durations taken the same day. classify_array costs 1503 ms per
frame; running it per frame against an 8 fps x11grab drained the pipe at
0.64 fps, so every classified frame was stale and increasingly so. It
manufactured 'plate 24.66 s after the title art', 'B->title 15.58 s', 'B->title
25.60 s' and 'A->menu 20.26 s'. The tell: a transition, a press and a fade do
not share a duration, and the two B figures GREW across a longer run.

A backlog preserves ordering and destroys durations, which is why the sequence
results above stand and every timing does not. The ring's period is unaffected
and that was checked, not assumed -- ring_period ran at 15.03 fps against a
requested 15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
2026-08-29 11:50:46 +00:00
sylph-decoder
724e06b134 re: the main menu's focus ring spins continuously -- period 2.18 s, measured
Answers the port's ask: ptbtneff01 is ANIMATED while a button is focused, not
drawn once and held. The existing page said 'the ring SPINS' from one frame at a
large angle, which is equally consistent with a static draw at a fixed angle.

No angle is quoted anywhere. The 360-bin angular estimator written for this
FAILED its own control -- a synthetic 30 deg came back as 0 deg (peak 0.596)
while 90/180/270 came back exactly -- so it was not used. What settles it needs
no angle: total annulus brightness is conserved to 0.4 % while individual
angular bins swing by 24, i.e. brightness moving AROUND the ring, which excludes
a pulse. The temporal-std map is a clean annulus, falling to ~1 both inside and
outside the stroke, which excludes positional jitter.

Period from the profile's autocorrelation: eight evenly spaced peaks, mean
2.177 s over nine revolutions. Even spacing is the internal check a drifting
instrument cannot pass. That is 120 units = 60 frames = 2.00 s at a true 30 Hz.

Also measured, same run: the ring is the ONLY moving thing on the settled main
menu -- temporal std is exactly 0.000 on every unfocused button, the labels and
the footer. And the ring's centre, located from the std map at game
(520.7, 339.7), matches the declared leaf offset's prediction of (521, 340).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
2026-08-29 11:50:32 +00:00
sylph-decoder
e2d2dd34f0 tools: a screen oracle that matches CONTENT, with movie frames as its controls
The statistics oracle (green/white/mean) cannot reject the class it exists to
reject. A frame of ADV.wmv with a bright green laser reads green 0.0018 /
white 0.086 / mean (53,67,76) -- the title's numbers -- and a probe built on it
tapped (A) into the movie, then waited 120 s for a menu that was never coming.

screen_match correlates against committed captures instead. Controls run before
it was ever used live: 8/8, and the negatives are COMMITTED movie frames rather
than scratch grabs -- an earlier list pointed at two scratch files and a later
run of the same probe overwrote one, failing the control for the wrong reason.

Two paths, both controlled. The exact path costs 1503 ms/frame, which is fine
offline and catastrophic in a live loop; fast=True decimates 4x for 38-75 ms and
agrees with the exact path to +/-0.005 on all eight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
2026-08-29 11:50:18 +00:00
sylph-decoder
9f34e6f7b6 re: withdraw the no-disc banner -- the disc is mounted, and both instruments that said otherwise were blind to it
The container was replaced at 11:07:38 UTC, 25 minutes after 3db09a3 wrote
"the decoder container has no disc". /disc is a real read-only bind mount
(device 2050 against /'s 92), 6.2 GB, 74 entries under dat/, and
`sylpheed-cli screen list` returns 12 GP_TITLE builds.

The reusable half is the instruments. `find / -xdev` cannot cross into a bind
mount on another device, so its "no ISO, no default.xex, no GP_TITLE.pak"
is what it returns whether or not the disc is there. `sylph-doctor` only ever
looks under /work and never consults $SYLPHEED_DISC, so it still reports
"no ISO" against a disc that works. Two instruments, one blind spot, read as
corroboration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNR5Y79D1T4bBr6gJQaWFP
2026-08-29 11:12:36 +00:00
sylph-decoder
c88f5e87a9 game: fill in the player's-eye navigation map from the committed oracle frames
Sections 1 to 3 were almost entirely open questions. Everything a capture in
docs/re/captures/ actually shows is now written down from the chair: what is on
each screen, what the cursor does, and what each footer offers.

Boot: the publisher plate is SQUARE ENIX, the developer plate is GAME ARTS /
SETA / studio anima, both still pictures the game draws rather than video, then
the cinematic -- one A skips it, 57 s to the title against 193 s without.

Main menu: the five labels and where each goes, the wrap rule, and the caveat
that initial focus varied across four boots. Carries the footer warning from the
measurement in the same push.

Submenus: NEW GAME's DIFFICULTY and SELECT DATA; LOAD GAME's slot carousel,
Details panel and its five-button footer; the six tutorial lessons in two
groups; OPTIONS' four categories; EXTRAS' three items; and MISSION SELECT with
the locked-list explanation for the cursor that would not move.

What stays open is marked open, and it is now the residue no capture answers
rather than the residue nobody looked at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UxPvE5cz7zekXBKi7Xw2r
2026-08-29 10:43:12 +00:00
sylph-decoder
6f4b4d8b4c re: two menu facts re-measured from the committed captures, no disc needed
Refutation attempt, per the adversarial duty. Target: this page's own row "B on
the main menu goes to the title". Chosen because it is one of only two Q5 rows
with an empty evidence cell, and because it is the only exit from the main menu,
so the port will build on it.

Whole-frame colour test for the pad-glyph discs. The main menu carries ZERO
red-B pixels anywhere in the frame, on two independent captures, while the same
unchanged detector finds 514 on EXTRAS and 518 on DIFFICULTY. The control passes
twice: the A glyph reads 438/438/440/438 across all four screens, so it is one
asset at one size and a B of that family could not have slipped under a
threshold. The main menu's legend is "Select / OK"; every submenu adds "Back".

The claim SURVIVES -- a legend is not behaviour, and an absent glyph cannot
refute an observed press -- but it is downgraded to amber. The observation is
uncited and single, it is now the only Q5 row the game's own text contradicts,
and there is a named confound: the title-side screens auto-return after ~8-10 s
idle, which looks exactly like what was described. Reading 0x828A690C while
pressing B would separate them in one run; that run needs a disc this container
does not have.

Second finding, same method. MISSION SELECT's "sixteen d-pad presses never left
Stage 01" was a LOCKED stage list, not a broken one. The labels have three
brightnesses, not two -- locked 104, unlocked 183, focused 254 -- and the
all-story-unlocked capture is the control that separates the lower two while
holding row 1 at an identical 254. On that save the cursor reaches Stage16 at the
bottom of a scrolled list. The list is 16 long and shows 8 at a time.

Regenerator committed beside the finding; it reads only files already in git.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UxPvE5cz7zekXBKi7Xw2r
2026-08-29 10:43:01 +00:00
sylph-decoder
3db09a3806 re: the decoder container has no disc -- root-caused to the volume migration
find / -xdev turns up no ISO, no default.xex and no GP_TITLE.pak; /exchange is
empty; SYLPHEED_DISC is unset; sylph-doctor agrees. Everything else in the
container is healthy -- xenia_canary is built, :98 is up, screenshot works,
Vulkan enumerates. There is simply no game to boot.

The cause is in the launcher. Before c58196b, sylph-agent bind-mounted the
human's working tree at /work, and the ISO and sylph_extract/ live in that tree,
so the disc arrived incidentally with the repository mount. c58196b replaced
that with `-v sylpheed-decoder-repo:/work` -- correct for the collision class it
was written for -- and nothing was added to replace the disc. sylph-decoder
still forwards SYLPH_ISO, but as a bare environment variable naming a host path
that does not exist inside the container. sylph-port mounts the disc explicitly,
so the one container that owns the disc and the oracle is the one without them.

This shuts the oracle, every sylpheed-cli call that names a pak, the disc-gated
tests, and -- because the XEX is on the disc -- the static PPC route too. It
does not touch the committed corpus, which is what this iteration worked from.

A second, smaller casualty of the same migration: no git identity is configured
anywhere, so the first commit in a fresh container fails outright. Both are
recorded with their fixes; neither is worked around, since the launcher runs on
the host and this container cannot restart itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UxPvE5cz7zekXBKi7Xw2r
2026-08-29 10:42:47 +00:00
43 changed files with 16701 additions and 67 deletions

12
Cargo.lock generated
View File

@@ -4618,6 +4618,18 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "sylpheed-export"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"image",
"serde",
"serde_json",
"sylpheed-formats",
]
[[package]]
name = "sylpheed-formats"
version = "0.1.0"

View File

@@ -377,6 +377,39 @@ pub fn leading_data_offset(first_riff: usize) -> usize {
first_riff % XMA1_PACKET
}
/// Length of the **bank header** when an entry begins with one, in bytes.
///
/// A music bank opens with a header the header itself sizes: big-endian, the
/// 2048-byte block size sits at `+0x18`, the bank id is repeated at `+0x00` and
/// `+0x20`, and `+0x24` is the header's length **in blocks** (5, i.e. 10 240 B,
/// on every music bank on this disc).
///
/// This exists because [`leading_data_offset`] derives a leading packet stream's
/// start as `first_riff % XMA1_PACKET`, which is only correct when the header is
/// SMALLER than one packet. A music bank's header is exactly five packets, so
/// the modulus returns 0 and the whole header was being emitted as a sub-wave —
/// a third "stem" on a bank the corpus documents as two
/// (`docs/re/structures/bgm-two-stems.md`).
///
/// Disc-wide over `sound.pak`'s 9 519 entries the signature fires on **28**, all
/// of them music banks (ids 10011023, 11011105), and on every one of the 28
/// the declared header ends **exactly** at the first `RIFF` — so no bank on this
/// disc has both a header at offset 0 and a leading packet stream. Zero false
/// positives on the 7 993 mid-bank windows, where the leading region IS real.
pub fn bank_header_len(slb: &[u8]) -> Option<usize> {
if slb.len() < 0x38 {
return None;
}
if slb[0x18..0x1c] != [0x00, 0x00, 0x08, 0x00] {
return None;
}
if slb[0x00..0x04] != slb[0x20..0x24] {
return None;
}
let blocks = u32::from_be_bytes(slb[0x24..0x28].try_into().ok()?) as usize;
blocks.checked_mul(XMA1_PACKET)
}
pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let first_riff = find(slb, b"RIFF", 0);
@@ -422,7 +455,15 @@ pub fn to_xma_riffs(slb: &[u8]) -> Vec<Vec<u8>> {
// bound to `VOICE_D_453`/`454`, i.e. precisely the broken ones — and
// ≤0.25 s to 66 of the rest. Callers clamp to the movie length anyway.
if let Some(ri) = first_riff {
let start = leading_data_offset(ri);
// A bank that carries its OWN header at offset 0 states how long it is,
// and on this disc that header always runs right up to the first `RIFF`
// — so there is no leading packet stream at all. Without this the
// modulus below returns 0 for a 5-packet header and the header itself is
// emitted as a sub-wave: `BGM_103.slb` came back as THREE waves against a
// census, an executable reference and a runtime XMA probe that all say
// two. It decodes to 0.009 s of PCM (the same chain returns 87.744 s for
// the bank's real wave 0), and it is 99.1 % zero bytes.
let start = bank_header_len(slb).unwrap_or_else(|| leading_data_offset(ri));
if ri > start {
if let Some(data) = slb.get(start..ri) {
if data.iter().any(|b| *b != 0) {

View File

@@ -111,12 +111,22 @@ pub struct Keyframe {
/// starts are negative.
pub x: i32,
pub y: i32,
/// Keyframe time, or `None` for the group's **last** frame.
/// The time at which this pose is reached.
///
/// A group's data stops 4 bytes short of its final block's time slot — that
/// word is already the next group's element index. Reading it anyway is
/// where a stray `time = 1869640736` comes from, and it silently corrupts
/// the max-dwell pick in [`Element::rest`].
/// ✅ Always `Some` since 2026-08-29. A placement group is an 8-byte header
/// followed by `frames` records of `{ u32 time; 36-byte pose }`, so the time
/// word **precedes** the pose it belongs to. Our block window starts at the
/// pose, so pose `k`'s time is the previous stride's `+36` word, and pose
/// 0's is the group's lead-in word at `header + 8`.
///
/// ⚠️ The old reading took `+36` as *this* pose's time. That left the final
/// pose — the end of every fade-out — untimed, and it is where the "a
/// group's data stops 4 bytes short of its final block's time slot" note and
/// the stray `time = 1869640736` both came from. There is no short group and
/// no missing word; the association was off by one.
/// See `docs/re/ui-keyframe-record-layout.md`.
///
/// `Option` is retained for the `SYLPHEED_KF_TIME_LEGACY=1` escape hatch.
pub time: Option<u32>,
}
@@ -493,8 +503,10 @@ fn mark_focused_states(elements: &mut [Element]) {
/// Read the placement region that follows the declaration table, filling in each
/// element's keyframe group.
fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
// Experiment gate, default off; see the `time` field below.
let shift_times = std::env::var("SYLPHEED_KF_TIME_SHIFT").as_deref() == Ok("1");
// Escape hatch for the pre-2026-08-29 reading, which mis-associated every
// keyframe time by one slot and could not time a group's final pose at all.
// See `docs/re/ui-keyframe-record-layout.md`.
let legacy_times = std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1");
let count = elements.len();
let mut order = Vec::with_capacity(count);
let mut pos = DECL_TABLE_AT + count * DECL_ENTRY;
@@ -507,7 +519,13 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
if idx >= count || frames == 0 || frames > 4096 {
break;
}
// Group header is (index, count) then one lead-in word; blocks follow.
// The region is `frames` records of 40 bytes, each `{ u32 time; 36-byte
// pose }`, after an 8-byte header — so the word at `pos + 8` is the
// FIRST pose's time, and each 40-byte stride's `+36` word is the time of
// the pose that follows it. Our block window is offset 4 bytes into the
// record (it starts at the pose), which is why the pose field offsets
// below are right while the times were off by one.
let first_time = be32(bundle, pos + 8);
let first = pos + 12;
// The region is packed so that the next group's header sits 4 bytes
// inside the last block — i.e. the group owns `frames * 40 - 4` bytes of
@@ -529,16 +547,17 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
tint: be32(bundle, blk + 24),
x: be32(bundle, blk + 28) as i32,
y: be32(bundle, blk + 32) as i32,
// Only a block wholly inside the group carries a time.
//
// ⚠️ Which block a time word BELONGS TO is under test — see
// `docs/re/ui-keyframe-time-unit.md`. Set `SYLPHEED_KF_TIME_SHIFT=1`
// to read `W[k-1]` as block `k`'s time ("the word is the time the
// NEXT pose is reached") instead of `W[k]`. Default is unchanged.
time: if shift_times {
(k >= 1).then(|| be32(bundle, blk - KEYFRAME + 36))
} else {
// Pose `k`'s time is the word that PRECEDES it: the group's
// lead-in word for `k == 0`, and the previous stride's `+36`
// otherwise. Every pose is timed; nothing is missing and nothing
// is special-cased. Checked disc-wide — see
// `docs/re/ui-keyframe-record-layout.md`.
time: if legacy_times {
(blk + 40 <= group_end).then(|| be32(bundle, blk + 36))
} else if k == 0 {
Some(first_time)
} else {
Some(be32(bundle, blk - KEYFRAME + 36))
},
});
}

View File

@@ -269,3 +269,62 @@ fn a_waves_declared_size_is_confirmed_by_the_next_seek() {
assert!(checked >= 30, "expected banks to check, got {checked}");
eprintln!("wave-boundary identity held for {checked} banks");
}
/// A **music** bank has no leading segment — the bytes before its first `RIFF`
/// are the bank header, and emitting them made `BGM_103` look like three stems.
///
/// The header sizes itself (`+0x24`, in 2048-byte blocks), and on every bank on
/// this disc that size lands exactly on the first `RIFF`. So the guard is not a
/// heuristic and has no threshold: if a bank states a header, believe it.
#[test]
fn a_bank_that_states_its_own_header_has_no_leading_segment() {
skip_without_disc!(root);
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
let mut with_header = 0usize;
let mut mid_bank = 0usize;
// Peek at the 56-byte header through the archive's flat data rather than
// decompressing 9 519 entries: `sound.pak` stores them uncompressed, and a
// full read of all of them is several GB (it OOM-killed the test runner).
for entry in snd.entries() {
let Some(head) = snd.data_at(entry.offset as usize, 0x38) else { continue };
match slb::bank_header_len(head) {
Some(h) => {
let b = snd.read(entry).expect("read a bank that states a header");
let ri = b.windows(4).position(|w| w == b"RIFF").expect("has a RIFF");
// Declared header ends exactly at the first RIFF: no gap, so
// nothing before it can be a packet stream.
assert_eq!(h, ri, "a bank header that does not end at its first RIFF");
with_header += 1;
}
None => mid_bank += 1,
}
}
// 28 music banks (ids 1001-1023, 1101-1105); the rest are mid-bank windows,
// where the leading region IS real and must keep being emitted.
assert_eq!(with_header, 28, "banks stating their own header at offset 0");
assert!(mid_bank > 9000, "mid-bank windows, got {mid_bank}");
eprintln!("{with_header} banks state a header; {mid_bank} mid-bank windows");
}
/// The regression itself: the menu's music bank is **two** sub-waves, and they
/// are the two the corpus names — matching the executable's `BGM_103` and the
/// two streams the runtime XMA probe saw at the main menu.
#[test]
fn the_menu_music_bank_is_exactly_two_sub_waves() {
skip_without_disc!(root);
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
for (name, sizes) in [
("BGM_103.slb", [3_876_864usize, 3_930_112]),
("BGM_001.slb", [4_466_688, 4_673_536]),
] {
let entry = snd.find_by_name(name).expect("bank present");
let b = snd.read(entry).expect("read");
let riffs = slb::to_xma_riffs(&b);
assert_eq!(riffs.len(), 2, "{name}: sub-wave count");
for (r, want) in riffs.iter().zip(sizes) {
let di = r.windows(4).position(|w| w == b"data").expect("data chunk");
let got = u32::from_le_bytes(r[di + 4..di + 8].try_into().unwrap()) as usize;
assert_eq!(got, want, "{name}: sub-wave payload size");
}
}
}

View File

@@ -120,8 +120,17 @@ fn header_0x08_against_the_keyframe_times() {
worst.push(format!("{pak}: max keyframe {max_time} > header {dur}"));
}
}
let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32;
*ratio.entry(r.min(30)).or_default() += 1;
// ⚠️ Bundles whose every group is a single static pose contribute
// `max_time == 0` and say nothing about whether `+0x08` is a length.
// Before 2026-08-29 they were invisible here, because the old keyframe
// time reading left a one-frame group's only pose untimed; the corrected
// record layout (`docs/re/ui-keyframe-record-layout.md`) gives it the
// group's lead-in time, which is 0. They are excluded rather than
// allowed to swamp the histogram's zero bucket — 546 of them do.
if max_time > 0 {
let r = ((max_time as f64 / dur as f64) * 10.0).round() as u32;
*ratio.entry(r.min(30)).or_default() += 1;
}
});
eprintln!("bundles with keyframe times and a non-zero +0x08: {animated}");
@@ -142,11 +151,18 @@ fn header_0x08_against_the_keyframe_times() {
assert!(animated > 0, "no animated bundles — the sweep is broken");
// MEASURED 2026-08-24. +0x08 bounds the keyframe times in EVERY one of the
// 2313 bundles that have both, and 444 of them reach it exactly. The
// MEASURED 2026-08-24, re-measured 2026-08-29 under the corrected keyframe
// record layout. +0x08 bounds the keyframe times in EVERY one of the 2 859
// bundles that have both (2 313 before the correction, which could not time
// a group's final pose at all), and 444 of them reach it exactly. The
// spread-out ratio histogram is what rules out the boring explanation: a
// large unrelated constant would bound everything too, but then the ratios
// would pile up near zero instead of peaking at 1.0.
//
// ✅ The correction STRENGTHENS this result rather than weakening it: 546
// more bundles now carry a readable last-pose time, and `over` is still 0 —
// i.e. the newly-visible times, which are the LATEST in every group, still
// do not run past the header's.
assert_eq!(over, 0, "a keyframe time runs past the header's +0x08");
assert!(exact > 400, "the bound is never attained — it may be unrelated");
let near_one = ratio.get(&10).copied().unwrap_or(0);

View File

@@ -0,0 +1,204 @@
//! A placement group's time word **precedes** the pose it belongs to.
//!
//! A 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
//! parser's block window starts at the *pose*, four bytes into the record, so
//! the word at a block's `+36` is the time of the pose that FOLLOWS it, and the
//! first pose's time is the group's lead-in word at `header + 8`.
//!
//! The old reading took `+36` as the block's own time. That is off by one, and
//! it is where two long-standing oddities came from: the group looked four bytes
//! short, and the final pose — the end of every fade-out — carried no time.
//!
//! Full argument and disc-wide census: `docs/re/ui-keyframe-record-layout.md`.
//!
//! Two checks here, both disc-wide:
//!
//! 1. **Every pose is timed, and the times are non-decreasing.** Under the old
//! reading the last pose has no time at all, so this cannot even be asked.
//! 2. **A monotone alpha ramp of three or more segments runs at a constant
//! rate.** Interpolation between keyframes is linear
//! (`docs/re/ui-keyframe-time-unit.md`), so a correct time assignment makes
//! multi-keyframe ramps come out at a constant d(alpha)/d(time). The old
//! reading achieves this on **zero** ramps on the whole disc.
use std::path::{Path, PathBuf};
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn disc_root() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
let p = PathBuf::from(p);
if p.join("dat").is_dir() {
return Some(p);
}
}
let default = Path::new(
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
);
if default.join("dat").is_dir() {
return Some(default.to_path_buf());
}
None
}
fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) {
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort();
for p in &paks {
let name = p.file_name().unwrap().to_string_lossy().to_string();
let Ok(arc) = PakArchive::open(p) else { continue };
for e in arc.entries() {
let Ok(bytes) = arc.read(e) else { continue };
if ratc::is_ratc(&bytes) {
f(&name, &bytes);
}
}
}
}
/// Maximal runs of strictly monotone alpha with at least `min_seg` segments.
fn monotone_ramps(alphas: &[i32], min_seg: usize) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let (mut i, n) = (0usize, alphas.len());
while i + 1 < n {
if alphas[i] == alphas[i + 1] {
i += 1;
continue;
}
let up = alphas[i + 1] > alphas[i];
let mut j = i + 1;
while j + 1 < n && ((alphas[j + 1] > alphas[j]) == up) && alphas[j + 1] != alphas[j] {
j += 1;
}
if j - i >= min_seg {
out.push((i, j));
}
i = j;
}
out
}
fn constant_rate(times: &[u32], alphas: &[i32], a: usize, b: usize) -> Option<bool> {
let mut rates = Vec::new();
for k in a..b {
let dt = times[k + 1].checked_sub(times[k])?;
if dt == 0 {
return None;
}
rates.push((alphas[k + 1] - alphas[k]).unsigned_abs() as f64 / dt as f64);
}
let mean = rates.iter().sum::<f64>() / rates.len() as f64;
if mean == 0.0 {
return None;
}
let worst = rates.iter().map(|r| (r - mean).abs()).fold(0.0, f64::max);
Some(worst / mean <= 0.06)
}
#[test]
fn every_pose_is_timed_and_the_times_are_ordered() {
let Some(root) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
};
if std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1") {
eprintln!("SKIP: the legacy time reading is selected");
return;
}
let (mut groups, mut untimed, mut out_of_order) = (0usize, 0usize, 0usize);
let mut examples: Vec<String> = Vec::new();
for_each_build(&root, |pak, bytes| {
let Some(build) = ui_layout::parse_build(bytes) else {
return;
};
if build.from_fallback {
return;
}
for el in &build.elements {
if el.keyframes.is_empty() {
continue;
}
groups += 1;
if el.keyframes.iter().any(|k| k.time.is_none()) {
untimed += 1;
if examples.len() < 8 {
examples.push(format!("{pak}: {} has an untimed pose", el.name));
}
continue;
}
let t: Vec<u32> = el.keyframes.iter().map(|k| k.time.unwrap()).collect();
if t.windows(2).any(|w| w[1] < w[0]) {
out_of_order += 1;
if examples.len() < 8 {
examples.push(format!("{pak}: {} times {t:?} descend", el.name));
}
}
}
});
eprintln!("placement groups: {groups}; untimed: {untimed}; out of order: {out_of_order}");
for e in &examples {
eprintln!(" {e}");
}
assert!(groups > 10_000, "expected the whole disc, saw {groups} groups");
assert_eq!(untimed, 0, "every pose must carry a time");
assert_eq!(out_of_order, 0, "keyframe times must not descend");
}
#[test]
fn multi_segment_alpha_ramps_run_at_a_constant_rate() {
let Some(root) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");
return;
};
if std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1") {
eprintln!("SKIP: the legacy time reading is selected");
return;
}
let (mut ramps, mut constant) = (0usize, 0usize);
for_each_build(&root, |_pak, bytes| {
let Some(build) = ui_layout::parse_build(bytes) else {
return;
};
if build.from_fallback {
return;
}
for el in &build.elements {
if el.keyframes.iter().any(|k| k.time.is_none()) {
continue;
}
let t: Vec<u32> = el.keyframes.iter().map(|k| k.time.unwrap()).collect();
let a: Vec<i32> = el
.keyframes
.iter()
.map(|k| ((k.fade >> 24) & 0xff) as i32)
.collect();
for (lo, hi) in monotone_ramps(&a, 3) {
if let Some(ok) = constant_rate(&t, &a, lo, hi) {
ramps += 1;
constant += usize::from(ok);
}
}
}
});
let share = 100.0 * constant as f64 / ramps as f64;
eprintln!("multi-segment alpha ramps: {constant}/{ramps} at a constant rate ({share:.1}%)");
assert!(ramps > 500, "expected the whole disc, saw {ramps} ramps");
// The old reading scores 0 of 1042. Half is a floor, not a target: the rest
// are genuinely shaped ramps, authored with keyframes that are not evenly
// spaced. Anything near zero means the time assignment has slipped again.
assert!(
share > 45.0,
"only {share:.1}% of ramps run at a constant rate — the time \
association has probably slipped (the old off-by-one scored 0%)"
);
}

View File

@@ -7,8 +7,9 @@ and wants to reach a mission — or who needs to script that journey.
Internal names (`ptbtn03`, `GP_LOAD`, build numbers) appear only as footnotes,
because they are how *we* find things, not what the game shows anyone.
**Status:** skeleton. Most of it is ❔ and is *meant* to be — this page exists to
be filled in by playing, not to look finished.
**Status:** filling in. §1§4 now carry what the committed oracle frames actually
show; what is still ❔ is what no capture answers. This page exists to be filled
in by playing, not to look finished.
> ## ⚠️ Fill this in from the real game
>
@@ -28,10 +29,20 @@ Confidence: ✅ seen in a capture · 🟡 inferred · ❔ unknown.
| # | What you see | What you do | What happens |
|---|---|---|---|
| 1 | Publisher and developer logos on black | nothing | plays through 🟡 |
| 2 | The opening cinematic | ❔ can it be skipped, and with which button? | ends into the title 🟡 |
| 3 | **Title screen** — the wordmark animates in, then a prompt | press **Ⓐ** | goes to the main menu ✅ |
| 4 | **Main menu** | — | see §2 |
| 1 | **SQUARE ENIX** in white on black, the two dots in red, `™` after it ✅ | nothing | fades on to the next logo |
| 2 | **GAME ARTS**, **SETA** and **studio anima** stacked on black ✅ | nothing | fades on into the cinematic |
| 3 | The opening cinematic | **Ⓐ** skips it ✅ | ends into the title |
| 4 | **Title screen** — the wordmark appears **first, with no prompt**; `PRESS Ⓐ BUTTON` fades in **2.13 s** later, above the 2006/2007 Square Enix copyright line, and then pulses about every 2.2 s ✅ | press **Ⓐ** | goes to the main menu ✅ |
| 5 | **Main menu** | — | see §2 |
Both logo screens are **still pictures the game draws**, not video — neither is a
`.wmv` on the disc. Captures:
[publisher](../re/captures/title-builds/live-splash-publisher.png) ·
[developer](../re/captures/title-builds/live-splash-developer.png) ·
[title](../re/captures/title-builds/live-title-press-a.png).
⚠️ **One Ⓐ skips the cinematic**, and it is worth a lot of time: the title
arrived at **57 s** with the skip against **193 s** without it ✅.
⚠️ **The title screen has two states that look identical.** The one that ends
the boot accepts Ⓐ. The one the attract loop returns to, after the game has sat
@@ -43,6 +54,21 @@ boot.
⚠️ **The title is not input-ready for about ten seconds** after it appears ✅.
And even then Ⓐ registers roughly half the time, with nothing yet found that
predicts which ✅ — budget retries.
🔴 **Refutation attempt, 2026-08-29 — both halves of that came out wrong on the
runs I could test.** Two boots, Ⓐ pressed **7.29 s** and **7.28 s** after the
title art settled (5.15 s and 5.15 s after the prompt appeared): **accepted both
times, first press, no retry**, and each went straight on to the main menu. Ⓑ on
the menu was then also accepted first press, both runs.
⚠️ Reach: **n = 2**, so "half the time" is only made unlikely (2/2 has p ≈ 0.25
under it), not excluded — but *"not input-ready for about ten seconds"* is
contradicted outright, because 7.3 s worked twice. Keep the retry budget; drop
the ten-second wait. Evidence:
[run 1](../re/data/plate-timing-run1.tsv) · [run 2](../re/data/plate-timing-run2.tsv) ·
[`title-plate-delay-measured.md`](../re/title-plate-delay-measured.md).
⚠️ **The prompt takes 2.13 s to arrive, measured twice (2.138 s / 2.132 s).**
Timed from the moment the wordmark stops animating, not from the moment it first
appears — the build-in itself varies by half a second between runs.
---
@@ -51,18 +77,57 @@ predicts which ✅ — budget retries.
Five options in a vertical stack, roughly centred, with a highlighted state on
the focused one.
> ✅ **The focused option carries a small ring to the left of its label, and the
> ring turns — continuously, about once every 2.2 s.** It has a bright head, so
> you can see it go round. It is the **only** thing moving on this screen once it
> has settled: the labels, the bracket and the footer are all completely still
> (temporal std exactly 0.000 over 20 s). Ⓑ
> [five frames, 4 s apart](../re/captures/focus-ring/ring-single-frames-4s-apart.png) ·
> [the measurement](../re/focus-ring-spin-measured.md)
| position | label | what it opens |
|---|---|---|
| 1 | ❔ | ❔ |
| 2 | ❔ | ❔ |
| 3 | ❔ | ❔ |
| 4 | ❔ | ❔ |
| 5 | ❔ | ❔ |
| 1 | **NEW GAME** | a **DIFFICULTY** prompt, then **SELECT DATA** |
| 2 | **LOAD GAME** | the save-slot list ✅ |
| 3 | **TUTORIAL** | the lesson list ✅ |
| 4 | **OPTIONS** | the settings menu ✅ |
| 5 | **EXTRAS** | a three-item submenu ✅ |
**To fill in, by looking:** read the five labels off the screen and say what each
one leads to. ❔ Which item is focused when the menu opens · ❔ does the cursor
wrap from the last item back to the first · ❔ does left/right do anything ·
❔ what B does here — back to the title, or nothing.
Read off [`live-main-menu.png`](../re/captures/title-builds/live-main-menu.png);
destinations off
[`q4-destinations.png`](../re/captures/menu-nav/q4-destinations.png) and
[`newgame-difficulty.png`](../re/captures/newgame-path/newgame-difficulty.png).
The screen is the title art gone dim, with the wordmark ghosted behind the list
and a bracket of glowing rule-lines drawn around it. The focused item is bright
white with a **spinning ring** to its left; the others are dim blue. Every item
carries a small dot-in-circle at the left end of its underline — that is on all
five all the time and is *not* the cursor.
**Moving around ✅**
| you press | what happens |
|---|---|
| ⬆ / ⬇ | one item, and it **wraps** at both ends |
| ⬅ / ➡ | nothing |
| Ⓐ | opens the focused item |
| Ⓑ | 🟡 back to the title — see the warning below |
**Which item is focused when the menu opens is not fixed.** Four boots of the
same harness opened on `TUTORIAL`, `TUTORIAL`, `NEW GAME`, `NEW GAME`. Do not
assume the top item, and do not assume the middle one either.
> ⚠️ **The main menu is the one screen whose footer does not offer Ⓑ.** It reads
> `⊙ : Select Ⓐ : OK` — every submenu adds `Ⓑ : Back`. Measured: **zero**
> red-Ⓑ glyph pixels anywhere in the frame, on two captures, with the same
> detector finding the glyph on `EXTRAS` and `DIFFICULTY` ✅.
> ✅ **But Ⓑ does leave it, and the objection that stood here is refuted
> (2026-08-29).** This page used to say the title "returns on its own after
> ~810 s idle", so an observer could not tell Ⓑ from the timer. That timer
> belongs to the **title**, not to this screen: the main menu was held untouched
> for **≥ 60 s** and never moved. Ⓑ is delivered and is the only input in ≥ 100 s
> before the return, so the ordering is measured — the *latency* is not
> ([the measurement](../re/menu-idle-and-b-2026-08-29.md)).
*Internals: `GP_TITLE.pak` build 5; buttons `ptbtn01``ptbtn05` top to bottom.*
@@ -73,26 +138,82 @@ wrap from the last item back to the first · ❔ does left/right do anything ·
One section each, in the shape of §2: what is on screen, what the cursor does,
what each choice leads to, and what a wrong choice shows you.
### Continue / Load ❔
❔ How saves are listed · ❔ what an empty slot looks like · ❔ the confirmation
prompt and where the cursor starts.
### New game ✅
Ⓐ on `NEW GAME` does **not** start a mission. It opens **DIFFICULTY**
`EASY` / `NORMAL` / `HARD` / `BACK`, opening focused on **NORMAL** ✅ — and Ⓐ
there opens **SELECT DATA**, a save-slot picker headed
`Current Storage: Dummy HDD` that asks you to choose a file for the auto-save.
Pick one and a movie plays ✅.
[DIFFICULTY](../re/captures/difficulty-screen.png)
### Load game ✅
A vertical list of numbered slots, **8 rows visible**, scrolling as a carousel —
one capture shows the order `19, 20, 01, 02, 03, 04` with `01` focused, so the
list runs past the end and back round to the start ✅. Each row shows
`Difficulty`, `Flight Time` and `Clear Ratio`; a **Details** panel to the right
gives `STAGE`, `Game Status`, `Points` and `Times Cleared`, and an empty slot
leaves every one of those blank ✅. `Current Storage: Dummy HDD` sits along the
top.
Its footer offers more than the other menus:
`⊙ : Select Ⓐ : OK Ⓑ : Back Ⓧ : Delete Ⓨ : Select Storage` ✅.
[capture](../re/captures/menu-nav/q4-destinations.png) (left panel)
❔ Still open: the overwrite / delete confirmation, and where its cursor starts.
Known: `title → LOAD GAME → slot 01 → YES → READY ROOM → TAKE OFF` reaches
flight ✅.
### Options ❔
❔ Which settings exist, what each ranges over, how a change is applied and
whether it needs confirming.
### Tutorial ✅
A list of lessons in two headed groups, with a one-line description shown on the
left for whichever is focused ✅ — e.g. `BASIC CONTROLS` reads
*"Learn how to move and attack"*. Opens focused on the first entry.
### Extras ❔
❔ What is in it — a movie theatre, a gallery, records? ❔ what is locked at the
start and what unlocks it.
| group | lessons |
|---|---|
| **Level 1** | `BASIC CONTROLS`, `HEADS-UP DISPLAY`, `RADAR` |
| **Level 2** | `SUPPLY AND SPECIAL MOVES`, `RADIO ORDERS`, `ADVANCED CONTROLS` |
| — | `BACK` |
### Mission select ❔
⚠️ **Stage select would not move**: sixteen d-pad presses never left Stage 01 ✅.
Whether that is because only one stage was unlocked, or because the list is
driven some other way, is unknown — worth settling early, since a scripted run
has to get past it.
[capture](../re/captures/menu-nav/q4-destinations.png) (middle panel)
### Options ✅ (one level in)
`GAME SETTINGS` · `CONTROL SETTINGS` · `SOUND SETTINGS` · `SCREEN SETTINGS` ·
`BACK`, opening focused on the first ✅.
[capture](../re/captures/menu-nav/q4-destinations.png) (right panel)
❔ Still open: what is inside each of the four, what each setting ranges over, and
whether a change needs confirming.
### Extras ✅
Three items: `MISSION SELECT` · `MOVIE THEATER` · `BACK`, opening focused on
`MISSION SELECT` ✅. The cursor wraps here too — it is a menu rule, not a
per-screen one ✅.
[capture](../re/captures/title-builds/live-extras.png)
`MOVIE THEATER` has never been opened.
### Mission select ✅ — and the "stuck cursor" is explained
The stage list on the left (**8 rows visible of 16**, with a scrollbar), a detail
panel showing the stage's name, a picture, `High Score` and `Best Time`, and a
**Wide Area Space Map** on the right with the named systems on it. The chosen
difficulty is printed top-right. Footer:
`⊙ : Select Ⓐ : OK Ⓑ : Back Ⓨ : Difficulty` ✅.
⚠️ **"Stage select would not move" — sixteen d-pad presses never left Stage 01 —
is now explained: the other fifteen stages were LOCKED** ✅. A locked row is
drawn *dimmer than an unfocused one*: measured, the labels sit at three distinct
brightnesses — focused **254**, unlocked **183**, locked **104** — and on a save
with the story unlocked the same rows read 183, with the cursor able to reach
**Stage16** at the bottom of the scrolled list.
[the measurement](../re/menu-navigation-semantics.md#-mission-select-the-cursor-was-stuck-because-the-stages-were-locked) ·
[locked](../re/captures/mission-select-stage01-only.png) ·
[unlocked](../re/captures/mission-select-all-story-unlocked.png) ·
[at Stage16](../re/captures/mission-select-ends-at-stage16.png)
So: if you are scripting a run, **check what the save has unlocked** before
concluding the list is broken. ❔ Whether the list wraps past Stage16, and
whether a locked row is skipped or simply unreachable, is not settled.
### Briefing and Ready Room ❔
❔ What you read, what you choose, and what finally launches the mission.

View File

@@ -23,12 +23,285 @@ There is no fourth kind. If a row says *measured* or *undecodable*, the port is
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`](../re/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 — builds 0/1 and 10/11 are the LOADING SCREEN (your ask #2)
**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 (builds 0/1) is the plain plate, the 10-element pair (builds 10/11) 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 (4/7, 5/8, 6/9) put English in the first.
So builds **0, 2, 4, 5, 6, 10 are English**; 1, 3, 7, 8, 9, 11 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`](../re/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`](../re/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`](../re/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`](../re/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`](../re/focus-ring-spin-measured.md) ·
[frames](../re/captures/focus-ring/ring-single-frames-4s-apart.png)
***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`](../re/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`](../re/title-plate-delay-measured.md) ·
[figure](../re/captures/ui-timing/plate-onset-two-runs.png) ·
[run 1](../re/data/plate-timing-run1.tsv) · [run 2](../re/data/plate-timing-run2.tsv)
* 🟡 **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.
## Status
| | Question | State | Answer / link |
|---|---|---|---|
| Q1 | keyframe time unit + ramp shape | ✅ answered, 🟡 one gap | ramp is **linear**; **2 units per rendered frame**; **`1 unit = 1/60 s`settled**, the idle title presents at 28.5 fps so the game is 30 Hz. 🟡 **The interpolation law is settled; the group TIMELINE for multi-keyframe elements is not**`palogo_gamearts` is still at full alpha 9 frames after its declared `a=32`, and its declared 80-frame fade-in never draws — [`ui-keyframe-time-unit.md`](../re/ui-keyframe-time-unit.md). ✅ **REPLICATED 2026-08-29 — for ANIMATION, read `+36` as the time the NEXT pose is reached.** Three elements across two screens: `palogo_gamearts` and `palogo_seta` hold full alpha for **83 frames** and `palogo_sqex` for **≥77**, where the current reading predicts **68** and the shifted one **80102**. The elements that cannot discriminate (the `_eff` glows, on which the linear law was measured) fit both. ⚠️ Our decoder still defaults to the other reading (`SYLPHEED_KF_TIME_SHIFT=1` to flip) because it changes `rest()` on one element — but that is an unsound fallback guessing either way, so **static rendering is unaffected and animation timing should use the shift** |
| 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`, 0/1 and 10/11 two unidentified `DELTASABER` plates — [`ui-title-build-map.md`](../re/ui-title-build-map.md) |
| 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`](../re/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`](../re/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 ✅ **0/1 and 10/11 are the LOADING screen** — two variants, plain and dressed, decoded from their `pgloading_*` element names (2026-08-29). 🟡 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`](../re/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`](../re/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 **2 overlapping tied pairs on `EXTRAS`** — [`structures/ui-paint-order-derived-check.md`](../re/structures/ui-paint-order-derived-check.md) |
| Q4 | button → GamePart | ✅ answered | **measured** which screen all **5** buttons open — `NEW GAME``DIFFICULTY``SELECT DATA`, not a hang. The **GamePart id is still a name match**, not a measurement — [`menu-navigation-semantics.md`](../re/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`](../re/menu-navigation-semantics.md) |
@@ -36,7 +309,7 @@ authored version can be deleted.
| 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`](../re/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`](../re/menu-audio-cues.md) |
| Q9 | video binding + playback rules | ✅ answered | **decoded** from the movie manifest: `ADVERTISE_MOVIE``ADV.wmv` (boot intro *and* attract are one asset), `MS00A``S00A.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`](../re/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`](../re/structures/bgm-two-stems.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`](../re/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`](../re/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`](../re/ready-room-probe.md) |
## Already settled — the port can rely on these today
@@ -900,7 +1173,8 @@ here until 2026-08-28 and is now settled.)
| 🟡 | **the paint-order tie-break** (Q3) | eight candidates refuted; costs one element's blend on one screen |
| 🟡 | **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 |
| | **builds 0/1 and 10/11**, the `DELTASABER` plates (Q2) | never seen anywhere in the boot path, the title-side screens or the attract loop. A mission load is the remaining candidate and this container kills runs before one completes |
| 🟡 | **Ⓑ 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`](../re/menu-navigation-semantics.md#-refutation-attempt-2026-08-29--the-main-menus-own-footer-does-not-advertise-ⓑ) |
| 🟡 | **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
@@ -912,6 +1186,15 @@ 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`](../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

View File

@@ -136,6 +136,7 @@ files, which is how the same ground got covered twice.
| [`structures/stage-mission-tables.md`](structures/stage-mission-tables.md) | The stage table set — phases, routes, sub-objectives and AI parameters | ✅ the table set and how the stage record reaches it, validated across; **`AIParams` disc-wide: 23 objects, one shared 34-profile roster (782 records), loader `sub_8233C368`; `Type`→field-count holds except the two `_Test` templates** |
| [`structures/texture-color-k8888.md`](structures/texture-color-k8888.md) | Texture colour interpretation — `k_8_8_8_8` (32bpp UI/HUD textures) | — |
| [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) | What a keyframe time is worth, and what shape the ramp has | ✅ CONFIRMED from the running game's own draw stream — the ramp is **linear** (a declared 15-unit fade lands on `round(255·k/15)` for all seven samples) and the animation clock advances **2 time units per submitted frame**. 🟡 the seconds conversion (`1 unit = 1/60 s`) rests on a measured 27.6 present-frames/second |
| [`ui-keyframe-record-layout.md`](ui-keyframe-record-layout.md) | A keyframe's time word comes **before** its pose — the placement record, decoded | ✅ CONFIRMED, **decoded**. A group is an 8-byte header then `frames` records of `{u32 time; 36-byte pose}`, so the time precedes the pose; the group's lead-in word at `header+8` is pose 0's time and **every** pose is timed. Disc-wide over 13 991 groups in 33 archives, each test with a control: lead-in prepended is non-decreasing **13 991/13 991**; a non-zero lead-in is strictly below the next time **5 058/5 058** (control 70.9 %); a multi-segment alpha ramp runs at a constant `dα/dt` **857/1 540** against **0/1 042** under the old reading. 🔴 Retires two long-standing corpus claims — *"a group's data stops 4 bytes short of its final block's time slot"* and *"the last keyframe carries no time"* — both of which were this off-by-one. Adoption is free: all 12 `GP_TITLE` builds render byte-identically, and over 217 builds only two elements pick a different `rest()` pose, both between equally invisible ones. ❔ the executable's own parser was **not** found (the 40/60 stride query is weak, not negative) |
| [`structures/ui-composable-bundles.md`](structures/ui-composable-bundles.md) | A screen build is not the only thing `compose` can draw | ✅ CONFIRMED by measurement over the disc, with the artifact to |
| [`structures/ui-focus-and-effect-elements.md`](structures/ui-focus-and-effect-elements.md) | `_eff` glow layers are not focused-state records | ✅ CONFIRMED by measurement over all 965 screen builds on the disc, |
| [`structures/ui-paint-order-key.md`](structures/ui-paint-order-key.md) | The paint order comes from a layer key in the T8aD sprite header | ✅ CONFIRMED on both screens whose paint order has been measured — |
@@ -147,17 +148,22 @@ files, which is how the same ground got covered twice.
| [`structures/unit-struct-runtime.md`](structures/unit-struct-runtime.md) | Runtime `Unit` struct (craft / vessel definitions) — read from live guest memory | — |
| [`structures/weapon-struct-runtime.md`](structures/weapon-struct-runtime.md) | Runtime `Weapon` / `Shell` structs — read from live guest memory | — |
| [`structures/xbg7-mesh.md`](structures/xbg7-mesh.md) | XBG7 — mesh geometry (inside XPR2 model containers) | — |
| [`capture-harness-status.md`](capture-harness-status.md) | Why the harness stops reaching the title — and the two instruments that could not see the disc | ✅ **the disc is BACK** (2026-08-29, container replaced at 11:07:38): `/disc` is a real 6.2 GB read-only mount and `screen list` returns 12 builds. The "no disc" section is withdrawn — and its two instruments were blind either way: `find / -xdev` cannot cross into a bind mount on another device, and `sylph-doctor` only ever looks under `/work`. Earlier sections: `screenshot` costs 10.8 s under xenia (92×), and `trace_gpu_stream` is a no-op in the Release build |
| [`title-crash-stl-tree.md`](title-crash-stl-tree.md) | The title-screen crash is an STL `map`/`set` erase on a bad iterator | ✅ CONFIRMED — the guest throws std::out_of_range from an STL |
| [`ui-paint-order-third-permutation.md`](ui-paint-order-third-permutation.md) | A third measured paint order — tool built and validated, screen not reached | ✅ the reader works and is CONFIRMED against both previously |
| [`ui-quad-class-foothold.md`](ui-quad-class-foothold.md) | The guest's UI quad class — a foothold found from the capture's vertex layout | 🟡 PROBABLE for the identification below (it is a static read, but |
| [`menu-navigation-semantics.md`](menu-navigation-semantics.md) | The title menu — how it moves, and where each button goes | ✅ measured: wraps both ends, Ⓑ restores focus, ⬅➡ inert; 4 of 5 destinations driven. 🟡 GamePart id is a name match, ❔ `NEW GAME` untested |
| [`menu-navigation-semantics.md`](menu-navigation-semantics.md) | The title menu — how it moves, and where each button goes | ✅ measured: wraps both ends, Ⓑ restores focus, ⬅➡ inert; all 5 destinations driven. 🟡 GamePart id is a name match. 🟡 **Ⓑ leaving the MAIN menu downgraded 2026-08-29** — uncited, and the main menu is the only screen whose footer omits Ⓑ (0 glyph px in frame vs 514/518 elsewhere). ✅ **MISSION SELECT's stuck cursor was a LOCKED stage list** — labels have three brightnesses, locked 104 / unfocused 183 / focused 254 |
| [`screen-transitions.md`](screen-transitions.md) | Between two screens — a fade through black, and where its timing lives | ✅ the fade quad's keyframe group is decoded (disc-wide: per-pak all-or-nothing; `GP_TITLE` = the 6 screens, not the 6 overlays); the ~0.4 s fade-OUT is measured, not on the disc |
| [`menu-audio-cues.md`](menu-audio-cues.md) | Menu audio — the event vocabulary is on the disc, the binding is not | ✅ `SE_UI_*` cue names/ids decoded and `BANK_SE``Static.slb` (0/322 in FILES); 🟡 event binding is a name match; ❔ `Static.slb` has no wave boundaries, so SE audio is not extractable |
| [`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md) | What the game reads at boot — `config.ini`, and which GameParts exist | ✅ `config.ini` selects the language (the disc's only config); ❔ its `[SYSTEM]` is empty so the boot order is not in config; 🟡 24/29 ids bind to a class, `GP_ADVERTISE_DEMO` is never registered |
| [`movie-binding.md`](movie-binding.md) | Which movie plays where — boot intro, attract loop, new-game intro | ✅ decoded from the movie manifest (`ADVERTISE_MOVIE``ADV.wmv`, `MS00A``S00A.wmv`); attract identity confirmed independently by frame matching; 🟡 skippability unsettled |
| [`ready-room-probe.md`](ready-room-probe.md) | S1 — the Ready Room probe: no-go, and not for the reason expected | ✅ it is 2D and enumerates (60 builds), but the pak is briefing/tactical-map content; and `kind == 0x3002` finds 0 buttons there |
| [`ui-title-build-map.md`](ui-title-build-map.md) | Which `GP_TITLE` build is which screen state | ✅ CONFIRMED for title / `PRESS Ⓐ` / main menu / `EXTRAS` against live captures; the archive is 8 screens × EN/JP, and "6/8/9 are submenus" is withdrawn |
| [`ui-title-build-map.md`](ui-title-build-map.md) | Which `GP_TITLE` build is which screen state | ✅ CONFIRMED for title / `PRESS Ⓐ` / main menu / `EXTRAS` against live captures; the archive is 8 screens × EN/JP, and "6/8/9 are submenus" is withdrawn**2026-08-29: the two "unidentified `DELTASABER` plates" are the LOADING screen** — builds 0/1 the plain variant, 10/11 the dressed one, decoded from their `pgloading_*` element names, and the executable (`sub_821C4EB0`, bytes checked in the image) names exactly five title-side screens: `TITLE_SCREEN`, `BUTTON`, `TITLE_MENU`, `LOADING`, `LOADING2`. 🟡 which loading bundle takes which of the two names is undecided. 🟡 the English member of a pair is the one in the first half of `GP_TITLE.p00` — 8/8 structurally, 3/3 where a capture can check it. |
| [`ui-title-paint-order-capture.md`](ui-title-paint-order-capture.md) | The title screen's paint order, measured from the guest's draw submissions | ✅ CONFIRMED — the order in which the running game paints the title |
| [`upstream-baseline.md`](upstream-baseline.md) | A stock-upstream baseline runs Stage 02 crash-free | ✅ CONFIRMED — upstream canary_experimental + only the pad |
| [`weapon-datasheet-runtime.md`](weapon-datasheet-runtime.md) | Weapon DATA SHEET — runtime capture (Route B) | 🟡 first dynamic capture, 2026-07-28. The Arsenal's Gallery Mode panel is a |
| [`xpr2-colour-check.md`](xpr2-colour-check.md) | XPR2 colours: channel order ✅ confirmed against the running game | — |
| [`focus-ring-spin-measured.md`](focus-ring-spin-measured.md) | The main menu's focus ring spins continuously — and how fast | ✅ **measured**: period **2.177 s** over 9 revolutions (8 evenly spaced autocorrelation peaks) = 120 units = 60 frames = 2.00 s at 30 Hz. A pulse is excluded — annulus total conserved to 0.4 % while per-bin brightness swings by 24. ✅ the ring is the **only** moving thing on the settled main menu (std exactly 0.000 elsewhere). 🔴 no angle is quoted: the angular estimator FAILED its own control (30° → 0°) |
| [`structures/slb-bank-header-not-a-wave.md`](structures/slb-bank-header-not-a-wave.md) | Why a music bank read as THREE sub-waves when the census says two | ✅ **decoded**: the third is the **bank header**, emitted by our own reader. `to_xma_riffs`'s hybrid branch derives a leading packet stream's start as `first_riff % 2048`, which is right only for a header shorter than one packet; a music bank's header is exactly **5 packets (10 240 B)**, so the modulus gave 0 and the whole header came back as sub-wave 0. The header states its own length at `+0x24` in blocks. Disc-wide over 9 519 `sound.pak` entries: **28** match the header signature at offset 0 (ids 10011023, 11011105), **28/28** end exactly at the first `RIFF`, **0** have a gap, **0** false positives — so a header at offset 0 and a leading packet stream never coexist. Decode control, same chain, same bank: the emitted region gives **0.009 s** against **87.744 s** for the real wave 0. Corroborated by the runtime XMA probe, which saw exactly two streams at the main menu. Fixed + 2 regression tests; the `VOICE_D_453` recovery is untouched (10/10 green) |
| [`title-plate-delay-measured.md`](title-plate-delay-measured.md) | How long the boot title shows build 4 before the `PRESS Ⓐ` plate | ✅ **decoded after a refutation**: build 2 and build 4 run on **one clock started together**, and the plate's own `ptbtn00` reaches `a=255` at `t=238`; the last build-in ramp ends at `t=118`, so the interval is a declared **120 units = 2.000 s**. 🔴 The instruction that shipped first — "wait 2.13 s after build 4 settles" — was **refuted by the port** with disc arithmetic and is corrected in place; 🔴 `rest.t` is **not** when a screen settles (it is the last hold keyframe before the exit: `ptlogo1` rests at `t=251` and stops moving at `t=42`). ⚠️ The wall-clock 2.13 s is 6.7 % long because Canary presents at **28.06 / 28.14 fps** against a nominal 30, matching the corpus's independent **28.5 fps**; author the 120 units. ✅ **measured**, two independent boots: **2.138 s** and **2.132 s** from the frame build 4 settles (glyph = its no-plate 154, motion → 0). Agreeing to **6 ms**. So the boot title's end state is **not** plate-free and a compositor must draw **two builds at once**. ⚠️ Measure from *settled*, not from first pixels — "first drawn → plate" is 3.78 s vs 4.26 s across the same two runs, because the build-in animation's own duration varies with emulator frame pacing. Plate pulse re-measured at 2.12/2.19/2.34/2.31 s (mean 2.24), replicating the corpus's ≈2.3 s. ✅ black hold between screens bracketed at **0.140.30 s**, consistent with the declared 12 units. 🔴 the Ⓐ→menu latency is still **not** available: both runs freeze one frame for ~1.4 s at surface mean **26.626** — agreeing between runs to 1e-6, and reproduced with stream restarts disabled — which is a guest **load stall**, not the capture path. Probe: 8.7 ms/frame, 7.97/7.98 fps against a requested 8, controls 9/9 + 4/4 |
| [`menu-idle-and-b-2026-08-29.md`](menu-idle-and-b-2026-08-29.md) | The main menu does not idle back to the title — and four durations that were a pipeline | ✅ **refuted**: no self-return in **≥ 60 s** untouched; the ~810 s idle belongs to the **title**. 🟡 Ⓑ→title ordering measured, latency not. 🔴 `classify_array` at **1503 ms/frame** drained an 8 fps stream at 0.64 fps and manufactured four latencies (24.66 s / 15.58 s / 25.60 s / 20.26 s) — all withdrawn; a backlog preserves ordering and destroys durations |

View File

@@ -617,3 +617,58 @@ neighbourhood, not just the line.
`10 144 of 10 148 references resolve`) → **withdrawn; it is on the disc.** It is
the `GP_STAGE_CLEAR` child the same scan named `8AX`. With the name decoded the
count is **10 148 of 10 148**. [`ratc-child-names.md`](structures/ratc-child-names.md)
## UI timing (2026-08-29)
* "a screen has SETTLED at its `rest.t`" → **refuted.** `rest.t` is the last
*hold* keyframe before the exit, not the end of motion. Build 4's `ptlogo1`
rests at `t=251` and stops moving at **`t=42`**; the title's visible build-in
ends at `t≈118`, where `pteff01`, `pteff02.prm` and `ptlogoall_eff` end their
ramps together. Believing `rest.t` put a port's plate 3.97 s late —
[`title-plate-delay-measured.md`](title-plate-delay-measured.md).
* "the `PRESS Ⓐ` plate is composited a measured 2.13 s after the title settles,
and the port should author that" → **the measurement stands, the instruction
was refuted by the port.** Build 2 has a keyframe group of its own; both builds
run on **one clock started together** and the plate's declared `t=238` supplies
the timing, so nothing is authored. `238 118 = 120 units = 2.000 s`, of which
2.13 s was a wall-clock reading stretched by Canary presenting at ~28.1 fps.
⚠️ General lesson: **a wall-clock duration off this emulator is ~6 % long**, so
a measured interval that lands near a round number of units probably *is* that
number of units.
* "a music bank has three sub-waves" → **refuted; it was our reader.** The third
is the bank header, emitted because `to_xma_riffs` derived a leading packet
stream's start as `first_riff % 2048` — valid only for a header shorter than
one packet. 28/28 disc-wide —
[`structures/slb-bank-header-not-a-wave.md`](structures/slb-bank-header-not-a-wave.md).
## The oracle harness and the container (2026-08-29)
* "the decoder container has no disc" → **refuted the same day.** The container
was replaced and `/disc` is a real 6.2 GB read-only mount. Worse, both
instruments behind the claim were blind to the answer either way:
`find / -xdev` **cannot cross** into a bind mount on another device, and
`sylph-doctor` only checks `/work` and never `$SYLPHEED_DISC`. "sylph-doctor
agrees" was two instruments sharing one blind spot.
→ To test for the disc, ask the variable that names it:
`sylpheed-cli screen list "$SYLPHEED_DISC/dat/GP_TITLE.pak"`.
* "the main menu returns to the title on its own after ~810 s idle" → **refuted.**
The menu sat untouched for **≥ 60 s** without moving (correlation never leaving
0.92450.9249). The ~810 s idle is real but belongs to the **title**. This was
the only reason "Ⓑ leaves the main menu" was classed as authored.
* "whole-image statistics (green / white / mean) can tell the title from the
attract movie" → **refuted.** A frame of `ADV.wmv` with a bright green laser
reads green 0.0018 / white 0.086 / mean (53,67,76) — the title's numbers. A
probe built on it tapped Ⓐ into the movie and waited 120 s for a menu that was
never coming. → Correlate against a committed capture instead, and keep movie
frames as the negative controls.
* "a 360-bin angular cross-correlation can measure the focus ring's rotation
angle" → **refuted by its own control**: a synthetic **30°** rotation of a live
frame came back as **0°** (peak 0.596), while 90/180/270° came back exactly
(peak 1.000) — it only resolves exact pixel permutations. No angle was quoted;
the spin was established from brightness conservation instead.
* "a latency read off a classified `x11grab` stream is a duration" → **refuted.**
At 1503 ms per classification against an 8 fps stream the consumer ran at
0.64 fps, so frames were stale and increasingly so. Four "durations" died with
it. The tell was that a screen transition, a button press and a plate fade all
came out at ~2025 s. → A backlog **preserves ordering and destroys
durations**; check consumed-fps against requested-fps before quoting a time.

View File

@@ -1,3 +1,62 @@
# ✅ WITHDRAWN 2026-08-29 (later the same day) — the interactive title IS reachable here, twice, with no pad input
**This banner supersedes everything below it about the title being unreachable,
and it supersedes the 🔴 "Emulator-side questions are blocked" section of
[MISSION](../port/MISSION.md).** Everything below is kept because the harness
defects it diagnoses were real and the fixes are in use; what it concluded about
the *game* is now refuted by measurement.
**Two consecutive boots reached the interactive title, with the `PRESS Ⓐ BUTTON`
plate, without a single pad press before it:**
| | run 1 | run 2 |
|---|---|---|
| plate on screen at | **205.4 s** into the probe | **218.4 s** |
| pad input before that | **none** | **none** |
| Ⓐ then reached the main menu | ✅ | ✅ |
| Ⓑ then returned to the title | ✅ | ✅ |
Full per-frame traces, 8 fps, 1783 and 1886 frames:
[`data/plate-timing-run1.tsv`](data/plate-timing-run1.tsv) ·
[`data/plate-timing-run2.tsv`](data/plate-timing-run2.tsv). The measurement they
were taken for is [`title-plate-delay-measured.md`](title-plate-delay-measured.md).
So the standing negative — "three runs, two locales, two launch paths, ~35
minutes of emulator time, no interactive title" — does not hold in this
container today. **The attract loop is simply passed through in ~3.5 minutes and
the title follows.**
## ❔ What changed is NOT established, and I am not going to guess it
What is different about this container, stated as facts rather than as a cause:
* it came up with **no Xenia storage root at all** — no
`~/.local/share/Xenia`, so no profile, no `xconfig.settings`, and no shader
cache. The earlier runs signed in a profile that already existed.
* run 1 therefore had to create one, with canary's own
`--create_profile_if_none=Decoder`. Run 2 signed in the profile run 1 made
(`B13EBABEBABEBABE`).
* the launch was otherwise `boot_menu.sh`'s, minus `skip_intro.sh` — this
measurement had to leave the title untouched, so nothing tapped Ⓐ at all.
⚠️ **A cold profile is a correlation across two runs, not a cause.** It is
written down so the next session can test it directly (delete the storage root,
boot, compare) instead of re-deriving that the title is reachable.
## 🔵 What this unblocks
* the **Japanese-locale capture** that MISSION parks as "🟡 needs one more run":
the mechanism (`set_console_language.py ja`, `user.language` at file offset
`0x912`) is in place, and the reason it was parked — *the title never
appears* — is gone. ⚠️ Note the storage root is new, so `xconfig.settings` has
been recreated and the byte offset should be re-located by its three landmarks
rather than assumed.
* the two items MISSION lists as emulator-blocked: the gamma control behind
[tone curve](structures/ui-render-tone-curve.md), and separating `8AX` from
`ptbase` in [8AX](structures/ui-8ax-fullres-background.md).
---
# 🔴 Why the boot harness stopped reaching the title — `screenshot` costs 10.8 s
**Status:****diagnosed, with a control.** Four consecutive runs on
@@ -362,3 +421,173 @@ The gamma run's flags plainly took effect — that run is where
`VdGetCurrentDisplayGamma` was captured — while its dump showed the file's
values. So the dump reflects the config file and cannot confirm or refute a
command-line override.
---
# ✅ 2026-08-29 (later) — the disc is back, and the section below is withdrawn as CURRENT status
Kept for its history, not as a live claim. The container was replaced: PID 1
here started at **11:07:38 UTC**, 25 minutes after commit `b9aca6a` wrote the
section below at 10:42, and the replacement has the disc mounted.
| check | result |
|---|---|
| `/proc/mounts` | `/dev/sda2 /disc ext4 ro,relatime` — a real bind mount |
| device | `/disc` is device **2050**; `/` is device **92** |
| size | 6.2 GB, 74 entries under `dat/`, `default.xex` = 3 497 984 B |
| ISO | `/iso/game.iso`, 7 835 492 352 B |
| end to end | `sylpheed-cli screen list /disc/dat/GP_TITLE.pak` → 12 builds, element/sprite counts matching the committed build map |
⚠️ **Two instruments would have said "no disc" either way, and both are still
in place.** This is the reusable lesson, and it is worth more than the
resolved incident:
* **`find / -xdev` cannot see `/disc`.** `-xdev` refuses to cross a filesystem
boundary; `/disc` is on a different device from `/`. The withdrawn section's
headline measurement — "no ISO, no `default.xex`, no `GP_TITLE.pak` anywhere"
— is what that command returns **whether or not the disc is mounted**. It had
no reach over the question it was used to answer.
* **`sylph-doctor` never checks `$SYLPHEED_DISC`.** Its two disc lines are
`find /work -maxdepth 2 -iname '*.iso'` and `[ -d /work/sylph_extract/dat ]`
(lines 7982). With the disc at `/disc` it reports "no ISO under /work" and
"no extracted disc — Reborn disc tests will SKIP" — as it does right now,
against a working disc. "`sylph-doctor` agrees" was two instruments sharing
one blind spot, not corroboration.
**To check for the disc, ask the variable that names it**: `ls "$SYLPHEED_DISC/dat"`,
or `sylpheed-cli screen list "$SYLPHEED_DISC/dat/GP_TITLE.pak"`, which fails
loudly and cheaply.
# 🔴 2026-08-29 — the disc is not in the decoder container at all *(WITHDRAWN — see the section immediately above)*
**Status:****diagnosed, root-caused in the launcher.** This supersedes every
"the emulator did not reach the title" entry above as the *current* reason the
oracle is unavailable: there is no game to run.
## The measurement
| looked for | result |
|---|---|
| `find / -xdev -iname '*.iso'` | **0** |
| `find / -xdev -iname 'default.xex'` | **0** |
| `find / -xdev -iname 'GP_TITLE.pak'` | **0** |
| `$SYLPHEED_DISC` | **empty** |
| `/work/sylph_extract` | does not exist |
| `/exchange/files` | **empty** |
`sylph-doctor` agrees and says so in its own words:
```
── project ──
✖ /work/xenia-canary not mounted
✖ /work/Syplheed-Reborn not mounted
! no ISO under /work — run-canary needs SYLPH_ISO
! no extracted disc — Reborn disc tests will SKIP
```
Everything else is healthy: `xenia_canary` is built and present, display `:98`
is up, `screenshot` works, Vulkan (llvmpipe) enumerates, cargo and the python
stack are fine. **The emulator has no disc to boot.**
## The cause — the volume migration, and a mount nobody replaced
Before [`06676d3`](#) the launcher bind-mounted the human's working tree:
```
-v "$PROJECT:$PROJECT"
-v "$PROJECT:/work"
```
The ISO and `sylph_extract/` live in that tree, so the disc arrived **incidentally
with the repository mount**, and `run-canary`'s `find "$PROJECT_DIR" -maxdepth 2
-iname '*.iso'` found it.
`06676d3` replaced that with the agent's own clone in a named volume —
```
-v "sylpheed-decoder-repo:/work"
```
— which is the right fix for the collision class it was written for, and it
removed the disc along with the working tree. **Nothing was added to replace
it.** The launcher still forwards
```
[ -n "${SYLPH_ISO:-}" ] && _out+=(-e "SYLPH_ISO=$SYLPH_ISO")
```
but that is an **environment variable with no bind mount behind it** — it names a
host path that does not exist inside the container, so it cannot help.
**The port container does not have this bug.** `docker/port/sylph-port` mounts
the disc explicitly:
```
_out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc")
```
So the one container that *owns* the disc and the oracle is the one container
without them.
## Reach of the negative
Whole-filesystem, single pass, `-xdev` per mount, three independent names (the
ISO, the executable, a pak the corpus names constantly). The exchange volume is
empty, so the disc is not arriving by `share` either. This is not "I looked in
the usual place".
## What it blocks — everything disc-side and everything dynamic
* the **oracle** — no boot, no capture, no `run-canary`;
* every `sylpheed-cli` invocation that names a pak — `screen list`, `screen info`,
`screen render`, `pak textures`;
* `build-reborn test` — the disc-gated tests self-skip, and per MISSION a green
run then means almost nothing. (`build-reborn` is *also* pointing at
`/work/Syplheed-Reborn`, a path the monorepo no longer has.)
* **static RE of the executable** — the XEX is on the disc, so the whole
PPC-disassembly route is shut too, not just the dynamic one.
## What it does not block
The committed corpus. `docs/re/captures/` is 99 MB of oracle frames and
`docs/re/data/` 2.5 MB of extracted tables, both in git — enough to re-measure
against captures, which is what this iteration did instead.
## 🔵 For the human — the one-line fix
Add a disc mount to `docker/decoder/sylph-decoder`, the way `sylph-port` already
has one:
```bash
[ -d "$DISC" ] && _out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc")
[ -f "$SYLPH_ISO" ] && _out+=(-v "$SYLPH_ISO:/disc.iso:ro" -e "SYLPH_ISO=/disc.iso")
```
Recorded rather than worked around, per *do not improvise around a blocker*
and **not attempted**, because the launcher runs on the host and this container
cannot restart itself.
⚠️ `sylph-doctor` reports the missing ISO as `!` (a warning) rather than `✖`. For
the decoder that is not a warning: it is the difference between having an oracle
and not having one.
### A second, smaller consequence of the same migration — no git identity
`git commit` in a fresh decoder container fails with *"Author identity
unknown"*: nothing in the image, the entrypoint or `sylph-decoder` sets
`user.name` / `user.email`, and the old bind mount used to bring the human's
`.git/config` along with the tree.
Set locally, per iteration if the volume is recreated:
```bash
git config --local user.name "sylph-decoder"
git config --local user.email "fabian@diekaulbachs.de"
```
⚠️ `push-work`'s header warns at length against `git config --local`, because
the credential helper it wrote there leaked a container-only path onto the host.
**That warning no longer applies to identity**: `/work` is a private named
volume now, not a shared bind mount, so nothing written to its `.git/config`
can reach a host checkout. The credential helper is still applied per-invocation
with `-c`, and should stay that way.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,14 @@
paks scanned : 33
placement groups : 13991
A. lead-in prepended to the shifted times is non-decreasing
13991/13991 = 100.000%
B. non-zero lead-in is strictly less than the next time
5058/5058 = 100.000%
control (another group's lead-in, same bundle): 35837/50580 = 70.852%
gap to the next time, most common: [(10, 2076), (1, 2022), (30, 116), (40, 80), (12, 78), (90, 78), (149, 78), (20, 78)]
C. constant d(alpha)/d(time) across a multi-segment ramp
corrected (time precedes pose): 857/1540 = 55.649%
old (+36 is own time) : 0/1042 = 0.000%

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
# ✅ The main menu's focus ring spins continuously — period **2.18 s**, measured
**Status:****measured** (not on the disc as a period; the disc declares the
ramp, the running game supplies the rate). Taken 2026-08-29 against Xenia Canary
with the disc mounted at `/disc`.
**Question this closes:** the port asked whether `ptbtneff01` — the 42×46 ring on
the focused button — is *animated* while a button sits focused, or drawn once and
held. It had shipped the ring at 0° and marked that known-wrong.
[`structures/ui-button-focus-record.md`](structures/ui-button-focus-record.md)
already said "the ring SPINS" from **one** frame showing it at a large angle;
that is consistent with a continuous spin *and* with a static draw at a fixed
angle, so it did not answer the question asked.
## What the ring actually does
Five single frames from one run, 4 s apart, focus held on `TUTORIAL` throughout:
![five frames](captures/focus-ring/ring-single-frames-4s-apart.png)
The ring carries a bright head, and the head is at a different angular position
in every frame. It is still moving 16 s in, so it does **not** ramp once and
stop.
⚠️ **The 20 s mean of the same run is a uniform circle**
([`ring-20s-mean-uniform.png`](captures/focus-ring/ring-20s-mean-uniform.png)) —
that is the spin smearing itself out, and it is why an averaged frame must never
be read as a single frame. A human looking at the live game sees the head; the
average does not have one.
## The measurement, and why it is not an angle
🔴 **No angle is estimated anywhere.** The corpus's centroid estimator fails its
own control by up to 19.8°, and a 360-bin angular cross-correlation written for
this measurement **also failed its control** — a synthetic 30° rotation of a live
frame came back as 0° (peak 0.596), while 90/180/270° came back exactly (peak
1.000), i.e. the estimator only resolves the exact pixel permutations. It was
therefore not used.
What was used needs no angle. Two observables separate *rotation* from a
*brightness pulse*, and both were taken in the same run:
| observable | rotation predicts | pulse predicts | **measured** |
|---|---|---|---|
| total annulus brightness | conserved | varies | **0.4 % spread over 16 s** (5 frames); **0.53 %** over 359 frames |
| per-angular-bin brightness | varies (a travelling feature) | varies together | **per-bin sd 24.2**, max 80.3, against a per-frame angular sd of 42.2 |
Brightness moves *around* the annulus while the total holds. A pulse is excluded.
The temporal standard deviation over 103 frames is **an annulus** and nothing
else — dark inside, dark outside, peaking exactly on the ring's stroke
(radial std: r 04 → 1.08, r 1013 → **36.75**, r 2026 → 1.18):
![std annulus](captures/focus-ring/ring-temporal-std-annulus.png)
⚠️ A positional *jitter* would smear variation outside the stroke. It does not:
variation falls to ~1 both inside and outside, so the ring is not moving, it is
turning.
### The period
A dense 359-frame filmstrip (24 s at **15.03 fps against a requested 15 fps**
the consumer kept up exactly, so these timestamps are not backlogged) gives the
annulus's 360-bin profile per frame, correlated against frame 0. A rotating ring
returns to itself once per revolution, so the trace's period **is** the spin
period — again with no angle estimated.
Autocorrelation local maxima, in seconds:
```
2.18 4.36 6.52 8.70 10.86 13.02 15.22 17.42
spacings: 2.18 2.16 2.18 2.16 2.16 2.20 2.20 mean 2.177 s
```
**Eight consecutive evenly-spaced peaks over nine revolutions.** A drifting
instrument cannot produce even spacing, which is the internal check on the
number.
Raw trace committed at [`data/focus-ring-period-corr.npy`](data/focus-ring-period-corr.npy)
(rows: t, correlation-with-frame-0, annulus mean).
### What the period is in the game's own units
⚠️ **2.18 s is wall-clock under this emulator, and the emulator is not running
the game at 30 Hz.** The corpus measures 27.628.8 fps here. `ptbtneff01`
declares its first keyframe at **t = 120**, and under the settled reading
(1 unit = 1/60 s, 2 units per rendered frame) 120 units is **60 rendered
frames** — which at 27.628.8 fps spans **2.082.17 s**. The measurement sits at
the top of that band.
**So the spin is one revolution per 120 units = 60 frames = 2.00 s at a true
30 Hz**, and no new constant is needed to account for it. 🟡 The 2.18 s is
consistent with the declared 120 rather than a re-derivation of it: the guest
frame rate was not measured in this same run, so the agreement is
consistency, not closure.
## Two other things the same run measured
***The focus ring is the ONLY moving thing on the settled main menu.** Over
103 frames / 20 s untouched, temporal std is **exactly 0.000** on every
unfocused button box, on the `NEW GAME` label, and on the `ptmsg` footer. Only
the focused button's box moves (std 4.46 against a background noise floor of
0.906). A port that draws the main menu statically plus a spinning ring is
drawing everything that moves.
***The ring is `ptbtneff01`, positionally confirmed.** Its centre was located
from the temporal-std map at game **(520.7, 339.7)**. The declared leaf offset
applied to button 3's rest position (542, 322) predicts **(521, 340)**. That
is a sub-pixel agreement between a decoded declaration and a live measurement,
and it is what ties the annulus to the record rather than to "a circle near the
cursor".
## Reach
* One run, one emulator, English locale, `GP_TITLE` build 5.
* The period is measured on **one** focused button (`OPTIONS`, button 4) and the
spin is shown on a second (`TUTORIAL`, button 3). Not checked on all five, and
not checked on `EXTRAS`.
* Says nothing about the direction of rotation — the estimator that would give a
signed angle failed its control and was not used.

View File

@@ -0,0 +1,120 @@
# 🔴 The main menu does NOT self-return to the title — and three "latencies" were my own pipeline
**Status:** one ✅ **measured** negative, one 🟡 ordering-only result, and one
🔴 **instrument defect that voids three numbers I took the same day.** Taken
2026-08-29.
## ✅ Refuted: "an ~810 s idle returns to the title" does not apply to the main menu
HANDOFF's residue table downgraded *Ⓑ leaves the main menu* to **authored**, on
the grounds that "an ~810 s idle also returns to the title, and nobody has
separated the two". That reason is now gone.
**Measured:** the main menu was held with **no input at all** and classified every
~1.2 s by [`screen_match.py`](../../tools/re-capture/screen_match.py):
| phase | duration untouched | screens seen |
|---|---|---|
| period capture | 24 s | menu only |
| idle probe | **60 s** (49 samples) | **menu only** |
Correlation against the committed main-menu capture never moved outside
**0.9245 0.9249** across the whole idle window — not a drift, not a fade, a
screen sitting still. Conservatively that is **≥ 60 s of continuous idle with no
self-return**, against a claim of 810 s.
**And the 810 s idle is real — it belongs to the TITLE.** Immediately after
this run, a probe that expected to find the title still on screen found it had
left on its own into the attract movie. So the corpus's idle timer is a property
of the **title screen** (title → `ADV.wmv` → title), and the residue table
attached it to the wrong screen.
**What this gives the port:** the idle alternative that made Ⓑ unprovable is
refuted *on the screen in question*. Ⓑ is no longer competing with a timer there.
## 🟡 Ⓑ on the main menu: the ordering survives, the timing does not
Ⓑ was **delivered** — Canary's own log records `[file-pad] keystroke vk=5801
down` / `up` and `XamInputGetKeystrokeEx -> user=0 vk=5801`, so this is not a
dropped press. In both runs the menu was followed by the title, and in both runs
**Ⓑ was the only input** in a window of ≥ 100 s either side.
🟡 **But it is still two observations with a confound I cannot yet exclude**, and
the reason is the next section: the "latency" I measured is worthless, so I
cannot say the return followed Ⓑ *promptly*. What stands is ordering plus the
absence of any other cause:
* no input for ≥ 100 s before → no transition;
* Ⓑ delivered → transition to the title.
**Classification: measured ordering, unmeasured timing.** The port should keep
Ⓑ→title, and it is now better supported than "authored" — but it is not yet a
timed measurement.
## 🔴 The defect: an oracle that cost 1503 ms per frame produced three fake latencies
`screen_match.classify_array` does a ±8 px ZNCC search over a 675×1279 surface
against two references. **Measured: 1503 ms per frame.** A probe calling it on
every frame of an 8 fps `x11grab` stream therefore drained the pipe at
**0.64 frames/s** — verified from the probe's own trace, 107 samples over 166 s.
The pipe backed up at ~7.4 fps, so every frame classified was **stale, and
increasingly so**. That is not a subtle bias; it manufactured three numbers:
| reported | actually |
|---|---|
| plate appears 24.66 s after the title art | unknown |
| Ⓑ → title in 15.58 s (run 1) | unknown |
| Ⓑ → title in 25.60 s (run 2) | unknown |
| Ⓐ → menu in 20.26 s | unknown |
🔴 **All four are withdrawn.** The tell was that they are all ~2025 s: a screen
transition, a button press and a plate fade do not share a duration, but a
backlog does. The two Ⓑ figures *growing* 15.6 → 25.6 s across a longer run is
the backlog accumulating, and it is the signature to remember.
⚠️ **What a backlog does and does not destroy.** It delays every frame by the
same growing amount, so it **preserves ordering** and destroys **durations**.
That is why the ordering results above survive and every duration here does not.
**Fixed and re-controlled.** `screen_match` now has a `fast=True` path
(4× decimation, ±2 decimated px) at **3875 ms**, a 2060× reduction, and the
control was re-run on **both** paths: 8/8 each, with the fast path agreeing with
the exact path to **±0.005** on every score.
**The ring measurements are NOT affected**, and this was checked rather than
assumed: `ring_period.py` does a greyscale conversion and a crop per frame, and
achieved **15.03 fps against a requested 15** — it kept up exactly, so its
timestamps carry no backlog. Its period also has an internal check a drifting
clock cannot pass: eight *evenly spaced* autocorrelation peaks
([`focus-ring-spin-measured.md`](focus-ring-spin-measured.md)).
## 🟡 The `PRESS Ⓐ` plate: sequence answered, duration not
The port asked whether the boot title is build 4 alone, build 4 with the plate
composited from the start, or build 4 **then** the plate after a delay.
**It is the third.** On the boot title the green-Ⓐ glyph count went
**154 → 781** with the title art already matching at 0.946. The 154 is the
decisive number: the committed no-plate capture
`live-title-build4-no-plate.png` reads **159** with the same counter, and plate
titles read 753 / 977 / 1493. So the title genuinely presents **without** the
plate first, and the plate arrives afterwards.
🔴 **How long afterwards is NOT measured** — that figure came from the backlogged
probe and is withdrawn with the rest. The port needs one more run with the fast
path to get it.
## Instrument controls, now committed
The negative controls for `screen_match` are **movie frames**, because that is
the class the oracle exists to reject — a statistics-based oracle
(green/white/mean) called a frame of `ADV.wmv` containing a bright green laser
`title`, and a probe built on it tapped Ⓐ into the movie and then waited 120 s
for a menu that was never coming.
An earlier version of the control list pointed at two **scratch** grabs, and a
later run of the same probe overwrote one of them — turning a negative control
into a title frame and failing the control for the wrong reason. They are now
committed fixtures under
[`captures/instrument-controls/`](captures/instrument-controls/).

View File

@@ -23,8 +23,8 @@ with [`tools/re-capture/menu_focus.py`](../../tools/re-capture/menu_focus.py).
| **wrap at the bottom** | ⬇ from the last item goes to the **first** | same, panels 3→4, and 4 presses from `EXTRAS` landing on `OPTIONS` — i.e. wrapping — is what makes the count come out |
| **left / right** | **nothing**, on the main menu | cursor unmoved across one ⬅ and one ➡ |
| **Ⓑ on a submenu** | returns to the parent **with focus restored to the item you entered from**`LOAD GAME``LOAD GAME`, `TUTORIAL``TUTORIAL`, `OPTIONS``OPTIONS`, `EXTRAS``EXTRAS` | 4/4 |
| **Ⓑ on the main menu** | goes to the **title**, which re-draws `PRESS Ⓐ BUTTON` after a beat | |
| **Ⓑ on the title** | **nothing** | |
| **Ⓑ on the main menu** | 🟡 goes to the **title**, which re-draws `PRESS Ⓐ BUTTON` after a beat | **none** — and the main menu's own footer does not advertise Ⓑ; [downgraded below](#-refutation-attempt-2026-08-29--the-main-menus-own-footer-does-not-advertise-ⓑ) |
| **Ⓑ on the title** | 🟡 **nothing** | **none** |
Wrap holds on both screens tested — the 5-item main menu and the 3-item `EXTRAS`
submenu — so it is a menu rule, not a per-screen table.
@@ -48,7 +48,7 @@ Measured by driving: focus the item, press Ⓐ, read the screen's own title.
| `TUTORIAL` | the lesson list, `TUTORIAL`, Level 1 / Level 2 | same, middle | 🟡 `25 GP_TUTORIAL` |
| `OPTIONS` | `OPTIONS` — GAME / CONTROL / SOUND / SCREEN SETTINGS / BACK | same, right | 🟡 `8 GP_OPTIONS` |
| `EXTRAS` | **`GP_TITLE.pak` build 6** — MISSION SELECT / MOVIE THEATER / BACK | [`ui-title-build-map.md`](ui-title-build-map.md) | 🟡 `5 GP_EXTRAS` |
| `EXTRAS ▸ MISSION SELECT` | the stage list + Wide Area Space Map | | 🟡 `7 GP_MISSION_SELECT` |
| `EXTRAS ▸ MISSION SELECT` | the stage list + Wide Area Space Map **8 rows visible of 16**, and rows below the first are **locked** on a fresh save ([below](#-mission-select-the-cursor-was-stuck-because-the-stages-were-locked)) | [`mission-select-stage01-only.png`](captures/mission-select-stage01-only.png) | 🟡 `7 GP_MISSION_SELECT` |
| `EXTRAS ▸ MOVIE THEATER` | ❔ not tested | | 🟡 `6 GP_MOVIE_THEATER` |
**Say which, as the gate asks.** The *screen* each button opens is **measured**
@@ -134,3 +134,109 @@ with nothing about `NEW GAME`. 🟡 n = 1 either way; do not read it as "fixed".
Worth recording because the first observation could easily have hardened into
"the new-game path crashes", which is what "A on NEW GAME hangs" had already
become once.
---
## 🟡 Refutation attempt 2026-08-29 — the main menu's own footer does **not** advertise Ⓑ
**Attempted claim:** this page's row *"Ⓑ on the main menu goes to the title,
which re-draws `PRESS Ⓐ BUTTON` after a beat"*.
**Why this row and not another.** It is one of only **two** rows in the Q5 table
with an **empty evidence cell** (the other is "Ⓑ on the title → nothing"); every
row that cites a capture cites one. And it is a rule the port will build on
directly — it is the only way out of the main menu.
**The measurement** — whole-frame colour test for the pad-glyph discs, run by
[`tools/re-capture/footer_and_locked_rows.py`](../../tools/re-capture/footer_and_locked_rows.py)
against the committed captures:
| capture | Ⓐ glyph px | Ⓑ glyph px |
|---|---|---|
| `live-main-menu.png` | 438 | **0** |
| `live-main-menu-options-focused.png` | 438 | **0** |
| `live-extras.png` | 440 | 514 |
| `difficulty-screen.png` | 438 | 518 |
**The control passes twice over.** The same detector, unchanged, finds the red Ⓑ
on the two screens that visibly have one; and the **Ⓐ** count is 438/438/440/438
across all four, i.e. the same glyph asset at the same size on every screen — so
a Ⓑ of that family would have been ~450520 px and cannot have fallen under a
threshold. The negative is over the **whole frame**, not a guessed footer band:
`live-main-menu.png` contains **zero** red-glyph pixels anywhere.
So the main menu's legend reads `⊙ : Select Ⓐ : OK` where every submenu reads
`⊙ : Select Ⓐ : OK Ⓑ : Back`.
**Verdict: the claim SURVIVES, at reduced confidence, and the row is downgraded
to 🟡.** A legend is not behaviour — a game may accept an unadvertised Ⓑ — so an
absent glyph cannot refute a press that was actually observed. But:
* the observation has **no capture behind it**, and it is now the only Q5 row
contradicted by the game's own on-screen text;
* there is a **named confound**: the title-side screens auto-return on idle, and
"I pressed Ⓑ and ended up at the title, which drew `PRESS Ⓐ BUTTON` after a
beat" is also exactly what an idle timeout looks like to an observer who does
not hold the two apart. The corpus documents that timeout at ~810 s
([`boot-config-and-gamepart-registry.md`](boot-config-and-gamepart-registry.md)).
**What would settle it:** press Ⓑ on the main menu and read `0x828A690C`, the
live screen id (`1` title, `3` main menu, `4` extras) — a transition inside a
second is a Ⓑ, one at ~810 s regardless of the press is the timeout. Cheap, and
it needs no screenshots.
🔴 **Not runnable here.** This container has no disc and no ISO, so there is no
oracle at all — see
[`capture-harness-status.md`](capture-harness-status.md#-2026-08-29--the-disc-is-not-in-the-decoder-container-at-all).
**For the port:** Ⓑ from the main menu to the title is **measured, single
observation, uncited, and unadvertised by the game**. Implement it — it is the
only exit — but treat it as authored rather than transcribed, and do not also
build the idle-return on the assumption that the two are distinct until someone
has separated them.
---
## ✅ MISSION SELECT: the cursor was stuck because the stages were **locked**
**Measured 2026-08-29 from committed captures**, no disc needed. Settles the
⚠️ open in [`../game/navigation.md`](../game/navigation.md): *"sixteen d-pad
presses never left Stage 01 — whether that is because only one stage was
unlocked, or because the list is driven some other way, is unknown"*.
The stage list has **three** label brightnesses, not two, and that is what
discriminates. Sampling the label strip (x 190…320) of each of the 8 visible
rows, 95th percentile of luminance:
| capture | row 1 | rows 28 |
|---|---|---|
| `mission-select-stage01-only.png` | **254** | **104** |
| `mission-select-all-story-unlocked.png` | **254** | **183** |
| `mission-select-ends-at-stage16.png` | 183 | 183 ×6, then **254** on row 8 |
* **254** = focused (the row carrying the spinning focus ring)
* **183** = unlocked, not focused
* **104** = **locked**
The all-unlocked capture is the control: it holds row 1 focused at the identical
254 while rows 28 move 104 → 183 as one uniform step. So the dim rows in the
Stage01-only capture are **not** "unfocused"; unfocused is 183, and they are
79 levels below it.
**And the cursor does move when they are unlocked.** In
`mission-select-ends-at-stage16.png` the list has scrolled to show Stage09…16,
the scrollbar thumb is at the bottom, and the focus ring is on **Stage16** — the
last row. Locked list: 16 presses, no movement. Unlocked list: the cursor reaches
the end.
| | |
|---|---|
| **rows visible at once** | **8** |
| **list length** | **16** (`Stage01``Stage16`; the scrollbar bottoms out at 16) |
| **why 16 presses did nothing** | every row below the first was locked |
⚠️ **Reach.** This is a still image, so it says the cursor *reached* Stage16, not
how it got there and not whether the list wraps — the scroll thumb bottoming out
at row 16 is consistent with either. Whether a *locked* row is skipped or simply
unreachable is likewise not separated: with only row 1 unlocked the two are the
same observation.

View File

@@ -0,0 +1,115 @@
# 🔴 I cannot measure this emulator's presentation rate — and the 2 % between two of my pages is not a disagreement about the game
**Status:** one 🔴 **instrument failure** (recorded, not published as a number),
and one ✅ **resolution of a challenge** that follows from it. 2026-08-29.
## The challenge
The port put two of my pages against each other. Both measure the same declared
quantity — **120 keyframe units of wall clock during a static hold, in Canary**
and they differ by 2 %:
| page | measured | implied presentation |
|---|---|---|
| [`title-plate-delay-measured.md`](title-plate-delay-measured.md), settle→plate, 2 runs | 2.138, 2.132 → **2.135 s** | 28.10 fps |
| [`focus-ring-spin-measured.md`](focus-ring-spin-measured.md), 7 spacings | 2.16 … 2.20 → **2.177 s** | 27.56 fps |
0.042 s apart — seven times the 6 ms run-to-run agreement the plate page rests
on. Its own corroboration argument (*"the build-in is where frames are dropped;
the static hold is not"*) is aimed at exactly this, and these are two static
holds. Fair challenge.
## ✅ The resolution: 2 % is far inside this emulator's own variation
The question assumes the wall clock is stable enough for 2 % to mean something.
It is not, and the counterexample is the same interval, in the same container, on
the same day:
| run | conditions | settle → plate |
|---|---|---|
| 1 | 8 fps grab | **2.138 s** |
| 2 | 8 fps grab | **2.132 s** |
| **3** | 8 fps grab **+ `--log_ui_draws=true`** | **2.549 s** |
**A 19 % swing on the declared interval, from a logging flag.** The 2 % the two
pages differ by is a fifth of that. They were taken in different sessions under
different load, and nothing in either can separate "the game timed it
differently" from "the emulator ran slower" — because both are wall clock.
**So the two pages were never in conflict about the game.** They are three
readings of one declared quantity through a clock that moves. What settles the
quantity is the disc: `t=118 → t=238` is 120 units, and the port's own structural
rule for the ring (two keyframes differing only by a 360° rotation, first timed
and second untimed — 16/212 elements matched, all of them focus rings) gives the
ring's period the same way.
⚠️ **This removes the evidence that the ring is not 120 units. It does not prove
that it is.** The disc-side rule does that, and it is the port's, not mine.
## 🔴 The instrument I built to answer it properly, and why it is dead
Wall clock cannot separate the two hypotheses; **frames** can. So I tried to
measure the presentation rate.
### Canary's own frame counter perturbs by a third
`--log_ui_draws=true --ui_draw_capture_frames=N` logs `[UI-CAP] capture armed`
and `[UI-CAP] done: D draws over F frames`. This is the instrument that produced
the corpus's **28.5 fps** ([`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)).
Measured here, armed on the title with a concurrent 8 fps grab:
> **300 frames in 16.567 s = 18.11 fps**, against ~28 for the same screen without
> it.
⚠️ **A frame counter that costs a third of the frame rate cannot measure the
frame rate.** This does not overturn the 28.5 fps — that run had no concurrent
grab — but it does mean the figure is a **lower bound taken under its own
instrument's load**, and it should not be treated as *the* rate.
### And the unperturbing replacement FAILED its own decisive control
The alternative: count **distinct frames** in an oversampled crop of something
that moves every frame (the spinning focus ring). At 60 fps against a source
presenting at R, the fraction of consecutive samples that differ is R/60.
Three controls were written before the run. It failed the one that matters:
| control | result |
|---|---|
| a static crop must read ≈ 0 | **2.63 fps of "change"** — not clean |
| two sampling rates must agree | 45 fps → **12.73**, 60 fps → **12.08** ✅ |
| **must agree with the game's own counter while both run** | counter **15.88** vs `[UI-CAP]` **17.59****10 % low** 🔴 |
The third is decisive and it is a failure: **the ring does not change on every
presented frame**, so the counter measures the ring's animation rate, not the
presentation rate. It also drifted 12.1 → 15.9 → 18.1 across one session, which
a real rate estimator on a settled screen should not do.
**Dead, not tuneable** — per [`METHOD.md`](METHOD.md). No rate is published from
it. Controls preserved:
[`data/present-rate-controls-2026-08-29.json`](data/present-rate-controls-2026-08-29.json).
## What this means for the port, and for everything I hand over
**Do not take a wall-clock duration off this container as a game constant.**
Demonstrated range for one declared interval: 2.13 2.55 s, and the emulator's
own rate read anywhere from 12 to 28 fps depending on what was watching it.
The rule that follows: **a measured interval landing near a round number of
declared units almost certainly IS that number of units**, and the units are what
to ship. Wall clock is for ordering and for sanity, not for constants.
## Reach
* One container, one day, one machine. It says nothing about how a different host
runs Canary, and nothing about hardware.
* It does **not** refute the 28.5 fps in `ui-keyframe-time-unit.md`; it reclassifies
it as a load-dependent lower bound.
***The game's true update rate is still not grounded in the disc.** "2 units
per submitted frame" *is* grounded — it was read off a frame-indexed draw
capture, so it is independent of how fast the emulator runs. What rests on the
emulator is only the step from *a submitted frame* to *1/30 s*, i.e. the
present interval. That is a constant in the executable, and reading it is
blocked on the missing disassembly route
([`static-route-recovered.md`](static-route-recovered.md)).

View File

@@ -0,0 +1,104 @@
# 🟡 The static PPC route was lost with the container migration — the image is back, the disassembly is not
**Status:** ✅ the **image** is recovered and validated; 🔴 the **disassembly
database** it is analysed with does not exist in this repository at all, and
never did. 2026-08-29.
## What broke
Four tools in `tools/re-capture/` open a DuckDB database at
`/work/xenia-rs/sylpheed.db``name_block_bases.py`, `archive_naming.py`,
`isl_cmdtab.py` and the census work that produced most of the `sub_82xxxxxx`
findings in [`INDEX.md`](INDEX.md). In this container:
```
$ ls /work/xenia-rs
ls: cannot access '/work/xenia-rs': No such file or directory
```
🔴 **And nothing in the repository builds it.** A grep for `duckdb` finds only
*consumers*; there is no disassembler, no PPC decoder vendored, and `capstone` is
not installed (and `pip install` is refused here by PEP 668). So the static route
is four read-only clients of a producer that is not in the tree.
⚠️ **This is not a small gap.** Every finding that cites a function address —
the GamePart registry, the challenge gate, the ISL command table, `PlayerParams`,
the boot sequencer — is currently **unre-checkable in this container**. They are
not wrong; they are unverifiable, which is a different and quieter problem.
## `default.xex` is not a substitute
The disc's executable is encrypted and LZX-compressed:
```
$ xxd -l 16 /disc/default.xex
00000000: 5845 5832 0000 0001 0000 3000 ... XEX2......0.
$ strings -a /disc/default.xex | grep -c GamePart
0
```
The header is intact — `XEX2`, media id `535107D4`, original PE name
`default.pe` — and everything after it is noise. No decrypt/decompress exists in
the tree either.
## ✅ What is recovered, and how it is validated
Xenia decrypts, decompresses and relocates the image at load, so a **running
guest holds the flat VA image** the corpus calls the `.pe`
(`VA = 0x82000000 + offset`). `tools/re-capture/dump_image.py` reads it straight
out of `/dev/shm/xenia_memory_*` — no debugger, no emulator patch, no pause.
```
$ tools/re-capture/dump_image.py /sylph-home/re/sylpheed-image.pe
wrote ... 4194304 bytes VA 0x82000000..0x82400000
validated: GamePart id table + D3D runtime strings; 1013/1024 non-empty 4K pages
```
**The validation is the corpus's own landmarks, not the tool's.** A mis-based or
partial dump fails both:
* `0x820A1630` holds the **GamePart id table** — 29 `.rdata` pointers resolving
to `GP_TITLE` (0) … `GP_TEST` (28), with `GP_CHALLENGE` at **26**, exactly as
[`challenge-mission-gate.md`](challenge-mission-gate.md) records;
* the image carries the **Xbox 360 D3D runtime's own error strings**
(`ERR[D3D]: Unanticipated CPU_INTERRUPT`, `D3D9D.LIB`), which only the real
loaded executable has.
So string search, table dumps and pointer chasing work again today. **What does
not** is anything needing decoded instructions: no `mnemonic`/`operands`, so no
xref search, no base solving, no call graphs.
⚠️ It also depends on a **booted emulator**, which is a bad dependency for the
foundation of the static corpus. Dump once and keep the file.
## 🔵 For the human — what is actually needed
Not the image; that is solved. What is missing is the **producer of the
database**, and its schema, which the four consumers pin exactly:
| table | columns used |
|---|---|
| `functions` | `address`, `end_address`, `name` |
| `instructions` | `address`, `mnemonic`, `operands` |
| `strings` | `address`, `content` |
An XEX unpacker would also be worth having on its own, so the static route stops
needing a running emulator to bootstrap.
## What it blocks right now
The one open question this iteration wanted it for: **the game's present
interval**, i.e. the step from *one submitted frame* to *1/30 s of real time*.
"2 units per submitted frame" is already grounded and emulator-independent — it
was read off a frame-indexed draw capture
([`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)). The remaining link is
`D3DPRESENT_PARAMETERS.PresentationInterval` at device creation: `_ONE` means the
game runs at the console's 60 Hz vblank, `_TWO` at 30. That is a constant loaded
into a register, so **finding it needs disassembly, not string search** — the
image contains the D3D runtime but the interval is an immediate, not a string.
Settling it would move Q1's seconds-per-unit from **measured** to **decoded**,
and it is the only part of the UI clock still resting on a wall clock this
container has now been shown to move by 19 %
([`present-rate-instrument-failed.md`](present-rate-instrument-failed.md)).

View File

@@ -21,7 +21,16 @@ BGM_001.slb (9 178 040 B)
```
A bank is a 10 240-byte header and then **exactly two waves**, and the two always
have the **same duration** — different byte sizes and different bitrates, same
have the **same duration**
⚠️ **Our own reader disagreed with this page until 2026-08-29, and the page was
right.** `slb::to_xma_riffs` was emitting that 10 240-byte header as a third
sub-wave, so `sound_bank_riffs("BGM_103.slb")` returned **three** — which the
port caught while exporting the menu music. The header is not a wave (it decodes
to 0.009 s and is 99.1 % zero); the cause was a modulus that assumes a bank
header is shorter than one 2048-byte packet, and it is fixed with a disc-wide
28/28 check —
[`slb-bank-header-not-a-wave.md`](slb-bank-header-not-a-wave.md) — different byte sizes and different bitrates, same
number of seconds. Duration is `data_size / PsuedoBytesPerSec` (the u32 at
`RIFF+0x20`; `RIFF+0x24` is the sample rate, 48 000 Hz except `BGM_020``023`
at 44 100).

View File

@@ -0,0 +1,121 @@
# ✅ A music bank's "third sub-wave" is its **header**, and the bug was arithmetic
**Status:**`CONFIRMED`**decoded**, with a disc-wide check over all 9 519
`sound.pak` entries, a decode control, and independent corroboration from the
running game. Fixed in `sylpheed-formats` 2026-08-29.
**Raised by the port**, on its P6 critical path:
`sound_bank_riffs("BGM_103.slb")` returned **three** sub-waves against
[`bgm-two-stems.md`](bgm-two-stems.md)'s census, which says a music bank is
exactly two. Its exporter was summing all three, so the shipped menu music was
the sum of three things where the corpus predicted two. It declined to choose
which to drop, which was right — that is a decoding question.
## The answer
The third thing is **the bank header**. Not a stem, not an artefact of the disc:
our own reader was emitting it.
`to_xma_riffs` has a hybrid branch for banks that carry a headerless packet
stream *before* their first `RIFF` — the fix that recovered `VOICE_D_453`'s line
([`slb-data-offset.md`](slb-data-offset.md)). It derives that stream's start as
```rust
first_riff % XMA1_PACKET // XMA1_PACKET = 2048
```
which is correct **only when the bank header is smaller than one packet**. It is,
in the voice banks the branch was written for: their headers put the first `RIFF`
at 1392, 1468, 1600 or 1728 mod 2048.
A music bank's header is **exactly five packets — 10 240 bytes** — so the
modulus returns **0**, and the branch emitted `slb[0..10240]`: the whole header,
as sub-wave 0.
The header states its own length, so nothing here needs a heuristic:
```
BGM_103.slb
+0x00 BE u32 1103 bank id
+0x18 BE u32 0x00000800 block size = 2048
+0x1c BE u32 7839244 data size
+0x20 BE u32 1103 the id again ← signature, with +0x18
+0x24 BE u32 5 HEADER LENGTH IN BLOCKS → 5 × 2048 = 10240
+0x28 BE u32 0x00100002 16 bit / 2 ch
```
## The disc-wide check
Over all **9 519** entries of `sound.pak`
([`tools/re-capture/slb_segment_phase.py`](../../../tools/re-capture/slb_segment_phase.py)
supplies the reader):
| | |
|---|---|
| entries matching the header signature at offset 0 | **28** |
| ...whose declared header ends **exactly** at the first `RIFF` | **28 / 28** |
| ...with a real gap between header and first `RIFF` | **0** |
| false positives among the 9 491 others | **0** |
The 28 are exactly the music banks — ids **10011023** and **11011105**. So on
this disc a bank header at offset 0 and a leading packet stream **never
coexist**, and the guard is not a threshold: if a bank states a header, believe
it, and there is nothing before the first `RIFF`.
⚠️ `BGM_106``BGM_109` are **not** in the 28 and must not be: their pak entries
start mid-bank, so they have no header at offset 0 and their leading region is
real audio (the tail of the previous bank). That is the same straddle
[`bgm-two-stems.md`](bgm-two-stems.md) already documents.
## The decode control
Decoding the emitted region proves it is not audio, and the control is run
through **the same chain, on the same bank, in the same invocation**:
| | bytes | PCM decoded |
|---|---|---|
| `BGM_103` — what we emitted as "sub-wave 0" | 10 240 | **0.009 s** |
| `BGM_103` — its real wave 0 (control) | 3 876 864 | **87.744 s** (declared 87.75) |
| `BGM_001` — what we emitted as "sub-wave 0" | 10 240 | **0.009 s** |
| `BGM_001` — its real wave 0 (control) | 4 466 688 | **173.809 s** (declared 173.82) |
FFmpeg `xma1`, mono/stereo taken from the bank's own `fmt `. The region is also
**99.1 % zero bytes** (6793 non-zero of 10 240 across the 28 banks) and its last
non-zero byte is at 6431, so its final 1.86 packets are entirely empty.
## Corroboration from the oracle, which was already in the corpus
[`bgm-two-stems.md`](bgm-two-stems.md) records that at the **main menu**, with
`--xma_param_probe=true`, the decoder was handed **two** stereo 48 kHz streams —
of **3 876 864** and **3 930 112** bytes, byte-for-byte `BGM_103`'s two declared
waves. A third stem would have been a third stream. The running game was already
saying two.
## The fix
`slb::bank_header_len` (new, `pub`) reads the signature and returns the declared
length; the hybrid branch uses it in preference to the modulus:
```rust
let start = bank_header_len(slb).unwrap_or_else(|| leading_data_offset(ri));
if ri > start { /* emit the leading stream */ }
```
Two regression tests in
[`tests/slb_leading_segment_disc.rs`](../../../crates/sylpheed-formats/tests/slb_leading_segment_disc.rs):
the disc-wide 28/28 identity, and `BGM_103`/`BGM_001` returning exactly two
sub-waves at their declared payload sizes. The pre-existing voice-bank tests —
`broken_banks_recover_their_line`, `derived_offset_recovers_voice_banks_without_regressing_etc`
— still pass, so the `VOICE_D_453` recovery is untouched. 10/10 green with
`SYLPHEED_DISC` set.
## Reach
* The 28 are the only banks on the disc that state a header at offset 0. A bank
format elsewhere with a header ≥ 2048 B that we have not seen would have had
the same bug; nothing on this disc does.
* This says nothing about **which** of the two remaining waves is which — that is
still 🟡 in [`bgm-two-stems.md`](bgm-two-stems.md) (surround-rear pair vs a
second intensity layer), and both readings predict playing them together.
* It does not change the count for any voice bank: `VOICE_*` entries have no
header at offset 0, so their leading region is emitted exactly as before.

View File

@@ -0,0 +1,213 @@
# ✅ The boot title shows build 4 alone for **2.13 s**, then composites the plate
**Status:****measured** — two independent boots of the real game in Xenia
Canary, 2026-08-29. Not on the disc as a delay: build 2 (the `PRESS Ⓐ BUTTON`
plate) is an overlay with no fade quad of its own, and nothing in either
bundle's keyframe group carries the gap between them.
**Question this closes:** the port asked which of three things the boot title is
— build 4 alone, build 4 with the plate composited from the start, or build 4
**then** the plate after a delay — because the third case is the only one where
`ScreenView` has to draw **two builds at once**, which it has never done. The
sequence was already answered (it is the third,
[`menu-idle-and-b-2026-08-29.md`](menu-idle-and-b-2026-08-29.md)); **the delay
was withdrawn the same day** and is what this page supplies.
## The number
| | run 1 | run 2 |
|---|---|---|
| title art first drawn (surface leaves black) | 201.617 s | 214.130 s |
| **title settled** — glyph counter first reads its no-plate value **154** | 203.260 s | 216.261 s |
| **plate first counted** — glyph leaves 154 | 205.398 s | 218.393 s |
| **settled → plate** | **2.138 s** | **2.132 s** |
| first drawn → plate | 3.781 s | 4.263 s |
**Take 2.13 s, measured from the moment build 4's own build-in animation
finishes.** The two runs agree to **6 ms**, which is under one sample interval.
⚠️ **Do not take "first drawn → plate".** It differs by 0.48 s between the two
runs because the build-in itself ran 1.64 s and 2.13 s — the emulator's frame
pacing during an animation is not the game's clock, and this is exactly the sort
of number that looks like a measurement and is really the harness.
![plate onset, both runs](captures/ui-timing/plate-onset-two-runs.png)
Raw per-frame data, 8 fps, every frame of both runs:
[`data/plate-timing-run1.tsv`](data/plate-timing-run1.tsv) ·
[`data/plate-timing-run2.tsv`](data/plate-timing-run2.tsv).
## Why "then the plate", and not "the plate was pulsing all along too dim to see"
The plate's declared alpha never exceeds `0x50` (80/255,
[HANDOFF](../port/HANDOFF.md)), so a glyph counter with a hard threshold could in
principle miss its dim phase and produce a fake delay. It does not, on two
independent observables:
* the glyph count is **exactly 154** — the committed no-plate title's own value,
159 on `live-title-build4-no-plate.png` — for every frame of the plateau, with
**zero** variation, for 1.99 s (run 1) and 2.13 s (run 2). After onset the
same counter swings 714 ↔ 1520 continuously. A cycling plate cannot produce a
flat exact-154 plateau nearly one full period long;
* the **surface mean** is flat to ±0.03 across the plateau (61.09 → 61.15) and
then rises. A pulsing overlay moves the frame mean; the frame mean does not
move until onset.
## 🔴 The instruction below was WRONG, and the port refuted it — corrected 2026-08-29
**What stands:** every measurement on this page. **What was wrong:** what I told
the port to do with it.
The instruction was *"when build 4 has settled, wait 2.13 s, composite build 2"*.
The port implemented it literally, then pointed out with arithmetic off the disc
that it cannot be right: build 2 has a **group of its own**, and playing that
group from a start at "settle" puts the plate at settle + 2.13 + 3.97 s. The
3.97 s is real — `ptbtn00.rat` reaches `a=255` at `t=238`, confirmed here
independently of their message:
```
$ sylpheed-cli screen info --build 2 --geometry $SYLPHEED_DISC/dat/GP_TITLE.pak
build [2] 1280x720 1 elements
0 ptbtn00.t32 214: 383,560 a=0 236: 383,550 a=0
238: 383,550 a=255 244: 383,550 a=255 -: a=0
```
### The reconciliation: one clock, and my landmark is `t≈118`, not `t=261`
**Build 2's group runs on the same clock as build 4's, starting together.** Then
the plate's own keyframes say when it arrives and nothing needs authoring.
The port's premise that *"build 4 settles at `t=261` = 4.35 s"* is the part that
fails, and it is worth stating plainly because it will bite elsewhere:
🔴 **`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`** —
after which it creeps 5 px over the next 209 units. The title's visible build-in
is over at **`t≈118`**, where three elements' ramps end together (`pteff01`,
`pteff02.prm`, `ptlogoall_eff`); the only later change is the copyright line and
the ™.
That closes the gap exactly, with no free parameter:
| | units |
|---|---|
| last build-in ramp ends (`pteff01` / `pteff02.prm` / `ptlogoall_eff`) | `t = 118` |
| `ptbtn00` reaches `a = 255` | `t = 238` |
| **difference** | **120 units = 2.000 s** at 1 unit = 1/60 s |
against a measured **2.138 s** and **2.132 s**. So the interval the two runs agree
on to 6 ms is a **declared** 120 units — the number was on the disc, and I handed
over a wall-clock reading of it.
### ⚠️ And the wall-clock reading is 6.7 % long, for a reason the corpus already knew
120 units in 2.135 s is **56.2 units/s**, i.e. the game presenting at **28.06 /
28.14 fps** against its nominal 30. The corpus independently measured the idle
title at **28.5 fps** ([`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md)) —
1.3 % from these two runs, established before and separately from them.
✅ Corroborating that it is presentation rate and not the game: within these 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**. The build-in is where frames are
dropped; the static hold is not. A model in which the game's own timing varied
would have to move both.
## What the port should author — nothing
1. draw build 4 and build **2** on **one clock, started together**, and play both
groups from their own keyframes;
2. the plate then appears at its declared `t = 238` with no authored constant;
3. its pulse is the focus record `ptbtn00f`, measured here at **2.12 / 2.19 /
2.34 / 2.31 s** over four intervals, mean **2.24 s** — replicating the
corpus's ≈ 2.3 s rather than replacing it.
⚠️ **If you do author a gap anyway, author 120 units (2.00 s at 30 Hz), not my
2.13 s.** The 2.13 s is this emulator's presentation rate baked into a game
constant, and a port running at a true 30 Hz would be visibly late.
So yes: `ScreenView` needs two builds at once, and the boot's end state is
**not** plate-free. That part of the answer is unchanged.
### ❔ What this does not settle
* **Which reading of the keyframe times** — the current one or Q1's replicated
shift — is right. It barely matters here (the plate's `a=255` is `t=238`
unshifted and `t=236` shifted, 0.03 s apart), but the two make different
predictions for `ptcopyright`'s fade, and my traces contain **both** a 0.4 s
rise and a 1.1 s creep before the plate. Not separated; Q1's 🟡 stands.
* **My settle landmark to better than ±5 units.** At 56 units/s, 8 units is
0.14 s — about one sample. `t=118` is identified from the file (three ramps
ending together) and is *consistent with* the measurement, not pinned by it.
## What is NOT measured here — the press latencies, again
Both runs pressed Ⓐ on the plate and Ⓑ on the menu, and both runs contain a
**frozen frame** on the Ⓐ path that makes the Ⓐ→menu duration meaningless:
| | run 1 | run 2 |
|---|---|---|
| frames held at surface mean **26.626**, motion exactly 0 | 14 (1.53 s) | 12 (1.39 s) |
🔴 **This is not the instrument.** Run 1's freeze straddled an x11grab restart,
so it looked exactly like the documented stale-stream failure; run 2 was run with
restarts **disabled** for the whole measuring window and reproduced the same
freeze, at the **same** surface mean to six decimals, in the same place relative
to the press. Two independent runs cannot agree to 1e-6 on a stalled buffer.
It is the guest: after Ⓐ, the fade-out starts (mean 64.4 → 51.0 → 26.6), the
frame is then **re-presented unchanged for ~1.4 s**, the full title reappears at
mean 64.28, and only then does the fade run to completion. That is the shape of
a **load stall**, and the Ⓑ path — menu → title, nothing to load — has no freeze
at all.
**So the Ⓐ→menu latency is an emulator load time, not a game beat, and the port
must not bake it in.** The parts of the transition that are stall-free:
| | run 1 | run 2 |
|---|---|---|
| press → first visible change (Ⓐ) | — | 0.290.37 s |
| press → first visible change (Ⓑ) | — | 0.280.33 s |
| **pure black between the two screens** (Ⓐ path) | 0.140.30 s | 0.140.27 s |
| black → menu settled | ≈ 1.0 s | ≈ 1.0 s |
| Ⓑ path: menu fade-out to black | — | 0.50 s |
| Ⓑ path: black → title art | — | ≤ 0.27 s, and it is a **cut**, not a fade |
⚠️ The two "first visible change" figures are **upper bounds that include this
harness**: the press is a file the emulator polls (`--hid=file`), so an unknown
poll interval sits inside them. They are quoted only because they bracket the
black hold, and they do **not** refute
[`screen-transitions.md`](screen-transitions.md)'s 0.07 s, which was taken a
different way.
**The black hold does check the port's authored constant.** The port ships
0.170.23 s; both runs put it in **0.140.30 s**. Consistent, at a sampling
resolution (0.125 s) that cannot do better — so the authored value stands and is
now bracketed by a measurement rather than only by the declared 12 units
(0.20 s).
## The instrument, and its controls
[`tools/re-capture/title_timing_probe.py`](../../tools/re-capture/title_timing_probe.py),
built because the four durations withdrawn on 2026-08-29 came from a classifier
costing **1503 ms per frame** draining an 8 fps stream at 0.64 fps.
* **8.7 ms of compute per frame** — measured, 173× cheaper. The saving is the
±8 px offset search: every committed capture aligns at exactly `dy=0 dx=0`
([`five-screens-acceptance.md`](five-screens-acceptance.md)), so the live path
decimates 4× and does one ZNCC per reference instead of 25 at full res.
* **Both runs sampled at 7.97 and 7.98 fps against a requested 8.** A backlog
preserves ordering and destroys durations; there was no backlog.
* `--control` **passed 9/9 content controls and 4/4 plate-detector controls**,
including the two committed movie frames that are the class this oracle exists
to reject.
* an **independent one-shot grab** every 20 s, through a separate process, is
logged beside the stream's own frame. On the static screens the two agree to
**0.000 / 0.001**; the large disagreements are all inside movies, where a
0.3 s difference in grab time is a different picture.
* and the plateau itself carries an internal clock check: the plate's ~2.2 s
pulse is visible in the same trace. A stalled stream cannot produce a periodic
signal.
## Reach
Two runs, English locale, one machine, Xenia Canary. It says nothing about the
**attract loop's** title (which the corpus records as accepting no input at all),
and nothing about the Japanese build 7.

View File

@@ -0,0 +1,239 @@
# A keyframe's time word comes **before** its pose — the placement record, decoded
**Status:**`CONFIRMED`, **decoded**. The field, plus a disc-wide check
(13 991 placement groups over 33 archives, three tests, each with a control) and
a regression test that runs against the disc
(`crates/sylpheed-formats/tests/ui_keyframe_record_disc.rs`).
This closes the one thing [MISSION](../port/MISSION.md) **Q1** still had open —
*"the interpolation law is settled; the group TIMELINE for multi-keyframe
elements is not"* — and it dissolves, rather than decides, the argument in
[`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) about whether to adopt
`SYLPHEED_KF_TIME_SHIFT`. Both sides of that argument were reasoning about a
missing word that is not missing.
## The record
A build bundle's placement region is a run of groups, one per element. A group
is an 8-byte header followed by `frame_count` **records of 40 bytes**:
```text
u32 element_index
u32 frame_count
u32 time │ record 0 ← the time comes FIRST
36 pose ┘
u32 time ┐ record 1
36 pose ┘
u32 time ┐ record n1
36 pose ┘
```
Total group size: `8 + frame_count * 40`.
The 36-byte pose is what the parser already reads correctly — fade ARGB, the
three signed rotation words, scale X/Y, tint, X, Y — at offsets 0…35 of the
pose, i.e. 4…39 of the record.
## What was wrong, and why it looked right for so long
Our parser opened its 40-byte window **at the pose**, four bytes into record 0,
and then read the word at window `+36` as that pose's time. That word is
record `k+1`'s `time` — the time of the *next* pose. Every pose field lands
correctly (the window is aligned to a pose, and poses are what it reads); only
the time association slips by one.
Two long-standing oddities in the corpus are that off-by-one, and nothing else:
| the oddity as recorded | what it actually was |
|---|---|
| *"a group's data stops 4 bytes short of its final block's time slot — that word is already the next group's element index"* | the group is **not** short. `8 + frames*40` is exact. The parser was reading 4 bytes past the last pose because its window began 4 bytes early |
| *"the last keyframe carries no time"*`Keyframe::time` was `Option<u32>`, `None` on every group's final pose | the final pose's time is the *previous* stride's `+36` word. **Every** pose is timed |
| the stray `time = 1869640736` (= `"ohnm"`, ASCII from the next record) that "silently corrupts the max-dwell pick in `Element::rest`" | the same over-read |
The first pose's time is the group's **lead-in word** at `header + 8` — the word
[`parse_placements`](../../crates/sylpheed-formats/src/ui_layout.rs) skipped as
*"one lead-in word"* without asking what it was.
## The disc-wide check
`tools/re-capture/kf_record_census.py`, output committed at
[`data/kf-record-census.txt`](data/kf-record-census.txt). Run it with
```bash
python3 tools/re-capture/kf_record_census.py "$SYLPHEED_DISC"/dat/*.pak
```
### A. The lead-in word takes its place in the sequence
Prepending the lead-in to the shifted time series must give a non-decreasing
sequence. **13 991 of 13 991 groups — 100.000 %.** (15 493 including
single-pose groups, which are trivially ordered; the regression test counts
those and also finds 0 out of order.)
### B. The 5 058 non-zero lead-ins are times, not padding
If the lead-in were padding, a flag, or a count, 5 058 of them would not all
happen to fall strictly below the group's next time.
| | result |
|---|---|
| non-zero lead-ins | 5 058 |
| strictly less than the next time | **5 058 — 100.000 %**, none equal |
| **control**: another group's lead-in from the same bundle | 35 837 / 50 580 = **70.9 %** |
The gap to the next time piles up at **10** (2 076 groups) and **1** (2 022) —
ramp lengths, not arbitrary numbers. And the values themselves read as times:
`GP_DIALOG` entry 9's `pzeff02.t32` runs `167 → 197 → 217 → 232`; entry 25's
`pznoise.rat` runs `40 → 80 → 230 → 260`.
### C. A multi-keyframe ramp only runs at a constant rate under this reading
Interpolation between two keyframes is linear — measured against the running
game, in [`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md). So where an
author chains three or more keyframes through a monotone alpha ramp, a correct
time assignment should often make `d(alpha)/d(time)` come out constant, and a
wrong one should scramble it.
| reading | multi-segment alpha ramps at a constant rate (±6 %) |
|---|---|
| **corrected** — time precedes pose | **857 / 1 540 = 55.6 %** |
| old — `+36` is the block's own time | **0 / 1 042 = 0.0 %** |
**Zero.** Not one ramp on the whole disc. The 44 % that are not constant under
the corrected reading are genuinely shaped ramps — authors do place keyframes
unevenly — so 56 % is a floor, not a fit.
A worked example, `pgloading_loop4.rat` on `GP_TITLE` build 11:
| | times | alphas | rate per unit |
|---|---|---|---|
| corrected | 0, 4, 6, 7, 8, 32, 38 | 0, 128, 192, 224, 255, 255, 0 | **32, 32, 32, 31** — then hold, then out |
| old | 4, 6, 7, 8, 32, 38, *(none)* | 0, 128, 192, 224, 255, 255, 0 | 64, 64, 32, 1.3 — then hold, then an **untimed** fade-out |
## What it costs to adopt: nothing, on every static composite
This is the change the corpus previously declined to make, because
`SYLPHEED_KF_TIME_SHIFT=1` moved `GP_TITLE` build 7 by 13.1 % of its pixels and
made the EN/JP twin brightness disagree (70.94 vs 76.32 against build 4's
71.41). **That was the missing first time word, not the shift.**
With the lead-in restored as pose 0's time:
| check | result |
|---|---|
| `GP_TITLE`, all 12 builds rendered under both readings | **12 / 12 byte-identical PNGs**, build 7 included |
| 217 builds over 6 UI archives, `rest()` pose per element | **2 builds differ**: `GP_TITLE` 7 and `GP_DIALOG` 31 |
| what those 2 differences are | `ptlogo_eff3.t32`: `(98,42)` vs `(108,72)`**both α = 0**, so neither paints. `pzstg14_2.t32`: one pixel of Y |
| renders of those 2 builds | **identical** |
So the build-7 luminance objection is withdrawn: it was `rest()`'s dwell
fallback picking the 200 %-scale bloom because pose 0 had no time to be compared
against. Given a time, the dwell rule picks an invisible pose — the same
*visible* result the old reading produced, by a rule that is now sound.
⚠️ **`Element::rest()` is unchanged and is still a heuristic.** The times feed
it; they do not fix it. `structures/ui-resting-pose.md` stands as written.
## Against the oracle
The committed `log_ui_draws` capture of the developer splash
([`captures/ui-timing/splash-build-quads.csv`](captures/ui-timing/splash-build-quads.csv))
is the check that this is the game's reading and not merely a tidier one.
`palogo_gamearts_eff.t32` — lead-in 0, `W = [15, 30, 45, ]`, alphas
`[0, 255, 255, 0]`:
| phase | corrected | old | captured |
|---|---|---|---|
| fade in | t 0→15 (7.5 f) | t 15→30 (7.5 f) | frames 94101, **7 f** |
| hold | t 15→30 (7.5 f) | t 30→45 (7.5 f) | frames 101107, **7 f** |
| fade out | t 30→45 (7.5 f) | **untimed** | frames 108115, **8 f** |
The glow's *durations* do not discriminate — that was already recorded — but its
**end does**: the old reading cannot say when the fade-out finishes, and the
capture plainly shows it finishing.
`palogo_gamearts.t32` — lead-in 0, `W = [15, 30, 190, 194, 206, 210, ]`, alphas
`[0, 0, 255, 255, 232, 32, 0]`:
| | corrected | old | captured |
|---|---|---|---|
| fade in | t 15→30, **7.5 f**, in the same window as its own glow | t 30→190, **80 f** | already at 255 when the quad first appears (frame 116) |
| hold at 255 | t 30→190, **80 f** | t 190→194, **2 f** | frames ≤116198, **≥ 83 f** |
| fade out | t 190→210, **10 f** | t 194→? , untimed end | frames 199211, **13 f** |
A logo whose bloom layer fades in over 7.5 frames while the logo itself takes 80
is not a thing anyone authored. This replicates the 26× result already in
[`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md) and adds the reason.
⚠️ **Reach.** The capture's absolute frame numbers sit about 18 frames later than
the glow-derived calibration `t = 2f 171` predicts for the *logo* — the
fade-out starts at frame 199 where the calibration says 180.5. Durations match;
the two elements' groups do not appear to start on the same frame. That offset is
**not explained here** and is not needed for this result, which is about which
word is which. It is the same lateness `ui-keyframe-time-unit.md` records as
"17 frames late" and leaves open.
### And the corpus had already used this reading without noticing
[`ui-title-build-map.md`](ui-title-build-map.md)'s splash timing table — written
on 2026-08-28 against a 10 fps capture, and agreeing with it to ±0.1 s — reads
`palogo_sqex.t32`'s declared `[15 30 235 239 251 255 ]` as
| | the table says | the OLD reading actually gives | the corrected reading gives |
|---|---|---|---|
| hold at α=255 | `30 → 235` = **3.42 s** ✅ measured ≈3.5 s | `235 → 239` = **0.07 s** | `30 → 235` = **3.42 s** |
| fade out | `235 → 255` = **0.33 s** ✅ measured ≈0.3 s | `239 → ?` — the α=0 pose is **untimed** | `235 → 255` = **0.33 s** |
Its author paired each time with the pose that *reaches* it, by eye, because that
is the only pairing that produces a sensible splash — and then checked it against
a capture, which agreed. The record layout is what that pairing was.
## What changed in the code
[`crates/sylpheed-formats/src/ui_layout.rs`](../../crates/sylpheed-formats/src/ui_layout.rs):
* `parse_placements` reads `header + 8` as pose 0's time and the previous
stride's `+36` as pose `k`'s. Every pose gets a time.
* `SYLPHEED_KF_TIME_SHIFT` is gone. `SYLPHEED_KF_TIME_LEGACY=1` restores the old
reading for A/B work.
* `Keyframe::time` stays `Option<u32>` only so the legacy gate still type-checks.
Under the default it is always `Some`.
New test, disc-gated: `tests/ui_keyframe_record_disc.rs` — every pose timed and
ordered (15 493 groups), and ≥ 45 % of multi-segment alpha ramps at a constant
rate (the old reading scores 0 %).
⚠️ `tests/ui_header_time_disc.rs` needed one line: bundles whose every group is a
single static pose now report `max_time = 0` where before they reported no time
at all, and 546 of them were swamping the ratio histogram's zero bucket. The
result it guards **strengthened** — the bound `max_time ≤ header +0x08` now holds
over **2 859** bundles instead of 2 313, still with **0** violations, and the
newly readable times are the latest in every group.
## What is NOT established
**The executable's own parser was not found.** Reach: queried the disassembly
database for functions carrying a `mulli` by 40 (the record stride) and by 60
(the declaration-entry stride) — 26 functions have the first, none have both, and
PowerPC compilers synthesise both constants as shift-adds, so the query is weak
rather than negative. Nothing here rests on a database row; every number above
comes from the disc bytes or from a committed capture. Finding the interpolator
would upgrade this from *decoded from the container's own arithmetic and a
disc-wide census* to *decoded from the code*, and would also settle the 18-frame
group-start offset above.
## For the port
The pose values you already have do not move. What moves is **when** each pose is
reached:
* pose `k`'s time is the word **before** it, not after it;
* pose 0 has a time — usually 0, but 5 058 groups on the disc start late;
* the **last** pose has a time, so an exit ramp now has an end. Anything you
authored to cover "the final keyframe has no time" can come out.
Static composites are unaffected: `screen render` produces byte-identical output
on all 12 `GP_TITLE` builds.

View File

@@ -1,5 +1,21 @@
# What a keyframe time is worth, and what shape the ramp has
> ## ✅ 2026-08-29 — the argument on this page about WHICH BLOCK OWNS A TIME is over
>
> It was never a choice between two readings. A placement group is
> `frames` records of `{u32 time; 36-byte pose}` after an 8-byte header, so the
> time word **precedes** its pose; the group's "lead-in word" is pose 0's time,
> and **no** time is missing. `SYLPHEED_KF_TIME_SHIFT` had the association right
> and pose 0 untimed, which is the only reason it looked like it cost build 7
> 13.1 % of its pixels. Decoded disc-wide, with controls, in
> [`ui-keyframe-record-layout.md`](ui-keyframe-record-layout.md); the gate is now
> `SYLPHEED_KF_TIME_LEGACY=1`.
>
> **Everything else on this page stands** — the ramp is linear, the clock advances
> 2 units per submitted frame, and `1 unit = 1/60 s` is measured. Read the
> sections below with that correction applied: where a table pairs a time with a
> pose, the pairing is the corrected one.
**Status:**`CONFIRMED` for the two things the port is blocked on — the ramp is
**linear**, and the animation clock advances **2 keyframe time units per frame the
game submits**. 🟡 the conversion to *seconds* rests on one further step: the game

View File

@@ -2,7 +2,11 @@
**Status:**`CONFIRMED` for the four screens the boot path actually shows
(title art, `PRESS Ⓐ BUTTON`, main menu, `EXTRAS`); 🟡 `PROBABLE` for their
Japanese twins; ❔ open for the two `DELTASABER` plates.
Japanese twins.
✅ **2026-08-29 — the two "unidentified `DELTASABER` plates" are the LOADING
SCREEN**, and the ❔ on them is withdrawn. See
[below](#-the-deltasaber-plates-are-the-loading-screen-and-there-are-two-of-them).
Answers [MISSION Q2](../port/MISSION.md). The previous statement — *"build 4
title, 5 main menu, 6/8/9 submenus"* — is **partly wrong** and is withdrawn:
@@ -53,7 +57,7 @@ Contact sheet of every render:
| build | what it is | confirmed how |
|---|---|---|
| 0, 1 | a `DELTASABER / SYLPHEED A.I.` plate low-left on black, no background | ❔ **not observed running.** Never seen in the boot path, the main menu, `EXTRAS` or `MISSION SELECT` |
| 0, 1 |**the LOADING screen**, plain — a `DELTASABER / SYLPHEED A.I.` plate low-left on black, no background. 7 elements, all named `pgloading_*` | ✅ **decoded from the declaration table**, not observed running |
| **2**, 3 | the `PRESS Ⓐ BUTTON` plate — **an overlay build of its own**, not a state of build 4 | ✅ seen composited over build 4 on the live title, at the same rect our render puts it |
| **4** | title art, English (`PROJECT SYLPHEED`, ™, `(C)2006,2007 SQUARE ENIX`) | ✅ [`live-title-press-a.png`](captures/title-builds/live-title-press-a.png) |
| 7 | the same, Japanese | 🟡 renders as the JP twin of build 4; the container runs an English locale, so it was not seen |
@@ -61,7 +65,96 @@ Contact sheet of every render:
| 8 | the same, Japanese | 🟡 as above |
| **6** | the `EXTRAS` submenu, English: MISSION SELECT / MOVIE THEATER / BACK, footer `Ⓐ : OK Ⓑ : Back` | ✅ [`live-extras.png`](captures/title-builds/live-extras.png) |
| 9 | the same, Japanese | 🟡 as above |
| 10, 11 | the same `DELTASABER` plate as build 0, over a dark circuit-line background | ❔ **not observed running** |
| 10, 11 | **the LOADING screen**, dressed — the same plate over a dark circuit-line background. 10 elements, the same seven plus `pgloading_eff00.prm`, `pgloading_loop5.rat` and `pgloading_baseeff.t32` | ✅ **decoded from the declaration table**, not observed running |
## ✅ The `DELTASABER` plates are the LOADING screen, and there are two of them
**2026-08-29. Decoded — the authors' own element names, out of the declaration
table.** No renderer, no capture, no inference. Every element of all four
bundles is prefixed `pgloading_`:
| build | entry | elements |
|---|---|---|
| 0, 1 | 0, 1 | `pgloading_loop1.rat` `pgloading_loop3.rat` `pgloading_str.t32` `pgloading_line.t32` `pgloading_loop4.rat` `pgloading_eff01.t32` `pgloading_eff02.t32` |
| 10, 11 | 12, 15 | the same seven, plus `pgloading_eff00.prm` `pgloading_loop5.rat` `pgloading_baseeff.t32` |
Reproduce:
```bash
sylpheed-cli screen info --build 0 "$SYLPHEED_DISC/dat/GP_TITLE.pak"
sylpheed-cli screen info --build 10 "$SYLPHEED_DISC/dat/GP_TITLE.pak"
```
`DELTASABER / SYLPHEED A.I.` is the artwork on `pgloading_str.t32` — the loading
screen's caption, not the screen's identity. Reading the picture named the plate;
reading the file names the screen.
⚠️ This also removes the reason the pair was open. The previous row said *"never
seen in the boot path, the main menu, `EXTRAS` or `MISSION SELECT`… a mission
load is the remaining candidate"*. It is a **loading** screen: it is not supposed
to appear on any of those, and the remaining candidate was right.
### 🟡 Which is `LOADING` and which is `LOADING2` — the executable names five
`sub_821C4EB0` builds `GamePart_Title` and asks its table for five sub-entries in
this order, one `bl sub_821CEDF8` each, setting an error flag on any failure:
| call site | string | VA |
|---|---|---|
| `0x821C503C` | `TITLE_SCREEN` | `0x820A3D3C` |
| `0x821C5068` | `BUTTON` | `0x820A339C` |
| `0x821C5090` | `TITLE_MENU` | `0x820A3D30` |
| `0x821C50BC` | `LOADING` | `0x820A214C` |
| `0x821C50E4` | `LOADING2` | `0x820A3D24` |
**Checked against the image, not just the database**`/image/sylpheed.pe` at
`0x821C503C`, `0x821C5090`, `0x821C50BC`, `0x821C50E4` reads `38aa3d3c`,
`3baa3d30`, `38aa214c`, `38aa3d24`, exactly the `addi rX, r10, <lo16>` the
database shows, with `r10 = 0x820A0000` set two instructions earlier.
That is five named title-side screens, and `BUTTON` is what the `PRESS Ⓐ BUTTON`
overlay would be called — which the corpus had already isolated as a build of its
own (pair B) on capture evidence alone.
🟡 **Two loading screens named, two loading bundles found — but nothing observed
maps one to the other.** `LOADING` is the 7-element plain plate and `LOADING2`
the 10-element dressed one *if* the suffix means "the second, richer variant",
and that is a guess about a name. The port should treat the pairing as
**undecided** and not carry either name into an asset path.
Also note the lookup is **not** by pak TOC hash: at `0x821C5118``0x821C512C` the
game hashes `BASE_INFO` and `TITLE_MENU` with `sub_82455C78` (the same name-hash
[`hash.rs`](../../crates/sylpheed-formats/src/hash.rs) implements) and splices
the two 32-bit results into one 64-bit key. So these strings key a **sub-table
inside the GamePart's own record**, not an archive entry — which is why
`pak list` resolves none of `GP_TITLE`'s 16 names.
## 🟡 Which member of each pair is English — the archive is packed in two halves
The port asked which member of pairs A, B and H is which locale, since those
three render byte-identically and no capture can tell them apart. The **data
segment** can:
| | entries | data offset |
|---|---|---|
| first half | 0, 2, 4, 5, 6, 10, 11, 12 | 0 … 507 904 |
| second half | 1, 3, 7, 8, 9, 13, 14, 15 | 6 078 464 … 10 868 736 |
Every one of the eight pairs has **exactly one member in each half**, and in all
three pairs whose language is visible — C (4/7), D (5/8), E (6/9) — the English
build is the one in the **first** half. `GP_TITLE.p00` is one locale's eight
bundles followed by the other's; the TOC interleaves them only because it is
sorted by name hash.
So: **first half = English**, i.e. builds 0, 2, 4, 5, 6, 10 are English and 1, 3,
7, 8, 9, 11 are Japanese.
⚠️ 🟡 not ✅. The rule is 8/8 structurally consistent and 3/3 where it can be
checked, but the three pairs it is *used* for are exactly the three it cannot be
checked on. Reach of the negative: nothing in the bundle bytes themselves — the
header, the declaration table, the element names — differs between the twins of
pairs A, B and H at all; they render byte-identical PNGs. If a locale marker
exists it is not in the bundle.
## ✅ The splash — the four "non-build" entries, rendered

View File

@@ -0,0 +1,234 @@
"""Time the whole boot, and measure the PRESENTATION RATE per screen.
Two jobs, one oracle session, because they need each other.
1. **The boot timeline the port asked for.** Every screen-to-screen transition
from launch to the main menu, wall-clock, with the black holds marked. The
port paces its boot off `ScreenView.settle_time()` = a group's `rest.t`, and
`rest.t` is NOT when a screen settles (docs/re/REFUTED.md) — so every screen's
dwell is currently wrong by an unknown amount.
2. **Frames, not seconds.** Two pages of the corpus measure the same declared
120 keyframe units during a static hold and disagree by 2 %: settle→plate is
2.135 s (28.10 fps implied) and one focus-ring revolution is 2.177 s (27.56
implied). Either the presentation rate differed between those sessions, or one
interval is not 120 units. The two were taken on DIFFERENT SCREENS, so this
measures the rate on each — with the game's own frame counter, not a guess
about what changes between grabs.
`--log_ui_draws=true --ui_draw_capture_frames=N` makes Canary log
`[UI-CAP] capture armed` and then `[UI-CAP] done: D draws over F frames`. Timing
between those two lines in its own stdout gives frames/second directly, and it
re-arms (the log index is `{:02d}`), so one session can measure several screens.
⚠️ The instrument can perturb what it measures — writing a draw log costs the
emulator something. Control built in: the ring period is measured both DURING a
capture and OUTSIDE one, and a rate that is an artefact of logging would move it.
boot_timeline_probe.py --control
boot_timeline_probe.py --run SECONDS OUT.tsv CANARY_STDOUT [shots_dir]
"""
import os
import subprocess
import sys
import time
import numpy as np
from PIL import Image
SD = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SD)
import title_timing_probe as T # noqa: E402 (same crop, same ZNCC, same controls)
REPO = os.path.dirname(os.path.dirname(SD))
CAP = os.path.join(REPO, "docs", "re", "captures")
# The boot shows two splashes before the movie; both are committed captures.
T.REFS["splash_pub"] = "title-builds/live-splash-publisher.png"
T.REFS["splash_dev"] = "title-builds/live-splash-developer.png"
T._R.clear()
RATE = 8
CAPTURE_FRAMES = int(os.environ.get("UICAP_FRAMES", "300"))
def control():
"""Every control title_timing_probe has, plus the two splashes."""
T.CONTROLS.extend([
(os.path.join(CAP, "title-builds/live-splash-publisher.png"), "splash_pub"),
(os.path.join(CAP, "title-builds/live-splash-developer.png"), "splash_dev"),
])
return T.control()
def _tail(path, seen):
"""New lines appended to the emulator's stdout since the last call."""
try:
with open(path, "rb") as f:
f.seek(seen)
b = f.read()
return b.decode("utf-8", "replace"), seen + len(b)
except OSError:
return "", seen
def arm_capture(log_path, seen, timeout=90.0):
"""Press F10, then time the emulator's own armed->done lines.
Timed between the two LOG lines, not from the keypress: the arm latency is
then excluded rather than folded into the rate.
"""
win = subprocess.run(["xdotool", "search", "--name", "Xenia-canary"],
capture_output=True, text=True).stdout.split()
if not win:
return None, seen
w = win[-1]
subprocess.run(["xdotool", "windowactivate", w], capture_output=True)
subprocess.run(["xdotool", "key", "--window", w, "F10"], capture_output=True)
subprocess.run(["xdotool", "key", "F10"], capture_output=True)
t_armed = t_done = None
frames = draws = None
deadline = time.time() + timeout
while time.time() < deadline:
chunk, seen = _tail(log_path, seen)
for line in chunk.splitlines():
if "[UI-CAP] capture armed" in line and t_armed is None:
t_armed = time.time()
elif "[UI-CAP] done" in line and t_armed is not None:
t_done = time.time()
# "[UI-CAP] done: 1526 draws over 300 frames"
parts = line.replace(":", " ").split()
try:
draws = int(parts[parts.index("done") + 1])
frames = int(parts[parts.index("over") + 1])
except (ValueError, IndexError):
pass
if t_done:
break
time.sleep(0.02)
if not (t_armed and t_done and frames):
return None, seen
dt = t_done - t_armed
return {"frames": frames, "draws": draws, "seconds": dt, "fps": frames / dt}, seen
def run(limit, out_path, log_path, shots_dir):
os.makedirs(shots_dir, exist_ok=True)
n = T.W * T.H * 3
p = T.open_stream()
t0 = time.time()
seg = t0
frames = 0
seen = 0
ev = []
rates = {}
state = "boot"
last_label = None
last_gray = None
prev_mean = -1.0
fh = open(out_path, "w")
fh.write("#t\tglyph\tmean\tmotion\t" + "\t".join(T.REFS) + "\tlabel\n")
def mark(name, t=None):
t = time.time() - t0 if t is None else t
ev.append((name, t))
print(f"EVENT {name} t={t:.3f}", flush=True)
return t
while time.time() - t0 < limit and state != "done":
now = time.time()
# Restart only while still waiting; never across a measured interval.
if state == "boot" and now - seg > 30:
p.kill(); p = T.open_stream(); seg = now
fh.write(f"#restart\t{now - t0:.3f}\n")
buf = p.stdout.read(n)
if len(buf) < n:
p.kill(); p = T.open_stream(); seg = time.time(); continue
t = time.time() - t0
rgb = np.frombuffer(buf, np.uint8).reshape(T.H, T.W, 3)
g = T.gray_of(rgb)
gl = T.glyph(rgb)
sc = T.scores(g)
lb = T.label(sc)
surf = T.surface(g)
mn = float(surf.mean())
mo = float(np.abs(surf[::8, ::8] - last_gray).mean()) if last_gray is not None else -1.0
last_gray = surf[::8, ::8].copy()
frames += 1
fh.write(f"{t:.3f}\t{gl}\t{mn:.3f}\t{mo:.3f}\t"
+ "\t".join(f"{sc[k]:+.4f}" for k in T.REFS) + f"\t{lb}\n")
# Every label change and every entry/exit from pure black is a boot event.
blk = "black" if mn < 1.0 else lb
if blk != last_label:
mark(f"screen:{blk}", t)
last_label = blk
if blk in ("splash_pub", "splash_dev", "title_noplate", "menu"):
Image.fromarray(rgb).save(os.path.join(shots_dir, f"boot-{blk}.png"))
if state == "boot":
if lb in ("title_noplate", "title_plate") and 0 <= mo < 2.0 and gl >= 100:
mark("title_settled", t); state = "title"
elif state == "title":
if gl >= T.PLATE_GLYPH:
mark("plate", t); state = "plate_hold"; hold_from = t
elif state == "plate_hold":
if t - ev[-1][1] > 3.0:
fh.flush()
r, seen = arm_capture(log_path, seen)
rates["title"] = r
mark(f"rate_title={r and round(r['fps'], 3)}")
state = "press"
elif state == "press":
tp = T.tap("A") - t0
ev.append(("pressA", tp)); print(f"EVENT pressA t={tp:.3f}", flush=True)
state = "toMenu"
elif state == "toMenu":
if lb == "menu":
mark("menu", t); state = "menuSettle"; menu_at = t
elif state == "menuSettle":
if t - ev[-1][1] > 6.0:
fh.write(f"#ring_free_start\t{t:.3f}\n")
state = "ringFree"; ring_from = t
elif state == "ringFree":
# 12 s of ring with NOTHING else running -- the outside-capture control
if t - ring_from > 12.0:
fh.write(f"#ring_free_end\t{t:.3f}\n")
fh.flush()
r, seen = arm_capture(log_path, seen)
rates["menu"] = r
mark(f"rate_menu={r and round(r['fps'], 3)}")
fh.write(f"#ring_capture_end\t{time.time()-t0:.3f}\n")
state = "ringAfter"; after_from = time.time() - t0
elif state == "ringAfter":
if t - after_from > 12.0:
state = "done"
p.kill()
dt = time.time() - t0
fh.write(f"#summary\tframes={frames}\telapsed={dt:.1f}\tfps={frames/dt:.2f}\trequested={RATE}\n")
for k, r in rates.items():
if r:
fh.write(f"#rate\t{k}\tframes={r['frames']}\tdraws={r['draws']}"
f"\tseconds={r['seconds']:.3f}\tfps={r['fps']:.4f}\n")
else:
fh.write(f"#rate\t{k}\tFAILED\n")
for name, t in ev:
fh.write(f"#event\t{name}\t{t:.3f}\n")
fh.close()
print(f"\n{frames} frames in {dt:.1f}s = {frames/dt:.2f} fps (requested {RATE})")
for k, r in rates.items():
print(f" presentation rate on {k}: "
+ (f"{r['fps']:.4f} fps ({r['frames']} frames in {r['seconds']:.3f} s, "
f"{r['draws']} draws)" if r else "FAILED"))
return 0
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--control":
sys.exit(control())
if len(sys.argv) > 4 and sys.argv[1] == "--run":
sys.exit(run(float(sys.argv[2]), sys.argv[3], sys.argv[4],
sys.argv[5] if len(sys.argv) > 5 else "/sylph-home/re/shots/boot-timeline"))
print(__doc__)
sys.exit(2)

81
tools/re-capture/dump_image.py Executable file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Dump the guest's DECOMPRESSED executable image out of live Xenia memory.
Why this exists: the static PPC route the corpus is built on ran against a
disassembly database at `/work/xenia-rs/sylpheed.db`, and that file is **not in
this container** — the same migration that took the Xenia storage root. Without
it, every finding that cites a `sub_82xxxxxx` is unre-checkable.
`/disc/default.xex` cannot substitute: it is encrypted and LZX-compressed. Its
header is intact (`XEX2`, original PE name `default.pe`) and everything after is
noise — `strings` finds **zero** occurrences of `GamePart` in it.
Xenia decompresses, decrypts and relocates the image at load, so a running guest
holds exactly the flat VA image the corpus calls the `.pe`. Dump it once and the
static route works offline, with no emulator and no disc.
Validated on write, and both checks are the corpus's own, not this tool's:
* `0x820A1630` must hold the **GamePart id table** — 29 pointers into `.rdata`
resolving to `GP_TITLE` … `GP_TEST`, with `GP_CHALLENGE` at id 26
(docs/re/challenge-mission-gate.md);
* the image must contain the Xbox 360 D3D runtime's own error strings, which a
mis-based or partial dump does not.
dump_image.py [OUT.pe] # with Canary running
"""
import os
import struct
import sys
SD = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SD)
import gmem # noqa: E402
LO, HI = 0x82000000, 0x82400000
BASE = LO
EXPECT = {0: "GP_TITLE", 3: "GP_LOAD", 11: "GP_READY_ROOM", 26: "GP_CHALLENGE", 28: "GP_TEST"}
def validate(buf):
def s_at(va):
o = va - BASE
e = buf.find(b"\0", o, o + 64)
return buf[o:e].decode("ascii", "replace")
bad = []
for i, want in EXPECT.items():
p = struct.unpack_from(">I", buf, 0x820A1630 - BASE + 4 * i)[0]
got = s_at(p) if LO <= p < HI else f"<ptr {p:#x} out of range>"
if got != want:
bad.append(f"GamePart id {i}: expected {want!r}, got {got!r}")
if buf.count(b"ERR[D3D]") < 1:
bad.append("no Xbox 360 D3D runtime strings — this is not the game image")
return bad
def main(out):
path = gmem.mem_path()
off = gmem.va_to_off(LO)
with open(path, "rb") as f:
f.seek(off)
buf = f.read(HI - LO)
if len(buf) < HI - LO:
print(f"short read: {len(buf)} of {HI - LO}", file=sys.stderr)
return 1
bad = validate(buf)
for b in bad:
print("FAIL:", b, file=sys.stderr)
if bad:
return 2
open(out, "wb").write(buf)
pages = sum(1 for i in range(0, len(buf), 4096) if any(buf[i:i + 4096]))
print(f"wrote {out} {len(buf)} bytes VA {LO:#x}..{HI:#x}")
print(f"validated: GamePart id table + D3D runtime strings; "
f"{pages}/{len(buf)//4096} non-empty 4K pages")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "/sylph-home/re/sylpheed-image.pe"))

View File

@@ -4,8 +4,12 @@
The screen-transition fade lives here -- see docs/re/screen-transitions.md.
Usage: PAK=<pak> fade_quads.py [build...] (default: GP_TITLE, builds 2 4 5 6)"""
import struct, sys, glob, os, zlib
sys.path.insert(0, "/work/Syplheed-Reborn/tools/re-capture")
src = open("/work/Syplheed-Reborn/tools/re-capture/regn_decode.py").read()
# The monorepo migration left this pointing at /work/Syplheed-Reborn, a path
# that no longer exists -- so the command screen-transitions.md cites as its
# evidence could not be re-run. Resolve beside this file instead.
_SD = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _SD)
src = open(os.path.join(_SD, "regn_decode.py")).read()
exec(src.split("# ── POF0")[0])
DECL_AT, DECL_ENTRY, KF = 0x20, 60, 40
@@ -34,7 +38,10 @@ def parse(bundle):
groups[idx]=g; pos=end
return names, groups
pak = os.environ.get("PAK", "/work/sylph_extract/dat/GP_TITLE.pak")
# ...and the default pak pointed at /work/sylph_extract, which the disc mount
# replaced. $SYLPHEED_DISC is what run-canary and sylpheed-cli both use.
pak = os.environ.get("PAK") or os.path.join(
os.environ.get("SYLPHEED_DISC", "/disc"), "dat", "GP_TITLE.pak")
E = pak_entries(pak)
E = [b for h,b in E]
# build index -> pak entry index, from `screen list`: 0..9 then 12, 15

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Does the main menu's focus ring KEEP spinning, or is it drawn once and held?
The question is not "is the ring rotated" -- one oracle frame already showed it
at a large angle (docs/re/structures/ui-button-focus-record.md). It is whether
that rotation is ANIMATED while a button sits focused, which is what decides
whether a port draws a static ring or runs a loop.
Instrument: a live x11grab filmstrip and the per-pixel TEMPORAL standard
deviation of the frames while nothing is touched. A spinning ring makes its
own box vary; a held one does not. No angle is estimated anywhere -- the
centroid estimator that would do that fails its own control by up to 19.8 deg
(same page), so this probe measures presence-of-change instead, which is the
question actually asked.
NO FIXED PIXEL BOXES. xenia's window has a menu bar and the game surface is
1279x675 inside a 1280x720 root, so game coordinates do not address grab
coordinates. This probe saves whole-frame accumulators; `focus_ring_report.py`
aligns them against a committed capture first and only then reads boxes.
Phases: A = 20 s untouched, then d-pad DOWN, then C = 12 s untouched.
The d-pad press is the POSITIVE CONTROL: |mean(A) - mean(C)| must fire at the
two ring locations, or a null in phase A is a dead instrument, not a finding.
Usage: focus_ring_probe.py OUTDIR
"""
import os, subprocess, sys, time
import numpy as np
from PIL import Image
W, H = 1280, 720
SD = os.path.dirname(os.path.abspath(__file__))
OUT = sys.argv[1] if len(sys.argv) > 1 else "/sylph-home/re/ringcap"
os.makedirs(OUT, exist_ok=True)
RESTART_S = 25 # a long-lived x11grab stream stalls and repeats frames
def open_stream():
return subprocess.Popen(
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
"-video_size", f"{W}x{H}", "-i", ":98", "-r", "4",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, bufsize=W * H * 3 * 2)
class Stream:
def __init__(self):
self.p = open_stream(); self.seg = time.time()
def read(self):
if time.time() - self.seg > RESTART_S:
self.p.kill(); self.p = open_stream(); self.seg = time.time()
buf = self.p.stdout.read(W * H * 3)
if len(buf) < W * H * 3:
self.p.kill(); self.p = open_stream(); self.seg = time.time()
return None
return np.frombuffer(buf, np.uint8).reshape(H, W, 3)
def close(self):
try: self.p.kill()
except Exception: pass
sys.path.insert(0, SD)
from screen_match import classify_array # controlled: 8/8, incl. the movie
# frames that broke the old oracle
def collect(st, secs, tag):
"""Whole-frame temporal mean and std over `secs`, plus a PNG filmstrip."""
t0 = time.time(); n = 0
acc = acc2 = None
next_shot = 0.0
while True:
el = time.time() - t0
if el >= secs:
break
a = st.read()
if a is None:
continue
f = a.astype(np.float64)
acc = f.copy() if acc is None else acc + f
acc2 = f * f if acc2 is None else acc2 + f * f
if el >= next_shot:
Image.fromarray(a).save(f"{OUT}/{tag}-t{el:05.1f}.png")
next_shot = el + 4.0
n += 1
mean = acc / n
std = np.sqrt(np.maximum(acc2 / n - mean * mean, 0))
np.save(f"{OUT}/{tag}-mean.npy", mean.astype(np.float32))
np.save(f"{OUT}/{tag}-std.npy", std.astype(np.float32))
Image.fromarray(mean.astype(np.uint8)).save(f"{OUT}/{tag}-mean.png")
# a visible std map, scaled x8 and clipped -- an artefact a human can look at
Image.fromarray(np.clip(std * 8, 0, 255).astype(np.uint8)).save(f"{OUT}/{tag}-std8.png")
print(f"[{tag}] {n} frames in {secs:.0f}s = {n/secs:.2f} fps; "
f"whole-frame std mean {std.mean():.4f} max {std.max():.2f}", flush=True)
return mean, std, n
def main():
st = Stream()
t0 = time.time(); seen = None; last = None; skipped = False
# ONE (A) ~45 s in skips the intro movie: measured, title at ~57 s against a
# ~193 s no-input baseline (HANDOFF, movie-binding.md). HAMMERING is what
# breaks the boot -- 88 presses left a permanent black screen -- so exactly
# one, and only once.
while time.time() - t0 < 620:
a = st.read()
if a is None:
continue
last = a
el = time.time() - t0
if not skipped and el > 45:
subprocess.run(["python3", f"{SD}/pad.py", "tap", "A", "0.3"], check=False)
skipped = True
print(f"t={el:6.1f}s one (A) to skip the intro movie", flush=True)
continue
c, sc = classify_array(a)
if c != seen:
print(f"t={el:6.1f}s screen={c} " +
" ".join(f"{k}={v:+.3f}" for k, v in sc.items()), flush=True)
seen = c
if c == "title":
break
if seen != "title":
print("NEVER REACHED THE TITLE"); st.close(); return 1
Image.fromarray(last).save(f"{OUT}/00-title.png")
subprocess.run(["python3", f"{SD}/pad.py", "tap", "A", "0.3"], check=False)
print("(A) on the title", flush=True)
t1 = time.time(); got = False
while time.time() - t1 < 150:
a = st.read()
if a is None:
continue
c, sc = classify_array(a)
if c == "menu":
got = True; break
if not got:
print("NO MENU AFTER A"); st.close(); return 2
time.sleep(4) # let the menu's ~1 s fade-in and element ramps settle
a = st.read()
if a is not None:
Image.fromarray(a).save(f"{OUT}/01-menu.png")
print("AT MAIN MENU", flush=True)
mA, sA, nA = collect(st, 20, "A")
subprocess.run(["python3", f"{SD}/pad.py", "dpad", "down"], check=False)
print(">>> d-pad DOWN pressed", flush=True)
time.sleep(2.0)
mC, sC, nC = collect(st, 12, "C")
d = np.abs(mA - mC)
np.save(f"{OUT}/AC-absdiff.npy", d.astype(np.float32))
Image.fromarray(np.clip(d * 4, 0, 255).astype(np.uint8)).save(f"{OUT}/AC-absdiff4.png")
print(f"[A-vs-C] absdiff mean {d.mean():.4f} max {d.max():.2f}", flush=True)
st.close()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Read focus_ring_probe.py's accumulators, after ALIGNING them to game space.
A grab is the whole root window; game coordinates only address it once the
window chrome offset is measured. This script measures that offset by
correlating the run's own mean frame against the committed `live-main-menu.png`
over a +/-12 px search, and refuses to report anything if the alignment is poor.
Then, in game coordinates:
ring boxes -- 80x80 around each button's declared rest position; the ring
`ptbtneff01` is 42x46 and sits left of the label
static boxes -- `ptmsg` (one untimed keyframe) and a background corner:
the NEGATIVE controls, which must read sensor noise
positive ctrl -- |mean(A) - mean(C)| across the d-pad press must fire at the
two rings that changed state, or a null in A is a dead
instrument rather than a finding.
"""
import os, sys
import numpy as np
from PIL import Image
OUT = sys.argv[1] if len(sys.argv) > 1 else "/sylph-home/re/ringcap"
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
REF = os.path.join(REPO, "docs/re/captures/title-builds/live-main-menu.png")
BTN_Y = [162, 242, 322, 401, 482]
LABEL = ["NEW GAME", "LOAD GAME", "TUTORIAL", "OPTIONS", "EXTRAS"]
BOXES = {}
for i, y in enumerate(BTN_Y):
BOXES[f"ring{i+1} ({LABEL[i]})"] = (480, y - 20, 560, y + 60)
BOXES["ptmsg footer [static ctl]"] = (527, 595, 773, 633)
BOXES["bg corner [static ctl]"] = (10, 10, 130, 130)
BOXES["button1 label [same row]"] = (560, 142, 760, 202)
def gray(a):
return (0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]).astype(np.float32)
def zncc(x, y):
x = x - x.mean(); y = y - y.mean()
d = np.sqrt((x * x).sum() * (y * y).sum())
return float((x * y).sum() / d) if d else 0.0
def align(mean_rgb, ref_rgb):
"""Measure (dy,dx) taking GAME coords -> GRAB coords. Returns (dy,dx,corr)."""
g = gray(mean_rgb); r = gray(ref_rgb)
rh, rw = r.shape
best = (None, None, -1.0)
for dy in range(30, 60): # chrome is ~45 rows
for dx in range(-12, 13):
if dy + rh > g.shape[0] or dx < 0 or dx + rw > g.shape[1]:
continue
c = zncc(g[dy:dy + rh, dx:dx + rw], r)
if c > best[2]:
best = (dy, dx, c)
return best
def main():
mA = np.load(f"{OUT}/A-mean.npy"); sA = np.load(f"{OUT}/A-std.npy")
mC = np.load(f"{OUT}/C-mean.npy"); sC = np.load(f"{OUT}/C-std.npy")
ref = np.array(Image.open(REF).convert("RGB")).astype(np.float32)
dy, dx, corr = align(mA, ref)
print(f"alignment: game(0,0) sits at grab({dx},{dy}); ZNCC {corr:+.4f}")
if corr < 0.80:
print("ALIGNMENT TOO POOR — refusing to report boxes"); return 1
print(f" (independent check: the window chrome measured 45 rows)\n")
def box(arr, b):
x0, y0, x1, y1 = b
return arr[y0 + dy:y1 + dy, x0 + dx:x1 + dx, :]
d = np.abs(mA - mC)
print(f"{'box':<30} {'A std':>9} {'A p99.9':>9} {'C std':>9} "
f"{'|A-C| mean':>11} {'|A-C| max':>10}")
print("-" * 84)
rows = {}
for k, b in BOXES.items():
a_s = box(sA, b); c_s = box(sC, b); dd = box(d, b)
rows[k] = (float(a_s.mean()), float(np.percentile(a_s, 99.9)),
float(c_s.mean()), float(dd.mean()), float(dd.max()))
print(f"{k:<30} {rows[k][0]:9.3f} {rows[k][1]:9.3f} {rows[k][2]:9.3f} "
f"{rows[k][3]:11.3f} {rows[k][4]:10.2f}")
noise = max(rows["ptmsg footer [static ctl]"][0],
rows["bg corner [static ctl]"][0])
print(f"\nnegative-control noise floor (max of the two static boxes): {noise:.3f}")
print("A box only counts as MOVING if its phase-A std clears that floor.\n")
for k in BOXES:
if "ctl" in k:
continue
v = rows[k][0]
print(f" {k:<30} A std {v:7.3f} = {v/noise:6.2f}x the noise floor"
f" {'MOVING' if v > 3*noise else 'static'}")
# visual artefacts, cropped to the game surface
for tag, arr, sc in (("A-std", sA, 8), ("C-std", sC, 8), ("AC-absdiff", d, 4)):
g = arr[dy:dy + 675, dx:dx + 1279, :]
Image.fromarray(np.clip(g * sc, 0, 255).astype(np.uint8)).save(f"{OUT}/{tag}-game.png")
Image.fromarray(mA[dy:dy + 675, dx:dx + 1279, :].astype(np.uint8)).save(f"{OUT}/A-mean-game.png")
Image.fromarray(mC[dy:dy + 675, dx:dx + 1279, :].astype(np.uint8)).save(f"{OUT}/C-mean-game.png")
print(f"\nwrote game-space artefacts to {OUT}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Re-measure two menu facts from the COMMITTED oracle captures — no disc needed.
1. Which screens advertise Ⓑ in their footer legend.
The pad glyphs are saturated green (Ⓐ) and red (Ⓑ) discs on a blue field,
so a colour test finds them without knowing where the footer is.
2. Whether a dim MISSION SELECT row is LOCKED or merely UNFOCUSED.
Three brightness levels discriminate; the all-unlocked capture is the
control that separates them.
Usage: python3 tools/re-capture/footer_and_locked_rows.py [repo-root]
"""
import sys
import pathlib
import numpy as np
from PIL import Image
ROOT = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
CAP = ROOT / "docs/re/captures"
def glyph_masks(rgb):
r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
green = (g > 110) & (g > r + 45) & (g > b + 45)
red = (r > 110) & (r > g + 45) & (r > b + 45)
return green, red
def blobs(mask, gap=20):
ys, xs = np.nonzero(mask)
if len(xs) == 0:
return []
o = np.argsort(xs)
xs, ys = xs[o], ys[o]
out, start = [], 0
for i in range(1, len(xs) + 1):
if i == len(xs) or xs[i] - xs[i - 1] > gap:
s = slice(start, i)
out.append((int(xs[s].min()), int(xs[s].max()),
int(ys[s].min()), int(ys[s].max()), i - start))
start = i
return out
def footers():
print("== 1. footer legends: does the screen advertise Ⓑ? ==")
print(f"{'capture':46} {'Ⓐ px':>7} {'Ⓑ px':>7} verdict")
shots = [
("title-builds/live-main-menu.png", "main menu"),
("title-builds/live-main-menu-options-focused.png", "main menu (OPTIONS focused)"),
("title-builds/live-extras.png", "EXTRAS"),
("difficulty-screen.png", "DIFFICULTY"),
]
for rel, _name in shots:
p = CAP / rel
if not p.exists():
print(f"{rel:46} MISSING")
continue
a = np.asarray(Image.open(p).convert("RGB")).astype(int)
g, r = glyph_masks(a) # WHOLE frame, not a guessed band
verdict = "no Ⓑ anywhere in frame" if r.sum() == 0 else f"Ⓑ at {blobs(r)[0][:2]}"
print(f"{rel:46} {g.sum():7d} {r.sum():7d} {verdict}")
ROW_Y0, ROW_PITCH, ROW_X = 201, 50, (190, 320)
def stage_rows():
print("\n== 2. MISSION SELECT rows: locked, or just unfocused? ==")
shots = [
("mission-select-stage01-only.png", "save with only Stage01 cleared"),
("mission-select-all-story-unlocked.png", "save with the story unlocked"),
("mission-select-ends-at-stage16.png", "unlocked, scrolled to the end"),
]
for rel, note in shots:
p = CAP / rel
if not p.exists():
print(f"{rel:44} MISSING")
continue
a = np.asarray(Image.open(p).convert("L")).astype(float)
p95 = []
for i in range(8):
y = ROW_Y0 + ROW_PITCH * i
p95.append(np.percentile(a[y - 14:y + 14, ROW_X[0]:ROW_X[1]], 95))
print(f"{rel:44} {note}")
print(" row p95: " + " ".join(f"{v:5.0f}" for v in p95))
if __name__ == "__main__":
footers()
stage_rows()

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Disc-wide test of the UI placement-group record layout.
A placement group in a screen bundle is an 8-byte header `{u32 element_index,
u32 frame_count}` followed by `frame_count` records of 40 bytes, each
`{u32 time; 36-byte pose}`. The time word therefore **precedes** the pose it
belongs to.
Our parser's block window starts at the pose, so under the old reading a
block's `+36` word was taken as *its own* time — off by one, which left the
group's final pose (the end of every fade-out) untimed and made the group look
4 bytes short.
This script tests the corrected reading against the whole disc, with controls:
A. monotonicity — prepending the lead-in word to the shifted time series must
give a non-decreasing sequence, for every group.
B. the non-zero lead-ins — a lead-in that is a *time* must be strictly less
than the next time. Control: swap in another group's lead-in from the
same bundle.
C. ramp linearity — within a monotone alpha ramp of >=3 segments, is
d(alpha)/d(time) constant? Keyframe interpolation is linear
(`docs/re/ui-keyframe-time-unit.md`), so a correct time assignment should
make multi-keyframe ramps come out at a constant rate far more often than
an incorrect one.
Usage: python3 tools/re-capture/kf_record_census.py "$SYLPHEED_DISC"/dat/*.pak
"""
import collections
import os
import random
import struct
import sys
import zlib
def be32(b, o):
return struct.unpack_from(">I", b, o)[0]
def load_pak(pak_path):
"""Entries of an IPFB archive, transparently un-Z1'd. See sylpheed-formats::pak."""
idx = open(pak_path, "rb").read()
if idx[:4] != b"IPFB":
return []
n = be32(idx, 4)
data = b""
base = os.path.splitext(pak_path)[0]
for i in range(100):
p = f"{base}.p{i:02d}"
if not os.path.exists(p):
break
data += open(p, "rb").read()
out = []
for k in range(n):
_h, off, sz = struct.unpack_from(">III", idx, 0x10 + 12 * k)
blob = data[off : off + sz]
if blob[:2] == b"Z1":
try:
blob = zlib.decompress(blob[0x0A:])
except Exception:
blob = b""
out.append(blob)
return out
def elem_name(b, o):
s = b[o : o + 28]
z = s.find(b"\0")
return (s[:z] if z >= 0 else s).decode("latin1")
def groups(bundle):
"""(element_index, name, frames, lead_in, W, alphas) per placement group.
`W[k]` is the word at the k-th 40-byte stride's `+36`; `W[-1]` is None
because it lies outside the group. Under the corrected reading the pose
times are `[lead_in] + W[:-1]`.
"""
n = be32(bundle, 0x14)
names = [elem_name(bundle, 0x20 + i * 60) for i in range(n)]
pos = 0x20 + n * 60
out = []
for _ in range(n):
if pos + 8 > len(bundle):
break
idx, frames = be32(bundle, pos), be32(bundle, pos + 4)
if idx >= n or frames == 0 or frames > 4096:
break
lead_in = be32(bundle, pos + 8)
first = pos + 12
end = first + frames * 40 - 4
if end > len(bundle):
break
W, alphas = [], []
for k in range(frames):
blk = first + k * 40
W.append(be32(bundle, blk + 36) if blk + 40 <= end else None)
alphas.append(be32(bundle, blk) >> 24)
out.append((idx, names[idx], frames, lead_in, W, alphas))
pos = end
return out
def is_build(raw):
if raw[:4] != b"RATC" or len(raw) < 0x20:
return False
n = be32(raw, 0x14)
return 0 < n <= 4096 and 0x20 + n * 60 <= len(raw)
def monotone_ramps(alphas, min_segments=3):
out, i, n = [], 0, len(alphas)
while i < n - 1:
if alphas[i] == alphas[i + 1]:
i += 1
continue
d = 1 if alphas[i + 1] > alphas[i] else -1
j = i + 1
while j < n - 1 and (alphas[j + 1] - alphas[j]) * d > 0:
j += 1
if j - i >= min_segments:
out.append((i, j))
i = j
return out
def constant_rate(times, alphas, a, b, tol=0.06):
rates = []
for k in range(a, b):
dt = times[k + 1] - times[k]
if dt <= 0:
return None
rates.append(abs(alphas[k + 1] - alphas[k]) / dt)
mean = sum(rates) / len(rates)
if mean == 0:
return None
return max(abs(r - mean) for r in rates) / mean <= tol
def main(paks):
rnd = random.Random(20260829)
per_bundle = collections.defaultdict(list)
total = mono = 0
ramp_new = [0, 0]
ramp_old = [0, 0]
for p in paks:
for ei, raw in enumerate(load_pak(p)):
if not is_build(raw):
continue
try:
gs = groups(raw)
except Exception:
continue
for _idx, nm, _frames, lead_in, W, alphas in gs:
rest = W[:-1]
if not rest or any(w is None for w in rest):
continue
total += 1
t_new = [lead_in] + rest
if all(t_new[i] <= t_new[i + 1] for i in range(len(t_new) - 1)):
mono += 1
per_bundle[(os.path.basename(p), ei)].append((lead_in, rest, nm))
for a, b in monotone_ramps(alphas):
r = constant_rate(t_new, alphas, a, b)
if r is not None:
ramp_new[1] += 1
ramp_new[0] += r
# old reading: `+36` is the block's own time, last pose untimed
a_old = alphas[:-1]
for a, b in monotone_ramps(a_old):
r = constant_rate(rest, a_old, a, b)
if r is not None:
ramp_old[1] += 1
ramp_old[0] += r
nz = nz_ok = ctrl = ctrl_ok = 0
gaps = collections.Counter()
for rows in per_bundle.values():
pool = [l for l, _, _ in rows]
for lead_in, rest, _nm in rows:
if lead_in == 0:
continue
nz += 1
nz_ok += lead_in < rest[0]
gaps[rest[0] - lead_in] += 1
for _ in range(10):
ctrl += 1
ctrl_ok += rnd.choice(pool) < rest[0]
pct = lambda a, b: f"{100 * a / b:.3f}%" if b else "n/a"
print(f"paks scanned : {len(paks)}")
print(f"placement groups : {total}")
print()
print("A. lead-in prepended to the shifted times is non-decreasing")
print(f" {mono}/{total} = {pct(mono, total)}")
print()
print("B. non-zero lead-in is strictly less than the next time")
print(f" {nz_ok}/{nz} = {pct(nz_ok, nz)}")
print(f" control (another group's lead-in, same bundle): "
f"{ctrl_ok}/{ctrl} = {pct(ctrl_ok, ctrl)}")
print(f" gap to the next time, most common: {gaps.most_common(8)}")
print()
print("C. constant d(alpha)/d(time) across a multi-segment ramp")
print(f" corrected (time precedes pose): {ramp_new[0]}/{ramp_new[1]} = "
f"{pct(ramp_new[0], ramp_new[1])}")
print(f" old (+36 is own time) : {ramp_old[0]}/{ramp_old[1]} = "
f"{pct(ramp_old[0], ramp_old[1])}")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(__doc__)
main(sys.argv[1:])

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Does (B) leave the main menu -- and does the menu self-return to the title?
HANDOFF downgraded "(B) on the main menu returns to the title" to authored,
because the corpus also carries "an ~8-10 s idle returns to the title" and one
unrecorded observation cannot separate the two causes. This separates them by
ordering: hold the menu UNTOUCHED for an idle window several times longer than
the claimed 8-10 s and timestamp what happens, THEN press (B) and timestamp
again. If the idle window passes with the menu still up, the idle cause is
gone and the (B) observation is unambiguous.
Screen identity comes from screen_match.py, whose control includes the movie
frames that broke the statistics-based oracle.
Usage: menu_b_probe.py IDLE_SECONDS AFTER_SECONDS
"""
import os, subprocess, sys, time
import numpy as np
from PIL import Image
SD = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SD)
from screen_match import classify_array
W, H = 1280, 720
IDLE = float(sys.argv[1]) if len(sys.argv) > 1 else 60.0
AFTER = float(sys.argv[2]) if len(sys.argv) > 2 else 30.0
OUT = "/sylph-home/re/ringcap"
def stream():
return subprocess.Popen(
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
"-video_size", f"{W}x{H}", "-i", ":98", "-r", "4",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, bufsize=W * H * 3 * 2)
def main():
p = stream(); n = W * H * 3
t0 = time.time(); seg = t0; last = None; prev = None
phase = "IDLE"; pressed_at = None
log = []
while True:
el = time.time() - t0
if phase == "IDLE" and el >= IDLE:
subprocess.run(["python3", f"{SD}/pad.py", "tap", "B", "0.3"], check=False)
pressed_at = time.time() - t0
print(f"t={pressed_at:6.2f}s >>> (B) PRESSED", flush=True)
phase = "AFTER"
if phase == "AFTER" and el >= IDLE + AFTER:
break
if time.time() - seg > 25:
p.kill(); p = stream(); seg = time.time()
b = p.stdout.read(n)
if len(b) < n:
p.kill(); p = stream(); seg = time.time(); continue
a = np.frombuffer(b, np.uint8).reshape(H, W, 3)
last = a
c, sc = classify_array(a)
log.append((el, c, sc["title"], sc["menu"]))
if c != prev:
print(f"t={el:6.2f}s screen={c:<6} title={sc['title']:+.3f} "
f"menu={sc['menu']:+.3f}", flush=True)
Image.fromarray(a).save(f"{OUT}/b-{el:06.2f}-{c}.png")
prev = c
p.kill()
with open(f"{OUT}/menu-b-trace.tsv", "w") as f:
f.write("t_s\tscreen\tcorr_title\tcorr_menu\n")
for r in log:
f.write(f"{r[0]:.3f}\t{r[1]}\t{r[2]:.4f}\t{r[3]:.4f}\n")
idle = [r for r in log if r[0] < IDLE]
aft = [r for r in log if pressed_at and r[0] > pressed_at + 1.0]
print(f"\nIDLE phase : {len(idle)} samples over {IDLE:.0f}s, "
f"screens seen = {sorted(set(r[1] for r in idle))}")
print(f"AFTER (B) : {len(aft)} samples, "
f"screens seen = {sorted(set(r[1] for r in aft))}")
first_title = next((r[0] for r in aft if r[1] == "title"), None)
if first_title:
print(f" first 'title' at t={first_title:.2f}s = "
f"{first_title - pressed_at:.2f}s after the (B) press")
print(f"trace: {OUT}/menu-b-trace.tsv")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,181 @@
"""Measure Canary's PRESENTATION RATE without perturbing it, and time the boot.
Why this exists: two pages of the corpus measure the same declared 120 keyframe
units during a static hold and disagree by 2 % -- settle->plate 2.135 s (28.10
fps implied) and one focus-ring revolution 2.177 s (27.56 implied). The port
challenged it. Either the rate differed between the sessions, or one interval is
not 120 units. Both were WALL-CLOCK, so nothing in either can tell them apart.
The obvious instrument is Canary's own `[UI-CAP]` frame counter, and it is the
one the corpus used for "28.5 fps". 🔴 **It perturbs badly.** Measured here:
armed on the title with a concurrent 8 fps grab, 300 frames took 16.567 s =
**18.11 fps** against the ~28 the same screen gives without it. A frame counter
that costs a third of the frame rate cannot measure the frame rate.
So: count DISTINCT FRAMES in an oversampled crop of something that moves every
frame (the spinning focus ring). Sampling at 60 fps a source presenting at R,
the fraction of consecutive samples that differ is R/60.
Its controls, all in one session and all required to believe a number:
* a STATIC crop must read ~0 -- if it does not, the counter is seeing noise;
* two sampling rates (45 and 60) must agree -- if the estimate tracks the
sampler it is measuring the sampler;
* and the decisive one: while `[UI-CAP]` runs, this counter and the emulator's
own frame count must AGREE. Both are perturbed in that window, but agreeing
there is what licenses using this counter alone outside it.
present_rate_probe.py --run SECONDS OUT.json CANARY_STDOUT
"""
import json
import os
import subprocess
import sys
import time
import numpy as np
SD = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SD)
import title_timing_probe as T # noqa: E402
import boot_timeline_probe as B # noqa: E402
# The button column, from ring_period.py: game coords (480,130)-(570,530), and
# the game surface sits at +1,+45 in the root.
RX, RY, RW, RH = 481, 175, 90, 400
# A crop that must NOT move: the top-left of the menu's background.
SX, SY, SW, SH = 60, 120, 90, 120
def crop_stream(x, y, w, h, rate):
return subprocess.Popen(
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
"-video_size", f"{w}x{h}", "-i", f"{T.DISPLAY}+{x},{y}", "-r", str(rate),
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, bufsize=w * h * 3 * 4)
def count_distinct(x, y, w, h, rate, secs, thresh=0.02):
"""Sample a crop and count how many frames differ from their predecessor.
Returns (samples, distinct, seconds, implied_fps, frames_list, times_list).
`thresh` is a mean-abs-difference floor; a capture path with no noise makes
an identical frame differ by exactly 0, so this only has to reject dither.
"""
p = crop_stream(x, y, w, h, rate)
n = w * h * 3
t0 = time.time()
prev = None
samples = distinct = 0
prof, ts = [], []
while time.time() - t0 < secs:
b = p.stdout.read(n)
if len(b) < n:
break
a = np.frombuffer(b, np.uint8).reshape(h, w, 3)
g = (0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]).astype(np.float32)
samples += 1
if prev is not None and float(np.abs(g - prev).mean()) > thresh:
distinct += 1
prev = g
prof.append(float(g.mean()))
ts.append(time.time() - t0)
p.kill()
dt = time.time() - t0
return dict(samples=samples, distinct=distinct, seconds=dt,
sample_fps=samples / dt, implied_fps=distinct / dt), prof, ts
def wait_for(pred, limit, rate=8):
"""Classify a full-frame stream until `pred(label, glyph, mean)` or timeout."""
p = T.open_stream()
n = T.W * T.H * 3
t0 = time.time()
trail = []
while time.time() - t0 < limit:
buf = p.stdout.read(n)
if len(buf) < n:
p.kill(); p = T.open_stream(); continue
rgb = np.frombuffer(buf, np.uint8).reshape(T.H, T.W, 3)
g = T.gray_of(rgb)
lb = T.label(T.scores(g))
gl = T.glyph(rgb)
mn = float(T.surface(g).mean())
trail.append((round(time.time() - t0, 3), lb, gl, round(mn, 2)))
if pred(lb, gl, mn):
p.kill()
return time.time() - t0, trail
p.kill()
return None, trail
def main(limit, out_path, log_path):
res = {}
t0 = time.time()
# --- reach the plate, then press A promptly: the title's idle window is short
hit, trail = wait_for(lambda lb, gl, mn: lb in ("title_plate", "title_noplate")
and gl >= T.PLATE_GLYPH, limit)
res["trail_to_plate"] = trail[-40:]
if hit is None:
res["error"] = "never reached the plate"
json.dump(res, open(out_path, "w"), indent=1)
return 1
res["plate_at"] = round(hit, 3)
print(f"plate at {hit:.1f}s -> A", flush=True)
T.tap("A")
hit, trail = wait_for(lambda lb, gl, mn: lb == "menu", 120)
if hit is None:
res["error"] = "never reached the menu"
res["trail_to_menu"] = trail[-40:]
json.dump(res, open(out_path, "w"), indent=1)
return 1
print(f"menu at +{hit:.1f}s; settling", flush=True)
time.sleep(8)
# --- CONTROL 1: a crop that must not move
st, _, _ = count_distinct(SX, SY, SW, SH, 60, 6)
res["control_static"] = st
print(f"static control: {st['distinct']}/{st['samples']} distinct "
f"({st['implied_fps']:.2f} implied)", flush=True)
# --- CONTROL 2: the same ring at two sampling rates
for r in (45, 60):
d, prof, ts = count_distinct(RX, RY, RW, RH, r, 12)
res[f"ring_free_{r}"] = d
res[f"ring_profile_{r}"] = [round(v, 4) for v in prof]
res[f"ring_times_{r}"] = [round(v, 4) for v in ts]
print(f"ring @{r}fps: {d['distinct']}/{d['samples']} -> "
f"{d['implied_fps']:.2f} fps (sampled {d['sample_fps']:.1f})", flush=True)
# --- CONTROL 3 (the decisive one): agree with the game's own counter
seen = os.path.getsize(log_path) if os.path.exists(log_path) else 0
import threading
box = {}
def _arm():
box["uicap"], box["seen"] = B.arm_capture(log_path, seen, timeout=120)
th = threading.Thread(target=_arm)
th.start()
d, prof, ts = count_distinct(RX, RY, RW, RH, 60, 30)
th.join(timeout=60)
res["ring_during_capture"] = d
res["uicap"] = box.get("uicap")
print(f"during UI-CAP: distinct-frame {d['implied_fps']:.2f} fps; "
f"UI-CAP {box.get('uicap')}", flush=True)
# --- and back to unperturbed
d, prof, ts = count_distinct(RX, RY, RW, RH, 60, 12)
res["ring_after"] = d
res["ring_profile_after"] = [round(v, 4) for v in prof]
res["ring_times_after"] = [round(v, 4) for v in ts]
print(f"after: {d['implied_fps']:.2f} fps", flush=True)
res["elapsed"] = round(time.time() - t0, 2)
json.dump(res, open(out_path, "w"), indent=1)
return 0
if __name__ == "__main__":
if len(sys.argv) > 4 and sys.argv[1] == "--run":
sys.exit(main(float(sys.argv[2]), sys.argv[3], sys.argv[4]))
print(__doc__)
sys.exit(2)

191
tools/re-capture/quad_rects.py Executable file
View File

@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Screen-space rectangles for every textured quad in a xenia draw log.
The draw logs under docs/re/captures/ record vertex positions in NDC, printed
to **two decimals**. That is the whole point of this script: it converts the
quads to screen space *and* carries the quantisation with them, so a
measurement taken off one of these logs cannot quietly claim more precision
than the log has.
NDC step 0.01 -> half-step 0.005 -> a single edge is +/- 3.2 px in X
and +/- 1.8 px in Y; a WIDTH or HEIGHT is a difference of two edges, so it
carries twice that: +/- 6.4 px and +/- 3.6 px. Getting this wrong is not
academic -- at the per-edge figure the control below fails 2 of 6.
Usage:
quad_rects.py LOG [LOG ...] # every textured quad, per frame
quad_rects.py --control LOG # check recovered sizes against
# known texture dimensions
The control is not optional in spirit. Any claim made from these numbers
should quote the control first: four sprites of known size are recovered from
the same log, and the residuals bound what the instrument can see.
"""
import math
import re
import sys
# Screen is 1280x720; NDC x in [-1,1] maps to [0,1280], y in [1,-1] to [0,720].
W, H = 1280.0, 720.0
NDC_HALF_STEP = 0.005
EDGE_X = NDC_HALF_STEP * W / 2.0 # 3.2 px on one edge
EDGE_Y = NDC_HALF_STEP * H / 2.0 # 1.8 px on one edge
SIZE_X = 2 * EDGE_X # 6.4 px on a width (two edges)
SIZE_Y = 2 * EDGE_Y # 3.6 px on a height (two edges)
# Decoded texture sizes for build 4 of GP_TITLE, from
# docs/re/ui-title-paint-order-capture.md and docs/re/ui-title-build-map.md.
# These are the known-positives the control checks against.
CONTROL_SIZES = {
"ptlogo1.t32": (919, 113),
"ptlogo2.t32": (992, 104),
"ptlogo_back2.t32": (1118, 262),
"ptlogo_back2eff.t32": (1133, 280),
"ptcopyright.t32": (694, 20),
"ptbtn00.t32": (513, 50),
"ptbtn00f.t32": (537, 76),
}
FRAME_RE = re.compile(r"--- frame (\d+) ---")
DRAW_RE = re.compile(r"\s*(\d+) prim=(\d+) indices=(\d+)")
TEX_RE = re.compile(r"tex\[base=(0x[0-9A-Fa-f]+) (\d+)x(\d+)")
VERT_RE = re.compile(r"\[(-?\d+\.\d+),(-?\d+\.\d+),z=")
def parse(path):
"""Yield dicts: frame, draw, tex base, and the quad's screen-space rect."""
frame, cur = 0, None
for line in open(path):
m = FRAME_RE.match(line)
if m:
frame = int(m.group(1))
continue
m = DRAW_RE.match(line)
if m:
t = TEX_RE.search(line)
cur = {"frame": frame, "draw": int(m.group(1)),
"tex": t.group(1) if t else None}
continue
if "v:" in line and cur is not None:
verts = [(float(a), float(b)) for a, b in VERT_RE.findall(line)]
# A draw can carry several quads; four vertices each.
for i in range(0, len(verts) - 3, 4):
q = verts[i:i + 4]
xs = [(x + 1.0) * W / 2.0 for x, _ in q]
ys = [(1.0 - y) * H / 2.0 for _, y in q]
# Vertex order is TL, TR, BR, BL, so edge 0->1 is the drawn
# width and 1->2 the drawn height. For a ROTATED quad the
# bounding box is not the sprite; the edges are.
e0 = math.hypot(xs[1] - xs[0], ys[1] - ys[0])
e1 = math.hypot(xs[2] - xs[1], ys[2] - ys[1])
ang = math.degrees(math.atan2(ys[1] - ys[0], xs[1] - xs[0]))
yield {**cur,
"left": min(xs), "top": min(ys),
"w": max(xs) - min(xs), "h": max(ys) - min(ys),
"ew": e0, "eh": e1, "rot": ang,
"cx": sum(xs) / 4.0, "cy": sum(ys) / 4.0}
cur = None
def dump(path):
print(f"# {path}")
print(f"# NDC printed to 2 dp -> edge +/- {EDGE_X:.1f}/{EDGE_Y:.1f} px, "
f"size +/- {SIZE_X:.1f}/{SIZE_Y:.1f} px (X/Y)")
print(f"{'frame':>5} {'draw':>5} {'tex':>12} "
f"{'left':>8} {'top':>8} {'bboxW':>8} {'bboxH':>8} "
f"{'edgeW':>8} {'edgeH':>8} {'rot':>7} {'cx':>8} {'cy':>8}")
for q in parse(path):
if q["tex"] is None:
continue
print(f"{q['frame']:>5} {q['draw']:>5} {q['tex']:>12} "
f"{q['left']:>8.1f} {q['top']:>8.1f} {q['w']:>8.1f} {q['h']:>8.1f} "
f"{q['ew']:>8.1f} {q['eh']:>8.1f} {q['rot']:>7.2f} "
f"{q['cx']:>8.1f} {q['cy']:>8.1f}")
def control(path):
"""Recover the known-positive sprites by size and report the residual."""
rects = [q for q in parse(path) if q["tex"] is not None]
print(f"# control: {path}")
print(f"{'sprite':<22} {'decoded':>11} {'measured':>13} "
f"{'dx':>6} {'dy':>6} verdict")
ok = True
for name, (tw, th) in CONTROL_SIZES.items():
best = min(rects, key=lambda q: abs(q["w"] - tw) + abs(q["h"] - th))
dx, dy = best["w"] - tw, best["h"] - th
good = abs(dx) <= SIZE_X and abs(dy) <= SIZE_Y
ok &= good
print(f"{name:<22} {tw:>5}x{th:<5} {best['w']:>6.1f}x{best['h']:<6.1f} "
f"{dx:>6.1f} {dy:>6.1f} {'PASS' if good else 'FAIL'}")
print(f"# {'CONTROL PASSES' if ok else 'CONTROL FAILS'}"
f"every known size recovered inside the log's own quantisation"
if ok else "# CONTROL FAILS — do not measure anything with this")
return 0 if ok else 1
# Every sprite the title's build-4 capture can draw, by decoded size. The two
# pteff03 entries are the nested ptloop leaves, whose declared vertical scales
# are 600 % and 800 %.
TITLE_SPRITES = {
(919, 113): "ptlogo1.t32",
(992, 104): "ptlogo2.t32",
(1118, 262): "ptlogo_back2.t32",
(1133, 280): "ptlogo_back2eff.t32",
(694, 20): "ptcopyright.t32",
(513, 50): "ptbtn00.t32",
(38, 18): "ptlogo_tm.t32",
(399, 180): "pteff03/pteff03a.t32",
(537, 76): "ptbtn00f.t32", # build 2's focus plate
}
def scales(path):
"""For each quad, the drawn size over the nearest decoded sprite size.
The question this answers: which elements are drawn at a scale other than
100 %? Only those can say anything about what scale is anchored on.
"""
print(f"# scale census: {path}")
print(f"{'frame':>5} {'sprite':<22} {'edgeW':>8} {'edgeH':>8} "
f"{'sx%':>7} {'sy%':>7} {'rot':>7}")
seen = set()
for q in parse(path):
if q["tex"] is None:
continue
if abs(q["ew"] - W) < SIZE_X and abs(q["eh"] - H) < SIZE_Y:
name, sx, sy = "full-screen layer", 1.0, 1.0
key = (name, 1.0, 1.0)
if key not in seen:
seen.add(key)
print(f"{q['frame']:>5} {name:<22} {q['ew']:>8.1f} "
f"{q['eh']:>8.1f} {100.0:>7.1f} {100.0:>7.1f} "
f"{q['rot']:>7.2f}")
continue
# Match on the edge lengths, allowing any uniform-ish scale factor.
best, bestcost = None, None
for (tw, th), name in TITLE_SPRITES.items():
sx, sy = q["ew"] / tw, q["eh"] / th
cost = abs(math.log(sx)) + abs(math.log(sy))
if bestcost is None or cost < bestcost:
best, bestcost = (name, tw, th, sx, sy), cost
name, tw, th, sx, sy = best
key = (name, round(sx, 2), round(sy, 2))
if key in seen:
continue
seen.add(key)
print(f"{q['frame']:>5} {name:<22} {q['ew']:>8.1f} {q['eh']:>8.1f} "
f"{100 * sx:>7.1f} {100 * sy:>7.1f} {q['rot']:>7.2f}")
return 0
if __name__ == "__main__":
args = sys.argv[1:]
if not args:
sys.exit(__doc__)
if args[0] == "--scales":
sys.exit(max(scales(p) for p in args[1:]))
if args[0] == "--control":
sys.exit(max(control(p) for p in args[1:]))
for p in args:
dump(p)

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Is the focus ring ROTATING, or just pulsing in brightness?
The temporal-std map of a focused button is an annulus, which both hypotheses
predict: a travelling bright feature varies every annulus pixel, and so does a
uniform fade. Two observables separate them, and this script reports both.
(1) TOTAL annulus brightness per frame. A rotation moves brightness around
the annulus and conserves the sum; an alpha pulse does not.
(2) The 360-bin ANGULAR PROFILE, cross-correlated between frames. A rotation
shifts the profile by a lag; a pulse scales it in place.
CONTROL FIRST. The angular estimator is run over a known synthetic rotation of
the run's own first frame (30/90/180/270 deg) and must recover it; the corpus
already has a centroid estimator that fails this by up to 19.8 deg, and that is
why one is not used here.
Usage: ring_angular.py CX CY [FRAME ...] (CX,CY in GAME coordinates)
"""
import os, sys
import numpy as np
from PIL import Image
DY, DX = 45, 1 # game(0,0) -> grab, measured by focus_ring_report.py
R_IN, R_OUT = 8.0, 18.0 # annulus radii, in px, read off the std map
NBINS = 360
def ndrotate(img, deg):
"""Bilinear rotation about the patch centre -- the control's known-positive."""
h, w = img.shape
cy, cx = (h - 1) / 2.0, (w - 1) / 2.0
yy, xx = np.mgrid[0:h, 0:w]
t = np.radians(deg)
ys = (yy - cy) * np.cos(t) - (xx - cx) * np.sin(t) + cy
xs = (yy - cy) * np.sin(t) + (xx - cx) * np.cos(t) + cx
y0 = np.floor(ys).astype(int); x0 = np.floor(xs).astype(int)
fy = ys - y0; fx = xs - x0
out = np.zeros_like(img)
for dy_, dx_, wgt in ((0, 0, (1 - fy) * (1 - fx)), (0, 1, (1 - fy) * fx),
(1, 0, fy * (1 - fx)), (1, 1, fy * fx)):
yi = np.clip(y0 + dy_, 0, h - 1); xi = np.clip(x0 + dx_, 0, w - 1)
ok = (y0 + dy_ >= 0) & (y0 + dy_ < h) & (x0 + dx_ >= 0) & (x0 + dx_ < w)
out += np.where(ok, img[yi, xi] * wgt, 0.0)
return out
def ndrotate(img, deg):
"""Bilinear rotation about the patch centre -- the control's known-positive."""
h, w = img.shape
cy, cx = (h - 1) / 2.0, (w - 1) / 2.0
yy, xx = np.mgrid[0:h, 0:w]
t = np.radians(deg)
ys = (yy - cy) * np.cos(t) - (xx - cx) * np.sin(t) + cy
xs = (yy - cy) * np.sin(t) + (xx - cx) * np.cos(t) + cx
y0 = np.floor(ys).astype(int); x0 = np.floor(xs).astype(int)
fy = ys - y0; fx = xs - x0
out = np.zeros_like(img)
for dy_, dx_, wgt in ((0, 0, (1 - fy) * (1 - fx)), (0, 1, (1 - fy) * fx),
(1, 0, fy * (1 - fx)), (1, 1, fy * fx)):
yi = np.clip(y0 + dy_, 0, h - 1); xi = np.clip(x0 + dx_, 0, w - 1)
ok = (y0 + dy_ >= 0) & (y0 + dy_ < h) & (x0 + dx_ >= 0) & (x0 + dx_ < w)
out += np.where(ok, img[yi, xi] * wgt, 0.0)
return out
def patch(path, cx, cy, half=28):
a = np.array(Image.open(path).convert("RGB")).astype(np.float32)
g = 0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]
return g[cy + DY - half:cy + DY + half, cx + DX - half:cx + DX + half]
def polar(p):
"""(total annulus brightness, 360-bin mean profile) of one patch."""
h, w = p.shape
yy, xx = np.mgrid[0:h, 0:w]
cy, cx = (h - 1) / 2.0, (w - 1) / 2.0
r = np.hypot(yy - cy, xx - cx)
m = (r >= R_IN) & (r <= R_OUT)
th = (np.degrees(np.arctan2(yy - cy, xx - cx)) + 360.0) % 360.0
idx = np.clip((th[m] / 360.0 * NBINS).astype(int), 0, NBINS - 1)
v = p[m]
prof = np.zeros(NBINS); cnt = np.zeros(NBINS)
np.add.at(prof, idx, v); np.add.at(cnt, idx, 1.0)
prof = np.where(cnt > 0, prof / np.maximum(cnt, 1), np.nan)
prof = np.nan_to_num(prof, nan=np.nanmean(prof))
return float(v.sum()), prof
def lag(p0, p1):
"""Circular cross-correlation lag in degrees taking p0 -> p1."""
a = p0 - p0.mean(); b = p1 - p1.mean()
c = np.fft.irfft(np.fft.rfft(b) * np.conj(np.fft.rfft(a)), NBINS)
k = int(np.argmax(c))
peak = c[k] / np.sqrt((a * a).sum() * (b * b).sum())
return (k if k <= 180 else k - 360), float(peak)
def main():
cx, cy = int(sys.argv[1]), int(sys.argv[2])
frames = sys.argv[3:]
p0 = patch(frames[0], cx, cy)
print("=== CONTROL: recover a known synthetic rotation of frame 0 ===")
ok = True
for deg in (30, 90, 180, 270):
rot = ndrotate(p0, -deg)
_, pr = polar(rot); _, pa = polar(p0)
d, pk = lag(pa, pr)
err = ((d - deg + 180) % 360) - 180
flag = "ok " if abs(err) <= 3 else "FAIL"
if abs(err) > 3:
ok = False
print(f" {flag} applied {deg:4d} deg -> recovered {d:5d} deg "
f"(err {err:+4d}, peak {pk:.3f})")
# negative control: a ring-free patch of the same frame must not correlate
off = patch(frames[0], cx + 160, cy)
_, po = polar(off); _, pa = polar(p0)
_, pk = lag(pa, po)
print(f" ring-free patch of the same frame: peak {pk:.3f} (must be low)")
if not ok:
print("\nCONTROL FAILED — the estimator cannot measure this; stopping.")
return 1
print(" CONTROL PASSED\n")
print("=== MEASUREMENT: successive live frames of the same focused ring ===")
print(f"{'frame':<24} {'annulus sum':>12} {'vs f0 %':>9} {'lag vs f0':>10} {'peak':>7}")
base_s, base_p = polar(p0)
for f in frames:
s, pr = polar(patch(f, cx, cy))
d, pk = lag(base_p, pr)
print(f"{os.path.basename(f):<24} {s:12.1f} {100*s/base_s:8.1f}% "
f"{d:9d}d {pk:7.3f}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Measure the focus ring's SPIN PERIOD from a dense live filmstrip.
No absolute angle is estimated. The corpus's centroid estimator fails its own
control by up to 19.8 deg, and a 360-bin angular cross-correlation also FAILED
the control written for it here (a synthetic 30 deg rotation of a live frame
came back as 0 deg, peak 0.596), so neither is trusted.
What is used instead needs no angle: the annulus's 360-bin brightness profile,
correlated against frame 0. A rotating ring's profile returns to itself once
per revolution, so the correlation trace is periodic and its first return to a
maximum IS the period. The ring is located from the data (the peak of the
temporal-std map over the button column), not from a declared coordinate.
Usage: ring_period.py SECONDS OUTDIR
"""
import os, subprocess, sys, time
import numpy as np
from PIL import Image
W, H, DY, DX = 1280, 720, 45, 1
R_IN, R_OUT, NB = 8.0, 18.0, 360
SECS = float(sys.argv[1]) if len(sys.argv) > 1 else 30.0
OUT = sys.argv[2] if len(sys.argv) > 2 else "/sylph-home/re/ringcap"
COL = (480, 130, 570, 530) # x0,y0,x1,y1 in GAME coords: the button column
def grab_stream(secs):
p = subprocess.Popen(
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
"-video_size", f"{W}x{H}", "-i", ":98", "-r", "15",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, bufsize=W * H * 3 * 2)
n = W * H * 3
t0 = time.time(); frames = []; ts = []
x0, y0, x1, y1 = COL
while time.time() - t0 < secs:
b = p.stdout.read(n)
if len(b) < n:
break
a = np.frombuffer(b, np.uint8).reshape(H, W, 3)
g = (0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]).astype(np.float32)
frames.append(g[y0 + DY:y1 + DY, x0 + DX:x1 + DX].copy())
ts.append(time.time() - t0)
p.kill()
return np.array(frames), np.array(ts)
def annulus_profile(patch, cy, cx):
h, w = patch.shape
yy, xx = np.mgrid[0:h, 0:w]
r = np.hypot(yy - cy, xx - cx)
m = (r >= R_IN) & (r <= R_OUT)
th = (np.degrees(np.arctan2(yy - cy, xx - cx)) + 360) % 360
idx = np.clip((th[m] / 360 * NB).astype(int), 0, NB - 1)
v = patch[m]
prof = np.zeros(NB); cnt = np.zeros(NB)
np.add.at(prof, idx, v); np.add.at(cnt, idx, 1.0)
prof = np.where(cnt > 0, prof / np.maximum(cnt, 1), np.nan)
return np.nan_to_num(prof, nan=np.nanmean(prof)), float(v.mean())
def main():
F, T = grab_stream(SECS)
if len(F) < 10:
print("too few frames"); return 1
fps = len(F) / (T[-1] - T[0])
print(f"{len(F)} frames over {T[-1]-T[0]:.1f}s = {fps:.2f} fps", flush=True)
std = F.std(0)
cy, cx = np.unravel_index(np.argmax(
np.array([[std[max(0, i-14):i+14, max(0, j-14):j+14].mean()
for j in range(std.shape[1])] for i in range(std.shape[0])])), std.shape)
print(f"ring located from the data at patch({cx},{cy}) = "
f"GAME({COL[0]+cx},{COL[1]+cy}); local std {std[cy, cx]:.2f}", flush=True)
profs = []; means = []
for f in F:
p, m = annulus_profile(f, cy, cx)
profs.append(p); means.append(m)
P = np.array(profs); M = np.array(means)
print(f"annulus mean brightness: {M.mean():.2f} +/- {M.std():.3f} "
f"({100*M.std()/M.mean():.2f}% -- a PULSE would move this)", flush=True)
a = P[0] - P[0].mean()
corr = np.array([float(((p - p.mean()) * a).sum() /
np.sqrt(((p - p.mean())**2).sum() * (a * a).sum()))
for p in P])
np.save(f"{OUT}/period-corr.npy", np.vstack([T, corr, M]))
print("\n t(s) corr-with-frame0 annulus mean")
for t, c, m in zip(T, corr, M):
bar = "#" * max(0, int((c + 1) * 25))
print(f"{t:6.2f} {c:+.3f} {bar:<50} {m:7.2f}")
# first return to a local maximum after the trace has dipped
dip = np.argmax(corr < 0.3) if (corr < 0.3).any() else None
if dip:
after = corr[dip:]
k = dip + int(np.argmax(after))
print(f"\nfirst return to max after the dip: t = {T[k]:.2f}s (corr {corr[k]:+.3f})")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Identify a LIVE grab by correlating it against committed oracle captures.
Why not whole-image statistics (screen_id.py's green/white/mean)? Because the
class they have to reject is MOVIE FRAMES, and a movie frame can be anything.
Measured 2026-08-29: a frame of `ADV.wmv` containing a bright green laser beam
scored green=0.0018 white=0.086 mean=(53,67,76) -- numerically indistinguishable
from the title plate, and a probe built on those features tapped (A) into the
attract movie and then waited 120 s for a menu that was never coming.
So match on CONTENT instead. Zero-normalised correlation against the committed
captures, over a small offset search, with the movie frames that fooled the
statistics kept as permanent negative controls.
A live grab is the whole 1280x720 root: xenia's title bar and menu bar occupy
the top ~45 rows, and the game surface below them is 1279x675 -- the same size
as the committed captures, which is not a coincidence.
Usage:
screen_match.py IMAGE [IMAGE ...] classify each
screen_match.py --control run the controls and exit non-zero on failure
"""
import os, sys
import numpy as np
from PIL import Image
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CAP = os.path.join(REPO, "docs", "re", "captures")
REFS = {
"title": "title-builds/live-title-press-a.png",
"menu": "title-builds/live-main-menu.png",
}
SURFACE_TOP = 45 # rows of xenia window chrome on a 1280x720 root
SEARCH = 8 # +/- px offset search, as the corpus does elsewhere
THRESH = 0.70
FAST_DS = 4 # decimation for the live path (see below)
# 🔴 The exact path costs 1503 ms PER FRAME, measured. A probe that ran it on
# every frame of an 8 fps x11grab drained the pipe at 0.64 fps, so the frames it
# classified were tens of seconds stale -- and the staleness GREW, which is how
# three "latencies" of 15.6 s, 20.3 s and 25.6 s were produced by a pipeline
# rather than by the game. Ordering survives a backlog; durations do not.
# `fast=True` decimates 4x and searches +/-2 decimated px, and is controlled
# below against the same 8 captures as the exact path.
def load(p):
return np.array(Image.open(p).convert("L"), dtype=np.float32)
def surface(a):
"""Crop a grab to the game surface. A committed capture is passed through."""
h, w = a.shape
if h == 720 and w == 1280:
return a[SURFACE_TOP:, :1279]
return a
def zncc(x, y):
x = x - x.mean(); y = y - y.mean()
d = np.sqrt((x * x).sum() * (y * y).sum())
return float((x * y).sum() / d) if d else 0.0
def best_corr(img, ref, fast=False):
"""Max ZNCC over a small 2-D offset search."""
if fast:
img = img[::FAST_DS, ::FAST_DS]; ref = ref[::FAST_DS, ::FAST_DS]
rng, step = 2, 1
else:
rng, step = SEARCH, 2
h = min(img.shape[0], ref.shape[0]); w = min(img.shape[1], ref.shape[1])
best = -1.0
for dy in range(-rng, rng + 1, step):
for dx in range(-rng, rng + 1, step):
ys0, ys1 = max(0, dy), min(h, h + dy)
yr0, yr1 = max(0, -dy), min(h, h - dy)
xs0, xs1 = max(0, dx), min(w, w + dx)
xr0, xr1 = max(0, -dx), min(w, w - dx)
c = zncc(img[ys0:ys1, xs0:xs1], ref[yr0:yr1, xr0:xr1])
if c > best:
best = c
return best
_REF_CACHE = {}
def refs():
if not _REF_CACHE:
for k, v in REFS.items():
_REF_CACHE[k] = surface(load(os.path.join(CAP, v)))
return _REF_CACHE
def classify(a_gray, fast=False):
"""Return (label, {name: corr}). label is 'title' | 'menu' | 'other'."""
img = surface(a_gray)
scores = {k: best_corr(img, r, fast) for k, r in refs().items()}
k = max(scores, key=scores.get)
return (k if scores[k] >= THRESH else "other"), scores
def classify_array(rgb, fast=False):
g = (0.299 * rgb[:, :, 0] + 0.587 * rgb[:, :, 1] + 0.114 * rgb[:, :, 2]).astype(np.float32)
return classify(g, fast)
CONTROLS = [
# (path, expected) -- positives from the committed corpus ...
(os.path.join(CAP, "title-builds/live-title-press-a.png"), "title"),
(os.path.join(CAP, "title-screen-oracle.png"), "title"),
(os.path.join(CAP, "title-builds/live-main-menu.png"), "menu"),
(os.path.join(CAP, "main-menu-oracle.png"), "menu"),
(os.path.join(CAP, "main-menu-reached.png"), "menu"),
# ... and the NEGATIVES. Movie frames are the class this oracle exists to
# reject, so they are COMMITTED fixtures, not scratch: an earlier version of
# this list pointed at two scratch grabs and a later run of the same probe
# overwrote one of them, turning a negative control into a title frame and
# failing the control for the wrong reason.
(os.path.join(CAP, "instrument-controls/movie-frame-attract-a.png"), "other"),
(os.path.join(CAP, "instrument-controls/movie-frame-attract-b.png"), "other"),
(os.path.join(CAP, "difficulty-screen.png"), "other"),
]
def control():
import time as _t
bad = 0
for fast in (False, True):
print(f"--- {'FAST (live path)' if fast else 'EXACT'} ---")
for p, exp in CONTROLS:
if not os.path.exists(p):
print(f" SKIP (missing) {os.path.basename(p)}"); continue
t = _t.time(); got, sc = classify(load(p), fast); ms = (_t.time() - t) * 1000
ok = "ok " if got == exp else "FAIL"
if got != exp:
bad += 1
print(f" {ok} {os.path.basename(p):<34} -> {got:<6} (exp {exp:<6}) "
+ " ".join(f"{k}={v:+.3f}" for k, v in sc.items())
+ f" [{ms:.0f} ms]")
print(f"\n{'CONTROL PASSED' if not bad else f'CONTROL FAILED ({bad})'}")
return 1 if bad else 0
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--control":
sys.exit(control())
for p in sys.argv[1:]:
got, sc = classify(load(p))
print(f"{p}: {got} " + " ".join(f"{k}={v:+.3f}" for k, v in sc.items()))

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""One boot, two answers: when the PRESS (A) plate appears, and what (B) does.
Q(plate): the port's boot ends on GP_TITLE build 4, which carries no plate, and
(A) is the only way off it -- so it ships a screen that needs a press and does
not say so. Builds 2/3 are the plate. The open half is the SEQUENCE: build 4
alone, build 4 with the plate composited from the start, or build 4 and THEN
the plate after a delay. This logs the title-art correlation and the green-(A)
glyph count on EVERY frame from before the title appears, so the two crossings
are read off one trace rather than inferred.
Q(B): whether (B) leaves the main menu, timed against the idle alternative.
Controls, both pre-run on committed captures:
* screen identity -- screen_match.py, 8/8 including the movie frames that
broke the statistics oracle;
* the plate -- green-(A) glyph count: 753/977/1493 px on plate titles, 159 on
`live-title-build4-no-plate.png`, 327 on the main menu. Threshold 400.
Usage: title_plate_and_b_probe.py OUTDIR
"""
import os, subprocess, sys, time
import numpy as np
from PIL import Image
SD = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SD)
from screen_match import classify_array
W, H = 1280, 720
PLATE = 400
OUT = sys.argv[1] if len(sys.argv) > 1 else "/sylph-home/re/platecap"
os.makedirs(OUT, exist_ok=True)
def stream():
return subprocess.Popen(
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
"-video_size", f"{W}x{H}", "-i", ":98", "-r", "8",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, bufsize=W * H * 3 * 2)
def glyph(a):
r, g, b = a[:, :, 0].astype(int), a[:, :, 1].astype(int), a[:, :, 2].astype(int)
return int(((g > 130) & (g - r > 45) & (g - b > 45)).sum())
def main():
p = stream(); n = W * H * 3
t0 = time.time(); seg = t0; prev = None
skipped = False; stage = 0; marks = {}
rows = []
while time.time() - t0 < 420:
if time.time() - seg > 25:
p.kill(); p = stream(); seg = time.time()
b = p.stdout.read(n)
if len(b) < n:
p.kill(); p = stream(); seg = time.time(); continue
a = np.frombuffer(b, np.uint8).reshape(H, W, 3)
el = time.time() - t0
c, sc = classify_array(a)
gl = glyph(a)
rows.append((el, c, sc["title"], sc["menu"], gl))
if c != prev:
print(f"t={el:7.2f}s screen={c:<6} title={sc['title']:+.3f} "
f"menu={sc['menu']:+.3f} glyph={gl}", flush=True)
prev = c
if not skipped and el > 45:
subprocess.run(["python3", f"{SD}/pad.py", "tap", "A", "0.3"], check=False)
skipped = True
print(f"t={el:7.2f}s one (A) to skip the intro movie", flush=True)
elif stage == 0 and c == "title":
marks["title_art"] = el; stage = 1
Image.fromarray(a).save(f"{OUT}/title-first-{el:07.2f}.png")
print(f"t={el:7.2f}s TITLE ART (glyph={gl}) — watching for the plate",
flush=True)
elif stage == 1 and gl >= PLATE:
marks["plate"] = el; stage = 2
Image.fromarray(a).save(f"{OUT}/title-plate-{el:07.2f}.png")
print(f"t={el:7.2f}s PLATE (glyph={gl}) — "
f"{el-marks['title_art']:.2f}s after the title art", flush=True)
elif stage == 2 and el > marks["plate"] + 6:
subprocess.run(["python3", f"{SD}/pad.py", "tap", "A", "0.3"], check=False)
marks["A"] = el; stage = 3
print(f"t={el:7.2f}s >>> (A) on the title", flush=True)
elif stage == 3 and c == "menu":
marks["menu"] = el; stage = 4
print(f"t={el:7.2f}s MENU — idling 25 s before (B)", flush=True)
elif stage == 4 and el > marks["menu"] + 25:
subprocess.run(["python3", f"{SD}/pad.py", "tap", "B", "0.3"], check=False)
marks["B"] = el; stage = 5
print(f"t={el:7.2f}s >>> (B) PRESSED", flush=True)
elif stage == 5 and c != "menu":
marks["left_menu"] = el
print(f"t={el:7.2f}s LEFT THE MENU -> {c}, "
f"{el-marks['B']:.2f}s after (B)", flush=True)
stage = 6
elif stage == 6 and el > marks["left_menu"] + 12:
break
p.kill()
with open(f"{OUT}/trace.tsv", "w") as f:
f.write("t_s\tscreen\tcorr_title\tcorr_menu\tglyph\n")
for r in rows:
f.write(f"{r[0]:.3f}\t{r[1]}\t{r[2]:.4f}\t{r[3]:.4f}\t{r[4]}\n")
print("\nmarks:", {k: round(v, 2) for k, v in marks.items()})
if "title_art" in marks and "plate" in marks:
print(f"PLATE DELAY: {marks['plate']-marks['title_art']:.2f}s "
f"after the title art first matched")
if "B" in marks and "left_menu" in marks:
print(f"(B) -> left the menu in {marks['left_menu']-marks['B']:.2f}s")
print(f"trace: {OUT}/trace.tsv")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""Time the boot title: when the PRESS (A) plate arrives, and what a press costs.
WHY THIS EXISTS. Four durations published on 2026-08-29 were withdrawn the same
day because `screen_match.classify_array` costs 1503 ms/frame and a probe calling
it per frame drained an 8 fps x11grab at 0.64 fps. A backlog PRESERVES ORDERING
and DESTROYS DURATIONS, so every "latency" it produced was really the queue
depth. See docs/re/menu-idle-and-b-2026-08-29.md.
So this probe is built the other way round:
* per-frame work is a few MILLISECONDS, not 1.5 s. The cost in screen_match is
the +/-8 px offset search over a full-res surface (25 znccs); every committed
capture aligns at exactly dy=0 dx=0 (five screens, +/-2 px search,
five-screens-acceptance.md), so this classifier decimates 4x and does ONE
zncc per reference. --control checks that shortcut against the same fixtures
screen_match uses, INCLUDING the movie-frame negatives.
* the stream is torn down and restarted every RESTART_S, because a long-lived
x11grab degrades and then freezes on a stale frame (fast_title_probe.py).
* an INDEPENDENT one-shot grab every CHECK_S is compared with the stream's own
latest frame. A stalled stream cannot pass that, and the check is logged so
a negative result can be audited rather than believed.
* the loop's real sample rate is reported. If frames/elapsed is not close to
the requested rate, the durations in the log are NOT trustworthy and the
probe says so in its own summary.
Every frame is written to a TSV; the durations are computed offline from it, so
nothing here depends on the probe having classified in real time.
title_timing_probe.py --control
title_timing_probe.py --run SECONDS OUT.tsv
"""
import os
import subprocess
import sys
import time
import numpy as np
from PIL import Image
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CAP = os.path.join(REPO, "docs", "re", "captures")
SD = os.path.dirname(os.path.abspath(__file__))
W, H = 1280, 720
SURFACE_TOP = 45 # xenia window chrome; the game surface is 1279x675
DS = 4 # decimation for both live frames and references
RATE = 8 # requested frames/s
RESTART_S = 30 # a long-lived x11grab freezes on a stale frame
CHECK_S = 20 # independent one-shot grab, cross-checked against the stream
THRESH = 0.70
DISPLAY = os.environ.get("DISPLAY", ":98")
REFS = {
# the interactive title WITH the plate -- what the run is waiting to see arrive
"title_plate": "title-builds/live-title-press-a.png",
# the same screen BEFORE the plate. This is the reference the plate delay is
# measured from, and it is a committed capture, not a render of ours.
"title_noplate": "title-builds/live-title-build4-no-plate.png",
"menu": "title-builds/live-main-menu.png",
}
def surface(a):
h, w = a.shape
if h == H and w == W:
return a[SURFACE_TOP:, :1279]
return a
def load_gray(p):
return np.asarray(Image.open(p).convert("L"), dtype=np.float32)
_R = {}
def refs():
if not _R:
for k, v in REFS.items():
r = surface(load_gray(os.path.join(CAP, v)))[::DS, ::DS]
_R[k] = (r - r.mean()) / (np.sqrt((r * r).sum() - r.size * r.mean() ** 2) or 1.0)
return _R
def scores(gray):
"""ZNCC of a frame against every reference, decimated, NO offset search."""
img = surface(gray)[::DS, ::DS]
out = {}
for k, rn in refs().items():
h = min(img.shape[0], rn.shape[0])
w = min(img.shape[1], rn.shape[1])
x = img[:h, :w]
y = rn[:h, :w]
xc = x - x.mean()
d = np.sqrt((xc * xc).sum())
out[k] = float((xc * y).sum() / d) if d else 0.0
return out
def label(sc):
k = max(sc, key=sc.get)
return k if sc[k] >= THRESH else "other"
def glyph(rgb):
"""Byte-identical to is_title.py's counter."""
r = rgb[:, :, 0].astype(np.int16)
g = rgb[:, :, 1].astype(np.int16)
b = rgb[:, :, 2].astype(np.int16)
return int(((g > 130) & (g - r > 45) & (g - b > 45)).sum())
def gray_of(rgb):
return (0.299 * rgb[:, :, 0] + 0.587 * rgb[:, :, 1] + 0.114 * rgb[:, :, 2]).astype(np.float32)
# ---------------------------------------------------------------- control
CONTROLS = [
(os.path.join(CAP, "title-builds/live-title-press-a.png"), "title_plate"),
(os.path.join(CAP, "title-screen-oracle.png"), "title_plate"),
(os.path.join(CAP, "title-builds/live-title-build4-no-plate.png"), "title_noplate"),
(os.path.join(CAP, "title-builds/live-main-menu.png"), "menu"),
(os.path.join(CAP, "main-menu-oracle.png"), "menu"),
(os.path.join(CAP, "main-menu-reached.png"), "menu"),
# the class this oracle exists to reject
(os.path.join(CAP, "instrument-controls/movie-frame-attract-a.png"), "other"),
(os.path.join(CAP, "instrument-controls/movie-frame-attract-b.png"), "other"),
(os.path.join(CAP, "difficulty-screen.png"), "other"),
]
# The plate detector is a THRESHOLD on the glyph counter, so it needs its own
# control: the committed no-plate title reads ~159 and plate titles 753..1493.
GLYPH_CONTROLS = [
(os.path.join(CAP, "title-builds/live-title-build4-no-plate.png"), "lo"),
(os.path.join(CAP, "title-builds/live-title-press-a.png"), "hi"),
(os.path.join(CAP, "instrument-controls/movie-frame-attract-a.png"), "lo"),
(os.path.join(CAP, "instrument-controls/movie-frame-attract-b.png"), "lo"),
]
PLATE_GLYPH = 400
def control():
bad = 0
print("--- content classifier (decimated, no offset search) ---")
for p, exp in CONTROLS:
if not os.path.exists(p):
print(f" SKIP (missing) {os.path.basename(p)}")
continue
t = time.time()
sc = scores(load_gray(p))
got = label(sc)
ms = (time.time() - t) * 1000
ok = got == exp
bad += 0 if ok else 1
print(f" {'ok ' if ok else 'FAIL'} {os.path.basename(p):<36} -> {got:<13} "
f"(exp {exp:<13}) " + " ".join(f"{k}={v:+.3f}" for k, v in sc.items())
+ f" [{ms:.1f} ms]")
print(f"\n--- plate detector (glyph >= {PLATE_GLYPH}) ---")
for p, exp in GLYPH_CONTROLS:
if not os.path.exists(p):
print(f" SKIP (missing) {os.path.basename(p)}")
continue
n = glyph(np.asarray(Image.open(p).convert("RGB")))
got = "hi" if n >= PLATE_GLYPH else "lo"
ok = got == exp
bad += 0 if ok else 1
print(f" {'ok ' if ok else 'FAIL'} {os.path.basename(p):<36} glyph={n:<6} -> {got} (exp {exp})")
print(f"\n{'CONTROL PASSED' if not bad else f'CONTROL FAILED ({bad})'}")
return 1 if bad else 0
# ---------------------------------------------------------------- live run
def open_stream():
return subprocess.Popen(
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
"-video_size", f"{W}x{H}", "-i", DISPLAY, "-r", str(RATE),
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, bufsize=W * H * 3 * 2)
def oneshot():
"""An INDEPENDENT grab, through a fresh short-lived process."""
p = subprocess.run(
["ffmpeg", "-loglevel", "error", "-f", "x11grab", "-draw_mouse", "0",
"-video_size", f"{W}x{H}", "-i", DISPLAY, "-frames:v", "1",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, timeout=20)
b = p.stdout
if len(b) < W * H * 3:
return None
return np.frombuffer(b[:W * H * 3], np.uint8).reshape(H, W, 3)
PAD = os.environ.get("XENIA_PAD_FILE", "/tmp/xenia_pad.txt")
def _pad_write(state):
tmp = PAD + ".tmp"
with open(tmp, "w") as f:
f.write(state)
os.replace(tmp, PAD)
def tap(button, secs=0.25):
"""Press INLINE and return the moment the press landed.
pad.py through subprocess.run costs a python start plus the hold before the
caller can timestamp anything, so run 1's press times were ~0.3 s late with
no way to tell how late. Same file, same rename-into-place, no interpreter.
"""
_pad_write(f"press={button}")
t = time.time()
time.sleep(secs)
_pad_write("")
return t
def run(limit, out_path, shots_dir):
os.makedirs(shots_dir, exist_ok=True)
n = W * H * 3
p = open_stream()
t0 = time.time()
seg = t0
chk = t0
frames = 0
saved = set()
ev = [] # (name, t) -- ordering only; durations come from the TSV
state = "wait" # wait -> title -> plate -> pressedA -> menu -> pressedB -> done
last_gray = None
prev_mean = -1.0
same = 0
longest_same = 0
fh = open(out_path, "w")
fh.write("#t\tglyph\tmean\tmotion\ttitle_plate\ttitle_noplate\tmenu\tlabel\n")
def mark(name):
t = time.time() - t0
ev.append((name, t))
print(f"EVENT {name} t={t:.3f}", flush=True)
return t
title_seen_at = None
while time.time() - t0 < limit and state != "done":
now = time.time()
# 🔴 Do NOT restart once the measurement is under way. Run 1 restarted
# 0.25 s after the (A) press and then reported 14 byte-identical frames
# over 1.5 s -- a stale stream straddling exactly the interval being
# timed, which is how a press latency gets inflated by 1.5 s. The
# degradation the restart guards against is a minutes-scale drift
# (fast_title_probe.py); the whole measuring window is under 30 s, so
# freezing the stream for it is strictly safer than restarting inside it.
if state == "wait" and now - seg > RESTART_S:
p.kill()
p = open_stream()
seg = now
fh.write(f"#restart\t{now - t0:.3f}\n")
buf = p.stdout.read(n)
if len(buf) < n:
p.kill()
p = open_stream()
seg = time.time()
continue
t = time.time() - t0
rgb = np.frombuffer(buf, np.uint8).reshape(H, W, 3)
g = gray_of(rgb)
gl = glyph(rgb)
sc = scores(g)
lb = label(sc)
surf = surface(g)
mn = float(surf.mean())
mo = float(np.abs(surf[::8, ::8] - last_gray).mean()) if last_gray is not None else -1.0
last_gray = surf[::8, ::8].copy()
frames += 1
if abs(mn - prev_mean) < 1e-6:
same += 1
longest_same = max(longest_same, same)
else:
same = 0
prev_mean = mn
fh.write(f"{t:.3f}\t{gl}\t{mn:.3f}\t{mo:.3f}\t{sc['title_plate']:+.4f}\t"
f"{sc['title_noplate']:+.4f}\t{sc['menu']:+.4f}\t{lb}\n")
# --- independent cross-check that the stream is not stale
if time.time() - chk > CHECK_S:
chk = time.time()
o = oneshot()
if o is None:
fh.write(f"#check\t{t:.3f}\tONESHOT_FAILED\n")
else:
om = float(surface(gray_of(o)).mean())
fh.write(f"#check\t{t:.3f}\tstream={mn:.3f}\toneshot={om:.3f}\t"
f"delta={abs(om - mn):.3f}\n")
fh.flush()
# --- the drive. DO NOT press during a movie: a run that taps through
# the intro reaches a title that accepts nothing (skip_intro.sh).
if state == "wait":
if lb in ("title_noplate", "title_plate") and 0 <= mo < 2.0:
title_seen_at = mark("title_static")
if gl >= PLATE_GLYPH:
mark("plate_already") # would mean the plate is not late
state = "plate"
else:
state = "title"
Image.fromarray(rgb).save(os.path.join(shots_dir, "t0-title.png"))
elif state == "title":
if gl >= PLATE_GLYPH:
mark("plate")
Image.fromarray(rgb).save(os.path.join(shots_dir, "t1-plate.png"))
state = "plate"
plate_at = t
elif state == "plate":
if t - ev[-1][1] > 5.0:
tp = tap("A") - t0
ev.append(("pressA", tp))
print(f"EVENT pressA t={tp:.3f}", flush=True)
state = "pressedA"
elif state == "pressedA":
if lb == "menu":
mark("menu")
Image.fromarray(rgb).save(os.path.join(shots_dir, "t2-menu.png"))
state = "menu"
elif state == "menu":
if t - ev[-1][1] > 8.0:
tp = tap("B") - t0
ev.append(("pressB", tp))
print(f"EVENT pressB t={tp:.3f}", flush=True)
state = "pressedB"
elif state == "pressedB":
if lb in ("title_plate", "title_noplate"):
mark("back_title")
Image.fromarray(rgb).save(os.path.join(shots_dir, "t3-back-title.png"))
state = "done"
p.kill()
dt = time.time() - t0
fps = frames / dt if dt else 0
fh.write(f"#summary\tframes={frames}\telapsed={dt:.1f}\tfps={fps:.2f}\trequested={RATE}"
f"\tlongest_identical_run={longest_same}\n")
for name, t in ev:
fh.write(f"#event\t{name}\t{t:.3f}\n")
fh.close()
print(f"\n{frames} frames in {dt:.1f}s = {fps:.2f} fps (requested {RATE})")
print(f"longest run of byte-identical surface means: {longest_same} frames "
f"({longest_same / RATE:.2f} s at the requested rate)")
if fps < RATE * 0.75:
print("🔴 SAMPLE RATE FELL BELOW 75% OF REQUESTED — durations in this log "
"are NOT trustworthy (this is the backlog failure mode).")
for name, t in ev:
print(f" {name:<14} {t:8.3f}")
return 0
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--control":
sys.exit(control())
if len(sys.argv) > 3 and sys.argv[1] == "--run":
sys.exit(run(float(sys.argv[2]), sys.argv[3],
sys.argv[4] if len(sys.argv) > 4 else "/sylph-home/re/shots/title-timing"))
print(__doc__)
sys.exit(2)