Compare commits

..

12 Commits

Author SHA1 Message Date
Sylpheed port agent
e57eda14e4 recover: the OPTIONS menu work from the deleted auto/port-p6-audio
The nine files touched by the OPTIONS commits of 2026-09-03 (77f1d18,
fda417a, 3efe1cc, 80042cb, 4c24e06, a921c1e, 41f1331, 6b4b1df, edf8979),
taken as of 0148cb8, the tip of auto/port-p6-audio. The branch was deleted
from the server on 2026-09-17 during the consolidation cleanup; issue #6
asks for this work as a reviewable PR, so it is recovered here before the
commits are garbage collected.

This is a review slice, not a self-consistent tree: the OPTIONS work and
the F5/F6 work interleaved in the original history and cannot be separated
by file, so each file carries whatever else had changed in it by
2026-09-04, and files it depends on are absent. The complete state is
recover/port-f5-f6.

Refs #6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:48:48 +02:00
MechaCat02
b305aa4a5a docker: support a long-lived Claude token, and stop the seeding fighting it
The rotating OAuth credential file is why the agents kept parking, and a
long-lived token removes the failure by construction instead of recovering from
it after the fact.

MEASURED 2026-09-04. ~/.claude/.credentials.json holds a refresh token that
ROTATES ON USE. Seeding both containers from the host left three clients holding
one token; the first to refresh invalidated the other two, and on the failed
refresh Claude Code CLEARS the stored tokens -- writes empty strings, keeps the
metadata, and parks at "Login expired".

  decoder credentials emptied  13:04:28
  decoder last transcript      13:04:29   <- one second later

The emptying and the park are the same event, which is why it never self-heals:
not a stale token a retry could fix, but no token at all, with no browser in the
container to complete /login. A hollow file passes every "does it exist" check --
508 B healthy against 280 B emptied -- which is how three separate diagnoses
missed it. And recovery re-armed the bug: after re-seeding, host and decoder held
the IDENTICAL refresh token hash.

`claude setup-token` issues a long-lived token against the same Claude
subscription. Checked, not assumed: `claude auth login` defaults to --claudeai
and it is `--console` that means Console/API billing, so this is not the separate
API bill. `CLAUDE_CODE_OAUTH_TOKEN` is recognised by the installed binary.

Passed as an ENVIRONMENT VARIABLE, both halves of the failure are gone: nothing
rotates, so peers cannot invalidate each other, and there is no file for Claude
Code to empty on a failure.

Both launchers read $HOME/.sylph-claude-token if present -- same pattern as
SYLPH_GIT_CREDENTIALS -- and both entrypoints skip OAuth seeding entirely when
the variable is set, because copying the rotating file in would re-create the
exact collision the token exists to remove.

Inert until the file exists. Without it, nothing changes.

Also worth recording for the preflight work: `claude auth status` prints JSON
with loggedIn/authMethod/subscriptionType. That is a far better SessionStart
assertion than checking a file exists, and it would have caught this on the first
iteration rather than the third incident.
2026-09-04 15:20:31 +02:00
MechaCat02
108308057a docker: the expect wrapper swallowed both the signal and the exit status
A tooling review predicted a PID-1 signal problem from two symptoms we could not
explain: `OOMKilled: true` with **ExitCode 0**, and `--continue` failing to find
a conversation that plainly existed. Traced it, and the prediction was right --
though the culprit is not PID 1, it is one level below.

The path is  tini (PID 1) -> entrypoint.sh (exec'd) -> expect -> spawn -> claude

`spawn` CANNOT be an exec: expect has to stay alive to drive the pty. So expect
is the process Docker signals, and everything depends on it passing things on.
It did neither, in two lines:

1. NO SIGNAL FORWARDING, no trap of any kind. `docker stop` sent SIGTERM to
   expect, which died and took the pty with it. Claude Code never got a SIGTERM,
   so it never ran SessionEnd hooks and never wrote lastSessionId/history --
   which are written ONLY at a graceful shutdown. That is the entire reason
   `claude --continue` answered "No conversation found to continue" with 33 MB of
   transcripts in the volume beside it, and why we resume by scraping a session
   id off a transcript filename.

2. `eof { exit }` RETURNED 0 FOR EVERY DEATH. A bare `exit` in expect is exit
   ZERO. When the OOM-killer took the child, expect saw EOF and reported a clean
   exit. `OOMKilled: true` with `ExitCode 0` was never Docker being odd -- it was
   this line. It also meant `--restart on-failure` would read a memory kill as
   success, which is why the policy had to be `unless-stopped`.

Fixed and MEASURED, old against new, in a container:

  child exits 7        old -> 0    (the bug)      new -> 7
  SIGTERM to wrapper   old -> 143, child's trap NEVER RAN
                       new -> 42,  child trapped and cleaned up

Same file in both images; they were byte-identical, so the port copy takes the
same change.

Consequences worth stating: a kill now reports 137 rather than 0, so exit codes
mean what they say; `docker stop` gives Claude Code a real SIGTERM, so it runs
SessionEnd and writes the session index -- which may make the transcript-filename
resume unnecessary. That is not assumed here: the resume path stays as it is
until it is verified redundant.
2026-09-03 21:07:19 +02:00
MechaCat02
620ec5e60b agents: one item only -- the title's animation timing -- and split work into human-checkable units
Two new findings from the human, both about WHEN a title animation starts, and
both handed over rather than guessed:

F5 Does (A) SNAP the title to finished, or ACCELERATE it? The human says they
   cannot tell and is right that they cannot -- a three-frame acceleration and a
   one-frame cut look identical to an eye. Two routes that should agree: a
   per-frame capture (acceleration shows intermediate alphas, a cut shows none)
   and the code (assigning a target time and raising a rate multiplier are
   different instructions). Their "looks more like a snap on multiple attempts"
   is recorded as a PRIOR, not a result.

F6 The title's sweeping white glow -- ptloop01/ptloop02, the blue PCB-like lines
   -- starts only when the plate appears in the real game, and starts earlier in
   the port. A lead from the exported declaration, mine and unverified: those
   elements are keyed at t = 0, 70, 100, 238, 250 while the plate reaches full
   alpha at 236, with pteff02 keyed at exactly 236 and ptlogo_back2eff and
   ptcopyright at 238. 236-238 is a synchronisation point in the declared data
   and a human just reported a behaviour change there. Flagged AGAINST itself
   too: 238...250 looks equally like an exit ramp -- ptcopyright uses that shape
   and starts nothing -- and the sweep lives in a nested .rat leaf with its own
   timeline.

F6 bears on clock: "shared" and on F4: if a title element does not move until
the plate arrives, either the declared data says so and our keyframe reading is
wrong, or something at the plate's arrival STARTS it, which is a mechanism
nobody has proposed.

And the process change, which is the human's and outlives this item:

  "attacking the 'whole' mission was too big for them to handle. Split the given
   missions and tasks into even smaller tasks which they can tackle and give to
   a human for feedback."

PROTOCOL.md gains "Work in units a human can check in a minute". A milestone is
not a unit of work, it is a bag of them. A unit is right-sized when it ends in
something a person can judge in under a minute WITHOUT READING ANYTHING, and
each one states its question, what the human looks at, and what it does NOT
cover. Do one, hand it over, stop -- an unverified fix under a second change
makes a regression two-variable.

The evidence for the rule is this week: the splash sat through a whole milestone
and took one day once scoped to "does it animate?". The bar is a HUMAN check,
not a green tool -- three instruments passed a frozen screen.
2026-09-02 20:14:46 +02:00
MechaCat02
de5f04038d agents: correct "both clocks" -- there is ONE, and F4 tests whether it is right
I wrote "whether the game snaps both clocks forward" into yesterday's F4 and the
human asked which clocks. There are none: authored/flow.json sets
`clock: "shared"`, so the title's two composited builds -- build 4 the artwork
(finishes t~=118) and build 2/3 the plate (full alpha t=236) -- run on ONE clock
started together. Left standing, that phrasing sends an agent hunting for a
second clock this corpus says does not exist.

Corrected in both briefs and in the playtest page, marked as a correction rather
than silently edited.

And the question is better than I first framed it. `clock: "shared"` is
AUTHORED, and the port's own plate-arrival-halves.md calls it "not falsified...
not confirmed to better than ~20 % either", with an unresolved anchor
disagreement inside one binary: the reconciliation picked t=118 while
settle_time() returns 160 and the boot prints "settles at t=160".

So F4 is a TEST OF THAT PREMISE, and the discriminator is observable -- press (A)
early, while the wordmark is still building in, and watch the ARTWORK rather
than the plate:

  advances the shared clock   -> the artwork SNAPS to finished
  only forces the plate       -> the artwork KEEPS ANIMATING its build-in

Both briefs now say to answer F4 before building on `shared`, and tell the port
not to choose what "jump" means.
2026-09-02 18:39:13 +02:00
MechaCat02
06f890361b agents: P5's gate is MET, and four findings from the same walk
"Menu walk and navigation is fine. Video skips too. Extras open. New Game
   shows new game intro video."  -- 2026-09-02

P5 is done. Its gate was "a human clicks through it", the retro said it had been
waiting on that and not on code for the whole milestone, and it has happened.
PORT-MISSION.md updated. The NEW GAME gap is accepted as-is.

Four findings, three of them the Decoder's:

F1 THE MENU REPEATS ON A HELD DIRECTION AND OURS DOES NOT. One step per
   deflection was authored as the conservative choice because nobody knew; a
   human has now watched the real game and it repeats, "at a medium pace... slow
   enough to see which item is selected". That settles the existence half of H1
   against us. The RATE is still unmeasured and must not be guessed -- the
   description bounds it and supplies no number. Decoder measures initial delay
   and repeat interval as frame counts; the port implements the mechanism and
   waits for the numbers.

F2 THE SFX ARE TOO LOUD BECAUSE THERE IS NO MIX AT ALL. Measured: confirm
   -17.7 dB mean / -0.0 dB peak, 3 dB hotter in mean than the music and 6.4 dB
   above move. No volume or gain value exists anywhere in export/ or authored/,
   so every clip plays at unity on one bus. Decoder: is per-cue or per-bus gain
   on the disc -- the cue table is the obvious place and cue 1103 is already
   decoded. Port: gains at PLAYBACK as data, and explicitly NOT normalisation in
   the exporter, which destroys the relationship between clips and cannot be
   undone by a modder.

F3 SOMETHING IS MISSING ON THE TITLE SCREEN. The export carries one music file
   and the port plays nothing on the title. Which cue does the title play, and
   is there a sting on the plate or on accept? A negative needs a positive
   control: find the menu's cue by the same method first.

F4 (A) SKIPS FORWARD THROUGH THE BOOT AND WE IMPLEMENT TWO OF THREE PRESSES.
   In the game: skip video, reveal plate immediately, accept plate. The middle
   one is missing here. Whether the game snaps both clocks forward or only
   reveals the plate is a question, not a detail -- and it is a cheap second
   route to the plate-arrival question, since a press that skips to the plate
   says where the game thinks the plate belongs.

H3, the plate delay, is ACCEPTED -- "feels the same... sufficient". Left
unattributed rather than closed green.
2026-09-02 18:32:38 +02:00
MechaCat02
a71dea9b8d agents: the logo splashes are DONE -- the human cannot tell them from the game
"Looks good! Cannot notice any obvious difference from the actual game.
   Mark logos as done."  -- 2026-09-02

Not "the check passes": a person compared the port against the real game and
could not tell them apart. That is the oracle, and it is the strongest result
this port has produced. The sole-focus order is lifted; both agents return to
their milestones.

The fix was one word -- pose_at ASSIGNED the settle instant instead of clamping
to it, so every query returned the settled pose whatever the clock said. The
same line manufactured the false green: the capture harness shoots after two
frames, so it was photographing t~=2 units, which looked settled only because
everything looked settled. The 0.01 % agreement that closed H2 was measured
through the accident. One bug produced the defect AND the evidence of its
absence.

Verified here before it went to the human, by film rather than by claim:
motion 16.4 % -> 27.7 %, distinct luma states 26 -> 43, the publisher ramp 6
steps -> 13 in one continuous run, and the developer splash's interrupting
0.50 s freeze gone. The publisher trajectory rises to a peak and settles back --
the crossfade signature.

The port then closed a gap motion-census names in its own header ("a wrong ramp
that moves every frame passes here") with a shape check pre-registered from the
disc, measured off a film, on a non-overlapped strip, in ratios so the texture
divides out: rise:last declared 1.20, measured 1.20 exact.

Kept as the standing lesson, because it is the fourth instance: an instrument
that sits below the thing under test cannot see it fail. Ask of any new check
what it would still report if the feature were entirely absent.

Explicitly NOT claimed: P5's gate is "a human clicks through it" and nobody has
said the milestone is met. The briefs say so, and say not to record it on the
human's behalf.

The decoder's end-to-end pipeline work returns to normal priority rather than
being dropped -- it is what decides whether the port's 60 units/s matches the
game. The ramp is now right in SHAPE and unverified in DURATION.
2026-09-02 18:15:03 +02:00
MechaCat02
d394c55cbb agents: the splash does not animate, and three instruments could not see it
A human on a GPU at ~140 fps: "the logos just switch, there is no animation at
all." Measured from a real boot with --film at 0.05 s, then per-frame change:

  splash moves        1.30 s of 7.95 s = 16.4 %
  publisher splash    0.30 s of motion, then 3.20 s FROZEN
  developer splash    0.35 s + 0.25 s, then 2.40 s FROZEN
  distinct luma states in 7.95 s   26

A 45-unit build-in cannot be drawn in 26 states, and a fade does not hold one
picture for 3.20 s. The frame counter says 24.8 fps achieved; both are true --
the port is DRAWING 25 times a second and CHANGING almost never.

🔴 Why every check passed, which matters more than the bug:

  frozen sweep     drives the clock BY HAND -- proves the renderer can draw
                   pose N, never that the poses are drawn in sequence
  settled compare  0.01 % against the capture -- a screen frozen 84 % of the
                   time matches a settled reference PERFECTLY, that is what
                   frozen means
  achieved fps     counts frames DRAWN -- the same pixels 25x/s scores
                   identically to animating

Every one measured throughput or a pose. None measured CHANGE. Same shape as
InputEventAction bypassing the input map: the instrument sat below the thing
that was broken, so the break could not appear in it.

tools/motion-census closes the class. It measures change and nothing else, and
its --selftest asserts it separates a fade (97.4 % moving) from a switch (2.6 %)
from a frozen film (0.0 %) -- a detector that cannot tell those apart would
report the same green line on all three.

Both briefs: this is the SOLE focus. The port reproduces before changing
anything and gates every fix on a film rather than a still. The decoder maps the
whole pipeline end to end -- disc bytes, the game's per-frame update (does it
interpolate between keyframes or hold?), what is submitted per frame, and what
Canary does to it before a capture records it -- delivered as a SERIES, not a
settled value.

The port should also record the refutation against itself: H2 reads ANSWERED on
the strength of the frozen sweep. The mechanism half stands, the blur is a baked
companion texture. The behaviour half does not.
2026-09-02 17:24:42 +02:00
MechaCat02
18b5b3d5f1 port: give the port container a GPU path -- it never had one
Reported as "the port has low FPS". Godot 4 renders through Vulkan and this
launcher passed nothing through, so it fell back to lavapipe: software Vulkan,
correct and slow. The decoder's launcher has had this block for a long time;
the container that actually runs a renderer was the one without it.

Same three cases as the decoder, including the part worth repeating: passing
/dev/dri alone does NOT work for NVIDIA -- Mesa cannot drive the card and the
proprietary userspace lives outside the image. It needs the container toolkit.

The NOTE now prints the full repo-add sequence, because the package is not in
Ubuntu's default repos and `apt install nvidia-container-toolkit` on its own
fails with 'no installation candidate' -- which reads like the package is
wrong rather than the source being missing.
2026-09-01 20:22:25 +02:00
MechaCat02
4ac23b94dd docker: auto-restart, and resume the session the agent was actually in
The decoder died mid-task and it took four separate findings to explain, each
of which read as something else:

1. OOM-KILLED, REPORTED AS A CLEAN EXIT. `OOMKilled: true` with **ExitCode 0**.
   So `--restart on-failure` would treat a memory kill as a successful finish
   and leave the agent down -- the policy has to be `unless-stopped`.

2. THE JOB CAP WAS SET AND THEN REMOVED THREE LINES LATER. build-reborn has
   always exported CARGO_BUILD_JOBS, but a raw `cargo test --release -p
   sylpheed-formats` never reaches the wrapper. Adding `-e CARGO_BUILD_JOBS` to
   the launcher did not help either: the entrypoint recomputes and exports over
   it unconditionally. An explicit value now wins, and says so in the log.

3. THE MEMORY CONSTANT WAS WRONG. `mem_gib * 2 / 3` assumes ~1.5 GB per job;
   release rustc on this workspace needs ~2 GB, and 4 jobs in 6 GB is what died.
   Divisor is now 2.

4. `--continue` CANNOT RESUME AN ABRUPT DEATH, which is the only kind we get.
   It resolves through ~/.claude.json's per-project `history`/`lastSessionId`,
   and MEASURED mid-session both are None -- they are written at a graceful
   shutdown. A killed container never writes them, so `--continue` answered
   "No conversation found to continue" with 33 MB of transcripts in the volume
   beside it. Persisting .claude.json did not help, because the fields were
   never populated in the first place; that attempt is removed rather than left
   in looking useful.

   The TRANSCRIPTS are durable and named by session id, so the entrypoint reads
   the id off the newest one for its cwd and passes `--resume <id>`. Verified
   on both agents: each reattached to its exact prior session and appended to
   the same file rather than opening a new one.

The /loop prompt is still passed alongside `--resume`, so the loop is RE-ARMED
rather than merely restored -- a resumed conversation with no wake-up scheduled
answers once and stops, which looks like resuming and is not.

Restarting into the same death is guarded at the other end: a start less than
120 s after the previous one begins FRESH instead of continuing back into
whatever killed it. That fired correctly during this work.

On resume the agent is told it was restarted, that its in-progress work is
uncommitted in the tree, that any build or capture it had running did not
finish and its absence is not a result, and which wrapper to prefer over a raw
release build.
2026-09-01 20:20:51 +02:00
MechaCat02
79783ff9ee agents: point each brief at its human branch, to merge on the first iteration
Both are pushed. The decoder's carries the R1 register reclassification and
tools/stale-instrument; the port's carries the two input fixes, verify-input
and BLOCKED H1-H3. Each branches from that agent's own tip, so it is a
fast-forward on the line they are already on -- and the port must merge before
touching input or it will re-derive a fix that is already asserted.
2026-09-01 17:59:59 +02:00
MechaCat02
1ad519d3ba agents: the splashes exactly, and stop photographing a moving thing
A human played the port on real hardware for the first time (2026-09-01) and
found four things. Two were port defects, fixed. Two are open and are now both
agents' focus: the PRESS (A) plate arrives late, and the splash fade/blur is
weaker than the game's.

Their verdict on method is the reason this is a brief change and not a ticket:

  "the agents were essentially guessing and trying to copy what one would see,
   but while they did get close it still is not quite right"

Close-but-not-right is the signature of reproducing APPEARANCE instead of
deriving MECHANISM. So the Decoder's focus block asks, in order: is there a
post-process pass at all, what is it, where do its parameters come from -- and
only then what curve. Both routes, dynamic (GPU state, shader constants, render
targets; add logging to Canary, it is theirs read-write) and static (.pe, the
DB, the paks), with each fact labelled by which produced it.

TEMPORAL-VERIFICATION.md is the other half, and it generalises past the
splashes. We have been photographing the game at time t, and t is never the
same twice: emulator speed varies with host load, Canary presents at ~28.1 fps,
the capture path costs a variable 0.1-10.8 s, and a long-lived x11grab stream
degrades and then freezes. The register already carries FOUR refutations of
exactly this shape. The replacement rule: record a film, not a photograph;
align by CONTENT, not by clock, and report the lag as a measurement rather than
minimising it away; prefer ordering, counts, durations and shape over any value
at a wall-clock instant; anchor on an event; report achieved fps against
requested fps.

Also into both briefs: the input set. The port had no joypad binding for (A) or
(B) and nobody noticed for a whole milestone, because --script sends
InputEventAction, which BYPASSES the input map -- so every check asserted the
code below the map and nothing about the map. The Decoder is asked to DECODE
the full set the game reads rather than discover it by pressing buttons; the
Port is told input is verified at the device level or not at all.

And both briefs now point at the R1 register reclassification, because two of
the ten re-opened entries land on this focus: "the declared keyframe timeline
reproduces the captured splash" is 🟡 our-reader, and the rest() pair is open
in BOTH directions -- while the two splashes are the only screens that reach
that fallback.
2026-09-01 17:59:17 +02:00
273 changed files with 4984 additions and 51848 deletions

View File

@@ -15,7 +15,7 @@
"boot": [
{
"screen": "publisher_logo",
"why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both."
"why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both. \ud83d\udccc SOURCE, added 2026-09-01: the boot's screen order and dwells are derived from GP_TITLE's own entries -- see docs/port/FORMAT.md for the export shape and docs/re/ui-title-build-map.md for which entry is which screen. The order here is not authored; it is what the archive declares."
},
{
"screen": "developer_logos",
@@ -25,31 +25,651 @@
"video": "ADV",
"why": "HANDOFF Q9, DECODED from the movie manifest: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the attract movie are the SAME asset -- there is no separate boot slot. Its POSITION here (after the developer logos, before the title) is measured, not decoded: it is the order the RE agent watched the game boot in.",
"skippable": true,
"skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline."
"skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline.",
"skippable_kind": "measured"
},
{
"screen": "title",
"why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. The port holds here -- nothing takes the title's place until P5 gives it somewhere to go."
"overlay": {
"screen": "press_start",
"clock": "shared",
"why": "MEASURED, 2026-08-29, docs/re/title-plate-delay-measured.md on branch auto/no-disc-and-menu-captures at 5b0a6e6 (NOT on main when this was written). The boot title shows build 4 ALONE and the `PRESS (A) BUTTON` plate -- build 2 -- arrives later. This is the ONE case in the port where two builds are drawn at once.",
"no_constant_why": "THERE IS NO AUTHORED DELAY HERE, AND THERE WAS ONE FOR ONE ITERATION. The first version of this block carried `after_settle_seconds: 2.13`, taken from the RE agent's instruction. The port refuted that instruction with arithmetic off the disc -- build 2 has a group of its own, and starting it at settle put the plate 3.97 s late -- and the corrected answer needs no constant at all: BOTH BUILDS RUN ON ONE CLOCK, STARTED TOGETHER, and the plate arrives at its own declared t=236 (CORRECTED 2026-09-01 from t=238, which is the last opaque frame rather than the arrival). `clock: \"shared\"` is that, spelled out rather than implied by the absence of a delay field. \ud83d\udccc SOURCES, added 2026-09-01 in the uncited-why backfill: the plate's arrival is docs/re/title-plate-delay-measured.md and its pulse is docs/re/structures/plate-pulse-measured.md. \ud83d\udd34 AND `clock: \"shared\"` IS AUTHORED FROM OUR OWN ARITHMETIC, NOT MEASURED. Nobody has watched whether build 2's group starts with build 4's; it is the reading that reconciles the oracle's 2.13 s IF the settle anchor is t=118. See docs/port/plate-arrival-halves.md and BLOCKED.md H3.",
"arithmetic_why": "Why one clock reproduces the measurement, checked against this export rather than taken on trust: build 4's effect quads `pteff01`, `pteff02` and `ptlogoall_eff` end their ramps together at t=118; `ptbtn00` reaches alpha 255 at t=236; the difference is 118 units = 1.967 s at 60 units/s. The oracle measured 2.138 s and 2.132 s. The gap is presentation rate: the emulator presents at 28.1 fps against a nominal 30, and the corpus had independently measured the idle title at 28.5 fps before these runs. \ud83d\udd34 CORRECTED 2026-09-01: this said `ptbtn00` reaches 255 at t=238 and that the difference is 120 units = 2.000 s. It reaches 255 at t=236 and HOLDS to 238, so 238 is the last opaque frame, not the arrival; 236 - 118 = 118. The port printed the contradiction in one sentence on every boot. The correction moves the reconciliation by 0.033 s and overturns nothing -- see docs/port/plate-arrival-halves.md. \ud83d\udd34 AND THE ANCHOR IS NOW OPEN. The oracle defines \"title settled\" operationally, as its glyph counter first reading the no-plate value 154. This export offers TWO anchors 42 units apart: t=118 (the effect quads) and t=160 (`ptcopyright` at full alpha -- the LAST element to finish building in, and the only one made of glyphs). This line picked 118, while `ScreenView.settle_time()` returns 160 and the boot prints `settles at t=160`, so one binary holds both. Asked in BLOCKED.md H3; not guessed here. \ud83d\udccc SOURCE: the pulse period and its phase behaviour are in docs/re/structures/plate-pulse-measured.md and docs/re/structures/plate-pulse-phase-lock.md, with the raw series in docs/re/data/plate-pulse-timeseries.txt. \u2705 AUDITED 2026-09-01: the corpus's 28.5 fps is a genuinely independent leg -- a different quantity (idle-title presentation rate), measured BEFORE these runs, so it could have come out disagreeing. It agrees to 1.4 %.",
"the_premise_that_failed_why": "The port's own, and it is worth keeping because it will bite again: `rest.t` IS NOT WHEN A SCREEN SETTLES. It is the last hold keyframe before the exit. Reading it as the settle put build 4's arrival at 4.35 s instead of 1.97 s, and every reconciliation computed from it came out wrong by exactly that error. `ScreenView.settle_time()` still uses rest.t -- see docs/port/BLOCKED.md. \ud83d\udd34 THE EXAMPLE THIS CITED IS GONE, THOUGH THE CONCLUSION IS NOT. It read \"`ptlogo1` has rest.t=251 and stops MOVING at t=42\". In the CURRENT export `ptlogo1.rest.t` is 42 -- equal to when it stops moving. The record-layout fix repaired precisely that element, and the entry was never re-derived under it (REFUTED.md now carries this at \ud83d\udfe1 \u27e8our-reader\u27e9). rest.t is still wrong for transients -- `ptlogo_back2eff1` is a two-frame flash whose rest.t=54 is the flash PEAK -- and for `pteff00`, whose rest.t=16 sits at the end of the fade-FROM-black while a fade-TO-black runs 261..269. Re-derived 2026-09-01: docs/port/plate-arrival-halves.md. \ud83d\udd34 AND IT IS NOT THIS DEFECT'S CAUSE. The plate's ARRIVAL is a declared keyframe (transparent to t=214, opaque at t=236), not a rest pose; rest.t=236 only chooses where `holding` parks it, and 236 is that ramp's own peak. Confirmed on a filmed boot with rest.t untouched: the onset is bracketed within one frame of 214.",
"scope_why": "Attached to the BOOT STEP, not to the `title` screen, and that is deliberate. What was measured is the boot title. Whether the title shows the plate when it is REACHED AGAIN -- by (B) from the main menu, or after the attract movie -- is not measured, and putting the overlay on the screen would quietly claim it is. \ud83d\udccc SOURCE, added 2026-09-01: the plate belongs to the boot's overlay step rather than to the title screen because its arrival is measured against the boot clock -- docs/re/title-plate-delay-measured.md. \ud83d\udd34 STALE CLAUSE, CORRECTED 2026-09-01: this said \"Whether the title shows the plate when it is REACHED AGAIN -- by (B) from the main menu, or after the attract movie -- is not measured\". It IS measured now, and has been since 2026-08-30: after (B) from the menu the plate is re-drawn, pressed at 351.2 s with its pulse back at 358.5 s (the Decoder, nav-autorepeat-and-settled-b data). The port re-arms the overlay on arrival at the title by any path, and that is correct. What stayed true is the structural half -- the declaration lives on the boot STEP and is looked up from there, so a screen that gains an overlay gets it on both paths at once. \u26a0\ufe0f What is STILL not measured is whether the returned plate FADES or appears at once; the 7.3 s between press and pulse is consistent with a transition plus the declared 214->236 fade, but that is consistency, not a measurement of the ramp on this path.",
"no_pulse_why": "The port draws the plate arriving and then holding. It does not pulse it. The RE agent identifies the pulse as the plate's FOCUS RECORD `ptbtn00f` -- a glow ramping 0x00 to 0x50 and back, t=6..105 -- not as a loop of `ptbtn00`'s own group, which was the port's earlier reading and was wrong. Looping that record is a candidate the port has NOT taken: its group is 105 timed units plus an AUTHORED 24-unit exit ramp, and hitting the measured 2.24 s mean requires composing that authored constant with a loop assumption, which is tuning rather than measuring. Filed in BLOCKED.md. \ud83d\udccc SOURCE, added 2026-09-01: docs/re/structures/plate-pulse-measured.md, and the phase-lock caveat that bounds what a gated capture can show is docs/re/structures/plate-pulse-phase-lock.md."
},
"why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. This is the LAST step, and a last step is where the sequence stops rather than fading out -- a boot that ends by fading to black looks like a boot that crashed. P5 gave the title somewhere to go, but that is a HANDOVER and not another boot step: `--boot` still stops here, and `--boot --play` hands the same held title to the menu flow, where (A) opens TITLE_MENU. Kept as a stop rather than folded into `screens` because what the boot does is authored from a measured sequence, and what (A) does is a separate measurement."
}
],
"dwell": {
"_": [
"DELIBERATELY EMPTY. Each screen's dwell is its own keyframe group -- the",
"publisher wordmark reaches its hold at t=235 (3.92 s) and the developer",
"logos at t=190 (3.17 s), both read from the disc. Holding beyond that would",
"be a number nobody has measured, so the sequencer holds for zero extra time",
"and the pacing is the disc's own.",
"NOT SET -- because the dwell is DECLARED, and the port already plays it.",
"",
"When a capture times the real boot, the extra hold per screen goes here."
"This key has now been wrong in two opposite directions, and the second was",
"mine, so both are recorded.",
"",
"It first said 'a screen's dwell is its OWN keyframe group'. Then GP_TITLE",
"build 4 was measured dwelling ~1100 presented frames against a declared ~120,",
"and I generalised that into 'the boot is KNOWN TOO FAST [refuted] on both splashes'.",
"\ud83d\udd34 THAT WAS AN OVER-CORRECTION and it is withdrawn. Build 4 is the title: its",
"exit is caused by something outside its timeline, so it holds. A splash's exit",
"is caused by nothing, so it plays its declared timeline and leaves. The title",
"is the exception, not the rule, and one screen was never enough to overturn",
"the other two.",
"",
"MEASURED 2026-08-29 by the Decoder over 3 cold boots",
"(docs/re/structures/boot-splash-dwells-are-declared.md):",
"",
" publisher declared t=0..255 = 4.250 s corpus 4.30 / 4.60 / 4.37",
" developer declared t=0..210 = 3.500 s corpus 3.51 / 3.50 / 3.37",
"",
"The developer agrees to 1.1 %, two of its three runs to 0.3 %. The port emits",
"4.400 s and 3.650 s -- each declared value plus the 9-unit black hold, exactly.",
"So the pacing was right all along and nothing changes in the code.",
"",
"\ud83d\udd34 AND THE UNIT STAYS UNITS, NOT SECONDS. The same two dwells timed in the",
"Decoder's own container came out 15-20 % LONGER than both the declared values",
"and the corpus -- same disc, same timeline -- and three independent readings",
"of that container's rate disagree with each other. A seconds figure records",
"one emulator's pacing on one run. The units are on the disc. If anything ever",
"goes in `dwell` it is an extra hold in UNITS, and only for a screen that is",
"measured to wait beyond its group."
]
},
"navigation": {
"_": [
"MEASURED off the running game, HANDOFF Q5 -- none of it is on the disc.",
"It lives here rather than in GDScript so that a reader can see it is a",
"measurement and delete it the day a field on the disc states it."
],
"wrap": true,
"wrap_why": "HANDOFF Q5: up/down move one item and WRAP at both ends. Measured on the 5-item main menu AND the 3-item EXTRAS, so it is a menu rule and not a per-screen one (docs/game/navigation.md, branch auto/no-disc-and-menu-captures 3a87a26).",
"wrap_kind": "measured",
"left_right": "nothing",
"left_right_why": "HANDOFF Q5: left/right do nothing. Measured. Implemented as an explicit no-op rather than by omission, so that 'we never wired it' and 'the game ignores it' are distinguishable in the code.",
"left_right_kind": "measured",
"input_during_transition": "ignored",
"input_during_transition_why": "AUTHORED, and NOT measured -- nobody has watched what the game does with a button pressed mid-fade. Ignoring is the choice that invents the least: it cannot queue a press the game might have dropped. Ask the RE agent before relying on it. \ud83d\udccc WHERE THE ASK LIVES, added 2026-09-01: docs/port/BLOCKED.md carries it, and until now this why said \"ask the RE agent\" without naming where the question is recorded -- a pointer with no destination. An `authored` kind still needs a citation, because the thing to cite is the OPEN QUESTION the choice stands in for; without it, an invented value and a placeholder for a measurement read the same.",
"input_during_transition_kind": "authored",
"auto_repeat": false,
"auto_repeat_why": "MEASURED 2026-08-30, Decoder daf8f47: a 2.0 s held (down) moves the cursor EXACTLY ONCE. Their counter passes its own control first -- a single 0.12 s tap gives exactly 1 spike, the hold gives 1, move spike 0.0202-0.0220 against a 0.0003-0.0038 floor. The port's edge-triggered _input already behaved this way; what changed is that it is now a MEASUREMENT rather than an unexamined consequence of how the handler was written. HANDOFF Q5's 'up / down' row is split at the source: one-item-per-press (evidenced by the 4-press wrap count) from no-auto-repeat (which had nothing until this run).",
"auto_repeat_kind": "measured"
},
"screens": {
"_": [
"What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the",
"destination screens and the RE agent later decoded that a transition is a",
"lookup by NAME, giving a candidate vocabulary (TITLE_SCREEN, TITLE_MENU,",
"LOADING, DIFFICULTY, EXTRA_MENU, TUTORIAL_MENU). Those are the right `goto`",
"targets when this is written, marked as the name match they are."
]
"What each button does. The NAVIGATION ORDER is not here -- it is derived,",
"in each screen file's `buttons` (button-role elements sorted by resting Y).",
"Only the destinations, the initial focus and the cancel target are",
"authored, because only those are measurements or decisions.",
"",
"`goto` is an EXPORTED SCREEN NAME or null. `goto_name` is the game's own",
"screen vocabulary from the decoded transition lookup -- carried so the",
"binding is not lost, and marked below as the NAME MATCH it is, never as a",
"measurement (HANDOFF: the strings are what the call sites reference, not",
"proven arguments, and the same list mixes in TEXT_FONT and GAMMA_RGB).",
"",
"`goto: null` with a `blocked` note means the destination screen is real and",
"measured but is NOT IN THIS EXPORT -- it lives in another archive. That is a",
"milestone boundary, not an unknown."
],
"title": {
"on_accept": {
"goto": "main_menu",
"goto_name": "TITLE_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"\ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"why": "MEASURED, HANDOFF: (A) on the title opens the main menu, with (A) on the boot title as the control in the same run."
},
"on_cancel": null,
"on_cancel_why": "MEASURED 2026-08-30, Decoder daf8f47, docs/re/data/nav-autorepeat-and-settled-b.txt: twenty seconds after a delivery-confirmed B the screen is still the title with PRESS (A) BUTTON up. The run waited for the PLATE PULSE -- the title's own settled signature -- before pressing, which is exactly what the earlier confounded attempt did not. This cell briefly said 'MEASURED, HANDOFF Q5' on no evidence, then said AUTHORED once that was caught; it is now measured for real. Value unchanged throughout: null.",
"on_cancel_kind": "measured"
},
"main_menu": {
"initial_focus": "ptbtn01",
"initial_focus_kind": "measured",
"focus_persists": true,
"focus_persists_kind": "measured",
"focus_persists_why": [
"MEASURED 2026-08-30, Decoder: the main menu REMEMBERS ITS CURSOR across a",
"round trip through the title. (B) out and (A) back returns to the item you",
"left, not to a default. Their control passed first -- two delivery-confirmed",
"DOWNs moved the cursor exactly two items before the round trip, so the",
"cursor demonstrably was not where it started.",
"",
"The port reset to `initial_focus` on every entry, so this was a real defect",
"and not a refinement: a player who moved to EXTRAS, pressed (B), then (A),",
"landed back on NEW GAME.",
"",
"\ud83d\udd34 SCOPED TO THIS SCREEN ON PURPOSE, and the scope is the authored part.",
"The measurement is of the MAIN MENU. Making it a menu-wide rule would be",
"n=1 wearing a rule's clothes -- and here it would actively contradict a",
"measurement, because `extras` opens on MISSION SELECT as a MEASURED initial",
"focus, and a remembered cursor would override it on re-entry. `wrap` is a",
"menu rule because it was measured on two screens; this was measured on one.",
"",
"\u26a0\ufe0f WHAT IS NOT KNOWN: whether the memory survives a return to the BOOT",
"(as opposed to the title), and whether any other screen has it. Ask before",
"widening this.",
"",
"\ud83d\udd34 CORRECTED 2026-08-30, SAME DAY, by the Decoder: the paragraph above argued",
"the scope from `extras` having a MEASURED initial focus that a remembered",
"cursor would override. That is a good reason to be CAUTIOUS and NOT a finding",
"that `extras` resets. Nothing has measured what a submenu's own cursor does on",
"re-entry: the corpus has EXTRAS' opening item from ONE entry, and (B) restoring",
"the PARENT's focus 4/4, and neither answers it.",
"",
"So `focus_persists: false` everywhere else is THE PORT'S DEFAULT, not the",
"game's behaviour. It invents the least and it preserves the one measurement",
"there is. `tools/port/contract-check` asserts only the main-menu half against",
"the contract and reports the scope as a GUARD, because for one iteration it",
"asserted non-persistence as though it had been measured -- which would have",
"held the port to the wrong behaviour and passed while doing it.",
"",
"\u2754 The Decoder is measuring EXTRAS re-entry now. Do not build on the",
"non-persistence half until it returns.",
"",
"\ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: docs/re/data/focus-persists-across-title.txt carries the round trip, and docs/re/data/extras-focus-resets.txt carries the contrasting submenu result that keeps this scoped to one screen."
],
"initial_focus_why": [
"MEASURED 2026-08-30 (later) -- `NEW GAME` on a fresh boot, 2/2 fresh boots,",
"both the FIRST menu entry. Decoder, HANDOFF `bf9e07f`, section \"correcting",
"today's focus delivery\"; ring row y=225.5 against a measured 79.25 px step,",
"data in docs/re/data/menu-focus-reader-offset.txt.",
"",
"\ud83d\udd34 THIS FIELD WAS `authored` UNTIL NOW AND THE UPGRADE IS NOT BECAUSE IT",
"AGREES WITH ME. The value did not change; its standing did. The confirmation",
"is a direct reading of a fresh boot's first menu entry, independent of the",
"reasoning that chose NEW GAME here -- and the Decoder had said explicitly that",
"my agreeing with their records was no evidence, which was correct at the time.",
"",
"\u2705 AND IT SURVIVES A REBOOT -- MEASURED 2026-08-31. Six fresh boots all",
"opened on NEW GAME, and THREE of them followed a session that ended with the",
"cursor on EXTRAS or OPTIONS. That is what makes it a test of persistence",
"rather than six repetitions of the same start.",
"",
"\u26a0\ufe0f REACH, and it is the Decoder's own caveat rather than mine: every one of",
"those sessions ended with the emulator KILLED, not shut down cleanly. A game",
"that writes menu state on a clean exit never gets the chance, so this",
"measures 'does not survive a KILLED session'. If a real console remembers a",
"cursor across a power cycle, that does not contradict this.",
"",
"\u26a0\ufe0f WHY 'FIRST ENTRY' IS LOAD-BEARING: the menu REMEMBERS ITS CURSOR (see",
"`focus_persists`), so any reading not taken on a fresh boot's first entry is",
"measuring HISTORY, not what the screen opens on. That objection is what",
"invalidated the earlier TUTORIAL/NEW GAME disagreement, and this measurement",
"is the one that is immune to it.",
"",
"The superseded reasoning is kept below, because it is what made the wait cheap:",
"the field existed and was labelled honestly, so arriving at a measurement was a",
"label change and not an archaeology problem.",
"",
" (was) AUTHORED, standing in for HANDOFF Q5, which measured that initial focus is NOT STABLE: four boots of the same harness opened on TUTORIAL, TUTORIAL, NEW GAME, NEW GAME. A port has to open on something. ptbtn01 (NEW GAME) is picked because it is one of the two states actually observed and it is the top item, so a reader can predict it. It is a CHOICE. Delete this the day the RE agent finds what selects it. CORROBORATED 2026-08-29, and still not decoded: the committed capture live-main-menu.png has NEW GAME focused. Identified by rendering all five focus states and taking the minimum difference -- 531 differing pixels against 6080-7094 for the others, an 11.5x margin -- with the method controlled on live-main-menu-options-focused.png, whose answer is in its filename and which it picks by 4.7x. That means the port's choice matches the state of one committed frame. It does NOT make focus stable: Q5's four boots gave TUTORIAL, TUTORIAL, NEW GAME, NEW GAME, and this identifies one frame rather than a rule. Delete this entry the day something says what SELECTS it. TIGHTENED 2026-08-29: Q5 now has SIX boots, and the shape is sharper than 'unstable' -- TUTORIAL x3, NEW GAME x3, and NO OTHER ITEM EVER OBSERVED. So it is not uniform over five buttons; whatever selects it has to explain a two-way split. That does not change this choice (NEW GAME remains one of exactly two observed states, and it is the state of the committed capture) but it does change what would REFUTE it: a boot opening on LOAD GAME, OPTIONS or EXTRAS would break the two-way shape, and a rule that predicts the split would delete this entry outright.",
" (was) ",
" (was) \u2705 CONSISTENT WITH THE ONE CAPTURE, measured 2026-08-30. Rendering each of the",
" (was) five buttons focused against `live-main-menu.png` gives 0.0705 % for ptbtn01",
" (was) and 0.72-0.84 % for the other four -- a 10x discrimination. So that capture",
" (was) shows NEW GAME focused, and the authored choice matches it.",
" (was) ",
" (was) \u26a0\ufe0f THIS DOES NOT OVERTURN Q5. Q5 measured initial focus as UNSTABLE across",
" (was) four boots; one capture showing ptbtn01 is consistent with that and does not",
" (was) contradict it. What the measurement establishes is narrower and still worth",
" (was) having: the port's focus rendering is distinctive enough that a capture",
" (was) identifies which button is focused, and this authored value is not at odds",
" (was) with the only frame we can check it against. It stays AUTHORED."
],
"on_cancel": {
"goto": "title",
"goto_name": "TITLE_SCREEN",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TITLE_SCREEN` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"\ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"kind": "measured",
"why": "MEASURED 2026-08-30, delivery-confirmed (B = 0x5801), 73.5 % of pixels changed, and both captures name themselves. Latency <= 0.4 s and NO loading screen in between, which matters because the disc carries four pgloading_* screens. This entry previously read 'likely but UNPROVEN': it had been seen once without a capture, and the title ALSO returns on its own after ~8-10 s idle, so an observer could not tell a response from a timeout. The <= 0.4 s latency is what kills that confound -- it is twenty times faster than the idle return. Decoder 86a8ce7, menu-navigation-semantics.md row 'B on the main menu', docs/re/data/b-on-main-menu.txt."
},
"buttons": {
"ptbtn01": {
"label": "NEW GAME",
"goto": null,
"goto_name": "DLG_SELECT_DIFFICULTY",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"\u2705 CORRECTED 2026-08-31: this read `DIFFICULTY`, and the destination is a",
"DIALOG rather than a GamePart -- `DLG_SELECT_DIFFICULTY`, `GP_DIALOG.pak`",
"entries 2/3 [see the withdrawal below]. Decoder, TWO arguments [corrected below]; the geometry one is",
"re-derived here with this port's own reader: entries 2 and 3 are the ONLY",
"builds in that archive carrying `pcbtn00`-`pcbtn03`, at design rows",
"259/329/399/469, spacing exactly 70. See",
"`crates/sylpheed-export/examples/dialog_rows.rs`.",
"",
"\ud83d\udd34 SO THE FOUR EXTERNAL DESTINATIONS ARE NOT UNIFORM: three open GameParts",
"and this one opens a dialog. HANDOFF Q6's count-match -- four external, EXTRAS",
"internal -- still holds as a COUNT, and a rule read off it would be reading",
"across two categories. The Decoder sent that count with disc support",
"yesterday and weakened it themselves today; recorded at the weaker strength.",
"",
"\u2705 THE REACH IS NOW BOUNDED -- 2026-08-31, and both agents scanned for it.",
"",
"It read: \"another four-button dialog with the same rows would be",
"indistinguishable by this evidence\". The Decoder searched every build in",
"every pak for four buttons within 6 px of those rows and found ZERO rivals.",
"Re-run here with this port's reader and a BROADER filter -- any element",
"whose name contains `btn`, not only `pcbtn`, so a rival under a different",
"naming convention would still be caught: 2 859 builds across 33 paks,",
"EXACTLY 2 matches, entries 2 and 3. The run carries its own known positive:",
"fewer than 2 would mean the reader cannot see the incumbents and its zero",
"would mean nothing.",
"",
"\u2705 And the name is now backed by a TABLE ENTRY rather than an inference",
"from a string list: every `DLG_` name in the image sits in a 12-byte record",
"(id, name pointer, handler [corrected]) spanning 0x820A0A2C-0x820A0D68 -- 70 names,",
"70 records, none unmatched. `DLG_SELECT_DIFFICULTY` is **id 2000**.",
"",
"\ud83d\udd34 \"THREE INDEPENDENT ROUTES\" CORRECTED TO TWO -- 2026-08-31, by the Decoder,",
"and I had relayed the count unchecked for the second time from one delivery.",
"",
"The image leg says DIFFICULTY is a dialog and names no entry, so alone it",
"identifies nothing. The disc and oracle legs are ONE COMPOUND ARGUMENT: the",
"capture is compared against the disc's rows. What makes that discriminating is",
"the EXCLUSION SCAN -- zero rivals within 6 px anywhere on the disc -- and that",
"is what the word \"three\" was taking credit for. The conclusion is unchanged;",
"the evidence is two arguments, one of them compound, and was never three.",
"",
"\ud83d\udccc The test that falls out of it, theirs: ask of an n-routes claim not whether",
"the routes are correct but whether ANY COULD HAVE COME OUT DIFFERENTLY GIVEN",
"THE OTHERS. That is an exclusion argument, and it is usually absent.",
"",
"\ud83d\udd34 WITHDRAWN 2026-08-31 -- \"AN EN/JP PAIR\", AND I RELAYED IT.",
"",
"The Decoder stated entries 2/3 as a language pair in the same HANDOFF row that",
"identifies DIFFICULTY, as a fact, and has withdrawn it: nothing established the",
"pairing. I copied it into this `why` -- twice -- in the SAME SENTENCE where I",
"was careful to say my re-derivation confirms the geometry and does not name the",
"screen. The unchecked half rode along inside the clause I had checked.",
"",
"What the scan actually shows is that adjacent GP_DIALOG entries are UNRELATED",
"DIALOGS: 26 of 65 adjacent pairs differ in BUTTON COUNT, which no language pair",
"can. Identical element sets is the language signature in GP_TITLE; here it is",
"equally consistent with a duplicate. So `2/3` are two builds with the same four",
"buttons at the same rows, and calling them EN and JP is an assumption.",
"",
"\u26a0\ufe0f THE IDENTIFICATION DOES NOT REST ON IT -- unique four-button geometry with",
"zero rivals disc-wide, plus the oracle capture. The pairing was decoration on a",
"conclusion that stands without it, which is exactly why it travelled unchecked.",
"",
"\u2705 RESTORED 2026-08-31, ON A MEASUREMENT RATHER THAN A RELAY. The Decoder took",
"the `ja` capture of DIFFICULTY that was missing and 2/3 ARE English/Japanese:",
"EN vs JP differ in 1.82 % of pixels in FOUR BANDS AND NOWHERE ELSE -- the",
"heading (DIFFICULTY -> the JP heading), the ring by 2 px, the BACK label, and",
"the footer. EASY/NORMAL/HARD are NOT in the differing set: the Japanese release",
"leaves the three difficulty names in Latin script, which is why the disc figure",
"is only 2.77 % of bytes against 1.82 % of pixels.",
"",
"\ud83d\udccc MY OBJECTION WAS NOT WRONG AND IS NOT WITHDRAWN. It was that IDENTICAL",
"ELEMENT SETS DO NOT IMPLY A LANGUAGE PAIR -- 26 of 65 adjacent pairs differ in",
"button count, so adjacency proves nothing. That argument still holds; what has",
"changed is that the conclusion now rests on a direct locale capture instead of",
"on that inference. A bad argument for a true claim is still a bad argument, and",
"the claim was correctly out of this file until somebody went and looked.",
"",
"\u26a0\ufe0f REACH, THEIRS: one JP boot, one screen, does not generalise. GP_TITLE 4/7 is",
"known to differ by MORE than text -- entry 7 carries nine sprites entry 4 lacks.",
"Nothing in the port keys off locale today; this is recorded, not consumed.",
"",
"\ud83d\udd34 RECORD LAYOUT CORRECTED 2026-09-01, and I had copied the wrong one. I wrote",
"\"(handler, id, name pointer)\"; it is {id, name_ptr, handler} -- the same three",
"fields shifted one word, so every record was being credited with the PREVIOUS",
"record's handler. The Decoder caught it with a control dump: under the old",
"alignment record 0 had a handler of 0x10000000, which is not a code address.",
"ids and names are unaffected and DLG_SELECT_DIFFICULTY is still 2000, so",
"nothing here moves except the sentence.",
"",
"\ud83d\udccc FOURTH aside of theirs relayed into this file. The first three were an EN/JP",
"pairing, a leg count and an independence claim -- all decorative. This one is a",
"STRUCTURE, which is worse: a wrong field order is the kind of thing a later",
"reader builds on, and it carried no weight here only by luck.",
"",
"\u2754 AND THE JOIN IS NOT REACHABLE BY THAT ROUTE -- their negative, with their",
"reach. All three handlers load the same global at 0x828E2B14 and take addresses",
"at 0x828E45E0/4640/467C, every one inside a 364 601-byte contiguous zero run:",
"BSS, populated only at runtime. Controlled, because an all-zero read is also",
"what a wrong address gives, and the dialog table itself reads non-zero through",
"the same arithmetic.",
"",
"\u26a0\ufe0f That closes the DIALOG HANDLERS, not the image. The archive loader and any",
"id-keyed table elsewhere are unexamined, so \"not in the image\" is NOT",
"established. Recorded as a route rather than an answer, which is how they sent",
"it.",
"",
"\u2754 STILL UNBOUND, and it is what would make this airtight: nothing connects",
"id 2000 to a pak entry. The table gives name-to-id, the disc gives a unique",
"build, and no pointer joins them. The tie is UNIQUENESS PLUS THE ORACLE",
"CAPTURE, not a binding -- so if a rival build ever appeared, this",
"identification would go with it.",
"",
"button count and geometry, NOT by a binding from the `DLG_` name to a pak",
"entry. No such binding was found. Another four-button dialog with the same",
"rows would be indistinguishable by this evidence -- my re-derivation",
"confirms the geometry and does not name the screen.",
"",
" (was) NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
" (was) screens are measured; the ids are a name match onto the executable's class",
" (was) names.\" So `DIFFICULTY` is a string that exists in the executable and plausibly",
" (was) denotes this screen -- nothing observed binds it to this transition.",
" (was) ",
" (was) It is carried so a reader can search for it and so the port never has to",
" (was) invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
" (was) a screen file, and this field is documentation.",
" (was) ",
" (was) \ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
" (was) labels rested on a sibling `why` that argues the DESTINATION -- a different",
" (was) claim from where the NAME came from. `tools/port/audit-kinds` reports that",
" (was) as BORROWED rather than ok, because a label resting on a neighbour's",
" (was) argument reads as evidenced and is not."
],
"blocked": "DIFFICULTY is not in this export. MEASURED destination (EASY/NORMAL/HARD/BACK, opening on NORMAL, then SELECT DATA) but it is not a GP_TITLE build, so there is no screen file to go to yet.",
"skipped_chain": [
"DIFFICULTY",
"SELECT DATA"
],
"skipped_chain_why": "THE PORT SKIPS TWO MEASURED SCREENS HERE, AND IT SAYS SO OUT LOUD RATHER THAN PRETENDING. The real chain is NEW GAME -> DIFFICULTY -> SELECT DATA -> (A) on a save slot -> ~4.5 s -> S00A. DIFFICULTY and SELECT DATA are MEASURED destinations (HANDOFF Q4) but neither is a GP_TITLE build, so there is no screen file to go to. The port jumps from NEW GAME to the one thing in that chain it has, and the runtime prints what it skipped on every run. This is a GAP, not a sequence: nobody may read the port's behaviour here as what the game does.",
"skipped_chain_kind": "measured",
"then_video": "S00A",
"then_video_why": "P7. HANDOFF Q9, DECODED from the movie manifest: MS00A -> S00A.wmv is the new-game intro, 93.9 s. Its POSITION is measured as well -- the movie starts ~4.5 s after (A) on the save slot, matched off the running game at 0.96-1.000 with a strictly monotone playhead over 25 consecutive 0.5 s samples.",
"then_video_kind": "decoded",
"unobserved_why": "WHAT FILLS THE ~4.5 s between the save slot and the movie is NOT KNOWN. The oracle run that would have shown it hit the already-documented sub_823070B0 cache crash after SELECT DATA. GP_TITLE does carry a LOADING screen -- entries 0/1 and 12/15, whose elements are every one of them named pgloading_* -- and LOADING is in the game's own screen vocabulary, but nobody has watched it appear here and the port does NOT put it in the chain on that basis. \ud83d\udccc WHERE THE OPEN QUESTION LIVES, added 2026-09-01: docs/port/BLOCKED.md carries the row -- 'what fills the 4.5 s before S00A'. An explicit unknown still needs a citation, or it cannot be distinguished from an unexamined one.",
"skippable": true,
"skippable_why": "HANDOFF Q9, MEASURED: one (A) press skips a movie -- the title was reached at 57 s against a 193 s baseline. Same rule the boot intro already uses.",
"skippable_kind": "measured",
"after_video": {
"goto": "title",
"kind": "authored",
"why": "AUTHORED, and it has to be: the game goes into MISSION 1, and gameplay is out of scope (PORT-MISSION section 7). P7's gate asks for 'plays, then returns to a defined state' -- this is that state. The title is chosen over the main menu because the boot's own end state is the title, so a run that finishes the new-game intro lands somewhere a player can start again from. Nothing measured says the game does this."
}
},
"ptbtn02": {
"label": "LOAD GAME",
"goto": null,
"goto_name": null,
"blocked": "The save-slot list is GP_SAVE_LOAD, not in this export. Destination MEASURED."
},
"ptbtn03": {
"label": "TUTORIAL",
"goto": null,
"goto_name": "TUTORIAL_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TUTORIAL_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"\ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"blocked": "The lesson list is not a GP_TITLE build. Destination MEASURED."
},
"ptbtn04": {
"label": "OPTIONS",
"goto": "options",
"goto_name": null,
"goto_why": "UNBLOCKED 2026-09-03. This read goto:null with blocked: 'The settings menu is GP_OPTIONS, not in this export.' GP_OPTIONS is in the export now -- authored/screen_names.json export_archives -- and entry 19 is its root, named `options` from the text it renders: GAME SETTINGS, CONTROL SETTINGS, SOUND SETTINGS, SCREEN SETTINGS, BACK. docs/port/options-screens.md. The destination itself was always MEASURED; only the screen file was missing.",
"goto_kind": "measured-destination-newly-exported",
"limits": [
"\ud83d\udd34 THE SCREEN OPENS BUT DOES NOT NAVIGATE. Its five rows are kind 0x3003 and the exporter only treats 0x3002 as a button, so export buttons[] is empty and up/down move nothing. \u24b7 backs out correctly.",
"What 0x3003 MEANS is a decode question and is with the Decoder -- not widened here on the port's authority. The circumstantial case is strong (five rows, each with a focus record, on a screen whose own text lists five options) and circumstantial is exactly the standard this project keeps getting burned by.",
"None of the sub-screens is wired. Which row leads where is read off content, not measured navigation."
]
},
"ptbtn05": {
"label": "EXTRAS",
"goto": "extras",
"goto_name": "EXTRA_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `EXTRA_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"\ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"why": "MEASURED, HANDOFF Q4: EXTRAS opens GP_TITLE build 6. It is the ONLY main-menu destination inside this archive, and therefore the only (A)-into-a-submenu the P5 gate can actually walk."
}
},
"labels_why": "The five labels are read off live-main-menu.png, a capture of the running game (docs/game/navigation.md, branch auto/no-disc-and-menu-captures 3a87a26). They are carried for logs and for a human reading this file; nothing draws them -- the button sprite already has its own text."
},
"extras": {
"initial_focus": "ptbtn11",
"initial_focus_kind": "measured",
"focus_persists": false,
"focus_persists_kind": "measured",
"focus_persists_why": [
"MEASURED 2026-08-30 -- EXTRAS RESETS. HANDOFF `4ed75e6`: ring back to",
"y=347.5 on re-entry after a confirmed DOWN, frame 0.0 % different from the",
"first entry, and the screen confirmed by eye as EXTRAS because an earlier",
"run was fooled about which screen it was on.",
"",
"\ud83d\udccc WRITTEN EXPLICITLY, THOUGH THE PORT'S DEFAULT IS ALREADY false. The",
"absent key and the measured false behave identically and mean completely",
"different things: one is 'nobody looked', the other is 'the game was",
"watched doing it'. `tools/port/audit-kinds` can see the second and not the",
"first, which is the whole reason for spending a key on it.",
"",
"\ud83d\udd34 AND THIS IS NOT A VINDICATION OF HOW IT GOT HERE. For one iteration the",
"port ASSERTED non-persistence for EXTRAS in `contract-check` while nothing",
"had measured it; the Decoder flagged that, and it turned out right. Being",
"right by luck does not retroactively make it evidence -- declining to",
"generalise the memory was the correct move, and encoding 'not measured",
"here' as a positive claim was a different and wrong one that happened to",
"land. The measurement is what makes it true; the assertion never did.",
"",
"\u26a0\ufe0f Do NOT generalise in either direction: main_menu persists, EXTRAS resets,",
"and OPTIONS / LOAD GAME / TUTORIAL are untouched."
],
"initial_focus_why": [
"MEASURED, unlike the main menu's: EXTRAS opens focused on MISSION SELECT (live-extras.png). It is authored here only because there is nowhere else to put a measurement -- it is not a choice.",
"",
"",
"\u2705 CAVEAT LIFTED 2026-08-30 -- MEASURED, not a single-entry reading any more.",
"HANDOFF `4ed75e6`, docs/re/data/extras-focus-resets.txt: EXTRAS opens at ring",
"y=347.5 on MISSION SELECT, moves to 427.5 after one delivery-confirmed DOWN,",
"and returns to 347.5 on re-entry with the frame 0.0 % different from the first",
"entry. Because this screen RESETS, a single-entry reading of it is not",
"measuring history -- which is precisely what made the caveat necessary while",
"persistence here was unknown.",
"",
"\u2705 THE AMBIGUITY IS RESOLVED -- MEASURED 2026-08-31, and it went the way",
"that makes `ptbtn11` right for a REASON rather than by coincidence.",
"",
"A submenu resets to ITS OWN OPENING ITEM, and that item is a per-screen",
"default which need NOT be the first. Decoder, docs/re/data/",
"difficulty-resets-to-named-item.txt: DIFFICULTY opens on NORMAL (second of",
"four); after one confirmed DOWN to HARD, (B) out and (A) back returns to",
"NORMAL -- in-cursor 1.0 from where it opened against 93.9 from where it was",
"left. Reproduced on a FRESH BOOT and confirmed by eye, not read off the",
"2026-08-29 capture.",
"",
"So the port's `initial_focus` is the reset target, and `buttons[0]` in",
"`MenuFlow.initial_focus` is a REPAIR rather than a default -- which is how",
"it was already documented, and is now measured rather than principled.",
"",
"\u2754 STILL OPEN, and not leaned on: whether the reset target MOVES once a",
"difficulty has actually been confirmed. A game that remembered your last",
"choice would behave differently, and the probe never confirms one -- the",
"same SELECT DATA crash that constrains the run prevents testing it.",
"",
"",
"\ud83d\udd34 CORRECTED 2026-08-31. This read \"it matters IF another screen is ever",
"authored\" whose opening item is not its first. Such a screen exists and is",
"recorded IN THIS FILE: DIFFICULTY, under `main_menu/buttons/ptbtn01`, is",
"EASY/NORMAL/HARD/BACK and opens on NORMAL -- the SECOND of four. Measured:",
"driven with no d-pad, unchanged for 90 s, matching the committed capture at",
"r=+0.999 (Decoder, docs/re/captures/newgame-path/newgame-difficulty.png).",
"",
"So \"a screen opens on its first item\" is REFUTED as a general description of",
"this game. On EXTRAS, TUTORIAL and OPTIONS the named item and the top item",
"coincide BY ACCIDENT. A top-item rule would be wrong on DIFFICULTY.",
"",
"nobody can separate \"resets to MISSION SELECT\" from \"resets to the TOP ITEM\".",
"They coincide here -- ptbtn11 is both. The port's value is correct under either",
"reading, and the REASON is not established.",
"",
"The superseded caveat is kept below.",
" (was) \u26a0\ufe0f WEAKENED 2026-08-30 -- the OBSERVATION stands, its reading as an INITIAL",
" (was) focus does not. It was taken on a single entry. Now that the main menu is known",
" (was) to remember its cursor across a round trip, a one-entry reading of any screen",
" (was) may be measuring HISTORY rather than what the screen opens on -- the same",
" (was) objection that reframed the main menu's TUTORIAL/NEW GAME disagreement.",
" (was) ",
" (was) Kept as `measured` because the frame really does show MISSION SELECT focused,",
" (was) and kept as the port's opening item because it is the only reading there is.",
" (was) \ud83d\udd34 If EXTRAS turns out to persist, this becomes history and the kind must",
" (was) change with it.",
"",
"\ud83d\udd34 CHECKED AGAINST THE BYTES 2026-08-31 by both agents -- and NOT independently.",
"Settled by fact, not by my inference: the Decoder's 282/362/442 came from",
"`crates/sylpheed-formats/examples/extras_button_order.rs`, which calls",
"`ui_layout::parse_build` -- THE SAME CRATE this port's export uses. The",
"Python RATC parsers in their tree exist and did not produce that number.",
"So the two legs are ONE READER USED TWICE, and the agreement carries no",
"information about the reader being right; it carries information only about",
"two callers of it agreeing, which they could not fail to do.",
"",
"\u26a0\ufe0f The VALUE is unaffected -- `ptbtn11` is decided by the DIFFICULTY",
"measurement and by the reset finding. What died is a word I used about the",
"evidence, which is the third such word in three iterations.",
"",
"is WEAKENED, by my own audit rather than by theirs.",
"",
"Applying their test to my own sentence: could my reading have come out",
"differently given theirs? Only if the implementations differ. Mine is",
"`sylpheed_formats::ui_layout::parse_build` via this port's export. Their tree",
"does carry separate Python RATC parsers (`kf_record_census.py` and others),",
"so a second implementation EXISTS -- but which reader produced their",
"282/362/442 is not established by me, and if they used the same crate the",
"two legs are one reader used twice.",
"",
"So: the values agreeing is still evidence, and calling it INDEPENDENT was a",
"claim about their tooling that I did not check. Recorded at the strength I",
"can support. \u26a0\ufe0f Nothing rests on it -- the row order is also decided by the",
"DIFFICULTY measurement -- which is exactly why it went unexamined.",
"",
"Decoder attempted to refute this value and it survives: `ptbtn11` is the TOP",
"button on this screen -- y 282 against 362 and 442 -- so the port is right",
"whichever reading of the reset target applies. Confirmed from THIS port's own",
"export, a different reader of the same disc: extras 282/362/442, and the main",
"menu as a control at 162/242/322/401/482.",
"",
"\ud83d\udd34 WHICH ALSO MEANS EXTRAS CANNOT SEPARATE the two readings -- named item and",
"top item coincide here. It was DIFFICULTY, opening on its second of four, that",
"settled it."
],
"on_cancel": {
"goto": "main_menu",
"goto_name": "TITLE_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"\ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"why": "MEASURED, HANDOFF Q5: (B) goes up one level and RESTORES FOCUS to the item you came from. EXTRAS advertises (B) in its own footer -- the red glyph is in ptmsg2.png and absent from the main menu's ptmsg.png."
},
"buttons": {
"ptbtn11": {
"label": "MISSION SELECT",
"goto": null,
"goto_name": null,
"blocked": "The stage list is GP_MISSION_SELECT, not in this export. Destination MEASURED."
},
"ptbtn12": {
"label": "MOVIE THEATER",
"goto": null,
"goto_name": null,
"blocked": "NEVER OPENED. docs/game/navigation.md marks this one unknown -- not merely unexported. Do not assume it opens GP_MOVIE_THEATER; that would be a name match dressed as a destination."
},
"ptbtn13": {
"label": "BACK",
"goto": "main_menu",
"goto_name": "TITLE_MENU",
"goto_name_kind": "name match, not measured",
"goto_name_why": [
"NOT MEASURED, and the label says so. HANDOFF Q4 states it exactly: \"the",
"screens are measured; the ids are a name match onto the executable's class",
"names.\" So `TITLE_MENU` is a string that exists in the executable and plausibly",
"denotes this screen -- nothing observed binds it to this transition.",
"",
"It is carried so a reader can search for it and so the port never has to",
"invent one. THE PORT NEVER BRANCHES ON IT: navigation uses `goto`, which is",
"a screen file, and this field is documentation.",
"",
"\ud83d\udd34 THIS `why` DID NOT EXIST UNTIL 2026-08-30. All seven `goto_name_kind`",
"labels rested on a sibling `why` that argues the DESTINATION -- a different",
"claim from where the NAME came from. `tools/port/audit-kinds` reports that",
"as BORROWED rather than ok, because a label resting on a neighbour's",
"argument reads as evidenced and is not."
],
"same_as_cancel": true,
"why": "MEASURED: EXTRAS' third item is BACK (live-extras.png). Treated as (B): it pops the stack, so focus is restored on the main menu exactly as (B) does. Whether the game distinguishes them is untested and there is no reason here to invent a difference."
}
}
}
}
}

View File

@@ -16,55 +16,151 @@
"",
"Delete an entry here the day the RE agent decodes a name field."
],
"export_archives": [
"dat/GP_TITLE.pak",
"dat/GP_OPTIONS.pak",
"dat/GP_SAVE_LOAD.pak"
],
"export_archives_why": [
"WHICH disc archives the export reads screen builds from.",
"",
"GP_TITLE was the only one for the whole project, hardcoded in the",
"exporter. That single constant is why four of the five main-menu",
"destinations are dead: authored/flow.json records LOAD GAME, TUTORIAL,",
"OPTIONS and NEW GAME's difficulty chain as MEASURED destinations,",
"blocked only because 'there is no screen file to go to'.",
"",
"GP_OPTIONS ADDED 2026-09-03, and deliberately alone. The probe",
"(crates/sylpheed-export/examples/probe_archives.rs) finds screen builds",
"in 24 archives with the EXISTING detector -- GP_OPTIONS 14,",
"GP_SAVE_LOAD 18, GP_DIALOG 105, GP_TUTORIAL 2. Adding all four at once",
"would land 139 new screens together and make any regression",
"unattributable, so this takes the smallest archive first.",
"",
"\u26a0\ufe0f is_build() PARSING IS NOT RENDERING. It says the record is a build,",
"not that its sprites resolve or that anyone has identified the screen.",
"Unnamed builds export as build_NN by entry index. Expect names to be",
"wrong-looking until someone drives the game to them; that is a naming",
"gap, not a decode failure.",
"",
"GP_SAVE_LOAD ADDED 2026-09-03, again alone. 18 builds. It is main_menu",
"ptbtn02 (LOAD GAME)'s destination, recorded in authored/flow.json as a",
"MEASURED destination blocked only by 'not a GP_TITLE build'. It may also",
"hold SELECT DATA, the second screen of the NEW GAME chain, but that is a",
"guess from the name until the screens are rendered and read.",
"",
"\u26a0\ufe0f OUT OF SCOPE ON PURPOSE: GP_HANGAR_ARSENAL (390 builds), the",
"GP_MAIN_GAME_* set and the rest of the gameplay archives. MISSION",
"section 7 scopes gameplay out, and a screen that parses is not a screen",
"this milestone wants."
],
"archives": {
"dat/GP_TITLE.pak": {
"2": {
"name": "press_start",
"why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture."
"why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"3": {
"name": "press_start_jp",
"why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need."
"why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"4": {
"name": "title",
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture."
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"5": {
"name": "main_menu",
"why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.)"
"why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.) \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"6": {
"name": "extras",
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture."
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"7": {
"name": "title_jp",
"why": "HANDOFF Q2: the Japanese twin of build 4."
"why": "HANDOFF Q2: the Japanese twin of build 4. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"8": {
"name": "main_menu_jp",
"why": "HANDOFF Q2: the Japanese twin of build 5."
"why": "HANDOFF Q2: the Japanese twin of build 5. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"9": {
"name": "extras_jp",
"why": "HANDOFF Q2: the Japanese twin of build 6."
"why": "HANDOFF Q2: the Japanese twin of build 6. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"10": {
"name": "publisher_logo",
"why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM)."
"why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM). \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"13": {
"name": "publisher_logo_r",
"why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs."
"why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"11": {
"name": "developer_logos",
"why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements."
"why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"14": {
"name": "developer_logos_r",
"why": "The region twin of entry 11, as 13 is to 10."
"why": "The region twin of entry 11, as 13 is to 10. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
}
},
"dat/GP_OPTIONS.pak": {
"3": {
"name": "sound_settings",
"why": "SOUND SETTINGS -- Music/Movie/Voice/SFX Volume. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
},
"4": {
"name": "control_settings",
"why": "CONTROL SETTINGS -- Control Type, Throttle, sensitivities, Vibration. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
},
"5": {
"name": "sound_settings_jp",
"why": "\u30b5\u30a6\u30f3\u30c9\u8a2d\u5b9a, the JP pair of entry 3. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
},
"6": {
"name": "screen_settings",
"why": "Gamma Correction with R/G/B and a NEXT PAGE affordance. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
},
"7": {
"name": "screen_settings_page2",
"why": "White Level / Black Level Adjust, PREVIOUS PAGE. Page 2 of entry 6. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
},
"8": {
"name": "control_settings_jp",
"why": "\u64cd\u4f5c\u8a2d\u5b9a, the JP pair of entry 4. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
},
"9": {
"name": "screen_settings_jp",
"why": "\u30ac\u30f3\u30de\u88dc\u6b63\u30ec\u30d9\u30eb, the JP pair of entry 6. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
},
"10": {
"name": "screen_settings_page2_jp",
"why": "\u767d\u30ec\u30d9\u30eb/\u9ed2\u30ec\u30d9\u30eb\u8abf\u6574, the JP pair of entry 7. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
},
"16": {
"name": "game_settings",
"why": "GAME SETTINGS -- Auto-Save, View Point, Radio Log, Subtitles. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
},
"18": {
"name": "game_settings_jp",
"why": "\u30b2\u30fc\u30e0\u8a2d\u5b9a, the JP pair of entry 16. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
},
"19": {
"name": "options",
"why": "\ud83d\udd34 THE OPTIONS ROOT. Rows: GAME SETTINGS, CONTROL SETTINGS, SOUND SETTINGS, SCREEN SETTINGS, BACK -- the four screens named here plus a back row. This is main_menu ptbtn04's destination. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
},
"20": {
"name": "control_customize",
"why": "CUSTOMIZE -- per-action key remapping, reached from CONTROL SETTINGS. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
},
"21": {
"name": "options_jp",
"why": "The JP OPTIONS root, pair of entry 19. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
},
"22": {
"name": "control_customize_jp",
"why": "\u30ad\u30fc\u30ab\u30b9\u30bf\u30de\u30a4\u30ba, the JP pair of entry 20. Named from the screen's OWN RENDERED TEXT. Each was exported, rendered by the port at rest and read: the title and row labels are legible English or Japanese. That is identification by CONTENT THE SCREEN STATES ABOUT ITSELF, not by position, size or ordinal -- the three this project has been burned by. Contact sheets were reviewed 2026-09-03; docs/port/options-screens.md."
}
}
},
@@ -75,19 +171,19 @@
"dat/GP_TITLE.pak": {
"10": {
"name": "publisher_logo",
"why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments."
"why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"11": {
"name": "developer_logos",
"why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn."
"why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"13": {
"name": "publisher_logo_r",
"why": "As entry 10, region twin."
"why": "As entry 10, region twin. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
},
"14": {
"name": "developer_logos_r",
"why": "As entry 11, region twin."
"why": "As entry 11, region twin. \ud83d\udccc SOURCE, added 2026-09-01 in the uncited-why backfill: the four bundles are identified in docs/re/ui-title-build-map.md, and the ordinal-versus-entry distinction this entry depends on is docs/re/structures/build-ordinal-vs-entry.md. \u26a0\ufe0f The sibling references below (\"as entry 10, region twin\") are a citation form too -- they point at another entry in this file rather than at a document, and forcing a path onto them would be mislabelling to satisfy a counter."
}
}
}

View File

@@ -195,36 +195,6 @@ enum ScreenCommands {
/// `--build`**, which is why it is a flag and not the default.
#[arg(long)]
all: bool,
/// Pose every element at this KEYFRAME TIME instead of at its resting
/// pose (60 units = 1 second). The resting pose is each element's last
/// *hold* keyframe, picked independently of every other element, so it is
/// not the screen at any one moment: it omits anything still moving (the
/// title's light sweeps hold off the right edge) and freezes a transient
/// at its PEAK (the title's five two-frame flashes burn forever).
/// ⚠️ This help used to end "Prefer `--settle`". That is WITHDRAWN and was
/// never measured: scored against a live capture of the JP title, settle
/// gives RMSE 40.210 and rest 41.690 — a margin of 1.48 against that
/// instrument's own noise floor of 1.2, which is NOT decisive. `--settle`
/// also has its own failure mode (25.5 % of elements are mid-ramp at their
/// screen's settle instant). Neither is established as better; pick by what
/// you are measuring. See `docs/re/structures/ui-resting-pose.md`.
#[arg(long, conflicts_with = "settle")]
at: Option<u32>,
/// Pose every element at the instant the screen is SETTLED, derived from
/// the disc: the midpoint of the longest interval containing no keyframe
/// of any element. Prints the window it used, whose width is how much the
/// midpoint is worth — a narrow one means the bundle never settles.
/// ⚠️ That is **38 % of the screen builds this command renders** (185 of
/// 491 carrying two or more keyframe times) and 39 % of the wider set
/// `--all` admits (862 of 2 211), mostly `loop*` fragments. This help used
/// to say "42 % of them" without saying of WHAT: 42 % was 731/1 758 over
/// composable bundles, computed before the keyframe record-layout fix,
/// which times a group's final pose and so admits ~450 bundles that
/// previously had only one timed keyframe. ⚠️ NOT established as better
/// than the resting pose — see the note on `--at`. See
/// `docs/re/structures/ui-settle-time.md`.
#[arg(long)]
settle: bool,
},
}
@@ -383,10 +353,8 @@ async fn main() -> Result<()> {
black,
all,
primitives,
at,
settle,
} => cmd_screen_render(
&pak, &output, build, focus, animated, black, all, primitives, at, settle,
&pak, &output, build, focus, animated, black, all, primitives,
),
},
Commands::Save { cmd } => match cmd {
@@ -613,40 +581,12 @@ fn cmd_screen_render(
black: bool,
all: bool,
primitives: bool,
at: Option<u32>,
settle: bool,
) -> Result<()> {
use sylpheed_formats::ui_layout::{self, ComposeOptions};
let builds = screen_builds(pak, all)?;
let idx = pick_build(&builds, want)?;
let bytes = &builds[idx].1;
let b = ui_layout::parse_build(bytes).context("build did not parse")?;
let at = if settle {
match (b.settle_window(), b.settle_time()) {
(Some((lo, hi)), Some(t)) => {
// Report the width, not just the answer. A 4-unit window and a
// 190-unit one give the same kind of number and mean entirely
// different things.
println!(
"settle window [{lo}, {hi}] = {} units ({:.2} s) -> posing at t={t}{}",
hi - lo,
(hi - lo) as f64 / 60.0,
if hi - lo < 30 {
" ⚠️ narrow — this bundle may never settle"
} else {
""
}
);
Some(t)
}
_ => {
println!("no settle window (fewer than two distinct keyframe times) — using rest()");
None
}
}
} else {
at
};
let screen = ui_layout::compose(
&b,
bytes,
@@ -659,7 +599,6 @@ fn cmd_screen_render(
ComposeOptions::default().backdrop
},
include_primitives: primitives,
at,
},
None,
);
@@ -682,35 +621,14 @@ fn cmd_screen_render(
if !screen.missing.is_empty() {
println!(" sprites that did not resolve/decode: {:?}", screen.missing);
}
// 🔴 Report the INDEX, the KIND and WHY, not just the name. A kind-`0x4`
// ghost instance carries its template's name, so a bare name list shows
// `ptlogo1.t32` twice and reads as "the logo is missing" when what is
// skipped is two motion-trail ghosts sitting at alpha 0 off-screen. That
// misreading cost this project a wrong finding sent to another agent.
let undrawn: Vec<String> = b
let undrawn: Vec<&str> = b
.elements
.iter()
.filter(|e| !screen.drawn.contains(&e.index))
.map(|e| {
let why = if e.name.ends_with(".prm") {
"untextured primitive, needs --primitives"
} else if e.name.ends_with(".rat") {
"animation, needs --animated"
} else if e.kind == 0x4 {
"kind 0x4 ghost instance"
} else if e.rest().map(|k| k.fade >> 24) == Some(0) {
"transparent at its pose"
} else {
"no reason established"
};
format!("[{}] {} ({why})", e.index, e.name)
})
.map(|e| e.name.as_str())
.collect();
if !undrawn.is_empty() {
println!(" not drawn ({}):", undrawn.len());
for u in &undrawn {
println!(" {u}");
}
println!(" not drawn ({}): {undrawn:?}", undrawn.len());
}
Ok(())
}
@@ -826,16 +744,8 @@ fn cmd_audio_info(file: &Path) -> Result<()> {
println!(" Channels : {}", opt(info.channels.map(|c| c.to_string())));
println!(" Sample rate: {}", opt(info.sample_rate.map(|r| format!("{r} Hz"))));
println!(" Bit depth : {}", opt(info.bits_per_sample.map(|b| format!("{b}-bit"))));
if let Some(b) = info.avg_bytes_per_sec {
println!(" Byte rate : {} B/s (declared)", b.to_string().yellow());
}
if let Some(d) = info.duration_secs {
let how = if info.codec == sylpheed_formats::AudioCodec::Xma {
" (from the declared byte rate, not decoded)"
} else {
""
};
println!(" Duration : {d:.2} s{how}");
println!(" Duration : {d:.2} s");
}
if let Some(p) = info.xma_packets {
println!(" XMA packets: {} (2048 B each)", p.to_string().yellow());

View File

@@ -0,0 +1,42 @@
//! Which disc archives contain UI screen builds?
//!
//! The exporter reads `dat/GP_TITLE.pak` and nothing else, so four of the five
//! main-menu destinations have no screen file to go to: `authored/flow.json`
//! records LOAD GAME as `GP_SAVE_LOAD`, OPTIONS as `GP_OPTIONS`, and NEW GAME's
//! chain as `DLG_SELECT_DIFFICULTY` -> `SELECT DATA`, all measured destinations
//! that this export cannot reach.
//!
//! This asks the cheap question before anyone refactors the exporter: does the
//! EXISTING build detector find anything in those archives? It changes nothing
//! and writes nothing.
//!
//! cargo run --release -p sylpheed-export --example probe_archives
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() -> anyhow::Result<()> {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut names: Vec<String> = std::fs::read_dir(format!("{disc}/dat"))?
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".pak"))
.collect();
names.sort();
println!("{:<32} {:>7} {:>8}", "archive", "entries", "builds");
for n in names {
let path = format!("{disc}/dat/{n}");
let Ok(ar) = PakArchive::open(&path) else {
println!("{n:<32} {:>7} {:>8}", "-", "open failed");
continue;
};
let total = ar.entries().len();
let builds = ar
.entries()
.iter()
.filter(|e| ar.read(e).map(|b| ui_layout::is_build(&b)).unwrap_or(false))
.count();
if builds > 0 || n.contains("OPTIONS") || n.contains("SAVE") || n.contains("DIALOG") {
println!("{n:<32} {total:>7} {builds:>8}");
}
}
Ok(())
}

View File

@@ -12,6 +12,7 @@
//!
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
mod audio;
mod check;
mod video;
mod screen;
@@ -20,7 +21,7 @@ use anyhow::{Context, Result};
use clap::Parser;
use serde::Serialize;
use std::path::{Path, PathBuf};
use sylpheed_formats::{pak::PakArchive, ui_layout};
use sylpheed_formats::{media, pak::PakArchive, ui_layout};
/// The revision of `sylpheed-formats` this exporter is pinned to, recorded in
/// every file it writes. Keep in step with `Cargo.toml` — it is what makes an
@@ -76,6 +77,48 @@ struct ManifestVideo {
/// dislikes the quality re-runs one line rather than reverse-engineering it.
command: String,
why: &'static str,
/// What the runtime should have played, so it can report what it did.
/// See `video::Transcoded::duration_s` — the port measured its player
/// presenting 2847 % of a stream's frames, and seconds alone hide that.
duration_s: f64,
fps: f64,
}
/// One exported audio file. Carries the same provenance a video does, plus the
/// measured peak and duration: silence and clipping are the two audio failures
/// that pass every check that is not looking for them.
#[derive(Serialize)]
struct ManifestAudio {
/// `se` or `bgm`. The runtime dispatches on it, so it is a field rather
/// than a prefix on `name` that a consumer would have to parse.
kind: &'static str,
name: String,
file: String,
command: String,
why: String,
#[serde(skip_serializing_if = "Option::is_none")]
peak_dbfs: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
duration_s: Option<f32>,
/// 🔴 One line saying what this asset is KNOWN to be missing, for the
/// runtime to announce. Absent means nothing is known to be missing --
/// never that the asset was checked and is complete.
///
/// It exists because the export could already say this and the RUNTIME
/// could not. `why` carries the full account, but it is a paragraph aimed
/// at a reader of the manifest; a player hears clean dialogue and has no
/// way to learn that a stream is absent from it. This port already
/// announces the two measured screens NEW GAME jumps over, on the principle
/// that a gap is announced before it is opened. Audio had no equivalent.
#[serde(skip_serializing_if = "Option::is_none")]
incomplete: Option<String>,
/// The game's own cue identifier where one is a NAME MATCH. Absent means
/// nobody has claimed one -- never that the binding is unknown.
#[serde(skip_serializing_if = "Option::is_none")]
name_match: Option<String>,
/// What the runtime does at the end of the file, where that was authored.
#[serde(skip_serializing_if = "Option::is_none")]
loop_mode: Option<String>,
}
#[derive(Serialize)]
@@ -88,6 +131,8 @@ struct Manifest {
screens: Vec<ManifestScreen>,
#[serde(skip_serializing_if = "Vec::is_empty")]
videos: Vec<ManifestVideo>,
#[serde(skip_serializing_if = "Vec::is_empty")]
audio: Vec<ManifestAudio>,
warnings: Vec<String>,
}
@@ -163,6 +208,54 @@ fn load_also_export(authored: &Path) -> Result<AlsoExport> {
/// exactly four bundles and all four are real screens, with zero fragments. In
/// another archive it would not be, which is why this is an allow-list and not
/// a widened predicate.
/// Which archives the export reads, from `authored/screen_names.json`
/// `export_archives`.
///
/// 🔴 THIS WAS ONE HARDCODED CONSTANT AND IT COST FOUR MENU DESTINATIONS.
/// `authored/flow.json` records LOAD GAME, TUTORIAL, OPTIONS and NEW GAME's
/// difficulty chain as MEASURED destinations that are `blocked` because "not a
/// GP_TITLE build, so there is no screen file to go to". The blocker was never
/// the disc or the reader -- `examples/probe_archives.rs` finds screen builds in
/// 24 archives using the EXISTING detector. It was this line.
///
/// Absent from the authored file, it stays exactly what it was, so an old
/// `authored/` tree exports what it always did.
fn load_export_archives(authored: &Path) -> Result<Vec<String>> {
let path = authored.join("screen_names.json");
let Ok(text) = std::fs::read_to_string(&path) else {
return Ok(vec!["dat/GP_TITLE.pak".into()]);
};
let v: serde_json::Value = serde_json::from_str(&text)
.with_context(|| format!("parse {}", path.display()))?;
match v.get("export_archives").and_then(|a| a.as_array()) {
None => Ok(vec!["dat/GP_TITLE.pak".into()]),
Some(list) => Ok(list
.iter()
.filter_map(|e| e.as_str().map(str::to_owned))
.collect()),
}
}
/// The sprite subdirectory for an archive: `dat/GP_OPTIONS.pak` -> `options`.
///
/// ⚠️ NOT cosmetic. Sprites are written to `sprites/<group>/<screen>/`, so two
/// archives sharing a group would collide by screen name -- and unnamed builds
/// are named `build_NN` by ENTRY INDEX, which restarts at 0 in every archive.
/// `GP_TITLE` keeps its historical `title` so no existing path moves.
fn group_for(archive: &str) -> &'static str {
match archive {
"dat/GP_TITLE.pak" => "title",
"dat/GP_OPTIONS.pak" => "options",
"dat/GP_SAVE_LOAD.pak" => "save_load",
"dat/GP_TUTORIAL.pak" => "tutorial",
"dat/GP_DIALOG.pak" => "dialog",
// Deliberately not derived from the filename: a new archive should be a
// decision someone made, not a directory that appears because a string
// parsed. An unmapped archive is rejected below.
_ => "",
}
}
fn screen_builds(ar: &PakArchive, also: Option<&std::collections::BTreeMap<String, NameEntry>>)
-> Vec<(usize, Vec<u8>)>
{
@@ -195,86 +288,275 @@ fn main() -> Result<()> {
fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
let names = load_names(authored_dir)?;
// Built up as the export runs. A warning is a thing a CONSUMER of the tree
// has to know about; it is not an error, and it is not a log line, because
// the person who needs it reads `manifest.json` and never sees stdout.
let mut warnings: Vec<String> = vec![
String::new(), // replaced below once the archive list is known
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
layout child, so `is_build` cannot see them and no content rule can: element \
count and design size both overlap with two-element fragments in other archives. \
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
which is a locator and not a claim -- see each one's name_why."
.into(),
];
// Derived output is regenerated wholesale: clear it, so a screen that stops
// being exported stops existing rather than lingering as a stale file that
// still validates.
//
// 🔴 EXCEPT `video/`, and leaving it out was a bug that hid in plain sight.
// `video::transcode` has always carried a cache -- it writes a `.cmd`
// sidecar with the exact command, the source size and the channel count, and
// skips the encode when all three still match. Its own doc comment says
// "without it every re-export pays ~4 minutes to produce a byte-identical
// file". **This wipe deleted the sidecar and the output immediately before
// the check, so the cache had never hit once.** Six exports in one session
// paid ~48 minutes of Theora to produce five byte-identical files, and
// nothing reported it: the cache is silent when it works and silent when it
// does not.
//
// The wholesale guarantee is kept rather than weakened -- everything else is
// still cleared outright, and `prune_videos` below deletes any file in
// `video/` that this run did not claim, so a movie that stops being exported
// still stops existing.
if out.exists() {
std::fs::remove_dir_all(&out).context("clear the output tree")?;
for entry in std::fs::read_dir(&out).context("clear the output tree")? {
let entry = entry?;
if entry.file_name() == "video" {
continue;
}
if entry.file_type()?.is_dir() {
std::fs::remove_dir_all(entry.path())
} else {
std::fs::remove_file(entry.path())
}
.with_context(|| format!("clear {}", entry.path().display()))?;
}
}
std::fs::create_dir_all(&out)?;
let archive = "dat/GP_TITLE.pak";
let pak = disc.join(archive);
let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?;
let also = load_also_export(authored_dir)?;
let archive_also = also.get(archive);
let builds = screen_builds(&ar, archive_also);
println!("{archive}: {} screen build(s)", builds.len());
let archive_names = names.get(archive);
let archives = load_export_archives(authored_dir)?;
warnings[0] = format!(
"Screen builds from {} only ({}). Other archives on the disc also contain UI \
builds and are not exported. Only the two movies MISSION section 6 puts in scope.",
archives.len(),
archives.join(", ")
);
let mut screens = Vec::new();
for (build_idx, (entry, bytes)) in builds.iter().enumerate() {
// Keyed by ENTRY, not by the ordinal: widening the enumeration to reach
// the splash renumbers ordinals, and a name that moves when the rule
// changes is not a name.
let key = entry.to_string();
let named = archive_names
.and_then(|m| m.get(&key))
.or_else(|| archive_also.and_then(|m| m.get(&key)));
let (name, name_source, why) = match named {
Some(e) => (e.name.clone(), "authored", e.why.clone()),
// Nobody has identified this build. Emit a stable synthetic id and
// say in the file that the name is not a recovered one.
None => (format!("build_{entry:02}"), "index", None),
};
let ex = screen::export_build(
&out,
archive,
*entry,
build_idx,
bytes,
&name,
name_source,
why,
"title",
EXPORTER,
FORMATS_REV,
)
.with_context(|| format!("export build {build_idx} of {archive}"))?;
println!(
" [{build_idx}] entry {entry:<3} -> {} ({} sprites{})",
ex.json_path,
ex.sprites,
if ex.missing.is_empty() {
String::new()
} else {
format!(", {} missing", ex.missing.len())
}
);
screens.push(ManifestScreen {
name: ex.name,
file: ex.json_path,
sprites: ex.sprites,
missing_sprites: ex.missing,
});
for archive in archives.iter().map(String::as_str) {
let group = group_for(archive);
if group.is_empty() {
anyhow::bail!(
"authored/screen_names.json export_archives lists {archive}, which has no \
sprite group in group_for(). Add one deliberately -- deriving it from the \
filename would let a typo create a directory."
);
}
let pak = disc.join(archive);
let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?;
let archive_also = also.get(archive);
let builds = screen_builds(&ar, archive_also);
println!("{archive}: {} screen build(s) -> sprites/{group}/", builds.len());
let archive_names = names.get(archive);
for (build_idx, (entry, bytes)) in builds.iter().enumerate() {
// Keyed by ENTRY, not by the ordinal: widening the enumeration to reach
// the splash renumbers ordinals, and a name that moves when the rule
// changes is not a name.
let key = entry.to_string();
let named = archive_names
.and_then(|m| m.get(&key))
.or_else(|| archive_also.and_then(|m| m.get(&key)));
let (name, name_source, why) = match named {
Some(e) => (e.name.clone(), "authored", e.why.clone()),
// Nobody has identified this build. Emit a stable synthetic id and
// say in the file that the name is not a recovered one.
None => (format!("build_{entry:02}"), "index", None),
};
let ex = screen::export_build(
&out,
archive,
*entry,
build_idx,
bytes,
&name,
name_source,
why,
group,
EXPORTER,
FORMATS_REV,
)
.with_context(|| format!("export build {build_idx} of {archive}"))?;
println!(
" [{build_idx}] entry {entry:<3} -> {} ({} sprites{})",
ex.json_path,
ex.sprites,
if ex.missing.is_empty() {
String::new()
} else {
format!(", {} missing", ex.missing.len())
}
);
screens.push(ManifestScreen {
name: ex.name,
file: ex.json_path,
sprites: ex.sprites,
missing_sprites: ex.missing,
});
}
}
// MISSION §6: the boot intro and the one new-game intro only.
let mut videos = Vec::new();
let mut movie_lengths: Vec<(&'static str, Option<f32>)> = Vec::new();
// 🔴 The export deviates from a HUMAN decision, and until this warning
// existed nobody could tell. MISSION §6 pins the 5.1 fold; `video.rs` ships
// that matrix scaled by 0.4142, i.e. 7.65 dB quieter. The deviation is
// justified for one of the two movies and over-broad for the other, and
// which of the three options to take is not the exporter's call -- so it is
// reported on every run rather than left in a doc comment nobody opens.
if video::MOVIES.iter().any(|m| disc.join(m.src).exists()) {
warnings.push(
"video/*.ogv: the 5.1->stereo fold is NOT the matrix MISSION §6 pins. §6 fixes it at FL = 1.0*FL + 0.707*FC + 0.707*BL (a human decision, 2026-08-29); this export ships that matrix scaled by 0.4142 -- same weighting, 7.65 dB quieter. Measured over the whole of both movies, float-decoded so nothing is pre-clamped: under the PINNED matrix ADV peaks at +4.26 dBFS with 4406 samples at or over full scale (1874 more than 1 dB over, longest clamped run 0.333 ms), while S00A peaks at -1.34 dBFS and never clips. So the pin overloads ADV and this constant is over-broad for S00A; the smallest single scalar under which neither clamps is 1/1.6339 = 0.612. NOT changed on the exporter's own authority -- the level of a mix is what §6 reserves to a human. See docs/port/DECISIONS.md."
.to_string(),
);
}
for m in video::MOVIES {
match video::transcode(disc, out, m)? {
Some(t) => {
println!(" video {} -> {}", m.src, t.file);
movie_lengths.push((m.stem, audio::probe_duration(&out.join(&t.file))));
videos.push(ManifestVideo {
name: t.name,
file: t.file,
command: t.command,
why: t.why,
duration_s: t.duration_s,
fps: t.fps,
});
}
None => println!(" video {} not on this disc -- skipped", m.src),
}
}
prune_videos(out, &videos)?;
// P6. Both tables are AUTHORED, for two different reasons -- the cue offsets
// because they were measured off the running game and are on the disc in no
// findable form, the BGM choice because HANDOFF Q10 is a negative and
// nothing states which track a menu plays. See `authored/audio.json`.
let mut audio = Vec::new();
let audio_cfg = audio::load(authored_dir)?;
match &audio_cfg {
None => println!(" no authored/audio.json -- no audio exported"),
Some(cfg) => {
let source = media::DirectorySource::new(disc);
for a in audio::export_cues(&source, out, &cfg.se)? {
println!(
" se {:<8} -> {} ({})",
a.name,
a.file,
describe(&a)
);
audio.push(ManifestAudio::from(a));
}
for (role, spec) in &cfg.bgm {
match audio::export_bgm(&source, out, role, spec)? {
Some(a) => {
println!(
" bgm {:<8} -> {} ({}, bank {}, {} sub-wave(s))",
a.name,
a.file,
describe(&a),
spec.bank,
a.sub_waves
);
// HANDOFF Q10's census is "exactly two waves of
// identical duration, 32/32 banks on the disc". When
// `media` hands back a different number, SAY SO -- the
// port does not get to decide that one of them is not a
// stem, and silently summing an extra region into the
// music is precisely the media-assembly mistake MISSION
// section 2 names. The decoder's answer is what ships;
// the disagreement is what gets reported.
if a.sub_waves != 2 {
warnings.push(format!(
"audio/bgm/{role}.ogg: sylpheed_formats::media::sound_bank_riffs \
returned {} sub-wave(s) for `{}`, but HANDOFF Q10's bank census \
says a music bank is EXACTLY TWO waves of identical duration \
(32/32 banks). All {} are summed, because choosing which to drop \
is a decoding question and this exporter does not answer those. \
See docs/port/BLOCKED.md.",
a.sub_waves, spec.bank, a.sub_waves
));
}
audio.push(ManifestAudio::from(a));
}
// Not an error: the authored bank may simply not be on this
// disc, and the export of everything else is still good.
None => warnings.push(format!(
"authored/audio.json bgm.{role} names bank `{}`, which is not in \
this disc's sound.pak -- no BGM exported for that role.",
spec.bank
)),
}
}
}
}
// The cutscene voices are DERIVED, not authored, so this runs outside the
// `authored/audio.json` block above: the binding comes off the disc (the
// movie manifest in `tables.pak`), and an export with no authored audio
// should still carry the dialogue for the movies it ships.
//
// A movie that resolves to no region is genuinely unvoiced and gets a
// warning rather than a substitute -- for both movies in scope this port
// expects a region, so a warning here is a real signal and not noise.
{
let source = media::DirectorySource::new(disc);
for (stem, len) in &movie_lengths {
// The presentation choice is AUTHORED and this block runs even when
// there is no `authored/audio.json` -- the voice binding is decoded,
// so the dialogue exports either way and only the choice defaults.
let want = audio_cfg.as_ref().map(|c| c.voice).unwrap_or_default();
let weights = audio_cfg.as_ref().map(|c| c.stream_weights.clone()).unwrap_or_default();
match audio::export_voice(&source, out, stem, *len, want, &weights)? {
Some(a) => {
// 🔴 A TOP-LEVEL WARNING, not just a `why` on the entry. The
// export is known to be missing audio the game plays, and
// the failure sounds like success: one stream decodes to
// clean dialogue, so nobody listening finds out.
if a.kept_waves < a.content_waves {
warnings.push(format!(
"{}: KNOWN INCOMPLETE. This region holds {} streams and the RUNNING \
GAME DECODES ALL OF THEM CONCURRENTLY (Canary --xma_param_probe: \
three XMA contexts, byte sizes matching the disc payloads exactly). \
The export carries ONE. Nothing in the audio reveals this -- a \
single stream is clean audible dialogue. Held rather than summed \
because an equal-gain sum of channel pairs is not a downmix and \
would be a second guess, not a fix. See authored/audio.json voice \
and docs/port/BLOCKED.md.",
a.file, a.sub_waves
));
}
println!(
" voice {:<8} -> {} ({}, {} of {} stream(s){})",
a.name,
a.file,
describe(&a),
a.kept_waves,
a.sub_waves,
if a.kept_waves < a.content_waves { " -- KNOWN INCOMPLETE, see warnings" } else { "" }
);
audio.push(ManifestAudio::from(a));
}
None => warnings.push(format!(
"movie `{stem}`: the movie manifest binds it to no voice region, so no dialogue was exported. That is a real answer for an unvoiced cutscene -- nothing is substituted, because resolving an unbound movie through a shared demo line was measured to play the WRONG recording."
)),
}
}
}
let manifest = Manifest {
format: "sylpheed.manifest/1",
@@ -283,16 +565,8 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
disc: disc.display().to_string(),
screens,
videos,
warnings: vec![
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
.into(),
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
layout child, so `is_build` cannot see them and no content rule can: element \
count and design size both overlap with two-element fragments in other archives. \
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
which is a locator and not a claim -- see each one's name_why."
.into(),
],
audio,
warnings,
};
std::fs::write(
out.join("manifest.json"),
@@ -301,3 +575,81 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
println!("wrote {}/manifest.json", out.display());
Ok(())
}
impl From<audio::Exported> for ManifestAudio {
fn from(a: audio::Exported) -> Self {
ManifestAudio {
kind: a.kind,
name: a.name,
file: a.file,
command: a.command,
why: a.why,
peak_dbfs: a.peak_dbfs,
duration_s: a.duration_s,
incomplete: (a.kept_waves < a.content_waves).then(|| {
format!(
"{} of {} streams. The running game decodes all {} concurrently. \
Nothing in the audio reveals the gap -- what plays is clean dialogue. \
WHICH streams are dropped and why differs per asset; the manifest \
entry's `why` says, and it is not the same story twice.",
a.kept_waves, a.sub_waves, a.sub_waves
)
}),
name_match: a.name_match,
loop_mode: a.loop_mode,
}
}
}
/// The two numbers worth reading on an audio line, in the console.
///
/// Printed rather than left to the manifest because the failure this catches is
/// a SILENT file: the right duration, the right channel count, the right size,
/// and nothing in it. `-inf dB` on stdout is the one form of that failure a
/// person notices without being told to look.
fn describe(a: &audio::Exported) -> String {
let peak = match a.peak_dbfs {
Some(p) => format!("peak {p:.1} dBFS"),
None => "peak unmeasured".into(),
};
match a.duration_s {
Some(d) => format!("{d:.3} s, {peak}"),
None => peak,
}
}
/// Delete anything in `video/` this run did not produce.
///
/// `video/` is the one directory the wholesale wipe spares, so that the
/// transcode cache survives to be consulted. This restores the guarantee the
/// wipe exists for: a movie that stops being exported stops existing, rather
/// than lingering as a file the manifest no longer lists.
fn prune_videos(out: &Path, kept: &[ManifestVideo]) -> Result<()> {
let dir = out.join("video");
if !dir.exists() {
return Ok(());
}
let mut keep: Vec<String> = Vec::new();
for v in kept {
if let Some(name) = Path::new(&v.file).file_name() {
let name = name.to_string_lossy().into_owned();
keep.push(name.clone());
// The cache sidecar goes with the file it stamps.
if let Some(stem) = Path::new(&name).file_stem() {
keep.push(format!("{}.cmd", stem.to_string_lossy()));
}
}
}
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if keep.contains(&name) {
continue;
}
println!(" video {name} is no longer exported -- removed");
let _ = std::fs::remove_file(entry.path());
}
Ok(())
}

View File

@@ -17,13 +17,27 @@ use sylpheed_formats::{t8ad, ui_layout};
///
/// ⚠️ `0x3002` is one member of a `0x3000` family and is **not** a general
/// button test — `GP_READY_ROOM` uses `0x3000`/`0x3004`/`0x300c`/`0x3008` and
/// has zero `0x3002`. Every screen in this milestone is `GP_TITLE`, where the
/// mapping is decoded; anything else exports as `unknown` with its raw kind.
/// has zero `0x3002`. The mapping is decoded for the kinds listed; anything else
/// exports as `unknown` with its raw kind visible.
fn role_of(kind: u32, has_sprite: bool) -> &'static str {
// 🔴 BIT 0 IS THE PARENT FLAG AND CARRIES NO ROLE INFORMATION. Decoded
// disc-wide: `kind & 1` agrees with "has a parent" on 15 493 elements with
// zero disagreements (`docs/re/ui-kind-bit0-is-has-parent.md`). So a role
// table keyed on the raw kind splits every class in two and calls the
// parented half `unknown` -- which is how the OPTIONS menu's rows came out
// roleless while the exporter had already accepted them as buttons.
//
// ⚠️ APPLIED TO EVERY PAIR, NOT JUST THE ONE THAT FAILED. Fixing only
// `0x3003` would have left `0x1` as `unknown` while `0x0` is `decoration`,
// i.e. the same inconsistency one kind along -- and half-applying this
// decode is exactly what produced the failure this is fixing.
//
// ⚠️ `0x73002`/`0x73003` are NOT folded in. Their `0x70000` bits are
// undecoded, so they stay `unknown` with their raw kind visible.
match kind {
0x3002 => "button",
0x10 if !has_sprite => "primitive",
0x0 => "decoration",
0x3002 | 0x3003 => "button",
0x10 | 0x11 if !has_sprite => "primitive",
0x0 | 0x1 => "decoration",
_ => "unknown",
}
}
@@ -103,6 +117,14 @@ pub struct FocusElement {
pub id: String,
pub declared: String,
pub sprite: Option<String>,
/// `true` when the game draws this sprite ADDITIVE — `T8aD +0x04` bit
/// `0x02`, decoded. Absent when the sprite resolves to no `T8aD` header.
///
/// A leaf's sprite may live in the leaf's own table or in the parent
/// bundle's, so the bit is looked up in the same two places, in the same
/// order, that the PNG is written from.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend_additive: Option<bool>,
pub pivot: [u32; 2],
pub rest: Rest,
pub keyframes: Vec<Keyframe>,
@@ -112,6 +134,34 @@ pub struct FocusElement {
pub struct Focus {
/// The `.rat` leaf this came from, e.g. `ptbtn01f.rat`.
pub record: String,
/// The record header's `+0x08`: **where the cycle restarts**, in keyframe
/// units — which is not the same thing as the last keyframe's time.
///
/// `ptbtn00f`, the `PRESS Ⓐ` plate's glow, ramps 0→80→0 over **105** units
/// inside a **120**-unit cycle and rests dark for the remaining 15. Deriving
/// the period from the largest keyframe time — what the port did until now —
/// runs it 14 % fast and deletes the dark rest entirely.
///
/// Decoded by the Decoder (`07e93ce`, `docs/re/structures/ui-record-loop-length.md`,
/// delivered in HANDOFF `27938aa`) and **re-run here before adoption**, with
/// their falsifier and their non-triviality control (⚠️ the 92.3 % below is
/// "of records where the question is meaningful" -- 1 643 of the 1 781 with a
/// timed keyframe. 3 311 nested records exist; the other 1 530 have no
/// keyframe time at all, so `+0x08 == max t` is not a question there. Quoted
/// bare until 2026-09-01, which is a population-scoped statistic reported
/// without its population):
/// `cargo run -p sylpheed-export --example record_loop_control`. Disc-wide
/// 1 781 timed records, 92.3 % exact, 7.7 % hold, **0 declaring less than
/// their own last pose**; on the eight records this port animates, seven
/// exact and `ptbtn00f` the one hold.
///
/// ✅ **The port no longer owns this reading.** For one iteration `screen.rs`
/// held its own guard and byte read, because the field was decoded in an
/// example and a test and exposed in no public API on any ref. It is now
/// `ui_layout::loop_length_units`, taken at `formats-pin-2026-08-30b`, and
/// the local copy is deleted — the doc comment that promised that deletion
/// is the only reason it did not quietly become permanent.
pub loop_length_units: Option<u32>,
/// Back-to-front, in the leaf's own declaration order.
pub elements: Vec<FocusElement>,
}
@@ -141,6 +191,59 @@ pub struct Element {
/// convention and a consumer may still want the bare highlight texture.
#[serde(skip_serializing_if = "Option::is_none")]
pub focus: Option<Focus>,
/// This element's own `.rat` leaf, when its declared name is itself a
/// record in the bundle.
///
/// 🔴 **DECODED DATA THE EXPORTER USED TO DROP.** `ptloop01`/`ptloop02` on
/// the title declare scale 100 % and rotation 0 at the parent, and their
/// leaves declare **(100, 600) at +30°** and **(100, 800) at 45°** — and
/// the leaves *move*, x from 639 → 1521 and 1721 → 839. `ui_layout`'s own
/// note says so: *"the rotated quads come from its two nested `.rat` leaf
/// records, which the census never opened."* Neither did this exporter: it
/// opened a leaf only for a FOCUS record, via `highlight_name`.
///
/// That omission is measurable. It is the whole of the title's 1.82 %
/// disagreement with the oracle — the port draws two 400 px sprites upright
/// and static at (441, 270) where the game sweeps two ~1080 and ~1440 px
/// quads across the frame at opposite leans.
///
/// ⚠️ **Emitted, not yet drawn.** Parent and leaf each carry their own alpha
/// ramp on a different span — parent 0→255 over t=70…238, leaf
/// 255→0x80→255 over t=150…600 — so how the two compose is a *decoding*
/// question and not the port's to answer. The data is exported so it stops
/// being invisible; `ScreenView` ignores it until the composition rule is
/// known.
#[serde(skip_serializing_if = "Option::is_none")]
pub leaf: Option<Focus>,
/// True when the leaf's geometry DIFFERS from the parent's, so the leaf is
/// what the game draws.
///
/// Decided here rather than in the runtime because it is disc knowledge.
/// The Decoder's rule: *"the discriminator is which record carries the
/// geometry, not a fixed order"* — and the census over this export splits
/// cleanly, with no ambiguous middle:
///
/// * **30 of 46** leaf elements duplicate the parent's scale and rotation
/// exactly. That is the BASE-record case `screen.rs` already handled: the
/// leaf may differ by a unit of position (`ptbtn04`: parent y=401, leaf
/// y=402) and the parent wins. Flag is false; nothing changes.
/// * **16 differ**, and all of them differ in scale or rotation, not by a
/// rounding unit: the ten `ptloop01`/`ptloop02` sweeps ((100,600) at +30°
/// and (100,800) at 45° against an identity parent), two
/// `pgloading_ring` (leaf scale **(0,0)**), and `title_jp`'s
/// `ptlogo_eff2` (**parent 125 %, leaf 100 %**).
///
/// ⚠️ **Only the `ptloop` case is decoded.** The Decoder fitted the game's
/// own composed alpha — vertex colours `C3FFFFFF`/`B6FFFFFF`, i.e. 195 and
/// 182 — against the two leaf ramps and got one consistent time, t=355, then
/// *predicted* the quad centres at 981 and 478 against 992.0 and 467.2
/// measured. The other two are the same shape and are **not** separately
/// confirmed; they are flagged so the harness can adjudicate them rather
/// than being asserted.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub leaf_carries_geometry: bool,
/// The raw `opt ` link inside this element's `.rat` record.
///
/// ⚠️ **This is not a focus link.** It was read as one, and that was
@@ -161,6 +264,23 @@ pub struct Element {
/// Paint-order key. `"sprite"` = read from the `T8aD` header at `+0x0A`.
/// `"implied"` = **measured off the running game**, for elements that carry
/// no header. `"none"` = neither; sorts last.
/// `true` when the game draws this element ADDITIVE — `T8aD +0x04` bit
/// `0x02`.
///
/// 🔴 **DECODED, and it replaces an authored map.** The port carried an
/// `additive_elements` table in `authored/rendering.json`, keyed by SCREEN
/// NAME and transcribed from the Decoder's per-draw `RB_BLENDCONTROL0` log.
/// A name-keyed map cannot answer for a screen nobody drove the game to,
/// which is why the port was drawing the English menus additive and the
/// Japanese ones alpha-over — asserting by omission that the JP build
/// blends differently. The bit is on the disc for every screen at once.
///
/// ⚠️ `kind_raw` is NOT this field. `kind` is `+40` of the RATC declaration
/// entry; this is `+0x04` of the sprite's own `T8aD` header. Tested over
/// four screens: `kind & 0x2` is *anti*-correlated with the measured map —
/// 0 of 14 additive elements set it and 9 non-additive ones do.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend_additive: Option<bool>,
pub layer_source: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<String>,
@@ -201,6 +321,35 @@ pub struct Screen {
/// **Geometric, not a decoded neighbour graph** — right for a vertical menu
/// and not to be trusted for anything else.
pub buttons: Vec<String>,
/// The instant every element of this screen is settled at, and the width of
/// the interval it was taken from — `[start, end, midpoint]` in keyframe
/// units, absent when the screen has fewer than two keyframe times.
///
/// 🔴 **A SETTLED SCREEN IS ONE INSTANT, AND THE DISC SAYS WHICH.** Posing
/// each element at its own `rest()` is right for anything that ends the
/// screen settled and **exactly wrong for a transient**: the title's
/// `ptlogo_back2eff1` is a two-frame flash — 0 until t52, 255 at t5456, 0
/// again by t58 — so its last *hold* is the flash peak and `rest()` leaves
/// it burning forever. There are five of these, and `rest()` draws all five
/// at once, saturating the light arc.
///
/// The window is the **longest interval containing no keyframe time**, over
/// this bundle's TOP-LEVEL elements only. Nested leaves are excluded, and
/// that exclusion is what reproduces the Decoder's independently computed
/// `[160, 236]` for the title: including the `ptloop` leaves gives
/// `[269, 540]` instead.
///
/// ⚠️ **Emitted for every screen; USABLE only where it is wide.** Across this
/// export the widths split with nothing in between — `press_start` 214,
/// `publisher_logo` 190, `developer_logos` 145, `title` 76, then
/// `main_menu` 12, `extras` 12, the loading screens 8 and 4. A 12-unit
/// "settle" on a menu that builds in until t=70 is not a settled pose, it is
/// a gap between staggered ramps. The Decoder's disc-wide census agrees on
/// the shape: only 30 % of bundles have a window ≥ 30 units and 42 % have
/// one under 10, the latter mostly `loop*` fragments meant to be in motion.
#[serde(skip_serializing_if = "Option::is_none")]
pub settle_window: Option<[i64; 3]>,
/// What this file does not answer. A consumer needing one of these must get
/// it from `authored/`.
pub unresolved: Vec<&'static str>,
@@ -316,6 +465,76 @@ pub fn export_build(
// Contrast with a BASE record, where the leaf duplicates the parent's
// placement and the two can differ by a unit (ptbtn04: parent y=401,
// leaf y=402). There the parent wins. Here there is no parent.
// Reads one record in the bundle as a nested build and returns its
// elements. Used twice: for a FOCUS record (`ptbtn0Nf.rat`) and for an
// element whose OWN declared name is a record (`ptloop01.rat`). One
// implementation, because the second case was missing for eight
// milestones and a second copy is how it would go missing again.
let read_leaf = |rec: &str,
written: &mut std::collections::BTreeMap<String, ()>,
missing: &mut Vec<String>|
-> Result<Option<Focus>> {
let Some(&(off, size)) = b.records.get(rec) else { return Ok(None) };
let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) else {
return Ok(None);
};
let mut fes = Vec::new();
for fe in &leaf.elements {
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
let mut fsprite = None;
if write_from(&sprite_dir, written, sp, &bundle[off..off + size], &leaf.sprites)?
|| write_from(&sprite_dir, written, sp, bundle, &b.sprites)?
{
fsprite = Some(sprite_rel(sp));
} else if sp.ends_with(".t32") {
missing.push(sp.to_string());
}
let Some(r) = fe.rest() else { continue };
fes.push(FocusElement {
id: id_of(&fe.name),
declared: fe.name.clone(),
sprite: fsprite,
blend_additive: ui_layout::blend_additive_by_name(
&leaf, &bundle[off..off + size], sp)
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
pivot: [fe.pivot_x, fe.pivot_y],
rest: Rest {
pos: [r.x, r.y],
scale: [r.scale_x, r.scale_y],
tint_rgba: hex32(r.tint),
fade_argb: hex32(r.fade),
rotation_deg: r.rotation_deg,
t: r.time,
},
keyframes: fe
.keyframes
.iter()
.map(|k| Keyframe {
t: k.time,
pos: [k.x, k.y],
scale: [k.scale_x, k.scale_y],
tint_rgba: hex32(k.tint),
fade_argb: hex32(k.fade),
rotation_deg: k.rotation_deg,
})
.collect(),
});
}
Ok(if fes.is_empty() {
None
} else {
Some(Focus {
record: rec.to_string(),
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
elements: fes,
})
})
};
// An element whose own declared name is a record in this bundle carries
// its geometry THERE, not in its parent entry. See `Element::leaf`.
let leaf = read_leaf(&el.name, &mut written, &mut missing)?;
let mut focus = None;
if let Some(rec) = highlight_name(&el.name) {
if let Some(&(off, size)) = b.records.get(&rec) {
@@ -341,6 +560,9 @@ pub fn export_build(
id: id_of(&fe.name),
declared: fe.name.clone(),
sprite: fsprite,
blend_additive: ui_layout::blend_additive_by_name(
&leaf, &bundle[off..off + size], sp)
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
pivot: [fe.pivot_x, fe.pivot_y],
rest: Rest {
pos: [r.x, r.y],
@@ -365,7 +587,11 @@ pub fn export_build(
});
}
if !fes.is_empty() {
focus = Some(Focus { record: rec, elements: fes });
focus = Some(Focus {
record: rec,
loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
elements: fes,
});
}
}
}
@@ -397,10 +623,21 @@ pub fn export_build(
sprite: sprite_out,
focus_sprite,
focus,
leaf_carries_geometry: leaf.as_ref().is_some_and(|l| {
let p = el.rest();
l.elements.iter().any(|le| {
p.is_none_or(|p| {
le.rest.scale != [p.scale_x, p.scale_y]
|| le.rest.rotation_deg != p.rotation_deg
})
})
}),
leaf,
opt_link: el.focus_link.clone(),
pivot: [el.pivot_x, el.pivot_y],
size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]),
parent: el.parent,
blend_additive: ui_layout::sprite_blend_additive(&b, bundle, el),
layer_source,
layer,
focused: el.focused,
@@ -419,14 +656,33 @@ pub fn export_build(
// Navigation order is geometric: buttons top-to-bottom by resting Y. A
// focused-state record is not itself a menu item.
//
// 🔴 `0x3003` IS `0x3002`. Bit 0 of `kind` is the PARENT FLAG and carries no
// role information: decoded disc-wide over every `.pak` in `dat/`, `kind & 1`
// agrees with "has a parent" on 15 493 elements with ZERO disagreements
// (`docs/re/ui-kind-bit0-is-has-parent.md`). Matching only `0x3002` meant the
// OPTIONS menu's five rows -- parented, hence `0x3003` -- were not buttons,
// so the screen opened and could not be navigated.
//
// ⚠️ TWO VALUES, LISTED, NOT A MASK. `kind & 0xFFFE == 0x3002` would also
// match `0x73002`/`0x73003` -- 160 elements whose `0x70000` bits nobody has
// decoded -- and it would do it silently, on screens neither agent has
// looked at. Those are excluded by construction until somebody decides about
// them deliberately.
let mut buttons: Vec<(i32, String)> = b
.elements
.iter()
.filter(|e| e.kind == 0x3002 && !e.focused)
.filter(|e| matches!(e.kind, 0x3002 | 0x3003) && !e.focused)
.filter_map(|e| e.rest().map(|k| (k.y, id_of(&e.name))))
.collect();
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let window = settle_window(&elements);
let order = forced_backdrop_first(
ui_layout::derived_paint_order(&b, bundle),
&elements,
[b.design_w, b.design_h],
);
let screen = Screen {
format: "sylpheed.screen/3",
exporter: exporter.to_string(),
@@ -441,8 +697,9 @@ pub fn export_build(
name_why,
design: [b.design_w, b.design_h],
elements,
paint_order: ui_layout::derived_paint_order(&b, bundle),
paint_order: order,
buttons: buttons.into_iter().map(|(_, n)| n).collect(),
settle_window: window,
unresolved: vec![
// The time unit is measured off the running game, not on the disc.
"keyframe_time_unit",
@@ -473,3 +730,234 @@ pub fn export_build(
missing,
})
}
/// The longest interval containing no keyframe time, over TOP-LEVEL elements.
///
/// See [`Screen::settle_window`] for why this is the settled instant and why
/// nested leaves are excluded. Returns `[start, end, midpoint]`.
fn settle_window(elements: &[Element]) -> Option<[i64; 3]> {
let mut times: Vec<i64> = elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t.map(i64::from)))
.collect();
times.sort_unstable();
times.dedup();
if times.len() < 2 {
return None;
}
// 🔴 A GAP IN WHICH NOTHING IS VISIBLE IS NOT A SETTLE WINDOW.
//
// The widest keyframe-free interval is only a settled state if the screen is
// actually PRESENTING something across it. `press_start` is the case that
// proves it: its keyframes are 0, 214, 236, 238, 244, so the widest gap is
// 0..214 -- the dead stretch BEFORE the plate appears, where `ptbtn00` is
// alpha 0 throughout. Taking its midpoint gave a settle instant of t=107,
// and the runtime then answered every question about that screen at t=107.
// The result was that the PRESS (A) plate could not be drawn at any instant
// at all, including the boot's own end state, whose entire purpose is to
// show it.
//
// The fix is not a tuned threshold: it is that the heuristic was reading an
// interval where the screen is BLANK as the interval where it has arrived.
// Rejecting those leaves `press_start` with 214..236 (22 units), which is
// under the runtime's 30-unit bar, so it falls back to each element's own
// hold -- which is the plate, opaque, exactly as the disc declares it.
//
// ⚠️ This does not disturb the windows the settle instant was measured on.
// `title` keeps [160, 236]: elements are visible across it, and the
// Decoder's draw stream independently found the game's clock freezing in
// that same interval.
let visible_at = |t: i64| elements.iter().any(|e| alpha_at(e, t) > 0);
let (a, b) = times
.windows(2)
.map(|w| (w[0], w[1]))
.filter(|(a, b)| visible_at((a + b) / 2))
.max_by_key(|(a, b)| b - a)?;
Some([a, b, (a + b) / 2])
}
/// Alpha of one element at instant `t`, under the linear ramp the port uses.
fn alpha_at(e: &Element, t: i64) -> u8 {
let ks = &e.keyframes;
let a = |k: &Keyframe| (u32::from_str_radix(k.fade_argb.trim_start_matches("0x"), 16)
.unwrap_or(0) >> 24) as i64;
let timed: Vec<&Keyframe> = ks.iter().filter(|k| k.t.is_some()).collect();
if timed.is_empty() {
return 0;
}
if t <= timed[0].t.unwrap() as i64 {
return a(timed[0]) as u8;
}
for w in timed.windows(2) {
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
if t < t1 {
if t1 <= t0 {
return a(w[0]) as u8;
}
let f = (t - t0) as f64 / (t1 - t0) as f64;
return (a(w[0]) as f64 + (a(w[1]) - a(w[0])) as f64 * f).round() as u8;
}
}
a(timed[timed.len() - 1]) as u8
}
/// Scale of one element at instant `t`, in percent per axis, under the same
/// linear ramp as the fade. Interpolated rather than stepped, because a scale
/// that animates passes through every value between its keyframes.
fn scale_at(e: &Element, t: i64) -> [f64; 2] {
let timed: Vec<&Keyframe> = e.keyframes.iter().filter(|k| k.t.is_some()).collect();
if timed.is_empty() {
return [100.0, 100.0];
}
let g = |k: &Keyframe, i: usize| k.scale[i] as f64;
if t <= timed[0].t.unwrap() as i64 {
return [g(timed[0], 0), g(timed[0], 1)];
}
for w in timed.windows(2) {
let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64);
if t < t1 {
if t1 <= t0 {
return [g(w[0], 0), g(w[0], 1)];
}
let f = (t - t0) as f64 / (t1 - t0) as f64;
return [
g(w[0], 0) + (g(w[1], 0) - g(w[0], 0)) * f,
g(w[0], 1) + (g(w[1], 1) - g(w[0], 1)) * f,
];
}
}
let l = timed[timed.len() - 1];
[g(l, 0), g(l, 1)]
}
/// Move a full-screen opaque primitive to the FRONT of the paint order when the
/// file forces it there.
///
/// 🔴 **The rule is a constraint, not a preference**, and it is the Decoder's:
/// *an element that covers the screen and is fully opaque at some instant cannot
/// paint above anything visible at that instant; where the elements visible
/// during its opaque span are ALL of them, its position is forced to first.*
///
/// It was found because `build_12`/`build_15` are **black at every instant** of
/// their declared timeline under the old rule — `pgloading_eff00` is opaque for
/// 39 instants while all 9 other elements live and die inside that span. A
/// screen that is black for its whole life is impossible on its face, which is
/// the only kind of check that survives two renderers sharing an assumption:
/// `sylpheed-cli` agreed with the port here because it agreed about
/// `implied_layer_key`.
///
/// Two measured controls, both prior orders off the running game:
///
/// | primitive | measured | opaque instants | forced below | |
/// |---|---|---|---|---|
/// | `palogo_eff0.prm` | **first** | 211 | 6 of 6 | ✅ forced |
/// | `pteff00.prm` | **last** | 2 | 3 of 23 | ✅ permitted on top |
///
/// ⚠️ **Do NOT reduce this to a name heuristic.** `*base*` first / `*eff*` last
/// matches 77 of 80 and fails on exactly the three families that cross it —
/// `palogo_eff0`, `pgloading_eff00`, `pzeff00`. `palogo_eff0.prm` is *named like
/// an overlay* and is measured painting first. The name is not the rule.
///
/// 🔴 **And it is restricted to elements with NO SPRITE**, which is the limit
/// that the rule's own disc-wide test caught: applied to sprites it claimed 22
/// `.t32` textures must sort first *against their own layer keys*. **An
/// element's alpha says nothing about whether its texture covers the screen** —
/// most of a sprite may be transparent.
///
/// ⚠️ Reach: assumes straight alpha-over. Blend mode is undecoded, and an
/// additive quad at alpha 255 would not occlude. It is a lower bound, not an
/// ordering — it says nothing about elements that are constrained but not
/// forced. Delete this when a pinned `sylpheed-formats` does it.
fn forced_backdrop_first(order: Vec<usize>, elements: &[Element], design: [u32; 2]) -> Vec<usize> {
let screen_end: i64 = elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t))
.map(i64::from)
.max()
.unwrap_or(0);
let forced: Vec<usize> = elements
.iter()
.enumerate()
.filter(|(_, e)| {
// 🔴 UNTEXTURED SOLID QUAD, tested positively -- NOT merely "has no
// sprite". Those coincide in GP_TITLE and the distinction is still
// the whole point, because the negative test guards a SYMPTOM.
//
// The rule needs the element's alpha to BE its pixels' alpha. That
// is true of a `.prm` solid quad and of nothing else. The Decoder
// found this the expensive way twice: first `.t32` sprites (an
// element's alpha says nothing about a texture that is mostly
// transparent), guarded with "no sprite" -- and then `.tbm`, which
// is 38 of their 80 forced-first verdicts and declares fade
// `ffffffff`. A solid WHITE quad painted first at alpha 255 would
// make the screen white; no screen is white, so a `.tbm`'s white is
// a modulation ON a texture and its element alpha proves nothing
// about coverage either.
//
// "No sprite" would keep admitting a `.tbm` that this exporter
// happens not to emit a sprite for. `role == "primitive"` cannot.
// GP_TITLE has no full-screen `.tbm` at all -- every layerless
// full-screen element here is `.prm` and pure black, checked -- so
// this changes no verdict today and is a guard against a corpus
// that grows.
// Cheap prefilter only -- the binding coverage test is per-instant,
// in `covers` below. An element scaled ABOVE 100 could cover the
// screen from a smaller declared size, so this deliberately does
// not reject on size.
e.role == "primitive" && e.sprite.is_none() && e.size.is_some()
})
.filter(|(i, e)| {
let span: Vec<i64> = e
.keyframes
.iter()
.filter_map(|k| k.t)
.map(i64::from)
.collect();
let Some(&lo) = span.first() else { return false };
// 🔴 COVERAGE IS TESTED AT EACH INSTANT, NOT ONCE FROM `size`.
// Declared size alone is not what the element draws: scale is a
// percent per axis and it animates. `pbafc.prm` is the disc's own
// counterexample -- declared 844x600, scaled 2 % x 3 %, so it draws
// about 17x18 px, a moving glint rather than a wash. A rule that
// read its declared size would call it screen-covering.
//
// Nothing in GP_TITLE needs this: every layerless full-screen
// element here is at scale 100 on every keyframe, so no verdict
// moves. It is in because the data that would break it exists on
// this disc, which is a better reason than a failure would have been.
let covers = |t: i64| {
let sc = scale_at(e, t);
e.size.is_some_and(|s| {
s[0] as f64 * sc[0] / 100.0 >= design[0] as f64
&& s[1] as f64 * sc[1] / 100.0 >= design[1] as f64
})
};
// An element HOLDS ITS FINAL POSE to the end of the screen -- it does
// not vanish at its own last keyframe. `palogo_eff0.prm` is the case
// that shows why: it declares ONE keyframe, opaque black full-screen
// at t=0, and reading its span as `0..=0` makes the splash's backdrop
// a single-instant event instead of the thing that is on screen for
// the whole splash. So the span runs to the SCREEN's last keyframe.
let hi = screen_end.max(*span.last().unwrap());
let opaque: Vec<i64> = (lo..=hi)
.filter(|&t| alpha_at(e, t) == 255 && covers(t))
.collect();
if opaque.is_empty() {
return false;
}
// Every OTHER element must be visible somewhere inside that span.
elements.iter().enumerate().all(|(j, o)| {
j == *i || opaque.iter().any(|&t| alpha_at(o, t) > 0)
})
})
.map(|(i, _)| i)
.collect();
if forced.is_empty() {
return order;
}
let mut out = forced.clone();
out.extend(order.into_iter().filter(|i| !forced.contains(i)));
out
}

View File

@@ -1,38 +0,0 @@
use sylpheed_formats::{pak, ui_layout};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap(); let i:usize=a.next().unwrap().parse().unwrap();
let ar=pak::PakArchive::open(pk).unwrap();
let by=ar.read(&ar.entries()[i]).unwrap();
let b=ui_layout::parse_build(&by).unwrap();
println!("entry {i}: {} elements, {} records, {} sprites",
b.elements.len(), b.records.len(), b.sprites.len());
let mut rk:Vec<&String>=b.records.keys().collect(); rk.sort();
println!(" records: {:?}", rk);
for (rn,&(o,sz)) in &b.records {
if o+sz>by.len() { continue }
let Some(lb)=ui_layout::parse_build(&by[o..o+sz]) else { continue };
println!(" RECORD {rn}: {} elements", lb.elements.len());
for le in &lb.elements {
let lts:Vec<String>=le.keyframes.iter()
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
println!(" [{}] {:<22} kind=0x{:<6x} {}", le.index, le.name, le.kind, lts.join(" "));
}
}
for e in &b.elements {
let ts:Vec<String>=e.keyframes.iter()
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
println!(" [{}] {:<24} kind=0x{:<6x} {}", e.index, e.name, e.kind, ts.join(" "));
if let Some(&(o,s))=b.records.get(&e.name) {
if o+s<=by.len() {
if let Some(lb)=ui_layout::parse_build(&by[o..o+s]) {
for le in &lb.elements {
let lts:Vec<String>=le.keyframes.iter()
.map(|k|format!("t{}a{}",k.time.map(|v|v as i64).unwrap_or(-1),k.fade>>24)).collect();
println!(" leaf {:<20} kind=0x{:<6x} {}", le.name, le.kind, lts.join(" "));
}
}
}
}
}
}

View File

@@ -1,21 +0,0 @@
use sylpheed_formats::{pak, ui_layout};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap();
let ar=pak::PakArchive::open(pk).unwrap();
for t in a {
let i:usize=t.parse().unwrap();
let by=ar.read(&ar.entries()[i]).unwrap();
let b=ui_layout::parse_build(&by).unwrap();
println!("=== entry {i} ===");
let order=ui_layout::derived_paint_order(&b,&by);
for e in &b.elements {
let k=ui_layout::sprite_layer_key(&b,&by,e);
let pos=order.iter().position(|&x|x==e.index);
println!(" [{}] {:<24} kind=0x{:<5x} sprite={:<24} key={:<12} paint#{:?}",
e.index, e.name, e.kind,
e.sprite.clone().unwrap_or_else(||"<none>".into()),
k.map(|v|format!("0x{v:08x}")).unwrap_or_else(||"NONE".into()), pos);
}
}
}

View File

@@ -1,39 +0,0 @@
use sylpheed_formats::{pak, ratc, ui_layout};
fn main(){
let root=std::env::var("SYLPHEED_DISC").unwrap_or_else(|_|"/disc".into());
let ar=pak::PakArchive::open(format!("{root}/dat/GP_READY_ROOM.pak")).unwrap();
for (i,e) in ar.entries().iter().enumerate() {
let Ok(by)=ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b)=ui_layout::parse_build(&by) else { continue };
let Some(p)=b.elements.iter().find(|el|el.name=="pbafc.prm") else { continue };
println!("=== entry {i}: {} elements ===", b.elements.len());
println!(" pbafc.prm pivot=({},{}) -> {}x{}", p.pivot_x,p.pivot_y,p.pivot_x*2,p.pivot_y*2);
for k in &p.keyframes {
println!(" t={:<5} fade={:08x} a={:<4} xy=({},{}) s={}/{}",
k.time.map(|v|v as i64).unwrap_or(-1), k.fade, k.fade>>24, k.x,k.y,k.scale_x,k.scale_y);
}
// what does it cover, and is anything visible while it is opaque?
let rest=p.rest().unwrap();
let (px,py,pw,ph)=(rest.x, rest.y, (p.pivot_x*2) as i32, (p.pivot_y*2) as i32);
println!(" its rect at rest: ({px},{py}) {pw}x{ph}");
let tmax=b.elements.iter().flat_map(|e|e.keyframes.iter().filter_map(|k|k.time)).max().unwrap_or(0);
let op:Vec<u32>=(0..=tmax).filter(|&t|p.pose_at(t).map(|k|k.fade>>24)==Some(255)).collect();
println!(" opaque at {} instants (t={:?}..{:?}) of 0..{tmax}", op.len(), op.first(), op.last());
let mut cov=0; let mut vis=0;
for o in &b.elements {
if o.index==p.index { continue }
let Some(ok)=o.rest() else { continue };
let (ow,oh)=((o.pivot_x*2) as i32,(o.pivot_y*2) as i32);
let overlap=(px+pw).min(ok.x+ow)-px.max(ok.x)>0 && (py+ph).min(ok.y+oh)-py.max(ok.y)>0;
if !overlap { continue }
cov+=1;
if op.iter().any(|&t| o.pose_at(t).map(|k|k.fade>>24).unwrap_or(0)>0) {
vis+=1;
if vis<=6 { println!(" covered AND visible while opaque: {}", o.name); }
}
}
println!(" elements its rect covers: {cov}; visible while it is opaque: {vis}");
break;
}
}

View File

@@ -1,27 +0,0 @@
use sylpheed_formats::{pak, ui_layout};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap();
let ar=pak::PakArchive::open(pk).unwrap();
for t in a {
let i:usize=t.parse().unwrap();
let by=ar.read(&ar.entries()[i]).unwrap();
let Some(b)=ui_layout::parse_build(&by) else { continue };
let top=u32::from_be_bytes(by[8..12].try_into().unwrap());
println!("entry {i:2} TOP +08 = {top}");
let mut ks:Vec<&String>=b.records.keys().collect(); ks.sort();
for rn in ks {
let &(o,s)=b.records.get(rn).unwrap();
if o+16>by.len() { continue }
let magic=&by[o..o+4];
let h4=u32::from_be_bytes(by[o+4..o+8].try_into().unwrap());
let h8=u32::from_be_bytes(by[o+8..o+12].try_into().unwrap());
let maxt=ui_layout::parse_build(&by[o..o+s])
.map(|lb|lb.elements.iter().flat_map(|e|e.keyframes.iter()
.filter_map(|k|k.time)).max().unwrap_or(0)).unwrap_or(0);
println!(" {rn:<24} magic={:?} +04={:08x}({:.1}) +08={h8:<6} max keyframe t={maxt} ratio={:.4}",
String::from_utf8_lossy(magic), h4, h4 as f64/65536.0,
if maxt>0 {h8 as f64/maxt as f64} else {0.0});
}
}
}

View File

@@ -1,53 +0,0 @@
//! Does `resolve_movie_voice_region` start LATE, and by exactly how much?
//!
//! The port agent's arithmetic: the running decoder's three `ADV` XMA contexts sum
//! to **3 584 000** payload bytes, but the resolved voice region is **3 114 352** —
//! 15 % too small to hold them. One of the two spans is not what the other thinks
//! it is, and the disc side is this crate's.
//!
//! The gap is exact. `ctx0` declares **632** packets (1 294 336 B); the leading
//! chunk the resolver yields has **394** (806 912 B). The difference is **238
//! packets = 487 424 B**, a whole number of packets — which is what a start offset
//! looks like, not corruption.
//!
//! So: walk the region start backwards and report where `to_xma_riffs` first
//! reproduces the decoder's own three sizes. The probe's byte_sizes are the
//! control — this is not free to fit, it either lands on them or it does not.
//!
//! cargo run -p sylpheed-formats --example adv_region_extend
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::slb::{self, VoiceLang};
/// What the running decoder reported (docs/re/structures/voice-three-streams-are-concurrent.md).
const WANT: [usize; 3] = [1_294_336, 1_118_208, 1_171_456];
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
let (start, end) =
media::resolve_movie_voice_region(&src, "ADV", VoiceLang::English).expect("region");
println!("resolver says {start}..{end} ({} B)", end - start);
println!("decoder wants {:?} = {} B payload\n", WANT, WANT.iter().sum::<usize>());
for back_packets in [0usize, 100, 200, 237, 238, 239, 300, 400] {
let back = (back_packets * 2048) as u64;
if back > start {
continue;
}
let s = start - back;
let Ok(bytes) = src.read_segment_range("dat/sound", s, (end - s) as usize) else {
println!("-{back_packets:4} packets: unreadable");
continue;
};
let riffs = slb::to_xma_riffs(&bytes);
let sizes: Vec<usize> = riffs.iter().map(|r| r.len() - 60).collect();
let hit = sizes.len() == 3 && sizes.iter().zip(WANT.iter()).all(|(a, b)| a == b);
println!(
"-{back_packets:4} packets (start {s}): {} chunk(s) {:?}{}",
riffs.len(),
sizes,
if hit { " <== MATCHES THE DECODER" } else { "" }
);
}
}

View File

@@ -1,41 +0,0 @@
//! Dump `ADV`'s voice chunks as RIFF/XMA, so each can be decoded and identified.
//!
//! `intro-audio-decomposed.md` measured that the intro's output is the movie's own
//! WMA Pro 5.1 track at 0.600 **plus** three streams occupying a front pair, a
//! centre (with a silent partner) and a rear pair. What it could **not** say is
//! *which* stream sits where — the assignment there is by position, not content.
//! The port needs that to weight a positional downmix.
//!
//! This writes the chunks out so they can be decoded (ffmpeg has `xma2`) and
//! correlated against the per-channel residuals.
//!
//! cargo run -p sylpheed-formats --example adv_voice_dump -- OUTDIR [MOVIE]
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::slb::{self, VoiceLang};
fn main() {
let out = std::env::args().nth(1).expect("OUTDIR");
let movie = std::env::args().nth(2).unwrap_or_else(|| "ADV".into());
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
std::fs::create_dir_all(&out).expect("outdir");
let (start, end) = media::resolve_movie_voice_region(&src, &movie, VoiceLang::English)
.expect("voice region");
let bytes = src
.read_segment_range("dat/sound", start, (end - start) as usize)
.expect("region");
println!("{movie}: region {start}..{end} = {} B", end - start);
let riffs = slb::to_xma_riffs(&bytes);
println!("{} RIFF chunk(s)", riffs.len());
for (i, r) in riffs.iter().enumerate() {
let p = format!("{out}/{movie}_{i}.xma");
std::fs::write(&p, r).expect("write");
// the probe reports `byte_size` = RIFF total - 60; print both so the
// dump can be tied to a specific XMA context by its own number
println!(" chunk {i}: {} B byte_size-equivalent {} -> {p}",
r.len(), r.len() as i64 - 60);
}
}

View File

@@ -1,40 +0,0 @@
//! List a sound bank's streams and their declared rates.
//!
//! cargo run -p sylpheed-formats --example bank_streams -- <disc> BGM_102.slb …
use sylpheed_formats::media::{self, DirectorySource};
use sylpheed_formats::{hash::name_hash, slb};
fn main() {
let mut a = std::env::args().skip(1);
let disc = a.next().expect("usage: bank_streams <disc> NAME.slb…");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
for name in a {
let h = name_hash(&name);
match media::read_sound_bank(&src, h) {
Ok(bytes) => {
let riffs = slb::to_xma_riffs(&bytes);
println!(
"{name} (hash {h:08x}, {} B on disc) header {:?} -> {} stream(s)",
bytes.len(),
slb::bank_header_len(&bytes),
riffs.len()
);
for (i, r) in riffs.iter().enumerate() {
let rate = if r.len() >= 0x28 {
u32::from_le_bytes(r[0x20..0x24].try_into().unwrap())
} else {
0
};
let payload = r.len() - 60;
println!(
" stream {i}: payload {payload} B ({} packets) declared {rate} B/s \
=> {:.3} s",
payload / 2048,
if rate > 0 { payload as f64 / rate as f64 } else { 0.0 }
);
}
}
Err(e) => println!("{name}: {e}"),
}
}
}

View File

@@ -1,22 +0,0 @@
//! Dump one BGM bank's waves as RIFF/XMA so they can be decoded and compared
//! against a capture of the running game.
//!
//! cargo run -p sylpheed-formats --example bgm_dump -- BGM_103.slb OUTDIR
use sylpheed_formats::media::{self, DirectorySource};
use sylpheed_formats::slb;
fn main() {
let name = std::env::args().nth(1).unwrap_or_else(|| "BGM_103.slb".into());
let out = std::env::args().nth(2).expect("OUTDIR");
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
std::fs::create_dir_all(&out).expect("outdir");
let h = sylpheed_formats::hash::name_hash(&name);
let bytes = media::read_sound_bank(&src, h).expect("bank");
println!("{name}: {} B", bytes.len());
for (i, r) in slb::to_xma_riffs(&bytes).iter().enumerate() {
let p = format!("{out}/{}_{i}.xma", name.trim_end_matches(".slb"));
std::fs::write(&p, r).expect("write");
println!(" wave {i}: {} B (byte_size {}) -> {p}", r.len(), r.len() - 60);
}
}

View File

@@ -1,78 +0,0 @@
//! Does a screen declare its own OPAQUE BLACK backdrop? Disc-wide.
//!
//! `sylpheed-port` observed that the splash builds declare `palogo_eff0.prm` as a
//! full-screen primitive at t=0 with `fade_argb 0xff000000` -- alpha 255 over RGB
//! 000000 -- and turned it into a candidate predicate: a declared opaque-black
//! backdrop separates STANDALONE screens from COMPOSITED ones. On their sixteen
//! exported screens it splits 12 / 4, with all four exceptions independently known
//! to be composited (the two `press_start` plates, and two loading builds that
//! carry the `pgloading_*` set without its backdrop).
//!
//! That matters because the corpus previously told them "no content rule exists,
//! take the entry index" -- correct for the question asked (recognise the splash),
//! but this is a content rule for a different and useful question. They asked for
//! it to be tested against an archive they do not have. This is that test.
//!
//! CONTROL: it must reproduce the 12/4 split on GP_TITLE's sixteen composable
//! bundles before its disc-wide numbers mean anything.
//!
//! cargo run -p sylpheed-formats --example black_backdrop_predicate
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::io::Write;
use std::path::PathBuf;
/// A screen declares its own backdrop if some `.prm` primitive holds
/// `fade == 0xff000000` at t = 0: full alpha over black.
fn has_black_backdrop(b: &ui_layout::UiBuild) -> Option<String> {
for el in &b.elements {
if !el.name.ends_with(".prm") { continue }
if let Some(k) = el.keyframes.iter().find(|k| k.time == Some(0)) {
if k.fade == 0xff00_0000 { return Some(el.name.clone()) }
}
}
None
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
println!("== CONTROL: GP_TITLE's 16 composable bundles (port reports 12 with, 4 without)");
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let (mut y, mut n) = (0, 0);
for e in 0..16usize {
let Ok(by) = ar.read(&ar.entries()[e]) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
match has_black_backdrop(&b) {
Some(nm) => { y += 1; println!(" entry {e:>2} YES {nm}") }
None => { n += 1; println!(" entry {e:>2} no") }
}
}
println!(" -> {y} with, {n} without\n");
println!("== DISC-WIDE, over every screen build");
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut tot, mut with) = (0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
let (mut t, mut w) = (0usize, 0usize);
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
t += 1;
if has_black_backdrop(&b).is_some() { w += 1 }
}
if t > 0 {
println!("{name:30} {w:4} / {t:<4} declare a black backdrop");
std::io::stdout().flush().ok();
}
tot += t; with += w;
}
println!("\n{with} of {tot} screen builds disc-wide declare an opaque-black backdrop \
({:.1} %)", 100.0 * with as f64 / tot as f64);
println!("--- END ---");
}

View File

@@ -1,63 +0,0 @@
//! How often is a screen's design size READ, and how often is it FABRICATED?
//!
//! `ui_layout.rs` scans the `.rat` records for a `(w,h)` at `+0x18`/`+0x1c` and,
//! finding none, falls back to `(DESIGN_W, DESIGN_H)` = 1280x720. Its own comment
//! says "every screen seen is 1280x720, **which is also the fallback**" -- which
//! is precisely the problem: the fabricated value equals the expected one, so no
//! output of the parser can distinguish a read design size from an invented one.
//! The port sizes its screens off this number.
//!
//! This replicates the scan through the public RATC API and counts.
//!
//! cargo run -p sylpheed-formats --example design_size_fallback
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
use std::io::Write;
use std::path::PathBuf;
fn be32(b: &[u8], o: usize) -> u32 {
if o + 4 > b.len() { return 0 }
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut read, mut fell_back, mut nonstd) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
let (mut r, mut f) = (0usize, 0usize);
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) { continue }
let Some(kids) = ratc::parse(&by) else { continue };
// the same predicate ui_layout uses, over the same records
// ⚠️ A first version took EVERY RATC child and failed its control:
// it reported all 965 builds stating a non-1280x720 size, where
// `screen list` prints 1280x720 for every one. `records` in
// ui_layout is the `.rat` children only; a T8aD sprite header read
// at +0x18 is garbage that passes the range test.
let found = kids.iter().filter(|k| k.kind == "RATC" || k.name.ends_with(".rat")).find_map(|k| {
let rec = &by[k.offset..(k.offset + k.size).min(by.len())];
let (w, h) = (be32(rec, 0x18), be32(rec, 0x1c));
(w > 0 && h > 0 && w <= 8192 && h <= 8192).then_some((w, h))
});
match found {
Some((w, h)) => { r += 1; if (w, h) != (1280, 720) { nonstd += 1;
println!(" {name} : a build states a NON-standard design size {w}x{h}"); } }
None => f += 1,
}
}
if r + f > 0 {
println!("{name:30} {r:5} read {f:5} FABRICATED");
std::io::stdout().flush().ok();
}
read += r; fell_back += f;
}
println!("\n{read} builds state a design size, {fell_back} get the 1280x720 FALLBACK");
println!("{nonstd} builds state something other than 1280x720");
println!("--- END ---");
}

View File

@@ -1,123 +0,0 @@
//! Which cue owns an XMA stream of a given payload size?
//!
//! A boot with `--xma_param_probe` logs each decoded stream's `byte_size`. Three
//! of the five on the take-2 `ADV` boot are that movie's own streams; two —
//! 1 150 976 and 1 269 760 B — belong to something unidentified. The probe gives
//! a size and nothing else, so the disc has to be asked which cue has a stream
//! that long.
//!
//! Searches every inter-descriptor span of the continuous voice stream, and
//! every `sound.pak` entry, for a stream whose payload matches.
//!
//! cargo run -p sylpheed-formats --example find_stream_by_size -- <disc> <bytes>…
use sylpheed_formats::media::{DirectorySource, DiscSource};
use sylpheed_formats::{slb, PakArchive};
const DESC_MARK: u32 = 0x11;
const DESC_REPEAT: usize = 0x800;
const ID_MAX: u32 = 0x1_0000;
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
let mut out = Vec::new();
if buf.len() < DESC_REPEAT + 8 {
return out;
}
let end = buf.len() - (DESC_REPEAT + 4);
let mut o = 0;
while o <= end {
let id = be(o);
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
out.push((o, id));
}
o += 4;
}
out
}
fn main() {
let mut args = std::env::args().skip(1);
let disc = args.next().expect("usage: find_stream_by_size <disc> <bytes>…");
let wanted: Vec<usize> = args.filter_map(|a| a.parse().ok()).collect();
assert!(!wanted.is_empty(), "give at least one payload size");
// A `to_xma_riffs` chunk is the payload plus a 60-byte RIFF wrapper.
let want_riff: Vec<usize> = wanted.iter().map(|w| w + 60).collect();
println!("looking for payloads {wanted:?} (riff sizes {want_riff:?})\n");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
// --- 1. the continuous movie-voice stream, span by span
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let marker = "eng\\Movie\\VOICE_ADV.slb";
let registry = tpak
.entries()
.iter()
.find_map(|e| {
tpak.read(e)
.ok()
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
})
.expect("registry");
let ids = sylpheed_formats::movie_voice::registry_voice_ids(&registry);
let name_of: std::collections::HashMap<u32, String> =
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
let win_start: u64 = 421_739_888 & !3;
let buf = src
.read_segment_range("dat/sound", win_start, 116_300_000)
.expect("window");
let descs = all_descriptors(&buf);
println!("voice stream: {} descriptors", descs.len());
let mut hits = 0;
for w in descs.windows(2) {
let (a, b) = (w[0].0, w[1].0);
if b <= a || b - a < 4096 {
continue;
}
for (i, r) in slb::to_xma_riffs(&buf[a..b]).iter().enumerate() {
if want_riff.contains(&r.len()) {
let name = name_of
.get(&w[1].1)
.cloned()
.unwrap_or_else(|| format!("id{}", w[1].1));
println!(
" ✅ cue {name} (id {}) stream {i}: payload {} B",
w[1].1,
r.len() - 60
);
hits += 1;
}
}
}
println!(" {hits} hit(s) in the voice stream\n");
// --- 2. every sound.pak entry
let stoc = src.read_file("dat/sound.pak").expect("sound.pak toc");
let entries = PakArchive::parse_toc(&stoc).expect("toc");
println!("sound.pak: {} entries", entries.len());
let mut phits = 0;
let mut scanned = 0usize;
for e in &entries {
// Only entries big enough to hold the target.
let need = wanted.iter().copied().min().unwrap_or(0) as u32;
if e.comp_size < need {
continue;
}
let Ok(bytes) = src.read_segment_range("dat/sound", e.offset as u64, e.comp_size as usize)
else {
continue;
};
scanned += 1;
for (i, r) in slb::to_xma_riffs(&bytes).iter().enumerate() {
if want_riff.contains(&r.len()) {
println!(
" ✅ sound.pak entry hash {:08x} offset {} size {} — stream {i}: payload {} B",
e.name_hash,
e.offset,
e.comp_size,
r.len() - 60
);
phits += 1;
}
}
}
println!(" scanned {scanned} entries large enough; {phits} hit(s)");
}

View File

@@ -1,65 +0,0 @@
//! Which focus records have a VARYING alpha — disc-wide, not export-wide?
//!
//! `rest()` returns an element's last hold keyframe. For a constant-alpha
//! element that is harmless. For one that pulses it returns the PEAK, which is
//! the `ui-settle-time.md` pathology: the plate's `ptbtn00f` ramps 0→80→0 and
//! `rest()` reports 80, its maximum.
//!
//! The port censused this over its own export (34 records, 2 varying) and
//! concluded there is nothing to fix. That conclusion is only as wide as the
//! export. This asks the same question of the whole disc.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut n_rec, mut n_elem, mut varying) = (0usize, 0usize, 0usize);
let (mut at_peak, mut mid_ramp) = (0usize, 0usize);
let mut by_pak: std::collections::BTreeMap<String, usize> = Default::default();
let mut hits: Vec<String> = Vec::new();
for p in &paks {
let pn = p.file_name().unwrap().to_string_lossy().to_string();
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for (ei, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (rn, &(o, s)) in &b.records {
// A focus record is one whose name is another record's plus `f`.
let Some(stem) = rn.strip_suffix("f.rat") else { continue };
if !b.records.contains_key(&format!("{stem}.rat")) { continue }
if o + s > by.len() { continue }
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
n_rec += 1;
for el in &lb.elements {
if el.keyframes.is_empty() { continue }
n_elem += 1;
let a: Vec<u32> = el.keyframes.iter().map(|k| k.fade >> 24).collect();
let (lo, hi) = (*a.iter().min().unwrap(), *a.iter().max().unwrap());
if lo == hi { continue }
varying += 1;
let rest = el.rest().map(|k| k.fade >> 24).unwrap_or(0);
if rest == hi { at_peak += 1 } else { mid_ramp += 1 }
*by_pak.entry(pn.clone()).or_default() += 1;
hits.push(format!(
"{pn} [{ei}] {rn}::{} alpha {lo}..{hi} rest()={rest}{}",
el.name, if rest == hi { " 🔴 == PEAK" } else { "" }));
}
}
}
}
println!("focus records disc-wide : {n_rec}");
println!(" their timed elements : {n_elem}");
println!(" with a VARYING alpha : {varying}");
println!(" of which rest() == the PEAK : {at_peak} <- burns bright forever");
println!(" of which rest() is MID-RAMP : {mid_ramp} <- neither extreme; looks plausible");
println!("\nby pak:");
for (k, v) in &by_pak { println!(" {k:<34} {v}") }
println!("\nevery varying one:");
hits.sort(); hits.dedup();
for h in &hits { println!(" {h}") }
println!("\n({} distinct)", hits.len());
}

View File

@@ -1,90 +0,0 @@
//! Reconcile two ink counts for one screen that were never counting the same pixels.
//!
//! The port agent double-witnessed the pixel-cost claim in Godot — a renderer
//! sharing no code with `compose` — and got `GP_TITLE` entry 12 at **59 530 px**
//! ink above threshold 0 and **48 368** above 1. This crate reported **49 771**.
//! Neither is wrong; the question is which convention each was using, and on a
//! mostly-dark frame the answer moves thousands of pixels.
//!
//! So: count the same composite every way, and print the family. Whichever row
//! the port's numbers land in is the convention, and then the two renderers can be
//! compared on purpose rather than by coincidence.
//!
//! cargo run -p sylpheed-formats --example forced_backdrop_ink_thresholds
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
use ui_layout::ComposeOptions;
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
idx.sort_by_key(|&i| {
let el = &build.elements[i];
(
ui_layout::sprite_layer_key(build, bundle, el)
.or_else(|| ui_layout::implied_layer_key(&el.name))
.unwrap_or(u32::MAX),
i,
)
});
idx
}
fn counts(rgba: &[u8], t: u8) -> (usize, usize) {
let rgb = rgba
.chunks_exact(4)
.filter(|p| p[0] > t || p[1] > t || p[2] > t)
.count();
let alpha = rgba.chunks_exact(4).filter(|p| p[3] > t).count();
(rgb, alpha)
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
for entry in [12usize, 15] {
let by = ar.read(&ar.entries()[entry]).expect("entry");
let b = ui_layout::parse_build(&by).expect("parse");
for (label, opts) in [
(
"primitives on (what the cost run used)",
ComposeOptions {
include_primitives: true,
backdrop: [0, 0, 0, 255],
..Default::default()
},
),
(
"primitives+focus+animated",
ComposeOptions {
include_primitives: true,
include_focus: true,
include_animated: true,
backdrop: [0, 0, 0, 255],
..Default::default()
},
),
] {
let with = ui_layout::derived_paint_order(&b, &by);
let without = order_without_rule(&b, &by);
let a = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&with));
let c = ui_layout::compose_with_order(&b, &by, opts, None, Some(&without));
println!("\n== GP_TITLE entry {entry}{label} ({}x{})", a.width, a.height);
println!(" threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT");
for t in [0u8, 1, 2, 4, 8, 16] {
let (r1, a1) = counts(&a.rgba, t);
let (r0, a0) = counts(&c.rgba, t);
println!(" >{t:<8} | {r1:>16} | {a1:>14} | {r0:>13} | {a0:>11}");
}
let changed = a
.rgba
.chunks_exact(4)
.zip(c.rgba.chunks_exact(4))
.filter(|(x, y)| x != y)
.count();
println!(" exact-RGBA changed pixels between the two orders: {changed}");
}
}
}

View File

@@ -1,71 +0,0 @@
//! Of the forced instances the rule merely CONFIRMS, how many have a key READ
//! FROM THE FILE, and how many an IMPLIED key that is itself a measurement?
//!
//! `forced_backdrop_necessity.rs` asked only whether an element had *a* key,
//! collapsing `sprite_layer_key` (a `u16` read out of the `T8aD` header — decoded)
//! with `implied_layer_key` (this crate's per-name table of positions **measured
//! in the running game**). For counting whether the rule moves anything that is
//! the right question. For describing what a confirmation is *made of*, it is not:
//! "the file already settles it" and "another measurement already settles it" are
//! different claims, and a reader who sees "own key" will take the first.
//!
//! Raised by the port agent 2026-08-30.
//!
//! cargo run -p sylpheed-formats --example forced_backdrop_key_source
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
paks.sort();
let (mut read, mut implied, mut none) = (0usize, 0usize, 0usize);
println!("# archive entry element key_source key");
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for el in &b.elements {
if !ui_layout::forced_backdrop(&b, el) {
continue;
}
let (src, key) = match ui_layout::sprite_layer_key(&b, &by, el) {
Some(k) => {
read += 1;
("read_T8aD", Some(k))
}
None => match ui_layout::implied_layer_key(&el.name) {
Some(k) => {
implied += 1;
("implied_MEASURED", Some(k))
}
None => {
none += 1;
("none", None)
}
},
};
println!(
" {name} {i} {} {src} {}",
el.name,
key.map(|k| format!("0x{k:08X}")).unwrap_or("-".into())
);
}
}
}
println!("\n# forced instances by key source:");
println!("# read from the T8aD header (decoded): {read}");
println!("# implied — this crate's MEASURED name table: {implied}");
println!("# none — only forced_backdrop can speak: {none}");
}

View File

@@ -1,114 +0,0 @@
//! Which screens does `forced_backdrop` DECIDE, and which does it merely agree with?
//!
//! Every check this corpus has run on the rule measured its **stability** — that
//! no verdict moved when something else changed. That is a different property
//! from **necessity**: an element whose position is already fixed by a read or an
//! implied key is confirmed by the rule, not decided by it.
//!
//! So: compute `derived_paint_order` with the rule, and again with the
//! `forced_backdrop` fallback removed, and report every entry whose order moves.
//! Where nothing moves, the rule is decorative on that screen; where it moves,
//! the rule is the only thing holding the order up.
//!
//! Raised by the port agent 2026-08-30. Reach note in
//! `docs/re/structures/ui-forced-backdrop.md`.
//!
//! cargo run -p sylpheed-formats --example forced_backdrop_necessity -- [pak...]
//!
//! 🔴 With no argument this used to default to `GP_TITLE` alone, so a bare run
//! reported **6 instances, not 80** — a thirteenth of the census, printed in the
//! same format and reading like the whole thing. The port agent hit it and nearly
//! filed the discrepancy back at me. It now walks every `dat/*.pak` by default and
//! says on stderr how many archives it opened, because "I ran your instrument" has
//! to mean the same thing to both of us.
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
idx.sort_by_key(|&i| {
let el = &build.elements[i];
(
ui_layout::sprite_layer_key(build, bundle, el)
.or_else(|| ui_layout::implied_layer_key(&el.name))
.unwrap_or(u32::MAX),
i,
)
});
idx
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let (mut total_decides, mut total_agrees) = (0usize, 0usize);
let mut paks: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
if paks.is_empty() {
let mut all: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
all.sort();
paks = all;
}
eprintln!("# scanning {} archive(s)", paks.len());
for path in &paks {
let ar = PakArchive::open(path).expect("pak");
println!("# {}", path.display());
println!("# entry forced decides elements note");
let mut decides = Vec::new();
let mut agrees = Vec::new();
#[allow(unused)]
let _ = (&decides, &agrees);
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let with = ui_layout::derived_paint_order(&b, &by);
let without = order_without_rule(&b, &by);
// Which elements does the rule fire on, and of those, which have no key
// of their own to fall back on?
let mut forced = Vec::new();
let mut keyless = Vec::new();
for el in &b.elements {
if !ui_layout::forced_backdrop(&b, el) {
continue;
}
forced.push(el.name.clone());
let own = ui_layout::sprite_layer_key(&b, &by, el)
.or_else(|| ui_layout::implied_layer_key(&el.name));
if own.is_none() {
keyless.push(el.name.clone());
}
}
if forced.is_empty() {
continue;
}
let moved = with != without;
if moved {
decides.push(i);
} else {
agrees.push(i);
}
println!(
" {i:>5} {:>6} {:>7} {:>8} forced=[{}] keyless=[{}]",
forced.len(),
if moved { "YES" } else { "no" },
b.elements.len(),
forced.join(","),
keyless.join(","),
);
}
println!("# rule DECIDES the order on entries {decides:?}");
println!("# rule merely AGREES on entries {agrees:?}\n");
total_decides += decides.len();
total_agrees += agrees.len();
}
println!("# TOTAL over {} archive(s): {total_decides} deciding entries, \
{total_agrees} agreeing", paks.len());
}

View File

@@ -1,119 +0,0 @@
//! What does `forced_backdrop` cost IN PIXELS on the screens it decides?
//!
//! `forced_backdrop_necessity.rs` answers "does the derived ORDER move", which is
//! a property of the sort. The port agent then pointed out — correctly — that its
//! re-run of that probe was **my code executed twice**, not a second witness, so
//! the disc-wide 62 has one measurement behind it and only `GP_TITLE` has two.
//!
//! This does not fix that (it is still this crate), but it moves the question to a
//! **different layer**: render each deciding build twice, once in the order
//! `compose` derives and once with the `forced_backdrop` fallback removed, and
//! count the pixels that differ. "The order moved" and "the picture moved" are not
//! the same claim, and the second is the one anybody cares about — the tie-break
//! work already found overlapping reorders that cost exactly zero pixels.
//!
//! Each entry carries its own CONTROL: the pixel count of the composite itself.
//! If a build renders empty, its zero means the instrument saw nothing, not that
//! the rule is free.
//!
//! cargo run -p sylpheed-formats --example forced_backdrop_pixel_cost -- [pak...]
//!
//! With no argument it walks **every `dat/*.pak`** — the necessity probe defaulted
//! to `GP_TITLE`, which made a bare run report a thirteenth of the census and read
//! like the whole thing.
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
use ui_layout::ComposeOptions;
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
idx.sort_by_key(|&i| {
let el = &build.elements[i];
(
ui_layout::sprite_layer_key(build, bundle, el)
.or_else(|| ui_layout::implied_layer_key(&el.name))
.unwrap_or(u32::MAX),
i,
)
});
idx
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
if paks.is_empty() {
let mut all: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
all.sort();
paks = all;
}
eprintln!("# scanning {} archive(s)", paks.len());
let opts = ComposeOptions {
include_primitives: true,
backdrop: [0, 0, 0, 255],
..Default::default()
};
println!("# archive entry element changed_px total_px ink_px(control) pct");
let (mut decided, mut zero_cost, mut blind) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let with = ui_layout::derived_paint_order(&b, &by);
let without = order_without_rule(&b, &by);
if with == without {
continue;
}
let forced: Vec<&str> = b
.elements
.iter()
.filter(|el| ui_layout::forced_backdrop(&b, el))
.map(|el| el.name.as_str())
.collect();
let a = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&with));
let c = ui_layout::compose_with_order(&b, &by, opts.clone(), None, Some(&without));
let n = a
.rgba
.chunks_exact(4)
.zip(c.rgba.chunks_exact(4))
.filter(|(x, y)| x != y)
.count();
// Control: does this build put any ink down at all, against the bare
// backdrop? A build that renders to nothing cannot show a reorder.
let ink = a
.rgba
.chunks_exact(4)
.filter(|p| p[..3] != [0, 0, 0])
.count();
let total = a.rgba.len() / 4;
decided += 1;
if ink == 0 {
blind += 1;
} else if n == 0 {
zero_cost += 1;
}
println!(
" {name} {i} {} {n} {total} {ink} {:.2}%",
forced.join(","),
100.0 * n as f64 / total as f64
);
}
}
println!("\n# builds whose ORDER the rule decides: {decided}");
println!("# of those, costing ZERO pixels: {zero_cost}");
println!("# of those, BLIND (build renders no ink, control fails): {blind}");
}

View File

@@ -1,86 +0,0 @@
//! Do `ui_layout`'s two IN-RANGE fallbacks ever fire? Counted, disc-wide.
//!
//! An in-range fallback supplies a value that is legitimate, so no output can
//! distinguish it from the real thing and inspection cannot settle it. The only
//! question that has an answer is *how often does it fire*.
//!
//! ui_layout.rs:1681 kf.time.unwrap_or(0) -- 0 is a real keyframe time
//! (pose 0's time IS 0), so a fabricated one is invisible.
//! ui_layout.rs:1010 pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0
//! -- alpha 0 is legitimate, and it makes "no pose here"
//! read as "fully transparent", biasing an occlusion test
//! toward NOT occluded.
//!
//! (ui_layout.rs:973's `unwrap_or(0)` is NOT counted: it is guarded two lines
//! later by `if tmax == 0 { return false; }`, so 0 is handled, not assumed.)
//!
//! cargo run -p sylpheed-formats --example inrange_fallback_count
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::io::Write;
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut kf, mut untimed, mut builds) = (0u64, 0u64, 0u64);
let (mut queries, mut none_at) = (0u64, 0u64);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
builds += 1;
// (a) :1681 -- how many poses carry no time?
for el in &b.elements {
for k in &el.keyframes {
kf += 1;
if k.time.is_none() { untimed += 1 }
}
}
// (b) :1010 -- ask every element for a pose at every time that any
// element declares, which is the set the occlusion test draws from.
let mut times: Vec<u32> = b.elements.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)).collect();
times.sort_unstable(); times.dedup();
for el in &b.elements {
for &t in &times {
queries += 1;
if el.pose_at(t).is_none() { none_at += 1 }
}
}
}
print!("."); std::io::stdout().flush().ok();
}
println!();
println!("{builds} builds, {kf} keyframes");
println!(":1681 untimed poses (the fallback would fabricate t=0): {untimed}");
println!(":1010 pose_at queries {queries}, of which None (fallback reads a=0): {none_at}");
// NEGATIVE CONTROL. Both counters above report 0, and a zero is the result
// this corpus has learned to distrust most -- it reads clean rather than
// suspicious. So prove the detector CAN see a hit: ask every element for a
// pose at a time no build declares. If pose_at is total, `none_out` is 0 too
// and the 0 above means nothing.
let mut out_queries = 0u64;
let mut none_out = 0u64;
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
for &t in &[u32::MAX, 1_000_000u32] {
out_queries += 1;
if el.pose_at(t).is_none() { none_out += 1 }
}
}
}
}
println!("CONTROL pose_at at an undeclared time: {out_queries} queries, {none_out} None");
println!(" (if this is 0 the detector is blind and the 0 above is meaningless)");
println!("--- END ---");
}

View File

@@ -1,72 +0,0 @@
//! Do `+4` / `+8` = 180 mean MIRROR?
//!
//! The disc-wide census shows `+4` and `+8` are dominated by 180 and ±90, while
//! `+12` (the decoded screen-plane rotation) takes 157 distinct values including
//! odd ones. That shape says flips rather than free rotation.
//!
//! Structural test, no renderer involved: if 180 means "mirror", then the same
//! sprite should appear both with the field 0 and with it 180 **within one
//! build** -- a mirrored pair. Free-rotation semantics predicts no such pairing.
//!
//! CONTROL: the same search run on `+12`, which is decoded as a real rotation
//! and should NOT show a 0/180 pairing pattern of the same strength.
use sylpheed_formats::{pak, ui_layout};
use std::collections::{BTreeMap, BTreeSet};
fn main() {
let dir = std::env::args().nth(1).expect("<disc>/dat");
let mut paks: Vec<_> = std::fs::read_dir(&dir).expect("dir")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false)).collect();
paks.sort();
// field -> (pak, entry, sprite) -> set of values seen
let mut seen: [BTreeMap<(String, usize, String), BTreeSet<i32>>; 3] =
[BTreeMap::new(), BTreeMap::new(), BTreeMap::new()];
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
let pn = p.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().to_vec().iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
let mut groups: Vec<(String, Vec<ui_layout::Keyframe>)> =
b.elements.iter().map(|el| (el.name.clone(), el.keyframes.clone())).collect();
for el in &b.elements {
if let Some(&(off, size)) = b.records.get(&el.name) {
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
for le in &lb.elements {
groups.push((le.name.clone(), le.keyframes.clone()));
}
}
}
}
for (nm, ks) in groups {
for k in &ks {
let key = (pn.clone(), i, nm.clone());
seen[0].entry(key.clone()).or_default().insert(k.unknown_4);
seen[1].entry(key.clone()).or_default().insert(k.unknown_8);
seen[2].entry(key).or_default().insert(k.rotation_deg);
}
}
}
}
for (idx, label) in [(0, "+4"), (1, "+8"), (2, "+12 (rotation, CONTROL)")] {
let m = &seen[idx];
let mut pair_0_180 = 0usize; // a sprite seen at BOTH 0 and 180 in one build
let mut only_180 = 0usize;
let mut multi = 0usize; // more than two distinct values
for v in m.values() {
if v.len() > 2 { multi += 1; }
let has0 = v.contains(&0);
let has180 = v.contains(&180) || v.contains(&-180);
if has0 && has180 { pair_0_180 += 1; }
else if has180 && !has0 { only_180 += 1; }
}
println!("{label}: {} sprite-instances", m.len());
println!(" both 0 and ±180 in one build : {pair_0_180}");
println!(" ±180 without any 0 : {only_180}");
println!(" more than 2 distinct values : {multi}");
}
}

View File

@@ -1,19 +0,0 @@
use sylpheed_formats::{pak, ui_layout};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap(); let bi:usize=a.next().unwrap().parse().unwrap();
let ar=pak::PakArchive::open(pk).unwrap();
let by=ar.read(&ar.entries()[bi]).unwrap();
let b=ui_layout::parse_build(&by).unwrap();
for t in a {
let i:usize=t.parse().unwrap();
let e=&b.elements[i];
println!("[{i}] {} kind=0x{:x} parent={:?} pivot=({},{}) kfs={}",
e.name, e.kind, e.parent, e.pivot_x, e.pivot_y, e.keyframes.len());
for k in &e.keyframes {
println!(" t={:<5} a={:<4} xy=({},{}) s={}/{} rot={} u4={} u8={} tint={:08x}",
k.time.map(|v|v as i64).unwrap_or(-1), k.fade>>24, k.x,k.y,
k.scale_x,k.scale_y,k.rotation_deg,k.unknown_4,k.unknown_8,k.tint);
}
}
}

View File

@@ -1,83 +0,0 @@
//! What are the keyframe block's `+4` and `+8`?
//!
//! `+12` is decoded as a screen-plane rotation in degrees. `+4` and `+8` sit
//! immediately before it and are carried but unexplained; one standing 🟡
//! reading is that the three together are rotations about three axes, "not tied
//! to an observed rotation". This censuses them across every UI pak on the disc
//! so the reading can be argued with rather than assumed.
use sylpheed_formats::{pak, ui_layout};
use std::collections::BTreeMap;
fn main() {
let dir = std::env::args().nth(1).expect("usage: <disc>/dat");
let mut paks: Vec<_> = std::fs::read_dir(&dir)
.expect("dir")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
.collect();
paks.sort();
let mut h4: BTreeMap<i32, usize> = BTreeMap::new();
let mut h8: BTreeMap<i32, usize> = BTreeMap::new();
let mut h12: BTreeMap<i32, usize> = BTreeMap::new();
let mut both_nz: Vec<String> = Vec::new();
let (mut kfs, mut builds) = (0usize, 0usize);
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
let name = p.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().to_vec().iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
builds += 1;
// parents and leaves alike
let mut groups: Vec<(String, Vec<ui_layout::Keyframe>)> = b
.elements.iter().map(|el| (el.name.clone(), el.keyframes.clone())).collect();
for el in &b.elements {
if let Some(&(off, size)) = b.records.get(&el.name) {
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
for le in &lb.elements {
groups.push((format!("{}->{}", el.name, le.name), le.keyframes.clone()));
}
}
}
}
for (nm, ks) in groups {
for k in &ks {
kfs += 1;
*h4.entry(k.unknown_4).or_default() += 1;
*h8.entry(k.unknown_8).or_default() += 1;
*h12.entry(k.rotation_deg).or_default() += 1;
if k.unknown_4 != 0 || k.unknown_8 != 0 {
both_nz.push(format!("{name} e{i} {nm} +4={} +8={} +12={}",
k.unknown_4, k.unknown_8, k.rotation_deg));
}
}
}
}
}
println!("{builds} builds, {kfs} keyframes (parents + leaves)\n");
for (nm, h) in [("+4", &h4), ("+8", &h8), ("+12 (rotation)", &h12)] {
let nz: usize = h.iter().filter(|(k, _)| **k != 0).map(|(_, v)| *v).sum();
println!("{nm}: {} distinct values, {} non-zero keyframes ({:.4}%)",
h.len(), nz, 100.0 * nz as f64 / kfs as f64);
let mut top: Vec<_> = h.iter().filter(|(k, _)| **k != 0).collect();
top.sort_by_key(|(_, v)| std::cmp::Reverse(**v));
for (k, v) in top.iter().take(6) { println!(" {k:>8} x{v}"); }
}
println!("\nkeyframes with a non-zero +4 or +8: {}", both_nz.len());
// Per-pak, so a reader can ask whether a pak they have a CAPTURE of is
// among them -- which decides whether the field is testable at all.
let mut per: BTreeMap<String, usize> = BTreeMap::new();
for l in &both_nz {
let pak = l.split_whitespace().next().unwrap_or("?").to_string();
*per.entry(pak).or_default() += 1;
}
println!(" by pak:");
for (k, v) in &per { println!(" {k:<28} {v}"); }
println!(" paks with NONE: (any UI pak not listed above)");
if let Ok(f) = std::env::var("KF_SHOW") {
println!("\n all lines for {f}:");
for l in both_nz.iter().filter(|l| l.starts_with(&f)) { println!(" {l}"); }
}
}

View File

@@ -1,56 +0,0 @@
//! How do a parent record's alpha ramp and its nested `.rat` leaf's compose?
//!
//! The port emits both but will not draw the leaf without knowing the rule --
//! rightly, since drawing on a guess trades a visible 1.82 % error for an
//! invisible wrong one. There is an oracle for this: the GPU draw capture
//! records **vertex colours**, and on the title's `ptloop` draw they are
//! `C3FFFFFF` and `B6FFFFFF` -- alpha **195** and **182**, not 255. So the game's
//! composed alpha is observable, and a candidate rule either predicts those two
//! numbers or does not.
//!
//! cargo run -p sylpheed-formats --example leaf_alpha_compose -- <GP_TITLE.pak>
use sylpheed_formats::{pak, ui_layout};
fn alpha_of(kf: &ui_layout::Keyframe) -> u32 {
// The keyframe block's +0 is an ARGB fade colour; alpha is its high byte.
kf.fade >> 24
}
fn main() {
let path = std::env::args().nth(1).expect("usage: <pak>");
let ar = pak::PakArchive::open(&path).expect("open");
let e = &ar.entries()[4]; // GP_TITLE entry 4 = the English title
let bytes = ar.read(e).expect("read");
let build = ui_layout::parse_build(&bytes).expect("build");
for name in ["ptloop01.rat", "ptloop02.rat"] {
println!("\n=== {name} ===");
if let Some(el) = build.elements.iter().find(|x| x.name == name) {
println!(" PARENT keyframes (t, alpha, scale, rot, x,y):");
for k in &el.keyframes {
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} ({},{})",
k.time.map(|t| t as i64).unwrap_or(-1), alpha_of(k), k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y);
}
}
match build.records.get(name) {
Some(&(off, size)) => {
let leaf = &bytes[off..off + size];
match ui_layout::parse_build(leaf) {
Some(lb) => {
for le in &lb.elements {
println!(" LEAF element {:?}:", le.name);
for k in &le.keyframes {
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} ({},{})",
k.time.map(|t| t as i64).unwrap_or(-1), alpha_of(k), k.scale_x, k.scale_y,
k.rotation_deg, k.x, k.y);
}
}
}
None => println!(" leaf did not parse"),
}
}
None => println!(" no leaf record"),
}
}
println!("\nOBSERVED in the draw capture: quad A alpha 195 (0xC3), quad B alpha 182 (0xB6)");
}

View File

@@ -1,42 +0,0 @@
//! Dump a record's parent keyframes and its nested `.rat` leaf's, for any entry.
//!
//! cargo run -p sylpheed-formats --example leaf_dump -- <pak> <entry> <name>
use sylpheed_formats::{pak, ui_layout};
fn a(k: &ui_layout::Keyframe) -> u32 { k.fade >> 24 }
fn t(k: &ui_layout::Keyframe) -> i64 { k.time.map(|v| v as i64).unwrap_or(-1) }
fn show(tag: &str, els: &[ui_layout::Element]) {
for e in els {
println!(" {tag} {:?} pivot=({},{}) kind={:#x}", e.name, e.pivot_x, e.pivot_y, e.kind);
for k in &e.keyframes {
println!(" t={:<5} a={:<4} scale=({},{}) rot={:<5} pos=({},{}) tint={:#010x}",
t(k), a(k), k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y, k.tint);
}
}
}
fn main() {
let mut it = std::env::args().skip(1);
let path = it.next().expect("pak");
let entry: usize = it.next().expect("entry").parse().expect("entry");
let want = it.next().expect("name");
let ar = pak::PakArchive::open(&path).expect("open");
let bytes = ar.read(&ar.entries()[entry]).expect("read");
let b = ui_layout::parse_build(&bytes).expect("build");
println!("entry {entry}: {} elements, design {}x{}", b.elements.len(), b.design_w, b.design_h);
let els: Vec<_> = b.elements.iter().filter(|e| e.name.contains(&want)).cloned().collect();
show("PARENT", &els);
for e in &els {
match b.records.get(&e.name) {
Some(&(off, size)) => {
println!(" -- leaf of {:?}: {size} B at {off}", e.name);
match ui_layout::parse_build(&bytes[off..off + size]) {
Some(lb) => show(" LEAF", &lb.elements),
None => println!(" leaf did not parse"),
}
}
None => println!(" -- {:?} has NO leaf record", e.name),
}
}
}

View File

@@ -1,20 +0,0 @@
use sylpheed_formats::{pak, ui_layout};
fn main(){
let ar=pak::PakArchive::open(std::env::args().nth(1).unwrap()).unwrap();
let by=ar.read(&ar.entries()[4]).unwrap();
let b=ui_layout::parse_build(&by).unwrap();
for n in ["ptloop01.rat","ptloop02.rat"] {
let el=b.elements.iter().find(|e| e.name==n).unwrap();
println!("{n}: kind={:#x} animated={} rec={:?}", el.kind, el.animated,
b.records.get(n).map(|&(o,s)|(o,s)));
if let Some(&(o,s))=b.records.get(n) {
match ui_layout::parse_build(&by[o..o+s]) {
Some(lb)=>for le in &lb.elements {
println!(" leaf {:?} sprite={:?} in_sprites={}", le.name, le.sprite,
le.sprite.as_ref().map(|x| b.sprites.contains_key(x)).unwrap_or(false));
},
None=>println!(" leaf parse FAILED"),
}
}
}
}

View File

@@ -1,26 +0,0 @@
// Leave-one-out over the elements that touch a band, dumping raw RGBA each time.
use sylpheed_formats::{pak, ui_layout};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap();
let bi:usize=a.next().unwrap().parse().unwrap();
let dir=a.next().unwrap();
let ar=pak::PakArchive::open(pk).unwrap();
let by=ar.read(&ar.entries()[bi]).unwrap();
let b=ui_layout::parse_build(&by).unwrap();
let o=ui_layout::ComposeOptions{ backdrop:[0,0,0,255], ..Default::default() };
let n=b.elements.len();
let base=ui_layout::compose(&b,&by,o.clone(),None);
std::fs::write(format!("{dir}/base.raw"),&base.rgba).unwrap();
println!("canvas {}x{} elements {n}", base.width, base.height);
for i in 0..n {
let e=&b.elements[i];
let Some(kf)=e.rest() else { continue };
// only bother with elements whose rest pose can touch the band
let mut v=vec![true;n]; v[i]=false;
let c=ui_layout::compose(&b,&by,o.clone(),Some(&v));
std::fs::write(format!("{dir}/wo-{i}.raw"),&c.rgba).unwrap();
println!("{i}\t{}\t{}\t({},{})\ta={}", e.name,
e.sprite.clone().unwrap_or_default(), kf.x, kf.y, kf.fade>>24);
}
}

View File

@@ -1,46 +0,0 @@
//! Recover a `sound.pak` TOC name from its hash by generating candidates.
//!
//! The hash is a Barrett-reduction over the uppercased path, so it cannot be
//! inverted — but the naming is regular enough to enumerate. Tries the shapes
//! this disc actually uses for sound entries.
//!
//! cargo run -p sylpheed-formats --example name_from_hash -- <hex-hash>…
use sylpheed_formats::hash::name_hash;
fn main() {
let wanted: Vec<u32> = std::env::args()
.skip(1)
.filter_map(|a| u32::from_str_radix(a.trim_start_matches("0x"), 16).ok())
.collect();
assert!(!wanted.is_empty(), "give hashes in hex");
let langs = ["eng", "jpn", ""];
let dirs = ["", "Movie", "etc", "Voice", "Sound", "BGM", "bgm", "se", "SE"];
let mut tried = 0usize;
let mut check = |name: String, tried: &mut usize| {
*tried += 1;
let h = name_hash(&name);
if wanted.contains(&h) {
println!("{h:08x} {name}");
}
};
for l in langs {
for d in dirs {
let pre = match (l.is_empty(), d.is_empty()) {
(true, true) => String::new(),
(true, false) => format!("{d}\\"),
(false, true) => format!("{l}\\"),
(false, false) => format!("{l}\\{d}\\"),
};
for stem in ["BGM", "bgm", "JNGL", "jngl", "SE", "Static", "VOICE"] {
for n in 0..1200u32 {
check(format!("{pre}{stem}_{n:03}.slb"), &mut tried);
check(format!("{pre}{stem}{n:03}.slb"), &mut tried);
}
}
for bare in ["Static.slb", "static.slb", "SE.slb", "BGM.slb"] {
check(format!("{pre}{bare}"), &mut tried);
}
}
}
println!("tried {tried} candidate names");
}

View File

@@ -1,63 +0,0 @@
//! Where does `screen --build N`'s ORDINAL diverge from the pak ENTRY index?
//!
//! `screen render --build N` takes an ordinal into the filtered build list, not
//! a pak entry. On `GP_TITLE` `[10]` is entry 12, which is how I rendered two
//! loading screens while believing they were the splashes — and every downstream
//! number validated. This enumerates the divergence across the disc so any
//! `--build N` in `docs/` can be checked instead of trusted.
//!
//! Two lists, because `screen list --all` swaps the predicate (`is_composable`
//! for `is_build`) and therefore RENUMBERS: `--build 4` and `--build 4 --all`
//! are not necessarily the same object.
//!
//! cargo run -p sylpheed-formats --example ordinal_entry_map
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::io::Write;
use std::path::PathBuf;
/// One decompression pass per entry, both predicates applied to it: reading the
/// archive twice doubled the cost on `GP_READY_ROOM` (902 entries) for nothing.
fn maps(ar: &PakArchive) -> (Vec<usize>, Vec<usize>) {
let (mut d, mut a) = (Vec::new(), Vec::new());
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if ui_layout::is_build(&by) { d.push(i) }
if ui_layout::is_composable(&by) { a.push(i) }
}
(d, a)
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut clean, mut div, mut allshift) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
let (d, a) = maps(&ar);
if d.is_empty() && a.is_empty() { continue }
let bad = d.iter().enumerate().find(|(o, &e)| *o != e).map(|(o, _)| o);
// does `--all` renumber? compare the entry each ordinal resolves to
let shift = (0..d.len().min(a.len())).find(|&o| d[o] != a[o]);
let tag = match bad {
None => { clean += 1; format!("{:4} builds ordinal == entry throughout", d.len()) }
Some(o) => { div += 1;
let t: Vec<String> = d.iter().enumerate().skip(o).take(5)
.map(|(x, &y)| format!("[{x}]->{y}")).collect();
format!("{:4} builds 🔴 diverges at ordinal {o}: {}", d.len(), t.join(" ")) }
};
let s = match shift {
Some(o) => { allshift += 1;
format!(" ⚠️ --all renumbers from [{o}]: entry {} -> {}", d[o], a[o]) }
None if a.len() != d.len() => format!(" (--all appends {} more)", a.len() - d.len()),
None => String::new(),
};
println!("{name:30} {tag}{s}");
std::io::stdout().flush().ok();
}
println!("\n{clean} archives ordinal==entry, {div} diverge, {allshift} renumbered by --all");
println!("--- END ---");
}

View File

@@ -1,40 +0,0 @@
//! Are `palogo_gamearts_eff` / `palogo_seta_eff` dwell-FALLBACK cases, or PLATEAU
//! cases? The port agent lists them among `GP_TITLE`'s four visible fallback
//! fires; this census listed only `palogo_sqex_eff` and `palogo_anima_eff`.
//!
//! It matters because the two are different defects. A plateau is a pose the
//! element genuinely HOLDS, and `rest_plateau()` returning it is correct. Only the
//! fallback is the unsound path.
//! cargo run -p sylpheed-formats --example palogo_eff_check
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if !el.name.starts_with("palogo") || !el.name.contains("eff") { continue }
// A single-keyframe element has no gap to maximise, so neither path
// applies and `rest()` trivially returns the only pose. Excluding it
// here matches the census, which filters `len < 2`.
if el.keyframes.len() < 2 { continue }
let plateau = el.keyframes.windows(2).position(|w| {
w[0].x == w[1].x && w[0].y == w[1].y && w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y && w[0].fade == w[1].fade
});
let ks: Vec<String> = el.keyframes.iter().map(|k| format!(
"{}:a{} {},{} {}%",
k.time.map(|v| v.to_string()).unwrap_or("-".into()),
(k.fade >> 24) & 0xff, k.x, k.y, k.scale_x)).collect();
let r = el.rest();
println!("e{i:<3} {:24} kf=[{}]", el.name, ks.join(" "));
println!(" plateau at pair {:?} -> path: {} rest a={} t={:?}",
plateau,
if plateau.is_some() { "PLATEAU (sound: the pose is held)" } else { "DWELL FALLBACK (unsound)" },
r.map(|k| (k.fade >> 24) & 0xff).unwrap_or(0),
r.and_then(|k| k.time));
}
}
}

View File

@@ -1,90 +0,0 @@
//! When an element has MORE THAN ONE plateau, does `rest_plateau()` pick the
//! wrong one — and is that the 21.9 % residual?
//!
//! `rest_vs_settle` found that among elements holding a pose ACROSS the screen's
//! settle instant, `pose_at(settle)` and `rest()` still disagree 21.9 % of the
//! time. I hypothesised that `rest_plateau()` picks the **longest** run (it does —
//! `len >= any_len`), which need not be the run covering the settle instant.
//!
//! ⚠️ **Control**: on elements with exactly ONE plateau that covers the settle
//! instant, the two MUST agree. If they do not, the hypothesis is not the
//! explanation and something else is wrong.
//!
//! cargo run -p sylpheed-formats --example plateau_choice
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut one_cov, mut one_agree) = (0usize, 0usize); // control
let (mut multi_cov, mut multi_agree, mut multi_wrongrun) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
let Some((lo, hi)) = b.settle_window() else { continue };
if hi - lo < 10 { continue }
let st = lo + (hi - lo) / 2;
for el in &b.elements {
let k = &el.keyframes;
if k.len() < 2 { continue }
let same = |a: &ui_layout::Keyframe, c: &ui_layout::Keyframe| {
a.fade == c.fade && a.scale_x == c.scale_x && a.scale_y == c.scale_y
&& a.tint == c.tint && a.x == c.x && a.y == c.y
};
// enumerate maximal runs of length >= 2, with their time spans
let mut runs: Vec<(usize, usize)> = Vec::new();
let mut i = 0usize;
while i < k.len() {
let mut j = i;
while j + 1 < k.len() && same(&k[j], &k[j + 1]) { j += 1 }
if j - i + 1 >= 2 { runs.push((i, j)) }
i = j + 1;
}
if runs.is_empty() { continue }
let covers = |&(a, c): &(usize, usize)| match (k[a].time, k[c].time) {
(Some(t0), Some(t1)) => t0 <= st && st <= t1,
_ => false,
};
let covering: Vec<_> = runs.iter().filter(|r| covers(r)).collect();
if covering.is_empty() { continue }
let (Some(r), Some(s)) = (el.rest(), el.pose_at(st)) else { continue };
let agree = r.fade == s.fade && r.x == s.x && r.y == s.y
&& r.scale_x == s.scale_x && r.scale_y == s.scale_y;
if runs.len() == 1 {
one_cov += 1;
if agree { one_agree += 1 }
} else {
multi_cov += 1;
if agree { multi_agree += 1 }
else {
// did rest() land on a run that does NOT cover settle?
let on_covering = covering.iter().any(|&&(a, c)| {
(a..=c).any(|idx| {
let kk = &k[idx];
kk.fade == r.fade && kk.x == r.x && kk.y == r.y
&& kk.scale_x == r.scale_x && kk.scale_y == r.scale_y
})
});
if !on_covering { multi_wrongrun += 1 }
}
}
}
}
}
println!("CONTROL — exactly ONE plateau, and it covers the settle instant:");
println!(" {one_cov} elements, rest() and pose_at(settle) agree on {one_agree} ({:.1} %)",
100.0 * one_agree as f64 / one_cov.max(1) as f64);
println!("\nTEST — MORE THAN ONE plateau, at least one covering the settle instant:");
println!(" {multi_cov} elements, agree on {multi_agree} ({:.1} %)",
100.0 * multi_agree as f64 / multi_cov.max(1) as f64);
println!(" of the {} disagreements, rest() landed on a run that does NOT cover",
multi_cov - multi_agree);
println!(" the settle instant: {multi_wrongrun}");
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -1,50 +0,0 @@
//! Does a primitive's alpha AT t=0 predict the layer it paints on?
//!
//! `implied_layer_key` is a measured per-name table. The four entries in it, read
//! against their own keyframes, suggest a rule derived from the file instead:
//!
//! * `palogo_eff0.prm` measured FIRST (0x0000) -- alpha at t=0 = ?
//! * `pfbase.tbm` measured FIRST (0x0000) -- alpha at t=0 = ?
//! * `pteff02.prm` measured MIDDLE (0x8030) -- alpha at t=0 = ?
//! * `pteff00.prm` measured LAST -- alpha at t=0 = ?
//!
//! ⚠️ The rule was invented AFTER seeing three of those answers, so it is fitted
//! on them and only `pfbase.tbm` is out of sample. This prints all four plus a
//! disc-wide census, so the fit and its reach are visible together.
use sylpheed_formats::{pak, ratc, ui_layout};
use std::collections::BTreeMap;
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
// name -> (count, set of t=0 alphas, set of "starts at max" flags)
let mut byname: BTreeMap<String, (usize, BTreeMap<u32, usize>)> = BTreeMap::new();
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
// primitives and the keyless: anything with no layer key
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
let Some(k0) = el.keyframes.first() else { continue };
let a0 = k0.fade >> 24;
let ent = byname.entry(el.name.clone()).or_default();
ent.0 += 1;
*ent.1.entry(a0).or_default() += 1;
}
}
}
println!("{:>28} {:>7} alpha at t=0 (count)", "keyless element", "n");
let known = ["palogo_eff0.prm", "pfbase.tbm", "pteff02.prm", "pteff00.prm"];
for (n, (c, a)) in &byname {
let tag = if known.contains(&n.as_str()) { " <- IN THE MEASURED TABLE" } else { "" };
if *c < 4 && tag.is_empty() { continue }
let al: Vec<String> = a.iter().map(|(k, v)| format!("{k}x{v}")).collect();
println!(" {n:>26} {c:>7} {}{tag}", al.join(" "));
}
}

View File

@@ -1,37 +0,0 @@
//! What COLOUR is a primitive, and does any of them only make sense additively?
//!
//! `ui-prm-primitives.md` leaves blend mode open, and `forced_backdrop` assumes
//! straight alpha-over. A quad whose ARGB would tint the whole screen a colour no
//! screen shows is evidence against alpha-over for that quad.
use sylpheed_formats::{pak, ratc, ui_layout};
use std::collections::BTreeMap;
fn main(){
let root=std::env::var("SYLPHEED_DISC").unwrap_or_else(|_|"/disc".into());
let mut paks:Vec<_>=std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e|e.path())
.filter(|p|p.extension().and_then(|s|s.to_str())==Some("pak")).collect();
paks.sort();
let mut m:BTreeMap<String,BTreeMap<String,usize>>=BTreeMap::new();
for p in &paks {
let Ok(ar)=pak::PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by)=ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b)=ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if el.sprite.is_some() { continue }
for k in &el.keyframes {
*m.entry(el.name.clone()).or_default()
.entry(format!("{:08x}", k.fade)).or_default()+=1;
}
}
}
}
println!("{:>26} fade ARGB values (count)", "keyless element");
for (n,v) in &m {
let tot:usize=v.values().sum();
if tot<4 { continue }
let s:Vec<String>=v.iter().map(|(k,c)|format!("{k}x{c}")).collect();
println!(" {n:>24} {}", s.join(" "));
}
}

View File

@@ -1,58 +0,0 @@
//! Which keyless primitives have their paint position FORCED by occlusion?
//!
//! An opaque full-screen quad must sort below every element visible at any
//! instant it is opaque. Where that set is *every* other element, its position is
//! forced to first — derived from the file, not analogised from a neighbour.
//!
//! Controls, both measured in the running game and both reproduced here:
//! * `palogo_eff0.prm` is measured painting FIRST — and comes out forced first.
//! * `pteff00.prm` is measured painting LAST — and is forced below only a
//! handful, so the constraint permits it on top.
//!
//! ⚠️ Assumes straight alpha-over blending. Blend mode is ❔ in
//! `ui-prm-primitives.md`; an additive quad at alpha 255 would not occlude.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut forced, mut partial, mut free) = (0usize, 0usize, 0usize);
let mut names: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
let tmax = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0);
if tmax == 0 { continue }
for el in &b.elements {
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
// full-screen only: a quad that does not cover cannot occlude
if el.pivot_x * 2 < 1280 || el.pivot_y * 2 < 720 { continue }
let op: Vec<u32> = (0..=tmax)
.filter(|&t| el.pose_at(t).map(|k| k.fade >> 24) == Some(255)).collect();
if op.is_empty() { continue }
let others: Vec<&ui_layout::Element> =
b.elements.iter().filter(|o| o.index != el.index).collect();
if others.is_empty() { continue }
let below = others.iter().filter(|o|
op.iter().any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0)).count();
let ent = names.entry(el.name.clone()).or_default();
ent.1 += 1;
if below == others.len() { forced += 1; ent.0 += 1 }
else if below > 0 { partial += 1 } else { free += 1 }
}
}
}
println!("keyless FULL-SCREEN primitives with an opaque interval:");
println!(" position FORCED FIRST (below every other element) : {forced}");
println!(" forced below SOME but not all : {partial}");
println!(" occludes nothing : {free}");
println!("\nby name — instances forced first / total:");
for (n, (f, t)) in &names { println!(" {n:>24} {f:>4} / {t}"); }
}

View File

@@ -1,73 +0,0 @@
//! An OPAQUE full-screen primitive cannot paint on top of elements that are
//! visible at the same time — the screen would be blank.
//!
//! That is a constraint read off the file, not a preference. For each keyless
//! primitive this computes the interval over which it is opaque, and the interval
//! over which any OTHER element is visible, and reports the overlap.
//!
//! The falsifier: `pteff00.prm` is MEASURED painting last on the title and the
//! main menu. If any of its instances is opaque while content is up, the
//! constraint is wrong and this whole line is dead.
use sylpheed_formats::{pak, ratc, ui_layout};
/// Interval(s) where alpha >= `thr`, sampled at every half unit.
fn opaque_span(el: &ui_layout::Element, thr: u32, tmax: u32) -> Vec<(f64, f64)> {
let mut out = Vec::new();
let mut cur: Option<f64> = None;
let mut t = 0.0;
while t <= tmax as f64 {
let a = el.pose_at(t as u32).map(|k| k.fade >> 24).unwrap_or(0);
if a >= thr {
if cur.is_none() { cur = Some(t) }
} else if let Some(s) = cur.take() {
out.push((s, t));
}
t += 0.5;
}
if let Some(s) = cur { out.push((s, tmax as f64)) }
out
}
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let want = ["pteff00.prm", "pgloading_eff00.prm", "palogo_eff0.prm",
"pfbase.tbm", "pteff02.prm", "pzeff00.prm", "pceff00.prm", "pdeff00.prm"];
println!("{:>22} {:>5} {:>16} {:>18} overlap", "primitive", "entry", "opaque while", "content visible");
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for (ei, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
let tmax = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0);
if tmax == 0 { continue }
for el in &b.elements {
if !want.contains(&el.name.as_str()) { continue }
if ui_layout::sprite_layer_key(&b, &by, el).is_some() { continue }
let op = opaque_span(el, 255, tmax);
if op.is_empty() { continue }
// when is any OTHER element visible?
let mut cmin = f64::MAX; let mut cmax = f64::MIN;
for o in &b.elements {
if o.index == el.index { continue }
for (s, t) in opaque_span(o, 1, tmax) { cmin = cmin.min(s); cmax = cmax.max(t) }
}
if cmin > cmax { continue }
// overlap of the primitive's opaque span with the content span
let ov: f64 = op.iter()
.map(|&(s, t)| (t.min(cmax) - s.max(cmin)).max(0.0)).sum();
let opd: String = op.iter().map(|&(s,t)| format!("{s:.0}-{t:.0}")).collect::<Vec<_>>().join(",");
let flag = if ov > 2.0 { " 🔴 CANNOT BE ON TOP" } else { "" };
println!("{:>22} {:>5} {:>16} {:>18} {ov:6.1}{flag}",
el.name, format!("{}:{}", p.file_name().unwrap().to_string_lossy()
.trim_end_matches(".pak").trim_start_matches("GP_"), ei),
opd, format!("{cmin:.0}-{cmax:.0}"));
}
}
}
}

View File

@@ -1,82 +0,0 @@
//! Does `forced_backdrop`'s verdict depend on how the screen's timeline ENDS?
//!
//! The rule quantifies over "every instant the primitive is opaque" and "every
//! element visible then", so both halves depend on where the timeline stops and
//! on what an element does after its own last keyframe. The port asked, and it is
//! the right question: a verdict that flips with the convention is not a decode.
//!
//! Four conventions, all applied to the same disc:
//! A span = max keyframe time over all elements; elements HOLD their last pose
//! (what `forced_backdrop` does, and what the port implements)
//! B span = the primitive's OWN last keyframe time; elements hold
//! C span = the bundle header `+0x08` (the declared length); elements hold
//! D span = max keyframe time; an element is GONE after its own last keyframe
//!
//! D is the one worth the most: it is the assumption the port flagged as "doing
//! real work", and it strictly shrinks the visible set, so it can only turn
//! `forced` into `not forced`.
use sylpheed_formats::{pak, ratc, ui_layout};
fn last_t(el: &ui_layout::Element) -> u32 {
el.keyframes.iter().filter_map(|k| k.time).max().unwrap_or(0)
}
fn forced(b: &ui_layout::UiBuild, el: &ui_layout::Element, tmax: u32, hold: bool) -> Option<bool> {
if el.sprite.is_some() { return None }
if (el.pivot_x * 2) < b.design_w as u32 || (el.pivot_y * 2) < b.design_h as u32 { return None }
if tmax == 0 { return None }
let alpha = |e: &ui_layout::Element, t: u32| -> u32 {
if !hold && t > last_t(e) { return 0 }
e.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0)
};
let op: Vec<u32> = (0..=tmax).filter(|&t| alpha(el, t) == 255).collect();
if op.is_empty() { return None }
let others: Vec<&ui_layout::Element> = b.elements.iter().filter(|o| o.index != el.index).collect();
if others.is_empty() { return None }
let below = others.iter().filter(|o| op.iter().any(|&t| alpha(o, t) > 0)).count();
Some(below == others.len())
}
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut n, mut a_true) = (0usize, 0usize);
let mut flips = [0usize; 3];
let mut examples: Vec<String> = Vec::new();
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for (ei, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
let tall = b.elements.iter().flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0);
let hdr = if by.len() >= 12 { u32::from_be_bytes(by[8..12].try_into().unwrap()) } else { 0 };
for el in &b.elements {
let Some(va) = forced(&b, el, tall, true) else { continue };
n += 1; if va { a_true += 1 }
for (k, vb) in [forced(&b, el, last_t(el), true),
forced(&b, el, hdr, true),
forced(&b, el, tall, false)].into_iter().enumerate() {
if vb != Some(va) {
flips[k] += 1;
if k == 2 && examples.len() < 6 {
examples.push(format!("{}:{} {} A={va} D={vb:?}",
p.file_name().unwrap().to_string_lossy(), ei, el.name));
}
}
}
}
}
}
println!("keyless full-screen primitives with an opaque interval: {n}");
println!(" convention A (span = all elements' max, hold) -> forced first: {a_true}\n");
println!(" verdicts that CHANGE under:");
println!(" B span = the primitive's own last keyframe : {}", flips[0]);
println!(" C span = the header's declared length +0x08 : {}", flips[1]);
println!(" D elements GONE after their last keyframe : {}", flips[2]);
for e in &examples { println!(" {e}") }
}

View File

@@ -1,65 +0,0 @@
//! The FULL extent of the two title sweep leaves, across their whole cycle.
//!
//! `ptloop_leaf_sweep_at.rs` samples t=340..540 — a window chosen to compare two
//! competing fits — so it never showed how far the leaves travel. That gap let a
//! claim stand that `ptloop01/02` "do not free-run", measured over the PARENT's
//! 200x90 rect, which is a pivot anchor the leaf spends almost no time inside.
//! `sylpheed-port` reports x tracks of -639..1521 and -839..1721 from their
//! export; this checks that against the disc.
//!
//! cargo run -p sylpheed-formats --example ptloop_leaf_extent
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
for entry in [4usize, 5, 7] {
let Ok(by) = ar.read(&ar.entries()[entry]) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
println!("\n######## GP_TITLE entry {entry} ########");
for parent in ["ptloop01", "ptloop02"] {
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else {
println!(" {parent}: not present in this build"); continue };
let Some(&(off, size)) = b.records.get(&el.name) else {
println!(" {}: no nested record", el.name); continue };
let span = u32::from_be_bytes(by[off + 8..off + 12].try_into().unwrap());
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else {
println!(" {}: leaf will not parse", el.name); continue };
println!(" {} parent rest ({},{}) nested cycle span {span}",
el.name, el.keyframes.last().map(|k| k.x).unwrap_or(0),
el.keyframes.last().map(|k| k.y).unwrap_or(0));
for le in &lb.elements {
let (mut lo, mut hi) = (i64::MAX, i64::MIN);
let (mut sxs, mut sys) = (Vec::new(), Vec::new());
for t in 0..=span {
if let Some(k) = le.pose_at(t) {
lo = lo.min(k.x as i64); hi = hi.max(k.x as i64);
if !sxs.contains(&k.scale_x) { sxs.push(k.scale_x) }
if !sys.contains(&k.scale_y) { sys.push(k.scale_y) }
}
}
// A CYCLE LENGTH IS NOT A MOTION DURATION. Find the last t at
// which x still changes: sylpheed-port reports the final segment
// HOLDS, which would make px/unit larger than cycle-based maths.
let mut last_move = 0u32;
let mut prev = None;
for t in 0..=span {
if let Some(k) = le.pose_at(t) {
if prev.map_or(false, |p| p != k.x) { last_move = t }
prev = Some(k.x);
}
}
let w = (le.pivot_x * 2) as i64;
println!(" leaf {:<12} pivot {}x{} quad w={w} x track {lo} .. {hi} \
(centre {} .. {}) scale_x {:?} scale_y {:?}",
le.name, le.pivot_x, le.pivot_y,
lo + le.pivot_x as i64, hi + le.pivot_x as i64, sxs, sys);
println!(" motion ends at t={last_move} of a {span}-unit cycle -> {:.3} px/unit over the MOVING span (vs {:.3} over the cycle)",
(hi - lo) as f64 / last_move.max(1) as f64,
(hi - lo) as f64 / span as f64);
}
}
}
println!("--- END ---");
}

View File

@@ -1,37 +0,0 @@
//! The sweep leaves' RAW keyframes, so a segment rate can be checked not assumed.
//!
//! `sylpheed-port` reports `pteff03` as +4.0000 px/unit over t 0..150 and +4.0000
//! again over 150..540 -- perfectly linear -- against `pteff03a` at -4.0667 then
//! -4.0625. That asymmetry is what makes their "inversion" observation sharp: my
//! linearity gate fails on the leaf whose source is exactly straight. It is their
//! number from their export, so it is worth deriving independently.
//!
//! cargo run -p sylpheed-formats --example ptloop_leaf_keyframes
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let by = ar.read(&ar.entries()[4]).expect("entry 4");
let b = ui_layout::parse_build(&by).expect("parse");
for parent in ["ptloop01", "ptloop02"] {
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else { continue };
let Some(&(off, size)) = b.records.get(&el.name) else { continue };
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else { continue };
for le in &lb.elements {
println!("\n== {} -> leaf {}", el.name, le.name);
let ks: Vec<_> = le.keyframes.iter().collect();
for w in ks.windows(2) {
let (a, c) = (w[0], w[1]);
match (a.time, c.time) {
(Some(t0), Some(t1)) if t1 > t0 => println!(
" t {t0:>4} -> {t1:<4} x {:>6} -> {:<6} = {:+.4} px/unit",
a.x, c.x, (c.x - a.x) as f64 / (t1 - t0) as f64),
_ => println!(" t {:?} -> {:?} x {} -> {} (no rate)", a.time, c.time, a.x, c.x),
}
}
}
}
println!("--- END ---");
}

View File

@@ -1,91 +0,0 @@
//! Where are the title's two light-sweep quads at a given time — and is a
//! best-fit against a framebuffer PNG even measuring their position?
//!
//! `ui-leaf-vs-parent-alpha.md` solves the sweep instant as **t = 357.7** from a
//! GPU per-draw capture: quad centre x measured off the submitted vertex buffer,
//! which is a position measurement at 4 px/unit. The port agent separately
//! best-fits the same leaf against `live-title-build4-no-plate.png` and gets
//! **~400 units**, and asked whether the two used the same capture.
//!
//! Before comparing the numbers, check whether the second method can see what it
//! claims to measure. A fit that is minimised by the quad being OFF-SCREEN is
//! minimised by absence, and would return "best" at whatever time draws least —
//! the same shape as the `.tbm` control that could not fail.
//!
//! So: print the leaves' own x, alpha, and on-screen overlap across the window.
//!
//! cargo run -p sylpheed-formats --example ptloop_leaf_sweep_at
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
const SCREEN_W: i64 = 1280;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let by = ar.read(&ar.entries()[4]).expect("entry 4");
let b = ui_layout::parse_build(&by).expect("parse");
for parent in ["ptloop01", "ptloop02"] {
let Some(el) = b.elements.iter().find(|e| e.name.starts_with(parent)) else {
eprintln!("no element {parent}");
continue;
};
let Some(&(off, size)) = b.records.get(&el.name) else {
eprintln!("{}: no nested record", el.name);
continue;
};
// `ui-record-loop-length.md`: a nested record's header `+0x08` is its
// CYCLE LENGTH, and its keyframes need not fill it. This is why two
// captures of the same settled title do not share a sweep phase.
let loop_len = u32::from_be_bytes(by[off + 8..off + 12].try_into().unwrap());
println!("\n-- {} nested record: loop length (+0x08) = {loop_len}", el.name);
let Some(lb) = ui_layout::parse_build(&by[off..off + size]) else {
eprintln!("{}: leaf will not parse", el.name);
continue;
};
for le in &lb.elements {
let w = (le.pivot_x * 2) as i64;
println!(
"\n== {} -> leaf {} (sprite {:?}, pivot {}x{}, {} keyframes, last t={:?})",
el.name,
le.name,
le.sprite,
le.pivot_x,
le.pivot_y,
le.keyframes.len(),
le.keyframes.last().map(|k| k.time)
);
// ⚠️ A keyframe's `x` is the quad's LEFT edge, not its centre. The
// draw-capture fit is quoted in CENTRES, so compare `centre`, which is
// `x + pivot_x`. Printing `x` under a "centre" heading is how a
// 200-px offset gets into a comparison unnoticed.
println!(" t | x | centre | a | on-screen px of a {w}px-wide quad");
for t in [
340u32, 350, 355, 357, 358, 360, 370, 380, 390, 395, 400, 405, 410, 420, 440, 480,
540,
] {
let Some(k) = le.pose_at(t) else {
println!(" {t:>4} | (no pose)");
continue;
};
let x = k.x as i64;
let a = k.fade >> 24;
let l = x;
let r = l + w;
let vis = (r.min(SCREEN_W) - l.max(0)).max(0);
println!(
" {t:>4} | {x:>4} | {:>6} | {a:>3} | {vis:>5} px {}",
x + le.pivot_x as i64,
if vis == 0 {
"*** ENTIRELY OFF SCREEN ***"
} else {
""
}
);
}
}
}
}

View File

@@ -1,69 +0,0 @@
//! Is a nested record's header `+0x08` its LOOP LENGTH — and do its keyframes
//! have to fill it?
//!
//! A `*f` focus record animates forever while its button is focused, so
//! something must say where the cycle restarts. The keyframes cannot: the plate's
//! `ptbtn00f` runs 0→80→0 over 105 units, and looping at 105 gives a period 15 %
//! short of every measurement of the real thing.
//!
//! Each record is itself a RATC bundle with its own header, and `+0x08` is a
//! frame count. If it is the loop length then it must never be LESS than the
//! record's largest keyframe time — an animation cannot restart before its own
//! last pose — and it may be more, which is a hold at the final pose.
//!
//! Two controls, both of which a wrong reading fails:
//! * `+0x08 < max keyframe time` must never happen. That is the falsifier.
//! * The distribution must not be trivial: if every record had exactly
//! `+0x08 == max t`, the field would carry nothing and "loop length" would be
//! an unfalsifiable relabelling of the keyframes.
use sylpheed_formats::{pak, ratc, ui_layout};
use std::collections::BTreeMap;
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.flatten().map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
paks.sort();
let (mut total, mut exact, mut holds, mut violations) = (0usize, 0usize, 0usize, 0usize);
let mut hold_hist: BTreeMap<i64, usize> = BTreeMap::new();
let mut worst: Vec<(i64, String)> = Vec::new();
for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (rn, &(o, s)) in &b.records {
if o + 12 > by.len() || o + s > by.len() { continue }
if &by[o..o + 4] != b"RATC" { continue }
let len = u32::from_be_bytes(by[o + 8..o + 12].try_into().unwrap()) as i64;
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
let maxt = lb.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0) as i64;
if maxt == 0 { continue } // a static record declares no cycle
total += 1;
let slack = len - maxt;
*hold_hist.entry(slack).or_default() += 1;
if slack == 0 { exact += 1 } else if slack > 0 { holds += 1 } else {
violations += 1;
if worst.len() < 12 {
worst.push((slack, format!("{}:{rn} len={len} maxt={maxt}",
p.file_name().unwrap().to_string_lossy())));
}
}
}
}
}
println!("nested records with timed keyframes : {total}");
println!(" +08 == max keyframe time (exact) : {exact} ({:.1}%)", 100.0*exact as f64/total as f64);
println!(" +08 > max keyframe time (a hold) : {holds} ({:.1}%)", 100.0*holds as f64/total as f64);
println!(" +08 < max keyframe time 🔴 : {violations} ({:.2}%) <- the falsifier",
100.0*violations as f64/total as f64);
println!("\nslack (+08 - max t) distribution, most common first:");
let mut h: Vec<_> = hold_hist.iter().collect();
h.sort_by_key(|&(_, n)| std::cmp::Reverse(*n));
for (k, n) in h.iter().take(14) { println!(" slack {k:>6} : {n}"); }
if !worst.is_empty() { println!("\nviolations:"); for (s, w) in &worst { println!(" {s:>6} {w}"); } }
}

View File

@@ -1,63 +0,0 @@
//! Verify the newly-public `ui_layout::loop_length_units` against the disc.
//!
//! `sylpheed-port` reads a record's `+0x08` itself, guarded on the RATC magic,
//! because the field was exposed on no public ref at all — example, test and
//! `docs/re/` only. This checks the public function reproduces the numbers the
//! finding was written from before the port depends on it.
//!
//! CONTROL FIRST: the function must return `None` for a non-RATC slice and for a
//! slice too short to hold the field. An accessor that returns a number for
//! anything cannot be trusted to return the right one.
//!
//! cargo run -p sylpheed-formats --example record_loop_length_api
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
// ---- controls -------------------------------------------------------
assert_eq!(ui_layout::loop_length_units(b"NOTR\x00\x00\x00\x00\x00\x00\x00\x78"), None,
"control FAILED: accepted a non-RATC slice");
assert_eq!(ui_layout::loop_length_units(b"RATC\x00\x00"), None,
"control FAILED: accepted a slice too short for +0x08");
assert_eq!(ui_layout::loop_length_units(b"RATC\x00\x00\x00\x00\x00\x00\x00\x78"), Some(120),
"control FAILED: did not read +0x08 big-endian");
println!("controls pass: rejects non-RATC, rejects short, reads BE at +0x08");
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
// The records the finding names, with their published values.
let expect: &[(&str, u32)] = &[("ptbtn00f.rat", 120), ("ptloop01.rat", 600),
("ptloop02.rat", 720)];
let mut seen = 0usize;
let (mut recs, mut viol) = (0usize, 0usize);
for (ei, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (name, (off, size)) in &b.records {
let rec = &by[*off..(*off + *size).min(by.len())];
let Some(len) = ui_layout::loop_length_units(rec) else { continue };
recs += 1;
// the disc-wide invariant the finding rests on
let largest = ui_layout::parse_build(rec)
.map(|l| l.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max().unwrap_or(0))
.unwrap_or(0);
if len < largest { viol += 1; }
for (want_name, want) in expect {
if name == want_name && seen < 16 {
seen += 1;
let ok = if len == *want { "OK" } else { "MISMATCH" };
println!(" entry {ei:2} {name:16} +0x08 = {len:4} \
(published {want}) largest kf {largest:4} {ok}");
assert_eq!(len, *want, "{name} disagrees with the published value");
}
}
}
}
println!("\n{recs} records read through the public fn; \
{viol} violate +0x08 >= largest keyframe time");
assert!(seen > 0, "found none of the named records — the check proved nothing");
}

View File

@@ -1,68 +0,0 @@
//! Does "1 697 fallback fires return a visible pose" survive being said out loud?
//!
//! `rest-fallback-census.txt` reports that of 2 305 elements where the dwell
//! fallback decides, 1 697 rest at `alpha > 0`. It was written as if that number
//! were the defect. **It is only a defect where the element is a transient.** An
//! element that genuinely ends visible and stays visible SHOULD rest visible, and
//! the fallback happening to be the path that got there is not an error.
//!
//! The port agent hit the mirror image of this: it counted a screen's own exit
//! ramp as the end of an element's visibility, so `ptmsg` — the main menu's
//! permanent footer — came out as "a 2-unit flash". The story collapsed when
//! said aloud. This asks the same question of my number.
//!
//! Split the 1 697 by what the element's LAST keyframe does:
//! * last alpha > 0 -> the element ends visible; resting visible is right
//! * last alpha == 0 -> it fades out; a visible rest is a transient's peak
//!
//! cargo run -p sylpheed-formats --example rest_fallback_audit
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
let (mut fires, mut vis, mut ends_visible, mut ends_zero, mut at_peak) = (0, 0, 0, 0, 0);
// ⚠️ The port agent's exit-ramp finding applies to THIS split too: if a
// screen's exit ramp drives every element to a=0, then "last keyframe a=0"
// says nothing about the element being a transient. Measure it on ALL
// elements before using it on the 1 697.
let (mut all_el, mut all_end_zero) = (0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if el.keyframes.len() < 2 { continue }
all_el += 1;
if (el.keyframes.last().unwrap().fade >> 24) & 0xff == 0 { all_end_zero += 1 }
if el.keyframes.windows(2).any(|w| w[0].x == w[1].x && w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade) { continue }
fires += 1;
let Some(r) = el.rest() else { continue };
let a = (r.fade >> 24) & 0xff;
if a == 0 { continue }
vis += 1;
let last = (el.keyframes.last().unwrap().fade >> 24) & 0xff;
if last > 0 { ends_visible += 1 } else { ends_zero += 1 }
let peak = el.keyframes.iter().map(|k| (k.fade >> 24) & 0xff).max().unwrap_or(0);
if a == peak { at_peak += 1 }
}
}
}
println!("fallback fires {fires}");
println!(" of those, rest alpha > 0 {vis}");
println!(" element's LAST keyframe alpha > 0 {ends_visible} <- ends visible; resting visible is CORRECT");
println!(" element's LAST keyframe alpha = 0 {ends_zero} <- fades out; a visible rest is a transient's peak");
println!(" rest alpha == the element's MAX {at_peak}");
println!("\nCONTROL on the split itself — is 'ends at a=0' near-universal?");
println!(" all elements with >= 2 keyframes {all_el}");
println!(" of those, last keyframe alpha = 0 {all_end_zero} ({:.1} %)",
100.0 * all_end_zero as f64 / all_el as f64);
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -1,77 +0,0 @@
//! When the resting-pose DWELL FALLBACK actually runs, does it pick a visible pose?
//!
//! `ui-resting-pose.md` argues the fallback is structurally unsound — the gap it
//! maximises is time spent *interpolating*, so neither endpoint is held. Its one
//! worked example, `GP_TITLE` build 7's `ptlogo_eff3.t32`, **no longer
//! discriminates**: under the corrected keyframe-record layout the longest gap
//! moved from `61→103` to `0→46`, and both ends of that are `a = 0`. The page's
//! listing still shows the stale parser's trailing `-`.
//!
//! Losing the example is not the same as closing the question, so: disc-wide, how
//! often does the fallback fire, and when it does, does it return something the
//! player would see? An element resting at `a = 0` is harmless whichever end the
//! rule lands on.
//!
//! cargo run -p sylpheed-formats --example rest_fallback_census
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
paks.sort();
let (mut elements, mut plateau, mut fallback, mut fb_visible) = (0usize, 0, 0, 0);
let mut worst: Vec<(u32, String, String)> = Vec::new();
let mut per_pak: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
let name = pak.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if el.keyframes.len() < 2 { continue }
elements += 1;
// a plateau is two ADJACENT poses that are equal — the same test
// the plateau path makes before the fallback can run
let has_plateau = el.keyframes.windows(2).any(|w| {
w[0].x == w[1].x && w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade
});
if has_plateau { plateau += 1; continue }
fallback += 1;
per_pak.entry(name.clone()).or_default().0 += 1;
let Some(r) = el.rest() else { continue };
let a = (r.fade >> 24) & 0xff;
if a > 0 {
fb_visible += 1;
per_pak.entry(name.clone()).or_default().1 += 1;
worst.push((a, name.clone(), format!("e{i}/{}", el.name)));
}
}
}
}
println!("POPULATION: {elements} elements with >= 2 keyframes, over {} archives", paks.len());
println!("COVERAGE: {plateau} have a plateau (fallback never runs)");
println!(" {fallback} have NONE -> the dwell fallback decides");
println!(" {fb_visible} of those rest at alpha > 0 -- i.e. VISIBLE\n");
println!("PER ARCHIVE — fallback fires / of those, rests VISIBLE:");
let mut rows: Vec<_> = per_pak.into_iter().collect();
rows.sort_by(|a, b| b.1.1.cmp(&a.1.1));
for (pak, (fires, vis)) in &rows {
println!(" {pak:34} {fires:5} fires {vis:5} visible");
}
println!();
worst.sort_by(|a, b| b.0.cmp(&a.0));
for (a, pak, el) in worst.iter().take(6) {
println!(" a={a:3} {pak} {el}");
}
println!("\n--- END OF CENSUS (if this line is missing, the run did not finish) ---");
}

View File

@@ -1,27 +0,0 @@
//! Which `GP_TITLE` elements does the resting-pose dwell fallback decide, and does
//! it hand back a visible pose? The disc-wide census says 5 fires / 4 visible here.
//! cargo run -p sylpheed-formats --example rest_fallback_title
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
for el in &b.elements {
if el.keyframes.len() < 2 { continue }
if el.keyframes.windows(2).any(|w| w[0].x == w[1].x && w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x && w[0].scale_y == w[1].scale_y
&& w[0].fade == w[1].fade) { continue }
let Some(r) = el.rest() else { continue };
let a = (r.fade >> 24) & 0xff;
let ks: Vec<String> = el.keyframes.iter()
.map(|k| format!("{}:a{}", k.time.map(|v| v.to_string()).unwrap_or("-".into()), (k.fade >> 24) & 0xff))
.collect();
println!("entry {i:2} {:24} rest a={a:3} t={:?} [{}]{}",
el.name, r.time, ks.join(" "),
if a > 0 { " <== VISIBLE" } else { "" });
}
}
}

View File

@@ -1,97 +0,0 @@
//! Should the settled pose come from each element's `rest()`, or from the
//! **screen's** settle instant?
//!
//! Three iterations have measured how badly `rest()`'s dwell fallback behaves —
//! 2 305 elements where it decides, 1 457 of them handed the element's *maximum*
//! alpha, and by construction none of those poses is held. What has been missing
//! is a proposal.
//!
//! `UiBuild::settle_time()` already exists: the midpoint of the longest
//! keyframe-free interval **across the whole build**. That is the port agent's
//! "re-key on the screen's span rather than the element's", and its shipped path
//! poses `pose_at(hold)` and agrees with every capture it holds at 0.01 %.
//!
//! ⚠️ **Control first.** On elements where `rest()` is already sound — the plateau
//! path, a pose the element genuinely holds — `pose_at(settle)` must AGREE. If it
//! disagrees there, it is not a better rule, it is a different one.
//!
//! ⚠️ `ui-settle-time.md` records that 42 % of bundles have a settle window under
//! 10 units and never settle at all. Bundles are split on that here rather than
//! averaged over.
//!
//! cargo run -p sylpheed-formats --example rest_vs_settle
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
// control population (plateau) and test population (fallback), each split by
// whether the bundle settles at all
let (mut ctl_n, mut ctl_cov, mut ctl_agree) = (0usize, 0usize, 0usize);
let (mut fb_n, mut fb_rest_vis, mut fb_settle_vis) = (0usize, 0usize, 0usize);
let mut narrow = 0usize;
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
let Some((lo, hi)) = b.settle_window() else { continue };
if hi - lo < 10 { narrow += 1; continue } // this bundle never settles
let st = lo + (hi - lo) / 2;
for el in &b.elements {
if el.keyframes.len() < 2 { continue }
let plateau = el.keyframes.windows(2).any(|w| w[0].x == w[1].x
&& w[0].y == w[1].y && w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y && w[0].fade == w[1].fade);
let (Some(r), Some(s)) = (el.rest(), el.pose_at(st)) else { continue };
let (ra, sa) = ((r.fade >> 24) & 0xff, (s.fade >> 24) & 0xff);
if plateau {
// ⚠️ The first version of this control compared EVERY plateau
// element and got 46.6 % agreement — then I asked what that
// means physically. `rest()` finds *a* held pose; many
// elements hold one during the build-in and then move on.
// `pose_at(settle)` asks what is on screen WHEN THE SCREEN HAS
// SETTLED. Those are different questions, so disagreement
// proves nothing. The fair control is the subset where the
// held interval actually CONTAINS the settle instant.
ctl_n += 1;
let covers = el.keyframes.windows(2).any(|w| {
let held = w[0].x == w[1].x && w[0].y == w[1].y
&& w[0].scale_x == w[1].scale_x
&& w[0].scale_y == w[1].scale_y && w[0].fade == w[1].fade;
match (w[0].time, w[1].time) {
(Some(a), Some(bb)) => held && a <= st && st <= bb,
_ => false,
}
});
if covers {
ctl_cov += 1;
if ra == sa && r.x == s.x && r.y == s.y
&& r.scale_x == s.scale_x && r.scale_y == s.scale_y { ctl_agree += 1 }
}
} else {
fb_n += 1;
if ra > 0 { fb_rest_vis += 1 }
if sa > 0 { fb_settle_vis += 1 }
}
}
}
}
println!("bundles skipped as never-settling (window < 10 units): {narrow}\n");
println!("CONTROL — elements where rest() takes the SOUND plateau path:");
println!(" {ctl_n} plateau elements in settling bundles");
println!(" {ctl_cov} of them HOLD ACROSS the settle instant — the fair control");
println!(" pose_at(settle) agrees with rest() on {ctl_agree} of those ({:.1} %)",
100.0 * ctl_agree as f64 / ctl_cov.max(1) as f64);
println!("\nTEST — elements where the unsound dwell fallback decides:");
println!(" {fb_n} elements");
println!(" rest() returns a VISIBLE pose on {fb_rest_vis} ({:.1} %)",
100.0 * fb_rest_vis as f64 / fb_n.max(1) as f64);
println!(" pose_at(settle) returns a VISIBLE pose on {fb_settle_vis} ({:.1} %)",
100.0 * fb_settle_vis as f64 / fb_n.max(1) as f64);
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -1,48 +0,0 @@
//! Every scale value on the disc's UI, parents AND nested leaves.
//!
//! `DECISIONS.md` records `ptlogo_eff2` at 125 % as "the single drawn element in
//! the whole export at a scale that is not a whole multiple of 100 %". That
//! census was over parents only -- leaves were never opened. This opens them.
use sylpheed_formats::{pak, ui_layout};
use std::collections::BTreeMap;
fn main() {
let path = std::env::args().nth(1).expect("pak");
let ar = pak::PakArchive::open(&path).expect("open");
let mut hist: BTreeMap<(u32, u32), Vec<String>> = BTreeMap::new();
let mut leaves_opened = 0usize;
for (i, e) in ar.entries().to_vec().iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&bytes) else { continue };
for el in &b.elements {
for k in &el.keyframes {
hist.entry((k.scale_x, k.scale_y))
.or_default()
.push(format!("e{i}/{}", el.name));
}
if let Some(&(off, size)) = b.records.get(&el.name) {
if let Some(lb) = ui_layout::parse_build(&bytes[off..off + size]) {
leaves_opened += 1;
for le in &lb.elements {
for k in &le.keyframes {
hist.entry((k.scale_x, k.scale_y))
.or_default()
.push(format!("e{i}/{}->LEAF/{}", el.name, le.name));
}
}
}
}
}
}
println!("{leaves_opened} leaves opened\n");
println!("{:>12} {:>7} examples", "scale", "count");
for (k, v) in &hist {
let mut ex: Vec<&String> = v.iter().collect();
ex.sort(); ex.dedup();
let odd = k.0 % 100 != 0 || k.1 % 100 != 0;
println!("{}{:>5},{:<5} {:>7} {}", if odd { "* " } else { " " },
k.0, k.1, v.len(),
ex.iter().take(3).map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
}
println!("\n* = not a whole multiple of 100%");
}

View File

@@ -1,75 +0,0 @@
//! How often does posing at the SCREEN's settle instant catch an element
//! mid-ramp? The adversarial census of my own proposal.
//!
//! The port agent found `ptmsg` — the main menu's footer — at alpha **127.5 of
//! 255** at that screen's settle instant, because the longest keyframe-free
//! interval ends exactly as the footer starts to arrive. `screen render --settle`
//! already prints "⚠️ narrow — this bundle may never settle" there: the window is
//! **12 units**.
//!
//! ⚠️ **And my `rest_vs_settle` filter was too permissive**: it dropped bundles
//! with a window under 10 units, so a 12-unit window passed while the tool itself
//! was flagging it. This splits by width instead of picking one cutoff.
//!
//! "Mid-ramp" = at the settle instant the element sits strictly inside an interval
//! whose two endpoint poses DIFFER — it is interpolating, not held.
//!
//! cargo run -p sylpheed-formats --example settle_midramp_census
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
// buckets by settle-window width
let edges = [0u32, 10, 20, 30, 60, u32::MAX];
let names = ["< 10", "1019", "2029", "3059", ">= 60"];
let mut els = [0usize; 5];
let mut mid = [0usize; 5];
let mut bundles = [0usize; 5];
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
let Some((lo, hi)) = b.settle_window() else { continue };
let w = hi - lo;
let bi = edges.windows(2).position(|p| w >= p[0] && w < p[1]).unwrap_or(4);
bundles[bi] += 1;
let st = lo + w / 2;
for el in &b.elements {
let k = &el.keyframes;
if k.len() < 2 { continue }
els[bi] += 1;
// the interval containing the settle instant
let mut interpolating = false;
for pair in k.windows(2) {
if let (Some(t0), Some(t1)) = (pair[0].time, pair[1].time) {
if t0 <= st && st <= t1 && t0 != t1 {
let same = pair[0].fade == pair[1].fade
&& pair[0].x == pair[1].x && pair[0].y == pair[1].y
&& pair[0].scale_x == pair[1].scale_x
&& pair[0].scale_y == pair[1].scale_y;
if !same && st != t0 && st != t1 { interpolating = true }
break;
}
}
}
if interpolating { mid[bi] += 1 }
}
}
}
println!("{:8}{:>10}{:>10}{:>12}{:>10}", "window", "bundles", "elements", "mid-ramp", "share");
for i in 0..5 {
if els[i] == 0 { continue }
println!("{:8}{:>10}{:>10}{:>12}{:>9.1}%", names[i], bundles[i], els[i], mid[i],
100.0 * mid[i] as f64 / els[i] as f64);
}
let te: usize = els.iter().sum(); let tm: usize = mid.iter().sum();
println!("{:8}{:>10}{:>10}{:>12}{:>9.1}%", "ALL", bundles.iter().sum::<usize>(), te, tm,
100.0 * tm as f64 / te as f64);
println!("\n--- END (if this line is missing, the run did not finish) ---");
}

View File

@@ -1,49 +0,0 @@
//! What share of bundles have a NARROW settle window -- and of WHICH bundles?
//!
//! `screen render --settle`'s help says "a narrow one means the bundle never
//! settles (42 % of them, mostly `loop*` fragments)". The 42 % is correct and is
//! stated precisely in ui-settle-time.md: 731 of **1 758 composable bundles
//! carrying two or more keyframe times**. But inside `screen render`, "them"
//! reads as the bundles you would render -- the SCREEN BUILDS -- which is a
//! different and much smaller population. This computes both.
//!
//! cargo run -p sylpheed-formats --example settle_narrow_rate
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat")).expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak")).collect();
paks.sort();
// (with >=2 keyframe times, narrow) for each population
let (mut b2, mut bn) = (0usize, 0usize); // screen builds (is_build)
let (mut c2, mut cn) = (0usize, 0usize); // composable (is_composable)
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
let is_b = ui_layout::is_build(&by);
let is_c = ui_layout::is_composable(&by);
if !is_b && !is_c { continue }
let Some(b) = ui_layout::parse_build(&by) else { continue };
let mut ts: Vec<u32> = b.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).collect();
ts.sort_unstable(); ts.dedup();
if ts.len() < 2 { continue }
let narrow = match b.settle_window() { Some((lo, hi)) => hi - lo < 10, None => true };
if is_b { b2 += 1; if narrow { bn += 1 } }
if is_c { c2 += 1; if narrow { cn += 1 } }
}
}
println!("population n narrow (<10 u) share");
println!("SCREEN BUILDS (is_build, what `screen render` renders by default)");
println!(" {b2:5} {bn:9} {:.0} %",
100.0 * bn as f64 / b2.max(1) as f64);
println!("COMPOSABLE bundles (is_composable, what --all admits)");
println!(" {c2:5} {cn:9} {:.0} %",
100.0 * cn as f64 / c2.max(1) as f64);
println!("\nui-settle-time.md quotes 731 / 1758 = 42 % over composable bundles.");
println!("--- END ---");
}

View File

@@ -1,31 +0,0 @@
// The settled screen, computed from the keyframe times alone.
//
// `rest()` picks each element's last HOLD keyframe independently, which is right
// for an element that ends settled and wrong for a transient: a 2-frame flash
// holds at its PEAK, so `rest()` leaves it burning forever. The settled screen is
// instead one INSTANT that every element is posed at, and the instant to pick is
// inside the longest interval during which no element has a keyframe at all.
use sylpheed_formats::{pak, ui_layout};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap();
let ar=pak::PakArchive::open(&pk).unwrap();
let only:Option<usize>=a.next().and_then(|s|s.parse().ok());
for (i,e) in ar.entries().iter().enumerate() {
if let Some(o)=only { if o!=i { continue } }
let Ok(by)=ar.read(e) else { continue };
let Some(b)=ui_layout::parse_build(&by) else { continue };
if b.elements.len()<2 { continue }
let mut ts:Vec<u32>=b.elements.iter().flat_map(|el|
el.keyframes.iter().filter_map(|k|k.time)).collect();
if ts.len()<2 { continue }
ts.sort_unstable(); ts.dedup();
// longest gap between consecutive keyframe times
let (mut best,mut lo,mut hi)=(0u32,0u32,0u32);
for w in ts.windows(2) {
if w[1]-w[0] > best { best=w[1]-w[0]; lo=w[0]; hi=w[1]; }
}
println!("{:>4} {:<28} times {:>3} span {:>4} settle window [{lo},{hi}] = {best} units ({:.2}s) -> t={}",
i, format!("{:08x}",e.name_hash), ts.len(), ts.last().unwrap(), best as f64/60.0, lo+best/2);
}
}

View File

@@ -1,27 +0,0 @@
//! Where does `settle_window()`'s answer come from? The port agent recomputes the
//! publisher splash's widest keyframe-free gap as 190 units; `--settle` reports 8.
//! One of the two readings is wrong and the file settles it.
//! cargo run -p sylpheed-formats --example settle_window_check -- <build>
use sylpheed_formats::{pak::PakArchive, ui_layout};
use std::path::PathBuf;
fn main() {
let b: usize = std::env::args().nth(1).unwrap_or("10".into()).parse().unwrap();
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let by = ar.read(&ar.entries()[b]).expect("entry");
let build = ui_layout::parse_build(&by).expect("parse");
println!("entry {b}: {} elements", build.elements.len());
for el in &build.elements {
let ts: Vec<String> = el.keyframes.iter()
.map(|k| k.time.map(|v| v.to_string()).unwrap_or("-".into())).collect();
println!(" {:26} [{}]", el.name, ts.join(" "));
}
let mut ts: Vec<u32> = build.elements.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)).collect();
ts.sort_unstable(); ts.dedup();
println!("\nunion of all element keyframe times: {ts:?}");
let gaps: Vec<(u32,u32,u32)> = ts.windows(2).map(|w| (w[1]-w[0], w[0], w[1])).collect();
let mut g = gaps.clone(); g.sort_by(|a,b| b.0.cmp(&a.0));
println!("widest gaps: {:?}", &g[..g.len().min(4)]);
println!("settle_window() reports {:?}", build.settle_window());
}

View File

@@ -1,12 +0,0 @@
use sylpheed_formats::{pak, ui_layout, t8ad};
fn main(){
let mut a=std::env::args().skip(1);
let pk=a.next().unwrap(); let i:usize=a.next().unwrap().parse().unwrap();
let ar=pak::PakArchive::open(pk).unwrap();
let by=ar.read(&ar.entries()[i]).unwrap();
let b=ui_layout::parse_build(&by).unwrap();
let mut v:Vec<(String,u32,u32)>=b.sprites.iter().filter_map(|(n,&(o,s))|{
let im=t8ad::parse(&by[o..o+s])?; Some((n.clone(),im.width,im.height))}).collect();
v.sort();
for (n,w,h) in v { println!(" {w:>5} x {h:<5} {n}"); }
}

View File

@@ -1,9 +0,0 @@
use sylpheed_formats::{pak, ui_layout};
fn main(){
let ar=pak::PakArchive::open(std::env::args().nth(1).unwrap()).unwrap();
let b=ui_layout::parse_build(&ar.read(&ar.entries()[4]).unwrap()).unwrap();
for el in b.elements.iter().filter(|e| e.name.contains("ptloop")||e.name.contains("ptbtn")) {
println!("{:<18} sprite={:?} has_leaf={}", el.name, el.sprite,
b.records.contains_key(&el.name));
}
}

View File

@@ -1,332 +0,0 @@
//! What does the unknown paint-order TIE-BREAK actually cost, in pixels?
//!
//! `ui-paint-order-derived-check.md` bounds *where* a wrong tie-break could
//! show — 24 overlapping tied pairs across `GP_TITLE` — and says outright that
//! nobody has measured how many of them change a pixel. Overlap is an upper
//! bound: two elements can overlap and still composite identically in either
//! order, if either is transparent where they meet.
//!
//! This renders each screen twice — once in the order `compose` derives, once
//! with one tied pair swapped — and counts the pixels that differ. Same-key
//! elements are contiguous in the derived order (a stable sort by `(key, i)`),
//! so swapping two of them paints nothing else in between: the diff is the
//! tie-break's cost and nothing else.
//!
//! Every entry also runs a CONTROL: a swap of two OVERLAPPING elements with
//! DIFFERENT keys, i.e. a pair whose order the game is known to care about. If
//! the control diff is zero the instrument cannot see a reorder on this screen
//! and its zeros mean nothing.
//!
//! cargo run -p sylpheed-formats --example tie_break_pixel_cost -- <GP_TITLE.pak>
use sylpheed_formats::{pak, ui_layout};
use ui_layout::{ComposeOptions, UiBuild};
/// Pixels that differ, and the largest per-channel difference.
fn diff(a: &[u8], b: &[u8]) -> (usize, u8) {
let (mut n, mut worst) = (0usize, 0u8);
for (pa, pb) in a.chunks_exact(4).zip(b.chunks_exact(4)) {
if pa != pb {
n += 1;
for k in 0..4 {
worst = worst.max(pa[k].abs_diff(pb[k]));
}
}
}
(n, worst)
}
/// Where does element `ei` actually put ink? Render with it and without it;
/// the pixels that move are the ones it paints. This is what turns a bare
/// "0 px differ" into an explained one: bounding boxes can overlap while the
/// sprites inside them never touch the same pixel.
fn ink_mask(
build: &UiBuild,
bundle: &[u8],
opts: ComposeOptions,
order: &[usize],
base: &[u8],
ei: usize,
) -> Vec<bool> {
let n_el = build.elements.iter().map(|e| e.index).max().unwrap_or(0) + 1;
let mut vis = vec![true; n_el.max(build.elements.len())];
vis[build.elements[ei].index] = false;
let without = ui_layout::compose_with_order(build, bundle, opts, Some(&vis), Some(order));
base.chunks_exact(4)
.zip(without.rgba.chunks_exact(4))
.map(|(x, y)| x != y)
.collect()
}
fn swapped(order: &[usize], a: usize, b: usize) -> Vec<usize> {
let mut o = order.to_vec();
let (pa, pb) = (
o.iter().position(|&e| e == a).unwrap(),
o.iter().position(|&e| e == b).unwrap(),
);
o.swap(pa, pb);
o
}
/// The element's on-screen rect, the same approximation the tie census uses:
/// the declared pivot doubled, placed at the keyframe. `at` selects the pose —
/// `None` is `rest()`, which is where the original census was computed.
///
/// 🔴 An element that is TRANSPARENT at the chosen pose gets no rect at all. A
/// tie involving something invisible cannot cost a pixel, and counting it as an
/// overlap is what made the original census an upper bound rather than a cost.
fn rect(e: &ui_layout::Element, at: Option<u32>) -> Option<(i32, i32, i32, i32)> {
let kf = match at {
Some(t) => e.pose_at(t)?,
None => e.rest()?.clone(),
};
if kf.fade >> 24 == 0 || kf.scale_x == 0 || kf.scale_y == 0 {
return None;
}
let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32);
if w == 0 || h == 0 {
return None;
}
Some((kf.x, kf.y, w, h))
}
fn overlaps(a: &ui_layout::Element, b: &ui_layout::Element, at: Option<u32>) -> bool {
let (Some(ra), Some(rb)) = (rect(a, at), rect(b, at)) else {
return false;
};
(ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0) > 0
&& (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1) > 0
}
struct Case {
name: &'static str,
opts: ComposeOptions,
}
fn cases() -> Vec<Case> {
vec![
Case {
name: "default (what `screen render` draws)",
opts: ComposeOptions {
backdrop: [0, 0, 0, 255],
..Default::default()
},
},
Case {
name: "everything on (focus+animated+primitives)",
opts: ComposeOptions {
include_focus: true,
include_animated: true,
include_primitives: true,
backdrop: [0, 0, 0, 255],
..Default::default()
},
},
// 🔴 The two cases above pose at `rest()`, which is each element's last
// hold picked independently — so they draw transients that the settled
// screen does not have (`docs/re/structures/ui-settle-time.md`). A tie
// between two elements that are transparent at the settle time cannot
// cost a pixel on the screen the player sees, however much their rects
// overlap at rest. `at` is filled in per entry.
Case {
name: "AT THE SETTLE TIME (what the player sees)",
opts: ComposeOptions {
backdrop: [0, 0, 0, 255],
at: Some(0), // replaced per entry
..Default::default()
},
},
]
}
fn tied_overlapping_pairs(build: &UiBuild, bytes: &[u8], at: Option<u32>) -> Vec<(usize, usize, u32)> {
let keys: Vec<u32> = build
.elements
.iter()
.map(|e| ui_layout::sprite_layer_key(build, bytes, e).unwrap_or(u32::MAX))
.collect();
let mut out = Vec::new();
for a in 0..keys.len() {
for b in (a + 1)..keys.len() {
if keys[a] != keys[b] || keys[a] == u32::MAX {
continue;
}
if overlaps(&build.elements[a], &build.elements[b], at) {
out.push((a, b, keys[a]));
}
}
}
out
}
/// A pair the game's own order DOES separate: overlapping, different keys.
/// Used as the control — swapping it must move pixels.
fn control_pair(build: &UiBuild, bytes: &[u8], at: Option<u32>) -> Option<(usize, usize)> {
let keys: Vec<u32> = build
.elements
.iter()
.map(|e| ui_layout::sprite_layer_key(build, bytes, e).unwrap_or(u32::MAX))
.collect();
let mut best: Option<(i64, usize, usize)> = None;
for a in 0..keys.len() {
for b in (a + 1)..keys.len() {
if keys[a] == keys[b] || keys[a] == u32::MAX || keys[b] == u32::MAX {
continue;
}
if !overlaps(&build.elements[a], &build.elements[b], at) {
continue;
}
let (ra, rb) = (rect(&build.elements[a], at)?, rect(&build.elements[b], at)?);
let ox = ((ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0)) as i64;
let oy = ((ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1)) as i64;
let area = ox * oy;
if best.map_or(true, |(x, _, _)| area > x) {
best = Some((area, a, b));
}
}
}
best.map(|(_, a, b)| (a, b))
}
fn main() {
let path = std::env::args()
.nth(1)
.expect("usage: tie_break_pixel_cost <pak>");
let ar = pak::PakArchive::open(&path).expect("open pak");
let entries: Vec<_> = ar.entries().to_vec();
println!("# tie-break pixel cost — {path}\n");
let mut totals = (0usize, 0usize, 0usize); // pairs, changed-a-pixel, controls-dead
for (i, e) in entries.iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
let Some(build) = ui_layout::parse_build(&bytes) else {
continue;
};
// The census pairs are still computed at rest, so the report can say
// how many of THOSE survive posing at the settle time.
let pairs_at_rest = tied_overlapping_pairs(&build, &bytes, None);
if pairs_at_rest.is_empty() {
continue;
}
let settle = build.settle_time();
println!(
"entry {i:2} {} elements {} overlapping tied pair(s) at rest{}",
build.elements.len(),
pairs_at_rest.len(),
match (settle, build.settle_window()) {
(Some(t), Some((lo, hi))) => format!(" settle t={t} (window {} units)", hi - lo),
_ => " NO SETTLE WINDOW".to_string(),
}
);
let derived = ui_layout::derived_paint_order(&build, &bytes);
for mut c in cases() {
// The settle case is a no-op on a bundle that never settles.
if c.opts.at.is_some() {
match settle {
Some(t) => c.opts.at = Some(t),
None => {
println!(" [{}] SKIPPED: no settle window", c.name);
continue;
}
}
}
let pairs = tied_overlapping_pairs(&build, &bytes, c.opts.at);
if pairs.len() != pairs_at_rest.len() {
println!(
" [{}] 🔴 {} of the {} tied pairs are GONE at this pose (an element is \
transparent or collapsed there) — they cannot cost a pixel",
c.name,
pairs_at_rest.len() - pairs.len(),
pairs_at_rest.len()
);
}
let base = ui_layout::compose_with_order(&build, &bytes, c.opts, None, Some(&derived));
let drawn: std::collections::HashSet<usize> = base.drawn.iter().copied().collect();
// Control first. An instrument that cannot see a reorder it is
// supposed to see makes every zero below meaningless.
let ctrl = match control_pair(&build, &bytes, c.opts.at) {
Some((a, b)) if drawn.contains(&a) && drawn.contains(&b) => {
let alt = ui_layout::compose_with_order(
&build,
&bytes,
c.opts,
None,
Some(&swapped(&derived, a, b)),
);
let (n, w) = diff(&base.rgba, &alt.rgba);
Some((a, b, n, w))
}
_ => None,
};
match ctrl {
Some((a, b, n, w)) if n > 0 => println!(
" [{}] CONTROL ok: swapping [{a}] {} x [{b}] {} moves {n} px (max Δ {w})",
c.name, build.elements[a].name, build.elements[b].name
),
Some((a, b, _, _)) => {
totals.2 += 1;
println!(
" [{}] CONTROL DEAD: swapping [{a}] {} x [{b}] {} changes NOTHING — \
zeros below are uninterpretable",
c.name, build.elements[a].name, build.elements[b].name
)
}
None => {
totals.2 += 1;
println!(
" [{}] CONTROL UNAVAILABLE: no overlapping different-key pair is drawn",
c.name
)
}
}
for &(a, b, key) in &pairs {
let both_drawn = drawn.contains(&a) && drawn.contains(&b);
if !both_drawn {
println!(
" [{}] [{a}] {} x [{b}] {} (key {key}): NOT BOTH DRAWN — unreachable here",
c.name, build.elements[a].name, build.elements[b].name
);
continue;
}
let alt = ui_layout::compose_with_order(
&build,
&bytes,
c.opts,
None,
Some(&swapped(&derived, a, b)),
);
let (n, w) = diff(&base.rgba, &alt.rgba);
let total = (base.width as usize) * (base.height as usize);
// Explain the number: how many pixels do the two BOTH paint on?
// A zero with a large shared-ink count is a real "order does
// not matter here"; a zero with no shared ink means the
// bounding boxes overlapped and the sprites did not.
let ma = ink_mask(&build, &bytes, c.opts, &derived, &base.rgba, a);
let mb = ink_mask(&build, &bytes, c.opts, &derived, &base.rgba, b);
let shared = ma.iter().zip(&mb).filter(|(x, y)| **x && **y).count();
let (ia, ib) = (
ma.iter().filter(|x| **x).count(),
mb.iter().filter(|x| **x).count(),
);
println!(
" [{}] [{a}] {} x [{b}] {} (key {key}): {n} px differ ({:.4}% of frame), \
max Δ {w} | ink {ia} / {ib} px, shared {shared} px",
c.name,
build.elements[a].name,
build.elements[b].name,
100.0 * n as f64 / total as f64
);
if c.name.starts_with("default") {
totals.0 += 1;
if n > 0 {
totals.1 += 1;
}
}
}
}
println!();
}
println!(
"default-options summary: {} of {} overlapping tied pairs change at least one pixel; \
{} dead/unavailable controls",
totals.1, totals.0, totals.2
);
}

View File

@@ -1,76 +0,0 @@
//! How many tied pairs can cost a pixel, as a function of TIME?
//!
//! `tie_break_pixel_cost` answers "at one pose". That leaves the answer looking
//! like it might be a knife-edge: pick a different instant and the count could
//! jump. This sweeps every keyframe time in the bundle and reports, per entry,
//! how many same-key pairs are simultaneously **opaque, non-collapsed and
//! overlapping** — the pairs whose order could possibly matter at that instant.
//!
//! The instrument's control is built in: the count at t=0 (nothing has faded in)
//! and the count at rest must bracket it, and an entry whose count is flat at
//! zero for the whole sweep would be suspicious rather than reassuring — so the
//! peak is printed too.
use sylpheed_formats::{pak, ui_layout};
use ui_layout::UiBuild;
fn rect(e: &ui_layout::Element, t: u32) -> Option<(i32, i32, i32, i32)> {
let kf = e.pose_at(t)?;
if kf.fade >> 24 == 0 || kf.scale_x == 0 || kf.scale_y == 0 {
return None;
}
let (w, h) = ((e.pivot_x * 2) as i32, (e.pivot_y * 2) as i32);
if w == 0 || h == 0 { return None; }
Some((kf.x, kf.y, w, h))
}
fn live_pairs(b: &UiBuild, bytes: &[u8], keys: &[u32], t: u32) -> usize {
let mut n = 0;
for a in 0..keys.len() {
for c in (a + 1)..keys.len() {
if keys[a] != keys[c] || keys[a] == u32::MAX { continue }
let (Some(ra), Some(rb)) = (rect(&b.elements[a], t), rect(&b.elements[c], t)) else { continue };
if (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0) > 0
&& (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1) > 0 { n += 1 }
}
}
n
}
fn main() {
let path = std::env::args().nth(1).expect("usage: tie_cost_over_time <pak>");
let ar = pak::PakArchive::open(&path).expect("open pak");
println!("# tied pairs that could cost a pixel, over time — {path}\n");
for (i, e) in ar.entries().to_vec().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else { continue };
let keys: Vec<u32> = b.elements.iter()
.map(|el| ui_layout::sprite_layer_key(&b, &by, el).unwrap_or(u32::MAX)).collect();
let mut ts: Vec<u32> = b.elements.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).collect();
ts.sort_unstable(); ts.dedup();
if ts.len() < 2 { continue }
let last = *ts.last().unwrap();
// sample every keyframe time AND every midpoint between them
let mut samples: Vec<u32> = ts.clone();
for w in ts.windows(2) { samples.push(w[0] + (w[1] - w[0]) / 2); }
samples.sort_unstable(); samples.dedup();
let counts: Vec<(u32, usize)> =
samples.iter().map(|&t| (t, live_pairs(&b, &by, &keys, t))).collect();
let peak = counts.iter().map(|&(_, n)| n).max().unwrap_or(0);
if peak == 0 { continue }
let Some((lo, hi)) = b.settle_window() else { continue };
let st = b.settle_time().unwrap();
let at_settle = live_pairs(&b, &by, &keys, st);
// the whole plateau, not just its midpoint
let plateau: Vec<usize> =
(lo..=hi).step_by(((hi - lo).max(1) / 8).max(1) as usize)
.map(|t| live_pairs(&b, &by, &keys, t)).collect();
let pmax = plateau.iter().copied().max().unwrap_or(0);
println!("entry {i:2} peak {peak} live pair(s) over t=0..{last} \
settle window [{lo},{hi}] at t={st}: {at_settle} ACROSS THE WHOLE WINDOW: max {pmax}");
let busy: Vec<String> = counts.iter().filter(|&&(_, n)| n > 0)
.map(|&(t, n)| format!("t{t}:{n}")).collect();
if busy.len() <= 24 { println!(" live only at {}", busy.join(" ")); }
else { println!(" live at {} of {} sampled instants", busy.len(), counts.len()); }
}
}

View File

@@ -1,71 +0,0 @@
//! Is raising the start filter's 1.5 MB cap safe, disc-wide?
//!
//! `voice_region_fix_test.rs` shows that for `ADV` the predecessor start recovers
//! the decoder's own three byte_sizes exactly. But the cap exists to protect a
//! case: the code says *"only within one bank (~1.5 MB), else this is the first cue
//! in its block and the audio starts at the anchor itself"*. Raising it blindly
//! could pull a **previous asset's** streams into the region.
//!
//! So compare, per movie: the chunk list the resolver gives today against the one
//! the predecessor start gives. A safe change makes the FIRST chunk bigger and
//! leaves the rest identical. An unsafe one adds leading chunks.
//!
//! cargo run -p sylpheed-formats --example voice_region_cap_sweep
use sylpheed_formats::hash::name_hash;
use sylpheed_formats::media::{DirectorySource, DiscSource};
use sylpheed_formats::pak::PakArchive;
use sylpheed_formats::slb::{self, VoiceLang};
use sylpheed_formats::{movie_manifest, movie_voice};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
let code = VoiceLang::English.code_pub();
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let manifest = tpak.entries().iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("manifest");
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
let registry = tpak.entries().iter().find_map(|e| {
tpak.read(e).ok().filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
}).expect("registry");
let ids = movie_voice::registry_voice_ids(&registry);
let stoc = src.read_file("dat/sound.pak").expect("sound.pak");
let entries = PakArchive::parse_toc(&stoc).expect("toc");
let (mut same, mut grew, mut extra, mut skip) = (0, 0, 0, 0);
for m in movie_manifest::parse(&manifest) {
let movie = m.movie;
let Some(token) = movie_manifest::voice_token(&manifest, &movie) else { skip += 1; continue };
let Some(&id) = ids.get(&token) else { skip += 1; continue };
let Some(anchor) = ["Movie","etc","Voice"].iter().find_map(|dir| {
let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
entries.binary_search_by_key(&h, |e| e.name_hash).ok().map(|i| entries[i].offset as u64)
}) else { skip += 1; continue };
let win_start = anchor.saturating_sub(2*1024*1024) & !3;
let Ok(window) = src.read_segment_range("dat/sound", win_start, 8*1024*1024) else { skip += 1; continue };
let Some(end_local) = movie_voice::find_descriptor(&window, id) else { skip += 1; continue };
let end = win_start + end_local as u64;
let cand = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
.map(|o| win_start + o as u64)
.filter(|&s| s < end);
let today = cand.filter(|&s| end - s < 1_500_000).unwrap_or(anchor);
let Some(proposed) = cand else { skip += 1; continue };
if today == proposed { same += 1; continue }
let sizes = |s: u64| -> Vec<usize> {
src.read_segment_range("dat/sound", s, (end - s) as usize)
.map(|b| slb::to_xma_riffs(&b).iter().map(|r| r.len() - 60).collect())
.unwrap_or_default()
};
let (a, b) = (sizes(today), sizes(proposed));
let tail_same = a.len() == b.len() && a.iter().skip(1).eq(b.iter().skip(1));
let verdict = if a.len() == b.len() && tail_same && b[0] > a[0] {
grew += 1; "first chunk GREW, tail identical"
} else if b.len() > a.len() { extra += 1; "EXTRA leading chunks" }
else { extra += 1; "changed otherwise" };
println!("{movie:10} today {a:?}\n{:10} prop {b:?} {verdict}", "");
}
println!("\nunchanged {same} fixed-cleanly {grew} would-break {extra} skipped {skip}");
}

View File

@@ -1,50 +0,0 @@
//! How many voice regions hold three chunks? A COUNT, stated with its population.
//!
//! `voice-region-starts-late.md` published "8 of 10 three-chunk regions start
//! mid-stream". The port agent counts **25** three-chunk regions. Mine was not a
//! count: the audit that produced it was cut short and I read a partial file as a
//! complete one — it ends mid-list with no summary line.
//!
//! This does the cheap half properly. It does not step backwards looking for the
//! clip; it resolves each region once and counts its chunks, and it prints the
//! population, the coverage and the skips **in the same output** so a truncated run
//! cannot be mistaken for a complete one.
//!
//! cargo run -p sylpheed-formats --example voice_region_chunk_census
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::movie_manifest;
use sylpheed_formats::slb::{self, VoiceLang};
use std::collections::BTreeMap;
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let manifest = tpak
.entries().iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("manifest");
let movies: Vec<String> = movie_manifest::parse(&manifest).into_iter().map(|m| m.movie).collect();
let total = movies.len();
let mut hist: BTreeMap<usize, Vec<String>> = BTreeMap::new();
let (mut resolved, mut unresolved, mut unreadable) = (0, 0, 0);
for movie in movies {
let Some((start, end)) = media::resolve_movie_voice_region(&src, &movie, VoiceLang::English)
else { unresolved += 1; continue };
let Ok(b) = src.read_segment_range("dat/sound", start, (end - start) as usize)
else { unreadable += 1; continue };
resolved += 1;
hist.entry(slb::to_xma_riffs(&b).len()).or_default().push(movie);
}
println!("POPULATION: {total} movies in the manifest");
println!("COVERAGE: {resolved} resolved and read, {unresolved} unresolved, {unreadable} unreadable");
println!(" {} accounted for\n", resolved + unresolved + unreadable);
for (n, ms) in &hist {
println!(" {n} chunk(s): {:>3} region(s) {}", ms.len(),
ms.iter().cloned().collect::<Vec<_>>().join(" "));
}
println!("\nTHREE-CHUNK REGIONS: {}", hist.get(&3).map(|v| v.len()).unwrap_or(0));
println!("--- END OF CENSUS (if this line is missing, the run did not finish) ---");
}

View File

@@ -1,97 +0,0 @@
//! What ARE the chunks a movie-voice region decodes to?
//!
//! The port reports a resolved voice region decoding to **three** chunks — two
//! of equal duration each spanning the whole movie, and a leading one that
//! "matches nothing" — and notes that this is the same 2+1 signature the BGM
//! banks showed before `bank_header_len` attributed the extra to a bank header.
//! Two different asset kinds with one signature is worth checking, because if
//! the same explanation applies then `bank_header_len` is incomplete, and if it
//! does not then the leading chunk is something we are discarding.
//!
//! `slb.rs`'s own doc comment already predicts the answer and disagrees with
//! "drop it": the header signature fires on 28 entries, all music banks, with
//! "zero false positives on the 7 993 mid-bank windows, WHERE THE LEADING
//! REGION IS REAL". A voice region is a mid-bank window by construction —
//! `resolve_movie_voice_region` starts it at the PREDECESSOR cue's trailer.
//!
//! cargo run -p sylpheed-formats --example voice_region_chunks -- <disc-dir>
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::slb::{self, VoiceLang};
fn main() {
let disc = std::env::args()
.nth(1)
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc dir"));
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
// Disc-wide, not the four movies that motivated the question: every movie
// the manifest binds a voice to.
let movies: Vec<String> = {
use sylpheed_formats::movie_manifest;
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let manifest = tpak
.entries()
.iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("movie manifest");
movie_manifest::parse(&manifest)
.into_iter()
.map(|m| m.movie)
.collect()
};
println!("{} movies in the manifest\n", movies.len());
let mut census: std::collections::BTreeMap<usize, usize> = Default::default();
let (mut with_header, mut with_leading, mut no_leading) = (0, 0, 0);
for movie in movies.iter().map(|s| s.as_str()) {
let Some((start, end)) = media::resolve_movie_voice_region(&src, movie, VoiceLang::English)
else {
continue;
};
let len = end - start;
let Ok(bytes) = src.read_segment_range("dat/sound", start, len as usize) else {
println!("{movie:8} region {start}..{end} unreadable");
continue;
};
// Where does the first RIFF sit? Everything before it is the leading
// headerless packet region.
let first_riff = bytes
.windows(4)
.position(|w| w == b"RIFF")
.map(|p| p as i64)
.unwrap_or(-1);
let hdr = slb::bank_header_len(&bytes);
let riffs = slb::to_xma_riffs(&bytes);
let kind = if first_riff <= 0 {
no_leading += 1;
"no leading region".to_string()
} else if hdr == Some(first_riff as usize) {
with_header += 1;
format!("BANK HEADER ({first_riff} B = {} packets exactly)", first_riff / 2048)
} else {
with_leading += 1;
*census.entry(first_riff as usize % 2048).or_default() += 1;
format!(
"leading STREAM ({first_riff} B = {} packets + {} B)",
first_riff / 2048,
first_riff % 2048
)
};
println!(
"{movie:10} {start:12}..{end:12} {len:9} B chunks {} {kind}",
riffs.len()
);
if let Ok(dir) = std::env::var("VOICE_CHUNK_DUMP") {
for (i, r) in riffs.iter().enumerate() {
let _ = std::fs::write(format!("{dir}/{movie}-chunk{i}.wav"), r);
}
}
}
println!(
"\n{with_header} region(s) open with a BANK HEADER (bank_header_len fires)\n\
{with_leading} open with a leading STREAM\n{no_leading} start at a RIFF"
);
println!("leading-stream length mod 2048, i.e. the derived data offset:");
for (rem, n) in &census {
println!(" {rem:5} B x{n}");
}
}

View File

@@ -1,40 +0,0 @@
//! Would keeping the predecessor (instead of falling back to `anchor`) recover
//! the streams the running decoder actually decodes?
//!
//! `voice_region_start_why.rs` shows the failing branch: the start filter
//! `end - s < 1_500_000` rejects `ADV`'s predecessor because its span is 3.6 MB,
//! so `start` falls back to `anchor` — a TOC offset, not a stream boundary.
//!
//! This does NOT patch the resolver. It asks the one question that decides whether
//! raising that cap is the fix: **from the predecessor, does `to_xma_riffs` return
//! the decoder's own byte_sizes?** For `ADV` those are known, so this is a test and
//! not a fit.
//!
//! cargo run -p sylpheed-formats --example voice_region_fix_test
use sylpheed_formats::media::{DirectorySource, DiscSource};
use sylpheed_formats::slb;
const ADV_PRED: u64 = 433_425_776;
const ADV_ANCHOR: u64 = 433_930_240;
const ADV_END: u64 = 437_044_592;
/// What the running decoder reported.
const WANT: [usize; 3] = [1_294_336, 1_118_208, 1_171_456];
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
for (label, start) in [("anchor (today)", ADV_ANCHOR), ("predecessor (proposed)", ADV_PRED)] {
let bytes = src
.read_segment_range("dat/sound", start, (ADV_END - start) as usize)
.expect("region");
let sizes: Vec<usize> = slb::to_xma_riffs(&bytes).iter().map(|r| r.len() - 60).collect();
let hit = sizes.len() == 3 && sizes.iter().zip(WANT.iter()).all(|(a, b)| a == b);
println!(
"{label:24} start {start} span {:>9} -> {:?}{}",
ADV_END - start,
sizes,
if hit { " <== MATCHES THE DECODER" } else { "" }
);
}
}

View File

@@ -1,63 +0,0 @@
//! Does `resolve_movie_voice_region` start inside the first stream, disc-wide?
//!
//! Verified on `ADV`: the resolver starts **238 packets (487 424 B) late**, and
//! extending the span by exactly that reproduces the running decoder's three
//! byte_sizes to the byte. The decoder is the ground truth there, but it exists
//! for one movie only — so this asks a structural question instead.
//!
//! **If the region began at a stream boundary, stepping the start backwards would
//! immediately expose the PREVIOUS asset's chunks.** If it began mid-stream, the
//! first chunk instead *grows*, packet for packet, until the real boundary. The
//! number of packets it grows for is the clip.
//!
//! cargo run -p sylpheed-formats --example voice_region_start_audit
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::movie_manifest;
use sylpheed_formats::slb::{self, VoiceLang};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let manifest = tpak
.entries()
.iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("manifest");
let movies: Vec<String> = movie_manifest::parse(&manifest)
.into_iter()
.map(|m| m.movie)
.collect();
println!("{:10} {:>8} {:>12} {:>10} {}", "movie", "chunks", "first chunk", "clip pkts", "verdict");
let (mut clipped, mut clean, mut skipped) = (0, 0, 0);
for movie in movies {
let Some((start, end)) = media::resolve_movie_voice_region(&src, &movie, VoiceLang::English)
else { skipped += 1; continue };
// ONE read of the region plus a lead-in, then slide inside it. Re-reading
// several MB per step made this too slow to finish at all.
const LEAD: u64 = 600 * 2048;
let lead = LEAD.min(start);
let buf = match src.read_segment_range("dat/sound", start - lead, (end - (start - lead)) as usize) {
Ok(b) => b, Err(_) => { skipped += 1; continue }
};
let at = |back: usize| -> Vec<Vec<u8>> {
let off = lead as usize - back * 2048;
slb::to_xma_riffs(&buf[off..])
};
let base_riffs = at(0);
if base_riffs.is_empty() { skipped += 1; continue }
let n0 = base_riffs.len();
let first0 = base_riffs[0].len() - 60;
let mut clip = 0usize;
for k in 1..=(lead as usize / 2048) {
if at(k).len() != n0 { break }
clip = k;
}
let verdict = if clip == 0 { clean += 1; "starts at a boundary" }
else { clipped += 1; "STARTS MID-STREAM" };
println!("{movie:10} {n0:>8} {first0:>12} {clip:>10} {verdict}");
}
println!("\nclipped {clipped} clean {clean} skipped {skipped}");
}

View File

@@ -1,70 +0,0 @@
//! WHY does `resolve_movie_voice_region` start inside the first stream?
//!
//! [`voice-region-starts-late.md`] establishes that it does — 238 packets late for
//! `ADV`, 8 of 10 multichannel regions disc-wide — but not why, and a fix guessed
//! from one movie would be worse than a documented defect. This reproduces the
//! resolver's own steps and prints each candidate, so the failing branch is visible
//! rather than inferred.
//!
//! The suspicion the code itself raises: the start is filtered by
//! `end - s < 1_500_000` — "only within one bank" — and `ADV`'s region has to span
//! **3.6 MB**. If that filter rejects the real predecessor, `start` silently falls
//! back to `anchor`, which is a TOC offset and not a stream boundary at all.
//!
//! cargo run -p sylpheed-formats --example voice_region_start_why
use sylpheed_formats::hash::name_hash;
use sylpheed_formats::media::{DirectorySource, DiscSource};
use sylpheed_formats::pak::PakArchive;
use sylpheed_formats::slb::VoiceLang;
use sylpheed_formats::{movie_manifest, movie_voice};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
let code = VoiceLang::English.code_pub();
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let manifest = tpak
.entries().iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("manifest");
let marker = format!("{code}\\Movie\\VOICE_ADV.slb");
let registry = tpak.entries().iter().find_map(|e| {
tpak.read(e).ok().filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
}).expect("registry");
let ids = movie_voice::registry_voice_ids(&registry);
let stoc = src.read_file("dat/sound.pak").expect("sound.pak");
let entries = PakArchive::parse_toc(&stoc).expect("toc");
println!("{:10} {:>6} {:>12} {:>12} {:>12} {:>10} {:>9} {}",
"movie","id","anchor","pred(id-1)","pred(before)","span","chosen","note");
for m in movie_manifest::parse(&manifest) {
let movie = m.movie;
let Some(token) = movie_manifest::voice_token(&manifest, &movie) else { continue };
let Some(&id) = ids.get(&token) else { continue };
let Some(anchor) = ["Movie","etc","Voice"].iter().find_map(|dir| {
let h = name_hash(&format!("{code}\\{dir}\\{token}.slb"));
entries.binary_search_by_key(&h, |e| e.name_hash).ok().map(|i| entries[i].offset as u64)
}) else { continue };
let win_start = anchor.saturating_sub(2*1024*1024) & !3;
let Ok(window) = src.read_segment_range("dat/sound", win_start, 8*1024*1024) else { continue };
let Some(end_local) = movie_voice::find_descriptor(&window, id) else { continue };
let end = win_start + end_local as u64;
let p1 = movie_voice::find_descriptor(&window, id.wrapping_sub(1)).map(|o| win_start + o as u64);
let pb = movie_voice::find_descriptor_before(&window, end_local).map(|o| win_start + o as u64);
let cand = p1.or(pb);
// the resolver's own filter
let kept = cand.filter(|&s| s < end && end - s < 1_500_000);
let chosen = kept.unwrap_or(anchor);
let span = cand.map(|s| end.saturating_sub(s)).unwrap_or(0);
let note = match (cand, kept) {
(Some(_), None) => "REJECTED by the 1.5 MB filter -> fell back to anchor",
(Some(_), Some(_)) => "predecessor kept",
(None, _) => "no predecessor found -> anchor",
};
println!("{movie:10} {id:>6} {anchor:>12} {:>12} {:>12} {span:>10} {:>9} {note}",
p1.map(|v| v.to_string()).unwrap_or("-".into()),
pb.map(|v| v.to_string()).unwrap_or("-".into()),
if chosen == anchor { "anchor" } else { "pred" });
}
}

View File

@@ -1,205 +0,0 @@
//! Who owns the bytes in front of a movie-voice region's first `RIFF`?
//!
//! [`voice-region-leading-chunk.md`] left one thing open: the leading chunk of
//! the 17 stream-opening regions is real XMA audio that **no other movie-voice
//! region claims** — but the census only enumerated the 95 movie cues, while the
//! same continuous stream also carries the in-mission `VOICE_D_*` lines. The
//! leading hypothesis was that the bytes belong to one of those, and the port
//! pointed out that the byte-span test already written settles it *without
//! anyone listening* if the enumeration is widened.
//!
//! So this widens it the whole way: rather than resolving cues one at a time
//! through the manifest, scan the stream itself for **every** trailer descriptor
//! — the `(id: u32be, 0x11, …)` pair whose id repeats at `+0x800`, which
//! `movie_voice` documents as the end of a cue's audio. Cue N's audio is
//! `[descriptor(N-1) .. descriptor(N)]`, so the full descriptor list IS the
//! complete cue partition of the stream, movie and mission alike.
//!
//! cargo run -p sylpheed-formats --example voice_stream_cue_map -- <disc-dir>
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::slb::VoiceLang;
const DESC_MARK: u32 = 0x11;
const DESC_REPEAT: usize = 0x800;
const ID_MAX: u32 = 0x1_0000;
/// Every trailer descriptor in `buf`, as `(offset, id)`.
///
/// Same predicate `movie_voice::find_descriptor` uses — the id-repeat at +0x800
/// is what makes a false match inside XMA audio ~2^-64.
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
let mut out = Vec::new();
if buf.len() < DESC_REPEAT + 8 {
return out;
}
let end = buf.len() - (DESC_REPEAT + 4);
let mut o = 0;
while o <= end {
let id = be(o);
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
out.push((o, id));
}
o += 4;
}
out
}
fn main() {
let disc = std::env::args()
.nth(1)
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc dir"));
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
// The cue-name -> id registry, so a descriptor id can be named.
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let marker = "eng\\Movie\\VOICE_ADV.slb";
let registry = tpak
.entries()
.iter()
.find_map(|e| {
tpak.read(e)
.ok()
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
})
.expect("voice registry");
let ids = sylpheed_formats::movie_voice::registry_voice_ids(&registry);
let name_of: std::collections::HashMap<u32, String> =
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
println!("registry: {} cue names, {} distinct ids", ids.len(), name_of.len());
// The 17 regions that open with a headerless stream, from the manifest.
let movies: Vec<String> = {
use sylpheed_formats::movie_manifest;
let manifest = tpak
.entries()
.iter()
.find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b)))
.expect("manifest");
movie_manifest::parse(&manifest).into_iter().map(|m| m.movie).collect()
};
let mut regions = Vec::new();
for m in &movies {
if let Some((s, e)) = media::resolve_movie_voice_region(&src, m, VoiceLang::English) {
regions.push((m.clone(), s, e));
}
}
let lo = regions.iter().map(|r| r.1).min().unwrap();
let hi = regions.iter().map(|r| r.2).max().unwrap();
// Scan a window covering every region, with margin for cues either side.
let win_start = lo.saturating_sub(8 * 1024 * 1024) & !3;
let win_len = (hi - win_start + 8 * 1024 * 1024) as usize;
println!("scanning dat/sound {win_start}..{} ({:.1} MB)", win_start + win_len as u64,
win_len as f64 / 1e6);
let buf = src
.read_segment_range("dat/sound", win_start, win_len)
.expect("stream window");
let descs = all_descriptors(&buf);
println!("{} trailer descriptors found\n", descs.len());
let named = descs.iter().filter(|(_, id)| name_of.contains_key(id)).count();
println!(" of those, {named} carry an id the registry names, {} do not\n",
descs.len() - named);
// For each stream-opening region, name the cue that OWNS the leading span:
// the cue whose [prev_desc .. desc] interval contains it.
println!("leading span -> owning cue\n");
let mut verdicts: std::collections::BTreeMap<&str, usize> = Default::default();
for (m, s, e) in &regions {
let Ok(bytes) = src.read_segment_range("dat/sound", *s, (e - s) as usize) else { continue };
let Some(fr) = bytes.windows(4).position(|w| w == b"RIFF") else { continue };
if fr == 0 || sylpheed_formats::slb::bank_header_len(&bytes) == Some(fr) {
continue; // bank-header case, not ours
}
let (a, b) = (*s, *s + fr as u64); // the leading span, global offsets
// Descriptors bracketing the MIDDLE of the leading span.
let mid = (a + b) / 2;
let mid_local = (mid - win_start) as usize;
let before = descs.iter().rev().find(|(o, _)| (*o as u64) < mid_local as u64);
let after = descs.iter().find(|(o, _)| *o >= mid_local);
let owner = after.map(|(_, id)| *id);
let owner_name = owner
.and_then(|id| name_of.get(&id).cloned())
.unwrap_or_else(|| owner.map(|i| format!("<unnamed id {i}>")).unwrap_or("<none>".into()));
let kind = if owner_name.starts_with("VOICE_D_") {
"MISSION line"
} else if owner_name.starts_with("VOICE_") {
"movie cue"
} else {
"unknown"
};
*verdicts.entry(kind).or_default() += 1;
println!(
" {m:8} lead {:8} B bracketed by desc@{:?} .. desc@{:?} owner {owner_name} [{kind}]",
b - a,
before.map(|(o, i)| (*o as u64 + win_start, *i)),
after.map(|(o, i)| (*o as u64 + win_start, *i)),
);
}
println!("\nverdicts: {verdicts:?}");
// WHY do exactly these 17 open mid-cue? `resolve_movie_voice_region` takes
// the predecessor trailer as the region start, but guards it with
// `end - start < 1_500_000` and falls back to the .slb TOC anchor when that
// fails. If the guard is the cause, then the stream-opening regions are
// exactly the cues whose true span exceeds the guard.
println!("\ncue span vs the 1.5 MB guard, and what the region actually starts at:\n");
let (mut over, mut under, mut over_is_stream, mut under_is_stream) = (0, 0, 0, 0);
for (m, s, e) in &regions {
let Ok(bytes) = src.read_segment_range("dat/sound", *s, (e - s) as usize) else { continue };
let fr = bytes.windows(4).position(|w| w == b"RIFF");
let is_stream = matches!(fr, Some(f) if f > 0
&& sylpheed_formats::slb::bank_header_len(&bytes) != Some(f));
// The true predecessor trailer for this cue, from the full descriptor list.
let end_local = (*e - win_start) as usize;
let prev = descs.iter().rev().find(|(o, _)| *o < end_local).map(|(o, _)| *o as u64 + win_start);
let Some(prev) = prev else { continue };
let span = e - prev;
let guarded = span >= 1_500_000;
if guarded { over += 1; if is_stream { over_is_stream += 1 } }
else { under += 1; if is_stream { under_is_stream += 1 } }
if is_stream {
println!(
" {m:8} true cue span {span:8} B (> guard: {guarded}) region starts at {s}, \
true start {prev} -> {} B of the cue's own audio is OUTSIDE the region",
s.saturating_sub(prev)
);
}
}
println!("\ncues over the 1.5 MB guard: {over}, of which stream-opening: {over_is_stream}");
println!("cues under the guard: {under}, of which stream-opening: {under_is_stream}");
// How many streams is ONE cue stored as? The port measured that a region's
// leading chunk is the TAIL of its first full-length chunk, i.e. the cue is
// re-presented. Structurally that predicts a fixed number of stream starts
// inside a cue's TRUE span [desc(N-1) .. desc(N)] -- which is measurable
// from the bytes alone, with no decoder.
println!("\nstream starts inside each cue's TRUE span (desc(N-1)..desc(N)):\n");
let mut hist: std::collections::BTreeMap<usize, usize> = Default::default();
let mut hist_long: std::collections::BTreeMap<usize, usize> = Default::default();
for w in descs.windows(2) {
let (a, b) = (w[0].0, w[1].0);
if b <= a || b - a < 4096 {
continue;
}
let span = &buf[a..b];
// A stream start is a RIFF; plus the run before the first one, when it
// is not a bank header, is itself a stream.
let riffs = span
.windows(4)
.enumerate()
.filter(|(_, w)| *w == b"RIFF")
.count();
let lead_is_stream = match span.windows(4).position(|w| w == b"RIFF") {
Some(f) if f > 0 => sylpheed_formats::slb::bank_header_len(span) != Some(f),
_ => false,
};
let streams = riffs + usize::from(lead_is_stream);
*hist.entry(streams).or_default() += 1;
if b - a >= 1_500_000 {
*hist_long.entry(streams).or_default() += 1;
}
}
println!(" all inter-descriptor spans: {hist:?}");
println!(" spans >= 1.5 MB (the long cues): {hist_long:?}");
}

View File

@@ -1,122 +0,0 @@
//! Are the three streams of a voice cue in a CONSISTENT size relationship?
//!
//! The port selected a voice presentation on this argument: `ADV` chunk 1 is
//! mono-in-stereo (channel 2 digitally silent) and chunk 2 is dual-mono (both
//! channels identical), so chunk 2's extra bytes encode a duplicate channel
//! rather than fidelity — which would explain its higher declared
//! `PsuedoBytesPerSec` without appealing to encode quality.
//!
//! That is a claim about the *encoding*, and it makes a structural prediction:
//! if stream 3 is always "the same take with its channel duplicated", it should
//! sit in a consistent size ratio to stream 2 across every 3-stream cue on the
//! disc. If the ratio scatters — or if some third streams are tiny — then the
//! observation is about `ADV`, not about the format.
//!
//! cargo run -p sylpheed-formats --example voice_three_stream_sizes -- <disc>
use sylpheed_formats::media::{DirectorySource, DiscSource};
use sylpheed_formats::slb;
const DESC_MARK: u32 = 0x11;
const DESC_REPEAT: usize = 0x800;
const ID_MAX: u32 = 0x1_0000;
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
let mut out = Vec::new();
if buf.len() < DESC_REPEAT + 8 {
return out;
}
let end = buf.len() - (DESC_REPEAT + 4);
let mut o = 0;
while o <= end {
let id = be(o);
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
out.push((o, id));
}
o += 4;
}
out
}
/// The declared `PsuedoBytesPerSec` at `fmt +0x20` of a RIFF chunk, if present.
fn declared_rate(riff: &[u8]) -> Option<u32> {
if riff.len() < 0x28 || &riff[0..4] != b"RIFF" {
return None;
}
Some(u32::from_le_bytes(riff[0x20..0x24].try_into().ok()?))
}
fn main() {
let disc = std::env::args()
.nth(1)
.unwrap_or_else(|| std::env::var("SYLPHEED_DISC").expect("disc"));
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let marker = "eng\\Movie\\VOICE_ADV.slb";
let registry = tpak
.entries()
.iter()
.find_map(|e| {
tpak.read(e)
.ok()
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
})
.expect("registry");
let ids = sylpheed_formats::movie_voice::registry_voice_ids(&registry);
let name_of: std::collections::HashMap<u32, String> =
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
// Same window the cue map uses.
let win_start: u64 = 421_739_888 & !3;
let win_len: usize = 116_300_000;
let buf = src
.read_segment_range("dat/sound", win_start, win_len)
.expect("window");
let descs = all_descriptors(&buf);
println!("{:<14} {:>10} {:>10} {:>10} {:>7} {:>9} {:>9}",
"cue", "stream1", "stream2", "stream3", "s3/s2", "rate2", "rate3");
let mut ratios: Vec<f64> = Vec::new();
let mut tiny = 0;
for w in descs.windows(2) {
let (a, b) = (w[0].0, w[1].0);
if b <= a || b - a < 4096 {
continue;
}
let span = &buf[a..b];
let riffs = slb::to_xma_riffs(span);
if riffs.len() != 3 {
continue;
}
let name = name_of
.get(&w[1].1)
.cloned()
.unwrap_or_else(|| format!("id{}", w[1].1));
let (s1, s2, s3) = (riffs[0].len(), riffs[1].len(), riffs[2].len());
let r = s3 as f64 / s2 as f64;
ratios.push(r);
if r < 0.5 {
tiny += 1;
}
println!(
"{:<14} {s1:>10} {s2:>10} {s3:>10} {r:>7.4} {:>9} {:>9}",
name.trim_start_matches("VOICE_"),
declared_rate(&riffs[1]).map(|v| v.to_string()).unwrap_or("-".into()),
declared_rate(&riffs[2]).map(|v| v.to_string()).unwrap_or("-".into()),
);
}
ratios.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = ratios.len();
println!("\n{n} three-stream cues");
if n > 0 {
let mean = ratios.iter().sum::<f64>() / n as f64;
let var = ratios.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n as f64;
println!(
" stream3/stream2 ratio: min {:.4} median {:.4} max {:.4} mean {:.4} sd {:.4}",
ratios[0], ratios[n / 2], ratios[n - 1], mean, var.sqrt()
);
println!(" cues where stream 3 is less than HALF of stream 2: {tiny}");
let near = ratios.iter().filter(|r| (**r - 1.0).abs() < 0.15).count();
println!(" cues where stream 3 is within 15% of stream 2: {near} of {n}");
}
}

View File

@@ -97,14 +97,6 @@ pub struct AudioInfo {
pub size_bytes: usize,
/// 2048-byte XMA packet count, for XMA/raw-XMA streams.
pub xma_packets: Option<u32>,
/// The stream's **declared** average bytes per second.
///
/// For XMA1 this is `XMASTREAMFORMAT::PsuedoBytesPerSec`. It is what makes a
/// duration available for a codec we cannot decode: `data_bytes / this`
/// agreed with an independently decoded duration to **0.01 %** on the two
/// movie voices it was checked against
/// (`docs/re/structures/voice-region-leading-chunk.md`).
pub avg_bytes_per_sec: Option<u32>,
}
impl AudioInfo {
@@ -118,7 +110,6 @@ impl AudioInfo {
duration_secs: None,
size_bytes: size,
xma_packets: None,
avg_bytes_per_sec: None,
}
}
@@ -190,7 +181,6 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
let mut pos = 12;
let (mut tag, mut channels, mut rate, mut bits) = (0u16, 0u16, 0u32, 0u16);
let mut avg_bps = 0u32;
let mut data_bytes: Option<u64> = None;
let mut have_fmt = false;
@@ -201,37 +191,13 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
match id {
b"fmt " if body + 16 <= bytes.len() => {
tag = le16(body);
// 🔴 XMA1 is NOT a WAVEFORMATEX. Reading it as one is where
// `audio info` got "16 channels, 4310 Hz, 2-bit" from the
// movie voices: 16 is `wBitsPerSample` read as channels, and
// 4310 is `wEncodeOptions` (0x10d6) read as a sample rate.
//
// XMA1 carries `XMAWAVEFORMAT`, then one `XMASTREAMFORMAT` per
// stream (xenia-canary `src/xenia/apu/xma_context.h`,
// cross-checked against the disc's own movie-voice headers):
//
// +0 wFormatTag +2 wBitsPerSample +4 wEncodeOptions
// +6 wLargestSkip +8 wNumStreams +10 bLoopCount (u8)
// +11 bStreamCount (u8)
// +12 PsuedoBytesPerSec +16 SampleRate +20 LoopStart
// +24 LoopEnd +28 SubframeData (u8) +29 Channels (u8)
// +30 ChannelMask
if tag == WAVE_FORMAT_XMA && body + 32 <= bytes.len() {
bits = le16(body + 2);
avg_bps = le32(body + 12);
rate = le32(body + 16);
channels = bytes[body + 29] as u16;
} else {
channels = le16(body + 2);
rate = le32(body + 4);
bits = le16(body + 14);
avg_bps = le32(body + 8);
// WAVE_FORMAT_EXTENSIBLE stores the real tag in the GUID's
// first two bytes, right after cbSize (+2) → +24 from the
// fmt body.
if tag == WAVE_FORMAT_EXTENSIBLE && body + 26 <= bytes.len() {
tag = le16(body + 24);
}
channels = le16(body + 2);
rate = le32(body + 4);
bits = le16(body + 14);
// WAVE_FORMAT_EXTENSIBLE stores the real tag in the GUID's first
// two bytes, right after cbSize (+2) → +24 from the fmt body.
if tag == WAVE_FORMAT_EXTENSIBLE && body + 26 <= bytes.len() {
tag = le16(body + 24);
}
have_fmt = true;
}
@@ -256,15 +222,6 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
info.channels = Some(channels).filter(|&c| c > 0);
info.sample_rate = Some(rate).filter(|&r| r > 0);
info.bits_per_sample = Some(bits).filter(|&b| b > 0);
info.avg_bytes_per_sec = Some(avg_bps).filter(|&b| b > 0);
// A declared byte rate gives a duration for a codec we cannot decode. Only
// for XMA1, where the field is `PsuedoBytesPerSec` and means exactly this.
if codec == AudioCodec::Xma && avg_bps > 0 {
if let Some(d) = data_bytes {
info.duration_secs = Some(d as f32 / avg_bps as f32);
}
}
match codec {
AudioCodec::Pcm | AudioCodec::PcmFloat => {
@@ -399,58 +356,6 @@ mod tests {
assert!((audio.samples[2] - 0.99997).abs() < 1e-3); // 32767/32768
}
/// XMA1 is not a `WAVEFORMATEX`, and reading it as one produced nonsense.
///
/// The bytes here are the real `fmt ` chunk of `ADV`'s first movie-voice
/// presentation, copied off the disc. Read as a `WAVEFORMATEX` it reports
/// **16 channels, 4310 Hz, 2-bit** — 16 is `wBitsPerSample`, 4310 is
/// `wEncodeOptions` (`0x10d6`). Read as an `XMAWAVEFORMAT` it reports 2
/// channels, 48 kHz, 16-bit, 8142 B/s.
///
/// The duration is the part worth guarding: this crate has no XMA decoder,
/// and `data_bytes / PsuedoBytesPerSec` is the only route to one. It agrees
/// with an independently decoded 137.324 s to **0.02 %**.
#[test]
fn xma1_fmt_is_not_a_waveformatex() {
let mut v = Vec::new();
v.extend_from_slice(b"RIFF");
v.extend_from_slice(&0u32.to_le_bytes());
v.extend_from_slice(b"WAVE");
v.extend_from_slice(b"fmt ");
v.extend_from_slice(&32u32.to_le_bytes());
// XMAWAVEFORMAT, exactly as it appears on the disc.
v.extend_from_slice(&[
0x65, 0x01, // wFormatTag = 0x0165 (XMA1)
0x10, 0x00, // wBitsPerSample = 16
0xd6, 0x10, // wEncodeOptions = 0x10d6 <- was misread as the rate
0x00, 0x00, // wLargestSkip
0x01, 0x00, // wNumStreams
0x00, // bLoopCount
0x02, // bStreamCount
0xce, 0x1f, 0x00, 0x00, // PsuedoBytesPerSec = 8142
0x80, 0xbb, 0x00, 0x00, // SampleRate = 48000
0x00, 0x00, 0x00, 0x00, // LoopStart
0x00, 0x00, 0x00, 0x00, // LoopEnd
0x00, // SubframeData
0x02, // Channels = 2 <- was read from +2 as 16
0x02, 0x00, // ChannelMask
]);
v.extend_from_slice(b"data");
v.extend_from_slice(&1_118_208u32.to_le_bytes());
let info = AudioInfo::probe(&v);
assert_eq!(info.codec, AudioCodec::Xma);
assert_eq!(info.channels, Some(2), "channels came from wBitsPerSample");
assert_eq!(info.sample_rate, Some(48_000), "rate came from wEncodeOptions");
assert_eq!(info.bits_per_sample, Some(16));
assert_eq!(info.avg_bytes_per_sec, Some(8142));
let d = info.duration_secs.expect("duration from the declared byte rate");
assert!(
(d - 137.324).abs() < 0.05,
"declared-rate duration {d} should match the decoded 137.324 s"
);
}
#[test]
fn probe_xma2_riff_reports_metadata_not_decode() {
// Minimal RIFF/WAVE with an XMA2 fmt tag.

View File

@@ -288,29 +288,12 @@ pub fn resolve_movie_voice_region<S: DiscSource + ?Sized>(
// Start = the predecessor trailer. Prefer the exact `id-1`; where the id
// sequence has a gap (VOICE_D_453 → 454) fall back to the nearest trailer
// below.
//
// 🔴 There used to be a second condition here — `end - s < 1_500_000`, "only
// within one bank, else this is the first cue in its block and the audio
// starts at the anchor itself". **It was wrong, and it silently truncated the
// first stream of every region larger than 1.5 MB.** `anchor` is a TOC offset,
// not a stream boundary, so the fallback started mid-packet-run: `ADV` began
// **238 packets (487 424 B) into its own first stream**, and a consumer then
// saw a leading chunk that "matched nothing" and dropped 62 % of a real stream.
//
// Ground truth is the running decoder, which reports `ADV`'s three contexts as
// 1 294 336 / 1 118 208 / 1 171 456 (`--xma_param_probe`). With the cap gone the
// region reproduces all three exactly; with it, the first is 806 912.
//
// Disc-wide over the 95 manifest movies that resolve: **17 regions fixed, 78
// unchanged, 0 changed in any other way** — in every one of the 17 the first
// chunk grows and the remaining chunks are byte-identical, which is what a
// corrected start looks like and what pulling in a neighbouring asset does not.
// `docs/re/structures/voice-region-starts-late.md`.
// below — but only within one bank (~1.5 MB), else this is the first cue in
// its block and the audio starts at the anchor itself.
let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1))
.or_else(|| movie_voice::find_descriptor_before(&window, end_local))
.map(|o| win_start + o as u64)
.filter(|&s| s < end)
.filter(|&s| s < end && end - s < 1_500_000)
.unwrap_or(anchor);
Some((start, end))
}

View File

@@ -377,39 +377,6 @@ 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);
@@ -455,15 +422,7 @@ 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 {
// 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));
let start = 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,22 +111,12 @@ pub struct Keyframe {
/// starts are negative.
pub x: i32,
pub y: i32,
/// The time at which this pose is reached.
/// Keyframe time, or `None` for the group's **last** frame.
///
/// ✅ 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.
/// 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`].
pub time: Option<u32>,
}
@@ -192,90 +182,13 @@ impl Element {
///
/// Falls back to the longest-dwell rule when no two adjacent keyframes
/// agree — a group that ramps through every frame and never holds.
/// The element's pose at keyframe time `t`, linearly interpolated.
///
/// `rest()` returns the last HOLD keyframe **of one element, chosen
/// independently of every other element**. That is the wrong pose twice over:
///
/// * for anything still moving — the title's light sweeps hold at `x = 1521`,
/// off the right edge, so a resting composite deletes them rather than
/// settling them;
/// * 🔴 and for anything **transient**. `ptlogo_back2eff1` is a two-frame
/// flash (`a=0` until t52, `255` at t5456, `0` again by t58); its last
/// hold *is* the flash peak, so `rest()` leaves it burning forever. Five
/// such flashes stack on the title and blow the light arc out to pure
/// white — see `docs/re/structures/ui-settle-time.md`.
///
/// A settled screen is one INSTANT that every element is posed at, which is
/// what [`UiBuild::settle_time`] computes and `ComposeOptions::at` applies.
///
/// The ramp is linear (`docs/re/ui-keyframe-time-unit.md`), and a group
/// **holds** at its last keyframe rather than looping, so `t` past the end
/// clamps.
pub fn pose_at(&self, t: u32) -> Option<Keyframe> {
let ks = &self.keyframes;
if ks.is_empty() {
return None;
}
let timed: Vec<(u32, &Keyframe)> =
ks.iter().filter_map(|k| k.time.map(|tt| (tt, k))).collect();
if timed.is_empty() {
return Some(ks[ks.len() - 1].clone());
}
if t <= timed[0].0 {
return Some(timed[0].1.clone());
}
if t >= timed[timed.len() - 1].0 {
return Some(timed[timed.len() - 1].1.clone());
}
for w in timed.windows(2) {
let ((t0, a), (t1, b)) = (w[0], w[1]);
if t >= t0 && t <= t1 {
if t1 == t0 {
return Some(b.clone());
}
let f = (t - t0) as f64 / (t1 - t0) as f64;
let li = |x: i32, y: i32| x + ((y - x) as f64 * f).round() as i32;
let lu = |x: u32, y: u32| (x as f64 + (y as f64 - x as f64) * f).round() as u32;
// ARGB / RGBA words interpolate per BYTE, not as integers.
let lc = |x: u32, y: u32| {
let mut o = 0u32;
for sh in [24, 16, 8, 0] {
let (cx, cy) = ((x >> sh) & 0xff, (y >> sh) & 0xff);
o |= (lu(cx, cy) & 0xff) << sh;
}
o
};
return Some(Keyframe {
fade: lc(a.fade, b.fade),
rotation_deg: li(a.rotation_deg, b.rotation_deg),
unknown_4: li(a.unknown_4, b.unknown_4),
unknown_8: li(a.unknown_8, b.unknown_8),
scale_x: lu(a.scale_x, b.scale_x),
scale_y: lu(a.scale_y, b.scale_y),
tint: lc(a.tint, b.tint),
x: li(a.x, b.x),
y: li(a.y, b.y),
time: Some(t),
});
}
}
Some(timed[timed.len() - 1].1.clone())
}
pub fn rest(&self) -> Option<&Keyframe> {
// `lastall`: the LAST keyframe for every element, bypassing the plateau
// rule entirely.
//
// ⚠️ ITS STATED PURPOSE IS RETIRED (corrected 2026-08-30). This comment
// read: "This is what the shifted time reading predicts — under it the
// final pose is reached at a definite time and nothing follows, so
// 'rest' needs no heuristic. Testing it against the captures is an
// independent check on that reading." The shifted reading was **refuted**
// by the record-layout fix above, so this override no longer checks
// anything about it. It survives only as a plain "take the last
// keyframe" diagnostic, alongside the documented `last` and `maxalpha`
// (see `docs/re/structures/ui-resting-pose.md`).
// rule entirely. This is what the shifted time reading predicts — under
// it the final pose is reached at a definite time and nothing follows,
// so "rest" needs no heuristic. Testing it against the captures is an
// independent check on that reading, from static composites rather than
// from animation timing.
if std::env::var("SYLPHEED_REST_RULE").as_deref() == Ok("lastall") {
return self.keyframes.last();
}
@@ -311,16 +224,7 @@ impl Element {
let (Some(t0), Some(t1)) =
(self.keyframes[k].time, self.keyframes[k + 1].time)
else {
// ⚠️ PRE-FIX COMMENT, corrected 2026-08-30. This read
// "the last frame carries no time", which was the rule
// BEFORE the record-layout fix directly above. Post-fix
// every pose is timed — measured at **0 untimed of
// 24 811 keyframes** across 965 builds — so this branch
// is unreachable on this disc. Kept as a guard because
// `time` is still `Option<u32>` and a malformed group
// could produce `None`; it is no longer a description of
// the format.
continue;
continue; // the last frame carries no time
};
let dwell = t1.saturating_sub(t0);
// `>=`, not `>`: on a tie take the LATER frame. A group is
@@ -498,30 +402,6 @@ fn opt_link(rec: &[u8]) -> Option<String> {
(!s.is_empty()).then_some(s)
}
/// A RATC record's animation **loop length** in keyframe units — its `+0x08`.
///
/// Works at either level: a nested `.rat` leaf is itself a RATC bundle with the
/// same header shape as the one containing it, so this reads a whole screen
/// build's length and a single record's length through one path.
///
/// **Why it is public.** The loop length is not the largest keyframe time —
/// `ptbtn00f.rat`, the `PRESS Ⓐ` plate glow, declares **120** while its last
/// keyframe is at **105**, and that 15-unit slack is the plate holding dark
/// between cycles. A consumer that infers the period from the keyframes gets
/// 105 (1.750 s) against a real pulse measured four times at 2.122.34 s.
/// Decoded disc-wide: 1 781 records, **0** violations of
/// `+0x08 >= largest keyframe time` — see
/// `docs/re/structures/ui-record-loop-length.md`.
///
/// Returns `None` for anything that is not a RATC record, so it is safe to call
/// on an arbitrary slice; callers do not need their own magic guard.
pub fn loop_length_units(rec: &[u8]) -> Option<u32> {
if rec.len() < 0x0c || rec[0..4] != *b"RATC" {
return None;
}
Some(be32(rec, 0x08))
}
/// The sprite a `.rat` record places: a NUL-terminated name at `0x20`.
///
/// The field is **not** 16 bytes. Capping it there truncates every longer name —
@@ -613,10 +493,8 @@ 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> {
// 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");
// Experiment gate, default off; see the `time` field below.
let shift_times = std::env::var("SYLPHEED_KF_TIME_SHIFT").as_deref() == Ok("1");
let count = elements.len();
let mut order = Vec::with_capacity(count);
let mut pos = DECL_TABLE_AT + count * DECL_ENTRY;
@@ -629,13 +507,7 @@ fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
if idx >= count || frames == 0 || frames > 4096 {
break;
}
// 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);
// Group header is (index, count) then one lead-in word; blocks follow.
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
@@ -657,17 +529,16 @@ 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,
// 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)
// 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 {
Some(be32(bundle, blk - KEYFRAME + 36))
(blk + 40 <= group_end).then(|| be32(bundle, blk + 36))
},
});
}
@@ -853,12 +724,6 @@ pub struct ComposeOptions {
/// Left on with the derived order, an opaque black quad sorts last and wipes
/// 32 of `GP_DIALOG`'s builds.
pub include_primitives: bool,
/// Pose every element at this keyframe time instead of at its resting pose.
///
/// `None` keeps the settled composite, which is what every existing caller
/// wants. A capture taken mid-animation needs the render posed at the same
/// instant — see [`Element::pose_at`].
pub at: Option<u32>,
}
impl Default for ComposeOptions {
@@ -868,7 +733,6 @@ impl Default for ComposeOptions {
include_animated: false,
backdrop: [14, 14, 20, 255],
include_primitives: false,
at: None,
}
}
}
@@ -961,99 +825,6 @@ pub fn implied_layer_key(name: &str) -> Option<u32> {
/// they land does not affect a composite. Ties keep declaration order — the game
/// breaks them some other way, which is unexplained and looks harmless because
/// tied elements are same-layer.
/// Is this element an opaque full-screen quad that **must** sort below everything?
///
/// A keyless primitive has no layer key and the game's own code decides where it
/// paints ([`implied_layer_key`] records the names measured in the running game).
/// For one whole class of them the file settles it without a measurement: an
/// element that covers the screen and is **fully opaque** at some instant cannot
/// paint above anything visible at that instant, or the screen would be blank.
/// Where the elements visible during its opaque span are *all* of them, its
/// position is forced to first.
///
/// Two controls, both measured in the running game and both reproduced by this
/// rule rather than assumed by it:
///
/// * `palogo_eff0.prm` is measured painting **first** — and comes out forced
/// first (opaque for 211 instants, below 6 of 6). A rule keyed on the *name*
/// would get this wrong: it is named like an overlay.
/// * `pteff00.prm` is measured painting **last** — and is forced below only 3 of
/// 23 elements on the title, because it is opaque for 2 instants at the screen's
/// entry and exit, so the rule permits it on top.
///
/// Disc-wide: 80 instances forced first, 50 constrained but not forced, 0
/// unconstrained. It also explains the 36 dialog builds that composite to one
/// colour — `pzeff00.prm` is forced first in 32 of 32 instances.
///
/// ⚠️ Assumes straight alpha-over blending. Blend mode is an open question in
/// `docs/re/structures/ui-prm-primitives.md`; an *additive* quad at alpha 255
/// would not occlude, and this rule would then be placing it wrongly.
pub fn forced_backdrop(build: &UiBuild, el: &Element) -> bool {
// 🔴 Untextured primitives only. A `.t32` sprite's ELEMENT alpha being 255
// says nothing about whether its texture covers the screen — most of it may
// be transparent, so it occludes nothing. Applied without this guard the
// rule claims 22 textured sprites must sort first, against their own layer
// keys: `pneff01.t32` (key 0xd850, paints #8 of 13) and `pbfriendly.t32`
// (key 0x9230, #17 of 49). Those disagreements are the rule being wrong,
// not the keys.
if el.sprite.is_some() {
return false;
}
// The element must have a declared size at all; coverage itself is tested
// per instant below, against the SCALED size.
if el.pivot_x == 0 || el.pivot_y == 0 {
return false;
}
let tmax = build
.elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
.max()
.unwrap_or(0);
if tmax == 0 {
return false;
}
// An instant counts only where the element is BOTH fully opaque AND actually
// covering — tested together, because both animate on the same ramp.
//
// ⚠️ Coverage is the SCALED size, not the declared one, and the test must be
// two-sided. A quad scaled down does not cover what its pivot suggests —
// `pbafc.prm` declares 844x600 and draws ~17x18 at 2 %/3 %. And a quad scaled
// *up* can cover from a smaller declared size, so rejecting on the declared
// size would replace one error with its mirror. Checked before adopting:
// across 921 keyless elements, **0** cover the screen only via scale, so the
// mirror case does not occur on this disc — the per-instant test is in
// because it does not need that to stay true.
let (dw, dh) = ((el.pivot_x * 2) as u64, (el.pivot_y * 2) as u64);
let opaque: Vec<u32> = (0..=tmax)
.filter(|&t| {
el.pose_at(t).map_or(false, |k| {
k.fade >> 24 == 255
&& dw * k.scale_x as u64 / 100 >= build.design_w as u64
&& dh * k.scale_y as u64 / 100 >= build.design_h as u64
})
})
.collect();
if opaque.is_empty() {
return false;
}
let mut others = 0usize;
let mut occluded = 0usize;
for o in &build.elements {
if o.index == el.index {
continue;
}
others += 1;
if opaque
.iter()
.any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0)
{
occluded += 1;
}
}
others > 0 && occluded == others
}
pub fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
idx.sort_by_key(|&i| {
@@ -1061,9 +832,6 @@ pub fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec<usize> {
(
sprite_layer_key(build, bundle, el)
.or_else(|| implied_layer_key(&el.name))
// An opaque full-screen quad that covers every other element
// while it is opaque cannot be on top — see `forced_backdrop`.
.or_else(|| forced_backdrop(build, el).then_some(0))
.unwrap_or(u32::MAX),
i,
)
@@ -1132,73 +900,11 @@ fn measured_paint_order(build: &UiBuild) -> Option<Vec<usize>> {
None
}
impl UiBuild {
/// The instant at which this screen is *settled*, in keyframe time units.
///
/// A screen is not settled when each element sits at its own last hold —
/// that is what [`Element::rest`] gives, and it is wrong for a transient
/// (see the note there). It is settled at one shared instant, and the disc
/// says which: gather every keyframe time in the build, and take the
/// **longest interval containing none of them**. Inside that gap nothing has
/// an inflection, so every element is either holding or on a long linear
/// ramp — which is exactly what "the screen has stopped changing" means.
///
/// Returns the midpoint of that gap, or `None` when the build has fewer than
/// two distinct keyframe times.
///
/// ⚠️ **Not every bundle has a settled instant.** Disc-wide over the 1 758
/// composable bundles carrying two or more keyframe times, 30 % have a gap of
/// at least half a second and 42 % have one under 10 units — the latter are
/// mostly `loop*` animation fragments, which are *meant* to be in motion and
/// have no settled pose to find. Check the gap width before trusting the
/// midpoint; `settle_window` returns it.
pub fn settle_time(&self) -> Option<u32> {
self.settle_window().map(|(lo, hi)| lo + (hi - lo) / 2)
}
/// The `[start, end]` of the longest keyframe-free interval — see
/// [`UiBuild::settle_time`]. The width `end - start` is how much confidence
/// the midpoint deserves.
pub fn settle_window(&self) -> Option<(u32, u32)> {
let mut ts: Vec<u32> = self
.elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
.collect();
ts.sort_unstable();
ts.dedup();
if ts.len() < 2 {
return None;
}
ts.windows(2)
.map(|w| (w[1] - w[0], w[0], w[1]))
.max_by_key(|&(d, _, _)| d)
.map(|(_, lo, hi)| (lo, hi))
}
}
pub fn compose(
build: &UiBuild,
bundle: &[u8],
opts: ComposeOptions,
visible: Option<&[bool]>,
) -> ComposedScreen {
compose_with_order(build, bundle, opts, visible, None)
}
/// `compose`, with the paint order supplied by the caller.
///
/// The only reason this exists is to *measure* what a paint order costs: render
/// a screen twice, once with the order `compose` would pick and once with two
/// elements swapped, and diff the pixels. `order` is a permutation of element
/// indices, first painted first; `None` means "whatever `compose` would use".
/// Nothing in the normal render path passes anything but `None`.
pub fn compose_with_order(
build: &UiBuild,
bundle: &[u8],
opts: ComposeOptions,
visible: Option<&[bool]>,
order_override: Option<&[usize]>,
) -> ComposedScreen {
let (w, h) = (build.design_w, build.design_h);
// A dim backdrop stands in for the PRMD dim-quad + the live 3D scene behind
@@ -1222,10 +928,8 @@ pub fn compose_with_order(
// same-layer-key ties, two being total occlusions. Of the port's five
// screens only `EXTRAS` rests on a derived order with ties: 15 tied pairs,
// 2 overlapping. See docs/re/structures/ui-paint-order-derived-check.md.
let order: Vec<usize> = match order_override {
Some(o) => o.to_vec(),
None => measured_paint_order(build).unwrap_or_else(|| derived_paint_order(build, bundle)),
};
let order: Vec<usize> =
measured_paint_order(build).unwrap_or_else(|| derived_paint_order(build, bundle));
for &ei in &order {
let Some(el) = build.elements.get(ei) else {
continue;
@@ -1263,23 +967,7 @@ pub fn compose_with_order(
{
continue;
}
// 🔴 `at` poses LEAVES ONLY, never the top-level elements.
//
// Posing everything at one global time was tried and is wrong: a
// top-level group's final keyframes are its **exit ramp** — the fade-out
// played when the screen leaves — and `rest()` deliberately stops at the
// last *hold* keyframe before it. Posing the title at t=358 walked every
// parent into its exit and drove the render's disagreement with the
// capture from 10.92 to 61.74. The leaf is the thing still animating at
// that instant, and it runs on its own timeline
// (`docs/re/structures/ui-leaf-vs-parent-alpha.md`).
let Some(kf) = (match opts.at {
Some(t) => el.pose_at(t),
None => el.rest().cloned(),
}) else {
continue;
};
let kf = &kf;
let Some(kf) = el.rest() else { continue };
// An untextured primitive: a solid quad of the keyframe's `fade` colour,
// sized by the declared pivot. `kind & 0x10` marks these exactly — see
// `docs/re/structures/ui-prm-primitives.md`. They are the screen's
@@ -1311,65 +999,6 @@ pub fn compose_with_order(
missing.push(sprite.clone());
continue;
};
// A nested `.rat` leaf sometimes carries the geometry while the parent
// carries none — the title's light sweeps are the case: the parent sits
// fixed at (441,270) scale 100 %, and the leaf holds the 600 %/800 %
// scale, the +30°/45° rotation and the whole sweep. Drawing the parent
// put the sprite upright in the middle of the screen.
//
// ⚠️ NOT a blanket rule. A button's base record has a leaf that
// DUPLICATES it, and there the parent wins
// (`docs/re/structures/ui-button-focus-record.md`). The discriminator is
// which record actually carries geometry, so the leaf is used only when
// its pose genuinely differs — see `ui-leaf-vs-parent-alpha.md`.
let leaf = build.records.get(&el.name).and_then(|&(lo, ls)| {
if lo + ls > bundle.len() {
return None;
}
let lb = parse_build(&bundle[lo..lo + ls])?;
let pose = |e: &Element| match opts.at {
Some(t) => e.pose_at(t),
None => e.rest().cloned(),
};
let differs = lb.elements.iter().any(|le| {
pose(le).map_or(false, |lk| {
lk.rotation_deg != 0 || lk.scale_x != kf.scale_x || lk.scale_y != kf.scale_y
})
});
if differs { Some(lb) } else { None }
});
if let Some(lb) = leaf {
let mut any = false;
for le in &lb.elements {
let lk = match opts.at {
Some(t) => le.pose_at(t),
None => le.rest().cloned(),
};
let Some(lk) = lk else { continue };
// A leaf element resolves no sprite of its own: sprite names are
// resolved against the bundle a build was parsed from, and a leaf
// is parsed from its own slice. Its NAME is the sprite name, and
// the sprite itself lives in the PARENT bundle's table.
let lsp = le.sprite.clone().unwrap_or_else(|| le.name.clone());
let Some(&(so, ss)) = build.sprites.get(&lsp) else { continue };
let Some(limg) = t8ad::parse(&bundle[so..so + ss]) else { continue };
// 🔴 Only count it as drawn if it CAN draw. `blit` returns early
// on a zero scale — "collapsed to nothing", not "unset" — so
// setting the flag unconditionally would let a scale-0 leaf
// suppress its parent and blank the element outright.
// `pgloading_loop5`'s leaf is scale (0,0), and scale-0 is one of
// the failures this corpus is already named for.
if lk.scale_x == 0 || lk.scale_y == 0 {
continue;
}
blit(&mut canvas, w, h, &limg, &lk, le.pivot_x, le.pivot_y);
any = true;
}
if any {
drawn.push(el.index);
continue;
}
}
blit(&mut canvas, w, h, &img, kf, el.pivot_x, el.pivot_y);
drawn.push(el.index);
}
@@ -1488,11 +1117,6 @@ fn blit(
// Keep the pivot point fixed as the element scales.
let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100;
let oy = kf.y - (pivot_y as i32 * (sy_pct as i32 - 100)) / 100;
// The pivot's ABSOLUTE position is invariant under scale, which is the whole
// point of the two lines above: at 100 % `ox = kf.x` so the pivot sits at
// `kf.x + pivot_x`; at 200 % `ox = kf.x - pivot_x` and the pivot sits at
// `ox + 2·pivot_x`, the same place. So rotation turns about it.
let (pax, pay) = (kf.x + pivot_x as i32, kf.y + pivot_y as i32);
// Two modulate colours multiply into one: `tint` (RGBA, and `0xffffffff` on
// essentially every keyframe seen) and `fade` (**ARGB** — the high byte is
// the alpha that ramps, the low 24 bits a colour multiply that is `0xffffff`
@@ -1511,73 +1135,6 @@ fn blit(
((kf.tint >> 8) & 0xff) * fb / 255,
(kf.tint & 0xff) * fa / 255,
);
// ---- rotated path -------------------------------------------------------
// `+12` is a screen-plane rotation in DEGREES, clockwise-positive with Y
// down (`docs/re/structures/ui-keyframe-rotation.md`). The game submits
// rotated quads for it; this used to draw them axis-aligned, which put the
// title's two light sweeps upright instead of at +30° / 45° and left at
// least two thirds of that screen's disagreement with the capture
// (`title-residual-tone-vs-geometry.md`).
//
// Zero rotation keeps the original forward-mapped path byte for byte, so
// the screens that do not rotate cannot regress. A rotated element is drawn
// by INVERSE mapping instead: forward-mapping a rotation leaves gaps.
let rot = ((kf.rotation_deg % 360) + 360) % 360;
if rot != 0 {
let th = (rot as f64).to_radians();
let (cs, sn) = (th.cos(), th.sin());
// Axis-aligned bounds of the rotated destination rect.
let corners = [
(ox, oy),
(ox + dw as i32, oy),
(ox + dw as i32, oy + dh as i32),
(ox, oy + dh as i32),
];
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
for (cx, cy) in corners {
let (rx, ry) = ((cx - pax) as f64, (cy - pay) as f64);
let px = pax as f64 + rx * cs - ry * sn;
let py = pay as f64 + rx * sn + ry * cs;
x0 = x0.min(px.floor() as i32);
y0 = y0.min(py.floor() as i32);
x1 = x1.max(px.ceil() as i32);
y1 = y1.max(py.ceil() as i32);
}
for ty in y0.max(0)..=y1.min(ch as i32 - 1) {
for tx in x0.max(0)..=x1.min(cw as i32 - 1) {
// Rotate the destination pixel BACK to find its source pixel.
let (rx, ry) = ((tx - pax) as f64 + 0.5, (ty - pay) as f64 + 0.5);
let ux = rx * cs + ry * sn;
let uy = -rx * sn + ry * cs;
let dx = ux + (pax - ox) as f64;
let dy = uy + (pay - oy) as f64;
if dx < 0.0 || dy < 0.0 || dx >= dw as f64 || dy >= dh as f64 {
continue;
}
let sxi = ((dx as u32) * sw / dw).min(sw - 1);
let syi = ((dy as u32) * sh / dh).min(sh - 1);
let si = ((syi * sw + sxi) * 4) as usize;
if si + 3 >= img.rgba.len() {
continue;
}
let sr = img.rgba[si] as u32 * tr / 255;
let sg = img.rgba[si + 1] as u32 * tg / 255;
let sb = img.rgba[si + 2] as u32 * tb / 255;
let sa = img.rgba[si + 3] as u32 * ta / 255;
if sa == 0 {
continue;
}
let di = ((ty as u32 * cw + tx as u32) * 4) as usize;
for (k, sc) in [sr, sg, sb].into_iter().enumerate() {
let dc = canvas[di + k] as u32;
canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8;
}
canvas[di + 3] = 255;
}
}
return;
}
// ---- unrotated path (unchanged) -----------------------------------------
for row in 0..dh {
let ty = oy + row as i32;
if ty < 0 {
@@ -1625,70 +1182,6 @@ fn blit(
mod tests {
use super::*;
/// A solid opaque rectangle sprite, for exercising `blit` geometry.
fn solid(w: u32, h: u32) -> t8ad::T8adImage {
t8ad::T8adImage { width: w, height: h, rgba: vec![255u8; (w * h * 4) as usize],
flags: 0 }
}
fn kf_at(x: i32, y: i32, rot: i32) -> Keyframe {
Keyframe { fade: 0xff_ff_ff_ff, rotation_deg: rot, unknown_4: 0, unknown_8: 0,
scale_x: 100, scale_y: 100, tint: 0xffff_ffff, x, y, time: Some(0) }
}
fn draw(img: &t8ad::T8adImage, kf: &Keyframe, px: u32, py: u32) -> Vec<u8> {
let mut c = vec![0u8; 64 * 64 * 4];
blit(&mut c, 64, 64, img, kf, px, py);
c
}
fn covered(c: &[u8]) -> Vec<(i32, i32)> {
let mut v = Vec::new();
for y in 0..64 { for x in 0..64 {
if c[((y * 64 + x) * 4 + 3) as usize] != 0 { v.push((x as i32, y as i32)); }
}}
v
}
/// 🔴 CONTROL for the rotated path. An estimator that is wrong on a known
/// angle cannot be trusted on an unknown one, so the rotated blit is pinned
/// against angles whose answer is arithmetic rather than measured.
#[test]
fn rotation_control_known_angles() {
let img = solid(10, 4);
// pivot at the sprite's centre, so rotation turns in place
let (px, py) = (5u32, 2u32);
let base = draw(&img, &kf_at(20, 30, 0), px, py);
// 0° and 360° must be identical to the unrotated path, byte for byte:
// the fast path must be exactly the old behaviour.
assert_eq!(base, draw(&img, &kf_at(20, 30, 360), px, py),
"360 degrees must equal the unrotated path exactly");
// 90° must turn a 10x4 into a 4x10 about the same centre.
let r90 = covered(&draw(&img, &kf_at(20, 30, 90), px, py));
let b = covered(&base);
let bw = b.iter().map(|p| p.0).max().unwrap() - b.iter().map(|p| p.0).min().unwrap();
let bh = b.iter().map(|p| p.1).max().unwrap() - b.iter().map(|p| p.1).min().unwrap();
let rw = r90.iter().map(|p| p.0).max().unwrap() - r90.iter().map(|p| p.0).min().unwrap();
let rh = r90.iter().map(|p| p.1).max().unwrap() - r90.iter().map(|p| p.1).min().unwrap();
assert_eq!((bw, bh), (9, 3), "unrotated extent");
assert_eq!((rw, rh), (3, 9), "90 degrees must swap the extents");
// The covered area must be conserved to a few percent -- a rotation that
// loses or invents pixels is the forward-mapping bug this path avoids.
let (a0, a90) = (b.len() as f64, r90.len() as f64);
assert!((a0 - a90).abs() / a0 < 0.15,
"area changed too much under rotation: {a0} -> {a90}");
// And the centroid must stay on the pivot.
let cen = |v: &Vec<(i32, i32)>| {
let n = v.len() as f64;
(v.iter().map(|p| p.0 as f64).sum::<f64>() / n,
v.iter().map(|p| p.1 as f64).sum::<f64>() / n)
};
let (c0, c9) = (cen(&b), cen(&r90));
assert!((c0.0 - c9.0).abs() < 1.0 && (c0.1 - c9.1).abs() < 1.0,
"rotation moved the centroid: {c0:?} -> {c9:?}");
}
/// A synthetic build bundle: RATC magic, entry count at 0x14, a declaration
/// table at 0x20, then a placement region.
fn synth_build(decls: &[(&str, u32, u32, u32, u32)], groups: &[(usize, Vec<Keyframe>)]) -> Vec<u8> {

View File

@@ -86,39 +86,3 @@ fn manifest_binding_is_the_only_route() {
None
);
}
/// The resolved `ADV` voice region must contain **all three** streams the running
/// decoder decodes — not a truncated first one.
///
/// Ground truth is the emulator, not this crate: booting with `--xma_param_probe`
/// reports three XMA contexts with `byte_size` 1 294 336 / 1 118 208 / 1 171 456
/// (`docs/re/structures/voice-three-streams-are-concurrent.md`). Until 2026-08-30
/// the resolver's start filter capped a region at 1.5 MB, `ADV`'s span is 3.6 MB,
/// so the start fell back to `anchor` — a TOC offset, 238 packets into the first
/// stream — and this returned 806 912 for the first chunk.
///
/// This is a regression test against an EXTERNAL measurement, which is the only
/// kind that can catch the class of bug it was written for: every internal check
/// passed happily while a third of a stream was missing.
#[test]
fn adv_voice_region_holds_all_three_decoded_streams() {
let Some(src) = disc() else {
eprintln!("SKIP: set SYLPHEED_DISC");
return;
};
let (start, end) = media::resolve_movie_voice_region(&src, "ADV", VoiceLang::English)
.expect("ADV voice region");
let bytes = src
.read_segment_range("dat/sound", start, (end - start) as usize)
.expect("region bytes");
let sizes: Vec<usize> = sylpheed_formats::slb::to_xma_riffs(&bytes)
.iter()
.map(|r| r.len() - 60)
.collect();
assert_eq!(
sizes,
vec![1_294_336, 1_118_208, 1_171_456],
"the region must reproduce the RUNNING DECODER's byte_sizes; \
a first chunk of 806912 means the start filter has come back"
);
}

View File

@@ -269,62 +269,3 @@ 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

@@ -1,197 +0,0 @@
//! An opaque full-screen primitive cannot paint above what it would hide.
//!
//! A keyless primitive has no layer key, and `implied_layer_key` records the
//! handful whose position was measured in the running game. For one class the
//! file settles it without a measurement: an element covering the screen and
//! fully opaque at some instant cannot paint above anything visible then, or the
//! screen is blank. Where that set is *every* other element, the position is
//! forced to first.
//!
//! The port found this by contradiction on `build_12`/`build_15`, which its
//! renderer composited to solid black at every instant of their declared life.
//!
//! Argument, census and reach: `docs/re/structures/ui-forced-backdrop.md`.
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn disc_root() -> Option<PathBuf> {
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
p.join("dat").is_dir().then_some(p)
}
fn build(ar: &PakArchive, i: usize) -> (Vec<u8>, ui_layout::UiBuild) {
let by = ar.read(&ar.entries()[i]).expect("entry");
let b = ui_layout::parse_build(&by).expect("parse");
(by, b)
}
fn el<'a>(b: &'a ui_layout::UiBuild, name: &str) -> &'a ui_layout::Element {
b.elements.iter().find(|e| e.name == name).expect(name)
}
/// The two controls are measured orders from the running game. The rule has to
/// reproduce one and permit the other, or it is not measuring occlusion.
#[test]
fn the_rule_reproduces_both_measured_primitives() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
// `palogo_eff0.prm` is MEASURED painting first. Named like an overlay, so a
// name-based rule gets it wrong; occlusion gets it right.
let (_, splash) = build(&ar, 11);
assert!(
ui_layout::forced_backdrop(&splash, el(&splash, "palogo_eff0.prm")),
"the developer splash's backdrop is measured FIRST and must come out forced"
);
// `pteff00.prm` is MEASURED painting last. It is opaque only at its screen's
// entry and exit, so the rule must NOT force it down.
for entry in [4usize, 5] {
let (_, b) = build(&ar, entry);
assert!(
!ui_layout::forced_backdrop(&b, el(&b, "pteff00.prm")),
"entry {entry}: pteff00.prm is measured painting LAST and must stay permitted on top"
);
}
}
/// The case that prompted it: the loading screens.
#[test]
fn the_loading_screens_backdrop_sorts_first() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
for entry in [12usize, 15] {
let (by, b) = build(&ar, entry);
let prim = el(&b, "pgloading_eff00.prm");
assert!(ui_layout::forced_backdrop(&b, prim), "entry {entry}");
let order = ui_layout::derived_paint_order(&b, &by);
assert_eq!(
order.first().copied(),
Some(prim.index),
"entry {entry}: the backdrop must be painted first, not last"
);
}
}
/// 🔴 The rule quantifies over "every instant the primitive is opaque" and "every
/// element visible then", so both halves depend on where the timeline ends and on
/// what an element does after its own last keyframe. **The hold is not a
/// convenience: a measured order requires it.**
///
/// `palogo_eff0.prm` is a SINGLE keyframe at t=0. If an element counted as *gone*
/// after its last keyframe, the splash's backdrop would exist for one instant, no
/// other element would be up yet, and the rule would call it free — against the
/// order measured in the running game, which paints it first.
///
/// Disc-wide the choice decides **72 of 130** verdicts, so this is the load-bearing
/// half of the rule. (Using the header's declared `+0x08` as the span instead of
/// the elements' maximum changes **0**.)
#[test]
fn the_hold_after_a_final_keyframe_is_required_by_a_measured_order() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
for entry in [10usize, 11] {
let (_, b) = build(&ar, entry);
let prim = el(&b, "palogo_eff0.prm");
assert_eq!(prim.keyframes.len(), 1, "entry {entry}: the case rests on it being static");
// With the hold — what `pose_at` does, and what the game does.
assert!(
ui_layout::forced_backdrop(&b, prim),
"entry {entry}: measured painting FIRST, so the rule must force it"
);
// Without it, spelled out here rather than imported, so the test states
// the counterfactual it is pinning.
let tmax = b.elements.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time)).max().unwrap_or(0);
let last = |e: &ui_layout::Element| e.keyframes.iter().filter_map(|k| k.time).max().unwrap_or(0);
let alpha_no_hold = |e: &ui_layout::Element, t: u32| -> u32 {
if t > last(e) { 0 } else { e.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) }
};
let opaque: Vec<u32> = (0..=tmax).filter(|&t| alpha_no_hold(prim, t) == 255).collect();
let others: Vec<_> = b.elements.iter().filter(|o| o.index != prim.index).collect();
let below = others.iter()
.filter(|o| opaque.iter().any(|&t| alpha_no_hold(o, t) > 0)).count();
assert_ne!(
below, others.len(),
"entry {entry}: without the hold this element would come out FREE — which is \
why the hold is load-bearing rather than incidental"
);
}
}
/// Disc-wide: the rule must fire on a real population and never on something it
/// cannot occlude.
#[test]
fn forced_backdrops_are_full_screen_and_plentiful() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
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();
let (mut forced, mut prm, mut tbm) = (0usize, 0usize, 0usize);
for p in &paks {
let Ok(ar) = PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else { continue };
for element in &b.elements {
if !ui_layout::forced_backdrop(&b, element) {
continue;
}
forced += 1;
if element.name.ends_with(".prm") { prm += 1 }
else if element.name.ends_with(".tbm") { tbm += 1 }
// 🔴 Untextured only. This assertion caught the rule's real
// limit: applied to `.t32` sprites it claimed 22 of them must
// sort first, against their own layer keys — a sprite's element
// alpha says nothing about its texture's coverage.
assert!(
element.sprite.is_none(),
"{}: a textured sprite cannot be judged to occlude by element alpha",
element.name
);
assert!(
element.pivot_x * 2 >= b.design_w as u32
&& element.pivot_y * 2 >= b.design_h as u32,
"{}: a quad that does not cover the screen cannot occlude it",
element.name
);
}
}
}
assert!(forced > 50, "expected a real population, got {forced}");
eprintln!("{forced} keyless primitives have their position forced to first");
// 🔴 Pin the split, so anyone tightening this rule sees what it would cost.
// Only the `.prm` half is DECODED: a solid colour quad's fade IS its pixel, so
// opacity and coverage are the same fact. Every `.tbm` in the set carries fade
// `ffffffff` — a white SOLID quad painted first would make the screen white, so
// they are textured, and element alpha does not establish their coverage.
// Their verdicts are kept because restricting to `.prm` would send eleven
// screens' backgrounds back to last, which is the bug this rule fixed.
assert!(prm >= 40 && tbm >= 30,
"expected roughly 42 .prm / 38 .tbm forced instances, got {prm} / {tbm} — \
if this moved, re-read the self-refutation section of ui-forced-backdrop.md");
}

View File

@@ -120,17 +120,8 @@ fn header_0x08_against_the_keyframe_times() {
worst.push(format!("{pak}: max keyframe {max_time} > header {dur}"));
}
}
// ⚠️ 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;
}
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}");
@@ -151,18 +142,11 @@ fn header_0x08_against_the_keyframe_times() {
assert!(animated > 0, "no animated bundles — the sweep is broken");
// 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
// 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
// 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

@@ -1,204 +0,0 @@
//! 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

@@ -1,121 +0,0 @@
//! A nested record's header `+0x08` is its LOOP LENGTH, and its keyframes need
//! not fill it.
//!
//! A `*f` focus record animates for as long as its button is focused, so
//! something has to say where the cycle restarts. The keyframes cannot: the
//! `PRESS Ⓐ` plate's `ptbtn00f` ramps 0→80→0 over **105** units, and a 105-unit
//! period is 15 % short of every measurement of the real thing.
//!
//! Each record is itself a RATC bundle with its own header. `+0x08` is a frame
//! count, and if it is the loop length it must never be **less** than the
//! record's largest keyframe time — an animation cannot restart before its own
//! last pose. Disc-wide that holds 1 781 times out of 1 781, and 7.7 % of records
//! declare *more*, which is a hold at the final pose before the cycle repeats.
//!
//! `ptbtn00f` is one of those: 105 units of ramp inside a **120**-unit cycle, so
//! it rests dark for 15 units between pulses.
//!
//! Argument, the falsification test against the measured period, and the census:
//! `docs/re/structures/ui-record-loop-length.md`.
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn disc_root() -> Option<PathBuf> {
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
p.join("dat").is_dir().then_some(p)
}
/// Read a nested record's declared length and its largest keyframe time.
fn record_len_and_maxt(bundle: &[u8], off: usize, size: usize) -> Option<(i64, i64)> {
if off + 12 > bundle.len() || off + size > bundle.len() || &bundle[off..off + 4] != b"RATC" {
return None;
}
let len = u32::from_be_bytes(bundle[off + 8..off + 12].try_into().ok()?) as i64;
let lb = ui_layout::parse_build(&bundle[off..off + size])?;
let maxt = lb
.elements
.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max()? as i64;
(maxt > 0).then_some((len, maxt))
}
/// The falsifier: a loop cannot restart before its own last keyframe.
#[test]
fn a_records_declared_length_is_never_shorter_than_its_keyframes() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
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();
let (mut total, mut exact, mut holds) = (0usize, 0usize, 0usize);
for p in &paks {
let Ok(ar) = PakArchive::open(p) else { continue };
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else { continue };
for (rn, &(o, s)) in &b.records {
let Some((len, maxt)) = record_len_and_maxt(&by, o, s) else { continue };
total += 1;
assert!(
len >= maxt,
"{}:{rn} declares a {len}-unit cycle but has a keyframe at t={maxt} — \
a loop cannot restart before its own last pose",
p.file_name().unwrap().to_string_lossy()
);
if len == maxt { exact += 1 } else { holds += 1 }
}
}
}
assert!(total > 1500, "expected >1500 timed nested records, got {total}");
// The field must carry information. If every record declared exactly its own
// last keyframe time, "loop length" would be an unfalsifiable relabelling.
assert!(
holds > 50,
"only {holds} of {total} records declare a hold — the field would be carrying nothing"
);
eprintln!("{total} records: {exact} exact, {holds} with a hold before the cycle repeats");
}
/// The case that motivated it, pinned by name.
#[test]
fn the_press_a_plate_glow_holds_dark_for_fifteen_units() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let by = ar.read(&ar.entries()[2]).expect("entry 2 — the PRESS A plate");
let b = ui_layout::parse_build(&by).expect("parse");
let &(o, s) = b.records.get("ptbtn00f.rat").expect("the focus record");
let (len, maxt) = record_len_and_maxt(&by, o, s).expect("a timed record");
assert_eq!(maxt, 105, "the glow's ramp ends at t=105");
assert_eq!(len, 120, "but the cycle is 120 units");
assert_eq!(len - maxt, 15, "so it holds dark for 15 units between pulses");
// The five main-menu focus records fill their cycle exactly — the contrast
// that shows the slack is a property of this record, not of the format.
let menu = ar.read(&ar.entries()[5]).expect("entry 5 — the main menu");
let mb = ui_layout::parse_build(&menu).expect("parse");
let mut checked = 0;
for n in 1..=5 {
let name = format!("ptbtn0{n}f.rat");
let Some(&(mo, ms)) = mb.records.get(&name) else { continue };
let (l, m) = record_len_and_maxt(&menu, mo, ms).expect("timed");
assert_eq!((l, m), (120, 120), "{name} should fill its 120-unit cycle exactly");
checked += 1;
}
assert_eq!(checked, 5, "expected five main-menu focus records");
}

View File

@@ -1,142 +0,0 @@
//! A settled screen is one INSTANT, not one hold per element.
//!
//! `Element::rest()` returns an element's last *hold* keyframe, picked for that
//! element alone. For anything that ends the screen settled that is right. For a
//! **transient** it is exactly wrong: a two-frame flash's last hold is the flash
//! *peak*, so `rest()` leaves it burning for the whole screen.
//!
//! `GP_TITLE` build 4 is the case that found this. `ptlogo_back2eff1` … `eff5`
//! are five staggered flashes — `a=0` until t52, `255` for two frames, `0` again
//! two frames later — that sweep left to right across the logo once and are gone
//! by t110. Two elements, `ptlogo_back2eff` (t66238) and `ptlogo_back2`
//! (t80243), then hold for the rest of the screen. `rest()` draws all seven at
//! `a=255` simultaneously, and stacking five extra white glows blows the light
//! arc out to saturation: against the console capture the arc's mean error is
//! 33.22 and 8 581 pixels sit at the clipping level, where the console has 1 459.
//!
//! `UiBuild::settle_time()` recovers the right instant from the disc alone — the
//! midpoint of the longest keyframe-free interval — with no reference to any
//! capture. For this build that is t=198, and posing there takes the arc error to
//! 11.79 and the clipped count to 1 452 against the console's 1 459.
//!
//! Argument, controls and the disc-wide census: `docs/re/structures/ui-settle-time.md`.
use std::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);
}
}
None
}
/// The case that found the bug, asserted end to end.
#[test]
fn a_flash_is_transparent_at_the_settle_time_and_opaque_at_rest() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
let arc = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let bytes = arc.read(&arc.entries()[4]).expect("build 4");
let b = ui_layout::parse_build(&bytes).expect("parse");
let (lo, hi) = b.settle_window().expect("a settle window");
let t = b.settle_time().expect("a settle time");
assert!(hi - lo >= 60, "title's settle window should be a second or more, got {lo}..{hi}");
assert!(lo < t && t < hi, "settle time {t} must lie inside {lo}..{hi}");
let alpha = |k: &ui_layout::Keyframe| k.fade >> 24;
let mut flashes = 0;
for el in &b.elements {
let Some(name) = el.name.strip_prefix("ptlogo_back2eff") else { continue };
// `ptlogo_back2eff.t32` itself holds; only the numbered ones flash.
if !name.starts_with(|c: char| c.is_ascii_digit()) {
continue;
}
flashes += 1;
let rest = el.rest().expect("a rest pose");
let posed = el.pose_at(t).expect("a posed keyframe");
assert_eq!(
alpha(rest),
255,
"{}: rest() is expected to report the FLASH PEAK — that is the bug",
el.name
);
assert_eq!(
alpha(&posed),
0,
"{} flashes once before t110 and must be gone at the settle time {t}",
el.name
);
}
assert_eq!(flashes, 5, "GP_TITLE build 4 has five numbered back2 flashes");
// …while the two that genuinely hold are still opaque there.
for want in ["ptlogo_back2eff.t32", "ptlogo_back2.t32"] {
let el = b.elements.iter().find(|e| e.name == want).expect(want);
assert_eq!(
alpha(&el.pose_at(t).expect("posed")),
255,
"{want} holds across the settle time and must stay opaque"
);
}
}
/// The window is a property of the data, so it must be computable disc-wide
/// without panicking, and must be self-consistent wherever it exists.
#[test]
fn settle_windows_are_self_consistent_disc_wide() {
let Some(root) = disc_root() else {
eprintln!("SYLPHEED_DISC unset — skipping");
return;
};
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();
let (mut with, mut wide) = (0usize, 0usize);
for p in &paks {
let Ok(a) = PakArchive::open(p) else { continue };
for e in a.entries() {
let Ok(by) = a.read(e) else { continue };
if !ratc::is_ratc(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else { continue };
let Some((lo, hi)) = b.settle_window() else { continue };
with += 1;
assert!(lo < hi, "an empty window is not a window: {lo}..{hi}");
let t = b.settle_time().expect("a window implies a time");
assert!((lo..=hi).contains(&t), "settle time {t} outside {lo}..{hi}");
// No element may have a keyframe strictly inside the window — that
// is the whole definition, so it is worth asserting rather than
// trusting.
for el in &b.elements {
for k in &el.keyframes {
if let Some(kt) = k.time {
assert!(
kt <= lo || kt >= hi,
"{}: keyframe t={kt} lies inside the settle window {lo}..{hi}",
el.name
);
}
}
}
if hi - lo >= 30 {
wide += 1;
}
}
}
assert!(with > 1000, "expected >1000 bundles with a settle window, got {with}");
eprintln!("{with} bundles have a settle window; {wide} are at least 30 units wide");
}

View File

@@ -39,6 +39,44 @@ set answered_trust 0
set answered_bypass 0
spawn -noecho claude --dangerously-skip-permissions {*}$argv
set child_pid [exp_pid]
# 🔴 THIS WRAPPER USED TO SWALLOW BOTH THE SIGNAL AND THE EXIT STATUS, and those
# two omissions caused most of this project's multi-hour outages. Found
# 2026-09-03 by tracing the signal path, after a tooling review predicted exactly
# this from the symptoms.
#
# The path is: tini (PID 1) -> entrypoint.sh (exec'd) -> expect -> spawn -> claude
#
# `spawn` CANNOT be an exec: expect has to stay alive to drive the pty. So expect
# is the process Docker signals, and everything below it depends on expect
# passing things along. It did not.
#
# 1. NO SIGNAL FORWARDING. `docker stop` sent SIGTERM to expect, which died and
# took the pty with it. Claude Code never received a SIGTERM, so it never ran
# its `SessionEnd` hooks and never wrote `lastSessionId`/`history` to
# `~/.claude.json` -- which are written only at a GRACEFUL shutdown. That is
# the whole reason `claude --continue` answered "No conversation found to
# continue" with 33 MB of transcripts sitting in the volume beside it, and why
# we resume by scraping a session id off a transcript filename instead.
#
# 2. `eof { exit }` RETURNED 0 FOR EVERY DEATH. A bare `exit` in expect is exit
# ZERO. So when the kernel OOM-killer took the child, expect saw EOF and
# reported a clean exit -- `OOMKilled: true` with `ExitCode 0`, which is not
# Docker being odd, it is this line. It also meant `--restart on-failure`
# would have treated a memory kill as success, which is why the policy had to
# be `unless-stopped`.
#
# Both are fixed here. Signals are forwarded to the child and its real status is
# propagated, so a kill reads as 137, a clean stop lets Claude Code shut down
# properly, and the exit code means what it says.
proc forward {sig} {
global child_pid
catch { exec kill -$sig $child_pid }
}
trap { forward TERM } SIGTERM
trap { forward INT } SIGINT
trap { forward HUP } SIGHUP
expect {
-re {Choose} {
@@ -70,3 +108,18 @@ expect {
# Hand the terminal over for the rest of the run.
interact
# Propagate the child's REAL exit status. `interact` returns when the child is
# gone; `wait` then yields {pid spawnid os_error status}. Without this the script
# simply ran off the end and returned 0 -- see the note at `spawn` above for what
# that cost.
catch wait result
set status 0
if {[info exists result] && [llength $result] >= 4} {
# os_error_flag (index 2) is -1 for a normal exit; anything else means the
# wait itself failed and the status field is not a status.
if {[lindex $result 2] == 0} {
set status [lindex $result 3]
}
}
exit $status

View File

@@ -84,11 +84,26 @@ if [ -r /sys/fs/cgroup/memory.max ]; then
[ "$_m" != max ] && mem_gib=$(( _m / 1073741824 ))
fi
[ "${mem_gib:-0}" -lt 1 ] && mem_gib=1
by_mem=$(( mem_gib * 2 / 3 ))
# 🔴 THIS CONSTANT WAS WRONG, AND IT COST A RUN. `mem_gib * 2 / 3` assumes
# ~1.5 GB per job. On 2026-09-01 a raw `cargo test --release -p sylpheed-formats`
# ran 4 jobs in a 6 GB container and was OOM-killed mid-task. Release-mode rustc
# on this workspace needs closer to 2 GB, so the divisor is 2, not 3/2.
#
# ⚠️ And the kill reported `OOMKilled: true` with **ExitCode 0**, so it read as a
# clean exit — which is why the restart policy is `unless-stopped` rather than
# `on-failure`.
by_mem=$(( mem_gib / 2 ))
[ "$by_mem" -lt 1 ] && by_mem=1
jobs=$(( cpus < by_mem ? cpus : by_mem ))
# An EXPLICIT cap from the launcher wins. Without this the launcher's
# `-e CARGO_BUILD_JOBS=...` was computed, exported over, and silently discarded
# — the guardrail was set and then removed three lines later.
if [ -n "${CARGO_BUILD_JOBS:-}" ] && [ "${CARGO_BUILD_JOBS}" -ge 1 ] 2>/dev/null; then
jobs="$CARGO_BUILD_JOBS"
_why=" (explicit, from the launcher)"
fi
export SYLPH_JOBS="$jobs" CARGO_BUILD_JOBS="$jobs" CMAKE_BUILD_PARALLEL_LEVEL="$jobs"
log "build parallelism: $jobs (cpus=$cpus, mem=${mem_gib}GiB avail)"
log "build parallelism: $jobs${_why:-} (cpus=$cpus, mem=${mem_gib}GiB avail)"
mkdir -p "$HOME/shots" "$HOME/logs"
@@ -141,7 +156,16 @@ mkdir -p /exchange/files 2>/dev/null || true
#
# Newer-wins rather than always-copy, because the container refreshes its own
# token during a run and that copy may legitimately be the fresher one.
if [ -d "$HOME/.claude.seed" ] && \
# 🔴 A LONG-LIVED TOKEN WINS, AND THE SEEDING MUST NOT FIGHT IT.
#
# With CLAUDE_CODE_OAUTH_TOKEN set, copying the host's rotating credential file
# in would re-create the exact collision the token exists to remove: three
# clients on one rotating refresh token, the losers of a rotation race getting
# their stored tokens CLEARED to empty strings and parking at "Login expired".
# Measured 2026-09-04 -- see the launcher.
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
log "auth: using the long-lived token from the environment; not seeding OAuth"
elif [ -d "$HOME/.claude.seed" ] && \
{ [ ! -s "$HOME/.claude/.credentials.json" ] || \
[ "$HOME/.claude.seed/.credentials.json" -nt "$HOME/.claude/.credentials.json" ]; }; then
mkdir -p "$HOME/.claude"
@@ -160,6 +184,10 @@ fi
if [ -f "$HOME/.claude.host.json" ] && [ ! -s "$HOME/.claude.json" ]; then
cp "$HOME/.claude.host.json" "$HOME/.claude.json" 2>/dev/null || true
fi
# Nothing is restored into `~/.claude.json` on purpose. Resuming is done by
# SESSION ID off the transcript instead — see the resume block below for why
# the index is useless for this.
# `credential.helper=store` rewrites this file by rename-over-target, which
# fails with EBUSY on a bind mount -- reported as `fatal: unable to write
# credential store`, while the push itself succeeds. A fatal line that is
@@ -181,6 +209,77 @@ if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then
if [ "$#" -eq 1 ] && [ "$1" = "bash" ]; then
set --
fi
# ── Resume across a restart ────────────────────────────────────────────────
#
# The container restarts automatically now, and a restart that opens a BLANK
# session throws away everything the agent knew. That is not hypothetical: on
# 2026-09-01 an OOM kill ended a run mid-task with a 2.7 MB transcript and two
# files uncommitted in the volume.
#
# 🔴 RESUME BY SESSION ID, NOT BY `--continue`. Measured 2026-09-01:
#
# `--continue` resolves through `~/.claude.json`'s per-project `history` and
# `lastSessionId`. Those are written at a GRACEFUL SHUTDOWN — mid-session the
# live file has `history: None`, `lastSessionId: None`. A container that is
# OOM-killed or `docker rm -f`ed never writes them, which is exactly the case
# this feature exists for. So `--continue` answered "No conversation found to
# continue" with 33 MB of perfectly good transcripts in the volume beside it,
# and persisting `.claude.json` did not help because the fields were never
# populated in the first place.
#
# The TRANSCRIPTS are durable and are named by session id, so read the id off
# the newest one for this working directory. Claude Code has not started yet
# at this point, so the newest is the previous run's.
#
# The /loop prompt is still passed, so the loop is RE-ARMED rather than merely
# restored — a resumed conversation with no wake-up scheduled answers once and
# stops, which looks like resuming and is not.
SYLPH_STAMP="$HOME/.claude/.sylph-last-start"
SYLPH_RESUME=0
SYLPH_SESSION=""
SYLPH_PROJ="$HOME/.claude/projects/$(printf '%s' "$PWD" | sed 's#/#-#g')"
if [ -d "$SYLPH_PROJ" ]; then
_newest=$(ls -1t "$SYLPH_PROJ"/*.jsonl 2>/dev/null | head -1)
if [ -n "$_newest" ]; then
SYLPH_SESSION=$(basename "$_newest" .jsonl)
SYLPH_RESUME=1
fi
fi
# 🔴 A POISONED TRANSCRIPT MUST NOT CRASH-LOOP. If the last start was under
# two minutes ago we are already in a restart loop, and continuing back into
# whatever killed us is the one thing guaranteed not to help. Start fresh and
# say so, rather than burning tokens on the same death repeatedly.
if [ "$SYLPH_RESUME" = "1" ] && [ -f "$SYLPH_STAMP" ]; then
_last=$(cat "$SYLPH_STAMP" 2>/dev/null || echo 0)
_now=$(date +%s)
if [ $((_now - _last)) -lt 120 ]; then
SYLPH_RESUME=0
log "restarted <120s after the last start — restart loop suspected;"
log " starting a FRESH session rather than continuing into the same death"
fi
fi
mkdir -p "$HOME/.claude" 2>/dev/null || true
date +%s > "$SYLPH_STAMP" 2>/dev/null || true
if [ "$SYLPH_RESUME" = "1" ] && [ "$#" -eq 1 ]; then
set -- "$1
⚠️ YOU WERE RESTARTED, and this session was resumed — your context is intact,
but the process that was running when it died is gone. Before anything else:
1. \`git -C /work status\`. Whatever you had in progress is still in the tree,
UNCOMMITTED. Commit it and \`push-work\` before starting anything new.
2. Any build, test or capture you had running did NOT finish. Do not read its
absence as a result.
3. The likeliest cause is an OOM kill — this container is capped at 6 GB.
\`CARGO_BUILD_JOBS\` is now set for you in the environment; do not raise it,
and prefer \`build-reborn test\` over a raw \`cargo test --release\`, which
bypasses the wrapper's job cap. That is exactly what killed the run on
2026-09-01."
log "resuming session ${SYLPH_SESSION%%-*}… with a restart notice"
fi
[ "$SYLPH_RESUME" = "1" ] && set -- --resume "$SYLPH_SESSION" "$@"
# The flag the user asked for. It is refused under root, which is why this
# image runs as `agent`.
# Remote Control registers the session with your account so you can chat with

View File

@@ -130,6 +130,19 @@ docker_args() {
-e "PROJECT_DIR=/work"
-e "SYLPH_EXCHANGE=/exchange"
-e "SYLPH_AGENT=decoder"
# 🔴 THE JOB CAP LIVES IN THE ENVIRONMENT, NOT IN THE WRAPPER.
#
# `build-reborn` has always exported CARGO_BUILD_JOBS, and on 2026-09-01
# that was not enough: the agent ran a RAW `cargo test --release -p
# sylpheed-formats`, which never touches the wrapper, got one rustc per
# granted CPU, and the container was OOM-killed at its 6 GB cap mid-task.
# Docker reported ExitCode 0 with OOMKilled true, so it read as a clean
# exit and cost a diagnosis.
#
# A guardrail reachable only through a wrapper protects the calls that use
# the wrapper. This one is inherited by every process in the container, so
# bypassing it takes an explicit override rather than forgetting.
-e "CARGO_BUILD_JOBS=${SYLPH_JOBS:-2}"
-e "SYLPH_REPO_URL=https://git.mc02.dev/fabi/Sylpheed.git"
-e "XENIA_SRC=/canary"
# ── claude ──
@@ -172,6 +185,33 @@ docker_args() {
# Read-only, and only ever used by `push-work`, which refuses anything but an
# auto/* branch and never force-pushes. Without this the agent's work only
# exists inside the container and dies with it.
# ── Claude auth ──
#
# 🔴 THE ROTATING OAUTH FILE IS WHY THIS AGENT KEPT PARKING, and a long-lived
# token removes the failure by construction rather than recovering from it.
#
# Measured 2026-09-04: `~/.claude/.credentials.json` holds a REFRESH TOKEN THAT
# ROTATES ON USE. Seeding both containers from the host's copy left three
# clients holding one token; the first to refresh invalidated the other two,
# and on the failed refresh **Claude Code CLEARS the stored tokens** -- it
# writes empty strings, keeps the metadata, and parks at "Login expired". The
# decoder's file was caught emptied at 13:04:28 with its last work at 13:04:29.
# A hollow file passes every "does it exist" check, which is why three separate
# diagnoses missed it.
#
# `claude setup-token` issues a LONG-LIVED token against the same Claude
# subscription (not Console/API billing -- `claude auth login` defaults to
# `--claudeai`, and `--console` is the billed one). Passed as an environment
# variable it cannot be rotated out from under a peer and there is no file for
# Claude Code to empty, so both halves of the failure are gone.
#
# Inert until the file exists: without it the OAuth path below is unchanged.
CLAUDETOK="${SYLPH_CLAUDE_TOKEN:-$HOME/.sylph-claude-token}"
if [ -f "$CLAUDETOK" ]; then
_out+=(-e "CLAUDE_CODE_OAUTH_TOKEN=$(tr -d '[:space:]' < "$CLAUDETOK")")
echo "==> auth: long-lived token from $CLAUDETOK (no rotating credential file)" >&2
fi
GITCRED="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
if [ -f "$GITCRED" ]; then
_out+=(-v "$GITCRED:/sylph-home/re/.git-credentials.host:ro")
@@ -269,7 +309,17 @@ case "${1:-}" in
echo "==> repo: own clone in volume sylpheed-decoder-repo -> /work"
echo "==> pacing: ${INTERVAL:-self-paced}"
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run -d -i -t "${ARGS[@]}" "$IMAGE" "/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
# 🔴 `unless-stopped`, NOT `on-failure` -- and the reason is a trap worth
# keeping. When this container was OOM-killed on 2026-09-01, Docker reported
# `OOMKilled: true` with **ExitCode 0**. `on-failure` keys off the exit code,
# so it would have treated a memory kill as a clean finish and left the agent
# down. `unless-stopped` restarts regardless, and still honours an explicit
# `./sylph-agent stop`.
#
# Restarting into the same death is handled at the other end: the entrypoint
# refuses to `--continue` if the last start was under two minutes ago.
docker run -d -i -t --restart unless-stopped "${ARGS[@]}" "$IMAGE" \
"/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
echo
echo " running detached as '$NAME'."
echo " ./sylph-agent remote link to chat with it from anywhere"

View File

@@ -39,6 +39,44 @@ set answered_trust 0
set answered_bypass 0
spawn -noecho claude --dangerously-skip-permissions {*}$argv
set child_pid [exp_pid]
# 🔴 THIS WRAPPER USED TO SWALLOW BOTH THE SIGNAL AND THE EXIT STATUS, and those
# two omissions caused most of this project's multi-hour outages. Found
# 2026-09-03 by tracing the signal path, after a tooling review predicted exactly
# this from the symptoms.
#
# The path is: tini (PID 1) -> entrypoint.sh (exec'd) -> expect -> spawn -> claude
#
# `spawn` CANNOT be an exec: expect has to stay alive to drive the pty. So expect
# is the process Docker signals, and everything below it depends on expect
# passing things along. It did not.
#
# 1. NO SIGNAL FORWARDING. `docker stop` sent SIGTERM to expect, which died and
# took the pty with it. Claude Code never received a SIGTERM, so it never ran
# its `SessionEnd` hooks and never wrote `lastSessionId`/`history` to
# `~/.claude.json` -- which are written only at a GRACEFUL shutdown. That is
# the whole reason `claude --continue` answered "No conversation found to
# continue" with 33 MB of transcripts sitting in the volume beside it, and why
# we resume by scraping a session id off a transcript filename instead.
#
# 2. `eof { exit }` RETURNED 0 FOR EVERY DEATH. A bare `exit` in expect is exit
# ZERO. So when the kernel OOM-killer took the child, expect saw EOF and
# reported a clean exit -- `OOMKilled: true` with `ExitCode 0`, which is not
# Docker being odd, it is this line. It also meant `--restart on-failure`
# would have treated a memory kill as success, which is why the policy had to
# be `unless-stopped`.
#
# Both are fixed here. Signals are forwarded to the child and its real status is
# propagated, so a kill reads as 137, a clean stop lets Claude Code shut down
# properly, and the exit code means what it says.
proc forward {sig} {
global child_pid
catch { exec kill -$sig $child_pid }
}
trap { forward TERM } SIGTERM
trap { forward INT } SIGINT
trap { forward HUP } SIGHUP
expect {
-re {Choose} {
@@ -70,3 +108,18 @@ expect {
# Hand the terminal over for the rest of the run.
interact
# Propagate the child's REAL exit status. `interact` returns when the child is
# gone; `wait` then yields {pid spawnid os_error status}. Without this the script
# simply ran off the end and returned 0 -- see the note at `spawn` above for what
# that cost.
catch wait result
set status 0
if {[info exists result] && [llength $result] >= 4} {
# os_error_flag (index 2) is -1 for a normal exit; anything else means the
# wait itself failed and the status field is not a status.
if {[lindex $result 2] == 0} {
set status [lindex $result 3]
}
}
exit $status

View File

@@ -38,7 +38,14 @@ echo "[entrypoint] display $DISPLAY ready ($SCREEN_GEOMETRY)"
#
# Newer-wins rather than always-copy, because the container refreshes its own
# token during a run and that copy may legitimately be the fresher one.
if [ -d "$HOME/.claude.seed" ] && \
# 🔴 A LONG-LIVED TOKEN WINS, AND THE SEEDING MUST NOT FIGHT IT. With
# CLAUDE_CODE_OAUTH_TOKEN set, copying the host's rotating credential file in
# would re-create the collision the token exists to remove: three clients on one
# rotating refresh token, and the loser of a rotation race gets its stored tokens
# CLEARED to empty strings by Claude Code and parks. Measured 2026-09-04.
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
echo "[entrypoint] auth: long-lived token from the environment; not seeding OAuth"
elif [ -d "$HOME/.claude.seed" ] && \
{ [ ! -s "$HOME/.claude/.credentials.json" ] || \
[ "$HOME/.claude.seed/.credentials.json" -nt "$HOME/.claude/.credentials.json" ]; }; then
mkdir -p "$HOME/.claude"
@@ -57,6 +64,10 @@ fi
if [ -f "$HOME/.claude.host.json" ] && [ ! -s "$HOME/.claude.json" ]; then
cp "$HOME/.claude.host.json" "$HOME/.claude.json" 2>/dev/null || true
fi
# Nothing is restored into `~/.claude.json` on purpose. Resuming is done by
# SESSION ID off the transcript instead — see the resume block below for why the
# index cannot serve.
# Same reason as .claude.json above: `credential.helper=store` rewrites this
# file by rename-over-target, which fails with EBUSY on a bind mount. Copy it to
# a writable path; nothing is ever written back to the host's file.
@@ -110,6 +121,70 @@ if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then
if [ "$#" -eq 1 ] && [ "$1" = "bash" ]; then
set --
fi
# ── Resume across a restart ────────────────────────────────────────────────
#
# The container restarts automatically now, and a restart that opens a BLANK
# session throws away everything the agent knew.
#
# 🔴 RESUME BY SESSION ID, NOT BY `--continue`. Measured on the decoder
# 2026-09-01: `--continue` resolves through `~/.claude.json`'s per-project
# `history` / `lastSessionId`, and those are written at a GRACEFUL SHUTDOWN --
# mid-session the live file has both as `None`. A container that is OOM-killed
# or `docker rm -f`ed never writes them, which is exactly the case this exists
# for, so `--continue` answered "No conversation found to continue" with the
# transcripts sitting in the volume beside it.
#
# The TRANSCRIPTS are durable and named by session id. Claude Code has not
# started yet here, so the newest is the previous run's.
#
# The /loop prompt is still passed so the loop is RE-ARMED rather than merely
# restored -- a resumed conversation with no wake-up scheduled answers once
# and stops, which looks like resuming and is not.
SYLPH_STAMP="$HOME/.claude/.sylph-last-start"
SYLPH_RESUME=0
SYLPH_SESSION=""
SYLPH_PROJ="$HOME/.claude/projects/$(printf '%s' "$PWD" | sed 's#/#-#g')"
if [ -d "$SYLPH_PROJ" ]; then
_newest=$(ls -1t "$SYLPH_PROJ"/*.jsonl 2>/dev/null | head -1)
if [ -n "$_newest" ]; then
SYLPH_SESSION=$(basename "$_newest" .jsonl)
SYLPH_RESUME=1
fi
fi
# 🔴 A POISONED TRANSCRIPT MUST NOT CRASH-LOOP. Restarted under two minutes
# after the last start, we are already looping: continuing back into whatever
# killed us is the one thing guaranteed not to help.
if [ "$SYLPH_RESUME" = "1" ] && [ -f "$SYLPH_STAMP" ]; then
_last=$(cat "$SYLPH_STAMP" 2>/dev/null || echo 0)
_now=$(date +%s)
if [ $((_now - _last)) -lt 120 ]; then
SYLPH_RESUME=0
echo "[entrypoint] restarted <120s after the last start -- restart loop"
echo "[entrypoint] suspected; starting FRESH rather than continuing"
fi
fi
mkdir -p "$HOME/.claude" 2>/dev/null || true
date +%s > "$SYLPH_STAMP" 2>/dev/null || true
if [ "$SYLPH_RESUME" = "1" ] && [ "$#" -eq 1 ]; then
set -- "$1
⚠️ YOU WERE RESTARTED, and this session was resumed — your context is intact,
but the process that was running when it died is gone. Before anything else:
1. \`git -C /work status\`. Whatever you had in progress is still in the tree,
UNCOMMITTED. Commit it and \`push-work\` before starting anything new.
2. Any build, test, export or Godot run you had going did NOT finish. Do not
read its absence as a result.
3. The likeliest cause is an OOM kill — this container is capped at 4 GB.
\`CARGO_BUILD_JOBS\` is now set for you in the environment; do not raise it,
and prefer \`build-export\` / \`build-reference-cli\` over a raw
\`cargo build --release\`, which bypasses the wrapper's job cap. That is what
killed the decoder's run on 2026-09-01."
echo "[entrypoint] resuming session ${SYLPH_SESSION%%-*}… with a restart notice"
fi
[ "$SYLPH_RESUME" = "1" ] && set -- --resume "$SYLPH_SESSION" "$@"
# Remote Control registers the session with the account so the agent can be
# reached from claude.ai -- the point of a detached run being that nobody is
# sitting in front of it. The name is passed EXPLICITLY: the flag's value is

View File

@@ -67,6 +67,12 @@ docker_args() {
-v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/port/.claude.host.json:ro"
-v "sylpheed-exchange:/exchange"
-e "PROJECT_DIR=/work"
# Same guardrail as the decoder, added the same day and for its reason: the
# decoder was OOM-killed mid-task by a RAW `cargo test --release`, which
# never reaches `build-export`/`build-reference-cli` and so never saw their
# CARGO_BUILD_JOBS. This container is smaller (4 GB, 3 CPUs), so the same
# bypass is at least as easy to hit here.
-e "CARGO_BUILD_JOBS=${SYLPH_PORT_JOBS:-2}"
-e "SYLPH_EXCHANGE=/exchange"
-e "SYLPH_AGENT=port"
-e "SYLPH_REPO_URL=https://git.mc02.dev/fabi/Sylpheed.git"
@@ -99,6 +105,20 @@ docker_args() {
# routinely wrong teaches the reader to ignore the one that is real. Mounting
# rw would also silence it, but then the container can clobber the host's
# credential file; copying cannot.
# ── Claude auth ──
# See the decoder's launcher for the full note. Short version: the OAuth
# credential file holds a refresh token that ROTATES ON USE, three clients were
# seeded from one copy, and the loser of a rotation race has its tokens CLEARED
# to empty strings by Claude Code and parks at "Login expired". A long-lived
# `claude setup-token` credential passed in the environment has nothing to
# rotate and no file to empty. Same subscription, not API billing.
# Inert until the file exists.
local claudetok="${SYLPH_CLAUDE_TOKEN:-$HOME/.sylph-claude-token}"
if [ -f "$claudetok" ]; then
_out+=(-e "CLAUDE_CODE_OAUTH_TOKEN=$(tr -d '[:space:]' < "$claudetok")")
echo "==> auth: long-lived token from $claudetok (no rotating credential file)" >&2
fi
local gitcred="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
if [ -f "$gitcred" ]; then
_out+=(-v "$gitcred:/sylph-home/port/.git-credentials.host:ro")
@@ -108,6 +128,53 @@ docker_args() {
fi
[ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
# ── GPU ──
# This block did not exist until 2026-09-01, and its absence was reported as
# a symptom rather than a cause: **the port agent reported low FPS.** Godot 4
# renders through Vulkan, and with nothing passed through it was falling back
# to lavapipe — software Vulkan, which is correct and slow. The decoder's
# launcher had this logic; this one never did, so the container that actually
# runs a renderer was the one without a GPU.
#
# Three distinct cases, and conflating them is how you end up believing you
# have hardware Vulkan while running llvmpipe:
#
# NVIDIA needs the NVIDIA Container Toolkit (`--gpus all`). Passing
# /dev/dri alone does NOT work — Mesa cannot drive an NVIDIA card,
# and the proprietary userspace lives outside the image.
# Mesa (AMD/Intel) works with a plain /dev/dri passthrough plus the
# host's render/video GIDs.
# neither software Vulkan (lavapipe): correct, and slow.
if [ "${SYLPH_VULKAN:-auto}" = "sw" ]; then
_out+=(-e SYLPH_VULKAN=sw)
elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then
if docker info --format '{{json .Runtimes}}' 2>/dev/null | grep -q nvidia; then
_out+=(--gpus all)
else
echo "==> NOTE: NVIDIA GPU found but the NVIDIA Container Toolkit is not" >&2
echo " installed, so Godot falls back to lavapipe (software — correct," >&2
echo " slow, and the reason for any low-FPS report). To enable it:" >&2
echo " curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \\" >&2
echo " | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg" >&2
echo " curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \\" >&2
echo " | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \\" >&2
echo " | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list" >&2
echo " sudo apt update && sudo apt install -y nvidia-container-toolkit" >&2
echo " sudo nvidia-ctk runtime configure --runtime=docker" >&2
echo " sudo systemctl restart docker" >&2
_out+=(-e SYLPH_VULKAN=sw)
fi
elif [ -e /dev/dri/renderD128 ]; then
_out+=(--device /dev/dri)
for g in render video; do
gid=$(getent group "$g" | cut -d: -f3 || true)
[ -n "$gid" ] && _out+=(--group-add "$gid")
done
else
_out+=(-e SYLPH_VULKAN=sw)
fi
printf '%s\n' "${_out[@]}"
}
@@ -140,7 +207,12 @@ case "${1:-}" in
INTERVAL="${SYLPH_LOOP_INTERVAL-45m}"
echo "==> loose | cpus=$CPUS mem=${MEM_GB}g pacing=${INTERVAL:-self}"
echo "==> repo: own clone in volume sylpheed-port-repo -> /work"
docker run -d -i -t "${ARGS[@]}" -e SYLPH_AUTONOMOUS=1 -w /work "$IMAGE" \
# `unless-stopped`, NOT `on-failure`: an OOM kill on this setup reports
# `OOMKilled: true` with **ExitCode 0**, so `on-failure` would read a memory
# kill as a clean finish and leave the agent down. Restarting into the same
# death is handled in the entrypoint, which refuses to `--continue` when the
# last start was under two minutes ago.
docker run -d -i -t --restart unless-stopped "${ARGS[@]}" -e SYLPH_AUTONOMOUS=1 -w /work "$IMAGE" \
"/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
echo
echo " running detached as '$NAME'."

176
docker/sylph-watchdog Executable file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# Restart an agent whose Claude session is parked at an expired login.
#
# ./sylph-watchdog watch forever (run detached)
# ./sylph-watchdog --once one pass, for cron or a manual check
# ./sylph-watchdog --status what it would do right now, changing nothing
#
# Env: SYLPH_WATCH_INTERVAL (default 300s), SYLPH_WATCH_CONTAINERS
#
# ── Why this exists ─────────────────────────────────────────────────────────
#
# 🔴 `--restart unless-stopped` DOES NOT COVER THIS, and that is the whole point.
# Docker restarts a container that EXITS. A Claude session sitting at
#
# Login expired · Please run /login
#
# never exits. The process is healthy, the container is Up, `docker ps` is green,
# and the agent has done nothing for hours. Three times now (2026-08-30,
# 09-02, 09-03) that has been noticed only because a human saw Remote Control
# report "Can't reach your computer" — which is a symptom of the session being
# unable to attach, not a report about the machine.
#
# The fix is already in the entrypoint: it copies the host's credentials in when
# they are newer than the container's. It just needs something to notice and
# bounce the container. That is all this does.
#
# ⚠️ It restarts rather than logging in. A restart re-runs the entrypoint, which
# re-seeds credentials AND resumes the session by id, so the agent keeps its
# context. There is nothing here that could log a session in on its own, and it
# should not pretend to: if the HOST's credentials are also stale, this loop will
# bounce the container and the agent will park again. It says so instead of
# retrying silently.
set -uo pipefail
CONTAINERS="${SYLPH_WATCH_CONTAINERS:-sylpheed-agent sylpheed-port}"
INTERVAL="${SYLPH_WATCH_INTERVAL:-300}"
# How far back to look. Longer than the interval so a stall spanning two passes
# is still seen, short enough that a login expiry cured an hour ago does not
# read as current.
WINDOW="${SYLPH_WATCH_WINDOW:-20m}"
log() { printf '[watchdog %s] %s\n' "$(date -u '+%H:%M:%S')" "$*"; }
# Has this container printed an expiry recently, and NOT recovered since?
#
# "Recovered" matters: the string stays in the log forever, so a bare grep would
# restart a healthy agent every pass on the strength of an hours-old line. The
# test is whether the transcript has been written SINCE the last expiry — a
# working agent writes constantly.
parked() {
local c="$1"
docker ps --filter "name=^${c}$" --format '{{.Names}}' | grep -q . || return 1
local hits
hits=$(docker logs --since "$WINDOW" "$c" 2>&1 \
| sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g' \
| grep -c 'Login expired' 2>/dev/null || true)
[ "${hits:-0}" -gt 0 ] || return 1
# Transcript idle for longer than one interval => it really is stuck. A busy
# agent that merely logged an expiry and recovered keeps writing.
local age
age=$(docker exec "$c" bash -lc '
f=$(ls -1t "$HOME/.claude/projects"/*/*.jsonl 2>/dev/null | head -1)
[ -n "$f" ] && echo $(( $(date +%s) - $(stat -c %Y "$f") )) || echo 999999
' 2>/dev/null | tr -d '[:space:]')
case "$age" in ''|*[!0-9]*) age=999999 ;; esac
[ "$age" -gt "$INTERVAL" ]
}
# Is the HOST's copy actually newer? If not, a restart cannot help and saying so
# is the useful output — otherwise this becomes a loop that bounces a container
# every five minutes and reports success.
host_is_newer() {
local c="$1"
docker exec "$c" bash -lc '
s="$HOME/.claude.seed/.credentials.json"; o="$HOME/.claude/.credentials.json"
[ -e "$s" ] || exit 2
[ ! -e "$o" ] || [ "$s" -nt "$o" ]
' >/dev/null 2>&1
}
pass() {
local acted=0
for c in $CONTAINERS; do
if parked "$c"; then
if host_is_newer "$c"; then
log "$c is parked at an expired login; host credentials are newer -- restarting"
[ "${1:-}" = "--status" ] || docker restart "$c" >/dev/null 2>&1 \
&& log "$c restarted (entrypoint re-seeds and resumes the session)"
else
log "🔴 $c is parked at an expired login and the HOST's credentials are"
log " NO NEWER. A restart cannot fix this -- log in on the host first."
fi
acted=1
fi
done
[ "$acted" = 0 ] && log "all watched agents are alive"
return 0
}
# ── The control, EXECUTED ───────────────────────────────────────────────────
#
# 🔴 A watchdog that has never fired is a hope, not a guard. Its whole value is
# in the true-positive path, and that path only runs when an agent is already
# broken -- so it gets a synthetic one.
#
# Two cases against real containers, because the detection is `docker logs` plus
# `docker exec` and neither can be reasoned about from the shell:
#
# a container printing "Login expired" with no transcript -> parked (TRUE positive)
# a live agent -> not parked (negative)
#
# ⚠️ Written after claiming, wrongly and without checking, that a bare grep
# "would have fired" on a recovered container. The count was zero. That is the
# same error this whole corpus keeps cataloguing -- asserting what an instrument
# would have said instead of running it -- so the instrument now runs.
selftest() {
local ok=0 name="sylph-watchdog-control-$$"
echo "control:"
docker run -d --rm --name "$name" alpine:latest \
sh -c 'echo "Login expired · Please run /login"; sleep 120' >/dev/null 2>&1
# Give docker a moment to have the line available in the log.
for _ in 1 2 3 4 5; do
docker logs "$name" 2>&1 | grep -q 'Login expired' && break
sleep 1
done
if SYLPH_WATCH_CONTAINERS="$name" parked "$name"; then
printf ' %-46s ✅\n' "an expired login with no transcript reads PARKED"
else
printf ' %-46s 🔴\n' "an expired login with no transcript reads PARKED"; ok=1
fi
# And it must NOT fire on the same container once it is gone -- a stopped
# container is not a parked one, and restarting it would be wrong.
docker rm -f "$name" >/dev/null 2>&1
if parked "$name"; then
printf ' %-46s 🔴\n' "a container that is gone reads NOT parked"; ok=1
else
printf ' %-46s ✅\n' "a container that is gone reads NOT parked"
fi
# The live negative, against whatever is actually running.
local live=0
for c in $CONTAINERS; do
docker ps --filter "name=^${c}$" --format '{{.Names}}' | grep -q . || continue
live=1
if parked "$c"; then
printf ' %-46s 🔴 (%s)\n' "a working agent reads NOT parked" "$c"; ok=1
else
printf ' %-46s ✅ (%s)\n' "a working agent reads NOT parked" "$c"
fi
done
[ "$live" = 1 ] || printf ' %-46s -- no agent running\n' "a working agent reads NOT parked"
echo
[ $ok -eq 0 ] && echo "the watchdog fires on a parked session and not otherwise" \
|| echo "🔴 the watchdog cannot tell parked from alive"
return $ok
}
case "${1:-}" in
--once) pass ;;
--status) pass --status ;;
--selftest) selftest; exit $? ;;
*)
log "watching [$CONTAINERS] every ${INTERVAL}s"
while true; do
pass
sleep "$INTERVAL"
done
;;
esac

View File

@@ -12,17 +12,6 @@ already went wrong once.
* **The toolchain is real.** `tools/re-capture/rebuild_canary.sh` exists because
the old box had no cmake/ninja/clang and only runtime sonames, so it hand-
relinked object files. **Do not use it here.** Use `build-canary`.
🔴 **But `build-canary` does not work in this container as it stands
(2026-08-29).** It builds `${PROJECT_DIR:-/work}/xenia-canary`, which **does
not exist here** — the Canary source is at **`/canary`** (`$XENIA_SRC`). The
warm 235 MB tree at `/sylph-home/re/canary-build` is configured with
`CMAKE_HOME_DIRECTORY=/work/xenia-canary`, also missing, and its
`build-Release.ninja` carries **no per-file rules** — it wants to re-run CMake
first, which would fail on the absent source root. **So any Canary change is a
full reconfigure against `/canary` plus a full compile**, not an incremental
one. Budget for that before starting: `SYLPH_JOBS=4`, and this box has been
sitting at **~700 MB free** with a documented history of full-parallel builds
OOM-killing the host.
* **numpy and Pillow are installed.** `entities2.py`, `flight_probe.py` and the
image oracles work. Their absence used to look like a logic bug.
@@ -51,71 +40,6 @@ python3 tools/re-capture/gmem.py find hex:820af844 400
```
* **One emulator at a time.** `run-canary` enforces it with a lockfile.
* 🔴 **`run-canary` is SILENT TWICE OVER, and that defeats `audio-capture`.**
Line 82 is `export SDL_AUDIODRIVER="${SDL_AUDIODRIVER:-dummy}"`, and its
header explains why: `--apu=nop` stalls the guest in the intro movie, so the
SDL driver against a *dummy* device is what lets the title advance. The
comment's premise — "there is no PulseAudio here" — **stopped being true when
`tools/audio-capture` landed**, and it starts a daemon on demand.
So a capture through the null sink records **pure silence**, at the right
length, with a perfectly healthy-looking run behind it. To actually record the
game:
⚠️ **And that is only the first of TWO layers.** `run-canary` also passes
**`--mute=true`** on its own command line (line 98). With the driver fixed and
the mute left alone, Canary attaches a healthy 6-channel stream to the sink,
holds it at 100 % volume for the whole run — and emits silence. Both have to go:
```bash
audio-capture start # or load a null sink yourself
PULSE_SINK=cap SDL_AUDIODRIVER=pulseaudio \
run-canary --mute=false … # `"$@"` is last, so this wins
```
Record at the monitor's real format, too — `parec` defaults to stereo/44.1 kHz
and will silently resample a 6-channel monitor:
`parec -d cap.monitor --channels=6 --rate=48000 --format=s16le`.
🔴 **And even with both mutes off, a PulseAudio-monitor capture is not
faithful — use the ALSA tee instead.** A null sink's *monitor* is sampled on a
wall clock and **invents silence** whenever the client is late, so a capture
through it is 39 % holes that the game never emitted. `PULSE_LATENCY_MSEC`
only trades gap count against gap size and never wins.
✅ **The working route is `--apu=alsa` with an ALSA `file` tee in front of a
paced slave** — full recipe, controls and three configuration traps in
[`audio-capture-alsa-file-tee.md`](../re/audio-capture-alsa-file-tee.md).
* 🔴 **A BARE ALSA `file` tee WILL FILL THE DISK — always run a size guard.**
Xenia's ALSA writer thread pads silence whenever its ring is empty
(`alsa_audio_driver.cc:359`), so against a device that never blocks it
free-runs: measured at **~250× real time, 7.34 GB in 50 seconds**. The slave
must pace — `slave.pcm { type pulse }` — and the capture loop should abort
above ~3× real time. The next person to try a bare tee hits this in the first
minute.
* ✅ **And use `--gpu=null` for an audio capture.** It is what takes the guest
from 0.70× to **0.96×** real time, which stops Xenia padding at all: 0.31 %
silence and 0.01 gaps/s, against 9.98 % / 8.37 rendered. ⚠️ No video, so
screen-based provenance is unavailable (use the XMA probe), and `--gpu=null`
runs here die at ~70 s with `PM4_DRAW_INDX: Failed in backend`.
🔴 **REFUTED 2026-08-30 — that lifetime does not hold.** A `--gpu=null` capture
ran **148.02 s** and ended on its own probe's timer with the emulator still
alive, having decoded the whole `ADV` movie
([`intro-audio-output-census.md`](../re/structures/intro-audio-output-census.md)).
More than twice the quoted figure. Whatever produced the ~70 s was fixed or was
never general, and this note had been the reason not to use `--gpu=null` for
anything long — which is exactly the configuration a clean audio capture needs.
⚠️ **Do not read Canary's 6-channel PulseAudio stream as evidence the GAME is
5.1.** `pactl` will show `float32le 6ch 48000Hz`, channel-mapped to a full 5.1
layout, on any title. That is `AudioDriver::kFrameChannelsDefault = 6`, a
hardcoded constant — the code path actually used
(`SDLAudioSystem::CreateDriver(index, semaphore, &driver)`) constructs
`SDLAudioDriver(semaphore)` and takes every default. The *format* is Xenia's;
only the *content* of those six channels is the guest's.
⚠️ **Check `pactl list sink-inputs` before trusting a recording.** If it is
empty, Canary never attached and you are recording zeroes; the sink also sits
at `IDLE`. `audio-capture run` warns on a `-inf` peak afterwards, which is the
backstop — but a live check fails in seconds instead of after the whole run.
* Boot is slow cold, ~25 s once the shader/code caches are warm — so a
launch-and-dump fits in a single call.
* **Screens: classify by whole-image statistics** (`screen_id.py`), not named
@@ -125,19 +49,7 @@ python3 tools/re-capture/gmem.py find hex:820af844 400
## Verifying your own work
* Reborn's disc-gated tests **self-skip** without `SYLPHEED_DISC`. A green run
with it unset means almost nothing.
🔴 **But `build-reborn` does not work in this container (2026-08-29).** Line 15
is `SRC="${PROJECT_DIR:-/work}/Syplheed-Reborn"` — note the transposed letters —
and no such directory exists; the workspace is at **`/work`** itself. It fails
immediately with `cd: /work/Syplheed-Reborn: No such file or directory`, so the
documented way to run the disc-gated tests is broken.
✅ **Run them directly instead**, setting the variable yourself:
```bash
SYLPHEED_DISC=/disc cargo test -p sylpheed-formats --test <name>
```
⚠️ This is the **second** wrapper in this container pointing at a source root
that does not exist — `build-canary` has the same defect. Check a wrapper's
`SRC` before trusting that a green or a failure came from your code.
with it unset means almost nothing. `build-reborn test` wires it up for you.
* Prefer a headless self-verify over "it compiles": `sylpheed-cli mesh render`,
`screen render`, `save info` all produce checkable artifacts.
* A Bevy system-parameter conflict is invisible to the type checker and panics
@@ -213,61 +125,3 @@ XEX decrypt + LZX decompress, and the disassembly-to-database step — belongs i
`crates/sylpheed-formats`.** Until then, every static finding rests on an
artefact this project cannot rebuild, and that is a real gap in the corpus rather
than a convenience.
## 🔴 Pressing Ⓐ on the title faults the guest — and the fault fills the disk
Three attempts to capture the main menu on 2026-08-29 ended the same way. Every
run that tapped Ⓐ **on the title** faulted; every run that tapped nothing there
completed and produced its capture.
| run | input on the title | outcome |
|---|---|---|
| 1 | Ⓐ, then Ⓐ again on the transition | guest fault, **519 MB** of register dump |
| 2 | one Ⓐ | drifted to a `flight` classification, 97 MB |
| 3 | one Ⓐ | guest fault, **223 MB** of register dump |
| 46 | none (`NOTAP=1`) | all completed normally |
This is the crash `ui_draw_capture.sh`'s own header records from 2026-08-18 — "a
stray A there sends the guest into the save-data probe". ⚠️ The corpus's existing
menu measurements (Q4, Q5, the focus ring) were taken by some route that survived
this; what differs has not been found. **Menu-side dynamic RE is blocked until it
is.**
⚠️ **A guest fault writes an UNBOUNDED register dump to stdout.** Xenia runs with
`break_on_unimplemented_instructions = true`, and the dump is `vN = [...]` / `rN =
...` lines at roughly 100 MB per 30 s. The filesystem here sits at **91 %**. Any
scripted run that presses a button must watch `canary.stdout` and kill on growth —
`ls -la` on it before trusting a long run.
📌 Two knobs added to `ui_draw_capture.sh` for boot-side work: `GRACE=1` (the fixed
8 s wait before arming means an `ARM=early` capture otherwise misses both splashes,
which run at ~1.29.5 s of guest time) and `NOTAP=1` (no input at all — the movie
tap fires on "the screen changed a lot", which is also true of a fading splash).
### ⚠️ `ARM=early` loses its F10 about 40 % of the time
Five `ui_draw_capture.sh ARM=early` runs on 2026-08-29: **two logged `ARMED EARLY`
and produced no `xenia_re_ui_draws_NN.log` at all.** The keypress goes to the
window and is silently lost — nothing in the session log distinguishes a run that
armed from one that did not, so **check the log file exists before spending the
run**, and treat a repeat measurement as needing more attempts than samples.
* 🔴 **`sylpheed-cli` in `$CARGO_TARGET_DIR` can be STALE, and `screen info` lies
quietly when it is.** The copy here was built **2026-08-29 12:38**, before the
keyframe-record-layout fix. The old parser shifted every keyframe time by one slot
and could not time a group's final pose, printing a trailing `-`:
```
stale pteff00.prm 4 kf rest t=70 [12:0,0 70:0,0 80:0,0 -:0,0]
fresh pteff00.prm 4 kf rest t=12 [ 0:0,0 12:0,0 70:0,0 80:0,0]
```
Both outputs are well-formed and neither announces its age. A whole page of this
corpus (`screen-transitions.md`) argued from *"there is exactly one untimed
keyframe"*, which was the stale parser's artefact.
⚠️ **`cargo build -p sylpheed-cli` before trusting `screen info`** — it takes 8 s
against a warm cache. ✅ Renders are **byte-identical** across the two binaries
(checked on `GP_TUTORIAL` build 0, max per-channel difference **0**), so
`screen render` output and anything derived from element identity, pivots or
keyframe *counts* is unaffected. It is the *times* that move.

View File

@@ -0,0 +1,106 @@
# Play-test, 2026-09-01 — a human, a real controller, the port
**The first time a person played this port on real hardware.** It found four
things. Two were fixed on the spot by the human; two are open and are the
**current focus of both agents**.
⚠️ This page is a record of observations, not a mission change. `PORT-MISSION.md`
and the loop briefs carry the objective.
## What was found
| # | finding | status |
|---|---|---|
| 1 | **Ⓐ and Ⓑ did nothing on the pad.** Could not skip the intro, could not open a submenu. | ✅ fixed by the human — `port/scripts/gamepad.gd` |
| 2 | **The left stick moved the cursor far too fast.** | ✅ fixed by the human — latched to one step per deflection |
| 3 | **The `PRESS Ⓐ` plate appears too late.** | 🔴 **OPEN** |
| 4 | **The splash fade/blur is wrong** — the game's is *more pronounced*. | 🔴 **OPEN** |
## 1 & 2 — why no check caught them, which matters more than the fixes
> **`--script` sends `InputEventAction`, which BYPASSES the input map.**
Every check the port had asserted the code *below* the input map and nothing
about the map itself. The map turned out to have **no joypad binding for
`ui_accept` or `ui_cancel` at all** — measured on Godot 4.7.2, not remembered,
because the remembered answer was wrong:
```
ui_accept key:Enter, key:Kp Enter, key:Space <- no joypad button at all
ui_cancel key:Escape <- no joypad button at all
ui_up key:Up, JOYBTN:11, JOYAXIS:1- <- d-pad AND left stick
ui_down key:Down, JOYBTN:12, JOYAXIS:1+
```
Four actions reached the pad and two did not. Ⓐ was dead for the whole of P5
while the unattended walk passed on every iteration.
The **same blind spot** hid finding 2: an `InputEventAction` is not an analog
axis, so nothing could observe that a stick held at deflection emits an event
per *jitter*, each reporting the action as pressed — one cursor step per jitter.
Now asserted by `tools/port/verify-input`, with a control that removes each
check's own subject. (Its first version inverted all nine assertions when only
two depended on the fix, and reported seven correct checks as broken. Three rows
now say plainly they are **not controllable** — they assert Godot's own bindings
— and one is a **negative carrying a positive control** rather than a faked
inversion.)
### The standing rule that follows
**Synthetic input is not a test of input.** Anything injected below the input map
is evidence about the code above it and nothing else. A test of input must go in
at the device level — `InputEventJoypadButton`, `InputEventJoypadMotion`,
`InputEventKey` — or must assert the map directly.
## 3 — the plate is late
The port raises the plate at `t=236`, **3.93 s** after the shared clock starts,
which it derives as `238 118 = 120 units = 2.000 s` after the title's build-in
ends. A human watching both says it is **late**.
This lands in a spot the corpus already knows is soft. All of the following are
live:
* `REFUTED.md`: *"a screen has SETTLED at its `rest.t`"* → ❌ — believing `rest.t`
had already put a port's plate **3.97 s late** once.
* `REFUTED.md`: the 2.13 s figure was *"a wall-clock reading stretched by Canary
presenting at ~28.1 fps"*, corrected to 120 units. **So the conversion between
units and seconds is load-bearing here and is exactly what
[`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md) says not to trust from a
wall clock.**
* The keyframe **time-unit shift** is unresolved (`ui-keyframe-time-unit.md`).
* 🔴 After the 2026-09-01 R1 pass, *"the declared keyframe timeline reproduces the
captured splash"* is **🟡 `⟨our-reader⟩`**, not ❌ — the record-layout fix
re-times a group's final pose and the entry was never re-derived under it.
**Candidate causes, none established:** the unit→seconds constant; the clock
origin (do both builds really start together?); `rest.t` again; the record layout.
Settle it by **ordering and counts**, not by a stopwatch.
## 4 — the splash fade/blur
The port applies **no blur at all**. It draws declared keyframe alphas. So
*"more pronounced in the game"* is consistent with a post-process the export does
not describe, a different ramp shape, or both — and nothing in the export can
distinguish those.
🔴 **And the two splashes are the ONLY screens that reach `rest()`'s
plateau-less fallback** — title, main menu and `EXTRAS` reach it zero times. So
finding 4 lands precisely where our resting-pose heuristic is least trustworthy,
and the R1 pass just re-opened that question **in both directions** (see the
`rest()` pair in `REFUTED.md`). That is not a coincidence to step around.
## The human's verdict on method
> *"It seems the agents were essentially guessing and trying to copy what one
> would see, but while they did get close it still is not quite right."*
Close-but-not-right is the signature of reproducing **appearance** instead of
deriving **mechanism**. A ramp tuned until it looks right is wrong in a way
nobody can name and has no reach to the next screen.
The instruction that follows: for the splashes, **find out what the game is
doing** — is there a post-process pass, how many, what shader, what blend, what
render targets, and where do its parameters come from — before proposing any
curve. See the Decoder's brief.

View File

@@ -0,0 +1,230 @@
# Play-test, 2026-09-02 (second) — **P5 IS MET**, and four findings
## ✅ P5's gate is MET — the human clicked through it
> *"Menu walk and navigation is fine. Video skips too. Extras open. New Game
> shows new game intro video."*
**P5 is done.** Its gate was *"a human clicks through it"*, the retro said it had
been waiting on that and not on code for the whole milestone, and it has now
happened. `PORT-MISSION.md` is updated.
The human also confirmed the NEW GAME gap is understood and acceptable:
*"Deliberate AFAIK, in actual game the difficulty select comes first."* The port
announces the two screens it skips; that stays as it is.
---
## F1 — 🔴 The menu DOES repeat on a held direction. Ours does not.
> *"Moving stick up/down and holding only moves one item. In game it actually
> continues to move when holding up/down, just at a medium pace so player does
> not need to move pad middle↔up/down, but also slow enough to see which item is
> selected and move to target."*
**This settles the existence half of H1, and it settles it against us.** One step
per deflection was authored as the conservative choice precisely because nobody
knew. Now somebody has watched the real game: **it repeats.**
⚠️ **The RATE is still not measured, and it must not be guessed.** The human's
description bounds it usefully and does not supply a number: fast enough that a
player need not return the stick to centre, slow enough to read the selection as
it passes. That is a range, not a value.
* **Decoder — measure it.** Hold a direction in Canary and count. Two numbers:
the **initial delay** before the first repeat, and the **repeat interval**
after it. Frames between cursor moves, at a stated present rate — a count, not
a stopwatch reading ([`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md)).
Also: does the d-pad differ from the stick? Does the rate accelerate while
held, or stay flat?
* **Port — implement the mechanism, take the number from the Decoder.** Do not
ship a placeholder rate: an invented interval here is indistinguishable from a
measured one a month from now, and this is the exact field where that has
already cost us once.
## F2 — 🔴 The sound effects are too loud. There is no mix at all.
> *"Largely OK. Biggest notice is the volume, many effects are too loud."*
**Measured, and the human is right.** Every clip plays at unity gain, because
**no volume or gain value exists anywhere** — not in `export/`, not in
`authored/`, not in the manifest:
| | mean | max |
|---|---|---|
| **`se/confirm`** | **17.7 dB** | **0.0 dB** — at full scale |
| `se/move` | 24.1 | 1.4 |
| `se/back` | 21.0 | 5.7 |
| `bgm/main_menu` | 20.7 | 4.2 |
`confirm` is the loudest thing in the export: **3 dB hotter in mean than the
music** and 6.4 dB above `move`. A game mixes SE against BGM on separate buses;
this port has one bus and no gains.
* **Decoder — is the mix ON THE DISC?** The obvious place is the cue table: a
cue record commonly carries volume alongside the wave index, and
`sub_821C5580` is already known to play cue 1103. If per-cue or per-bus gain is
there, it is **decoded** and nobody has to choose. If it is provably not, say
so with reach and it becomes an authored mix.
* **Port — do not normalise in the exporter to fix this.** Re-levelling the file
destroys the relationship between clips and cannot be undone by a modder.
Gains belong at playback, as data, where a measured value can replace a chosen
one without re-exporting.
## F3 — ❔ Something is missing on the title screen
> *"I also think that there is a sound track or effect missing at the title
> screen."*
The export carries exactly one music track, `bgm/main_menu.ogg`, and the port
plays nothing on the title. Whether the game does is unestablished.
* **Decoder:** which cue, if any, does the **title** play? The menu's is decoded
(cue 1103 = `BGM_103` via `sub_821C5580`); the same route should answer the
title. And is there a one-shot **sting** when the plate appears, or when Ⓐ is
accepted? Either would read as "something missing" to a player.
* ⚠️ A negative here needs a positive control (R4): show the method finding the
menu's cue before concluding the title has none.
## F4 — 🔴 Ⓐ SKIPS FORWARD through the boot. We only implement one of the three.
> *"In the game one can get the plate to immediately show by pressing Ⓐ,
> essentially skip to it. So after the logos one can immediately skip to the main
> menu by pressing Ⓐ three times: 1. skip intro video, 2. show plate, 3. the
> plate itself."*
A measured behaviour of the real game, and a good one — it is how a returning
player gets past the boot.
| press | in the game | in the port |
|---|---|---|
| Ⓐ #1 | skips the intro video | ✅ implemented |
| **#2** | **completes the title build-in and shows the plate immediately** | ❌ **missing** |
| Ⓐ #3 | activates the plate → main menu | ✅ implemented |
* **Port:** Ⓐ during the title build-in should jump the sequence to the plate's
arrival rather than being swallowed. ⚠️ **Careful what "jump" means, and do
not choose it.** See below — it is a test of an authored premise, not a detail.
* **Decoder:** what does Ⓐ do to the clock? This is also a **second, cheap route
to the plate-arrival question** — a press that skips to the plate tells you
where the game thinks the plate belongs.
### 🔴 F4 is a TEST OF `clock: "shared"`, which is authored and only ~20 % confirmed
**Correction, by the human who wrote this page: an earlier draft said "both
clocks". There is only ONE.** `authored/flow.json` sets `"clock": "shared"`: the
title is two composited builds — build 4 the artwork, build 2/3 the plate — and
they run on **one clock started together**. Build 4's artwork finishes at
`t ≈ 118`; the plate reaches full alpha at `t = 236`. Saying "both clocks" would
send someone hunting for a second one that this corpus says does not exist.
With that fixed, the question is sharp and **observable**:
| if Ⓐ … | then pressing EARLY looks like |
|---|---|
| **advances the shared clock** | the title artwork **snaps** to finished, and the plate appears |
| **only forces the plate visible** | the artwork **keeps animating** its remaining build-in while the plate appears over it |
So film a boot, press Ⓐ while the wordmark is still building in, and watch the
**artwork**, not the plate.
📌 **Why this matters beyond the feature.** `clock: "shared"` is **authored**, and
`plate-arrival-halves.md` says in its own words that it is *"not falsified… not
confirmed to better than ~20 % either"*. There is also an unresolved anchor
disagreement **inside one binary**: the reconciliation picked `t=118`, while
`settle_time()` returns **160** and the boot prints `settles at t=160`.
If Ⓐ snaps the artwork, that is evidence **for** one shared clock. If the artwork
carries on while the plate appears, the plate has a timeline of its own and the
authored premise is in trouble. **Answer F4 before building on `shared`.**
* 📌 And it bears on `REFUTED.md`: *"any title after the first one refuses
input"* is already narrowed to the attract-returned title. This is a third
input the boot title accepts.
---
## F5 — Ⓐ: does the animation SNAP, or accelerate? **A human cannot tell.**
Follow-up from the same human, and the honesty in it is the useful part:
> *"I think the animation speeds up to the finished state. So it is not a snap in
> the sense of a cut, but rather becoming much quicker — which however feels
> instant too, so it is difficult to discern by a human. It might also actually
> snap/cut to the finished state, but appear as a quick animation… Similar to how
> videos work by quickly playing distinct frames. So I cannot tell certainly
> which it is. Upon multiple attempts it does look more like a snap. Decoder
> still should verify."*
**This is a question the oracle-by-eye cannot answer, and it is being handed over
as such rather than guessed.** A three-frame acceleration and a one-frame cut are
indistinguishable to a person; they are trivially distinguishable to an
instrument. Two independent routes, and they should agree:
1. **Per-frame capture.** Press Ⓐ mid-build-in and read the submitted alphas
frame by frame. An acceleration shows **intermediate values**; a cut shows
one transition and none. This is a counting question — see
[`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md), and note that the eye
failing here is exactly why.
2. **The code.** Whatever Ⓐ does to the clock is a store somewhere: does it
assign the target time, or raise a rate multiplier? A snap and a speed-up are
different instructions, and the image says which.
⚠️ The human's *"looks more like a snap on multiple attempts"* is a **prior, not
a result.** Do not let it stand in for the measurement, and say so if the
measurement disagrees with it.
## F6 — 🔴 The title's sweeping glow starts TOO EARLY in the port
> *"…blue geometric lines (like on a PCB, straight lines and rectangular or 45°
> turns). These have a white glow moving on them as an animation. In the game
> this animation only starts when the plate is shown — basically the animation
> starts the same as the 'insert' of the plate. In the port it already starts
> before the plate arrives."*
**The elements are `ptloop01` and `ptloop02`** — already known to this corpus as
the sweeps whose leaf *"sweeps a 400 px quad whose left edge travels
639…1521"*, and whose free-running on the settled title is a ❌ entry in
`REFUTED.md` (they **do** free-run). Nothing there says **when they start**, and
that is the whole of this finding.
### 📌 A lead, from the exported declaration — MINE, unverified, check it first
`title.json` gives `ptloop01` and `ptloop02` keyframes at:
```
t = 0, 70, 100, 238, 250
```
and the plate reaches full alpha at **`t = 236`**. They are not alone: `pteff02`
has a key at exactly **236**, and `ptlogo_back2eff` and `ptcopyright` at **238**.
**236238 is a synchronisation point in the declared data**, and the human has
just reported a behaviour change at that instant.
So the first question is cheap: **is the sweep's motion declared to begin at 238,
with the port instead free-running the leaf from t=0?** If so this is a decode
question with a decoded answer, and nothing needs authoring.
⚠️ **Two reasons not to take that lead as the answer.** The `238…250` pair looks
just as much like an **exit ramp**`ptcopyright` and `ptlogo_back2eff` use
exactly that shape and they are certainly not starting anything. And the sweep
lives in a **nested `.rat` leaf** with its own three keyframes, so the parent's
envelope and the leaf's motion are different timelines. Which of the two the
human is watching is the thing to establish.
### Why this one is worth prioritising
It bears directly on **F4 and on `clock: "shared"`**. If a title element does not
begin moving until the plate arrives, then either the declared data says so — in
which case the shared clock survives and our reading of the keyframes is wrong —
or something at the plate's arrival **starts** it, which is a mechanism nobody
has proposed. Either answer constrains the clock question that F4 is also
probing.
## H3 — the plate delay is ACCEPTED
> *"Delay feels the same. Cannot verify it is exact same, but is sufficient."*
Good enough to stop working on, **not** established as correct. Leave the row as
unattributed rather than closing it green; if the duration question is ever
settled by the pipeline work, check it against this rather than re-opening it
from scratch.

View File

@@ -0,0 +1,193 @@
# Play-test, 2026-09-02 — the splash did not animate. **Now fixed and signed off.**
## ✅ CLOSED THE SAME DAY — the human's verdict, which is the only gate that counts
> *"Looks good! Cannot notice any obvious difference from the actual game.
> Mark logos as done."* — 2026-09-02
**The logo splashes are DONE.** Not "the check passes" — a person compared the
port against the real game and could not tell them apart. That is the oracle,
and it is the strongest result this port has produced.
⚠️ **Both agents: the sole-focus block is lifted.** Return to your milestones.
What remains open from the play-tests is listed at the bottom of this page; none
of it is this.
### The root cause was one word
```diff
- t = settle_instant if settle_instant >= 0.0 else min(t, settle_units(element))
+ t = min(t, settle_instant) # the comment above it already said "stop at"
```
`pose_at` **assigned** the settle instant instead of clamping to it, so every
query returned the settled pose whatever the clock said.
🔴 **And the same line manufactured the false green.** The capture harness shoots
after two frames, so it was photographing t ≈ 2 units — which *looked* settled
only because everything looked settled. **The 0.01 % agreement that closed H2 was
measured through the accident.** One bug produced the defect and the evidence
that the defect was absent.
### Verified independently before it went to the human
Filmed a real boot at 0.05 s, before against after:
| | before | after |
|---|---|---|
| splash in motion | 1.30 s / 7.95 s (16.4 %) | **2.20 s / 7.95 s (27.7 %)** |
| distinct luma states | 26 | **43** |
| publisher ramp | 0.30 s, 6 steps | **0.65 s, 13 steps, one continuous run** |
| developer splash | two bursts split by a **0.50 s freeze** | **one continuous 0.90 s run** |
The publisher trajectory rises to a peak and settles back — the **crossfade
signature**: glow alone, then both, then sharp only. The developer splash's
interrupting freeze disappearing is the clearest single sign the clock now drives
the poses.
The port then closed a gap `motion-census` names in its own header — *"a wrong
ramp that moves every frame passes here"* — with a shape check pre-registered
from the disc, measured off a film, on a strip no other element overlaps, in
**ratios** so the texture divides out: middle:last declared 0.203 / measured
0.213, rise:last declared 1.20 / measured **1.20 exact**. It agrees with the
Decoder's independent measurement of the running game.
### 🔴 The lesson, which outlives the bug
Three instruments passed a frozen screen. Keep this: **an instrument that sits
below the thing under test cannot see it fail.** A frozen sweep drives the clock
by hand; a settled comparison is *defined* to pass on a frozen screen; an
achieved-fps counter counts frames drawn, not frames different. Ask of any new
check: *what would this still report if the feature were entirely absent?*
---
## The original report, kept for the record
The human, watching the port on real hardware, on a GPU, at ~140 fps:
> *"The port does no blur animation at all! I cannot discern if there is any
> animation at all. The logos just switch without the animation."*
## Measured, not paraphrased
Filmed from a **real boot** at 0.05 s (`--film`), then per-frame change measured
with [`tools/motion-census`](../../tools/motion-census):
| | |
|---|---|
| splash moves | **1.30 s of 7.95 s — 16.4 %** |
| publisher splash | 0.30 s of motion, then **3.20 s frozen** |
| developer splash | 0.35 s + 0.25 s, then **2.40 s frozen** |
| distinct luma states in 7.95 s | **26** |
A 45-unit build-in cannot be drawn in 26 states, and a fade does not hold one
picture for 3.20 s. **This is a switch with a flicker on either side.**
The frame counter says 24.8 fps achieved. Both are true: the port is *drawing*
25 times a second and *changing* almost never.
## 🔴 Why three instruments all said it was correct
This is the part that matters more than the bug, because it is the fourth time
this shape has cost a milestone.
| instrument | what it proved | what it could not see |
|---|---|---|
| **frozen sweep** (`--time=`, 3 units a step) | the renderer CAN draw pose *N* | whether the poses are ever drawn **in sequence, while running** |
| **settled comparison** (0.01 % against the capture) | the resting pose is right | a screen frozen 84 % of the time matches a settled reference **perfectly — that is what frozen means** |
| **achieved-fps counter** | frames are being drawn | drawing the **same pixels** 25×/s scores identically to animating |
> **Every one measured throughput or a pose. Not one measured CHANGE.**
That is why the port could write *"the companion quads are drawn, verified by a
frozen sweep"* and be simultaneously right and useless: the sweep drives the
clock by hand. It is the same defect as `InputEventAction` bypassing the input
map — **the instrument sat below the thing that was broken**, so the thing that
was broken could not appear in it.
`tools/motion-census` exists to close this class. It measures change and nothing
else, and its `--selftest` proves it separates a fade (97.4 % moving) from a
switch (2.6 %) from a frozen film (0.0 %) — because a detector that cannot tell
those apart would report the same green line on all three.
## What both agents do now
**Nothing else.** Not the clock rate, not the plate, not blend, not audio. This
first.
### Port
1. **Reproduce it** with `--film` + `motion-census` before changing anything, and
quote the numbers. If your run does not reproduce 16.4 %, say so — the
disagreement is then the finding.
2. **Find why the clock does not advance the poses.** Candidates, unranked and
none established: the keyframe interpolation returns the same pose for a
range of *t*; `rest()`/plateau logic snapping to an endpoint; the group clock
not integrating; interpolation between keyframes not happening at all
(nearest-keyframe rather than lerp); the screen advancing by *keyframe index*
instead of by time.
3. **Every fix is gated by a film**, never by a still. A change that improves a
settled frame and leaves the film at 16 % has not fixed this.
4. `motion-census` goes into `check-all`, so a future regression fails a check
instead of waiting for a human.
### Decoder — **map the whole graphics pipeline, end to end**
The human's instruction, in their words: *"get the whole graphics pipeline, from
the xex/pe + the disc files to the final screen displayed, and take Xenia Canary
processing into account too."*
So: one continuous account, each stage with evidence and each labelled by the
instrument that produced it —
```
disc bytes → RATC/T8aD decode → what the GAME CODE does per frame
→ the draw calls it submits → Canary's own processing
→ the presented frame
```
Specifically, and none of it inferable from a file alone:
* **The game's per-frame update.** Which code advances a UI group's clock, in
what units, and what it does *between* keyframes — does it interpolate, or
hold to the next key? That single question decides whether the port should
lerp at all. It is in the image; find the function.
* **What the game submits per frame during the splash** — the draw list frame by
frame, not one settled frame. If the alpha changes, it changes *somewhere*
observable: a vertex colour, a PS constant, a blend factor, a texture swap.
**Name which, with the per-frame series.**
* **What Canary does to it.** Present cadence, any resolve/scaling/gamma between
the guest's draw and the pixels a capture records. A capture is evidence about
*Canary's output*, and the difference between that and the guest's intent has
bitten this corpus before (the `kernel_display_gamma_type` entry).
⚠️ **Deliver a per-frame SERIES, not a settled value.** Follow
[`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md): film it, align by
content, report ordering/counts/durations. The port needs to know what the
alpha *trajectory* is, and a single frame cannot carry one.
## And this is a refutation the port should record against itself
`BLOCKED.md` H2 currently reads ✅ **ANSWERED**, on the strength of the frozen
sweep. The mechanism half stands — the blur is a baked companion texture, that
is decoded and correct. **The behaviour half does not**: the port draws those
quads and does not animate them, so *"the companions are drawn"* was true and
did not mean what the row used it to mean.
---
## What is STILL OPEN after the sign-off
The logos are done. These are not, and none of them blocks a milestone gate:
| | | owner |
|---|---|---|
| **H1** | Does a held direction **repeat** in the menus, and at what rate? One step per deflection stays authored. The 61 % arm threshold is decoded and adopted; the 0.11 hysteresis gap is still authored. | Decoder |
| **H3** | The `PRESS Ⓐ` plate. All four named causes are dead and the port measures fractionally **early**, so what the human saw is **unattributed** — deliberately not closed green. ⚠️ Worth re-asking now: the animation fix changed what the whole boot looks like, so the original observation may simply no longer reproduce. | both |
| **pipeline** | The end-to-end graphics account — disc → decode → the game's per-frame update → submitted draws → Canary's processing → the presented frame. Cut short by the sole-focus order and **still the right work**: it is what would let the port know whether its ramp *duration* matches the game rather than only its own declared keyframes. | Decoder |
**P5's gate is "a human clicks through it" and has NOT been claimed here.** The
human has signed off the *logos*, and separately confirmed that Ⓐ, the stick and
the submenus work. Nobody has said the milestone is met, and an agent must not
say it on their behalf.

View File

@@ -152,6 +152,45 @@ asking X"** rather than approximating. An approximation from the wrong agent
arrives with no classification attached and is indistinguishable from a
measurement a month later.
## Work in units a human can check in a minute
**Set by the human, 2026-09-02, from what actually worked.** The splash bug had
sat through a whole milestone. Scoped to *one* question — *does it animate?* — it
was found, fixed, verified and signed off in a day.
> *"I think attacking the 'whole' mission was too big for them to handle. Split
> the given missions and tasks into even smaller tasks which they can tackle and
> give to a human for feedback."*
So: **a milestone is not a unit of work. It is a bag of them.** Before starting,
split it, and pick one.
A unit is right-sized when it ends in something **a person can judge in under a
minute without reading anything**. Not "P6 audio" — *"the confirm SFX is no
longer louder than the music; listen once."* Not "the title screen" — *"the glow
starts when the plate appears; watch one boot."*
Each unit, written down **before** the work:
* **the question**, as one sentence a non-expert could answer;
* **what the human looks at**, and what pass and fail each look like;
* **what it does NOT cover** — the neighbouring thing you are deliberately not
fixing, so nobody reads a narrow pass as a broad one.
Then: **do that one, hand it over, and stop.** Do not run ahead into the next
unit while the first is unverified — an unverified fix underneath a second change
is how a regression becomes two-variable and unattributable.
⚠️ **The bar is a HUMAN check, not a green tool.** Three instruments passed a
frozen screen. A tool answers *did my change do what I intended*; only the person
answers *is it right*. When a unit needs a look, say so plainly and say what to
look at — an ask that is buried in a document nobody opens is not an ask.
📌 And this bounds the writing, which has been the other failure: the retro found
`DECISIONS.md` past 13 000 lines while the gate did not move. **A unit's record
is proportional to the unit.** If explaining it takes longer than doing it, the
unit was too big or the writing is doing something other than explaining.
## Publishing
* Commit to `auto/<topic>`; a human merges.

View File

@@ -0,0 +1,139 @@
# Verifying things that MOVE
**Both agents read this.** Set by the human on 2026-09-01, after a play-test
found the splash fade and the `PRESS Ⓐ` plate visibly wrong while every check
either agent had was green.
## The diagnosis, in one sentence
> **We have been trying to photograph the game at time *t*, and *t* is never the
> same twice.**
Every temporal claim in this corpus rests on grabbing a frame at a wall-clock
instant and comparing it to something. That instant drifts — emulator speed
varies with host load, Canary presents at ~28.1 fps rather than 30, the capture
path costs a variable 0.110.8 s, and a long-lived `x11grab` stream degrades and
then freezes. So the comparison is between *our render at the time we meant* and
*the game at some other time*, and the difference between those two things is
being read as a difference in the **content**.
The register already carries four separate refutations of this exact shape:
* *"a latency read off a classified `x11grab` stream is a duration"* — ❌. At
1503 ms per classification against an 8 fps stream the consumer ran at
0.64 fps; four "durations" died. A screen transition, a button press and a
plate fade all came out at ~2025 s, which is the tell.
* *"2 391 frames over 600 s, max glyph 0, therefore the title never appears"* —
withdrawn: **the instrument stalls**, repeating one stale frame, reading
surface mean 5.21 where `import` read 125.65 at the same moment.
* *"the boot harness fails because its polling loop samples every ~41 s"* —
the defect was real and fixing it (13.7×) **did not change the answer**.
* *"the in-box capture noise of 0.32 between sessions"* — it was not noise, it
was the **trigger**: gating on the plate pulse phase-locks the shutter, so
0.32 is a lower bound produced by the instrument. At an arbitrary phase the
honest figure is 11.9.
That last one is the important one, and it cuts both ways: **gating hides
variance, and not gating produces it.** Neither is a measurement of the game.
## What to do instead
The rule is simple and it removes the whole class:
> **Never compare at an absolute time. Record a SEQUENCE, and align it by
> CONTENT.**
### 1. Capture a film, not a photograph
Record a continuous run of frames with an index and a timestamp each, spanning
the whole animation with margin at both ends. One frame is a sample of a
distribution you have not characterised; a film *is* the distribution.
**State the achieved rate against the requested rate, every time.** A capture
that asked for 4 fps and delivered 1.6 is not a slow capture, it is a
**different capture**, and it has already produced two withdrawn findings here.
An instrument that cannot report its own completeness may not be trusted (R3).
### 2. Align by content, then measure
Find the offset that best matches, rather than assuming offset zero:
* reduce each frame to a scalar or a small vector — mean of a region, an
element's alpha, a per-tile amplitude;
* do the same for the prediction;
* **search the lag** that maximises agreement, and report *both* the lag and the
agreement at it.
The lag is not an error to be minimised away — **it is a measurement**. A
consistent lag across runs is a real offset in our model. A lag that varies
run to run is the harness, and says so.
### 3. Prefer quantities that have no phase
Ranked by how much they survive a drifting clock:
| quantity | survives drift? |
|---|---|
| **ordering** — A finishes before B starts | ✅ completely |
| **counts** — 83 frames at full alpha | ✅ (given a known, reported rate) |
| **durations and ratios** — ramp is 2× the hold | ✅ |
| **shape** — monotone, eased, stepped, its inflections | ✅ |
| a value **at a named event** — alpha when the plate first appears | 🟡 needs the event found, not the time |
| a value **at wall-clock t** | ❌ this is the thing that has been failing |
The two strongest existing results in the corpus are both of this kind: the
**hold duration** (83 frames of full alpha) is called *calibration-free* in
`ui-keyframe-time-unit.md` and decided the question; and the `_eff` glows'
**exact steps of 34** are a shape, not a sample.
### 4. Anchor on an event
Quote everything relative to a frame you can *find* rather than a time you
requested: the first frame an element is non-black, the frame the plate first
appears, the last frame of the previous screen. Then a drifting start costs
nothing, because every number is a difference.
### 5. Say what you expected before you look (R2)
*"This ramp declares 80 units, so at 30 fps I expect ~80 frames and I will
accept 7486."* Written first, it makes a near-miss legible as a near-miss
instead of something to rationalise. Written after, any number can be explained.
### 6. Convert units deliberately
⚠️ **Canary presents at ~28.1 fps, so a wall-clock duration off this emulator is
~6 % long.** A measured interval landing near a round number of keyframe units
probably *is* that number of units — that is how `2.13 s` turned out to be
`120 units = 2.000 s`. Quote the unit count, then the seconds, then the fps you
divided by. Never the seconds alone.
## The other half: stop reproducing by eye
The play-test's verdict on the splashes was *"close, but not quite right"*, and
that is the signature of **matching appearance instead of deriving mechanism**.
A ramp tuned until it looks right will be wrong in a way nobody can name, and
"looks right" has no reach — it does not tell you what the next screen will do.
So for anything visual that is still not exact, the question is not *"what
curve fits?"* but **"what is the game actually doing?"**:
* Is there a **post-process pass at all** — a blur, a bloom, a fade quad, a tone
curve? That is a GPU-state question with a yes/no answer.
* If yes: how many passes, what render targets, what blend, what shader, and
**where do its parameters come from** — immediate constants, a table in a pak,
a computed ramp?
* Only then, what curve.
A mechanism found this way is *decoded*, generalises to every screen, and cannot
be "close". A curve fitted by eye is none of those things.
## What this does not license
Doing more of this is not a reason to stop shipping. A measurement that would
take an hour is not blocked on building the perfect harness first — take the
cheap phase-invariant version (an ordering, a count) and say what its reach is.
And **an instrument that cannot pass a control is not a starting point.** A
filter that fails its own known-positive is dead, not tuneable; a lag search
that cannot recover a synthetic 30-frame offset cannot measure an unknown one.
Run the control first, and record it.

View File

@@ -1,6 +1,233 @@
You are the **Decoder**. Answer the open questions the Godot menu port is
blocked on, one at a time.
## 🔴🔴 SOLE FOCUS, 2026-09-02: **THE TITLE'S ANIMATION TIMING — F5 and F6, nothing else**
**Work only these two.** Not the pipeline, not the audio mix, not the repeat
rate — they stay queued in
[`PLAYTEST-2026-09-02-menus.md`](PLAYTEST-2026-09-02-menus.md).
> *"Let's have the agents focus on this item and only this only."*
**F6 first** — it is the one with a lead. A human reports that the title's
sweeping white glow (**`ptloop01` / `ptloop02`**, the blue PCB-like lines) **only
starts when the plate appears** in the real game, while the port starts it
earlier. `title.json` declares those elements at `t = 0, 70, 100, 238, 250` and
the plate reaches full alpha at **`t = 236`** — with `pteff02` keyed at exactly
236 and `ptlogo_back2eff`/`ptcopyright` at 238. **236238 is a synchronisation
point in the declared data and a human just reported a behaviour change there.**
⚠️ `238…250` may equally be an **exit ramp** (`ptcopyright` uses that shape and
starts nothing), and the sweep lives in a nested `.rat` leaf with its own
timeline. Establish which of the two the human is watching.
**F5 second** — does Ⓐ **snap** the title to finished, or **accelerate** it? The
human says they cannot tell, and is right that they cannot: a three-frame
acceleration and a one-frame cut look identical to an eye. Two routes, and they
should agree: a **per-frame capture** (an acceleration shows intermediate alphas,
a cut shows none) and **the code** (assigning a target time and raising a rate
multiplier are different instructions). Their *"looks more like a snap"* is a
**prior, not a result** — say so if the measurement disagrees.
### And split it before you start
**Read the new "Work in units a human can check in a minute" section of
[`PROTOCOL.md`](PROTOCOL.md).** The human's diagnosis is that whole missions have
been too big to hold. Break even F6 down, write the question and the
look-at-this-and-you-will-see before working, do one, hand it over, stop.
## ✅ THE LOGO SPLASHES ARE DONE — signed off by the human, 2026-09-02
> *"Looks good! Cannot notice any obvious difference from the actual game.
> Mark logos as done."*
**The sole-focus order is lifted.** The port's defect was `pose_at` assigning the
settle instant rather than clamping to it; your per-frame measurement of the real
game (28 distinct alphas over 28 consecutive presents, modal steps 3 and 14
against predicted 2.87 and 14.13) is what let their fix be checked for *shape*
and not merely for motion. That is the pairing this team is for.
### 🔴 The pipeline work is STILL THE RIGHT WORK — continue it, at normal priority
It was cut short by the sole-focus order, and it remains the thing that decides a
question the port cannot answer about itself: **the port matches its own declared
keyframes; nobody has established that its 60 units/s matches the game.** The
ramp is right in shape and unverified in duration.
So carry on with the end-to-end account, unchanged in substance:
```
disc bytes → RATC/T8aD decode → what the GAME CODE does per frame
→ the draw calls it submits → Canary's own processing
→ the presented frame
```
The three load-bearing questions stand, and the first is now the most valuable:
1. **The per-frame update** — which function advances a UI group's clock, in what
units, and **what it does between keyframes**. The port interpolates
piecewise-linearly across declared segments and your capture agrees; the
remaining gap is the *rate*.
2. **What is submitted per frame** during a screen's build-in, as a series.
3. **What Canary does to it** before a capture records it — present cadence,
resolve, scale, gamma.
### 🔴 Four asks from the 2026-09-02 menu play-test — [`PLAYTEST-2026-09-02-menus.md`](PLAYTEST-2026-09-02-menus.md)
P5's gate is **met** (a human walked the menus). These came out of the same
session, and three of the four are yours. They are ahead of the pipeline work
because the port is blocked on two of them.
1. **F1 — MEASURE THE MENU REPEAT RATE.** The human watched the real game: a held
direction **repeats**, *"at a medium pace… slow enough to see which item is
selected"*. That settles the existence half of H1 against our authored
one-step-per-deflection. Two numbers, and the port will not move without
them: the **initial delay** before the first repeat, and the **repeat
interval** after it. Frames between cursor moves at a stated present rate — a
count, not a stopwatch. Also: does the d-pad differ from the stick? Does it
accelerate while held, or stay flat?
2. **F2 — IS THE AUDIO MIX ON THE DISC?** The SFX are too loud and there is **no
gain value anywhere** in the export; `confirm` peaks at 0.0 dBFS and sits
3 dB above the music in mean. A cue record commonly carries a volume beside
its wave index, and you already decoded `sub_821C5580` playing cue 1103. If
per-cue or per-bus gain is there it is **decoded** and nobody has to choose.
If it provably is not, say so with reach.
3. **F3 — WHAT DOES THE TITLE PLAY?** A human says something is missing there.
Which cue, if any, does the title screen play, and is there a **sting** when
the plate appears or when Ⓐ is accepted? ⚠️ A negative needs a positive
control (R4): show the method finding the *menu's* cue before concluding the
title has none.
4. **F4 — WHAT DOES Ⓐ DO TO THE CLOCK?** In the real game, Ⓐ during the title
build-in **reveals the plate immediately** — so the boot takes three presses:
skip video, reveal plate, accept plate.
🔴 **This is a test of `clock: "shared"`.** The title is two composited builds
— build 4 the artwork (finishes `t≈118`), build 2/3 the plate (full alpha
`t=236`) — and the port's `authored/flow.json` runs them on **one** clock
started together. That premise is **authored**, and the port's own
`plate-arrival-halves.md` calls it *"not falsified… not confirmed to better
than ~20 %"*, with an unresolved anchor disagreement inside one binary
(`t=118` from the reconciliation, `160` from `settle_time()`).
The discriminator is observable: **press Ⓐ early, while the wordmark is still
building in, and watch the ARTWORK, not the plate.**
| if Ⓐ … | the artwork |
|---|---|
| advances the shared clock | **snaps** to finished |
| only forces the plate visible | **keeps animating** its remaining build-in |
📌 It is also a **cheap second route to the plate-arrival question** — a press
that skips to the plate says where the game thinks the plate belongs — and a
third input the boot title accepts, narrowing `REFUTED.md`'s *"any title after
the first refuses input"* further.
⚠️ Deliver a **series, not a settled value** — see
[`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md), and note that the port's
whole defect was invisible to three instruments that each measured a pose or a
throughput rather than a change.
## Previous sole focus, 2026-09-02 — the order, kept for the method
A human on real hardware: *"the logos just switch, there is no animation."*
Measured from a real boot — **the splash moves 1.30 s of 7.95 s (16.4 %)**, the
publisher logo frozen **3.20 s**, and the whole thing takes **26 distinct luma
states**. The port draws the right quads in the right places and never moves
them.
Your half is not the port's bug. It is that **nobody can say what the game does
between keyframes**, so nobody can say what the port should be doing.
### The deliverable, in the human's words
> *"Get the whole graphics pipeline, from the xex/pe + the disc files to the
> final screen displayed. Take Xenia Canary processing into account too."*
One continuous account, each stage carrying its evidence and its `⟨instrument⟩`:
```
disc bytes → RATC/T8aD decode → what the GAME CODE does per frame
→ the draw calls it submits → Canary's own processing
→ the presented frame
```
Three questions that are load-bearing and none answerable from a file alone:
1. **The per-frame update.** Which function advances a UI group's clock, in what
units, and **what does it do BETWEEN keyframes** — interpolate, or hold to the
next key? That single answer decides whether the port should lerp at all. It
is in the image. Find it.
2. **What is submitted per frame during the splash** — the draw list frame by
frame, not one settled frame. If alpha changes it changes *somewhere*
observable: a vertex colour, a PS constant, a blend factor, a texture swap.
**Name which, and give the per-frame series.**
3. **What Canary does to it** — present cadence, and any resolve, scale or gamma
between the guest's draw and the pixels a capture records. A capture is
evidence about *Canary's output*; the gap between that and the guest's intent
has bitten this corpus before (`kernel_display_gamma_type`).
⚠️ **Deliver a SERIES, not a settled value.** Follow
[`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md): film it, align by
content, report ordering and counts and durations. The port needs the alpha
*trajectory*; a single frame cannot carry one.
[`../../tools/motion-census`](../../tools/motion-census) measures change and
nothing else — use it on your own captures too, and note that three of the
port's instruments passed a frozen screen because each measured throughput or a
pose rather than change.
## Previous focus, 2026-09-01 (still live, but AFTER the above)
A human played the port on real hardware and reported that the splashes are
**close but not right** — the fade/blur is more pronounced in the game — and that
the `PRESS Ⓐ` plate arrives late. Read
[`PLAYTEST-2026-09-01.md`](PLAYTEST-2026-09-01.md) first; it has the findings and
why none of our checks caught them.
Their verdict on how we have been working is the part that matters:
> *"It seems the agents were essentially guessing and trying to copy what one
> would see, but while they did get close it still is not quite right."*
**So do not fit a curve to a screenshot. Find the mechanism.** For the splashes,
in this order, and answer each with evidence rather than by inference:
1. **Is there a post-process pass at all?** A blur, a bloom, a fade quad, a tone
curve, a resolve-and-resample. Yes/no, from GPU state.
2. **If yes: what is it?** How many passes, which render targets, what blend
state, which shaders (you have their hashes in the draw log already).
3. **Where do its parameters come from?** Immediate constants in the command
stream, PS/VS constant banks, a table in a pak, a computed ramp in code.
4. **Only then, what curve** — and it should fall out of 3, not be fitted.
Use **both** routes and say which produced each fact:
* **Dynamic** — Canary. Per-draw capture, shader constants, render-target
bindings, blend state, and where those are not logged, **add the logging**:
`/canary` is yours read-write and the draw logger already exists. Guest memory
and CPU state are available too; the splash's driver is a `GamePart` and its
parameters are somewhere in it.
* **Static** — the `.pe` image, `sylpheed.db`, the paks. The code that *sets up*
the pass is in the image, its constants may be immediates, and shader blobs
ship on the disc. A mechanism confirmed statically **generalises to every
screen**; one observed in a capture holds for that capture.
A mechanism found this way is *decoded* and cannot be "close". A curve fitted by
eye is neither.
⚠️ Anything you conclude about *timing* here must obey
[`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md). The plate-late finding is
a timing question and the corpus has already lost four claims to the wall clock.
### Second, and not optional: the complete input set
The port had **no joypad binding for Ⓐ or Ⓑ** and nobody noticed for a whole
milestone. The port has fixed its side. Yours is the other half:
**Decode what the game actually reads.** Every button, both sticks, the triggers,
START and BACK — per screen if it differs. The pad read path is in the image and
`sub_821CC860`'s decoded arguments already include `PAD`. Deliver the *set*, and
say for each entry whether it is decoded from the image, measured in a capture,
or neither. Guessing which buttons exist by pressing them is how we got here.
## Your objective
`docs/port/MISSION.md` — read it every iteration. It lists the open questions and
@@ -19,6 +246,19 @@ an export schema, stop and go back to the question you were answering.
git -C /work fetch origin && git -C /work merge --no-edit origin/main
```
🔴 **On your FIRST iteration after 2026-09-01, also merge the human's branch:**
```bash
git -C /work merge --no-edit origin/human/r1-register-reclassification
```
It carries the **R1 reclassification of `REFUTED.md`** (every entry now names its
`⟨instrument⟩`; ten moved ❌ → 🟡), R1 as standing text in `PROTOCOL.md`, and
`tools/stale-instrument`. It branches from `auto/frame-blend-draw-path`, so if
you are on that line it is a fast-forward. **Two of the ten re-opened entries
land on this iteration's focus** — do not start the splashes without reading
them.
You work on a topic branch, and you read the protocol, the mission and the
shared tooling **from your own checkout** — so without this you are following
whichever version of the rules existed when your branch started. That is not
@@ -40,6 +280,24 @@ If the merge conflicts, resolve it, say so in your reply, and carry on.
side**. Fill it in as you go: you are the one who sees the real screens.
8. `docs/agents/CONTAINER-NOTES.md` — the container's tooling, and the reference
assets described below.
9. `docs/agents/TEMPORAL-VERIFICATION.md` — **how to verify anything that
moves.** Set by the human. Every temporal claim must obey it.
10. `docs/agents/PLAYTEST-2026-09-01.md` — what a human found playing the port.
⚠️ **`REFUTED.md` was reclassified by the human on 2026-09-01 under rule R1.**
Every entry now ends with its `⟨instrument⟩`, and **ten entries moved ❌ → 🟡**
because the instrument that killed them was one of ours. A 🟡 is *not* dead — it
is re-openable, and each says what would settle it. Read the file's own "How to
read this file" section once. When you improve a renderer, a reader or the
capture harness, run `tools/stale-instrument <that instrument>`: it lists exactly
what that instrument killed, so those claims re-open instead of staying dead
because nobody remembered which ones rested on it.
🔴 Two of the ten bear directly on the current focus. *"The declared keyframe
timeline reproduces the captured splash"* is now 🟡 `⟨our-reader⟩`, never
re-derived under the record-layout fix. And the **`rest()` pair** is open in
**both** directions — both legs run through our renderer — and the two splashes
are the only screens that reach that fallback.
## Reference assets you may not know you have
@@ -132,6 +390,23 @@ renderer is a claim about our renderer.
* Verify with an **artifact**, not "it compiles".
* Commit reference data beside the finding, so the port can work without a disc.
### Anything that moves
**Read `docs/agents/TEMPORAL-VERIFICATION.md` and follow it.** The short form:
* **Record a film, not a photograph.** One frame is a sample of a distribution
you have not characterised.
* **Align by CONTENT, not by clock.** Search the lag that best matches and report
the lag *and* the agreement at it. The lag is a measurement, not an error.
* **Prefer quantities that have no phase** — ordering, counts, durations, ratios,
shape. The two strongest timing results in this corpus are both of that kind.
* **Anchor on an event**, then quote differences from it.
* **State the expected number before reading the actual one.**
* **Report achieved fps against requested fps.** A capture that asked 4 and got
1.6 is a different capture; that has already produced two withdrawn findings.
* ⚠️ Canary presents at **~28.1 fps**, so a wall-clock duration off this emulator
is **~6 % long**. Quote unit counts first, then seconds, then the fps used.
## Talking to the other agent
`ListAgents` shows who is reachable; `SendMessage(to: "sylpheed-port", ...)` reaches

View File

@@ -1,5 +1,167 @@
You are the **Port**. Build the Godot menu shell, one milestone at a time.
## 🔴🔴 SOLE FOCUS, 2026-09-02: **THE TITLE'S ANIMATION TIMING — F5 and F6, nothing else**
**Work only these.** Not the repeat rate, not the audio mix, not P7 — they stay
queued in
[`../agents/PLAYTEST-2026-09-02-menus.md`](../agents/PLAYTEST-2026-09-02-menus.md).
> *"Let's have the agents focus on this item and only this only."*
**F6 — the title's sweeping white glow starts too early here.** A human watching
the real game reports that the glow travelling along the blue PCB-like lines
(**`ptloop01` / `ptloop02`**) **only begins when the plate appears**; the port
starts it before. **This is the Decoder's to establish and yours to implement**
do not choose a start time. What you *can* do now without an answer: determine
exactly **what your renderer currently uses** to start that sweep, so that when
the answer lands the change is one line and not an investigation.
**F5 — does Ⓐ snap or accelerate the title?** The Decoder is measuring it. Until
they answer, **do not implement Ⓐ#2** — a snap and a speed-up are different
behaviours and picking one is exactly the guessing that has cost this project.
### And split it before you start
**Read the new "Work in units a human can check in a minute" section of
[`PROTOCOL.md`](PROTOCOL.md).** The human's diagnosis is that whole missions have
been too big to hold — the splash sat through a milestone, then took a day once
scoped to *does it animate?*. Break the work down, write the question and what
the human should look at **before** working, do one unit, hand it over, and stop.
Do not stack a second change on an unverified first.
## ✅ THE LOGO SPLASHES ARE DONE — signed off by the human, 2026-09-02
> *"Looks good! Cannot notice any obvious difference from the actual game.
> Mark logos as done."*
**The sole-focus order is lifted. Return to your milestones.** The fix was
`pose_at` assigning the settle instant instead of clamping to it — and that same
line manufactured the false green, because the capture harness was photographing
t ≈ 2 units and it *looked* settled only because everything did.
📌 **Keep the lesson, it outlives the bug.** Three instruments passed a frozen
screen: a frozen sweep drives the clock by hand, a settled comparison is
*defined* to pass on a frozen screen, and an achieved-fps counter counts frames
drawn rather than frames different. Ask of any new check: **what would this still
report if the feature were entirely absent?** `tools/motion-census` exists for
exactly that question; keep it in `check-all`.
## ✅ P5's GATE IS MET — the human walked it, 2026-09-02
> *"Menu walk and navigation is fine. Video skips too. Extras open. New Game
> shows new game intro video."*
`PORT-MISSION.md` is updated. The NEW GAME gap is accepted as-is — they know the
difficulty select comes first in the real game and that the port announces it.
### 🔴 Four findings from the same session — read [`../agents/PLAYTEST-2026-09-02-menus.md`](../agents/PLAYTEST-2026-09-02-menus.md)
| | | yours to do |
|---|---|---|
| **F1** | **The menu REPEATS on a held direction. Ours does not.** One step per deflection was authored as the safe choice; the human has now watched the real game and it repeats. | **Implement the mechanism. Take the RATE from the Decoder — do NOT ship a placeholder interval.** An invented rate here is indistinguishable from a measured one later, and this is the exact field where that already cost us. |
| **F2** | **SFX too loud, and there is no mix at all.** Measured: `confirm` 17.7 dB mean / **0.0 dB peak**, 3 dB hotter than the music; no gain value exists anywhere in `export/` or `authored/`. | Add gains **at playback, as data** — a bus per kind. ⚠️ **Do NOT normalise in the exporter**: re-levelling destroys the relationship between clips and a modder cannot undo it. The Decoder is checking whether the mix is on the disc. |
| **F3** | **Something is missing on the title screen** — a track or a sting. The export has one music file and the port plays nothing on the title. | Wait for the Decoder; nothing to author yet. |
| **F4** | **Ⓐ skips FORWARD through the boot, and we implement two of three presses.** Ⓐ#1 skips the video ✅, **#2 reveals the plate immediately ❌ missing**, Ⓐ#3 activates it ✅. | Make Ⓐ during the title build-in jump to the plate — but **do not choose what "jump" means.** 🔴 It is a **test of `clock: "shared"`**, which is authored and, in your own words, *"not confirmed to better than ~20 %"*. If Ⓐ advances the shared clock the artwork **snaps**; if it only forces the plate visible the artwork **keeps animating**. Those look different on an early press, so the oracle can settle it. **Answer it before building on `shared`.** (Correction: an earlier draft of this brief said "both clocks" — there is only ONE, and hunting for a second would waste an iteration.) |
**H3, the plate delay, is ACCEPTED***"feels the same… sufficient"*. Stop
working on it. Leave the row unattributed rather than closing it green.
## Previous sole focus, 2026-09-02 — RESOLVED, kept for the method
> *"The port does no blur animation at all. The logos just switch."*
Measured from a real boot, not paraphrased: **the splash moves 1.30 s of 7.95 s
(16.4 %)**, the publisher logo is **frozen for 3.20 s**, the developer logo for
2.40 s, and the whole 7.95 s takes **26 distinct luma states**. A 45-unit
build-in cannot be drawn in 26 states.
🔴 **Your three instruments all passed this, and the reason is the point:**
* the **frozen sweep** drives the clock by hand — it proves the renderer can
draw pose *N*, never that the poses are drawn in sequence while running;
* the **settled comparison** scored 0.01 % — a screen frozen 84 % of the time
matches a settled reference *perfectly*, because that is what frozen means;
* the **achieved-fps counter** counts frames DRAWN — drawing the same pixels
25×/s scores exactly like animating.
**Every one measured throughput or a pose. None measured CHANGE.** Same shape as
`InputEventAction` bypassing the input map: the instrument sat below the thing
that was broken.
**Use [`tools/motion-census`](../../tools/motion-census)** — it measures change
and nothing else, and its `--selftest` proves it separates a fade from a switch
from a frozen film. Order of work:
1. **Reproduce first**, with `--film` + `motion-census`, and quote the numbers.
If you do not get ~16 %, that disagreement is the finding — say so.
2. **Find why the poses do not advance.** Unranked, none established:
interpolation returning one pose across a range of *t*; `rest()`/plateau
snapping to an endpoint; the group clock not integrating; nearest-keyframe
instead of lerp; advancing by keyframe *index* rather than by time.
3. **Every fix is gated by a FILM, never a still.** A change that improves a
settled frame and leaves the film at 16 % has not fixed this.
4. Put `motion-census` in `check-all` so the regression fails a check instead of
waiting for a human.
⚠️ **And record the refutation against yourself.** `BLOCKED.md` H2 reads ✅
ANSWERED on the strength of the frozen sweep. The *mechanism* half stands — the
blur is a baked companion texture, decoded and correct. The *behaviour* half does
not: you draw those quads and do not animate them, so "the companions are drawn"
was true and did not mean what the row used it to mean.
## Previous focus, 2026-09-01 (still live, but AFTER the above)
A human played this port on a real controller for the first time. Read
[`../agents/PLAYTEST-2026-09-01.md`](../agents/PLAYTEST-2026-09-01.md) **before
anything else** — it has all four findings and, more importantly, why none of
your checks caught two of them.
**Two were fixed for you by the human. Do not re-do them; do read them.**
1. **Ⓐ and Ⓑ were never bound to the pad.** Godot 4.7.2 binds no joypad button to
`ui_accept` or `ui_cancel`, while it binds the d-pad *and* the left stick to
`ui_up`/`ui_down`. Ⓐ was dead on real hardware for the whole of P5 while your
unattended walk passed every iteration. Fixed in `port/scripts/gamepad.gd`;
asserted by `tools/port/verify-input`, now in `check-all`.
2. **The left stick fired once per jitter.** An axis is not an edge. Latched to
one step per deflection, with hysteresis.
> ### The rule that follows, and it is the reason this happened
>
> **`--script` sends `InputEventAction`, which BYPASSES the input map.** Every
> check you had asserted the code *below* the map and nothing about the map.
> Synthetic input is not a test of input.
>
> **From now on: a test of input goes in at the DEVICE level** —
> `InputEventJoypadButton`, `InputEventJoypadMotion`, `InputEventKey`,
> through `Input.parse_input_event` — or it asserts the input map directly.
> `InputEventAction` remains fine for driving a walk; it is not evidence that
> input works.
**Two are open and are your focus:**
3. **The `PRESS Ⓐ` plate arrives late.** You raise it at `t=236`, derived as
`238 118 = 120 units = 2.000 s`. A human watching both says late. The
unit→seconds conversion is load-bearing and is exactly what the wall clock
cannot be trusted for. **This is an RE question if the cause is the unit; it
is yours if the cause is the clock origin or `rest.t`.** Establish which
half it is before asking, and say how you established it.
4. **The splash fade/blur is not the game's** — the game's is more pronounced.
You apply **no blur at all**. Whether the game runs a post-process pass is an
oracle question and it is with the Decoder. **Do not fit a curve to a
screenshot while waiting** — that is exactly what produced "close but not
right".
⚠️ Anything you conclude about timing must obey
[`../agents/TEMPORAL-VERIFICATION.md`](../agents/TEMPORAL-VERIFICATION.md).
Record a film and align by content; never compare at an absolute time.
⚠️ **`REFUTED.md` was reclassified by the human on 2026-09-01 (rule R1).** Ten
entries moved ❌ → 🟡 because our own renderer or reader killed them. Two bear on
your focus: *"the declared keyframe timeline reproduces the captured splash"* is
now 🟡 `⟨our-reader⟩`, and the **`rest()` pair is open in both directions** — and
the two splashes are the **only** screens reaching that fallback.
## Your objective
`docs/port/PORT-MISSION.md` — read it every iteration. Milestones P0…P7, each
@@ -15,6 +177,19 @@ guess of yours is indistinguishable from a fact and will be believed later.
git -C /work fetch origin && git -C /work merge --no-edit origin/main
```
🔴 **On your FIRST iteration after 2026-09-01, also merge the human's branch:**
```bash
git -C /work merge --no-edit origin/human/r1-retro-tick
```
It carries **the two input fixes made for you** (`port/scripts/gamepad.gd`,
`tools/port/verify-input` + its control, wired into `check-all`), the new
`BLOCKED.md` rows **H1H3**, and the retro tick. It branches from
`auto/port-p6-audio`, so on that line it is a fast-forward. **Merge it before
touching input**, or you will re-derive a fix that is already written and
asserted.
You work on a topic branch, and you read the protocol, the mission and the
shared tooling **from your own checkout** — so without this you are following
whichever version of the rules existed when your branch started. That is not
@@ -79,6 +254,14 @@ That is the easiest thing here to get subtly wrong.
disagree, say which is wrong rather than tuning until they match.
* Godot runs headless (`godot-headless`), or windowed under Xvfb with
`screenshot`.
* **Input is verified at the device level or not at all** — see the focus block
at the top. `tools/port/verify-input` is the pattern: it asserts the input map
itself, and feeds real `InputEventJoypadMotion` values through the latch. Run
it and its `--control` in `check-all`.
* **Anything that moves** follows `../agents/TEMPORAL-VERIFICATION.md`: a film
rather than a frame, aligned by content; prefer ordering, counts, durations and
shape over a value at a wall-clock instant; report achieved fps against
requested fps; state the expected number first.
* Audio: `docs/port/AUDIO-VERIFICATION.md` — no sound card is needed to answer
any of it. Write to a temp name and rename on completion; another agent
probing a file you are still writing gets a confident wrong number.

View File

@@ -7,9 +7,8 @@ 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:** 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.
**Status:** skeleton. Most of it is ❔ and is *meant* to be — this page exists to
be filled in by playing, not to look finished.
> ## ⚠️ Fill this in from the real game
>
@@ -29,29 +28,10 @@ Confidence: ✅ seen in a capture · 🟡 inferred · ❔ unknown.
| # | What you see | What you do | What happens |
|---|---|---|---|
| 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 |
✅ **The order is publisher then developer, confirmed in three cold boots
(2026-08-29)** — `SQUARE ENIX` for ~4.3 s, a ~0.25 s black hold, then
`GAME ARTS` / `SETA` / `studio anima` for ~3.5 s, and both dwells are declared on
the disc (240 and 195 keyframe units). ⚠️ There is a **third** SQUARE ENIX
wordmark about ten seconds in — bloomed, below centre — and it is the opening
card of the intro movie, not a splash.
[the three frames side by side](../re/captures/boot-order/splash-order-two-runs.png) ·
[`boot-order-and-splash-dwell.md`](../re/boot-order-and-splash-dwell.md)
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 ✅.
| 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 |
⚠️ **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
@@ -63,21 +43,6 @@ 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.
---
@@ -86,57 +51,18 @@ appears — the build-in itself varies by half a second between runs.
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 | **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 ✅ |
| 1 | ❔ | ❔ |
| 2 | ❔ | ❔ |
| 3 | ❔ | ❔ |
| 4 | ❔ | ❔ |
| 5 | ❔ | ❔ |
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)).
**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.
*Internals: `GP_TITLE.pak` build 5; buttons `ptbtn01``ptbtn05` top to bottom.*
@@ -147,82 +73,26 @@ assume the top item, and do not assume the middle one either.
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.
### 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.
### Continue / Load ❔
❔ How saves are listed · ❔ what an empty slot looks like · ❔ the confirmation
prompt and where the cursor starts.
Known: `title → LOAD GAME → slot 01 → YES → READY ROOM → TAKE OFF` reaches
flight ✅.
### 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.
### Options ❔
❔ Which settings exist, what each ranges over, how a change is applied and
whether it needs confirming.
| group | lessons |
|---|---|
| **Level 1** | `BASIC CONTROLS`, `HEADS-UP DISPLAY`, `RADAR` |
| **Level 2** | `SUPPLY AND SPECIAL MOVES`, `RADIO ORDERS`, `ADVANCED CONTROLS` |
| — | `BACK` |
### Extras ❔
❔ What is in it — a movie theatre, a gallery, records? ❔ what is locked at the
start and what unlocks 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.
### 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.
### Briefing and Ready Room ❔
❔ What you read, what you choose, and what finally launches the mission.
@@ -252,36 +122,6 @@ side the cursor is on before pressing Ⓐ.**
---
## 4b. What a screen change looks like ✅ measured, three of them
Every screen carries a full-screen black quad (`pteff00.prm`) that paints last.
A screen change is that quad ramping to opaque on the way out, and the **incoming**
screen's own copy of it starting opaque and clearing on the way in — so a
transition is two screens' quads, not one shared effect. The outgoing ramp's length
is on the disc and matches the game **three for three**; the black between them
does not, and is not a constant.
| you press | going | outgoing ramp | black between | incoming clears over |
|---|---|---|---|---|
| Ⓑ | main menu → title | 5 frames | **none — they cross-fade** | 8 frames |
| Ⓐ | title → main menu | 4 frames | ~3 frames | 5 frames |
| Ⓑ | EXTRAS → main menu | 5 frames | **2 frames, fully blank** | 5 frames |
⚠️ **Ⓑ is not "the cancel animation".** Ⓑ out of the main menu cross-fades — the
title is already drawing while the menu is still fading — while Ⓑ out of EXTRAS
goes properly black first. Same button, two different-looking moves, and if you are
scripting against "the screen goes black" one of them will not do it.
⚠️ **Ⓐ off the title is slow to start.** About 25 rendered frames (~0.8 s) pass
between a delivered press and anything changing on screen; the other two start
immediately. A script that presses and then looks 0.5 s later sees the title still
up and can conclude the press was dropped.
Timings are in *rendered frames* at ~30 Hz, from the emulator's own draw stream, so
they do not stretch when the emulator runs slow —
[`screen-transitions.md`](../re/screen-transitions.md) ·
[`data/fade-three-transitions.txt`](../re/data/fade-three-transitions.txt).
## 5. Flying
Not started, and the game teaches it better than we could: **play the in-game
@@ -307,66 +147,3 @@ Traps that read as bugs but are not, all measured ✅:
* **A trace consumer that exits stalls the emulator**, which also reads as a dead
pad.
* Cold boot is slow; ~25 s once the shader and code caches are warm.
* **`pkill -f xenia_canary` kills the shell that ran it**, because `-f` matches
the whole command line and your own `bash -c` contains the pattern. The script
dies before the emulator does, silently, with no output at all. Kill by process
**name**: `ps -o pid= -C xenia_canary | xargs -r kill -9`. (The same trap is in
`METHOD.md` for `pgrep` wait-loops; it cost another launch on 2026-08-30.)
* **`kill -9` on xenia ORPHANS `/tmp/xenia-canary.lock`**, and the next
`run-canary` refuses with *"an emulator is already running"* — to **stderr**,
where a polling script never looks. A probe of mine then sampled a dead display
for **484 s**, reporting `other` every 4 s, because `screen_id.py` on an empty
screen returns `other` and *"not the title yet"* is indistinguishable from
*"there is no emulator"*. Kill with a plain `kill` so it can clear its own lock;
if you must use `-9`, `rm -f /tmp/xenia-canary.lock` after. **And assert the
emulator is alive before entering any wait loop** — `ps -C xenia_canary` — so
the loop cannot spend its whole deadline on nothing.
***Ⓐ on a settled boot title DOES reach the main menu — re-run clean,
2026-08-30, and the withdrawn counter-example below is now explained.** One
trial, **exactly one emulator verified by count**, gated on the plate pulse
(glyph in [500, 2500] held 12 samples) so the press lands on the boot title
rather than the attract loop's. Delivery confirmed (`[file-pad] keystroke
vk=5800 down`/`up`). Glyph after the press: `0, 0` at +2 s and +4 s — the
transition — then **327 steady from +6 s to +39 s**. ⚠️ 327 is a proxy, so the
screen was checked with [`which_title_screen.py`](../../tools/re-capture/which_title_screen.py)
instead: **`main_menu` at RMSE 19.91 and 20.08, margin ~10**, inside the 9.911.7
band its control establishes on four known captures. The `before` frame gives
the "neither" signature (margin 0.10), correctly, since the title is neither.
📌 **Latency 46 s**, which is why a script that presses and looks 0.5 s later
concludes the press was dropped. So the count is now **3 of 3**, and the two
earlier failures were the three-emulator confound, not the game.
* ~~🔴🔴 **THE ENTRY BELOW IS WITHDRAWN — the experiment was confounded.**~~ When it
ran, **three emulators were live at once** (started 15:39, 15:44 and 16:12 on
2026-08-30), all reading the same `/tmp/xenia_pad.txt` and sharing display `:98`.
A press written to that file is delivered to **every** instance, and `screenshot`
grabs whichever window is topmost — which need not be the one that acted on it.
So "Ⓐ was delivered and the screen did not change" may simply be *two different
emulators*, and the keystroke-level confirmation proves only that **some**
instance received it. ⚠️ The cause was mine: `run-canary`'s lockfile is the
implementation of the "one emulator at a time" rule, and I had been clearing it
with `rm -f` to get past a stale one — which disables the guard for the next
launch too. **Clear a stale lock only after confirming zero live instances**
(`ps -C xenia_canary --no-headers | wc -l`). The claim below is unsupported and
needs a clean re-run before anyone relies on it.
* ~~🔴 **Ⓐ on a settled boot title does NOT reliably reach the menu.**~~ This page's
§1 and `canary-scripted-input-traps.md` record "the boot title accepts a single
Ⓐ (2 of 2 runs)". A run on 2026-08-30 gated on the **plate pulse** (glyph in
[500, 2500] held 12 consecutive samples), fired at t=484.5 s with glyph 1723 —
a verified settled boot title, not the attract one — pressed Ⓐ, and **the press
was delivered** (`[file-pad] keystroke vk=5800 down` / `up`, 8 `[RE-INPUT]`
lines). Twenty seconds later every captured frame still classified as the title.
So the sample is no longer 2 of 2, and a script that presses once and proceeds
can be left on the title with nothing in its log to say so. **Confirm the screen
changed; do not infer it from a delivered press.**
* **`screen_id.py` reports `menu` during the attract loop.** Two boots on
2026-08-30 logged `menu` at t=106 s and t=418 s while the game was still in the
intro movie. A wait-loop that breaks on `menu` will act on the wrong screen; the
loops here break on `title` for that reason.
* **`screen_id.py` cannot tell EXTRAS from the main menu** — both are dark blue
`GP_TITLE` screens and it answers `menu` for either. Use
[`which_title_screen.py`](../../tools/re-capture/which_title_screen.py), which
separates them by ~11 RMSE against ~18 within-class, and read the **margin**: a
margin under ~1 means "neither", which is what you get on a screen outside
`GP_TITLE` entirely.

File diff suppressed because it is too large Load Diff

View File

@@ -74,39 +74,10 @@ alongside it.
| **Q7** | **Transitions.** What happens visually between screens — the `pteff00.prm` quads, a fade, a cut — and its timing | Described and timed against a capture |
| **Q8** | **Menu audio.** Which BGM per screen; which cue on move / confirm / back / error. The cue table is complete; the event binding is not | Cue names bound to events, with how you established each |
| **Q9** | **Video binding.** Which movie is the boot intro vs the new-game intro; whether playback is skippable and what ends it | Named movies plus the playback rules |
| **Q10** | ~~**What are a music bank's sub-waves?**~~**ANSWERED — see below.** Every factual premise in the original row is refuted: a bank is **two** waves, not three (the 10 KB was the bank *header*, emitted by our own reader), and the three candidate roles it listed — intro + loop, two variations, two halves — are all dead. | ✅ **Gate met.** Role established on the menu's own bank: [`bgm-two-stems.md`](../re/structures/bgm-two-stems.md). 🟡 One sub-question survives — *which kind* of second stem — and it is 🟡 by measurement, not by neglect |
| **Q10** | **What are a music bank's sub-waves?** `BGM_001.slb` is three sub-waves — 10 KB, 4.47 MB, 4.67 MB — and we currently **concatenate them blindly** into one 347 s track. Two near-equal halves could be intro + loop, or two variations, or two halves of one piece. A menu that loops its music needs to know which | The role of each sub-wave, established for at least the menu BGM. "Concatenate" is a decision, not a default — right now it is a default nobody chose |
| **S1** | ~~**Ready Room probe.**~~ **DONE 2026-08-28 — [no-go](../re/ready-room-probe.md).** It is 2D and enumerates fine, but the pak is briefing/tactical-map content, not the Ready Room menu | ✅ go/no-go written |
## Emulator-side questions are NOT blocked — corrected 2026-08-30
⚠️ **This heading read "🔴 Emulator-side questions are blocked — the title is not
reachable here".** That is false and has been for some time: **twelve** emulator
runs on 2026-08-30 reached the settled title, gated on the plate pulse, and drove
it into the menu, `EXTRAS` and out of the archive. `HANDOFF.md` recorded the
banner as withdrawn; this document did not, and it is the one the brief says to
read **every iteration**.
🔴 **My first correction of this section was itself wrong, on all three clauses,
and is replaced (2026-08-30).** It read: *"The two items this section named are
UNBLOCKED, not answered … Both need a running menu, both now have one, and neither
has been attempted."* I wrote that without reading either page. Reading them:
* **`8AX` vs `ptbase` was RESOLVED on 2026-08-29.** Its page says so in its status
line — both its questions closed, kept for the evidence.
[8AX](../re/structures/ui-8ax-fullres-background.md)
* **The gamma control was attempted and half-answered**, and its page records that
the run *"needed the emulator only to **boot**, not to reach a menu … parked
behind the title-screen blocker for no reason."*
[tone curve](../re/structures/ui-render-tone-curve.md)
* **So neither ever needed a running menu**, and this section's premise was wrong
independently of whether the menu was reachable.
⚠️ **A correction is a new claim.** Mine replaced a stale status with an unchecked
one, in the same edit that criticised the document for carrying unchecked status —
which is the failure I had just finished cataloguing elsewhere.
The original section is kept below for the record, demoted so it cannot be read as
current.
## 🔴 Emulator-side questions are blocked — the title is not reachable here
**Status 2026-08-29, instrument-verified.** Two open items need a running menu:
the gamma control behind [tone curve](../re/structures/ui-render-tone-curve.md),
@@ -129,24 +100,7 @@ reachable from this container on 2026-08-28. Clearing the shader cache fixed a
including three earlier "the title never appears" claims that were withdrawn
because the instrument was broken each time.
## ✅ The Japanese-locale capture was taken — TWICE. Corrected 2026-08-30.
⚠️ **This heading read "🟡 Needs one more run — a Japanese-locale capture", and the
text below calls it "one capture we cannot take".** Both are false.
[`live-title-jp-at-rest.png`](../re/captures/title-builds/live-title-jp-at-rest.png)
and [`-run2`](../re/captures/title-builds/live-title-jp-at-rest-run2.png) are
committed, from two independent sessions, via
[`jp_title_session.sh`](../../tools/re-capture/jp_title_session.sh) which sets the
console language and always restores it.
📌 **And both questions it was blocking are closed** — Q1's association by the
[record-layout fix](../re/ui-keyframe-record-layout.md), and `rest()` for a
plateau-less element by a 1 036/1 036 discriminator, then **confirmed against this
very capture**: the corrected pose scores RMSE 41.69 where the stale one scores
58.41 ([`ui-resting-pose.md`](../re/structures/ui-resting-pose.md)).
⚠️ I noticed this section was stale several iterations ago, said so in a message,
and did not fix it. Kept below, demoted.
## 🟡 Needs one more run — a Japanese-locale capture
Recorded rather than worked around, per "do not improvise around a blocker".
@@ -254,21 +208,7 @@ settled and only multi-keyframe absolute timing is open; `rest()` differs from
its alternative on **one** element across all five screens, and the current
answer there is the defensible one.
## ✅ Rotation — the decision was TAKEN and implemented. Corrected 2026-08-30.
⚠️ **This heading read "🔵 Needs a human decision — rotation (raised 2026-08-29)".**
It was answered the same day it was raised: `HANDOFF.md` records *"OPTION A IS
DONE. The reference renderer rotates"*, and `ui_layout.rs` carries the rotated blit
with a control test (`rotation_control_known_angles`) pinning it against angles
whose answer is arithmetic — 0° and 360° byte-identical to the unrotated path.
**And the field itself is now confirmed from the ORACLE, not just decoded.** The
port's `rotation_deg` of **+30** on `pteff03` and **45** on `pteff03a` predict a
rotated quad's AABB height at 1135.3 and 1301.1; the game's draw stream measures
**1134** and **1303** — both under 0.2 %
([`data/title-sweep-drawn-at-rest.txt`](../re/data/title-sweep-drawn-at-rest.txt)).
The original section is kept below, demoted.
## 🔵 Needs a human decision — rotation (raised 2026-08-29)
The port agent asks whether it should **render** `rotation_deg` (decoded at
keyframe `+12`) when `sylpheed-cli screen render` deliberately does not. Its own
@@ -290,37 +230,6 @@ What needs a decision is which way the divergence gets closed:
Recorded rather than chosen, per "do not improvise around a blocker".
## ✅ Q10 is answered — corrected 2026-08-30
The Q10 row above was written on a premise that has since been **refuted in every
part**, and it survived as a live question for days after the refutation landed.
Recording the correction here rather than silently editing the row:
**"`BGM_001.slb` is three sub-waves (10 KB, 4.47 MB, 4.67 MB)"* — the 10 KB is
the **bank header**. Our reader emitted it, from a modulus valid only for a
header shorter than one XMA packet. A bank is **two** waves, **28/28** disc-wide.
**"we currently concatenate them blindly"* — and concatenating is **wrong**,
now measured: the two waves are **sample-synchronous** and the running game
decodes **both at once** (the XMA probe at the main menu saw two stereo streams
whose byte sizes are `BGM_103`'s two declared waves, exactly).
**"could be intro + loop, or two variations, or two halves of one piece"* — all
three predict unequal durations; **32 banks give equal ones**.
***The gate — "the role of each sub-wave, established for at least the menu
BGM" — is met**, and on the menu's own bank: `BGM_103`, named from the
executable, confirmed against the disc and against the running game.
🟡 **What is still open is narrower than the row**: *which kind* of second stem —
the rear pair of a 4-channel mix, or a second intensity layer. Both predict
simultaneity, so runtime observation cannot separate them, and a coherence
discriminator run 2026-08-30 **refuted the "filtered copy" model but could not
separate the two** — its own control showed that in this material even L vs R of
one performance reads only 0.220.50, so the test's premise does not hold.
[`../re/data/bgm-stem-coherence.txt`](../re/data/bgm-stem-coherence.txt)
⚠️ **This distinction does not block the port.** Both readings give the same
instruction: play both waves, aligned at sample 0, together. It changes only how
they would be *mixed* if the port ever does surround.
## Known unknowns — say so, do not fill them in
Some of these may turn out to be undecodable. That is a valid, useful answer, and

View File

@@ -100,7 +100,7 @@ A milestone is done when its **artifact** exists, not when the code compiles.
| **P2** | Keyframe animation | Buttons slide in. **Blocked on HANDOFF Q1** (the time unit). Do not invent it |
| **P3** | Splash → title, with the transition | Both screens back to back, unattended |
| **P4** | Intro video | `ADV.wmv` plays with audio (§6) |
| **P5** | Main menu: navigation, focus states, Ⓐ into a submenu, B back | A human clicks through it |
| **P5** | Main menu: navigation, focus states, Ⓐ into a submenu, B back | ~~A human clicks through it~~ — ✅ **GATE MET 2026-09-02.** A human walked it: *"Menu walk and navigation is fine. Video skips too. Extras open."* [`../agents/PLAYTEST-2026-09-02-menus.md`](../agents/PLAYTEST-2026-09-02-menus.md) |
| **P6** | Audio — menu BGM and move/confirm SFX | Sound on the P5 gate. **Looping is blocked on HANDOFF Q10** |
| **P7** | New-game intro video after NEW GAME | Plays, then returns to a defined state |

View File

@@ -0,0 +1,597 @@
# F6 unit a — what the port currently uses to start the title sweep
**Status:** ✅ answered. **No behaviour changed** — this unit exists so that when
the Decoder says *when* the glow should start, the edit is one line.
Port at `937f055`, 2026-09-02.
## The answer, in one line
`port/scripts/screen_view.gd:684`
```gdscript
var t := leaf_time_units if leaf_time_units >= 0.0 else time_units
```
**That is the whole start mechanism, and it is not a start mechanism.**
`leaf_time_units` is set in exactly one place — `boot.gd:359`, the `--leaf-time`
diagnostic flag — and is `-1.0` on every real boot. So the travelling glow runs
on `view.time_units`, the title screen's own clock, which `_advance` sets to
`0.0` when the title is raised. **Zero offset, no gate.**
## 🔴 And the obvious gate does not exist
The natural reading — mine, before checking — is that the parents gate it:
`ptloop01`/`ptloop02` declare `0:0 70:0 100:255 238:255 250:0`, invisible until
t=70. **That is not what happens**, because what reaches the screen is the LEAF,
and `screen_view.gd` records as decoded that *"the leaf runs on its OWN timeline
and the parent's alpha is NOT multiplied in"*. The parent ramp gates nothing.
The leaves' own declarations:
| leaf | alpha | x position |
|---|---|---|
| `pteff03` | **`0:255`** 150:128 540:255 600:255 | 639 → 39 (t=150) → 1521 (t=540) |
| `pteff03a` | 0:0 150:128 630:255 720:255 | 1721 → 1111 (t=150) → 839 (t=630) |
**`pteff03` is at full alpha from title t=0** and is travelling from t=0. It
clears the left edge (sprite is 399 wide) at around t≈60 and is well inside the
frame by t=150.
The plate arrives at **t=214236**. So the port starts the sweep roughly
**150+ units ≈ 2.5 s early** — which is the size and the direction of what the
human reported.
## Corroborated on a film, not only read
Filmed a real boot at 0.05 s and measured frame-to-frame change in the title art
band `1280x420+0+90`, which **excludes the plate's own rectangle** (y 550600) so
the plate cannot be what registers:
```
view_units 22 54 69 86 118 134 214 341 406
delta 31.8 39.9 30.8 9.4 12.6 0.2 0.2 0.1 0.4
```
Motion is heavy through the build-in and the band is quiet by t≈134 — consistent
with `pteff03` having already crossed the measured band and with the coarse
resize washing a thin glow out. **The film neither adds to nor contradicts the
declaration; the declaration is the evidence here.**
## Where the sweep actually is, computed from the leaf's own translation
The port positions the leaf **from the leaf's own clock** — it does not draw the
parent's pose and ignore the translation. Sprite 399 wide on a 1280 screen:
| t | 0 | **61** | 70 | 100 | 150 | **236** | 250 |
|---|---|---|---|---|---|---|---|
| `pteff03` x | 639 | **395** | 359 | 239 | 39 | **305** | 361 |
| `pteff03a` x | 1721 | 1477 | 1436 | 1314 | 1111 | 762 | 705 |
**`pteff03` enters the frame at t=61 and is mid-screen at t=305 when the plate
reaches full alpha at t=236** — visible and travelling for ~175 units ≈ 2.9 s
before the plate. `pteff03a` enters much later.
## 🔴 The open question in this file sits exactly inside F6's window
`screen_view.gd` flags its own limit on the leaf-vs-parent alpha decode:
> *"Every observation behind this has parent alpha 0, so 'the leaf wins' and 'the
> parent is ignored because it draws nothing' are NOT separated. **A capture
> during t=100…238 would separate them.**"*
The parent is non-zero exactly on `t=100…238`, and the plate arrives at 236. So
that unresolved ambiguity is **the same interval F6 is about**, and it is
load-bearing for the first stretch: the parent ramps 70→100, so applying it would
hide the sweep until t=70 and dim it to t=100, while the port shows it at full
alpha from t=61.
That accounts for ~40 units of the earliness. **It does not account for the other
~175**, which is the leaf clock starting at title t=0 with no offset.
📌 **One capture in `t=100…238` would settle both** — F6's start time and the
leaf/parent alpha rule — rather than two.
## What changes when the answer lands
A start time is an **offset**, and `leaf_time_units` is an absolute override —
they are not the same field. The one-line edit at 684 becomes a subtraction, fed
by one authored value. Nothing else moves.
## What this does NOT do
* **It does not choose a start time.** That is the Decoder's, and this unit was
scoped to exclude it deliberately.
* It does not touch the glow. A boot looks exactly as it did.
* It says nothing about whether the *speed* or the *path* is right — only when it
begins.
---
# 🔴 Unit b, HELD: the parent-alpha refutation may be right, but its identification step cannot carry it
**Status:** ⏸️ **the renderer is NOT changed.** The Decoder's
`f6-unit2-parent-alpha-multiplies.md` refutes `screen_view.gd`'s *"the parent's
alpha is NOT multiplied in"* using a bound. The bound's shape is sound and its
premise checks out against this export. **The step that assigns the measurement
to an element does not.**
## The premise holds
`pteff03`'s leaf declares `0:255 150:128 540:255 600:255`**minimum 128**,
confirmed off `export/screens/title/title.json`. A drawn alpha below 128 cannot
come from that leaf alone. That part is right.
## 🔴 But the two strips are the SAME SIZE, so size cannot say which is which
The identification is stated as *"by size against the corpus's independently
measured AABB height of 1134 px"*. Measured off this export:
| sprite | dimensions | leaf alpha range | travel |
|---|---|---|---|
| `pteff03` | **399 × 180** | **128 … 255** | left → right (639 → 1521) |
| `pteff03a` | **399 × 180** | **0 … 255** | right → left (1721 → 839) |
**They are byte-identical in size**, which is consistent with the two reported
rows measuring `1.38 × 3.15` and `1.39 × 3.15` — a 0.7 % difference. Size
separates the sweeps from everything else on the screen; it cannot separate them
from **each other**, and that is the distinction the argument needs.
## Why it matters — the assignment flips the conclusion
The quoted row that reaches **8** is the one the argument leans on. But
`pteff03a`'s leaf alpha floors at **0**, not 128, and ramps `0 → 128` across
t=0…150. Values of 8, 24, 33, 50 … are exactly what **that leaf alone** produces.
So if the 8-row is `pteff03a`, the bound is satisfied with no parent at all.
⚠️ **And the conclusion may still be correct via the OTHER row.** The row
reported as `16 41 67 91 116 128 129 130 131` contains values below 128 *and* a
dense cluster at 128131 — the signature of `pteff03`'s floor. If that row is
`pteff03`, then 16 < 128 refutes no-multiply exactly as claimed. **The finding
may be right and the cited row wrong.**
## The discriminator is free and already in their capture
The two leaves travel in **opposite directions**: `pteff03` left→right,
`pteff03a` right→left, separated by ~1 000 px for most of their run. One frame
pair settles it. Nothing needs re-capturing.
## ✅ Resolved: the discriminator worked, and MY proposed repair was wrong
The Decoder ran the travel-direction check on the capture they already had:
```
1.38x3.15 n=1140 x centre -1.690 -> +0.500 LEFT->RIGHT
1.39x3.15 n= 614 x centre -1.685 -> +0.495 LEFT->RIGHT
```
**Both rows travel the same way, so both are `pteff03`** — the 0.7 % size
difference is per-frame rounding splitting **one** element across two keys.
`pteff03a` is not in the capture at all.
🔴 **So my "the finding may still be correct via the OTHER row" does not apply —
there is no other row.** The identification was broken a third way that neither
of us named: not two elements confused for each other, but one element counted
twice. I was right that size could not carry the assignment and wrong about what
the assignment actually was.
⚠️ And the part I could not see from here, which is the larger one: the quoted
`8 24 33 50 58 …` were the nine **lowest distinct** values, not a series — a
tail, presented in a shape that reads as a ramp. **14 sub-floor samples out of
1754.** The bound survives in shape and collapses in weight: one sample below 128
is still unexplainable by a leaf flooring at 128, but 14 of 1754 is where a
grouping slip or a partial frame would show up.
**Status: 🟡 not refuted, not established.** The next unit is a re-read of data
already on disk — the same capture as a *series* across t=70…100, where a
multiplying parent must ramp on nearly every frame rather than in 14 stragglers.
That unit is the Decoder's and has not been run.
## What the port does about it: nothing yet
Adopting a decode whose element assignment is unresolved is how this project has
been burned before — and the port would be changing a renderer rule on it.
`screen_view.gd` keeps its current behaviour until the row is identified by
travel direction. **This is not scepticism about the conclusion**, which the
port's own flagged limit predicted would fall this way; it is about which row.
---
# 📌 What was actually holding this up: we both picked the wrong observable
The human asked what was blocking us and whether the approach needed revising.
**It did, and the diagnosis is short: two agents spent three exchanges on ALPHA
while the answer sat in a POSITION series neither of us compared to anything.**
## The arithmetic that should have been step one
The Decoder's capture reports the sweep's x-centre running `1.690 → +0.500`.
Read as NDC (`x_ndc = 2·x_px/W 1`, **an assumption, stated as one**):
| | ndc | centre px | implied leaf t |
|---|---|---|---|
| first sample | 1.690 | 441.6 | **0.5** |
| last sample | +0.500 | +960.0 | 349.9 |
| **declared at leaf t=0** | **1.687** | **439.5** | — |
**A 2 px agreement on a 2 160 px travel.** So the game's sweep begins travelling
at leaf t≈0, from off-screen left — **the same as the port.**
🔴 **That contradicts my own earlier framing on this page**, which attributed
~135 units of the earliness to "the leaf clock starting at title t=0 with no
offset". If the game's leaf clock also starts at 0, that is not a defect and F6
is a **visibility** question — alpha, or draw order, or something not yet named —
rather than a clock question. I am flagging it rather than rewriting the section:
this rests on two numbers relayed in a message, which is exactly the thing that
should be read from the repository instead.
## Why alpha was the wrong tool, stated generally
| | alpha | position |
|---|---|---|
| dynamic range | 8 bits, quantised | **2 160 px** |
| shape | non-monotone, ramps and holds | **monotone** |
| failure mode that bit us | a 14-sample tail out of 1754 looks like signal | a wrong shape raises the residual |
| yields the clock? | no | **origin AND rate together** |
**When something moves, its position carries the clock and its alpha carries
almost nothing.** Neither of us reached for a trajectory comparison because
neither of us had one.
## So: `tools/port/fit-trajectory`
Solves `x_measured(frame) ≈ declared(t0 + rate·frame)` for the pair, and reports
the **residual**, which is the part that matters: it says whether the model was
right at all, where a value-at-an-instant never can.
Its `--selftest` runs both directions — recovers a known clock from a synthesised
series to 0.09 px, and **rejects** a wrong-shape series at 81.9 px against a 20 px
bar — because a fit that cannot fail is a curve-fitter, not a measurement. Wired
into `check-all`.
⚠️ It fits a **constant** rate. A stalling guest clock or uneven capture drops
raise the residual rather than being absorbed, which is deliberate.
---
# ❌ WITHDRAWN — Unit c: "the port draws a sweep the game does not"
> 🔴 **This whole section is refuted, and the port was right.** `pteff03a` **is**
> drawn by the game. The two strips are batched into a **single additive draw of
> eight vertices — two quads** — and the Decoder's log reader took the first
> vertex match per draw line and discarded the rest, so every analysis saw quad A
> and never quad B. No new capture was needed; `pteff03a` was in the same logs
> that were read as declaring it absent
> (`docs/re/f6-unit11-pteff03a-IS-drawn.md`). Measured on both sides: the strips
> travel in opposite directions with a size ratio of 1.301 against the declared
> 800/600 = 1.333.
>
> ✅ **Nothing in the port changed on the strength of it.** I proposed gating
> `pteff03a` and held, because absence in one capture read by one probe is a lead
> and not a finding, and because the check I asked for was a human's look rather
> than another measurement. That hold is the only reason this cost nothing.
>
> ⚠️ **And the absence claim cited the port as evidence against itself** — "the
> port draws it, the game does not" — so a defect was inferred in my renderer
> from a gap in a reader. Kept in place rather than deleted: the reasoning below
> is sound given its premise, and the premise is exactly the kind that looks like
> data.
## The original section, kept for its shape
## First, the correction: my refutation was right in outcome and WRONG in its reason
I challenged the Decoder's by-size identification on the ground that *"both
sweep sprites are 399×180, so size cannot separate them"*. **That was wrong.** I
compared the source PNGs and never looked at the leaf declarations:
| leaf | sprite | declared scale | **drawn height** |
|---|---|---|---|
| `pteff03` | 399×180 | `[100, 600]` | **1080 px** |
| `pteff03a` | 399×180 | `[100, 800]` | **1440 px** |
The *drawn* quads differ by a third, which is exactly the 3.15 vs 3.62 NDC the
Decoder was separating by. **Size distinguishes them fine.** The hold was still
correct and the check I asked for still found a real defect — but it found a
different one (one element double-counted, and a set presented as a series), and
my stated reason did not survive. Recorded because a right answer reached by a
wrong argument is the kind that gets cited later for the wrong reason.
## And it makes the real finding sharper
Because size *does* separate them, the Decoder's line — *"`pteff03a` does **not**
appear in this capture at all"* — is well-evidenced rather than incidental. They
looked for a distinct size and found nothing.
**The port draws it.** Asked directly, at three instants:
```
t=120 drew 9: ptbase2, pteff03, pteff03a, pteff04, ...
t=180 drew 10: ptbase2, pteff03, pteff03a, pteff04, ...
t=240 drew 10: ptbase2, pteff03, pteff03a, pteff04, ...
```
`pteff03a` is on screen in the port from t≈108 (it crosses x=1280 there) until
t≈521, travelling **right-to-left** at 800 % vertical scale while `pteff03` runs
left-to-right at 600 %. The capture covers that window and contains only
`pteff03`.
> ~~**So the port appears to render a second light sweep, larger and travelling
> the opposite way, that the game does not draw during the title build-in.**~~
> ❌ **False.** The game draws both, batched into one eight-vertex draw.
⚠️ **Absence in one capture is not absence in the game**, and this is one
capture, read by one probe, identified by size. It is a lead, not a finding. But
it is the first thing in F6 that is *visible*, *port-side*, and *checkable by a
person in seconds* — which is what this whole exchange has been missing.
## What did NOT work, recorded so nobody repeats it
I tried to isolate the two sweeps visually by differencing title frames at
several `--time` values. **It failed and the output is not evidence**: at those
instants the whole title is still animating — logo, effects, copyright — so the
difference is the entire screen rather than the sweeps. Frame-differencing
isolates motion only when everything else is still, and during a build-in nothing
is.
## The unit, and it is one question for a person
> **On the real game's title screen, is there ONE light streak sweeping across,
> or TWO travelling in opposite directions?**
Pass for the port as it stands: two. If the game shows one, `pteff03a` is drawn
here and should not be — and an extra glow arriving at t≈108 is a very good
candidate for *"the glow starts too early"*.
**Not covered:** the start time of `pteff03` itself, which is still open; and the
parent-alpha question, still 🟡.
---
# ❌ Unit d — the "variant link" explanation, raised and killed in one pass
The Decoder's second candidate for why the game submits `ptloop01` and not
`ptloop02` was *"a focus/variant link means only one of the pair is ever
active"*. **That is answerable from the export, and the answer is no.**
## What looked like a smoking gun
`ptloop01` carries **`opt_link = "ptloop02.rat"`**, `ptloop02` carries none, and
it is the only linked element on the title screen. The field is exported straight
from `el.focus_link` (`crates/sylpheed-export/src/screen.rs:622`), and
**`port/scripts/` never reads it.** An ignored variant link would have explained
the extra sweep exactly.
## ❌ And it is not a variant link
Surveying `opt_link` across the whole export splits it into two populations:
| target | example | is the target also a top-level element? |
|---|---|---|
| `*f.rat` | `ptbtn00 → ptbtn00f` | **no** — variant only |
| everything else | `ptloop01 → ptloop02` | **yes** — both are drawn |
And the second population **chains across unrelated element kinds**. On
`main_menu`:
```
index 3 ptloop01 -> ptloop02.rat
index 4 ptloop02 -> ptbtn01.rat
index 10 ptbtn01 -> ptbtn01f.rat
```
**A light sweep points at a button.** A variant selector cannot do that, so
`opt_link` is a chain pointer that happens to land on the focus variant when the
element is a button — which is why it was exported under the name `focus_link`.
> So the field does not select between `ptloop01` and `ptloop02`, and the port
> ignoring it is not what draws the extra sweep. **Candidate eliminated.**
## The smaller finding that survives
**`focus_link` is carrying two different things** and the exporter names it after
only one of them. The `*f` population is a variant; the rest is a chain. Nothing
depends on this today — the port reads neither — but the name asserts a meaning
the data does not support, and the next person to reach for it will reach for the
wrong one. Worth renaming when something actually needs it; not worth a
re-export on its own.
## Where that leaves F6
The lead is unchanged and unexplained: **the port draws `pteff03a`, the game's
capture never does** — now confirmed by an exhaustive scan of every tall quad in
1..2499 rather than a filtered subset. One of the two candidate causes is now
eliminated from the export side, which leaves the Decoder's first: a zero-alpha
skip suppressing the opening frames. ⚠️ That one does not obviously survive
either — it would explain `pteff03a`'s *opening* frames, not its whole run, and
its leaf reaches α=128 well inside the captured window.
**Nothing is deleted and the renderer is unchanged**, pending one five-second
human look: one streak, or two?
---
# Unit e — the port draws exactly TWO travelling lights, and the human reports more
The human, watching the real game: *"I think multiple, possible more than two…
The lights move on blue lines looking like PCB board lines. And frankly I cannot
tell if the game renders a light per line or uses a light that is shown around
multiple, close lines."*
That is a different question from the one both agents had been asking, and it is
worth having the port's own number first.
## Census of every element on the title that travels
| element | x travel | note |
|---|---|---|
| `ptlogo1` / `ptlogo2` (×3 instances) | 300 px | the **logo** sliding in, t=34…251 — not a light |
| **`pteff03`** (leaf of `ptloop01`) | **2 160 px** | left → right |
| **`pteff03a`** (leaf of `ptloop02`) | **2 560 px** | right → left |
Every other title element — `pteff00`, `pteff01`, `pteff02`, `pteff04`,
`ptlogo_back2eff` and `…eff1…5`, `ptlogoall_eff`, `ptlogoall_eff2`,
`ptcopyright`, `ptbase2`**declares no positional travel at all.** They fade in
and out in place.
> **The port renders exactly two moving lights.** The human describes multiple,
> possibly more than two, running along individual PCB traces.
## What that reframes
Both agents had been asking *when* the sweep starts. If the game's effect is a
population of small lights on separate traces and the port's is two full-height
streaks crossing the screen, then **the port may have the wrong effect
altogether**, and "starts too early" is what a wrong effect looks like to someone
who is not reading keyframes.
⚠️ **And it puts a limit on the capture result.** The Decoder's scan that found
`pteff03a` absent covered every quad **taller than 1.2 NDC**. Small per-trace
lights are far below that, so that scan cannot count them — it is exhaustive over
full-height streaks and silent about the population in question. `pteff03a`'s
absence stands (it would be 3.62 NDC); *"only one travelling quad exists"* does
not generalise beyond tall quads.
## ⚠️ A limit of this census
It reads **declared** keyframes. An element with a single keyframe shows as
"visible 0…0" here and is in fact held and drawn — `ptbase2`, the background, is
the obvious case. So the visibility column understates; **the travel column is
the load-bearing one**, and travel is what a moving light needs.
It also cannot see motion that is not positional — a scrolling UV, a texture
animation, or a shader would move light along a trace while declaring no travel
at all. **Nothing in this export declares such a thing**, but the port would not
know if the game did it that way, and that is now a live possibility rather than
a remote one.
## Not covered
Whether the game's lights are one-per-trace or one glow spanning several — the
human says they cannot tell, and it is the Decoder's screenshots to settle.
---
# 📌 What the withdrawal is worth, since the port lost nothing
Three of my own claims rested on `pteff03a` being absent and all three fall with
it: that the port renders a sweep the game does not, that this was "the first
thing in F6 that is visible and port-side", and — in a report to the human — that
"the port draws two, the game's capture has one." **The port draws two and so
does the game.** The census on this page stands unchanged; what changed is that
it now agrees with the capture rather than contradicting it.
**The one thing that made this free was refusing to act on it.** The evidence was
an exhaustive scan, from an agent with the oracle, corroborated by a mechanism
and by two candidate causes. It was still an *absence*, measured once, by one
reader — and the check I asked for was a human's look, not another measurement.
⚠️ **An absence is a claim about an instrument, not about the world.** A count of
zero says only that nothing got through the reader. Every positive result on that
same capture — the alpha decomposition, the press-vs-control comparisons, the
pulse ratio — is untouched, because those compare like with like on the same
quad. Only the absence compared a count against zero, and that is precisely where
a truncating reader is fatal.
📌 The Decoder notes this is the third time this corpus has been bitten by an
under-reading dump, and that `REFUTED.md` already recorded a draw carrying two
rotated parallelograms — **the general fact was written down before the reader
contradicted it.** Their cheap check is worth repeating here because it applies to
anything the port ever reads: *read one raw record in full before trusting any
count derived from it.* The batch size was printed on every one of those lines.
## 📌 And the same error recurred, which makes it a pattern rather than a slip
The withdrawn alpha bound on this page failed because nine values quoted as a
series were `sorted(set(...))[:9]` — the lowest distinct values, a tail wearing
the shape of a trajectory. The Decoder has since found the same thing in a second
finding: an implied-parent range quoted as 254.0256.9 turned out to be *the rows
they had printed*, every twentieth frame, standing in for a population whose real
first-cycle spread was 250.9260.5.
**Twice, and both times the output looked fine.** That is the tell: a summary
drawn from a subset does not look like an error, it looks like a result. The
conclusion survived on both occasions, so nothing here needs undoing — but a
conclusion surviving is not evidence the number under it was sound, and this port
has now inherited two numbers that were not.
⚠️ **Neither was reachable by reasoning**, which is the part worth keeping. In
both cases the argument was valid and the *inputs to the summary* were wrong. No
amount of re-reading the claim finds that; only re-running it does. It is the
argument for re-running over re-checking, and it is why the two findings flagged
as unverified above were re-run rather than defended.
---
# ❌ A refutation aimed at this renderer, measured and NOT landed
The Decoder raised it and could not test it from their side: *"if your renderer
runs both leaves on a single rate, the two strips stay locked together and drift
from the game by ~118 units per cycle, growing without bound."* The two leaves
declare **600** and **720** unit loops.
**Pre-registered, then measured on a real boot** via `--probe-leaf`. At a raw leaf
clock of 4873:
| leaf | span | measured `leaf_t` | `fposmod(4873, span)` |
|---|---|---|---|
| `pteff03` | 600 | **72.6** | 73 |
| `pteff03a` | 720 | **552.6** | 553 |
The port takes each leaf's span from **its own keyframes**`span = max(k.t)`
over `fe.keyframes` — so the two were never locked. **17 748 probe samples, title
clock reaching 9 745**, i.e. the sweep is still looping 162 seconds in.
## 🔴 Two false alarms of my own on the way there, both from the same mistake
1. **I used `--time` to ask a question about running behaviour.** It sets
`frozen`, which by design bypasses the `holding` clamp, so the title read as
*empty* past t=250 and I nearly reported the whole title vanishing. On a real
boot it does not: `settle_window` is `[160, 236, 198]`, the elements clamp to
t=198, and a filmed frame at `view_units 6733` shows the complete title.
2. **I read a probe stopping as the feature stopping.** Two runs ended at
u≈236 and I took that as the sweep dying at settle. It was the run ending —
without `--film` the boot exits sooner. With a film attached the same probe
reaches 9 745.
📌 Both are the frozen-sweep lesson wearing new clothes: *the diagnostic that
pins the clock cannot answer a question about the clock running*, and *an
instrument going quiet is not the subject going quiet*. The second is the same
shape as the Decoder's own absence-of-a-quad bug — a count of zero says only that
nothing reached the reader.
---
# ✅ Out-of-sample: what the port ships was in the passing half
The Decoder pre-registered six predictions and tested them on a fresh boot that
had no hand in deriving them. **Three failed.** Audited here against what this
port actually authors, and the answer is **nothing to change**:
| their prediction | fresh boot | does the port carry it? |
|---|---|---|
| leaf period ratio 1.200 | 1.1753 ✅ | **yes** — this is `rate = 0.5` |
| strip size ratio 1.333 | 1.3009 ✅ | yes, as element identity |
| pulse / sweep loop 0.100 | 0.0963 ✅ | yes, `looping_focus_records` 120 |
| pulse amplitude ≤3 levels | 8.73 🔴 | no |
| `ptcopyright` ramp ratio 0.733 | 0.550 🔴 | no |
| sweep leads plate 0.1380.141 | **0.0996** 🔴 | **no** — grepped, absent |
`authored/rendering.json` `leaf_clock` is `{start_units: null, rate: 0.5}` and
nothing else. No separation constant exists in `authored/`, `tools/port/` or
`port/scripts/`.
📌 **That split is not luck and is worth naming.** Everything the port adopted is
either **declared on the disc** (the parent gate, the 120-unit pulse loop, the
600/720 leaf periods) or **corroborated by three independent legs** (the rate).
Every failed prediction is a figure derived from *relationships between elements
measured in a capture* — the class with no declared counterpart, which
`check-authored-vs-declared` says out loud it cannot arbitrate. The rule "adopt
what the disc declares, or what three unrelated things agree on" selected exactly
the surviving half without anyone knowing which half that would be.
⚠️ And the Decoder reports that `check_labels.py` — offered last iteration as the
mechanism for capture-only labels — **fails its first independent test**: two of
its four checks fire on a third capture, having been validated on the two that
produced the labels. An instrument validated on its own training data. Nothing
here depends on it, but it is not a mechanism this port should lean on either.

View File

@@ -0,0 +1,70 @@
# Four of five main-menu destinations are blocked on ONE hardcoded archive
**Status:** ✅ feasibility established, nothing changed yet. 2026-09-03.
## The gap, in player terms
| button | destination | today |
|---|---|---|
| NEW GAME | `DLG_SELECT_DIFFICULTY` → SELECT DATA → video | **jumps straight to the video** |
| LOAD GAME | `GP_SAVE_LOAD` | **dead** |
| TUTORIAL | — | **dead** |
| OPTIONS | `GP_OPTIONS` | **dead** |
| EXTRAS | `extras` | works |
All four are recorded in `authored/flow.json` as **measured destinations**
somebody drove the real game to them. They are `blocked` for one structural
reason, stated there: *"not a GP_TITLE build, so there is no screen file to go
to."*
## The cause is one line
`crates/sylpheed-export/src/main.rs` hardcodes `let archive = "dat/GP_TITLE.pak"`.
## And the reader already works on the rest
`examples/probe_archives.rs` runs the **existing** `ui_layout::is_build` over
every `.pak` on the disc. It decodes nothing new:
| archive | entries | builds |
|---|---|---|
| `GP_OPTIONS` | 26 | **14** |
| `GP_SAVE_LOAD` | 108 | **18** |
| `GP_DIALOG` | 140 | **105** |
| `GP_TUTORIAL` | 2 | **2** |
| `GP_TITLE` | 16 | 12 |
**24 archives contain UI screen builds. The exporter reads one.**
> So this is not blocked on the Decoder and needs no new format work. It is an
> exporter scope limit, and the exporter is the port's.
## Why this is worth doing before the queued items
Measured against *"if this is wrong, what does a player experience?"* — the
filter this port adopted after spending two rounds on a plate pulse that turned
out not to be a defect:
* **four dead menu entries** and a missing difficulty screen: a player hits them
immediately and three of them do nothing at all;
* the audio mix (F2): a player notices, but the menu still works;
* the repeat rate (F1) and the title track (F3): both blocked on measurement.
## ⚠️ What this does NOT establish
* **That the screens will render.** `is_build` says the record parses as a build,
not that its sprites resolve, its names are known, or its layout is complete.
`GP_HANGAR_ARSENAL` reports 390 builds and is squarely gameplay, out of scope.
* **Which entry is the difficulty dialog.** `GP_DIALOG` has 105 builds and none
of them is named yet; `DLG_SELECT_DIFFICULTY` is a name from the flow, not an
entry index.
* **That more screens are free.** Every screen the export gains is a screen
`check-all`'s comparisons iterate over, and screen names are authored per
archive+entry — unnamed screens need a naming decision, not just a loop bound.
## Next unit
Widen the exporter to **one** further archive — `GP_OPTIONS`, the smallest at 26
entries — as data rather than a second hardcoded constant, and see what actually
comes out. Not all four at once: 139 new screens arriving together would make any
regression unattributable.

View File

@@ -0,0 +1,118 @@
# The OPTIONS menu tree exists, renders, and is named
**2026-09-03.** `GP_OPTIONS` joined `export_archives` and produced 14 screen
builds. All 14 render; all 14 are now named.
## What they are
| entry | name | English | | entry | name |
|---|---|---|---|---|---|
| 19 | **`options`** | **the root** — GAME / CONTROL / SOUND / SCREEN SETTINGS, BACK | | 21 | `options_jp` |
| 16 | `game_settings` | Auto-Save, View Point, Radio Log, Subtitles | | 18 | `game_settings_jp` |
| 4 | `control_settings` | Control Type, Throttle, sensitivities, Vibration | | 8 | `control_settings_jp` |
| 3 | `sound_settings` | Music / Movie / Voice / SFX Volume | | 5 | `sound_settings_jp` |
| 6 | `screen_settings` | Gamma Correction, R/G/B, NEXT PAGE | | 9 | `screen_settings_jp` |
| 7 | `screen_settings_page2` | White / Black Level Adjust, PREVIOUS PAGE | | 10 | `screen_settings_page2_jp` |
| 20 | `control_customize` | per-action key remapping | | 22 | `control_customize_jp` |
A clean EN/JP pair for every screen, which is itself a check: 14 builds, 7
pairs, no leftovers.
## How they were identified, and why that is stronger than usual here
**By the text the screen renders about itself.** Each was exported, drawn by the
port at rest, and read: the titles and row labels are legible.
📌 That matters because this project has been bitten three times by
identification via **position, size or ordinal** — the sweep strips confused by
size, the plate identified by screen position, `ptcopyright` mistaken for the
plate. A screen that renders the words `SOUND SETTINGS` above four volume rows is
not that kind of inference.
⚠️ **What it still does not establish:** which screen the *game* navigates to
from which. The tree above is read off content, so `control_customize` being
"reached from CONTROL SETTINGS" is a reading of its own legend
(`Ⓨ : Customize` on `control_settings`), not a measured transition. Wiring
anything beyond `main_menu → options` needs the real navigation.
## Not yet done
* **Nothing is reachable yet.** `main_menu` `ptbtn04` still has `goto: null`.
* **`po_pad_slider1` has no sprite** in the export and reports NOT DRAWN.
* **All 14 are `NEVER COMPARED`** by `verify-screen` — reported, not asserted;
both its allowance and the reference renderer were calibrated on `GP_TITLE`.
* The screens are static: no navigation, no focus movement, no value editing.
---
# ✅ OPTIONS is reachable — and navigation inside it is blocked on a kind
`main_menu` `ptbtn04` now has `goto: "options"`. Walked with the menu harness:
main_menu → ⬇⬇⬇ → Ⓐ → the OPTIONS root renders. Ⓑ backs out.
## ✅ RESOLVED — the rows move. `0x3003` is `0x3002` with the parent bit set
The Decoder decoded it disc-wide: **bit 0 of `kind` is the PARENT FLAG**, and it
carries no role information. Over every `.pak` in `dat/`, `kind & 1` agrees with
"has a parent" on **15 493 elements with zero disagreements**
(`docs/re/ui-kind-bit0-is-has-parent.md`). The OPTIONS rows are parented; the
main-menu buttons are not. Same record class.
So the detector now matches `0x3002 | 0x3003` — **two values listed, not a
mask**. `kind & 0xFFFE == 0x3002` would also catch `0x73002`/`0x73003`, 160
elements whose high bits nobody has decoded, silently and on screens neither
agent has seen.
**Impact measured before re-exporting, not after:** exactly two screens gain
buttons — `options` and `options_jp`, five rows each. No existing screen changes.
Verified by walking it: `main_menu` → ⬇⬇⬇ → Ⓐ → OPTIONS, then ⬇⬇ moves
`po_menu_btn2``po_menu_btn3` with the focus ring rendering on the highlighted
row.
📌 **The port was right to wait.** The rejected rule — "carries a focus record ⇒
menu item" — would have reached the same answer here by a second inference from
structure, and would have reclassified elements on screens nobody had looked at.
The field cost one question and needed no inference at all.
## The original section, kept for the shape of the block
The exporter's button detector is `kind == 0x3002 && !focused`. The OPTIONS rows
are **`kind_raw = 0x3003`**, so `role` comes out `unknown`, the export's
`buttons[]` is empty, and up/down move nothing.
| screen | element | kind | focus record | in `buttons[]` |
|---|---|---|---|---|
| `main_menu` | `ptbtn01` | `0x3002` | yes | yes |
| `extras` | `ptbtn11` | `0x3002` | yes | yes |
| **`options`** | **`po_menu_btn1`** | **`0x3003`** | **yes** | **no** |
**What `0x3003` means is not the port's to decide**, so the rule was not widened
here. The circumstantial case is strong — five rows, each carrying a focus
record, on a screen whose own text lists five options — and *circumstantial* is
precisely the standard that has cost this project three separate retractions.
Asked of the Decoder.
⚠️ A tempting alternative rule is "an element with a focus record is a menu item",
which fits both screens. It is still an inference about semantics from structure,
and it would silently reclassify elements on every screen in the export. Not
taken.
## The workflow cost this exposed, worth knowing before repeating it
**A screen name is authored data, but it only reaches the port through a full
re-export** — which re-transcodes both movies. Renaming one screen costs the
whole tree. Not worth fixing today; worth knowing before anyone plans a naming
pass.
## 🔴 And a genuinely dangerous mistake, recorded because it nearly cost the session
Killing a background check with `pkill -f "check-all"` matched **the container's
own entrypoint**, whose command line contains the loop prompt — and that prompt
mentions `check-all`. `pgrep` duly reported the process as still running after it
had stopped, and a `pkill -9` on that pattern could have killed the session
itself.
**Match a process by its actual `comm`, or list with `ps` and check, before
sending a signal.** A pattern that appears in your own instructions is not a
pattern that identifies a process.

View File

@@ -35,7 +35,6 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
| Scripted input / profile traps | ✅/🟡 | [canary-scripted-input-traps](canary-scripted-input-traps.md) | Why a scripted run appears unable to press Ⓐ: **F10 opens the emulator menu bar**, and any Xenia UI makes `XamInputGetKeystrokeEx` return SUCCESS with an empty keystroke *before* any driver is asked (Canary now logs `[RE-INPUT] … swallowed by IsUIActive`); the title needs a **signed-in profile** (hence `--create_profile_if_none`); and a **FIFO trace consumer that exits stalls the emulator**, which reads exactly like a dead pad. 🟡 The main menu HAS been reached — Ⓐ works, but only intermittently (1 in ~4), which is the open question |
| Title-screen guest crash | ✅ | [title-crash-stl-tree](title-crash-stl-tree.md) | The guest throws **`std::out_of_range`** from its cache-manager flush (`sub_823070B0`, an STL map/set erase that builds `'invalid map/set<T> iterator'`); the access violation after it is only the throw **returning**, because this build does not unwind guest EH. Trigger found and controlled: an **incomplete on-disc cache** (`~/.local/share/Xenia/cache/aab216c3`) throws ~100 s into a boot, a complete one never does — 2 runs each way. ❌ `mem_watch`, the handoff's suspect #1, is **eliminated**: cold cache + `--mem_watch=false` throws anyway |
| Save file (`savedata`) | ✅/❔ | [savegame-format](structures/savegame-format.md) + [`tools/re-capture/savegame.py`](../../tools/re-capture/savegame.py) | `GDHA` container, zlib payload, chunk stream (`GDAA` / phase name / `GHAD` 122 B progress block / 16×20 B slot table / trailer). **Container and layout read off the title's own serializer `0x822C00E8` and verified by a byte-identical round-trip**; the whole save is 545 B. Payload offsets are also the live save object's offsets (`save+8` GHAD, `save+136` slots). A second save made in-game names **Points** (+24), **flight time in ms** (+4) and **clear ratio %** (+8) off the game's own Details panel; the payload is a **pure function of game state** (same state saved twice = byte-identical, only the header FILETIME and its uninitialised pointer padding move), and the 16 `SHAB` records are **not** the UI's 20 save slots. Difficulty vs stage is undecided — three fields hold 2. **A third save, taken after developing exactly one Arsenal weapon** (Light Machine Gun MG I, 4000 P), moves exactly three things: `+24` Points 4101→101 (which **separates it from `+28`**, that did not move), `+8` clear ratio 5→6 (so the ratio counts *collection*, not only stages), and two entries of the 54-byte blob — `2→4` for the item bought and `0→2` for the successor the game announced as newly developable, giving the blob its alphabet ✅ *0 locked / 2 developable / 4 developed* (only the `4`s are stored — `2` is re-derived at load). **Saves can also be written back**: three derived header fields (length at `+0x30`, payload length at `+0x8c`, `adler32` at `+0x8e`) are all that stand between a parse and a hand-written save that the title loads, and [`savegame_edit.py`](../../tools/re-capture/savegame_edit.py) re-wraps a real save byte-identically. That turned the blob's index space from blocked-on-story-progress into four probe saves — see the [economy note](arsenal-develop-economy.md) |
| `--build N` addressing (ordinal vs pak entry) | ✅ | [build-ordinal-vs-entry](structures/build-ordinal-vs-entry.md) + [`data/ordinal-entry-map.txt`](data/ordinal-entry-map.txt) | `screen --build N` indexes a **predicate-filtered list**, not the pak. Disc-wide: **21 of 24** build-bearing archives diverge, **18 at ordinal 0**`--build 0` is entry **108** in each `GP_MAIN_GAME_*2D`, entry 24/26 in `GP_HANGAR_ARSENAL`/`GP_READY_ROOM`. `GP_TITLE` is the **only** archive whose ordinals 09 are the identity, which is the sole reason 207 of the corpus's 226 build citations are safe. ⚠️ `--all` swaps the predicate and **renumbers 18 archives**, so `--build N` and `--build N --all` differ. Instrument controlled against the CLI's own `screen list` on `GP_TITLE` (12 builds, `[10]→12`, `[11]→15`) — a first version using `parse_build` as the predicate **failed** that control, reporting ordinal==entry throughout. Audit of all 226 citations: 1 defect found and fixed (a five-row table in `ui-keyframe-time-unit.md` labelled "build 11" spanned builds 10 and 11 — placements all correct, only the label wrong); `GP_DIALOG --build 0` and `GP_DEBRIEFING_PILOTLOG --build 10` re-run and reproduce |
## Runtime / dynamic-capture technique
@@ -137,7 +136,6 @@ 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 — |
@@ -149,38 +147,17 @@ 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; 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 |
| [`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 |
| [`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**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-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-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 |
| [`structures/ui-settle-time.md`](structures/ui-settle-time.md) | Which instant a "settled screen" composite depicts | ✅ **decoded**: a settled screen is **one instant every element is posed at**, and the disc names it — the midpoint of the **longest keyframe-free interval** in the build (`UiBuild::settle_time` / `settle_window`). 🔴 `rest()` is *not* that: it picks each element's last hold **independently**, so a two-frame flash holds at its **peak** and burns forever. `GP_TITLE` build 4 has five staggered flashes (`ptlogo_back2eff1``eff5`, all extinguished by t110) that `rest()` draws simultaneously and permanently, saturating the light arc. Predicted t=198 from `[160,236]` **before scoring**: arc band **33.22 → 11.79**, clipped pixels **8 581 → 1 452** against the console's **1 459** (an unfitted statistic), whole frame 14.07 → 12.06; controls at t=100 and t=358 are far worse, and a hand-picked visibility list reaches the identical 12.06/11.79/1 452. Controls: `at=None` byte-identical (`cmp`), pre- and post-rotation tags both 14.07, 13 paint-order tests green. ⚠️ **Reach**: of 1 758 bundles with ≥2 keyframe times only **30 %** have a window ≥ 30 units and **42 %** under 10 — mostly `loop*` fragments that never settle; check the width. 🔴 Withdraws two claims in [`ui-rotation-implemented.md`](structures/ui-rotation-implemented.md) — its "Flat. No minimum." (`at` posed **leaves only**) and its "Reborn does not draw `ptlogo1`/`ptlogo2`" (both **are** drawn; only kind-`0x4` ghosts are skipped, and hiding the real ones makes the error *worse* by +5.20/+7.47). ❔ its **10.92** baseline is unreproducible — 14.07 at both tags |
| [`structures/ui-tie-break-cost-at-settle.md`](structures/ui-tie-break-cost-at-settle.md) | What the unknown paint-order tie-break costs, in pixels | ✅ **decoded**, closing the open half of Q3: at the settled instant the tie-break costs **at most 1 px at Δ1**, on the **Japanese title only** (`ptlogo2`×`ptlogo_tm`, 5 px shared ink); **exactly 0 px on all five port screens**. The earlier 24-pair bound was a `rest()` count — and 10 of the title's 11 tied pairs are between `ptlogo_back2eff1``eff5`, five transient flashes that are **transparent** on the settled screen ([`ui-settle-time.md`](structures/ui-settle-time.md)). Live pairs at settle: entry 4 → **1**, entry 7 → **2**, the four loading bundles → **0**. ✅ Not a knife-edge — sweeping every keyframe time and midpoint, the count is **flat across the whole settle window**, and the loading bundles' tie is live only at t17t33. ✅ Controls: an overlapping *different*-key swap moves 25 310 / 268 698 / ~765 000 px on the entries reporting zero; zeros are explained by shared-ink counts (the `ptframe` pairs share **0 px** of ink). ⚠️ Entries 0/1/12/15 have **no live control** — their zeros rest on keyframe data, not a render. 🟡 Refutation attempt on the corpus's "24 pairs": **survives** as a rest-pose bound, 16/16 on entry 7. ❔ *Why* ties order as they do is still unknown — and now worth one pixel |
| [`structures/ui-record-loop-length.md`](structures/ui-record-loop-length.md) | Where a looping record's cycle restarts — and the `PRESS Ⓐ` plate's real period | ✅ **decoded**: a nested record is itself a RATC bundle and its header **`+0x08` is the loop length**; its keyframes need not fill it, and the slack is a hold at the final pose. Disc-wide over **1 781** timed nested records: 92.3 % declare exactly their last keyframe time, **7.7 % declare more**, and **0 declare less** — the falsifier (a cycle cannot restart before its own last pose) never fires. 🔴 **The plate's `ptbtn00f` is 105 units of ramp inside a 120-unit cycle, so it holds dark for 15 units** — the port was shipping **105**, and the answer is **120**. ✅ Falsification test against the running game, using a pacing factor measured *independently* on the focus ring (declared 120 → **2.177 s**, factor **1.0885**): to reach the corpus's measured 2.122.34 s, 105 units needs a factor of **1.2111.337** (🔴 excludes the ring's) while 120 needs **1.0601.170** (✅ contains it). Different elements, different bundles, separate runs — tied only by both declaring 120. ⚠️ Says where a cycle *ends*, not which records cycle. ❔ the **top-level** `+0x08` (300 on every `GP_TITLE` entry, elements ending at 244269) is a different question, untouched |
| [`structures/ui-focus-record-pulse-census.md`](structures/ui-focus-record-pulse-census.md) | Every focus record whose glow pulses, and where `rest()` puts it | ✅ **decoded**, disc-wide: **1 130** focus records, **2 664** timed elements, **210 with a varying alpha** — of which **202** have `rest()` == the **peak** (burns bright forever) and **8** land **mid-ramp**. By pak: `PILOTLOG` 116, `MOVIE_THEATER` 54, `HANGAR_ARSENAL` 30, `LEADERBOARD` 8, **`GP_TITLE` 2**. 🟡 Bounds rather than refutes the port's "34 in the export, 2 varying, nothing to fix" — correct, and correct *because* `GP_TITLE` has 2; the pathology sits in the screens a wider port needs next. 🔴 The 8 mid-ramp ones are the worse mode: `py_ranking_btn01f` swings 255→127→255 and `rest()` returns **244**, neither extreme, which looks entirely plausible and nothing reports it. ✅ Control: `ptbtn01f` is genuinely constant (255 throughout) and is **not** flagged; two hits verified keyframe by keyframe. ⚠️ A pulsing element has no resting pose — the question is malformed, not mis-answered; `pose_at(t)` inside the record's declared cycle ([`ui-record-loop-length.md`](structures/ui-record-loop-length.md)) is the only well-formed query. ⚠️ 210 is a **floor**: focus records are matched by the `Xf.rat` name rule, and varying scale/rotation/position is not counted |
| [`structures/ui-title-buildin-measured.md`](structures/ui-title-buildin-measured.md) | The title's build-in and the plate glow, read out of the guest's own draw stream | ✅ **measured** (Canary, `ARM=early` draw capture): the decoded *mechanism* is observed, not just its end state. **The five flashes fire in a six-frame window and are absent from all 155 other sampled frames**; `ptlogo_back2eff1` is drawn in exactly 2 frames at **t = 54.0** against a decoded peak of **t5456**, and `ptlogo1` first appears at **t = 42.2** against a decoded **t42** — with units/frame taken from the **glow's period alone**, a different element. The two holders (`ptlogo_back2eff`, `ptlogo_back2`) are continuous from frame 134. ✅ The glow's per-vertex colour alpha IS its fade alpha: **observed range 0…80 against a decoded peak of 80**, exact and unfitted; **period 51.158 presented frames** over 20 cycle starts; fitting the decoded ramp gives RMS **13.16** against **38.18 reversed** (2.9×), so the asymmetry is real and correctly directed. Structure: the settled title is 1011 draws naming no sprite — which is why arming at the title sees nothing. ⚠️ Frame **107** is a 27-draw spike between the movie's last frame and the title's first; calling it "the composite" was an **over-read** — it binds **no texture** and only 4 of its 27 draws log geometry. The second title entry has no such frame. ⚠️ The two entries are the same animation at **different sampling phases** (only 4 of 46 aligned frames match), which is what makes the `eff3` result robust. 🔴🔴 **RETRACTED — the game DOES draw `ptlogo_back2eff3`, and all five flashes fire in both entries in the declared stagger** (`eff3` at frames 133134 / 59575958, i.e. t=60.1 and 62.3, inside its declared t∈(58,64)). The absence was an **instrument artefact**: a draw batches several quads (`indices=8` is two) and the log dumps only the first 8 vertices, so min/max over a line **merges** them — and because the wipe is right-aligned, `eff3` (788…1196) lies entirely inside `eff4` (447…1196), making the union *exactly* `eff4`'s extent. The merged box matched `eff4` to 1 px. 🔴 Three explanations had been "ruled out" and all three were aimed at the wrong failure — notably the invisible-draw check counted draws with **no** geometry, where the hiding place was **partial** geometry. Superseded text follows: ~~three alternative explanations tested and failed: *phase* (its window is **6 units** against a **2.23-unit** step, so it cannot be missed — frames 133/134 sit at t=60.1/62.3 inside it and draw `eff2` and `eff4` instead), *an unlogged draw* (exactly 2 blind draws/frame, always the same full-screen-triangle shader, present when no wipe is active), and *a bad position guess* (dropping position entirely, **zero** quads anywhere have a width within ±30 of 408; the spectrum jumps 262 → 748). Draw counts across both entries: eff1 **4**, eff2 **3**, eff3 **0**, eff4 **6**.~~ (all from the merged-box parse, and wrong) 🔴 **The port draws `eff3` at t=6062 and the console does not.** ❔ Why is not established — nothing in its element record differs from its neighbours. ⚠️ An earlier "sub-frame phase" explanation and the advice that drawing all five "shows more sweep than the console" are both **withdrawn**. ⚠️ What a frame-by-frame build-in comparison *will* show is disagreement about which flash lands in which frame — 2 units/submitted frame against this run's 2.231 units/presented frame — and neither side is wrong. 🔴 **Trap:** matching a bound texture's dimensions to a sprite fails both ways — it missed every flash *and* read the intro movie's 640×360 YUV planes as `ptbase2`. ✅ A regression of five events' observed frames against their declared times (residuals ≤0.9 frames) recovers the intercept at frame **106.1** when the composite spike, not in the fit, is frame **107**. ⚠️ Per-vertex alpha = fade alpha holds for the **glow** and does not generalise — `eff4` reads 255/127/254 on consecutive frames. ❔ Frame rate not recorded, so nothing is in seconds; the glow's period implies a **114**-unit cycle against a declared 120, unexplained; `eff5` vs `ptlogo_back2eff` not separated |
| [`structures/boot-splash-gap-measured.md`](structures/boot-splash-gap-measured.md) | The black gap between the two boot splashes | ✅ **measured** in the guest's **draw stream**, which separates true black from a fade tail where luminance cannot: the publisher's last sprite is frame 125 (alpha 7), then **frames 126129 submit NO sprite quad at all**, then the developer fades in at alpha 34. **The gap is 4 presented frames.** Converted with the disc as its own clock — `palogo_sqex` declares alpha≥1 for **239.8 units** and is drawn in **105** frames → **2.284 units/frame** (the title capture independently gave 2.231) — that is **~9.1 units ≈ 0.152 s**, against the **12** the port authored; ⚠️ and the true black is *shorter*, since both boundary frames still carry picture. 🔴 **RETRACTED**: "the developer splash is ONE composited 525×259 quad" — the same batching artefact. It draws three logos and three glows as separate quads in one `indices=24` call; the 525×259 was `gamearts_eff` merged with `seta_eff`. The port refuted it with arithmetic (a 259-tall box cannot hold logos spanning y 164…585) before I checked. ⚠️ The gap measurement is unaffected — those glows are the developer splash's first draw. ❌ Not declared on the disc: `palogo_eff0.prm` is a single static keyframe, and the top-level `+0x08` is a **family constant** (300 / 60) whose slack ranges 12226 units. ❔ The executable is **not** looked at — named, not claimed. 🔴 The instrument was perturbing the measurement: the capture script taps Ⓐ on "screen changed a lot", which is also true of a fading splash — it tapped through the publisher and the developer never appeared. `GRACE=1` and `NOTAP=1` knobs added |
| [`structures/ui-forced-backdrop.md`](structures/ui-forced-backdrop.md) | Where a keyless primitive paints, when the file forces it | ✅ **decoded**, partly closing `ui-prm-primitives.md`'s standing blocker: **an element covering the screen and fully opaque at some instant cannot paint above anything visible then**, and where that set is *every* other element its position is **forced first**. Disc-wide **80** instances forced, 50 constrained but not forced, 0 unconstrained. ✅ **Two controls, both measured orders from the running game**: it reproduces `palogo_eff0.prm` = FIRST (opaque 211 instants, below 6/6) — which a **name**-based rule gets wrong, since it is named like an overlay — and permits `pteff00.prm` on top (opaque 2 instants, below 3/23), which is where it is measured. ✅ Answers the port's `build_12`/`build_15` blank-screen contradiction: `pgloading_eff00.prm` is forced first, 4/4. ✅ Explains 36 builds the corpus recorded as "one colour" with no cause — `pzeff00.prm` forced first 32/32, so **our own sort wiped them**. 🔴 The rule's limit was found by its own test failing: applied to `.t32` sprites it claimed 22 must sort first against their own keys (`pneff01` 0xd850 at #8/13, `pbfriendly` 0x9230 at #17/49) — a sprite's *element* alpha says nothing about its *texture*'s coverage, so it is now restricted to untextured primitives. ⚠️ Assumes straight alpha-over; blend mode is still ❔. ⚠️ A lower bound, not an ordering. ⚠️ No new oracle run — the controls are prior measurements |
| [`structures/tbm-submenu-not-reached.md`](structures/tbm-submenu-not-reached.md) | Does a `.tbm` draw pixels in the running game? | ✅ **YES — measured 2026-08-30**, and it closes the surviving "inert" reading in [ui-forced-backdrop](structures/ui-forced-backdrop.md). The `TUTORIAL` screen was reached and captured: a **full-screen blue circuit/hex background**, where `GP_TUTORIAL` build 0's **element 0 is `pubase.tbm`, pivot (640,360)** = 1280×720, the only full-screen *textured* element in the bundle (the other, `pueff00.prm`, is an untextured primitive the colour census puts at pure black). Our render of the same build is the **identical layout on pure black** — 6.06.4 % inked against the game's 99.7 %. So the rule's **24 `.tbm` deciders are correct, not harmless**, and 🔴 **`screen render` silently omits the background of every screen carrying a `.tbm`**. ⚠️ One `.tbm` observed; the class question is settled, the ten other families are not. 🔴 Getting there took three runs and cost two instrument failures worth reading: a 0.12 s Ⓐ during a screen load is **never delivered** (`[RE-INPUT]` is the only witness), and **correlation cannot identify a screen when the candidate renders are near-blank** — masked correlation failed its control (picked `EXTRAS` over the known menu by 0.004), a high-passed variant passed by only 1.28×, and **reading the title off the screen** settled it in one look |
| [`structures/ui-forced-backdrop.md`](structures/ui-forced-backdrop.md) *(span sensitivity)* | How much of the forced-backdrop rule rests on the timeline convention | ✅ **decoded**: the span is `0..=max keyframe time over every element`, and an element **holds** its final pose — decoded, not assumed ([`ui-keyframe-time-unit.md`](ui-keyframe-time-unit.md), [`ui-record-loop-length.md`](structures/ui-record-loop-length.md)). Sensitivity over the 130 keyless full-screen primitives with an opaque interval: using the header's declared **`+0x08`** instead changes **0** verdicts (interchangeable); using the primitive's **own** last keyframe changes **72**; counting elements **gone** after their last keyframe changes **72**. 🔴 So the hold decides **55 %** of verdicts — and dropping it is **refuted by a measured order**: `palogo_eff0.prm` is a single keyframe at t=0, so without the hold it is opaque for one instant, nothing else is up, and the rule calls it *free* against a game measured painting it first. ✅ The verdicts that matter are convention-independent — `pgloading_eff00.prm` is FIRST under all four, `pteff00.prm` FREE under all four. ⚠️ The port's **256 vs 211** was a **bundle mismatch, not a definitional one**: `palogo_eff0.prm` runs to t=255 on the publisher splash (entries 10/13) and t=210 on the developer (11/14) |
| [`structures/ui-clock-freezes-at-settle.md`](structures/ui-clock-freezes-at-settle.md) | The top-level clock stops at the settle point — observed in the running game | ✅ **measured**: `GP_TITLE` build 4 declares `t = 0…269`, about 120 presented frames at this run's pacing, and the dwell lasted **~1 100**. `ptcopyright` declares alpha≥1 for **106 units** (t=138…244) and is **drawn for 1 050 frames**; `ptlogo1` declares an exit at t=264 and is drawn for 1 095. Both vanish within three frames of the dwell ending. **The clock advances through the build-in, stops inside the settle window `[160,236]`, and holds; the exit ramp plays when the screen leaves, not on a timer** — [`ui-settle-time.md`](structures/ui-settle-time.md)'s decode observed from the other side. A nested record keeps looping on its own clock throughout. 🔴 **This closes the 114-vs-120 gap, and it was my arithmetic**: 2.231 units/frame was regressed over *build-in* events (the only stretch the top-level clock advances) and applied to a period measured during the freeze — two different clocks. The declared **120** was never in doubt from the calibration-free dark-fraction test. ✅ The 51.158-frame period is now confirmed by a **second independent estimator** (autocorrelation, lag 51 with harmonics at 102/154) — ⚠️ whose first version **failed its control**, returning 48, because it indexed by sample position where the log's frame numbers have gaps. ❔ The **sweeps'** period stays unmeasured: the same validated estimator disagrees between two dwells of one screen (515 vs 452 frames). 🔴 **Blocker: a single Ⓐ on the title faults the guest** — 3 attempts, 2 register dumps of 223 MB and 519 MB, against 3 no-input runs that all completed; bounds menu-side dynamic RE here, and any scripted button press needs a `canary.stdout` size guard |
| [`structures/boot-splash-dwells-are-declared.md`](structures/boot-splash-dwells-are-declared.md) | How long each boot splash is shown | ✅ **decoded**: the dwells are the bundles' own declared timelines — publisher **t=0…255 = 4.250 s**, developer **t=0…210 = 3.500 s** at 60 units/s. The corpus's independent screenshot timing over 3 cold boots gives 4.30/4.60/4.37 and **3.51/3.50/3.37** — the developer agreeing to **1.1 %**, two of its three runs to 0.3 %. 🔴 **Wall clock is the wrong unit to author**: a fresh no-input boot measured the same two dwells at **5.105.61 s** and 3.834.30 s, 1520 % longer than both the declared values and the corpus's runs, on the same disc — so a seconds figure is one run's emulator pacing. Boundaries from the draw stream: publisher wordmark frames 6119, **3 frames with no sprite drawn**, developer glows 123, wordmarks 140209, intro video 216. 🔴 **The frame→wall-clock instrument resolves to one BUFFER FLUSH, not one frame** — 69 of 125 samples showed no advance and the rest jumped 715 frames, making the apparent rate swing 0.01640.0316 s/frame; frames 119 and 123 fall in one burst, so the inter-splash gap is **not separable** by it. Quoted as brackets; sub-flush estimates withdrawn before reporting. ⚠️ `palogo_anima` never appears — almost certainly the 8-vertex cap (7 elements batched, 2 logged), the same trap as the `eff3` false negative, so it is named not reported. ❔ the publisher's 4.1 % error vs the developer's 1.1 % is unexplained |
| [`structures/ui-forced-backdrop.md`](structures/ui-forced-backdrop.md) *(colour census + self-refutation)* | What colour a keyless element is, and which forced verdicts the argument actually supports | ✅ **decoded, disc-wide**: every full-screen `*eff00*` **primitive** is **pure black** at its various alphas (`ff000000`, `7f000000`, `40000000`, `b2000000`, `cc000000`, `d4000000`, `00000000`) — exactly an alpha-over dim or fade, and an *additive* black quad would be a no-op nobody would author. The **only** non-black primitive on the disc is `pbafc.prm`, RGB `00e8e0` cyan at alphas to `ff`, and it is **844×600, not full-screen**, so outside the backdrop rule's geometry guard — ❔ it is now the sole additive candidate. 🔴 **Self-refutation: of the 80 forced-first instances only 42 are `.prm`; 38 are `.tbm` carrying fade `ffffffff`.** A *solid* white quad painted first would make the screen white and no screen is white, so a `.tbm` is a white **modulation on a texture** — and element alpha does not establish its coverage. That is the `.t32` error one extension further out: I had fixed the symptom (`el.sprite.is_some()`) not the cause, **an element's alpha is not its texture's opacity, and only an untextured primitive makes the two the same fact**. So 42 verdicts stay **decoded**, 38 drop to 🟡 (still almost certainly right — all named `*base*`, full-screen, and `pfbase.tbm`'s first position is *measured* — but on a name-and-role argument this page elsewhere calls the weaker kind). ⚠️ Code deliberately unchanged: restricting to `.prm` would send eleven screens' backgrounds back to last, the blank-screen bug the rule fixed. Split pinned by a test |
| [`structures/ui-prm-blend-mode.md`](structures/ui-prm-blend-mode.md) | Whether a primitive blends additively or alpha-over | ❔ **undecodable, with reach** — but the consequence is closed. Looked in **the bundle** (no field: the declaration words are constant and a primitive has no RATC child at all), **the colour census** (every full-screen `*eff00*` primitive is **pure black**; the only non-black primitive on the disc is `pbafc.prm`, cyan `00e8e0`), **the occlusion constraint** (inapplicable — `pbafc.prm` strobes 255/124 every 2 units, travels, and is scaled **2 %×3 %**, so it draws ~**17×18 px**, not its declared 844×600), and **the oracle** (`GP_READY_ROOM` is a recorded no-go and gameplay needs the Ⓐ that faults the guest). ✅ **Why it stopped mattering:** for a *black* quad the hypotheses differ only in whether it hides what is beneath — drawn **first** it is correct under **both**, drawn **last** only under additive. So `forced_backdrop`'s verdict is robust to the open question, and the port's original "layerless sorts last" was wrong under alpha-over and merely pointless under additive. ⚠️ This is not evidence *for* alpha-over. 🔴 The investigation found `forced_backdrop` judged coverage from the **pivot alone**, ignoring scale; checked first, **all 80 forced instances are at 100 %**, so no verdict moved and the added guard is defensive |
| [`structures/title-a-press-fault.md`](structures/title-a-press-fault.md) | Why a single Ⓐ on the title faults the guest — the blocker on all menu-side dynamic RE | ✅ **SOLVED 2026-08-30, and it is the emulator, not the game.** Xenia returns `X_ERROR_SUCCESS` with a *zeroed* keystroke on every `XamInputGetKeystrokeEx` while a XAM dialog is up (`xam_input.cc:197`, upstream); the game's pump is an **unbounded** `while (GetKeystrokeEx()==SUCCESS) queue.push_back()`, so it queued **8 388 608** empty keystrokes, grew its vector to 64 MB, asked for 128 MB, got a failed allocation back **unchecked** and copied off the top of the guest thread stack. ✅ **The number is the argument**: the Canary counter reports **8 388 601** swallowed calls at the last report before the crash, the dump's `r29` says the vector held **8 388 608** — two independent instruments, 7 apart, inside the 600-call reporting granularity. No new boot: the failing run's 326 MB log was still on disk. 🔴 **RETRACTED — "`r9` is a wild pointer above 4 GB"**. Xenia prints `si_addr`, a *host* address, and the guest is mapped at `0x100000000`: `0x1701D0000 0x100000000 = 0x701D0000`, which **is** `r9` in the dump — an ordinary guest heap address on an uncommitted page. Subtract `0x100000000` from every `Access Violation … at 0x1________` before reading it. ✅ **Decoded code path**, image-checked with **0 mismatches** over 586 instructions: `sub_824574C0` the input-manager singleton at `0x828F3888`, `sub_82457038` the keystroke pump, `sub_82457780` its `vector<X_INPUT_KEYSTROKE>` insert-with-grow. ✅ **It explains the earlier successes**: whether a XAM dialog is up is *emulator* state, so "reproduced 4/4" and "Q4/Q5 pressed Ⓐ fine" were both always true. 🟡 **Which** dialog is still open — `XamShowDeviceSelectorUI` is ruled out (`storage_selection_dialog = false` takes the headless path), `XamShowSigninUI` / `XamShowMessageBoxUIEx` are not; the settling experiment is one log line per `is_xam_dialog_present_.store(true)` site, not another blind boot. 🟡 Three untried routes out: dismiss the dialog, `--headless`, or return `X_ERROR_EMPTY` from the swallow. ✅ `frame_clock.sh`'s 300 MB guard killed the run as designed — keep it |
| [`structures/plate-pulse-phase-lock.md`](structures/plate-pulse-phase-lock.md) | Does gating on the plate pulse bias what a title capture can show? | ✅ **measured — it PHASE-LOCKS the shutter.** The plate's pulse is part of the animation, so `wait_plate_pulse.py` does not only wait for settling, it synchronises the shutter to the animation's phase: at the shutter instant the sweep strips sit **2526 px apart across two runs in different locales and different sessions****1.6 %** of a ~1600 px traverse. 🔴 **Consequence: the RMSE 0.32 recorded as "between-session capture noise" is a lower bound produced by the instrument, not a property of the game**; the honest figure at an arbitrary phase is **11.9**, a factor of 37, and I had read 0.32 as evidence the JP title is still when it is evidence the gate works. ✅ **The era adjudication survives** — margin **16.72** clears even 11.9 — and survives *for the reason its own file gave*: correlated noise moves both candidates together and cancels in a margin, so prefer margins to absolute scores under a gated shutter. ✅ The within-run at-rest result also survives (five frames ~1.5 s apart are not gated individually). ⚠️ Reach: this shows the lock, not its mechanism — both runs boot the same ISO from the same state, so a deterministic boot could produce it without the gate; **two runs deliberately shuttered at gate + k frames would separate those** and were not run |
| [`structures/plate-pulse-measured.md`](structures/plate-pulse-measured.md) | Does the `PRESS Ⓐ` plate stay up, pulse, or blink once? | ✅ **measured** — it **PULSES**, continuously and without decay, on a title held with **no input**: two windows in one boot, 58 s and 57 s, ~23 cycles each, periods **2.530 / 2.540 s** agreeing to 0.4 %. ⚠️ **It never goes off** — the plate-absent floor is **159** green pixels (the title art's own, from `live-title-build4-no-plate.png`) and the pulse bottoms at **714**, 4.5× that. So the port's "flash and nothing after", reasoned from `ptbtn00` expiring at t=244, is wrong; `ptbtn00f`'s 120-unit cycle is what runs. 🔴 **Two estimators, one misspecified**: mid-crossings replicate to 0.4 %, a single-sinusoid fit does not (2.553 vs 2.413) because the waveform is fast-rise/slow-decay — and its own r² of 0.468/0.228 is the tell. Both were controlled on synthetics at 2.24/2.55/3.10 s laid on the real timestamps and recovered all three exactly. 🟡 wall-clock is **13 % longer** than the corpus's earlier 2.24 s mean — same declared 120 units, different pacing (×1.27 vs ×1.12), so **author the units**. ⚠️ Reach: one boot; does not distinguish the boot title from an attract-loop title; the glyph count is a thresholded pixel count and **not** an alpha, so no duty cycle can be read off it |

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More