THE TRANSCODE CACHE HAD NEVER HIT. `video::transcode` has carried one since P4 -- a `.cmd` sidecar with the command, the source size and the channel count -- and `main.rs` clears the output tree wholesale immediately before the check, deleting the sidecar and the file it stamps. Six exports in this session paid the full Theora encode and produced five byte-identical files, roughly 48 minutes. Nothing reported it, and nothing could: a cache is silent either way and the only symptom is a wall clock that looks like the job being slow. The wipe now spares `video/` and `prune_videos` deletes anything in it this run did not claim, so the wholesale guarantee is kept rather than traded. A re-export is 20 s. SETTLE TIMES, MEASURED, and they refute more of my row than they confirm. The principle holds -- the title's rest.t is 251 units = 4.183 s where its art finishes at ~2 s -- but "everything the sequencer paces off that landmark is therefore late" does not. Measured the port the way the game was measured, by VISIBLE SPAN rather than arrival-to-arrival: publisher wordmark port 4.25 s game 4.297 / 4.604 / 4.370 developer logos port 3.50 s game 3.508 / 3.503 / 3.366 black hold port ~0.25 s game 0.2 - 0.3 title -> plate port 2.000 s (declared 120 units) game 2.247 s Dead on. My earlier reading compared the port's transition timestamps against the game's visible spans, which differ by the exit ramp plus the black hold -- the whole of the discrepancy I was about to chase, and the same definitional trap that cost this corpus 0.48 s on the plate delay. Nothing in the sequencer is changed. `dwell_seconds` stays null, now for a measured reason rather than an absent one: `timing.json` said "if a capture ever times the real boot, this is where that number goes", and the answer is that nothing goes there. Not authored, deliberately: an (A)->menu dwell, which measures 3.763 s and contains a 1.53 s guest load stall on a cold cache; and the menu build-in and (B)->title, which rest on one run where the port is already within ~0.1 s. THE VOICE PRESENTATION MOVED TO authored/, because the recommendation behind it was withdrawn as self-contradictory and the choice is now unambiguously mine. `voice.presentation` = `loudest`, and the reason to switch is a measurement, not a preference: ADV chunk 1 is MONO-IN-STEREO and chunk 2 is DUAL-MONO, so chunk 2's extra bytes encode a duplicate channel rather than fidelity. That explains the byte-rate difference and removes the only argument for `highest_rate`. ADV's dialogue now exports at +0.3 dBFS instead of -8.7, which is the SE bound's documented decode overshoot on a wave mastered at full scale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
150 KiB
Decisions
One entry per decision that outlives the container it was made in. Newest last. A decision that lives only in an agent's context is lost when that container dies, which is what this file is for.
P0 — the exporter, 2026-08-28
The exporter reads one authored file, and stamps its provenance into the output
export/ is derived and authored/ is hand-written, and the natural reading of
that is that the exporter never touches authored/. But a screen has to be
called something, and the disc does not name its builds — the identification of
build 5 as the main menu is HANDOFF Q2, measured against a live capture, not
a field.
Two ways to handle that:
- the exporter emits
build_05.jsonand the runtime renames it fromauthored/screen_names.json; - the exporter reads that map and writes
main_menu.jsondirectly.
Chose 2, with a condition: every name it applies carries name_source: "authored" and a name_why quoting the evidence, and check rejects an
authored name with no why. The file that lands in export/ is therefore still
honest about which of its fields is a measurement — which is the property the
derived/authored split exists to protect — while a human opening the tree sees
main_menu.json rather than having to resolve a rename in their head. A build
nobody has identified exports as build_NN with name_source: "index", which is
a locator and not a claim.
This is the only authored input the exporter takes. Everything else in
authored/ is applied by the runtime over export/.
Sprites are per screen, not a flat pool
main_menu and extras both ship a ptbase.t32 and they are different
pictures. A flat sprites/ directory would have silently collided; whichever
screen exported second would have won, and the loser would have drawn the wrong
background with no error anywhere. sprites/<subdir>/<screen>/<name>.png.
The format is executable
sylpheed-export check --out export validates a tree against docs/FORMAT.md
with no disc in hand. It exists because "the export is correct" is otherwise an
assertion, and because the P0 gate is "validates against FORMAT.md" — which is
not a thing anyone can confirm by reading.
It reads the tree the way Godot will: as a stranger, with no access to the disc,
the decoders, or the exporter's internals. It deliberately does not check the
export against the disc — that is what sylpheed-cli screen render is for, at P1.
Checked that it bites, rather than assuming: five mutations of a valid
main_menu.json — a broken paint_order permutation, a dangling
focus_sprite, a reversed buttons list, a #rrggbbaa colour, an invented
name_source — are each caught with a specific message.
The highlight sprite pairs by name; opt is exported but not believed
FORMAT v1 said focus_sprite came from the element's opt link. That reading
was measured and refuted by the RE agent, and this export shows why plainly:
on the main menu, opt chains ptloop01 → ptloop02 → ptbtn01 — two decorations
and then a button. It is a linked list of something, and it is not focus.
The highlight is paired by sprite name instead (ptbtn01.t32 ↔
ptbtn01f.t32), which is HANDOFF's convention and holds for all 54 real pairs on
the disc. It resolves all five main-menu buttons. The raw link is still exported
as opt_link, renamed so that nothing downstream mistakes it for navigation, and
so that whoever eventually decodes it has the data.
Note this is 🟡 a naming convention, not a decoded field. It is authored in effect, and lives in the exporter only because it is a rule over disc data rather than a value we chose.
The paint order is exported, not authored
Q3 decoded it — a u16 layer key at +0x0A of each T8aD sprite header,
stable-sorted with declaration index. So it is read in the exporter, per the
contract's own rule for a decoded answer, and paint_order in export/ is a
derived field. "paint_order" is gone from unresolved; paint_order_ties
replaces it, because the tie-break is still unknown and costs one element's
blend on one screen.
Where an element has no T8aD header the key comes from the decoders' table of
keys measured off the running game. That is a different kind of fact, so it
is labelled: layer_source is "sprite", "implied" or "none", and a
consumer that needs to know whether a layer is read or measured can tell.
Colours are exported as two fields with the byte order in the name
There are two modulate colours and they multiply: tint is RGBA, fade is
ARGB and its high byte is the alpha that ramps. v1's single "#ffffffff"
could not carry both and silently discarded the ramping alpha. They are exported
as tint_rgba and fade_argb, raw hex, byte order in the key — because getting
it backwards is silent and looks like an art bug rather than a parse bug.
t stays raw
HANDOFF Q1 is answered — linear ramp, 2 units per rendered frame, working conversion 1 unit = 1/60 s — but that conversion is measured off the running game, not read from the file, and the finding itself flags the 27.6 present- frames/second measurement as the part worth re-testing. If the game turns out to present at 60 Hz, every duration halves.
So t is exported exactly as the disc spells it, keyframe_time_unit stays in
unresolved, and the conversion will live in one authored place at P2. One
constant to change, in a file that says it is a decision.
The final keyframe has no t, and check enforces that
The disc has no time slot on the last keyframe of a group. A file that carries
one there has invented it. check rejects it — this is the one place where the
temptation to emit a plausible number is strongest and the resulting error is
completely invisible.
P1 — Godot draws the screen, 2026-08-28
The Godot side reads the manifest, not a path
ExportTree is the only class that knows where export/ is: SYLPHEED_EXPORT
if set, otherwise <project>/../export. Screens are addressed by their manifest
name (main_menu), never by a file path, so the runtime never encodes the
archive's subdirectory and a re-export that moves a file does not break it. It
also checks format on both the manifest and each screen, and refuses a tree it
was not built to read rather than half-drawing one.
Textures are read as bytes and decoded with load_png_from_buffer at runtime.
They are deliberately not Godot-imported resources: export/ is gitignored
and regenerated wholesale, and a .import sidecar per sprite would be derived
state living next to derived state, invalidated on every re-export.
One CanvasItem draws the whole screen
ScreenView._draw walks paint_order and draws each element itself, rather
than making a node per element and leaning on z_index. The export's
paint_order is already back-to-front, so honouring it is a loop; expressing
the same order through sixteen nodes' z-indices would hide the one thing that is
still unresolved about it — the ties — behind Godot's own sibling rules,
where a change in the export would silently become a change in Godot's tree
order instead of a visible change in the draw sequence.
P1 draws rest and nothing else
Every element is drawn at its resting pose. No keyframe interpolation: that is P2, and it depends on the keyframe time unit, which is measured rather than decoded. A milestone whose gate is a pixel diff must not have a measured constant inside it, or the diff stops being evidence about the port.
For the same reason focused_id is empty at P1. Initial focus was measured as
unstable boot to boot (HANDOFF Q5), so choosing one is an authored decision and
it belongs to P5, where a human is pressing keys.
Nearest-neighbour, and why that is not a preference
TEXTURE_FILTER_NEAREST. The export is a 1:1 copy of the disc's own texels and
elements draw at up to 500 %; a bilinear filter invents detail the disc does not
have. It is also what the reference renderer does — ui_layout::blit maps
destination to source by integer division — so a filter difference cannot
masquerade as a placement difference in the diff.
The capture is the SubViewport, not the window
The screen is drawn into a SubViewport sized to the export's own design
rectangle and shown through a container that scales it to the window. The first
attempt captured get_viewport() and got 1235×695: there is a window manager
on the Xvfb display and its title bar had eaten 45×25 px of a screen the export
declares as 1280×720. A gate that compares a rescaled 1235×695 capture against a
1280×720 composite measures the compositor.
So --capture grabs the SubViewport texture: exactly the design rectangle,
independent of the window, directly comparable with screen render with no crop
and no resample. The windowed run is still worth doing — it is what proves a
human sees the screen — but it is not what the numbers come from.
P1 gate — the diff, and what it found
tools/verify-screen renders every screen in the manifest both ways and reports
the largest per-channel difference anywhere in the frame. Both renderers are held
to the same inputs: the reference CLI built by build-reference-cli from the
revision the exporter is pinned to (not /reborn/target/, which is a live
mount that moves mid-iteration), --black because the screen carries its own
background, and --primitives --animated because those are what make the CLI
draw the same element set the port draws at rest.
| screen | build | max per-channel Δ | |
|---|---|---|---|
main_menu |
5 | 3 | the P0/P1 gate screen |
main_menu_jp |
8 | 3 | |
extras / extras_jp |
6 / 9 | 4 / 3 | |
press_start / press_start_jp |
2 / 3 | 1 | |
build_00 / build_01 |
0 / 1 | 3 | |
build_10 / build_11 |
10 / 11 | 0 | byte-identical |
title |
4 | 6 | paint-order tie, below |
title_jp |
7 | 154 | sampling phase, below |
main_menu — the milestone's own gate — agrees to ≤3/255 on every channel of
every pixel, RMSE 0.38 %, with no pixel differing by more than 4 %. 3/255
is what integer-truncating compositing in the CLI and float rounding on a GPU
differ by; there is no structural disagreement anywhere in the frame.
Three screens exceed that, and each has a named cause rather than a threshold.
title: a tie in the paint order — neither renderer is wrong
Build 4 is the one screen where the CLI uses a paint order measured off the
running game instead of deriving it. Compared against the order this port
exports, every single disagreement is inside a tie — the two orders differ
only among elements carrying identical layer keys (0x8083, the back2 glow
group, and 0x80a0):
derived : … 15, 16, 17, 18, 0, 1, 2, 3, 4, 5, 7, …
measured: … 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, …
That is exactly the residual HANDOFF Q3 documents and this export already
declares in unresolved: ["paint_order_ties"]. It is worth stating what it
costs: 904 px in the glow band at (445,117)–(1195,313), all of them 4–6/255.
The port keeps the stable sort, per HANDOFF's own recommendation. Nothing to fix,
and nothing to tune — a "fix" here would be fitting the port to one screen's
capture.
Two of the reordered indices (0x80a0) are kind & 0x4 template instances that
both renderers skip, so the only real reorder outside the glow group is
ptlogo2 against ptlogo_tm, which do not overlap.
title_jp: nearest-neighbour sampling phase — the CLI is the one I would call wrong
title_jp is the only screen in the export with a drawn element at a scale
that is not a whole multiple of 100 %: ptlogo_eff2 at 125 %. It is also the
only screen with a difference above 6/255. The two facts are the same fact.
At a non-integer ratio the two renderers pick different source texels:
ui_layout::blitsamples the source at the destination pixel's top-left corner —sxi = col * sw / dw.- A GPU samples at the destination pixel's centre —
floor((col+0.5)·sw/dw).
At 125 % those disagree on one column in five, which is why the differing pixels are ~30 above 100/255 strung along thin diagonal edges rather than a shifted region. At every whole multiple of 100 % they agree exactly, which is why the other eleven screens are clean.
Which is wrong: the CLI, I think. Corner-sampled nearest is a half-
destination-pixel bias toward the top-left that no rasteriser produces, and the
Xenon GPU that drew this screen sampled at pixel centres. But I have no
framebuffer capture of title_jp and the disagreement is sub-pixel on one glow,
so this is a reading, not a measurement — recorded in docs/BLOCKED.md rather
than acted on. The port is not changing to match, because matching the CLI
here would mean deliberately reproducing a half-pixel offset in order to make a
number smaller.
extras: two pixels
Two pixels at 4/255. Rounding.
What the diff cannot tell us
The pivot question in docs/BLOCKED.md predicted that a P1 diff could not
distinguish "anchor scale to the declared pivot" from "anchor to half the
texture", because both renderers use the declared pivot. That prediction held:
the port and the CLI agree on every scaled element, and that agreement is not
evidence about which anchor the game uses. It stays open.
pteff05.t32 and pteff04.t32 have no sprite, and that is correct
pteff05.t32 and pteff04.t32 have no sprite, and that is correctRETRACTED 2026-08-29. This was wrong, and it was the most consequential thing on this page. See "The menu had no background" below.
P2 — keyframe animation, 2026-08-28
The time unit is authored, in one file, and says loudly that it is not on the disc
authored/timing.json. HANDOFF Q1 is answered — linear ramp, 2 units per
rendered frame, 1 unit = 1/60 s — but that conversion is measured off the
running game, not read from a file, which is exactly the case the
derived/authored split exists for. It is expressed as
keyframe_units_per_second: 60 rather than seconds-per-unit so the value is
exact instead of a repeating decimal, and it carries the two independent lines
that support it. t stays raw everywhere in export/; seconds appear only
where this file is applied, which is one line of boot.gd.
exit_ramp_seconds is deliberately null. See below.
The timeline stops at the last timed keyframe, and never plays the exit
The last keyframe of every group carries no t — the disc has no time slot
there. Across this export that final frame is an exit pose: for 116 of 134
elements it differs from the last timed keyframe in alpha only (a fade-out),
for 12 it is the loading splash's scale-and-slide exit, and for 6 it is
identical (no exit animation at all).
So the group is pre-roll → ramp in → hold → [exit], and the port plays it up to
the hold and stops. Playing into the exit would mean inventing how long the
ramp takes, because the disc does not say. That duration is the screen
transition — HANDOFF Q7 measured it at ~0.4 s — and it belongs to P3, with its
own evidence. This is why exit_ramp_seconds is null rather than 0.4: P2 has no
business holding it.
The interpolation is checked by where it lands, not by inspection
For 8 of the 12 screens the settled timeline is byte-identical to the
--pose=rest render. That is the useful assertion: the port walks the keyframes
with an authored time unit and arrives, to the pixel, at the pose the pinned
decoders independently identify as the resting one. tools/screen-strip reports
this per screen, so a change to the interpolation that drifts by one unit shows
up as a diff rather than as nothing.
The four that differ do so for two distinct reasons, below.
rest misidentifies six elements, and the running game says so
On main_menu, the settled timeline and rest differ in exactly one region:
400×470 at (440,108) — the bounding box of ptframe1 and ptframe2, and
nothing else on the screen.
rest puts both at their first keyframe: off-position and fully
transparent. The keyframes say they slide (620,108)→(440,108) and (403,267)→
(583,267) while fading 0x00→0xff, and then hold that pose for their last three
keyframes including the untimed one.
/reborn/docs/re/captures/main-menu-oracle.png, a capture of the running game,
shows them: the bright circuit-frame bracket around the menu, with a ring at
the bottom right. Cropping the same 250×180 region from the capture and from
both renders puts the ring and its elbow trace in the port's timeline render
pixel-aligned with the game's, and absent from the rest render. That is
geometry, not luminance, so it does not depend on the capture's gamma or on the
fact that it was taken with NEW GAME focused.
Why the decoders get it wrong, precisely
ui_layout::rest_plateau excludes a run of identical keyframes that ends the
group, because that run is normally the exit — the comment cites the pause
menu, where taking the trailing run erased the word PAUSE. That exclusion is
right in general and wrong for an element with no exit animation, where the
trailing run is the hold. The rule then falls back to an earlier run, which
for a slide-in is the invisible pre-roll.
The condition that identifies the affected elements exactly, with no false positives in this export, is:
the final untimed keyframe has the same pose as the last timed keyframe
Six elements match it and rest misses all six: ptframe1/ptframe2 on
main_menu and main_menu_jp, and pteff02 on title and title_jp. This is
a finding for the RE agent about sylpheed-formats, not something this port
fixes: the decoders are pinned and must not be reimplemented here. The port
simply does not use rest — it derives the arrived pose from the keyframes,
which needs no heuristic — and verify-screen still asks for --pose=rest so
that renderer-vs-renderer diffing compares like with like.
Note what this says about P1: the port and the reference renderer agreed on
main_menu to 3/255, and both were missing two elements the game draws. Two
renderers reading the same field through the same decoder agreeing is not
evidence that the field is right. docs/BLOCKED.md had already said that about
the pivot; here it bit for real.
The title is not settled, and P2 does not claim it
title and title_jp differ between the two modes by much more (max 142 and
247), and there the disagreement is not the six-element bug alone. rest
picks a mid-timeline hold for several glows (pteff01, ptlogoall_eff,
ptlogoall_eff2, ptlogo_back2eff5) where the timeline runs on to a much
brighter pose.
I could not settle which is right, and did not try to make the numbers agree:
- No element's alpha ever reverses direction anywhere in this export, so the title's 4.48 s timeline is a slow one-way ramp, not a pulse — which removes the obvious reason to expect a loop, but does not prove there is none.
- The only live title capture composites the
PRESS Ⓐplate (build 2) over the title (build 4), so it cannot be diffed against build 4 alone. Mean luminance is oracle 64.1,rest62.8, timeline 80.0 — which looks like it favoursrest, except that the plate adds brightness andrestis carrying a 25 % black dim quad (pteff02) that is itself one of the six misidentified elements. The comparison is confounded in both directions and settles nothing. - Both modes are visibly wrong anyway. Side by side with the capture, the
port draws a washed-out cyan glow slab across the logo that the running game
does not have — in
restmode too. That is a third problem, independent of this one, and it is P3's.
So: the timeline is the default because it is derived from the disc's own keyframes with one measured constant and no heuristic, and because it is proven right on the screen this milestone gates. On the title it is unverified, and P3 should not assume P2 settled it.
P2, corrected — the pin moved, and the settle rule was wrong, 2026-08-28
Answering the RE agent's question: which six, and on what screens
They asked, having found only two elements on the English main menu satisfying the condition this port proposed. The six span the whole 12-screen export:
| element | screens | trailing run |
|---|---|---|
ptframe1, ptframe2 |
main_menu, main_menu_jp |
alpha 0xff — visible |
pteff02 |
title, title_jp |
alpha 0x00 — transparent |
So four of the six are the pair they already found, once per language build, and
their alpha rule accepts exactly those. The other two are pteff02, whose
trailing run is transparent, so their rule excludes it and leaves rest at
0x40.
That exclusion is right, and their own measurement proves it. pteff02 is
the 25 % dim quad; they measured the title render going from +13.14 to +0.55
against the plate-free capture once the dim is drawn. rest must therefore stay
at 0x40 and must not move to the transparent trailing run — which is what
their rule does. Two investigations converging from opposite directions.
The condition this port proposed was too loose; the alpha discriminator is the correct rule and the port has no amendment to offer.
The pin moved 8b6dbcf → 5414db3
Its own commit, and what I wanted from it is the fixed ui_layout::rest().
Pinned at 5414db3 rather than 4bc9706 where the fix was written, because
5414db3 is where it carries its disc-wide check — 30 of 13 991 elements move,
4 become visible, 0 become invisible.
The re-export is the evidence the change was contained: two files changed, and
within them exactly four rest blocks — ptframe1/ptframe2 on both main
menus moving from (620,108)/(403,267) at t=16 and alpha 0x00 to
(440,108)/(583,267) at t=62 and alpha 0xff. Every diff line pairs; the
other ten screens are byte-identical, pteff02 did not move, and no sprite
changed.
The settle rule was wrong, and their title finding is what showed it
P2 shipped "hold the last timed keyframe", on the reasoning that the exit is
the final untimed frame. That is wrong, and the title is the counter-example:
pteff02 holds at t=46 with the dim at alpha 0x40 and then ramps to 0x00
by t=236. The exit is not only the untimed frame — it can be a long run of
timed ones. Running to the end drops the dim and makes the whole screen ~13/255
too bright, which is exactly the luminance excess P2 recorded (oracle 64.1,
rest 62.8, timeline 80.0) and could not explain.
A group is pre-roll → ramp in → hold → ramp out → post-roll, and a screen that
has arrived sits on the hold. So the timeline now plays in and stops at
rest, which is the decoders' identification of that hold and carries its own
t. settle_units() is rest.t.
The check is that the disagreement vanishes: on all twelve screens the
settled timeline is now byte-identical to the --pose=rest render, where before
this change four of them differed by up to 247/255. The timeline's endpoint
should be rest — the animation is what the timeline adds, not a different
destination — so this is the property to want, and it now holds without a
special case.
That also retires P2's open question about looping, from the other side: the RE
agent measured that groups hold rather than loop (ptloop01/ptloop02 park
off-screen at x=1521 and x=−839; 18 s of settled title sits at sd ≤ 0.01).
The reference renderer was stale for three diff runs
Worth recording as a process failure, because it defeated the project's whole verification method for a while and it failed silently.
After bumping the pin I rebuilt the reference CLI, and build-reference-cli
reported success at rev 5414db3. verify-screen then showed main_menu
jumping from 3/255 to 72/255. The natural reading — the port had regressed —
was wrong. The port was right and the reference was a revision behind: the
shared CARGO_TARGET_DIR still held a sylpheed-cli built from 8b6dbcf, and
cargo reported Finished in 0.13s and left it in place. Building into a clean
target directory produced a binary that resolves ptframe1 to (440,108) t=62;
the shared one still said (620,108) t=16.
The old check — "does screen list run?" — cannot catch this, because a stale
binary runs perfectly.
Two changes:
build-reference-clibuilds into$CARGO_TARGET_DIR/reference-cli/$rev, a tree keyed by the pinned revision, so a new pin has no artifacts to reuse. A stable copy is placed alongside for consumers.- It then checks the binary against
export/: both come from the same pin, so if the CLI resolvesptframe1's rest differently from what the exporter wrote, the two halves of the verification are not the same revision and it fails loudly. It compares the two rather than asserting a literal, so it stays true when the pin moves again.
docker/bin/ is baked into the image, so this takes effect on the next image
build; until then the repo copy has to be invoked by path. The RE agent hit the
same class of trap this session from the other side (./target/debug stale
against a redirected CARGO_TARGET_DIR). It is worth naming the general shape:
a build system reporting success is not evidence that the artifact you are
about to trust is the code you pinned.
What this did not change
title (6/255), extras (4/255) and title_jp (154/255) are unchanged, and
their diagnoses stand — a paint-order tie, two pixels, and nearest-neighbour
sampling phase at 125 % scale. The title's swoosh defect the RE agent localised
(drawn thick and white where the game draws it thin and pink) is untouched by
any of this and remains P3's.
The menu had no background, and P1 called that correct, 2026-08-29
The pin moved 5414db3 → f817dd5 for 56cc7ac, "a RATC child's name is stated,
not inferred". ratc::parse had named each child by scanning backwards for the
last printable run of bytes before its magic. For pteff05.t32 the three
trailing payload bytes are 38 41 58 — 8AX — which beat the real name, so the
child registered under a name no element declares and resolved to no sprite.
pteff05.t32 is the full-resolution background of all five menu screens.
So every render this port has produced of a menu screen has been missing its background, and P1 wrote that up as a property of the disc: "the bundle declares them and carries zero RATC children for either, so there is no texture on the disc to export." That sentence was false. The bundle carries the child; the decoder was handing back the wrong name for it. Retracted above rather than edited away.
What the re-export shows
Six new sprites and nothing else: pteff05.png on main_menu, extras and
their Japanese twins, pteff04.png on both titles. Per screen the JSON gains a
sprite line and layer_source moves "implied" → "sprite" — the layer key is
now read from the file instead of taken from the decoders' table of keys
measured off the running game. That is the derived/authored ratchet turning the
right way, in the exporter rather than in authored/.
pteff05.png is 1280×720; ptbase.png, which had been carrying the
background alone, is 640×360 drawn at 200 %. The screen was being shown its own
art at half resolution.
Measured against the live capture, not against the other renderer
Whole-frame RMSE of the settled main_menu against
captures/main-menu-oracle.png:
| RMSE | |
|---|---|
| before this pin | 8.05 % |
| with the real background | 5.92 % |
A 26 % reduction, and it is the right kind of evidence: the reference renderer was missing the same element for the same reason, so a renderer-vs-renderer diff could not have found this. It is the third time on this project that the capture caught something both renderers agreed on — the bracket, the title dim quad, and now the background.
verify-screen after the bump is unchanged in character: everything at 3–4/255
except title (6, the paint-order tie) and title_jp (155, the sampling phase).
Both renderers gained the background together.
One thing the comparison says that I did not expect
Rendering with --focus=ptbtn01, which is how the capture was taken, makes the
RMSE worse — 5.92 % → 7.00 %. The port replaces an element's sprite with
its *f twin; sylpheed-cli's own --focus is documented as drawing the
focused record over the base element. Those are different operations, and
the capture shows a ring marker beside NEW GAME that the port does not draw.
This is P5's, not P2's, and it is not being guessed at here. Raised in
docs/BLOCKED.md.
P3 — splash → title, unattended, 2026-08-29
The splash is located by entry index, because no rule can find it
The RE agent looked for a content predicate and there is none: design size fails
(every extra composable bundle sampled is 1280×720, the same as every screen) and
element count fails (fragments run 2…15 elements in GP_OPTIONS/GP_SAVE_LOAD
while the splash halves are 3 and 7 — the ranges overlap).
So screen_builds is now is_build plus an authored allow-list of entry
indices, in authored/screen_names.json under also_export, each with a why
that says it is a locator and not a claim. This is safe in GP_TITLE and would
not be in general: there, widening adds exactly four bundles and all four are
real screens with zero fragments. That is why it is an allow-list rather than a
loosened predicate.
There were two splash screens and the port had neither. Entries 11/14 are the developer logos (GAME ARTS / SETA / studio anima); entries 10/13 are the SQUARE ENIX publisher wordmark, the first thing the boot shows, and nothing in this project had noticed them. Both pairs are region twins — ™ on 10, ® on 13 — and the port shows one of each, not both.
authored/screen_names.json is now keyed by pak entry, not by ordinal
Widening the enumeration renumbers the ordinals, and a name that moves when the
enumeration rule changes is not a name. The file had always called the entry
"the stronger locator"; it is now the only stable one. In GP_TITLE the two
coincide across all 16 entries, which is also the numbering sylpheed-cli screen --build N --all takes — so verify-screen now passes --all, and without it
--build 10 would have landed on entry 12.
The two previously-unnamed plates therefore renamed build_10/build_11 →
build_12/build_15. Their names were always locators; now they locate the
right thing.
The exit is the group playing itself out, not a black rect over a freeze
HANDOFF's answer to ask 2 was (a), and it came with a test that discriminates
rather than a plausibility argument. Under "a black quad over a frozen screen"
every region is scaled by the same 1−α, so the button-region / background-region
brightness ratio stays constant through the fade. Measured, it falls
6.495 → 5.574 → 3.105 → 2.125 → 1.935 — a 3.4× monotonic drop. The screen plays
out: pteff00.prm ramps to opaque black while the labels, ptmsg, pteff10
and pteff12 ramp to transparent, and ptframe1/ptframe2 hold.
Implemented by giving the final untimed keyframe a synthetic time,
exit_ramp_units after the last timed one, and then interpolating it like any
other. One code path: the difference between arriving and leaving is only how far
t is allowed to run, not a second kind of animation.
exit_ramp_units = 24 (~0.4 s) is authored, and authored/timing.json carries
the RE agent's own reach caveat rather than smoothing it: the filmstrip is
downsampled and the button region contains some background, so this pins the
direction, not 0.4 s to ±0.05 s, and it is one transition pair.
Nothing waits on a timer the disc does not carry
dwell in authored/flow.json is deliberately empty. Each screen's dwell is its
own keyframe group — the publisher wordmark reaches its hold at t=235 (3.92 s),
the developer logos at t=190 (3.17 s), both read from the disc. Adding a hold on
top would be inventing a number nobody measured. The pacing you see is the
disc's own, and the file says where a measured number would go.
The last screen holds
A screen plays itself out because something is taking its place. Nothing takes the title's place yet, so the sequencer holds there. A boot that ends by fading to black is a boot that looks like it crashed. P4 puts the intro video in front of the title and P5 gives the title somewhere to go.
flow.json reproduces an observation and says so
Q6 closed with a negative: the order is in none of the four places it could have been, and a transition is a call with a name argument chosen by code. So this file is authored and its header says plainly that it reproduces what was watched, not what any file states. The intro video's place in the real boot is named as a gap rather than the order being quietly rewritten to hide it.
P3 gate
godot --path port -- --boot --film=/tmp/boot runs unattended:
publisher_logo → developer_logos at 4.65 s → title at 8.57 s
boot sequence complete after 13.05 s, holding on title
The filmstrip shows each screen fading in, holding, and fading through black into
the next, and the title staying up. verify-screen covers all 16 screens
now; the four new splash bundles come in at max 1–2/255 against the reference
renderer. The three known differences are unchanged: title 6 (paint-order tie),
main_menu 4, title_jp 155 (sampling phase at 125 % scale).
Answers taken from the RE agent without re-deriving them
- Focus stays "replace". Over-vs-instead is unobservable: the focused sprite
covers the base at 100 % of base-visible pixels, and the two compositions
differ by RMSE 1.1 inside the button rect — under the gamma floor. The port's
guess was right for the wrong reason, and the actual gap is that
ptbtn0Nf.ratdeclares two sprites —ptbtneff01.t32, a glowing ring, and then the bright label — whereptbtn0N.ratdeclares one. The ring is P5's, and its placement inside the record is not decoded, so it will be authored from the capture and marked as such. - RMSE against captures has a floor, so stop chasing it. The capture is
≈ 255·(render/255)^γwith γ ≈ 1.49 on the menu andEXTRAS, 1.34 on the title, and it is a ramp the game installed (VdGetCurrentDisplayGammaat video init), not a capture-path artefact to subtract. Its reach is narrow — the flat patches it was fitted on are almost all dark — so the port will not extrapolate it across the range, and will not apply it to rendered output on this evidence. It is a comparison constant, not a rendering one. - Rotation is escalated to a human and the port has not acted. The RE half is
answered — rotate about the declared pivot, measured against the GPU
capture — and it has zero effect on the five screens at rest. The port will
carry
rotation_degin a future FORMAT v3 because carrying a decoded field the renderer ignores beats dropping it, but it will not draw it until the divergence question is settled.
P4 — the intro video, 2026-08-29
Theora at 720p is fine here, and no runtime dependency is requested
MISSION §6 anticipated that Theora might be too poor at 720p and permitted the
FFmpeg-GDExtension fallback to be proposed. It is not needed, and this was
measured rather than judged by eye alone. SSIM against the decoded source over a
10 s sample: 0.9863 at -q:v 6, 0.9896 at 8, 0.9924 at 10. At 200 % zoom on
the reel's hardest case — fine serif text and soft gradients over near-black,
where Theora breaks first — q8 is indistinguishable from the source.
-q:v 8, and no GDExtension is being proposed or adopted.
-ac 2 because the source is 6-channel WMA Pro and Godot's Theora path is
not a surround one. That downmix is a decision, so it lives in the recorded
command where a modder can see and change it rather than in prose.
The exact command is in the manifest, per MISSION §6
export/manifest.json gains a videos array, each entry carrying the verbatim
ffmpeg line that produced it. A modder who dislikes the quality re-runs one
line instead of reverse-engineering what was done to their video — which is the
whole reason this project converts the disc rather than reading it at runtime.
A cache, and why that is not a hand-edit
export/ is regenerated wholesale, but re-encoding 232 s of video on every run
costs ~4 minutes to produce a byte-identical file, and an exporter nobody re-runs
is worse than a cache. So each movie gets a .cmd sidecar recording the command
and the source size, and the encode is skipped only when both match exactly. Any
change to either re-encodes. This is derived state validating derived state, not
a hand-edit.
The player renders into the design viewport, not beside it
First attempt parented the VideoStreamPlayer to the Boot node. It played, and
every captured frame was black: the capture reads the SubViewport, and the
player was rendering to the window. Worth stating as more than a capture bug —
everything this port draws composes in the export's own 1280×720 design space,
and a movie outside that space is outside the coordinate system every screen is
expressed in.
Ⓐ skips, because Q9 measured it
The only input the port handles so far. HANDOFF Q9: one Ⓐ press skips a movie, measured — the title was reached at 57 s against a 193 s baseline. Menu navigation is still P5.
P4 gate
godot --path port -- --boot --film=… runs
publisher_logo → developer_logos → ADV.ogv → title, unattended. The filmstrip
shows the SQUARE ENIX ident, then the reel's live-action-styled CG, then the
title. The movie's place in the boot is measured, not decoded — Q9 decodes
ADVERTISE_MOVIE → ADV.wmv from the movie manifest, but where it sits in the
boot order is what the RE agent watched, and authored/flow.json says so.
What I cannot verify from here
Audible playback. This container has no audio device — Godot falls back to the dummy driver. What is verified is that the Vorbis stream exists in the transcode, is 2-channel, and decodes. Whether Godot emits it audibly is unconfirmed and is stated as unconfirmed rather than assumed from the stream's presence. It is a cheap check for anyone with a sound device and an impossible one here.
RETRACTION — sylpheed-cli is not the oracle, 2026-08-29
This corrects a framing that runs through everything above, so it is a
retraction rather than an edit. Every place this file called
sylpheed-cli screen render "the reference renderer" — and it does so
repeatedly, starting at P1 — overstated what it is.
The correction comes from the human, via the RE agent, in their words: Reborn "was/is just a GUI explorer and extraction CLI for verifying the decoding of the various files. It may very well be wrong." The oracle is the Xenia Canary capture and the game.
So tools/verify-screen is a consistency check between two decoders that
share their assumptions, and a regression detector. It is not a correctness
check, and agreement in it is not evidence of correctness.
The embarrassing part is that this file already knew
After the ptframe1 case, P2's write-up says: "Two renderers reading one field
through one decoder agreeing is not evidence that the field is right." Then P1's
numbers kept being quoted as though 3/255 against sylpheed-cli meant the port
was right. Having the principle written down did not stop me leaning on the
agreement — which is worth recording, because that is the failure mode, not
ignorance of the principle.
Three times both renderers agreed and both were wrong, all three caught by a capture and catchable by nothing else:
| what both got wrong | how it surfaced | |
|---|---|---|
pteff05 |
the menu screens had no background | the RE agent decoded the RATC child name |
| scale 0 | drawn at full size instead of collapsed | RE agent's control run |
rest() |
ptframe1/ptframe2 invisible; the menu bracket missing |
main-menu-oracle.png |
What changes
tools/verify-screensays all of this in its own header, calls the CLI the comparison renderer, and aDIFFERSrow now means "we moved apart, find out which of us moved" rather than "the port is wrong".- The correctness question moves to the captures. The RE agent has committed
nine of them with an index at
docs/re/captures/ORACLE-CAPTURES.md, covering all five screens in scope — including a main menu withOPTIONSfocused, whose difference from the unfocused menu isolates exactly what focus changes. - Three cautions travel with any capture comparison, and they are the RE agent's: the captures are not gamma-neutral (γ ≈ 1.49 menu, 1.34 title — there is a floor, do not chase it); geometry is sound (best alignment 0,0 at corr 0.9466, so a positional disagreement is real); and each is one moment of a still-animating screen, so compare settled poses or regions known to be at rest.
What does not change
The port keeps running verify-screen over all 16 screens every iteration. A
consistency check is still worth having — it is total, it is cheap, and it is
what catches a divergence the RE agent introduces on their side. It is simply
not a grade, and this file will stop quoting it as one.
P5 groundwork — the focus record, checked against a capture, 2026-08-29
P5 is the lowest unfinished milestone (P0–P4 are gated above). This iteration did not implement navigation; it did the measurement P5 needs first, because the one thing P5 is built on — how a focused button is drawn — had three claims attached to it and none of them had been checked from this side.
The exporter already emits the focus record's second element
HANDOFF ask 3 answers with a 🔴: "what you are actually missing is the focus
record's SECOND element" — ptbtneff01.t32, a 42×46 glowing ring, declared
before the bright label in ptbtn0Nf.rat.
That gap is in the renderer, not the exporter. export/screens/title/main_menu.json
already carries both, in declaration order, under focus.elements, for all five
buttons — ptbtneff01 then ptbtn0Nf, each with its own pivot, rest pose and
keyframes. Nothing needs to change in crates/sylpheed-export for the ring. What
is missing is that screen_view.gd draws only one sprite per focused button.
Recording this so P5 does not re-open the exporter looking for it.
The ring's size checks out exactly: ptbtneff01.png is 42×46, as stated.
The (7,7) focus offset survives a refutation attempt, uniquely
Ask 3 states the focused sprite covers the base at 100.0 % of base-visible pixels "once aligned properly (the true offset is (7,7), and at the centre alignment it reads a misleading 78–84 %)". P5 builds directly on this, so it was worth attacking.
Re-ran the RE agent's own metric on the exported PNGs — over every pixel where the base sprite is visible, the fraction where the focus sprite's alpha ≥ the base's — scanning the whole offset space, not just the stated answer:
| alignment | ptbtn01 | ptbtn02 | ptbtn03 | ptbtn04 | ptbtn05 |
|---|---|---|---|---|---|
| (7,7) | 100.00 % | 100.00 % | 100.00 % | 100.00 % | 100.00 % |
| geometric centre | 80.58 % | 79.20 % | 79.58 % | 79.45 % | 79.45 % |
| pivot-to-pivot | 80.58 % | 86.59 % | 87.40 % | 79.45 % | 84.58 % |
The refutation fails, and more strongly than the original claim. Over a
15×14 offset scan, (7,7) is a unique isolated cell at 100 % on every one of
the five buttons — every neighbouring offset, including (6,6) and (7,6), falls
below 90 %. The centre and pivot alignments reproduce the 78–84 % band the RE
agent reported as misleading. A claim that survives a search of its whole
parameter space is worth more than one checked at a single point, so this is
recorded as strengthened, not merely unrefuted.
(7,7) is not a constant to apply — it is what the declared positions already say
An earlier version of this analysis had the port disagreeing with the capture by
6 px. That was my arithmetic error and it is worth writing down, because it is
the mistake this format invites: I computed each element's top-left as
pos - pivot, which gives base→focus deltas of (13,13) and a 29 % coverage — a
confident wrong number.
pos is the top-left. screen_view.gd:121 is
Rect2(pos - pivot*(s - 1), natural*s): the pivot is the anchor scale grows
about and it cancels at 100 %, which is exactly the "can be got wrong
invisibly" the comment there warns about. Getting it wrong invisibly is what
happened.
With pos as the top-left, base − focus is (542,162) - (535,155) = (7,7)
directly, on four of the five buttons. So P5 draws each focus element at its own
declared pos and needs no offset constant at all. Nothing to author.
The one real find: ptbtn04 is 1 px off the grid on the disc
The focus records sit on a clean 80 px pitch — 155, 235, 315, 395, 475. The
base records do not: 162, 242, 322, 401, 482, i.e. spacings 80, 80, 79,
81. So ptbtn04's declared base→focus delta is (7,6), while the art
itself aligns at (7,7) — the coverage scan puts ptbtn04 at 100 % on (7,7) and
below 90 % on (7,6), the same as every other button.
This is 1 px of authoring jitter on the disc, not a decode error, and it has one
consequence worth stating: do not derive the focus placement from the base by a
constant. Draw the focus record at its own declared pos. A port that
"simplified" this to base + (7,7) would put ptbtn04's focus art 1 px off, and
would look right on the other four.
Verified against a capture, not against our other renderer
Diffing two oracle frames isolates what focus changes without any instrument in
the path: live-main-menu.png vs live-main-menu-options-focused.png differ in
one tight cluster of 6 338 px at x 506..702, y 398..445. ptbtn04 is the
OPTIONS button, and the union of its focus record under the pos-as-top-left
reading — ring ptbtneff01 at (500,396) 42×46 plus label ptbtn04f at (535,395)
172×56 — is x 500..706, y 395..451. Those agree on all four edges to within a
few px of near-transparent sprite border.
Under the pos - pivot reading the same record predicts x 433..604, y 367..422,
which matches nothing in the capture — and no button matches that cluster. The
capture, not our renderer, is what settles it.
An instrument that failed its own control, and was therefore discarded
To locate the buttons independently I wrote a masked normalised-cross-correlation template matcher and ran it as PROTOCOL requires — through a control first: match each base sprite against the plain capture, where the declared position is known and the answer must be a (0,0) delta.
It returned deltas of (13,5), (6,−19), (12,21), (−16,22), (6,8) at NCC 0.096–0.206 — noise, with three of five pinned to the ±22 search boundary. The control fails, so the instrument is dead rather than tuneable, and none of its output is used above. The button art is dark, low-contrast and further crushed by the capture's γ ≈ 1.49 ramp, which is the likely cause; a matcher for this corpus would have to work on gradients rather than luminance. Filed so the next iteration does not rebuild the same broken tool.
What P5 still needs, and has not got
- Initial focus is not stable across boots (Q5: 2×
TUTORIAL, 2×NEW GAME). That is a value to author, with awhynaming Q5 — it is not written yet. - The ring's own animation is unread.
ptbtneff01's two keyframes gorotation_deg0 → 360 at t=120 with no second timed keyframe, i.e. a full turn. Whether it spins continuously while focused, or turns once and holds, is the group-loop question again — answered "groups hold" for build-in animations, but a 360° hold and a 0° hold are the same pose, so this group cannot be told apart by its rest pose. Not guessed; raised below.
P5 — navigation, 2026-08-29
The gate is "a human clicks through it", and the artifact is a walk that
proves the wiring rather than the intent: up (which wraps 01→05), five down,
Ⓐ into EXTRAS, down, Ⓑ back — landing on the main menu with focus restored
to EXTRAS.
xvfb-run -a godot --path port -- --menu \
--script=up,down,down,down,down,down,accept,down,cancel --shots=/tmp/p5
Ten PNGs, one per step, each taken after the screen it produced had settled.
Contact sheet handed over as share id 1788002507-ef4468a0a33a.
The scripted walk goes through the input system, not around it
--script posts InputEventAction through Input.parse_input_event and lets
it arrive at _unhandled_input exactly as a d-pad's press would. Calling
MenuFlow.move()/accept()/cancel() directly would have been shorter and
would have proved nothing: the thing most likely to be broken is the wiring
between a press and the cursor, and a direct call is precisely the part that
skips it. The same reasoning says the settle wait must be real — a shot taken
before the screen stops moving photographs a fade and calls it a menu.
What is authored here, and what is derived
Split deliberately, because P5 is where the two are easiest to blur:
| where | why | |
|---|---|---|
| the ORDER of the items | derived — each screen file's buttons, filled by the exporter from the button-role elements sorted by resting Y |
it is on the disc |
| where an item goes | authored — authored/flow.json |
HANDOFF Q4 measured the destinations; they are not in the file |
| which item opens focused | authored | Q5 measured that it is not stable boot to boot |
| what Ⓑ does | authored | Q5, measured — except on the main menu, see below |
| ⬅➡ do nothing | authored, written as an explicit no-op | so that "the game ignores it" and "we never wired it" are different lines of code |
Four of the five main-menu destinations are goto: null with a blocked note.
That is not an unknown: DIFFICULTY, the save-slot list, the lesson list and
the settings menu were all measured, and they live in archives this export does
not carry. blocked and none are kept apart so a later reader does not
"discover" a gap that was a milestone boundary.
EXTRAS is the only main-menu destination inside GP_TITLE, and therefore the
only Ⓐ-into-a-submenu this gate can actually walk.
The one navigation rule with nothing behind it
Ⓑ on the main menu → title. HANDOFF Q5 states it, and flow.json marks it
authored — likely but UNPROVEN, because the title also self-returns after
~8–10 s idle and a single unrecorded observation cannot separate the two. The
port implements it anyway — a menu with no way out is worse than a menu with a
plausible one — and says in the file that it did. Asked of the Decoder this
iteration; see BLOCKED.md.
Independent corroboration that the main menu is different from its submenu:
the main menu's footer advertises only Ⓐ : OK, while EXTRAS' footer
advertises Ⓑ : Back. That is on the disc, in ptmsg.png vs ptmsg2.png, and
it is visible in both the port's render and the captures.
A press during a fade is dropped
Authored, and not measured. Nobody has watched what the game does with a
button pressed mid-transition. Dropping invents less than queueing does: it
cannot manufacture a press the game might have discarded. flow.json says so
under navigation.input_during_transition.
--headless cannot draw, and the port hung instead of saying so, 2026-08-29
docs/port/PORT-MISSION.md and the loop prompt both name godot-headless as
how this project runs unattended. It does not work, and the way it failed was
the worst available shape.
Measured, not assumed. Under --headless Godot's dummy renderer never emits
RenderingServer.frame_post_draw. Every capture path in boot.gd awaits it —
--capture since P1, --film since P3, --shots as of this milestone — so all
three blocked forever. Isolated by the difference between two runs:
godot --headless --path port --quit # prints, exits 0
godot --headless --path port -- --screen=… --capture=… # no output at all, killed at 40 s
The second produces zero bytes of output before it is killed, because Godot's stdout is block-buffered and never flushes. So the observable behaviour of an unattended headless capture was: silence, forever. In a loop, a job that waits reads as a job still working — this is the failure mode that costs a whole iteration and leaves nothing behind to say what happened.
Two changes, and deliberately not one:
--capture,--filmand--shotsrefuse at startup under--headless, naming the flag and printing thexvfb-runline that does work. Refusing early rather than at the first frame means the run does not die halfway through a filmstrip with some frames written.--scriptno longer waits for a drawn frame when it is not going to photograph one. Navigation is checkable where nothing draws, and that is worth keeping:godot --headless --path port -- --menu --script=…now walks the menus and exits 0 in about four seconds, which is a cheap regression check that needs no X server at all.
The Xvfb path is unchanged and is what produced the P5 artifact.
Refutation — the focus ring IS drawn rotated, and it is not at 0° in either capture
Attempted against the Decoder's 7eeae30 ("re(ui): the focus ring SPINS, the
game draws it, and the leaf owns the f record"), point 2: that in the
OPTIONS-focused capture the ring's bright head sits in a different angular
position from the sprite's own, caught mid-spin. It survives, and the
evidence is stronger than what was claimed.
Chosen for refutation because it is exactly what PROTOCOL says to aim at: a claim the port is about to build on, resting on an estimator (a brightest-region centroid) whose own control the Decoder reported as ±19.8°.
The test, and why it needs no absolute registration
live-main-menu.png has ptbtn01 focused; live-main-menu-options-focused.png
has ptbtn04 focused. Both draw the same sprite, ptbtneff01.png — the
export confirms the two focus records name the same file. So the two captures
contain two instances of one 42×46 image, 240 px apart in design space, and the
question "is it drawn rotated" becomes "are these two crops the same image
at a different angle" — which needs no crop offset and no reference to our own
renderer.
Method: sample each ring into a 360-bin angular luminance profile over the annulus band (r = 9…15 px, bilinear, 0.5 px radial step) and circularly cross-correlate. A rotation about the centre shifts that vector and changes nothing else.
The instrument was run through two controls before it was believed
| control | result |
|---|---|
| rotate a capture's own ring by a known 0/30/90/150/210/270/330° and recover it | 0° error on all seven, peak corr 1.000 |
| the same estimator on a ring-free 60×64 patch of the same capture | peak corr 0.369 — it does not manufacture a match |
The measurement
On one shared centre for all three images, so a centroid difference cannot masquerade as a rotation:
| pair | best shift | peak corr | corr at 0° |
|---|---|---|---|
| capture A vs capture B | 134° | 0.968 | −0.064 |
| sprite (unrotated) vs capture A | 76° | 0.969 | −0.295 |
| sprite (unrotated) vs capture B | 210° | 0.948 | −0.181 |
210 − 76 = 134: the three measurements are internally consistent, which nothing in the method forced them to be. Sweeping the centre by ±2 px moves the A-vs-B answer over 117…161° while the peak correlation stays 0.9+ across the middle of that range, so the magnitude is ~134° ± ~15° and the precision claim stops there.
Evidence sheet — sprite, capture A, capture B, each cropped at the declared
42×46+500+156 / +500+396 — handed over as share id
1788002507-afe1ad843789. The phase difference is obvious by eye; the numbers
are here so it is not only obvious by eye.
The two things this settles for the port
-
The game draws
rotation_degon an element the English boot path shows. This is a second, independent confirmation on a different screen and a different element from theptloopsweeps, and it moves HANDOFF ask 4 (should the port draw rotation) off "changes nothing at rest" — it changes the main menu's focus marker, in every frame. -
0° is not a pose the running game shows.
screen_view.gdcurrently draws the ring at itsrestpose, which isrotation_deg 0, and both captures put it at 76° and 210°. So the port's focus marker is known to be wrong, not suspected — and the comment inscreen_view.gdnow says which two numbers it is wrong against.
Registration, as a by-product
The ring's annulus centroid lands at (32.94, 36.63) and (33.30, 38.90) in
windows whose design-space prediction under a zero crop offset is
(33.0, 37.0). Within ~0.4 px on the better-thresholded of the two. That
corroborates ORACLE-CAPTURES.md's "1279×675, top-left aligned" directly, on
a feature nobody chose for the purpose.
⚠️ Do not read the earlier P5-groundwork note "button text bands land at design y + 23" as a crop offset — it is an offset within the button sprite, and the two were nearly confused here.
What the port did NOT do about it
It did not start spinning the ring. The period is a guess with two unknowns and both belong to the Decoder:
- the keyframes are
t=120, rot 0then an untimedrot 360. Under HANDOFF Q1's replicated reading ("+36is the time the NEXT pose is reached") that is one revolution in 120 units = 2.0 s — but this port'spose_atimplements the other reading, and switching it is a change to every screen's animation timing, not a P5 change; - "groups hold" (settled 2026-08-28) predicts the ring stops at 360° = 0°. Both captures show it elsewhere. That is either a spin that loops, or two captures both taken inside the first two seconds of focus. The port cannot tell those apart, and a wrong answer here is a visible continuous rotation on whichever button the player is sitting on.
Filed in BLOCKED.md and asked over the message channel. What settles it is two
frames of one focused button a known time apart.
P5 end to end — and the title does not say PRESS Ⓐ, 2026-08-29
The gate walk above starts on a screen. This is the whole thing, unattended, in one run — the sequence PORT-MISSION names as the objective:
xvfb-run -a godot --path port -- --boot --play \
--script=accept,down,down,down,down,accept,cancel,cancel --shots=/tmp/e2e
screen publisher_logo … settles at t=235 (3.917 s)
-> developer_logos at 4.70 s
-> video ADV at 8.60 s
video ended at 151.91 s
-> title at 151.91 s
boot sequence complete after 156.30 s, holding on title
menu on title
script[1] accept (A) -> main_menu
…
script[6] accept (EXTRAS) -> extras
script[7] cancel (B) -> main_menu focus restored to ptbtn05
script[8] cancel (B) -> title
script complete after 166.76 s on title
Publisher wordmark → developer logos → ADV → title → Ⓐ → main menu →
navigate → Ⓐ → EXTRAS → Ⓑ (focus restored) → Ⓑ → title. Contact sheet shared.
Two smaller things this run found, both fixed here:
- the boot step's
whyinauthored/flow.jsonstill said "nothing takes the title's place until P5 gives it somewhere to go". P5 has. Rewritten to say what is actually true —--bootstill stops on the title, and--playhands the held title over; the stop is not a bug and the handover is not another boot step. - an empty focus printed as a line that trailed off, which reads like a value
went missing rather than like there is none. The title is a screen with no
buttonsthat still takes Ⓐ, so it prints(none -- this screen has no focusable item).
Also confirmed on the way: entering a submenu directly (--menu=extras) and
pressing Ⓑ enters the parent at its authored initial focus, not at a restored
one — there is no history to restore, and MenuFlow.cancel only claims a
restored focus when the stack agrees about where it is going.
🔴 The port's title does not tell the player to press Ⓐ
Found by running the objective end to end, which is the only thing that would
have found it: the boot's last step is title (build 4), and build 4 has no
PRESS Ⓐ BUTTON plate. P5 has now made Ⓐ the only way off that screen.
This is not a guess about the art. Both states are captured off the running game and they differ by exactly that plate:
| capture | |
|---|---|
| title without the plate | title-builds/live-title-build4-no-plate.png |
| title with the plate | title-builds/live-title-press-a.png |
And the plate is already exported — press_start, GP_TITLE build 2 (HANDOFF
Q2), sitting in export/screens/title/ unused by anything.
This is P3's gate, not P5's, and P5 is what exposed it. Recording rather than fixing, for two reasons:
- Which state an idle post-boot title shows — build 4 alone, build 4 with the plate over it, or build 4 then the plate after a delay — is behavioural, and the port has no oracle for a sequence. The game demonstrably has both states; nothing here says which one follows the intro movie. That is the Decoder's.
- Showing it would mean drawing two builds at once, which this port has
never done — every mode loads exactly one screen. That is a real change to
ScreenView, not a line inflow.json, and it should not be smuggled in under a navigation milestone on the strength of "it looks more right".
Filed in BLOCKED.md. Not blocking: P5's gate is Ⓐ into a submenu and Ⓑ back,
and both work.
P6 — menu audio, 2026-08-29
The disc's menu sound reaches Godot as Ogg Vorbis: three cues and one music bed.
Nothing in port/ has heard of XMA, sound.pak or Static.slb, and nothing in
it reassembles anything — sylpheed_formats::media does that and the exporter
converts what it hands back.
The cue offsets moved OUT of the exporter, into authored/
The previous iteration left crates/sylpheed-export/src/audio.rs holding the
three Static.slb offsets as a Rust const CUES. That is wrong under MISSION
§3 and the fix is the first thing this iteration did.
Those offsets are measured, not decoded. Static.slb has no RIFF, no seek
chunk and no container: it is a packed run of whole 2048-byte XMA1 packets, and a
wave is defined only by (offset, packet_count). Both numbers came from the
running game — Canary with --xma_param_probe=true prints a stream's packet
count and first 32 bytes when it is played, and searching those bytes in the bank
gives the offset (HANDOFF Q8).
A measured value compiled into the exporter is a measurement wearing the
costume of a decoded field. It reads as though the exporter derived it from the
disc; nobody deletes it when the real answer lands, because nobody can see that
there is anything to delete. So the table is authored/audio.json se.*, each
row carrying its own why, and the exporter holds no cue table at all.
crate::video::MOVIES stays a const in the exporter, and the contrast is the
point: Q9 decoded that mapping off the movie manifest on the disc. Same
shape, different provenance, different home.
name_match is a field, and its absence means something
Q8 names SE_UI_CURSOR for the move cue by name match against the authors' own
identifiers — a plausible guess, not the measurement. For Ⓐ, Q8 is explicit
that the wave was not separated between SE_UI_DECIDE and
SE_UI_SUB_WIN_OPN, so no name is claimed at all.
name_match therefore travels beside every cue in authored/audio.json and in
manifest.json, and an absent one means nobody claimed a name — never that
the binding is unknown. The binding is the measured part. Collapsing the two
would turn "we did not separate two candidates" into "we do not know what this
sound is", which is a different and much weaker statement than the one the RE
agent actually made.
The BGM is NOT a choice, and this port spent an iteration believing it was
The first draft of authored/audio.json picked BGM_001, wrote a careful why
explaining that the choice was arbitrary, and was wrong.
docs/port/BLOCKED.md carried the row that caused it: "not on the disc … the
port is choosing a track, and that choice is authored." The menu's music is
BGM_103, and it is in HANDOFF at 9ca1eb5 — the exact commit that page
says it was reconciled against. So this was not staleness. The row was wrong
when it was written.
What HANDOFF says is a negative with a bound, and the bound is the entire content of it:
the tables cannot say —
SOUNDS,FILESand the bank headers name no screen.GamePart_Title's phase handlersub_821C5580carriesli r5, 1103into a sound call; cue 1103 isBGM_103; andBGM_103.slb's two declared waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA probe saw decoding at the main menu. Static code, disc census and runtime all agree. "The port does not have to choose a track."
The failure is worth naming precisely, because "read HANDOFF more carefully" is
not the lesson — BLOCKED.md's own staleness check passed, twice, and would pass
again. A negative summarised without its reach reads as a bigger negative than
it is. "The tables cannot say" became "it is not on the disc", and one word of
scope was the whole answer. A row in BLOCKED.md must quote the reach.
It also cost a second thing worth recording: the port would have shipped a menu
playing the wrong music with a confident why beside it saying the choice was
deliberate. That is exactly the shape of error this project's vocabulary exists
to prevent, produced by the machinery meant to prevent it.
The bank name carries .slb, and that is how the mistake surfaced
BGM_001 is not in sound.pak. BGM_001.slb is — media::read_sound_bank
looks up name_hash(name) against the TOC, and the TOC hashes the file name.
Static.slb worked from the first run only because the RE finding happens to
write it with its extension.
So the wrong track never played: the export failed loudly with "BGM_001: not
present in sound.pak". That is luck, not design — had the draft picked a name
that happened to resolve, nothing would have complained. The why in
authored/audio.json now records both the correct name and why the short form
fails.
export_bgm now distinguishes the two cases it was conflating. A bank that is
not in this disc's sound.pak is a missing asset: the manifest takes a
warning and everything else still exports. Any other failure — a short read, a
malformed bank — still stops the run, because a partly-read bank produces a file
that plays.
The two stems are summed. That part is not a choice
Q10 also measured that a bank's sub-waves are two stems of one performance, played together — sample-synchronous, equal duration, on all 32 banks. Concatenating them is explicitly wrong.
Emitting them as two files would be wrong for a second, independent reason:
MODDING rule 1 is one logical asset, one file, and handing a modder two stems
to line up by hand is precisely the reassembly the exporter exists to have
already done. amix=normalize=0 sums at unity rather than halving, because
halving is a mix decision nobody made — and because a sum can clip, the peak is
measured and reported rather than silently corrected.
The loop seam is ugly on purpose
No loop-point field has been identified. loop: "restart" replays from sample 0,
so a listener hears the track's own fade-out and its trailing silence before the
music comes back.
Trimming to the fade would sound better and would be worse. It would invent a loop point, and an invented one is indistinguishable from a decoded one a month later — which is the failure mode this whole project is organised against. The seam stays audible until a loop point is measured or a capture of the real menu looping settles it.
When a cue fires — two rules measured, one authored
- Move fires on a press that actually moves the cursor.
MenuFlow.move()already returned whether it did, which is why left/right stay silent by construction rather than by a rule written twice (Q5: ⬅➡ do nothing, and Q8: they play nothing). - Ⓐ and Ⓑ fire when the press does something, and not when nothing is
bound. 🟡 This half is authored and NOT measured — nobody has watched the
game take a dead press. Silence invents less: a sound the game does not make is
a wrong fact you can hear, while a missing one is a gap.
blockedcounts as doing something, because those destinations were measured off the running game and are missing from this export, not from the game. - The bed starts when the menu becomes live and carries across submenus.
play_bedis idempotent, because music that restarts every time you press Ⓑ is the kind of wrong that reads as "the audio works".
--audio= records the Master bus, because neither container has a sound card
docs/port/AUDIO-VERIFICATION.md §2. An AudioEffectRecord on the Master bus
captures the mixed output from inside a headless run with no device at all, and
that is the only thing that closes the loop the file opens: comparing an exported
Ogg against the disc proves the asset is right and says nothing about whether
the engine ever reached it.
The run prints AudioServer.get_driver_name() beside the file it wrote, because
"recorded under a dummy driver" is a weaker claim than "heard" and the write-up
has to be able to say which one it is making.
The WAV is saved in _exit_tree rather than beside each quit(). There are
eight of those, and the one that would get missed is an error path — exactly the
run whose audio somebody wants to look at.
check now refuses silence and clipping
sylpheed-export check gained an audio pass, and two of its rules are content
checks rather than schema checks. That is deliberate. Silence is the audio
failure that looks like success — a file of the right duration, the right channel
count and the right size, full of zeroes — and it passes every structural check
there is. Clipping is the other one, and the BGM can produce it because it is a
sum at unity gain. The exporter measures both at export time; check refuses a
tree whose peak is ≤ −90 dBFS or ≥ 0 dBFS.
Neither is a judgement about whether the audio is the right audio. Nothing in
that binary can know that, and BLOCKED.md says which parts are still authored
guesses.
A bug worth naming: the temp name ate the file extension
run_ffmpeg wrote to .back.ogg.partial — the temp-name-then-rename discipline
this project uses everywhere, and which AUDIO-VERIFICATION.md records as
already having caused a confident wrong number once.
ffmpeg picks its muxer from the output filename, so that is not a slightly
uglier temp name; it is a hard failure before a byte is written: "Unable to
choose an output format for '.back.ogg.partial'". video.rs already had the
right shape (.ADV.partial.ogv) and this function was written from scratch
without looking at it. The extension goes last.
Refutation — the three Q8 cue durations, checked end to end
The claim: HANDOFF Q8 publishes three cue lengths — move 0.533 s (8 192 B, 4 packets), back 0.344 s (4 096 B, 2), confirm 1.016 s (12 288 B, 6). P6 is built directly on top of these, which by PROTOCOL's own rule makes them the right thing to attack: refutation is cheapest where the other agent is most confident, and most valuable where the port is about to build.
Why they looked attackable. The three do not share a rate. Seconds per packet is 0.133, 0.172 and 0.169 — the move cue is 22 % off the other two. If a packet were a fixed span of audio, at most one of these numbers could be right.
Why that is not a refutation. An XMA1 packet is 2 048 bytes of bitstream, not a fixed span: it carries a variable number of 512-sample frames. At 48 kHz a frame is 10.667 ms, and the three durations come to 50.0, 32.3 and 95.3 frames — near-integers, which is what a variable-frames-per-packet encoding looks like and is not what an arithmetic slip looks like.
The measurement. The exporter reads (offset, packet_count) through
media::se_wave_riff, decodes, and ffprobes the finished Ogg:
| cue | Q8 claims | exported file measures |
|---|---|---|
| move | 0.533 s | 0.533 s |
| back | 0.344 s | 0.344 s |
| confirm | 1.016 s | 1.016 s |
Verdict: survives, exactly, at every published digit. Recorded as a survival rather than a pass, because that is what PROTOCOL asks for — a claim that has survived an attempt is stronger than one nobody challenged, and the corpus should say which it is.
⚠️ Reach, stated so nobody over-reads it. This is not independent of Q8: the
durations were derived from the same packet counts the exporter feeds in, so what
it confirms is that reading those (offset, packets) through
sylpheed_formats::media yields streams of exactly the claimed length — i.e.
that the transcription into authored/audio.json and the assembly path are
right. It does not confirm that these three waves are the sounds the game
plays on those three events; that is Q8's own measurement, taken by playing them,
and this port has no oracle to re-take it with.
The attempt did find something, just not here: see the BGM section above, where
the port's own BLOCKED.md row failed the same kind of check.
The BGM bank has three sub-waves and HANDOFF says it has two
media::sound_bank_riffs("BGM_103.slb") returns three. HANDOFF Q10's census
says a music bank is "exactly two waves of identical duration (32/32 banks on
the disc)" — and that census is itself a correction, of an earlier reading that
called BGM_001 three sub-waves and was refuted with "the 10 KB is the bank
header".
The third comes from sylpheed-formats/src/slb.rs:380, to_xma_riffs: when a
bank has a leading headerless packet region ahead of its first RIFF, that
region is emitted as a sub-wave. It exists because the voice path needs it —
VOICE_D_453 decoded to 0.14 s without it. docs/re/REFUTED.md already records
the same region as what makes BGM_106–BGM_109 "break the two-wave rule".
The port sums all three and says so in the manifest. That is not the
appealing answer — dropping sub-wave 0 would give a file matching the census, and
it would have been one line. It is the correct one: which bytes belong together
is the question sylpheed_formats::media owns, MISSION §2 names re-deriving it
here as the single easiest thing in this project to get subtly wrong, and "the
decoder returned something the corpus does not predict" is a finding to report,
not a number to quietly adjust. Adjusting it would also have destroyed the
evidence: a corrected export looks exactly like a correct one.
So the export ships the decoders' answer, the manifest carries a warning naming
the contradiction, BLOCKED.md has the row, and the Decoder has the pointer.
Until it comes back, the menu plays a sum of three things where the census
predicts two, and every one of those places says so.
Clipping — and a comment of mine that argued for the thing that clipped
The BGM came out at +1.8 dBFS. The comment above the code that produced it
said amix=normalize=0 sums at unity "because halving is a mix decision nobody
made".
That was wrong in both halves. Unity summing is a decision, and it is the one
that clips. And 1/n is not a taste call: it is the smallest constant that makes
an n-input sum of unity-scale signals provably clip-free, which is precisely the
reasoning video.rs already carried for its 0.4142-normalised 5.1 downmix — in
this same repository, written by this same port, and not looked at. It preserves
the stems' relative balance exactly, which is the only thing about the sum that
Q10 settles.
It is written as an explicit volume= rather than left to amix's
normalize=1 default, so the coefficient appears in the manifest's command line.
A default is a decision nobody made and it can move under an ffmpeg upgrade —
the same argument MISSION §6 makes about the downmix matrix.
The confirm cue is a different case and is not "fixed". It lands at
+0.18 dBFS, and it is a single wave off the disc with no arithmetic of ours in
it: the disc masters it near full scale and a lossy decode of a near-full-scale
signal overshoots by a fraction of a dB. Attenuating it would mean altering a
game asset to make one of our own numbers smaller. So check bounds the two
kinds differently — a bgm peak ≥ 0 dBFS is refused outright, because it is our
sum; an se is refused only above +1.0 dBFS.
🟡 That +1.0 is a judgement and not a measurement, and it is the weakest number in P6. Nobody has measured the overshoot distribution across a corpus of cues. If a cue ever trips it, the right response is that measurement, not a looser bound.
P6 gate — the audio is in the mix, and a null control says which part
No container here has a sound card, so "P6 works" cannot be answered by
listening. docs/port/AUDIO-VERIFICATION.md splits the question into three, and
these are the two that need no device.
1. The exported files, measured off the finished assets
se back -> audio/se/back.ogg (0.344 s, peak -5.7 dBFS)
se confirm -> audio/se/confirm.ogg (1.016 s, peak +0.2 dBFS)
se move -> audio/se/move.ogg (0.533 s, peak -1.4 dBFS)
bgm main_menu -> audio/bgm/main_menu.ogg
(87.744 s, peak -7.7 dBFS, bank BGM_103.slb, 3 sub-waves)
sylpheed-export check export passes: 16 screens validate, and every audio entry
carries a peak and a duration inside its bounds. The three cue durations match
HANDOFF Q8 at every published digit — see the refutation record above.
2. The engine, recorded off the Master bus
godot --path port -- --menu --script=down,down,accept,cancel --audio=…/p6.wav
→ recorded 6.037 s of Master bus (driver Dummy)
peak 0.0 dBFS, RMS −21.1 dBFS
Non-silent is not the claim. A WAV of the right duration full of the bed would look exactly like this, and the cues could be missing entirely. So the cue was isolated with a null control: the same scripted walk with ⬅ in place of ⬇. Left/right are measured no-ops (Q5) and fire nothing, so the two runs differ by exactly two move cues and nothing else — same screens, same transitions, the same Ⓐ and Ⓑ cues in both, the same bed.
| RMS | |
|---|---|
| walk with two ⬇ presses | −21.9 dBFS |
| walk with two ⬅ presses (null) | −22.1 dBFS |
| difference | −34.6 dBFS |
The difference is not spread over the run. It is one burst beginning at t = 1.10 s and lasting 0.55 s — two overlapping 0.533 s move cues — with 22 of 237 windows above −70 dBFS and silence everywhere else, including across the Ⓐ and Ⓑ presses, which cancel because both runs make them. That is the cue reaching the bus, separated from the music that was playing over it.
The control that proved nothing, kept because it nearly passed
The first attempt paired --script=down,down against --script=left,left. The
difference was bit-identical zero, which reads as "the cues never reached the
bus" and would have been reported as a bug.
It was neither. Both runs recorded 1.115 s while the first press lands at ~1.17 s: the control ended before the event it was controlling for. A null result from an instrument that was not running is not a null result — PROTOCOL's "run your own instrument through a control" applies to the control too.
What this does NOT establish
- That it sounds right. Everything above is correspondence and separation, not judgement. A ten-second human listen still answers something no measurement here does.
- That the bed is at a sane level against the cues. 🔴 The Master bus peaks at
0.0 dBFS in the four-step run — the
confirmcue is +0.2 dBFS on its own, so any music under it puts the mix on the ceiling. Per-file levels are the disc's and are fine; the runtime mix has no headroom. The port has not set a bus balance, because nothing measures one and an invented balance is the same class of mistake as an invented loop point. Recorded here rather than fixed quietly. - That "Dummy driver" means heard. It does not, and the run prints the driver name so a write-up cannot forget to say so.
One bug, in two dialects, both about a temp filename
The temp-name-then-rename discipline this project uses everywhere broke twice in this milestone, in two different tools, for the same underlying reason: tools dispatch on the extension, so a temp name must preserve it.
run_ffmpegwrote.back.ogg.partial→ "Unable to choose an output format", a hard failure before a byte was written.boot.gdwrotep6.wav.part→save_to_wavappends.wavwhen the path does not end in it, producingp6.wav.part.wav; the rename then failed to find its source, its return value was not checked, and the run printed a success line naming a file that did not exist.
The second is the more dangerous shape, and it is the one this project has already warned itself about: a confident line of output pointing at nothing. The rename's return is now checked and the failure is loud.
P3, reopened — the boot title was missing the PRESS Ⓐ plate, 2026-08-29
P3 passed its gate with a boot that ended on build 4 alone. BLOCKED.md carried
that as 🔴 from the start: both states were captured, so the art was never the
question — the sequence was, and it is behavioural, so the port had no oracle
for it.
It is answered. docs/re/title-plate-delay-measured.md
(auto/no-disc-and-menu-captures at fb536df, not on main at the time of
writing) measures two independent boots: the title presents without the
plate, and the plate arrives 2.13 s later, the two runs agreeing to 6 ms.
Two builds at once, as two ScreenViews
ScreenView draws one screen. The obvious change was to teach it about a
subordinate overlay screen; the change made was to put a second ScreenView
in the same SubViewport, after the first.
That is what "two builds at once" actually is. Each build has its own timeline,
its own textures and its own hold — the plate's group runs independently of the
title's, which is the entire content of the finding — and Node2D siblings already
paint in tree order. The alternative would have put an if overlay in every
method that walks elements, and would have expressed the same information less
directly. The export's paint_order still means what it always meant: an
ordering within a build.
The delay is timed from where build 4 stops animating
Not from where the title first appears. This is the finding rather than a detail: measured from first-draw the two oracle runs differ by 0.48 s, because the build-in itself ran 1.64 s and 2.13 s and the emulator's frame pacing during an animation is not the game's clock. Measured from settle they differ by 6 ms.
So _boot_done — the moment the sequencer already had for "this screen has
reached its hold" — is the landmark, and the overlay is due after_settle_seconds
later. A number taken from the wrong instant here looks exactly like a
measurement.
The overlay is attached to the BOOT STEP, not to the title screen
What was measured is the boot title. Whether the plate is there when the title is
reached again — by Ⓑ from the main menu, or after the attract movie — is not
measured, and hanging the overlay on the screen would quietly claim that it is.
So it lives on the boot step in authored/flow.json, and _drop_overlay takes
it away with the screen it belongs to. BLOCKED.md carries the gap.
Refutation — the RE agent's instruction contradicts the RE agent's measurement
The claim under test, quoted from the finding's "What the port should author": draw build 4, "when build 4 has settled, wait 2.13 s, composite build 2 over it".
It does not reproduce the measurement it came from, and the gap is 3.97 s.
Build 2 is not a static plate: it has a group, and this port plays groups.
press_start has one element, ptbtn00, and its fade_argb reads
t=214 0x00ffffff pos (383, 560) invisible
t=236 0x00ffffff pos (383, 550) still invisible, having slid 10 px up
t=238 0xffffffff full alpha
t=244 0xffffffff holds
— 0x00ffffff the exit, untimed
At the measured 60 units/s that is 3.967 s from the group's start to full alpha. Compose the instruction with the group and the plate is first visible at settle + 2.13 + 3.97 = settle + 6.10 s. What was measured — the glyph counter leaving its no-plate value of 154 — is the plate becoming visible at settle + 2.13 s.
Neither obvious reconciliation works:
| reading | plate visible at | measured |
|---|---|---|
| both groups start together | 3.97 s (build 4 settles at 4.350 s) — i.e. 0.38 s before settle | settle + 2.13 s |
| build 2's group starts at settle | settle + 3.97 s | settle + 2.13 s |
| build 2's group starts at settle + 2.13 s (the instruction) | settle + 6.10 s | settle + 2.13 s |
To land on the measurement, build 2's group has to start 2.51 s after build 4's, which is not a landmark of anything.
Verdict: the instruction is refuted as written; the measurement is untouched. The measurement is an observation of the running game and this port has no standing to doubt it. What is refuted is the step that turns it into an authoring rule, and that step is an interpretation.
So the port ships the instruction, not its own arithmetic, prints the discrepancy on every boot, and files the row. This is the same call as the BGM sub-waves and for the same reason: reconciling two of the RE agent's numbers is a decoding question, and a port that quietly picks the one that looks right destroys the evidence — a corrected boot looks exactly like a correct one.
The first thing to check is about the instrument rather than the game: is "title settled", the glyph counter first reading 154, the same instant as the port's last-element settle (t=261, 4.350 s into the group)? If that landmark is earlier, the gap closes with nothing else moving.
Refuting the port's own claim: things in this export DO pulse
BLOCKED.md has carried this since P2, under the port's own raised question
about whether groups loop:
no element's alpha reverses direction anywhere in this export, so nothing pulses, which removes the obvious reason to expect a loop without disproving one.
ptbtn00 reverses. 0x00 → 0xff → 0x00, in the table above, in the
export, the whole time. The claim was never checked against press_start; it was
checked against the screens P2 happened to be animating. The RE agent has now
measured the running game pulsing this exact element at a mean 2.24 s.
So the reason to expect a loop is back — and the port still does not draw one,
because no reading of this group produces 2.24 s: the whole group is 268
units = 4.47 s, and from its first keyframe 54 units = 0.90 s. The plate is drawn
arriving and then holding at its settle (t=238, alpha 0xff), which is what
every other screen does and what the static oracle capture
live-title-press-a.png shows. Which instant a repeat restarts from is filed,
not guessed.
--boot --capture= — one frame instead of six hundred
The boot had no artifact of its own except --film, a PNG every 0.25 s for the
whole 156 s run, to answer one question: is the plate on top of the title at the
end. --capture was a --screen-only flag taken in _ready, which for a boot
run is 150 s too early. It is now deferred to the end of the sequence when
--boot is given.
P3 gate — the boot ends on two builds
godot --path port -- --boot --capture=…/p3-plate.png
→ boot sequence complete after 155.86 s, holding on title
overlay press_start due at 157.99 s (+2.13 s after settle)
overlay press_start raised at 158.00 s, 1 element(s), settles at t=238
⚠ plate raised at settle+2.13 s but its own group reaches full alpha 3.97 s
later, so it is first VISIBLE at settle+6.10 s -- the measurement is
settle+2.13 s.
boot ends on title + press_start at 161.99 s
drew 16: ptbase2, ptloop01, …, ptcopyright
overlay press_start at t = 261.00 units, drew 1: ptbtn00
The PNG shows the title logo with PRESS Ⓐ BUTTON under it — build 4 and
build 2 in one frame, which this port had never drawn.
Two things the run made obvious and that are now fixed:
- The capture reported only the base build's elements. The first composited
capture printed
drew 16and no mention of the plate, which reads as though the overlay had not drawn at all. The overlay gets its own line; folding its elements into the first list would have reported a screen that does not exist. --screen=<a> --overlay=<b>raises the same composite immediately, by the same code path, with no delay. It exists because the only other way to see two builds was a 156 s boot of which 137 s is the intro movie — and under Xvfb's software Theora decode that is several minutes to answer "is the plate on top of the title". It applies no delay: the delay is a measurement and lives inauthored/flow.json. The boot-mode narration is suppressed there, because a log line that describes a sequence it is not running is worse than no log line.
P5 — the focus ring spins, 2026-08-29
The ring was drawn at 0° and the file said so: "THIS IS KNOWN TO BE WRONG, and
is drawn anyway because the right answer is a guess." What was missing was the
period, and it is now measured — docs/re/focus-ring-spin-measured.md
(auto/no-disc-and-menu-captures at 4fa3099): a continuous spin, from eight
evenly spaced autocorrelation peaks over nine revolutions, with no angle
estimated anywhere — both angle estimators failed their own controls and were
not used.
The period comes off the disc; the RE agent supplied only that it repeats
ptbtneff01 declares two keyframes that differ in nothing but
rotation_deg, 0 → 360, the first timed at t = 120 and the second untimed.
The port turns once per 120 units. Nothing is authored: the number is on the
disc, and what the measurement adds is that the turn repeats rather than
stopping at 360 = 0, which "groups hold" could not distinguish because those are
the same pose.
ScreenView.spin_period_units is the rule, and it is structural and narrow:
exactly two keyframes, differing only in rotation, by a full 360, first timed and
second untimed. Disc-wide check over this export: 16 of 212 elements match, and
all 16 are focus rings — ptbtneff01 on the five main-menu buttons and
ptbtneff02 on the three EXTRAS buttons, in both locales, every one declaring
t = 120. Zero false positives.
That check is the point rather than a formality. The measurement was taken on one button of one screen; a rule that also caught something else would be extrapolating it to elements nobody watched.
⚠️ It is a rule about shape, not a decoded field. Nothing on the disc says "this loops". The day a loop flag is decoded, this goes.
Verified on the port's own render, with the RE agent's own control
Captures at --time= 2.0 … 4.0 s on the settled main menu, ptbtn01 focused:
| t=2.0 vs t=4.0 (one full period apart), whole frame | 0.0000 / 255 — bit-identical |
| t=2.5, 3.0, 3.5 against t=2.0, inside the ring's box | 3.60, 3.71, 3.58 / 255 |
| sum of box luminance across eight phases | spread 0.027 % of the mean |
The last row is deliberately the RE agent's own observable: they separated rotation from a brightness pulse by showing total annulus brightness is conserved while per-bin brightness moves. The port's render conserves it to 0.027 % (theirs was 0.4 % over 16 s, with capture noise in it). A filmstrip of the four quarter-period phases shows the bright head at top, right, bottom, left.
Two things it does not settle
- Direction. The port turns 0° → +360°, the sign the disc declares. No signed angle was ever measured — the estimator that would have given one failed its control and was not used.
- Phase across a focus change. The port drives the ring off the screen clock, so moving the cursor does not restart the turn. The alternative — the record's group restarting when the record is instantiated — is the stronger claim, and the oracle run held focus on one button throughout, so nothing separates them. Two frames straddling a focus change would.
P3, corrected — the plate needs no authored delay at all, 2026-08-29
Last iteration the port refuted the RE agent's authoring instruction ("when build 4 has settled, wait 2.13 s, composite build 2") with arithmetic off the disc, shipped the instruction anyway rather than pick between two of their numbers, and printed the discrepancy on every boot.
The refutation held, and the answer that came back is better than either option
the port offered: author nothing. 5b0a6e6.
The premise that failed was the port's, and it will bite again
🔴
rest.tis not when a screen settles. It is the last hold keyframe before the exit.
Checked here rather than taken on trust. title's ptlogo1:
t=26 (-116,-7) 150% a=0x00 the pre-roll
t=42 (179,186) 101% a=0xe0 it has arrived
t=251 (184,193) 100% a=0xff 5 px and 31 alpha steps later, 3.5 s on
It stops moving at t=42 and then creeps for 209 units. rest.t = 251 is the
end of that creep, not the arrival. The title's visible build-in is over at
t = 118, where pteff01, pteff02 and ptlogoall_eff finish together.
Every reconciliation the port computed last iteration was wrong by exactly that
error: reading rest.t put build 4's arrival at 4.350 s instead of 1.967 s, and
the "2.51 s, which is not a landmark of anything" that looked so damning is
(4.350 − 1.967) + 0.13 — the error itself, wearing a decimal point.
One clock, and the interval is declared
| units | |
|---|---|
| build 4's last build-in ramp | t = 118 |
ptbtn00 reaches a = 255 |
t = 238 |
| difference | 120 units = 2.000 s |
Measured: 2.138 s and 2.132 s. The 6.7 % is presentation rate — 120 units in 2.135 s is 56.2 units/s, the emulator running 28.1 fps against a nominal 30, and the corpus had independently measured the idle title at 28.5 fps before these runs.
So authored/flow.json carries "clock": "shared" and no delay, boot.gd
raises the overlay when the step's screen loads rather than at its settle, and
overlay.time_units = view.time_units — assigned, not accumulated, because two
independently advanced clocks drift by a frame here and there and the whole
content of the finding is that 120 units is a fixed interval on one timeline.
⚠️ The general hazard, stated by the RE agent and worth repeating where the port will read it: discount a wall-clock number off that oracle by ~6 %. It is Canary's presentation rate baked into whatever it measures. A port at a true 30 Hz that authored 2.13 s would be visibly late.
Refutation — two of the RE agent's numbers for the same 120 units disagree by 2 %
Both findings measure the same declared quantity: 120 keyframe units of wall clock, during a static hold, in Xenia Canary.
| implied presentation | ||
|---|---|---|
| plate: settle → plate, two runs | 2.138, 2.132 s → mean 2.135 s | 28.10 fps |
| ring: one revolution, seven spacings | 2.18 2.16 2.18 2.16 2.16 2.20 2.20 → mean 2.177 s | 27.56 fps |
| disagreement | 0.042 s = 1.97 % |
That is seven times the plate finding's own run-to-run agreement of 6 ms, and it lands on the argument that finding uses to justify trusting itself: "the build-in is where frames are dropped; the static hold is not. A model in which the game's own timing varied would have to move both." Two static-hold measurements are exactly what should agree under that model.
A second, smaller arithmetic slip in the same place: the ring page reconciles 2.177 s against a band of "27.6–28.8 fps", saying the measurement "sits at the top of that band". It does not sit in it. 60 rendered frames at 27.6 fps is 2.1739 s; the mean needs 27.56 fps and the two slowest spacings (2.20 s) need 27.27 fps. Four of the seven spacings are above the band's top.
Verdict: the containment claim is refuted; the spin, the period and the reconciliation are untouched. Either the presentation rate genuinely differed between the two sessions — which the plate page's own corroboration argues against for static holds — or the ring's revolution is not exactly 120 units. The corpus should say which, because they are the same claim measured twice.
🟢 Nothing in the port moves either way. spin_period_units uses the
declared 120 units at 60 units/s = 2.000 s of port time, which is the
true-30 Hz value both readings agree the disc means. This is a corpus
consistency problem, not a port one — which is why it is filed rather than
worked around.
The corrected boot ended one build too early, and the capture showed it
Moving the plate onto the shared clock also moved the boot's exit, and the first
capture taken afterwards was visibly darker than the one before it. The cause
is pteff00, the title's black fade quad: it ramps 0xff000000 → 0x00000000
over t=16…261, so at t=243 — where the run was quitting, overlay.settle_time()
after the overlay was raised — the frame is still ~7 % black.
The plate arrives at t=238; build 4 is not finished until t=261. The boot now ends at the later of the two, and says which in the log:
-> title at 145.79 s
overlay press_start raised at 145.79 s, 1 element(s), settles at t=238
boot ends at 150.14 s, once both builds have arrived (t=261)
Worth recording because of how it presented: nothing failed, no warning was printed, and the only symptom was a frame slightly darker than the previous run's. A gate artifact that silently drifts is the failure mode this project keeps meeting — and it was caught only because there was a previous capture to compare against.
P7 — the new-game intro, 2026-08-29
S00A.wmv has been in export/video/ since P4 (MISSION §6 put both movies in
scope at once). What P7 needed was for something to play it and for the run to
end somewhere defined.
The port skips two measured screens, and says so on screen
The real chain is NEW GAME → DIFFICULTY → SELECT DATA → Ⓐ on a save slot
→ ~4.5 s → S00A (HANDOFF Q4 measured the screens, Q9 decoded the movie and
then measured its onset off the running game at 0.96–1.000 with a strictly
monotone playhead over 25 consecutive 0.5 s samples).
DIFFICULTY and SELECT DATA are measured destinations that are not
GP_TITLE builds, so no screen file exists to go to. The port therefore jumps
from NEW GAME to the one thing in that chain it has.
That is a gap, not a sequence, and the whole design here is about not letting it read as one:
MenuFlow.acceptreturns a new kind,video, rather than folding this intoblocked. The caller has to announce the skip, and a distinct kind is what forces it to.- The runtime prints it every time:
(NEW GAME) -> the real chain is DIFFICULTY -> SELECT DATA, then the movie. Neither screen is in this export. authored/flow.jsoncarriesskipped_chainas data, so the names of what is missing live beside the decision rather than inside a GDScript string.
A port that quietly jumped from NEW GAME to the intro would be showing a
sequence the game does not have, with nothing on screen saying so. That is the
exact failure this project keeps meeting from the other direction.
What happens after the movie is authored, and had to be
The game goes into mission 1. Gameplay is out of scope (PORT-MISSION §7), so
"returns to a defined state" is a decision, and P7's gate says as much. The port
returns to the title: the boot's own end state, so a run that finishes the
intro lands somewhere a player can start again from. Nothing measured says the
game does this, and after_video.kind is "authored".
The 4.5 s gap is left empty on purpose
Q9 measures the movie starting ~4.5 s after Ⓐ on the save slot. What is on screen
for those 4.5 s was never observed — the run that would have shown it hit the
documented sub_823070B0 cache crash after SELECT DATA.
GP_TITLE does carry a loading screen (below), and 4.5 s is about the right
shape for one. That is precisely why it is in BLOCKED.md and not in
flow.json: a plausible filler that nobody watched is the kind of thing that is
indistinguishable from a measurement a month later.
A script timeout that would have failed every movie
--script's per-step timeout is 20 s, to stop an unattended run waiting forever
on a screen that never settles. S00A is 93.9 s, so the first scripted
new-game run would have been killed at step 1 and reported as "never settled".
Raising the constant would have been wrong in the other direction: a movie stuck at frame 0 would then hang the job, and a job that waits is worse than a job that fails, because it does not look like a failure.
So the test is liveness, not duration: while get_stream_position()
advances, the deadline moves with it; a stalled movie still trips the same 20 s.
Found while looking: GP_TITLE's four unnamed builds are LOADING screens
build_00, build_01, build_12, build_15 have never had names. Every
element in all four is pgloading_* — pgloading_processing.png,
pgloading_circle1, pgloading_delta, pgloading_ring — and LOADING is one
of the three screen names the RE agent read out of the title part's state
function.
Two variants: 0/1 carry 7 elements, 12/15 carry 10.
They are not renamed here. The archive's own pairing (adjacent for 2/3, +3
for 4…9 and for 10/13, 11/14) suggests 0 is 1's twin and 12 is 15's, but which
member of each pair is which locale is an inference, and a name is exactly
the kind of thing that stops being questioned once written. Handed to the RE
agent, who can answer it from a capture in one look. BLOCKED.md has the row.
⚠️ And one of them is a second casualty of the rest.t problem.
pgloading_eff00.prm on entries 12/15 is a full-screen black quad whose group
runs 0xff000000 at t=38 → 0xff000000 at t=48 → 0x00000000 untimed: black,
held, then clear. Its rest.t is 38, where it is fully opaque. A port that
draws that screen at its declared rest draws a black rectangle over the entire
loading screen. The title's case only dimmed a frame; this one hides
everything. Filed with the settle_time() row it belongs to.
Refutation — attempted on the fade-quad census; it survives, with a caveat
The claim (HANDOFF, on transitions): "in GP_TITLE exactly the six screen
builds carry it while the six overlays do not", where "it" is the full-screen
black .prm quad whose keyframe group is the transition.
The test, over the whole export: count builds carrying a full-screen primitive with black in its keyframes.
16 builds exported; 12 carry one.
Of the 12 `is_build` bundles (excluding the 4 authored splashes): 8 carry, 4 do not.
carry: title, title_jp, main_menu, main_menu_jp, extras, extras_jp,
build_12, build_15
do not: press_start, press_start_jp, build_00, build_01
8 and 4, not 6 and 6. But the two extras are build_12 / build_15, and their
quad is a different shape:
transition quad (pteff00.prm, title) |
loading quad (pgloading_eff00.prm) |
|
|---|---|---|
0xff000000 t=16 |
0xff000000 t=38 |
|
0x00000000 t=261 |
0xff000000 t=48 |
|
0xff000000 untimed |
0x00000000 untimed |
|
| shape | black → clear → black | black → held → clear |
The transition quad returns to black on exit; the loading quad does not. Read strictly — the quad whose group is the transition — the claim holds.
Verdict: survives. The refinement is worth recording anyway, because the
naive test over-counts by two and somebody will run the naive test. There are
two kinds of full-screen black .prm in GP_TITLE, and only one of them is
a transition.
P7 gate
godot --path port -- --menu --script=accept --audio=…/p7.wav
menu on main_menu, focus ptbtn01
script[1] accept
(NEW GAME) -> the real chain is DIFFICULTY -> SELECT DATA, then the movie.
Neither screen is in this export.
-> video S00A at 1.18 s (/work/export/video/S00A.ogv)
video ended at 94.93 s
-> title (authored: authored)
menu on title, focus (none -- this screen has no focusable item)
script complete after 99.28 s on title
recorded 98.453 s of Master bus (driver Dummy)
The movie ran 93.75 s against a declared 93.9 s, the run ended on the title,
and the Master bus recorded 98.453 s: pcm_s16le, 44.1 kHz stereo, RMS
−22.2 dBFS.
What this does not show, stated because it would be easy to imply otherwise:
the recording contains the menu bed and the movie together, and this run did
not separate them. So it establishes that the engine reached an output for
98 s of a run whose middle 94 s was a movie — not that S00A's own audio track
is in the mix. Separating them wants the P6 null-control method (a paired run
that differs only in the movie), and that is not done here.
🔴 Peak 0.0 dBFS again. The same runtime-headroom problem P6 filed: per-file levels are the disc's and are fine, the Master bus has no headroom, and the port has set no bus balance because nothing measures one.
One more file read while it was being written
ls reported the recording as 3 702 828 B; ffprobe on the finished file
reports 17 367 084 B / 98.452608 s — a factor of 4.7. ffprobe is right and
the ls caught it mid-flight.
AUDIO-VERIFICATION.md opens by naming this failure and the port has had the
temp-name-then-rename discipline since P6, which is what makes it worth writing
down rather than shrugging off: the discipline protects a reader who opens the
path, and it does not protect a reader who stats it at the wrong moment. Size
on disk is not a measurement of a file somebody else is still writing. Ask the
decoder, not the directory entry.
Modding — rule 4 was never implemented, 2026-08-29
docs/port/MODDING.md is explicit that modding is "a design constraint on the
exporter today — not a milestone to add later", and its rule 4 is base-and-
overrides: a mod replaces a file by shadowing its path, so a modder edits
nothing under the derived tree and re-exporting is always safe.
Nothing read data/mods/ at all. The directory has existed since the
monorepo merge with a .gitkeep in it and no code path anywhere — exporter or
runtime — that looked at it. Eight milestones shipped past that.
One resolver, and every read goes through it
ExportTree.resolve(rel) returns the mod tree's copy when one exists and the
derived tree's otherwise. read_json, texture, video and MenuAudio all
call it, so a mod can replace a screen's JSON, a sprite, a cue, the music bed
or a movie — every asset kind the port reads.
MenuAudio was reading tree.root.path_join(...) directly and had to be
changed. Left alone it would have made audio the one asset kind a mod could not
touch, for no reason a modder could have guessed — which is the failure mode
rule 4 exists to prevent.
There is deliberately no manifest of what a mod contains and no registration step: the path is the registration, which is the whole of the rule.
⚠️ One tree, not a stack. Several mods layering over each other needs a load
order, and a load order needs a rule nobody has asked for. Said out loud in
data/mods/README.md rather than answered.
A modded run must not look like an unmodded one
Every shadowed file is printed the first time it is read:
mod: sprites/title/main_menu/ptbtn01.png <- /work/data/mods/sprites/…/ptbtn01.png
MODDING says "did I break it?" is answered by disabling a mod. That is a fine last resort and a poor only resort, so the log names the replacement instead.
The first version of this got it wrong in an instructive way: it printed a
summary in _ready, before a single asset had been read, and so always said
(nothing shadowed yet). A report structurally incapable of reporting anything
is worse than no report, because it looks like an answer. It now announces each
shadow at the moment it happens.
Gate
A synthetic 203×43 magenta PNG — nothing disc-derived — dropped at
data/mods/sprites/title/main_menu/ptbtn01.png:
| pixels changed between the two renders | 8 501 of 921 600 (0.92 %) |
| bounding box of the change | x 542…744, y 162…204 — 203×43, the sprite's own size |
sylpheed-export check export afterwards |
16 screens still validate |
The changed region is exactly the sprite and nothing else moved.
data/mods/ was not gitignored, and that is a hole in a hard rule
"Never commit game assets" has been enforced on export/ and data/base/
since P0. But a mod is usually an edited game asset, and data/mods/ was
fully tracked — so the one directory a user is invited to put modified sprites in
was the one directory git would happily take them from.
.gitignore now excludes everything under it except the README.
The naming split is not mine to resolve
MODDING.md describes the tree as data/base/; PORT-MISSION.md §3, the
exporter, ExportTree and .gitignore all say export/. Both are mission
files, and PROTOCOL is clear that only the human changes a mission, so this
is raised rather than picked. .gitignore has ignored both names on purpose
since P0.
It matters here for one concrete reason: MODDING's layout has base/ and mods/
as siblings, and today they are not — the tree is export/ at the repo root
while mods are data/mods/. The resolver takes SYLPHEED_MODS or defaults to
data/mods/, which is what exists; if the tree is ever renamed to data/base/
the sibling rule becomes natural and that default can go.
Refutation — the paint-order key, and the reach of its tie-break
The claim (HANDOFF Q3): paint order is "a u16 layer key at +0x0A,
decoded", with the tie-break filed 🟡 as "eight candidates refuted; costs
one element's blend on one screen".
First pass: 2 of 16 screens did not match a stable sort by layer key — both
loading screens, build_12 and build_15.
That was my test, not the claim. pgloading_eff00.prm carries no layer key
at all — layer: null, layer_source: "none": it is a primitive with no
sprite header, and the exporter's implied-name fallback produces nothing either.
My sort put a keyless element first; the decoders put it last.
Completing the rule as "stable sort by layer key, elements with no key last"
gives 16 of 16. And last is right: pgloading_eff00 is the full-screen black
quad, and HANDOFF's own sentence is that the fade quad paints last.
Verdict: survives, with the rule completed. Worth recording because the published statement does not say where a keyless element goes, and there is at least one in the archive.
🟡 But the tie-break's reach looks understated. Census over this export:
elements sharing a layer key with another element: 105, across 12 of 16 screens
HANDOFF characterises the cost as "one element's blend on one screen". 105
elements on 12 screens is a much larger surface than that. Most of those ties are
probably invisible — two elements that share a key and never overlap cannot show
a difference — but probably is doing the work in that sentence, and nothing has
measured which. The port is unaffected either way: it draws
ui_layout::derived_paint_order verbatim and derives no order of its own.
Correction — the runtime "clipping" I flagged 🔴 twice was overstated
P6 and P7 both filed 🔴 "the runtime mix has no headroom" on the strength of a peak reading of 0.0 dBFS off the Master bus. Measured properly:
| samples at full scale | of total | longest clamped run | |
|---|---|---|---|
| P6 walk (5.944 s) | 43 | 0.0082 % | 10 samples — 0.23 ms |
| P7 new-game run (98.453 s) | 24 | 0.00028 % | 11 samples — 0.25 ms |
That is not a headroom defect. It is the disc's own confirm cue, mastered near
full scale (+0.18 dBFS after a lossy decode), touching the ceiling for a quarter
of a millisecond on a transient — and possibly only in the recording's 16-bit
conversion, since Godot mixes in float and AudioEffectRecord saves s16.
Nothing is changed, and that is the point. Attenuating the mix to buy headroom would be an unmeasured decision about level — the same class of thing this port refused for the BGM loop point and the stem balance. Refusing it there and taking it here would be inconsistent, and it would trade an inaudible 0.25 ms clamp for an audible change nobody measured.
A peak reading is not a clipping measurement. One sample at 0 dBFS and two seconds of square wave give the same number, and I reported the first as though it were the second — twice, in red, in two milestones' write-ups.
The P1 regression harness had been broken since the monorepo merge, 2026-08-29
tools/port/verify-screen is the P1 gate's regression detector: Godot's drawing
of a screen against sylpheed-cli screen render of the same build. It had not
been run since P1, across four milestones that changed the renderer — rotation,
the focus record, the spinning ring, two builds composited at once.
It could not have been run. It resolves its reference binary to a path that
build-reference-cli stopped being able to produce. That script greps
crates/sylpheed-export/Cargo.toml for
sylpheed-formats = { git = "…Syplheed-Reborn.git", rev = "…" }
and the monorepo merge (65cefa7) replaced that line with
{ path = "../sylpheed-formats" }. The grep returns nothing, the script exits 1,
and the binary left at reference-cli/sylpheed-cli is whatever predated the
merge — here, three hours older than the sources and built from a revision
nothing in the tree points at any more.
Running the diff against it would have compared the port to a decoder from
another era and called the result a regression check. DECISIONS.md already
carries "The reference renderer was stale for three diff runs" from P2. This
would have been the fourth, and the mechanism was different: not a forgotten
rebuild, but a build step that could no longer succeed and a consumer that
only checked whether the file existed.
The fix is a deletion, not a repair
The revision-keying solved a two-repo problem: /reborn's target/ was a live
mount of the other agent's checkout and moved mid-run, so a pixel disagreement
against it had a free variable in it. The monorepo removed that problem by
construction — the exporter, the reference and the port now read one decoder,
the working tree's. So verify-screen builds sylpheed-cli from the workspace.
SYLPHEED_CLI still overrides for anyone who wants to pin one deliberately.
The baseline, all 16 screens
build_00/01 max 3 over3 0 OK
press_start(_jp) max 1 over3 0 OK
title max 6 over3 790 DIFFERS
main_menu(_jp) max 4 over3 0 DIFFERS
extras(_jp) max 3 over3 0 OK
publisher_logo(_r) max 1-2 over3 0 OK
developer_logos(_r) max 2 over3 0 OK
title_jp max 155 over3 20498 DIFFERS
build_12/15 max 0 over3 0 OK
No new drift. Four milestones of renderer change and the only screen with a
substantial disagreement is title_jp — which is the same one P1 recorded and
left open: ptlogo_eff2 is the single drawn element in the whole export at a
scale that is not a whole multiple of 100 % (125 %), and the two renderers pick
different source texels there. ui_layout::blit samples at the destination
pixel's top-left corner, a GPU at its centre. The port has still not changed to
match, because matching would mean reproducing a half-pixel bias on purpose to
make a number smaller. Only an oracle capture settles it.
title's 790 pixels at ≤ 6/255 are the same class, one texel wide, on the logo's
scaled edges. main_menu and main_menu_jp say DIFFERS on a max of 4 with
zero pixels over the bar — a couple of pixels differing in a single channel.
max alone could not tell 2 pixels from 25 000
The script reported only the largest difference anywhere in the frame, so
main_menu (two pixels) and title_jp (2.8 % of the frame) produced the same
verdict. It now also reports how many pixels are over the bar.
The bar itself is not raised. Tuning a threshold until things match is the
failure the script's own header warns about; adding a second number is
information, not a loosened bound. ⚠️ The count is thresholded on greyscale
luma while max is a per-channel maximum, so they are not two views of one
measurement — a per-channel check counts 957 on title where the luma count
says 790.
What this harness cannot see, stated because the OK rows look reassuring
It renders --pose=rest. That is deliberate — it holds both renderers to the
same declared pose so the test is port vs reference and not rest vs timeline
— but it means none of this iteration's or the last four's visible work is
under test: not the spinning focus ring, not the plate composited over the
title, not any timeline behaviour, not audio. Sixteen OK rows are a statement
about the resting composite and nothing else.
And it remains what its header says: a consistency check between two renderers
that share their assumptions. Both have been wrong together three times —
pteff05, scale-0, rest() — and each time only a capture caught it.
Refutation — "builds 0/1 and 10/11 are the loading screen" is false in the index space this export uses
The claim, from the RE agent 2026-08-29, answering the port's ask to name
GP_TITLE's unnamed bundles: "builds 0/1 and 10/11 are the loading screen,
decoded from their own pgloading_* element names."
In this export, entries 10 and 11 are the splash screens, and it is not close:
| entry | elements |
|---|---|
| 10 | palogo_eff0, palogo_sqex, palogo_sqex_eff |
| 11 | palogo_eff0, palogo_gamearts, palogo_seta, palogo_anima … |
| 12 / 15 | pgloading_eff00, pgloading_loop1, pgloading_str … |
Entry 10 is the SQUARE ENIX wordmark and 11 the developer logos — which the same agent identified, in the answer to the port's ask 1, as "entries 10/13 are the SQUARE ENIX publisher wordmark, the first thing the boot shows".
Verdict: the finding is almost certainly right and the index space is wrong.
Over the twelve bundles is_build accepts — entries 0,1,2,3,4,5,6,7,8,9,12,15 —
ordinals 10 and 11 are entries 12 and 15, which are exactly the two dressed
loading variants. So "0/1 and 10/11" is the is_build ordinal, and this export
addresses by pak entry index.
Why this is worth a section rather than a shrug. authored/screen_names.json
is keyed by entry index, and the exporter's own comment says why: "keyed by
ENTRY, not by the enumeration ordinal — widening the enumeration to reach the
splash renumbers the ordinals, and a name that moves when the rule changes is not
a name." Someone reading that message and writing keys "10" and "11" would
name the publisher wordmark and the developer logos as loading screens, and
the export would validate, and the boot would still run.
Two enumerations of the same archive differ by exactly the four bundles the port had to add an allow-list to reach. That is the sharpest possible demonstration of why the exporter switched, and it has now nearly caused the error it switched to prevent. Reported; the names are still the RE agent's to give.
The intro's missing dialogue was an export gap, not a transcode bug, 2026-08-29
A human play-test heard music under the boot intro and no voices. The obvious reading is that the 5.1→stereo fold dropped the centre channel, and it is wrong.
ADV.wmv carries music and effects only. On this disc a cutscene's voice is
a separate asset: one continuous XMA stream in sound.pak, bound to the movie
by the manifest in tables.pak (ADV → VOICETRACK = VOICE_ADV). Nothing was
dropped — grep -rn voice crates/sylpheed-export/src/ returned nothing, because
the exporter had never been asked for it. The transcode was correct the whole
time, which is why every measurement on it passed.
That is worth stating plainly because the failure looked exactly like a codec
bug, and docs/port/AUDIO-VERIFICATION.md is full of ways to measure a
transcode against its source. Every one of them would have come back clean.
The binding is resolved, and must never be matched by name
audio::export_voice takes exactly one route:
media::resolve_movie_voice_region(source, movie, VoiceLang::English), which
walks movie → cue token (manifest) → sound id (registry) → a [start, end) byte
region of the continuous stream. The cheap route — read VOICE_<movie>.slb —
was not taken, and the reason is a measurement:
| movie | resolved region | inside the bank named after it? |
|---|---|---|
ADV |
433 930 240…437 044 592 | yes |
S00A |
452 798 464…455 499 120 | yes |
RT01A |
437 044 592…437 345 648 | no — it is inside VOICE_ADV.slb |
⚠️ Name-matching is correct on exactly the two movies this port ships, and wrong on the radio cutscenes. It would have exported clean, verified clean against both in-scope movies, and returned the wrong recording the moment anybody widened the export. This is the failure mode MISSION §2 names — one playable thing is not one archive entry — in its most convincing disguise: the spot-checks a person would actually run are the ones it passes.
Three choices, and why none is a guess
- One file per movie, per MODDING rule 1, and the region's chunks are summed — see the correction below, because the first version of this paragraph said the opposite and was wrong.
- Mono, folded from the stream's own declared channel count, probed with
ffproberather than assumed. This is not pedantry:pansilently ignores a channel the input does not have — measured this iteration on the 5.1 fold below, whereFLC/FRC/SL/SRvanished with no warning at all — so a stereo matrix applied to a mono voice track is not an error, it is a −6 dB attenuation that nothing reports. A track that is already mono is passed through untouched. - No sync offset, and no length clamp. The voice plays from the video's first frame, so nothing is authored. The decoded length is recorded in the manifest beside the movie's own length rather than trimmed to it: the voice has no shared container to disagree with, so a length mismatch is the only symptom a resolution error would ever show, and clamping would delete it. That decision is the reason the error below was caught in the same hour it was made.
Correction, within the hour — the chunks are stems, and I had concatenated them
The first version of export_voice joined the region's chunks end to end and
produced 359.201 s of voice for a 137.437 s movie, and 255.460 s for a
93.779 s one. Both ratios sit near 3, and both regions decode to 3 chunks.
The manifest said so on the first run, because the length was recorded against
the movie's instead of being clamped to it. A clamp — which is what
sylpheed-viewer does, and what media's own doc comment invites with "trimmed
by the caller's length clamp" — would have produced a file of exactly the right
duration containing the wrong audio, and every check in
docs/port/AUDIO-VERIFICATION.md would have passed it.
Decoding each chunk and timing it (crates/sylpheed-export/examples/voice_chunks.rs):
| movie | movie length | chunk 0 | chunk 1 | chunk 2 |
|---|---|---|---|---|
ADV |
137.437 s | 84.553 | 137.324 | 137.324 |
S00A |
93.779 s | 68.072 | 93.694 | 93.694 |
RT01A |
— | 0.009 | 34.034 | — |
Chunks 1 and 2 are equal to six decimals and each span the whole movie. That
is HANDOFF Q10's decoded shape — two stems of one performance, played together;
do not concatenate — showing up on a second asset kind. They are summed at
1/n, exactly as export_bgm sums a music bank.
⚠️ Chunk 0 is dropped and its status is open. Its duration matches nothing:
84.6 s under a 137 s movie, 9 ms under RT01A. docs/re/REFUTED.md records
to_xma_riffs's hybrid branch emitting a leading headerless packet region
ahead of the real RIFF waves, and docs/port/BLOCKED.md already carries that
as an open row against BGM_103, where media returns three sub-waves against a
census of two. This is the same signature on an independent asset kind — good
corroboration, not proof, and the port is not entitled to close it. So the
selection rule is written in terms of the measurement (keep the longest
duration and everything tying with it), and every dropped chunk is named in the
manifest with its length.
This is the media-assembly trap MISSION §2 names, and it caught me: I wrote a doc comment asserting concatenation, gave the reason, and had it wrong. What saved it was refusing to clamp — the one decision in the first version that was made for the right reason.
What a None means
A movie whose region does not resolve is genuinely unvoiced — the honest
answer for most hokyu_* resupply cutscenes — and gets a manifest warning, not
a substitute. The corpus already paid for the alternative: resolving unbound
movies through a shared demo line played the wrong recording.
This is decoded, not authored, so it runs outside the authored/audio.json
block in main.rs. Nothing new goes in authored/; there is nothing here we
decided.
Refutation, of my own exporter — MISSION §6 pins a downmix matrix, and the exporter ships a different one
The claim under test is the port's, not another agent's, and it has been in
video.rs since P4: that the 5.1 fold is normalised by
1/(1 + √½ + √½) = 0.4142 because "the unnormalised form was measured too and
clips: peak 0.0 dBFS."
That sentence rests on a peak reading. docs/port/BLOCKED.md records this port
withdrawing a 🔴 runtime-clipping flag on precisely the grounds that a peak
reading is not a clipping measurement — one sample at full scale and two
seconds of square wave give the same number. So the justification for deviating
from a matrix a human pinned was produced by an instrument this port has already
declared unfit for the question.
Measured properly, over the whole of both movies
Decoded to 32-bit float so nothing is pre-clamped, then counted: samples at or over full scale, how many exceed it by more than 1 dB, and the longest consecutive run.
| peak | RMS | ≥ full scale | > +1 dB | longest run | |
|---|---|---|---|---|---|
ADV, MISSION §6 matrix |
+4.26 dBFS | −14.55 | 4 406 / 13 187 900 | 1 874 | 16 samples (0.333 ms) |
ADV, exporter's matrix |
−3.39 dBFS | −22.21 | 0 | 0 | — |
S00A, MISSION §6 matrix |
−1.34 dBFS | −18.73 | 0 | 0 | — |
S00A, exporter's matrix |
−8.99 dBFS | −26.39 | 0 | 0 | — |
The claim survives, and the reasoning behind it does not. The pinned matrix
genuinely overloads ADV: not one stray sample but 4 406 of them, 1 874 more
than a full dB over, wanting 4.26 dB more headroom than the container has. That
is a different animal from the 43 samples and 0.25 ms transient I withdrew a flag
over, and the number that separates them is the magnitude, not the count.
But the same table refutes the scope of the fix. S00A never clips under the
pinned matrix — it peaks at −1.34 dBFS. The exporter attenuates it by 7.65 dB
to solve a problem it does not have, because 0.4142 is derived from a theoretical
worst case (every channel correlated at full scale at once) that neither movie
comes near.
Control, before believing any of it
The pinned matrix names FLC, FRC, SL and SR, and a 5.1 source has none of
them. ffmpeg neither errors nor warns — measured at -loglevel warning, the
output was empty. So the literal string was decoded alongside its three-term 5.1
reduction (FL = 1.0·FL + 0.707·FC + 0.707·BL) and the two outputs compared:
bit-identical, 52 751 600 bytes. The reduction is what runs, and it is the
matrix §6 intends. That silence is itself the trap the mono fold above guards
against.
Not changed, and deliberately so
MISSION §6 is a human decision of 2026-08-29, and the level of a mix is exactly the kind of thing §6 reserves — "adjust it deliberately, as a commit". Three options, and choosing between them is not mine:
- Keep the pin.
ADVclamps on 4 406 samples. Rejected on the measurement. - Keep the exporter's 0.4142. Preserves the two movies' relative loudness exactly, costs 7.65 dB, and is safe by construction for any movie a modder drops in.
- One measured constant,
1/1.6339 = 0.612. The smallest single scalar under which no in-scope movie clamps: +3.39 dB over today, still one constant so relative loudness is untouched. Tuned to two files, but the exporter's owncheckrefuses any export whose peak reaches 0 dBFS, so a third movie that needed more headroom would fail loudly rather than clamp quietly.
Per-file normalisation is not on that list: it would put ADV 4.26 dB below
S00A and change how two cutscenes sit against each other and against the menu
bed, which is an aesthetic decision with nothing measured behind it.
What changes today is only that the deviation is visible: video.rs now
cites MISSION §6 by name and says it departs from it, and the export carries a
manifest warning with these numbers. Before this, a reader of the manifest could
not tell that a pinned human decision had been overridden at all — the command
line was recorded faithfully, and recording the command you ran does not disclose
that it is not the command you were given.
The voice reaches the output, and a null control says so quantitatively
+ voice ADV in the log proves only that play_voice found a stream and called
play(). Whether the audio arrives at the Master bus is a different question,
and docs/port/AUDIO-VERIFICATION.md §2 exists because it is.
The control needed no test-only code: MODDING rule 4 already shadows any
exported asset by path, so 140 s of silence dropped at
data/mods/audio/voice/ADV.ogg mutes the dialogue and changes nothing else. Two
--boot --skip-at=25 --audio=… runs, then astats over the same 14 s of movie:
| peak | RMS | |
|---|---|---|
ADV.ogv's own audio (the bed) |
−6.239 | −24.941 |
| the exported voice alone | −7.614 | −27.965 |
| run with the voice muted | −6.251 | −25.126 |
| run with the voice playing | −5.415 | −22.913 |
The muted run reproduces the bed to 0.01 dB peak / 0.19 dB RMS, which is what makes the other row worth reading. And the mixed run is not merely louder: two incoherent sources at −24.941 and −27.965 dBFS predict a sum at −23.184, and the run measures −22.913 — 0.27 dB out. The voice is in the mix, at the level its own file says it should be.
⚠️ Under the Dummy driver. Per AUDIO-VERIFICATION, "recorded under a dummy
driver" is a weaker claim than "heard", and no measurement here says the
recording is the right dialogue for this cutscene — only that the file the
exporter resolved is the one reaching the output at the expected level. The two
runs are also not sample-aligned (they differ by 1.7 s of wall clock), which is
why the RMS trough column is omitted: it moved by 40 dB between runs on window
placement alone, and peak and RMS are the two numbers that survive that.
Ⓐ does skip the intro in this build, so the play-test's report is not this bug
--skip-at=25 on a --boot run: video skipped at 25.02 s, video ended at 25.02 s, title at 25.02 s. The press goes through Input.parse_input_event and
arrives at _unhandled_input exactly as a pad's would, so the wiring from press
to skip is live. What that does not cover is a real key event from a focused
window, which is the difference between this run and the human's — and, separately,
whether the game permits skipping an attract movie at all is HANDOFF Q9 and
still 🟡. If the answer is no, this path is deleted rather than debugged.
Refutation of my own two-stem reading — and it had already been adopted elsewhere
Two hours after writing that a voice region's equal-length chunks are "HANDOFF Q10's decoded two-stem shape", the Decoder asked me to decode the leading chunk — it has no XMA1 decoder in its container — and the decoder run refuted the claim I had made.
Equal duration was a shape match, and I carried Q10's music census across to voice on the strength of it. The content does not support it:
S00A chunk 2 |
digital silence — 4 497 300 samples, peak −inf |
ADV chunk 2 |
0.60 × chunk 1 (best-fit scalar), residual 26.8 dB below the target |
About 95 % of ADV's second chunk is a −4.4 dB copy of the first. Two chunks of
equal length, one silence and the other a scaled near-duplicate, are not two
stems of one performance. ⚠️ The claim had already travelled — it is quoted in
the Decoder's voice-region-leading-chunk.md — which is the failure PROTOCOL
names: a wrong belief moving faster than its correction, through two documents
that share a source.
What it cost, and what changed
Summing chunk 1 with silence at 1/n put S00A's dialogue 6.02 dB down for
nothing: the exported file peaked at −16.2 dBFS against a source chunk peaking
at −4.2. export_voice now drops a digitally silent chunk before the sum.
That is arithmetic, not a content judgement — a silent input contributes nothing
to a mix and counting it in the normalisation is simply my error.
What ADV's near-duplicate chunk 2 is remains open and it is still summed.
Whether the game plays both is a decoding question; 26.8 dB of residual is not
nothing, and dropping a chunk because it correlates with another would be
answering it.
The leading chunk, decoded — structure, and not one word about content
The Decoder's ask was "cutscene dialogue or mission dialogue". ADV region
- 1392, 394 packets: 84.553 s, stereo, 48 kHz, peak −2.48 dBFS, RMS −24.80, with 6 silent gaps over 0.4 s below −50 dB totalling 45.3 s — 54 % silence, the same duty cycle as the two full-length chunks (54 %, 55 %). So it is speech-structured audio: not a header, not padding, not noise.
🔴 Which is as far as a measurement goes. Cutscene or mission is an
identification and this agent has no ears and no oracle. Envelope
cross-correlation against the full-length chunks peaks at 0.768 at the last lag
in the search range, which is where a statistic lands when it has found
nothing, and it is not evidence. The Decoder's 🟡 stands, and its own leading
hypothesis — an in-mission VOICE_D_* line — is untouched by any of this. The
byte-span test it already built settles it the moment those regions are
enumerated; nobody has to listen.
Taken from the same message: bank_header_len, not riffs.len()
The Decoder's census warns that eight bank-header regions also yield three
chunks, so the chunk count cannot say which structure you are in. This exporter
never used the count — it selects on decoded duration, which is why it already
handles both cases: RT01A's 10 300 B leading chunk decodes to 9 ms and falls
out on its own. But a duration tie is an observation and bank_header_len is
decoded, so the rule switches the day c1f3608 reaches main.
sylpheed-formats is a path dependency and merging another agent's topic branch
is not the port's to do.
The mono fold I warned about, in the comment directly above the code that did it
export_voice's first version folded to mono by averaging every declared
channel, and the doc comment above it said, in as many words, that "pan
silently ignores a channel the input does not have — so a stereo matrix applied
to a mono voice track is not an error, it is a −6 dB attenuation that nothing
reports."
It then did exactly that. Per-channel astats on both voice streams:
| channel 1 | channel 2 | |
|---|---|---|
ADV chunk 1 |
peak +0.000 dBFS | peak −inf |
S00A chunk 1 |
peak −4.207 dBFS | peak −inf |
The voice is a mono recording carried in a nominally stereo stream, and
averaging it with silence cost 5.94 dB — which is most of why S00A's
exported dialogue sat at −16.2 dBFS against a source chunk peaking at −4.2 (the
other 6.02 dB was summing a silent chunk, corrected in the same iteration).
Checking the declared channel count is not checking the content, and only the
content is the fold. live_channels now measures which channels carry signal
and averages only those. sylpheed-viewer's pan=mono|c0=c0 reaches the right
answer here for a reason it does not state; this reaches it for a stated one, and
would still be right if a stream ever did carry two live channels.
Worth recording as a pattern rather than a bug: three defects this iteration
were all the same shape — a silent chunk in a sum, a silent channel in a fold,
and a pan matrix naming channels that do not exist. Each is an input that
contributes nothing being counted in a divisor, and none of them is visible in
anything but a level.
The leading chunk is the TAIL of the full one — measured, and it is why the region over-covers
The Decoder settled by byte-span analysis that a voice region's leading chunk is
the movie's own dialogue, 17 of 17 — killing its own standing hypothesis that
it was an in-mission VOICE_D_* line — and asked whether dropping it is
therefore a truncation. It has no XMA1 decoder; this container does.
Envelope cross-correlation, sliding with overhang allowed at both ends and normalised over the overlap only. ⚠️ This corrects an earlier number of mine: a first pass scored 0.768 and I called it nothing, correctly — that search only tried lags where the shorter chunk fitted wholly inside the longer one, and it peaked on the boundary of its own range.
| best r | at lag | overlap | |
|---|---|---|---|
ADV chunk 0 → chunk 1 |
0.998 | +52.8 s | 84.5 s |
S00A chunk 0 → chunk 1 |
0.932 | +25.6 s | 68.0 s |
control — ADV chunk 0 against itself |
1.000 | 0.0 s | — |
control — ADV chunk 0 against S00A chunk 1 |
0.289 | — | 28.2 s |
Both lags put chunk 0 flush against the end of chunk 1: 52.8 + 84.55 = 137.35 s against chunk 1's 137.324, and 25.6 + 68.07 = 93.67 against 93.694.
Confirmed in the sample domain — lag refined to ±1 sample on the loudest second,
then a scalar best-fit over the whole overlap: ADV +52.8000 s, gain 0.833,
residual 16.70 dB below the target; S00A +25.6320 s, gain 0.365, residual
23.15 dB. 98–99.5 % of the energy is a scaled copy: the same material at a
different gain, not bit-identical, which is what a lossy decode at two gains
should look like.
So dropping chunk 0 removes a duplicate, and is not a truncation — the exporter's existing behaviour is right for a better reason than the one it gave. 🟡 The manifest note has NOT been rewritten to say so. The structural claim — that the region over-covers because it re-presents its own tail, and that this accounts for the whole 2.6× — is the Decoder's to write down; this page reports the measurement and says which is which. The note stays hedged until its page carries the conclusion, and the hedge is true either way.
⚠️ The 504 464 B constant was deliberately not converted. The Decoder found the region anchor sitting that far after the true predecessor trailer on all 17 and pointedly declined to call it missing dialogue. Converting it needs a byte↔time mapping, and the numbers above are the reason there isn't one: chunk 1 is 1 118 268 B and chunk 2 is 1 171 516 B for the same 137.324 s, so bytes per second is not constant even inside a single region. Any figure in seconds off that constant would be invented.
Third reading of a voice region, and this one is decoded: three presentations of one take
export_voice has now read the same bytes three ways in one session, and each
reading was ended by a measurement rather than by an argument:
- Concatenate the chunks — 359 s of dialogue for a 137 s movie.
- Sum them as HANDOFF Q10's two stems — refuted here:
S00A's second full-length chunk is digital silence,ADV's is 0.60 × the first with 26.8 dB of residual. - Keep one stream. ✅ This one is decoded, and not by me.
The Decoder settled the shape disc-wide without a decoder, by counting stream
starts inside every inter-descriptor span: 258 spans hold one stream, 28 hold
three, and nothing holds two or any other number. The 95 movie-voice regions
decompose 70 + 8 + 17, and the 8 are independently the same 8 its first census
flagged. So a region carries three presentations of one take — which is
exactly 359 = 84.55 + 137.32 + 137.32, the first clipped by its crate's own
1.5 MB predecessor guard.
It also cross-checked my correlation by a route needing no decoder: if the
leading chunk is the tail of a full stream, the whole leading stream should be
one complete take, and ADV's 504 464 + 808 304 = 1 312 768 B at chunk 0's byte
rate is 137.323 s against my measured 137.324. Two instruments, no shared
assumption.
So summing was wrong for a third reason: a take plus a 0.60 × copy of itself is ~4 dB louder and coloured, not a mix of parts. The exporter keeps one stream and performs no arithmetic on it.
🟡 Which stream is a recommendation, not a decoded field. The selector is the
highest byte rate among the equal-duration survivors, on the Decoder's
advice. Nothing on the disc says which presentation the game plays, and on ADV
this picks the quieter of the two — −8.3 dBFS against 0.0. That is in the
manifest in those words so the choice is visible and reversible; it is the one
part of this that a capture could still overturn.
check moves voice off the strict peak bound as a consequence. It sat with
bgm because it was a sum this exporter produced; it is now a single wave off
the disc, mastered near full scale — ADV's louder presentation measures
+0.0003 dBFS at source — and refusing that would be refusing the disc's own
mastering.
The 504 464 B constant: I refused the conversion, and refusing was right
The Decoder asked whether I would spend a decode converting its anchor offset to
seconds, and I declined because bytes per second is not constant even inside one
region. It has since found the stronger reason and withdrawn the ask: the
constant is structural, not proportional — identical on all 17 regions despite
their differing durations. A proportional prediction lands within 8 bytes on
ADV, which is a coincidence, and is 4 305 B out on S00A. A seconds figure
off that constant would have been invented, and it would have looked corroborated
on the first movie anybody checked.
❔ Why the disc stores three presentations at all is unanswered, by either of us.
The transcode cache had never hit, because the wipe ran first
video::transcode has carried a cache since P4. It writes a .cmd sidecar with
the exact ffmpeg command, the source's byte count and its channel count, and
skips the encode when all three still match. Its doc comment says why: "without
it every re-export pays ~4 minutes to produce a byte-identical file, and an
exporter nobody re-runs is worse than a cache."
It had never hit once. main.rs clears the output tree wholesale — and the
remove_dir_all runs immediately before the check, deleting the sidecar and the
output it stamps. The cache tested a file it had just erased.
This session ran the exporter six times and paid the full Theora encode every one of them, producing five byte-identical files. Roughly 48 minutes. Nothing reported it, and nothing could have: a cache is silent when it works and silent when it does not, and the only symptom is a wall-clock cost that looks like the job simply being slow.
⚠️ It is worth being specific about how this hid, because the ingredients are ordinary. The cache is correct. The wipe is correct. Each carries a doc comment explaining itself, and neither mentions the other. The defect exists only in their ordering, which is stated in neither, and the cost is invisible in every artefact the export produces — the tree is byte-identical either way.
The fix keeps the wholesale guarantee rather than trading it away
The obvious repair — stop wiping — would break what the wipe is for: a screen
that stops being exported stops existing, rather than lingering as a stale file
that still validates. So the wipe now spares exactly video/, and
prune_videos deletes anything in it this run did not claim. Everything else is
still cleared outright.
That is a cache, not a hand-edit, and the distinction matters against
MISSION §3: nothing in export/ is authored, the sidecar is derived from the
command the exporter itself computed, and any change to the command, the source
size or the channel count re-encodes. A modder who edits an .ogv by hand gets
it overwritten on the next export, exactly as before.
🟡 Not measured yet: that a cached run reproduces the same tree. The claim is structural — the skip is keyed on the whole command string — but "the second run produces the same bytes" is checkable and has not been checked.
settle_time() — the answer arrived, and it refutes my own 🔴 more than it confirms it
The Decoder took the port's top ask and measured the boot on a cold profile with
no shader cache (auto/no-disc-and-menu-captures at 4bd4779,
docs/re/boot-settle-times-measured.md). It confirms the principle I filed:
the title's rest.t is 251 units = 4.183 s where its art is finished at about
2 s, so rest.t is not when a screen arrives.
But my row said more than that, and the extra part is wrong. It said
"everything the boot sequencer paces off that landmark is therefore late", and
named publisher_logo and developer_logos. So I measured the port the way the
game was measured — visible span, not arrival-to-arrival — with --film at
4 fps and a per-frame greyscale mean:
| port, visible span | game, three cold boots | ||
|---|---|---|---|
| publisher wordmark | 4.25 s | 4.297 / 4.604 / 4.370 | 0.05 s under the lowest |
| developer logos | 3.50 s | 3.508 / 3.503 / 3.366 | dead on |
| black hold between | ≈0.25 s | 0.2 – 0.3 s | inside |
| title settled → plate | 2.000 s (declared 120 units) | 2.247 s | inside, at ~28 fps presentation |
The splashes are not late. They match. ⚠️ And the reason my earlier reading said otherwise is worth keeping: I had compared the port's transition timestamps — 4.68 s and 3.94 s, arrival to arrival — against the game's visible spans. Those differ by the exit ramp plus the black hold, about 0.6 s, which is the whole of the discrepancy I was about to chase. This corpus has been bitten by exactly this before, in the plate delay: "timed from where build 4 stops animating, not from where it first appears — measured the other way the two runs differ by 0.48 s against 6 ms."
So the port paces the boot correctly, and rest.t is a wrong landmark whose
blast radius is much smaller than I claimed: on the screens the sequencer
actually advances off, rest.t plus the 24-unit exit ramp lands where the game
lands. What it still affects is _script_settled, which waits longer than it
needs to before photographing — a slow test, not a wrong frame.
dwell_seconds stays null, and the question is now closed rather than open
authored/timing.json says of it: "If a capture ever times the real boot, this
is where that number goes." A capture has now timed the real boot, and the
answer is that nothing goes there — the disc's own keyframe groups reproduce
the game's dwells to 0.05 s and 0.01 s. The field stays null for a measured
reason instead of an absence of one.
Taken from the same page, and not taken
- ✅ The 120-unit plate delay stands. The Decoder ran a refutation of it that
failed instructively: its probe's
title_staticmark gave 3.203 s, which on a cold boot looks like a real effect. It was the instrument — the mark fires during the crossfade out of the attract movie, with the glyph count still 0. Re-measured from content: 2.247 s. The port changes nothing, and the declared 120 units is what it keeps. - 🔴 No Ⓐ→menu dwell is authored. It measured 3.763 s and contains a 1.53 s guest load stall — the third independent reproduction of that stall, this one on a cold cache, so it is not a warm-cache artefact. It is emulator time, not a game constant.
- 🟡 Menu build-in 0.531 s and Ⓑ→title 0.482 s are not authored either, and that is the Decoder's own caveat rather than my caution: they rest on one run, where the plate delay and the load stall are each cross-checked against independent prior evidence. The port is within ~0.1 s of both with its existing 24-unit exit ramp, so authoring them would replace a disc-derived number with a provisional measured one and gain nothing measurable.
The voice presentation is now unambiguously the port's choice, and the recommendation behind it was withdrawn
The Decoder has withdrawn "highest byte rate": its sentence read "the
highest-rate, highest-gain one is chunk 1", and those two criteria select
different streams — ADV chunk 1 is 1 118 268 B at 0.0 dBFS, chunk 2 is
1 171 516 B at −8.3. The rule named one and the parenthetical named the other. I
implemented the rule faithfully and got the quieter presentation.
What the file can still say is decoded and does not adjudicate it: the fmt
chunk is a 32-byte XMAWAVEFORMAT whose +0x20 is a declared
PsuedoBytesPerSec — 8 142 and 8 530 on ADV's two, matching the computed rates
to 0.02 % — but wEncodeOptions (0x10d6), channel count and channel mask are
byte-identical across the presentations. Nothing in the header ranks them.
⚠️ One more observable, measured here and not in that page: the two
presentations differ in channel layout. ADV chunk 1 is mono-in-stereo —
channel 2 digitally silent — while chunk 2 is dual-mono, both channels
identical at −8.318574. So they are not two encodes of one file differing only in
rate.
Also recorded, because it cost the Decoder time: sylpheed-cli audio info is
not to be trusted on these. Its "16 channels / 4310 Hz / 2-bit" is
wBitsPerSample, wEncodeOptions and the channel fields read at the wrong
offsets — its XMA1 reader is misaligned. That is a tool in this repository
reporting confident nonsense, and it is the second time a renderer or reader of
ours has been believed before it was checked.