The Decoder resolved it: palogo_eff0.prm is on BOTH splash pairs, publisher running to t=255 (256 instants) and developer to t=210 (211). My own census table had both numbers side by side and said the offset was not constant; I filed a disagreement anyway by comparing one of my numbers to one of theirs. Span convention confirmed as implemented. Records their counterfactual: the hold decides 55% of verdicts disc-wide, and palogo_eff0's measured order rules out the reading I nearly shipped. Boot gate re-run after the reorder and still holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
5111 lines
264 KiB
Markdown
5111 lines
264 KiB
Markdown
# 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:
|
||
|
||
1. the exporter emits `build_05.json` and the runtime renames it from
|
||
`authored/screen_names.json`;
|
||
2. the exporter reads that map and writes `main_menu.json` directly.
|
||
|
||
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::blit` samples 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~~
|
||
|
||
**RETRACTED 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, `rest` 62.8, timeline 80.0 — which looks like it
|
||
favours `rest`, except that the plate *adds* brightness and `rest` is 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 `rest` mode 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-cli` builds 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 resolves `ptframe1`'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.rat` declares **two** sprites — `ptbtneff01.t32`, a glowing ring, and
|
||
then the bright label — where `ptbtn0N.rat` declares 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 and `EXTRAS`, 1.34 on the
|
||
title, and it is a ramp *the game installed* (`VdGetCurrentDisplayGamma` at
|
||
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_deg` in 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-screen` says all of this in its own header, calls the CLI the
|
||
**comparison** renderer, and a `DIFFERS` row 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 with `OPTIONS` focused**,
|
||
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 a `why` naming Q5 — it is not written yet.
|
||
* **The ring's own animation is unread.** `ptbtneff01`'s two keyframes go
|
||
`rotation_deg` 0 → **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`.
|
||
|
||
```sh
|
||
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`, `--film` and `--shots` **refuse at startup** under `--headless`,
|
||
naming the flag and printing the `xvfb-run` line 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.
|
||
* `--script` **no 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
|
||
|
||
1. **The game draws `rotation_deg` on an element the English boot path shows.**
|
||
This is a second, independent confirmation on a different screen and a
|
||
different element from the `ptloop` sweeps, 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.
|
||
|
||
2. **0° is not a pose the running game shows.** `screen_view.gd` currently draws
|
||
the ring at its `rest` pose, which is `rotation_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 in `screen_view.gd` now 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 0` then an **untimed** `rot 360`. Under HANDOFF
|
||
Q1's replicated reading (*"`+36` is the time the NEXT pose is reached"*) that
|
||
is one revolution in 120 units = **2.0 s** — but this port's `pose_at`
|
||
implements 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:
|
||
|
||
```sh
|
||
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 `why` in `authored/flow.json` still said *"nothing takes the
|
||
title's place until P5 gives it somewhere to go"*. P5 has. Rewritten to say
|
||
what is actually true — `--boot` still **stops** on the title, and `--play`
|
||
**hands 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
|
||
`buttons` that 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:
|
||
|
||
1. 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.
|
||
2. 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 in `flow.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`, `FILES` and the bank headers name no
|
||
> screen. `GamePart_Title`'s phase handler `sub_821C5580` carries `li r5, 1103`
|
||
> into a sound call; cue 1103 is `BGM_103`; and `BGM_103.slb`'s two declared
|
||
> waves (3 876 864 / 3 930 112 B) are byte-for-byte the two streams the XMA probe
|
||
> saw decoding at the main menu. Static code, disc census and runtime all agree.
|
||
> **"The port does not have to choose a track."**
|
||
|
||
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. `blocked` counts 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_bed` is 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 `ffprobe`s 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 `confirm` cue 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_ffmpeg` wrote `.back.ogg.partial` → *"Unable to choose an output format"*,
|
||
a hard failure before a byte was written.
|
||
* `boot.gd` wrote `p6.wav.part` → `save_to_wav` **appends** `.wav` when the path
|
||
does not end in it, producing `p6.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 `ScreenView`s
|
||
|
||
`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 16` and 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 in
|
||
`authored/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.t` is 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.accept` returns a **new kind**, `video`, rather than folding this
|
||
into `blocked`. 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.json` carries `skipped_chain` as **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
|
||
`ffprobe` rather than assumed. This is not pedantry: `pan` silently ignores a
|
||
channel the input does not have — measured this iteration on the 5.1 fold
|
||
below, where `FLC`/`FRC`/`SL`/`SR` vanished 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:
|
||
|
||
1. **Keep the pin.** `ADV` clamps on 4 406 samples. Rejected on the measurement.
|
||
2. **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.
|
||
3. **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 own
|
||
`check` refuses 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:
|
||
|
||
1. **Concatenate the chunks** — 359 s of dialogue for a 137 s movie.
|
||
2. **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.
|
||
3. **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_static` mark 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.
|
||
|
||
## Refutation of my dual-mono inference — the measurement stands, the generalisation does not
|
||
|
||
I argued that `highest_rate` had no case because `ADV`'s higher-rate presentation
|
||
is **dual-mono** while its louder one is mono-in-stereo, so the extra bytes buy a
|
||
duplicated channel rather than fidelity. The Decoder tested that disc-wide, as a
|
||
refutation attempt, and **it fails**.
|
||
|
||
Over the 28 three-stream cues, the stream-3 / stream-2 size ratio runs:
|
||
|
||
| min | median | max | sd | within 15 % of 1.0 |
|
||
|---|---|---|---|---|
|
||
| 0.0778 | 1.2565 | 2.9163 | 0.5057 | **12 of 28** |
|
||
|
||
Declared rates scatter with them — `S06A` is 5 661 against 16 513 B/s. **A 37×
|
||
spread is not a duplicated channel.**
|
||
|
||
**The channel measurement itself stands**: `ADV` chunk 1 really is mono-in-stereo
|
||
and chunk 2 really is dual-mono at −8.318574, and that is this port's own decode,
|
||
which the Decoder could not re-run and did not dispute. What fails is the step
|
||
from *one asset* to *the format*.
|
||
|
||
### What this changes, and what it does not
|
||
|
||
Nothing in the export changes. `loudest` is a **per-asset content** rule — it
|
||
reads the peak of the actual streams in front of it — so a scattering structural
|
||
ratio cannot undermine it, and `ADV`'s dialogue at +0.3 dBFS instead of −8.7 is
|
||
plainly the better outcome either way.
|
||
|
||
What changes is the *reason*, in four places: `authored/audio.json`'s
|
||
`presentation_why`, the selector comment in `audio.rs`, `BLOCKED.md`'s row, and
|
||
this page. The honest statement is narrower and slightly less satisfying:
|
||
**`highest_rate` was never refuted — it was never argued for, and neither is
|
||
`loudest`.** Which is exactly why the entry is marked *chosen* rather than
|
||
*measured*, and why one capture deletes it.
|
||
|
||
⚠️ **This is the third claim of mine in two iterations that generalised a
|
||
single-asset observation** — after "the chunks are two stems" and "everything the
|
||
sequencer paces off `rest.t` is late". All three were true of the thing I looked
|
||
at. The pattern is not carelessness about the measurement; it is reaching for the
|
||
rule the measurement would imply if it held everywhere, and writing that down in
|
||
the same breath. The corpus catches it because someone else runs the census.
|
||
|
||
### Two things in that data that are not mine, recorded so they are not lost
|
||
|
||
* **`S12B`'s three streams are byte-size identical** (14 396 each).
|
||
* **`BIRD_224` is three-stream and is not a movie cue** — so the three-stream
|
||
shape is not exclusive to cutscenes, which narrows how it was described to this
|
||
port earlier. Neither affects `export_voice`, which only resolves movies.
|
||
|
||
## Two rows of the P1 baseline were comparing blank frames and reporting OK
|
||
|
||
`docs/port/BLOCKED.md` has carried a 🔴 since P3: *"the loading screen's fade quad
|
||
rests OPAQUE BLACK … it will bite whoever first draws a loading screen."* It had
|
||
already bitten, in the one place nobody looks — the regression harness.
|
||
|
||
`build_12` and `build_15` render as **pure black in both renderers**: mean 0,
|
||
max 0, on the Godot side and on `sylpheed-cli`'s. The difference between two
|
||
blank frames is zero, so `verify-screen` scored them `max 0 over3 0 OK` — the
|
||
strongest verdict it has. **Two of sixteen rows were comparing nothing against
|
||
nothing**, and the committed baseline reads as sixteen passes.
|
||
|
||
That is worse than a missing test. A missing test is visible in the count.
|
||
|
||
### The cause, isolated by a control rather than by reading
|
||
|
||
`build_00` and `build_01` are the *plain* loading variant — the same screen minus
|
||
three elements. They render: **mean 1.913, max 214.5**. `build_12`/`build_15` add
|
||
`pgloading_baseeff`, `pgloading_loop5` and **`pgloading_eff00`**, a 1280×720
|
||
primitive whose `rest` is `0xff000000` — opaque black — at `t=38`, inside its own
|
||
opening black hold (`0xff000000` at 38, `0xff000000` at 48, clear on the untimed
|
||
final). It carries `layer_source: "none"`, so paint order puts it **last**, over
|
||
everything.
|
||
|
||
Same screen, one element different, one renders and one does not. That is the
|
||
diagnosis, and it did not require an opinion about `rest`.
|
||
|
||
### The rule I was about to write, and the census that killed it
|
||
|
||
The obvious reading is that `rest.t = 38` is wrong because it precedes the
|
||
element's last timed keyframe at 48 — so "`rest.t` before the last timed
|
||
keyframe" would flag the pathology. **I ran the census before writing the rule,
|
||
and it does not survive: 152 of 212 elements in this export have `rest.t` earlier
|
||
than their last timed keyframe.** It is the norm.
|
||
|
||
What actually distinguishes this element is its *content*, and the reach of that
|
||
is one:
|
||
|
||
| screen | full-frame primitive | rest.t | last | rest fade |
|
||
|---|---|---|---|---|
|
||
| `build_12` / `build_15` | `pgloading_eff00` | 38 | 48 | **`0xff000000`** |
|
||
| `extras` / `extras_jp` | `pteff00` | 64 | 74 | `0x00000000` |
|
||
| `main_menu` / `main_menu_jp` | `pteff00` | 70 | 80 | `0x00000000` |
|
||
| `title` / `title_jp` | `pteff00` | 261 | 269 | `0x00000000` |
|
||
| `title` / `title_jp` | `pteff02` | 46 | 236 | `0x40000000` |
|
||
|
||
**`pgloading_eff00` is the only element in the whole export whose resting pose is
|
||
a fully opaque full-frame quad — 1 of 212.** Every other full-frame primitive
|
||
rests clear or at 25 %. One instance is not a rule about `rest`, and keying the
|
||
renderer on "an opaque full-frame quad at rest is probably wrong" would be a
|
||
content heuristic of exactly the kind this port refuses elsewhere.
|
||
|
||
### So nothing in the renderer changed, and the harness did
|
||
|
||
The screens stay black. Nothing draws a loading screen, and the honest position
|
||
is that either `rest` is mis-identified for this one element — a decoding
|
||
question, asked — or the screen really does begin fully black and `--pose=rest`
|
||
is simply the wrong thing to photograph it at.
|
||
|
||
What changed is that **a blank pair can no longer score.** `verify-screen` now
|
||
checks both frames for ink first and reports
|
||
`BLANK -- both renderers drew nothing; this row proves nothing`. It is not a
|
||
failure — the port may legitimately have nothing to draw — and `status` is
|
||
untouched, so an unrelated `DIFFERS` still fails the run. The corrected baseline:
|
||
|
||
```
|
||
build_00/01 max 3 OK press_start(_jp) max 1 OK
|
||
title max 6 DIFFERS main_menu(_jp) max 4 DIFFERS
|
||
extras(_jp) max 3 OK title_jp max 155 DIFFERS
|
||
publisher_logo(_r) max 1 OK developer_logos(_r) max 2 OK
|
||
build_12 / build_15 BLANK ← previously OK
|
||
```
|
||
|
||
Fourteen rows, not sixteen. No new drift among the fourteen.
|
||
|
||
## Refutation attempt — the loading-screen variants, and it survived
|
||
|
||
The Decoder's `ui-title-build-map.md` says entries 0/1 are the plain loading
|
||
variant at 7 elements and 12/15 the dressed one at 10, the three additions being
|
||
`pgloading_eff00`, `pgloading_loop5` and `pgloading_baseeff`. Checked against this
|
||
export, which addresses by pak entry:
|
||
|
||
| entry | elements |
|
||
|---|---|
|
||
| 0, 1 | 7 — `eff01 eff02 line loop1 loop3 loop4 str` |
|
||
| 12, 15 | 10 — the same seven **plus** `baseeff`, `eff00`, `loop5` |
|
||
|
||
**Exact, in both the count and the identity of the three.** The claim survives,
|
||
and it paid for itself immediately: the two variants differing by exactly the
|
||
black quad is what made `build_00` a control for `build_12` and turned "the
|
||
loading screen is black" into "this one element blacks it out".
|
||
|
||
## 🔴 The voice export is known incomplete — the game decodes all three streams at once
|
||
|
||
The Decoder booted with Canary's `--xma_param_probe=true` — the cvar whose own
|
||
comment exists to say which sub-wave a movie's `.slb` the game decodes — and the
|
||
answer is that **it does not pick one. It decodes all three, concurrently, in
|
||
three separate XMA contexts.**
|
||
|
||
| ctx | packets | byte_size | disc payload (RIFF − 60) |
|
||
|---|---|---|---|
|
||
| 0 | 632 | 1 294 336 | 1 294 396 |
|
||
| 1 | 546 | 1 118 208 | 1 118 268 |
|
||
| 2 | 572 | 1 171 456 | 1 171 516 |
|
||
|
||
Three-way, byte-exact. **So "three presentations of one take, pick one" is
|
||
refuted by the running game**, and the question I had been arguing about —
|
||
*which* presentation — has no answer, because its premise was wrong.
|
||
|
||
### This one was not caught by a census, and could not have been
|
||
|
||
The last three claims of mine that overreached were all killed by counting
|
||
something. This one survived every count available: the streams really are
|
||
equal-duration, one really is silence, one really is 0.60 × another with the
|
||
residual 26.8 dB down. Every measurement was right and the frame around them was
|
||
wrong, and **no amount of looking harder at the file would have moved it** — the
|
||
file says `ChannelMask = 0x0002` on all three. It took the running game.
|
||
|
||
That is the mission's own sentence arriving in practice: *the Port has no oracle
|
||
— if it needs to know what the game does, it asks.* I did ask, repeatedly, and
|
||
each time for the wrong thing: which stream, rather than whether the premise held.
|
||
|
||
### What changed, and what deliberately did not
|
||
|
||
**The behaviour is held.** Reverting to the `1/n` sum is not obviously less
|
||
wrong: an equal-gain sum of channel pairs is **not** a downmix — MISSION §6 makes
|
||
exactly that point when it pins an explicit matrix for the movies' 5.1 fold
|
||
rather than letting ffmpeg default — and the sum cost `S00A` 6.02 dB when one
|
||
stream was silence. Swapping one guess for another on the strength of a message
|
||
is what produced this entry twice already.
|
||
|
||
**What changed is that the wrongness is now loud.** ⚠️ *This failure sounds like
|
||
success*: a single stream decodes to clean, audible dialogue, so nothing a
|
||
listener hears reveals that two streams are missing. So it is stated in three
|
||
places a reader cannot miss — a top-level `manifest.json` warning per movie, the
|
||
console line (`1 of 3 streams -- KNOWN INCOMPLETE`), and the entry's own `why` —
|
||
and 🟡 became 🔴 in `authored/audio.json` and `BLOCKED.md`.
|
||
|
||
🟡 **"They are 5.1" is the Decoder's hypothesis and is not established.** Three
|
||
concurrent stereo streams is six channels and N stereo streams is how XMA carries
|
||
multichannel on the 360, which would explain the differing byte rates, the
|
||
near-silent stream, and why cues are 1-stream or 3-stream and never 2. Against
|
||
it: all three declare `ChannelMask = 0x0002` identically, which is odd for
|
||
distinct channel roles. Nothing here builds on it.
|
||
|
||
**What settles it, and it is asked:** a recording of the game's own output over
|
||
`ADV`, through the PulseAudio null sink (`AUDIO-VERIFICATION` §3). Candidate
|
||
combinations of the three decoded streams can then be correlated against what the
|
||
game actually played — which turns the channel-role question from a decode into a
|
||
fit against an oracle. Twenty seconds over dialogue is enough.
|
||
|
||
### The measurements survive; only their meaning moved
|
||
|
||
`S00A`'s silent stream and `ADV`'s 0.60 × relationship are untouched and now read
|
||
as facts about **channels**: 0.60 × with the residual 26.8 dB down is what a
|
||
correlated channel pair at a lower level looks like, and a silent channel is an
|
||
unused one. Nothing measured here is retracted. What is retracted is every
|
||
sentence that called them *presentations*.
|
||
|
||
## 🔴 The oracle capture does not contain the intro — a controlled negative
|
||
|
||
The Decoder took the capture I asked for — `adv-game-output-6ch.wav`, 70.2 s,
|
||
6 ch, 48 kHz, shared as `1788018994-16f9d19d90b8`, taken at `68aa192` — described
|
||
as *"the FULL mix, the movie's own WMA track plus the three XMA streams"*.
|
||
|
||
**It contains none of them.** Envelope cross-correlation, sliding with overhang,
|
||
normalised over the overlap, minimum 30 s of overlap so a short window cannot win
|
||
on an edge:
|
||
|
||
| capture ch | against | best *r* | runner-up | margin | |
|
||
|---|---|---|---|---|---|
|
||
| c0 | `ADV` bed | 0.361 | 0.359 | **+0.003** | no match |
|
||
| c1 | `ADV` bed | 0.407 | 0.403 | +0.004 | no match |
|
||
| c0 | voice stream 1 / 2 / 3 | 0.42 / 0.26 / 0.27 | — | ≤ +0.006 | no match |
|
||
| c1 | voice stream 1 / 2 / 3 | 0.47 / 0.34 / 0.34 | — | ≤ +0.006 | no match |
|
||
| c0 | `BGM_103` (menu bed) | 0.271 | 0.260 | +0.011 | no match |
|
||
| c0 | `S00A` | 0.351 | 0.349 | +0.002 | no match |
|
||
|
||
**The margin is the number that matters**, not *r*. A match has a *peak*; these
|
||
have a *plateau* — best and second-best differ by 0.001–0.016 across every
|
||
pairing, which is what a statistic does when no alignment exists.
|
||
|
||
### Three controls, because a negative from an uncontrolled instrument is worthless
|
||
|
||
1. **The instrument finds matches on this data.** `bed` vs `bed` → r = 1.000,
|
||
margin **+0.115**. Voice stream 2 vs stream 3 → r = 1.000, margin **+0.300**.
|
||
2. **My reference really is the movie.** The `.ogv` transcode against the disc's
|
||
own `ADV.wmv` → r = 1.000, margin **+0.114**. So a failure to match is not my
|
||
transcode.
|
||
3. **Time drift is ruled out.** A stretched playback would break a long
|
||
correlation while still matching locally, with the best lag climbing
|
||
monotonically. Five-second windows of the capture slid over the whole bed give
|
||
best lags of **4.95, 15.30, 119.35, 50.75, 29.35, 83.95 s** — scattered across
|
||
the movie, not monotonic, and every margin ≤ 0.017.
|
||
|
||
### What I can say, and what I will not
|
||
|
||
**Said:** this capture cannot answer the channel-role question, and it is not the
|
||
intro's audio. **Not said:** what it *is*. It is 70 s of something, all six
|
||
channels carrying signal at RMS ≈ −27 dBFS, matching nothing this port exports.
|
||
Diagnosing it is the Decoder's side of the wall and I have handed it back rather
|
||
than guessing.
|
||
|
||
⚠️ **One measurement on the file that may help them.** Split as 5.1, **channels 3
|
||
and 6 are byte-identical** — same MD5, not merely the same peak and RMS to six
|
||
decimals. An exact duplicate pair inside a six-channel "surround" output is
|
||
consistent with the Decoder's own warning that the 6-channel frame is Xenia's
|
||
hardcoded `kFrameChannelsDefault`, not the guest's request. It weakens, further,
|
||
any reading of that file as evidence of a 5.1 game mix.
|
||
|
||
### The voice export stays exactly as it is
|
||
|
||
Still one stream of three, still marked 🔴 in the manifest, the console line and
|
||
`authored/audio.json`. **The capture changed nothing**, which is the correct
|
||
outcome for a measurement that failed: the question is open, and it was open
|
||
before. What would have been wrong is treating a 70 s recording as an oracle
|
||
because it was expensive to obtain.
|
||
|
||
|
||
### Resolved the same day — it was the capture path, and the duplicate pair was the thread
|
||
|
||
The Decoder found the cause and **withdrew the capture**: PulseAudio was
|
||
remapping between two mismatched channel maps, and a 6-channel remap **silently
|
||
drops and duplicates**. Its control needs no emulator and no disc — six channels,
|
||
six different tones, the same sink and the same `parec` invocation — and came
|
||
back `400 / 3200 / 200 / 800 / 800 / 200` for an input of
|
||
`400 / 800 / 200 / 1600 / 3200 / 6400`. **Two source channels were gone
|
||
entirely.** Setting the sink's `channel_map` to the guest's own returns all six.
|
||
|
||
So the negative was right and, more usefully, **the byte-identical pair I
|
||
reported was the thread that unravelled it.** That is worth recording precisely,
|
||
because it was nearly not reported at all: it began as an idle check of two
|
||
channels whose peak *and* RMS matched to six decimals, and the only reason it
|
||
became evidence is that a coincidence at six decimals is cheaper to hash than to
|
||
explain.
|
||
|
||
**Withdrawn with the file**, both the Decoder's: *"all six channels carry
|
||
signal"*, and the non-zero-surround observation offered as weak support for a 5.1
|
||
guest mix. Unaffected: the three-XMA-context concurrency result, which is read
|
||
from the emulator's own log rather than the audio path, on two independent boots.
|
||
|
||
### What the port took from it: `tools/port/check-capture`
|
||
|
||
A capture now has to pass a provenance check before anyone analyses it, and it is
|
||
one command. It splits the file, hashes every channel and fails on any duplicate
|
||
pair. Documented in `docs/port/AUDIO-VERIFICATION.md` §5.
|
||
|
||
**Run through its own controls, both directions**, because a checker nobody
|
||
controlled is the thing this whole incident is about:
|
||
|
||
* six distinct tones → **PASS**;
|
||
* the remap's own output pattern → **FAIL**, naming all four duplicate pairs;
|
||
* the corrupt game capture → **FAIL** on `ch2 == ch5`.
|
||
|
||
⚠️ **The known-bad control is the part worth reading.** All six of its channels
|
||
report a peak of **−18.063656 dB — identical to six decimals — while containing
|
||
three duplicate pairs.** A level check cannot see this failure at all. That is
|
||
why the tool hashes rather than measures, and it is why the corrupt capture's
|
||
"plausible per-channel levels" were never evidence of anything.
|
||
|
||
The tool says so itself: it is **necessary, not sufficient.** Passing means no
|
||
channel was duplicated; it says nothing about whether the right thing was
|
||
recorded. A capture should survive both that and §1's correlation against a known
|
||
source before anything is concluded from it — and the one that was analysed here
|
||
would have failed the cheap check in thirty seconds.
|
||
|
||
The corrupt file is withdrawn from the exchange (`share drop`), so the next agent
|
||
cannot pick it up and repeat the work.
|
||
|
||
## 🔴 Take 2 is clean, my instrument was not, and the negative had to be re-earned
|
||
|
||
The Decoder's second capture passes `check-capture` — I re-ran it myself rather
|
||
than cite theirs — carries a screen log, and was recorded with the sink's
|
||
`channel_map` set equal to Canary's own. It is a good file.
|
||
|
||
⚠️ **One provenance discrepancy, minor but worth stating:** the message gives
|
||
253.3 s; the file is **318.539 s**. The screen log runs to 316 s and is
|
||
consistent with the file, so this is a mis-stated number rather than a bad
|
||
capture — but a length quoted in a provenance claim should match the artefact.
|
||
|
||
### The retraction that came out of measuring it
|
||
|
||
Take 2 also showed no alignment with the bed or the voice streams. Before
|
||
reporting a second negative I asked whether the method could do the job at all,
|
||
by building a **synthetic mix** — the bed plus the three voice streams — and
|
||
hunting the bed inside it.
|
||
|
||
**It failed. r = 0.415, against the `r > 0.8` bar my earlier negatives were
|
||
judged against.**
|
||
|
||
So the instrument that produced *"the capture contains no ADV audio"* could not
|
||
have found ADV audio in a mix even when it was certainly there. That conclusion
|
||
was right — the Decoder's tone control proved take 1 corrupt independently — but
|
||
**it was right by luck, and I reported it as measurement.** The three controls I
|
||
was pleased with tested the wrong things: that the method finds a *clean* signal
|
||
in a *clean* reference, which was never the task.
|
||
|
||
### The rebuilt instrument, calibrated in both directions
|
||
|
||
Band-limit so the target dominates, then judge on **lag and margin**, not on
|
||
absolute *r* — the `r > 0.8` bar is correct clean-against-clean and meaningless
|
||
for a component in a mix.
|
||
|
||
| hunting | band | against | *r* | lag | margin |
|
||
|---|---|---|---|---|---|
|
||
| the bed | 40–180 Hz | mix containing it | 0.663 | **0.0 s** ✓ | **+0.111** |
|
||
| the bed | 40–180 Hz | voice-only mix | 0.262 | wrong ✗ | +0.005 |
|
||
| voice stream 2 | 300–3000 Hz | mix containing it | 0.810 | **0.0 s** ✓ | **+0.248** |
|
||
| voice stream 2 | 300–3000 Hz | the bed alone | 0.358 | wrong ✗ | +0.005 |
|
||
|
||
A 20–50× separation in the discriminating statistic. Documented as
|
||
`AUDIO-VERIFICATION.md` §6.
|
||
|
||
### And now the negative, supported
|
||
|
||
Every one of take 2's six channels, against both targets, sits in the
|
||
**known-absent** regime:
|
||
|
||
| | bed (40–180 Hz) | voice stream 2 (300–3000 Hz) |
|
||
|---|---|---|
|
||
| margins | +0.000 … +0.014 | +0.001 … +0.017 |
|
||
| lags | −58 … +255 s, scattered | −72 … +183 s, scattered |
|
||
|
||
**Take 2 contains neither the movie's WMA bed nor the cutscene voice**, on an
|
||
instrument that demonstrably finds both when they are present.
|
||
|
||
### What that leaves, and it is not mine to answer
|
||
|
||
Two captures, differently configured, the second provably free of the channel-map
|
||
fault, with a screen log saying the movie was on screen — and **neither carries
|
||
either audio source.** That points away from a one-off setup error. The
|
||
possibilities I can see are a capture path that still loses the guest's mix, or
|
||
the guest not emitting these sources at all during the movie, and **only one side
|
||
of that wall can tell them apart.** Handed back with the numbers.
|
||
|
||
⚠️ **If it is the second, it reaches the port directly**: the export's movie audio
|
||
comes from the `.wmv`'s WMA track, and if the game never plays that track, then
|
||
`ADV.ogv`'s audio is wrong in a way no amount of transcode fidelity would fix. I
|
||
am not asserting that — it is a question about what the game does — but it is the
|
||
reason this is worth another boot rather than being written off.
|
||
|
||
## Every music bank was summed at 1/3 when only two sub-waves are music — 3.52 dB, since P6
|
||
|
||
The Decoder's message about `BGM_102` came with declared durations from the
|
||
corrected XMA1 `PsuedoBytesPerSec`, and checking my export against them turned up
|
||
a defect of mine that had been shipping since P6.
|
||
|
||
`export_bgm` summed every sub-wave `media` returned and scaled by `1/n`. Decoded
|
||
and timed, the three banks are identical in shape:
|
||
|
||
| bank | sub-wave 0 | sub-wave 1 | sub-wave 2 |
|
||
|---|---|---|---|
|
||
| `BGM_103` | **10 300 B → 0.009 s, peak −inf** | 3 876 924 B → 87.744 s | 3 930 172 B → 87.744 s |
|
||
| `BGM_102` | **10 300 B → 0.009 s, peak −inf** | 1 151 036 B → 37.482 s | 1 269 820 B → 37.482 s |
|
||
| `BGM_001` | **10 300 B → 0.009 s, peak −inf** | 4 466 748 B → 173.809 s | 4 673 596 B → 173.809 s |
|
||
|
||
**Sub-wave 0 is digitally silent in all three**, and 10 300 B is 10 240 + a
|
||
60-byte RIFF wrapper — 10 240 B being exactly what the Decoder's disc-wide census
|
||
identifies as the bank header. So it is not a stem. Counting it in the divisor
|
||
put every real stem at 1/3 instead of 1/2: **3.52 dB of attenuation on all the
|
||
menu music this port has shipped since P6.**
|
||
|
||
Dropping it is **arithmetic, not a decoding decision** — a silent input
|
||
contributes nothing to a sum, and this is the same rule `export_voice` already
|
||
applies. Measured after the fix: `main_menu.ogg` goes **−7.69 → −4.20 dBFS**,
|
||
**+3.49 dB** against 3.52 predicted, the remainder being Vorbis.
|
||
|
||
⚠️ **This is the third instance of one defect in this pipeline** — a silent chunk
|
||
in the voice sum, a silent channel in the mono fold, and now a silent sub-wave in
|
||
the music sum. Each was invisible in every check except a level, and each time
|
||
the divisor was computed from *how many inputs there are* rather than *how many
|
||
carry signal*. That is the shape to look for, not the individual bug.
|
||
|
||
### It also closes a 🔴 that has been open since P6
|
||
|
||
`docs/port/BLOCKED.md` carried *"`media::sound_bank_riffs` returns three
|
||
sub-waves where HANDOFF Q10's census says two"* as a disagreement the port shipped
|
||
deliberately. The census was right; the third was never a stem. The export now
|
||
reports **2 sub-waves** and the warning is gone — closed by measurement on my
|
||
side, corroborating the Decoder's `c1f3608` from a different direction (decoding
|
||
it, rather than counting headers).
|
||
|
||
### The declared-rate method, cross-checked a third time — and one correction
|
||
|
||
Their declared lengths against my decodes: `BGM_103` 87.750/87.749 vs **87.744**;
|
||
`BGM_102` 37.487 vs **37.482**; `BGM_001` 173.821 vs **173.809**. Agreement to
|
||
**5–12 ms** on three banks. The method is good for lengths.
|
||
|
||
🟢 **Refutation attempt, and the conclusion survives while the reasoning does
|
||
not.** The Decoder wrote that `BGM_001` reads *"173.821 s declared against your
|
||
decoded 167.663 s — a gap of 6.158 s"*, explaining it as *"declared is the
|
||
encoded stream, decoded is where the audio stops."* **A full decode of
|
||
`BGM_001` yields 173.809 s of PCM, not 167.663 s.** The 167.663 figure is where
|
||
the music *fades out*, measured from the audio; the stream then continues, silent,
|
||
to its declared end. So declared and decoded agree to 12 ms and the trailing
|
||
silence is *inside* the decode, not the difference between two methods. The
|
||
cross-check stands — better than stated, since it is now three banks rather than
|
||
a coincidence — and the sentence explaining it should go.
|
||
|
||
## Take 2 was starved, my correlator was fine, and `check-capture` was incomplete
|
||
|
||
The Decoder diagnosed take 2: a **starved** capture. Verified here independently
|
||
rather than taken on trust — 35.6 % of frames silent on all six channels, 10 482
|
||
alternating runs, median burst 13.5 ms and gap 3.9 ms, a 17.4 ms period at 57 Hz.
|
||
Their untruncated original reads 39.3 % and 10 595 runs; the difference is
|
||
exactly the truncation, and every other number agrees.
|
||
|
||
**So my rebuilt correlator was working correctly on a file that could not carry
|
||
the signal.** ✅ And the alarming reading it produced — *"the game may not play
|
||
the `.wmv`'s WMA track, so `ADV.ogv`'s audio has been wrong since P4"* — **is not
|
||
supported by this capture, and is not refuted either.** It is withdrawn as a
|
||
concern arising from evidence, and nothing is changed on account of it in either
|
||
direction. That matters more than it looks: it was the most expensive-to-act-on
|
||
hypothesis in the port, and it came from a file that could not speak to it.
|
||
|
||
### The real deliverable: my own checker passed the starved file
|
||
|
||
`check-capture` tested only for duplicated channels, so it cleared a recording
|
||
that was 36 % holes. A provenance check that passes the artefact it was built in
|
||
response to is not a check.
|
||
|
||
It now measures starvation too. ⚠️ **Two thresholds I invented were both wrong,
|
||
and the controls caught both** — which is the part worth recording:
|
||
|
||
1. **Counting exact-zero frames.** Real audio crosses zero constantly; a clean
|
||
voice track scored 5 947 "gaps" of median 0.0 ms and was called starved. **A
|
||
gap is a run, not a sample.** Only runs ≥ 1 ms count.
|
||
2. **Gap count and median length.** A genuine music bed shows **454 gaps at a
|
||
median of 1.4 ms** — quiet 16-bit passages really are zero for milliseconds —
|
||
so neither statistic separates it from a starved file.
|
||
|
||
What separates them is the **rate**:
|
||
|
||
| | gaps/s | median gap | all-channel silence |
|
||
|---|---|---|---|
|
||
| the starved capture | **32.9** | 3.9 ms | 35.6 % |
|
||
| a real music+SFX bed | **3.3** | 1.4 ms | 1.1 % |
|
||
| a voice track, 53 % pauses | **0.03** | — | — |
|
||
|
||
Bar set at **20 gaps/s** — 1.6× below the bad case, 6× above the worst good one,
|
||
**derived from the controls rather than chosen and then justified.** Controlled
|
||
in both directions: real stereo bed PASS, six distinct tones PASS, starved
|
||
capture FAIL. It also now reports a `data` chunk declaring 0 bytes, which is what
|
||
a file copied while still being written looks like.
|
||
|
||
### 🔴 The voice channel roles are not obtainable this session
|
||
|
||
Both capture routes are closed and the Decoder has said so plainly. The monitor
|
||
sink is starved **by construction** — it advances at wall-clock rate and
|
||
substitutes silence, so every moment the emulator runs slow is a hole, and
|
||
deleting the holes warps the timebase rather than repairing it. The route that
|
||
works is an internal tap at `SDLAudioDriver::SubmitFrame`, and that needs a
|
||
Canary rebuild whose cost they have measured: the build root `build-canary`
|
||
targets does not exist in that container, the warm tree is configured against the
|
||
same missing path, so it is a full reconfigure and compile on a box with ~700 MB
|
||
free and a history of parallel builds OOM-killing the host.
|
||
|
||
**A whole session for one probe.** That is a human's call and neither agent
|
||
should start it mid-loop. Until then the port keeps authoring with the known
|
||
recorded: one stream of three, 🔴 in the manifest, the console line and
|
||
`authored/audio.json`.
|
||
|
||
## The settle run carries an unmeasured real-time factor — and the numbers it touches were already unauthored
|
||
|
||
The Decoder has withdrawn one of the two arguments propping up its settle-time
|
||
run. It had claimed the plate *pulse period* was an internal clock proving the
|
||
run was not slowed; re-examined, that estimate rests on **one interval at a
|
||
125 ms sample interval (±6.7 %)**, and re-running the trough-picking gives
|
||
**2.628 s** rather than the 2.369 quoted — an adjacent local minimum had been
|
||
counted as a separate trough. Against the corpus's 2.24 s that is **+17.3 %**. It
|
||
is too weak to show anything and **cannot resolve a real-time factor below
|
||
~7 %**.
|
||
|
||
**✅ Nothing in the port moves, and this is the second time in two iterations that
|
||
the right call was to have authored nothing.**
|
||
|
||
| number from that run | anchored by | did the port take it? |
|
||
|---|---|---|
|
||
| title → plate, 2.247 s | three prior readings (2.13 / 2.132 / 2.138) and the disc's declared 120 units | ✅ it is what the port already draws |
|
||
| menu build-in, 0.531 s | **nothing** | ❌ not authored |
|
||
| Ⓑ → title, 0.482 s | **nothing** | ❌ not authored |
|
||
| Ⓐ → menu, 3.763 s | contains a 1.53 s load stall | ❌ explicitly refused |
|
||
|
||
Checked rather than remembered: `grep` over `authored/` and `port/scripts/` finds
|
||
no `0.531` and no `0.482`. The only build-in reference in the tree is the plate
|
||
arithmetic — `t=118 → t=238`, 120 units — which is the **anchored** leg.
|
||
|
||
I declined those two because they were one-run figures the Decoder had itself
|
||
flagged, and because the port was already within ~0.1 s of both from the disc's
|
||
own keyframes. **That reasoning has now been joined by a second, independent one
|
||
I did not have at the time**: a few per cent of slowdown sits inside them
|
||
undetected. A provisional measurement adopted over a decoded number would have
|
||
imported an error nobody could see.
|
||
|
||
## `verify-dwell` — the comparison that refuted my own 🔴, made repeatable
|
||
|
||
Last iteration I measured the port's visible spans against the oracle's dwells by
|
||
hand, and it refuted a red flag I had filed myself: `rest.t` *is* the wrong settle
|
||
landmark, but *"everything the sequencer paces off it is therefore late"* was
|
||
false, and I nearly went and re-paced screens that already matched.
|
||
|
||
That check existed once, in a transcript. It is now `tools/port/verify-dwell`.
|
||
|
||
```
|
||
screen port oracle (3 cold boots) verdict
|
||
publisher wordmark 4.25 s 4.297 / 4.604 / 4.370 agrees
|
||
developer logos 3.75 s 3.508 / 3.503 / 3.366 agrees
|
||
```
|
||
|
||
⚠️ **The trap it exists to prevent is in its header, because it is the whole
|
||
point:** a port's *transition timestamps* and the oracle's *visible spans* are
|
||
not the same quantity — they differ by the exit ramp plus the black hold, about
|
||
0.6 s, which was the entire discrepancy I was about to chase. This corpus has
|
||
been bitten by the identical confusion before, on the plate delay, where the two
|
||
readings differ by 0.48 s against 6 ms.
|
||
|
||
**The bar is the oracle's own run-to-run spread**, plus one film interval. Three
|
||
cold boots of the real game differ by 0.3 s; agreeing more tightly than the
|
||
oracle agrees with itself would not mean anything, and a tighter bar would be a
|
||
number chosen to look impressive. The developer-logo span read 3.50 s on the
|
||
hand-run and 3.75 s here — one film interval apart, both inside the bar, which is
|
||
the tool reporting its own resolution honestly rather than hiding it.
|
||
|
||
**The oracle's numbers are quoted in the script as a test fixture and labelled as
|
||
the oracle's**, with the RE document they come from. Nothing in the port derives
|
||
them and nothing may.
|
||
|
||
## The `PRESS Ⓐ` plate pulses — authored per element, because the census forbids a rule
|
||
|
||
The human listed pulsation as first-class and the port drew nothing at all: the
|
||
plate's focus record `ptbtn00f` was never reached, because `press_start` has no
|
||
`buttons` and nothing is focused.
|
||
|
||
**That it loops is measured**, not assumed. The corpus timed the pulse four times
|
||
— 2.12 / 2.19 / 2.34 / 2.31 s — and you cannot measure a period unless the thing
|
||
repeats.
|
||
|
||
### The rule I was going to write, and the census that forbade it
|
||
|
||
The spinning ring is a **rule** in the renderer (`spin_period_units`) and it
|
||
earns that: 16 of 212 elements match its shape and **all 16 are focus rings**,
|
||
zero false positives. So I looked for the analogous shape for a pulse — a group
|
||
whose keyframes vary **only** in alpha, whose first and last alpha are equal, a
|
||
closed cycle. `ptbtn00f` fits it exactly: `0 → 6 → 74 → 80 → 80 → 74 → 6 → 0`.
|
||
|
||
**Censused before writing it: 82 of 212 elements match.** `ptcopyright`,
|
||
`palogo_sqex`, `ptmsg`, `ptlogo_back2`, and every `_eff` fade-in-hold-fade-out on
|
||
every screen. **A renderer rule on that shape would make the copyright notice
|
||
pulse.**
|
||
|
||
Narrowing to focus records leaves exactly **one** distinct element (plus its JP
|
||
twin). A rule justified by n = 1 is a special case wearing a rule's clothes. So
|
||
the pulse is a **lookup** in `authored/timing.json`, keyed `<screen>/<element>`,
|
||
and the census is recorded beside it so nobody widens it later.
|
||
|
||
### The period is the element's own group — and the alternative is stated
|
||
|
||
**129 units**: its last timed keyframe is t=105, and the final untimed keyframe
|
||
is reached `exit_ramp_units` (24) later. **No new constant** — 24 is the same
|
||
authored value every other element's exit already uses. That is 2.150 s at
|
||
60 units/s, or 2.295 s at the ~28.1 fps the emulator presents, against
|
||
measurements of 2.12–2.34 s. It sits inside the spread at either rate.
|
||
|
||
⚠️ **It is a choice, and the alternative cannot be ruled out**: the cycle could
|
||
restart at the group's first keyframe (t=6) rather than at 0, giving 123 units =
|
||
2.050 / 2.189 s — **also inside the measured spread**. Nothing available
|
||
separates them. t=0 is taken because it is where every other group in this port
|
||
starts, which is consistency and not evidence, and `authored/timing.json` says so.
|
||
|
||
A fifth reading is recorded and **not averaged in**: the Decoder re-picked its
|
||
troughs and got 2.628 s having previously reported 2.369 from the same run, then
|
||
withdrew the estimate as too weak to resolve better than ~7 %.
|
||
|
||
### Verified the way the ring was — bit-identity one period apart
|
||
|
||
20 authored periods is 2.15 × 20 = **43.00 s = exactly 172 film frames**, so
|
||
frames N and N+172 must be the same frame:
|
||
|
||
| | max difference |
|
||
|---|---|
|
||
| f_055 vs f_227 (43.00 s) | **0/255** |
|
||
| f_060 vs f_232 (43.00 s) | **1/255** |
|
||
| f_070 vs f_242 (43.00 s) | **1/255** |
|
||
| f_079 vs f_251 (43.00 s) | **0/255** |
|
||
| **control** — f_070 vs f_243 (43.25 s) | **58.7/255** |
|
||
|
||
The control is what makes the rest mean anything: a quarter-second off the period
|
||
differs by 58.7, on the period by 0–1. Measured on the held boot title, where the
|
||
glow-box mean swings **26.0 ↔ 37.7** — a real pulse, not a static glow.
|
||
|
||
## ✅ The oracle finally speaks: the exported voice IS the game's centre channel
|
||
|
||
The Decoder's fourth capture is the first faithful one — `--gpu=null` takes the
|
||
guest from 0.70× to 0.96× real time so Xenia stops padding, an ALSA `file` tee in
|
||
front of a paced slave removes PulseAudio's wall clock entirely. **59.7 s, 0.35 %
|
||
silence, one gap in the whole file, six distinct channel hashes.** Checked here
|
||
independently: it passes `check-capture`, and its header sizes verify exactly
|
||
(RIFF 34 369 572 / data 34 369 536 against 34 369 580 actual).
|
||
|
||
The correlator was already calibrated, so the fit was a re-run rather than a
|
||
rebuild. **Controls first, on this instrument: known-present margin +0.248,
|
||
known-absent +0.005.**
|
||
|
||
### Speech band, 300–3000 Hz
|
||
|
||
| | FL | FR | **FC** | LFE | RL | RR |
|
||
|---|---|---|---|---|---|---|
|
||
| stream 1 (leading) | +0.013 | +0.006 | +0.012 | +0.009 | +0.012 | +0.005 |
|
||
| **stream 2 — the one exported** | +0.238 | +0.171 | **+0.305** | +0.011 | +0.035 | +0.006 |
|
||
| stream 3 | +0.240 | +0.173 | **+0.307** | +0.009 | +0.054 | +0.006 |
|
||
|
||
**`r = 0.989` on FC, margin +0.305 — above the known-present control.** And the
|
||
bed, in the low band, is the mirror image: FL 0.763 / FR 0.838 / RL 0.805 / RR
|
||
0.817, all agreeing on the same lag, and **FC 0.317**.
|
||
|
||
### What is established, and what is not
|
||
|
||
✅ **The capture contains `ADV`'s audio**, at lag ≈ +6.6–6.7 s — agreed by two
|
||
independent bands and by six channels.
|
||
|
||
✅ **The dialogue is in the centre channel and the bed is in the four corners.**
|
||
FC carries the voice and not the bed; FL/FR/RL/RR carry the bed and not the
|
||
voice. That is a textbook film mix, and it is measured rather than inferred from
|
||
a header — which matters, because the header says `ChannelMask = 0x0002` on all
|
||
three streams and would never have told us.
|
||
|
||
✅ **The port's exported voice file is the material the game plays in FC.** The
|
||
`loudest` choice in `authored/audio.json` — which I recorded as an unjustified
|
||
choice — selects the dialogue. A mono voice file played into the mix is the
|
||
right approximation of a centre channel.
|
||
|
||
⚠️ **Streams 2 and 3 are indistinguishable to this instrument**, and that is
|
||
expected: I measured months-of-iterations ago that stream 3 is 0.60 × stream 2
|
||
with the residual 26.8 dB down. They are the same take at two levels, so they
|
||
correlate identically with everything. **This does not say `loudest` picked the
|
||
*right* one of the two — only that whichever it picked is the dialogue.**
|
||
|
||
⚠️ **Stream 1 is not detectable in this window**, margins +0.005…+0.013. Consistent
|
||
with it being the tail of stream 2 (measured earlier at r = 0.998) and with a
|
||
59.7 s window that starts before the tail.
|
||
|
||
🔴 **The `1 of 3 streams` warning stays.** Nothing here explains what the other
|
||
two contribute to the game's output, and the export still ships one. What changed
|
||
is its character: it is no longer *"one of three, contents unknown"* but *"the
|
||
centre-channel dialogue, plus two streams whose relationship to it is measured
|
||
and whose role is not."*
|
||
|
||
⚠️ **And the reach: 59.7 s of a 137 s movie**, one run, at 0.96× real time. The
|
||
`--gpu=null` route costs video, so this capture has no screen provenance — its
|
||
provenance is the XMA probe showing `ADV`'s three streams decoding during the
|
||
run, which for an audio question evidences the thing recorded rather than what
|
||
was on screen.
|
||
|
||
## The stripping control passes — `S00A` is obtainable, and the gate is cleared
|
||
|
||
The Decoder made this the gate on `S00A`, and it is the right call: `ADV` plays
|
||
itself on boot so it can be captured with `--gpu=null` at 0.96× real time, but
|
||
`S00A` starts ~4.5 s after Ⓐ on a save slot, which needs a **driven** run, which
|
||
needs screens, which rules out `--gpu=null`. So `S00A` is necessarily the 0.70×
|
||
rendered route with ~10 % additive padding — and is only worth a boot if
|
||
stripping that padding is exact.
|
||
|
||
**It is.** A real music+SFX bed (137.37 s, carrying 454 genuine zero runs of its
|
||
own) had 1 149 holes inserted at 8.37/s to +9.9 % length, matching the observed
|
||
ALSA profile, then was stripped and correlated in the low band:
|
||
|
||
| | *r* | lag | margin |
|
||
|---|---|---|---|
|
||
| original vs itself — **ceiling** | 1.000 | 0.0 s | +0.141 |
|
||
| **padded** vs original — what padding costs | **0.436** | −12.2 s | **+0.006** |
|
||
| **stripped** vs original — recovered | **1.000** | **0.0 s** | **+0.142** |
|
||
| stripped vs original-also-stripped | 1.000 | 0.0 s | +0.143 |
|
||
|
||
**Two things worth reading off that table.**
|
||
|
||
First, **padding at that profile destroys correlation completely** — r 0.436,
|
||
margin +0.006, which is the known-absent regime. That independently confirms, on
|
||
a file whose contents I control, that the earlier captures were unusable for the
|
||
reason claimed rather than for some other reason.
|
||
|
||
Second, **recovery does not require stripping both sides.** The stripped capture
|
||
matches the *unstripped* source at the ceiling. That matters operationally: the
|
||
port's reference assets never need touching.
|
||
|
||
⚠️ **What the control does not license.** Stripping removes genuine silence too
|
||
and cannot tell the two apart. On this material the genuine runs total 0.71 s in
|
||
137 s and cost nothing measurable; on material that is mostly silence they would.
|
||
And the whole thing rests on the **substituted-versus-additive** distinction — it
|
||
is valid for Xenia's ALSA padding, which inserts, and it is vandalism on a
|
||
PulseAudio monitor capture, which substitutes. `tools/port/strip-padding` says so
|
||
in its header before it says anything else, because running it on the wrong
|
||
artefact would look like it worked.
|
||
|
||
Its output is **byte-identical** to the control's own stripping, so the tool and
|
||
the experiment are the same operation rather than two implementations that agree.
|
||
|
||
## The correctness harness the docs promised for eight milestones did not exist
|
||
|
||
`tools/port/verify-screen`, line 20, since P1: *"Use `tools/verify-capture` for
|
||
the correctness question."* **There was no such file.** The port has had a harness
|
||
comparing itself to `sylpheed-cli` — two renderers sharing its assumptions — and
|
||
none comparing it to the game, while its own documentation said otherwise.
|
||
|
||
`docs/re/captures/ORACLE-CAPTURES.md` is blunt about why that matters: two
|
||
renderers agreeing proves nothing, and this corpus has been bitten three times —
|
||
the dropped `pteff05` background, the scale-0 rect, `rest()` — each invisible to a
|
||
render-vs-render diff and obvious against a capture.
|
||
|
||
`tools/port/verify-capture` now exists. **Five screens, against framebuffer
|
||
captures of the real game:**
|
||
|
||
| screen | RMSE | differing | note |
|
||
|---|---|---|---|
|
||
| `main_menu` | 14.79 | **0.25 %** | focus state may differ |
|
||
| `extras` | 15.29 | 0.46 % | focus state may differ |
|
||
| `title` | 21.07 | 1.82 % | `ptloop` sweeps never stop |
|
||
| `publisher_logo` | 10.77 | 1.00 % | |
|
||
| `developer_logos` | 9.37 | 0.39 % | |
|
||
|
||
**No screen shows a large connected blob** — the shape a missing or misplaced
|
||
element makes, and the shape all three historical failures made. The differences
|
||
are scattered, and the two largest have stated causes.
|
||
|
||
### 74 % of `main_menu`'s difference is the oracle's own focus signature
|
||
|
||
The corpus ships `live-main-menu.png` and `live-main-menu-options-focused.png` —
|
||
the same screen with a different button lit. Their difference *is* what focus
|
||
changes, measured by the oracle against itself. Of the port's 2 159 differing
|
||
pixels, **1 599 — 74.1 % — fall inside that signature.** So the bulk of the
|
||
disagreement is a state mismatch (the port focuses `NEW GAME`, authored, because
|
||
HANDOFF Q5 measured initial focus as unstable), not a rendering defect.
|
||
|
||
## Refutation attempt — the tone curve survives in its stated reach and not past it
|
||
|
||
`ui-render-tone-curve.md` models the relationship as
|
||
`capture = 255·(render/255)^γ`, γ ≈ 1.34–1.49, **measured on dark flat patches
|
||
(render ~0–60), with "nothing constrains midtones or highlights"** written into
|
||
its own reach.
|
||
|
||
**I tried to fit that γ and got contradictory answers three times, and the
|
||
contradictions were mine.** Binning every structurally matched pixel of
|
||
`main_menu` by render level gives the relationship directly:
|
||
|
||
| render | capture | implied γ | pixels |
|
||
|---|---|---|---|
|
||
| 8 | 4.04 | 1.20 | 183 026 |
|
||
| 16 | 7.89 | **1.26** | 227 630 |
|
||
| 24 | 15.57 | 1.18 | 100 945 |
|
||
| 32 | 26.15 | 1.10 | 87 474 |
|
||
| 40 | 38.07 | 1.03 | 86 094 |
|
||
| 48 | 53.96 | **0.93** | 85 255 |
|
||
| 64 | 78.52 | 0.85 | 6 509 |
|
||
| 96 | 130.44 | **0.69** | 1 682 |
|
||
|
||
✅ **The claim survives where it was measured.** In the darks the capture really
|
||
is darker than the render and γ > 1.
|
||
|
||
🔴 **It is not a single power law.** The implied exponent falls monotonically and
|
||
**crosses 1.0 near render ≈ 44** — above that the capture is *brighter*. One
|
||
exponent cannot express a curve that crosses unity, which is precisely why my
|
||
whole-frame fits kept returning γ = 1.00: the darks want more than 1 and the
|
||
midtones want less, and they cancel.
|
||
|
||
**So the corpus's stated reach was not a hedge, it was the finding.** ⚠️ And the
|
||
exponent in the darks measures **1.18–1.26 here against the page's 1.49 for this
|
||
screen** — a disagreement I am recording rather than resolving, since they fit
|
||
selected flat patches and I binned every matched pixel.
|
||
|
||
### The tool reports the curve, not a best exponent
|
||
|
||
Two earlier versions of `verify-capture` reported a best-fit γ and were wrong
|
||
both times — once by fitting across a 74 % structural mismatch, once by
|
||
extrapolating past a reach the measurement's own authors had written down.
|
||
**Extrapolating a measurement past its stated reach is how this tool got it wrong
|
||
twice**, and the answer was not a better fit but a different instrument: a table
|
||
somebody can argue with.
|
||
|
||
## Identifying the capture's focused button — and my harness was posing the port wrong
|
||
|
||
The Decoder attached an honest caveat to its reproduction of the tone-curve
|
||
refutation: its bins included the focus-state mismatch, so it was *"not a clean
|
||
second opinion"*. That is removable, and removing it found a defect of mine.
|
||
|
||
### The method, with a known-answer control
|
||
|
||
`--menu=main_menu --script=down,down,down,down` walks focus through all five
|
||
buttons and shoots each. Compare every one against a capture; the minimum
|
||
identifies the focused button. **The control is the capture whose answer is in
|
||
its own filename:**
|
||
|
||
| render focus | vs `live-main-menu-options-focused` | vs `live-main-menu` |
|
||
|---|---|---|
|
||
| `ptbtn01` NEW GAME | 6 351 | **531** |
|
||
| `ptbtn02` LOAD GAME | 7 087 | 7 094 |
|
||
| `ptbtn03` TUTORIAL | 6 230 | 6 237 |
|
||
| `ptbtn04` OPTIONS | **1 292** | 6 364 |
|
||
| `ptbtn05` EXTRAS | 6 073 | 6 080 |
|
||
|
||
✅ **The control picks `OPTIONS`, by 4.7×** — the answer the filename gives.
|
||
✅ **So the test is trustworthy, and `live-main-menu.png` has NEW GAME focused, by
|
||
11.5×.**
|
||
|
||
**Which is what the port already focuses.** `authored/flow.json`'s
|
||
`initial_focus: ptbtn01` was chosen because HANDOFF Q5 measured focus as
|
||
*unstable* across boots — four boots gave TUTORIAL, TUTORIAL, NEW GAME, NEW GAME
|
||
— and it is one of the two observed states. It is now also the state of the
|
||
committed capture. ⚠️ That is corroboration, **not** a decode: Q5's instability
|
||
stands, and this identifies one frame rather than a rule.
|
||
|
||
### The defect: `verify-capture` rendered menus with no focus at all
|
||
|
||
`--screen=` draws no focus record, so the harness had been comparing `main_menu`
|
||
to the oracle **in a state the oracle was never in**. Rendered properly, with
|
||
`--menu=`:
|
||
|
||
| | before | after |
|
||
|---|---|---|
|
||
| `main_menu` | 0.25 % differing, RMSE 14.79 | **0.06 %**, RMSE 13.21 |
|
||
| `extras` | 0.46 %, RMSE 15.29 | **0.20 %**, RMSE 13.38 |
|
||
|
||
A 4× improvement on `main_menu` that was **entirely my harness posing the port
|
||
wrong**, not the port drawing wrong. Worth stating plainly: the first run of a
|
||
new correctness harness reported a discrepancy, and three quarters of it was the
|
||
harness.
|
||
|
||
### And the Decoder's caveat resolves to nothing — measured, not assumed
|
||
|
||
Re-deriving the transfer curve on the correctly-posed pair gives 1.20 / 1.26 /
|
||
1.18 / 1.10 / 1.03 / 0.94 at render 8…48 — **unchanged** from the mismatched run.
|
||
So the focus-state contamination it flagged really did not move the trend. Its
|
||
reproduction stands as a second opinion after all, and that is now a measurement
|
||
rather than a hope.
|
||
|
||
## `tools/port/which-focus` — the Decoder asked for a detector, and it carries its own control
|
||
|
||
`S00A` is blocked on knowing which button a screenshot has focused.
|
||
`newgame_path.sh` assumed NEW GAME is focused at boot, drove on that assumption,
|
||
and landed in a **tutorial mission** — because HANDOFF Q5 measured focus as
|
||
*unstable across boots*. And counting presses cannot substitute: ⬆ from the first
|
||
item wraps to the last, so no fixed number of presses lands on a known item from
|
||
an unknown start.
|
||
|
||
The Decoder's own attempt — a per-row brightness statistic — **failed the
|
||
control**, picking NEW GAME on the capture whose filename says OPTIONS. The
|
||
render-difference method passes it, so it is now a script that agent can run.
|
||
|
||
### It runs the control on every invocation, not once when it was written
|
||
|
||
```
|
||
control -- live-main-menu-options-focused.png (answer is in the filename):
|
||
OPTIONS 1285 <- picked
|
||
EXTRAS 6073
|
||
...
|
||
-> OPTIONS, margin 4.7x CONTROL PASSED
|
||
```
|
||
|
||
If that fails, the tool **refuses to report a result at all**. A control that
|
||
does not execute is not a control, and this one cannot be skipped.
|
||
|
||
### Three checks, and one of them independently reproduces a corpus measurement
|
||
|
||
| input | verdict | margin |
|
||
|---|---|---|
|
||
| `live-main-menu-options-focused` — **known answer** | OPTIONS | 4.7× |
|
||
| `live-main-menu` — the question | **NEW GAME** | 11.4× |
|
||
| `live-extras` — **known from the corpus** | MISSION SELECT | 4.2× |
|
||
| `live-title-press-a` — **no menu at all** | *refuses* | 1.0× |
|
||
|
||
The `extras` row is a second known answer I did not plant: `authored/flow.json`
|
||
already records *"MEASURED: EXTRAS opens focused on MISSION SELECT
|
||
(live-extras.png)"*, and the tool reaches it independently.
|
||
|
||
The title row is the negative control. A frame with no menu in it gives a margin
|
||
of 1.0× and the tool says *"this frame does not decide it. Do not act on this."*
|
||
|
||
⚠️ **And that refusal now carries a non-zero exit code.** The first version
|
||
printed the warning and exited 0 — so a caller scripting it, which is the entire
|
||
point, would have read a refusal as an answer. That is the same defect as a
|
||
checker claiming a check it skipped, and it is the fifth instance of that shape
|
||
between the two of us this session.
|
||
|
||
**What it is not:** it identifies focus in *one frame*. It says nothing about
|
||
what *selects* focus; Q5's instability stands.
|
||
|
||
## The title's 1.82 % — three of my own explanations refuted, and the format has no blend mode
|
||
|
||
`title` is the port's largest disagreement with the oracle, and last iteration I
|
||
attributed it to the moving `ptloop` sweeps *without checking*. That attribution
|
||
is wrong, and so were the two hypotheses I formed after it.
|
||
|
||
**❌ Not the sweeps.** `ptloop01`/`ptloop02` are **399×180 at (441, 270)** — small
|
||
and central — and their exported keyframes hold `pos`, `scale` and `rotation`
|
||
constant, varying only alpha. The difference peaks at **x ≈ 1088**, nowhere near
|
||
them.
|
||
|
||
**❌ Not an over-held element.** `--screen` holds every element at its own
|
||
`rest.t`, so I added `--no-hold` to render the other answer. Playing the title's
|
||
groups past their rest **fades the whole screen to black by t = 5.2 s** —
|
||
30.97 % differing against 1.82 % held. Holding at rest is right.
|
||
|
||
**❌ Not a timing offset.** Sweeping the build-in: 24.05 % at t=1.6, falling
|
||
monotonically to **1.68 % at t=4.18** and 1.82 % settled. The capture is at the
|
||
settled end; there is no earlier moment that fits better than marginally.
|
||
|
||
### What it actually looks like: a horizontal redistribution
|
||
|
||
Signed difference (port − capture), by cell:
|
||
|
||
| | x=0 | x=320 | x=640 | x=960 |
|
||
|---|---|---|---|---|
|
||
| y=0 | +1.1 | **−13.1** | −6.6 | **+16.0** |
|
||
| y=169 | +2.1 | **−8.3** | +4.2 | **+9.9** |
|
||
| y=338 | +5.8 | −4.5 | −0.0 | +3.2 |
|
||
| y=507 | +3.9 | +3.3 | +2.0 | +2.3 |
|
||
|
||
**The port is darker centre-left and brighter right, and it nearly cancels** —
|
||
whole-frame means 63.8 against 62.5. That is not a level error and not a tone
|
||
ramp; it is brightness in the wrong *place*. And it falls in exactly the rows
|
||
spanned by the two wide elements `ptlogo_back2` (1118×262 at 71,126) and
|
||
`ptlogo_back2eff` (1133×280 at 64,117), with the column profile falling off past
|
||
x≈1152 against their right edges at 1189 and 1197.
|
||
|
||
### 🔴 The export carries no blend mode, so the port cannot draw one
|
||
|
||
`ptlogo_back2eff`'s exported keys are `declared, id, index, keyframes, kind_raw,
|
||
layer, layer_source, pivot, rest, role, sprite`. **There is no blend field**, in
|
||
this element or in `FORMAT.md` at all. The port composites everything with normal
|
||
alpha.
|
||
|
||
If the game draws `_eff` layers **additively**, a wide gradient sprite would
|
||
produce precisely this signature — surplus where the sprite is bright, deficit
|
||
where the underlying art is brighter than the sum — and **nothing in the export
|
||
would reveal it.** That is a decoding question, not a port one, and it is asked
|
||
rather than assumed. ⚠️ It is a hypothesis I have not tested; I am recording it
|
||
because the three I could test are dead.
|
||
|
||
### A separate `rest.t` casualty, recorded and not acted on
|
||
|
||
`pteff02` is a full-frame primitive whose group runs `0x40000000` (25 % black) at
|
||
t=46 → `0xd4000000` at 76 → `0xcc000000` at 118 → **`0x00000000` at 236**. Its
|
||
`rest.t` is **46**, so the port holds a **25 % black veil the screen's own
|
||
timeline removes**. This is the third instance of `rest.t` naming a hold that is
|
||
not the settled state — after the loading screen's opaque quad and `ptlogo1`'s
|
||
creep. ⚠️ **It does not explain the residual** — removing a darkening veil would
|
||
make the port brighter still, and it is already brighter where it disagrees — so
|
||
it is recorded rather than fixed.
|
||
|
||
### And a new diagnostic
|
||
|
||
`--no-hold` plays a screen past its rest instead of clamping each element at
|
||
`rest.t`. Added because the question *"is the held pose what the idle game
|
||
shows"* could not be asked otherwise. ⚠️ Its first version set the flag thirty
|
||
lines before `view` exists and silently rendered nothing — caught because the
|
||
comparison loop found no files, not because anything reported an error.
|
||
|
||
## 🔴 The exporter dropped nested `.rat` leaf geometry on 45 elements — and it is the title's 1.82 %
|
||
|
||
The Decoder overturned one of my three eliminations, and it was the one I was
|
||
most confident about. I ruled out the `ptloop` sweeps because *"399×180 at
|
||
(441, 270), keyframes hold position constant"* — **that is the parent's record.
|
||
The geometry is in the leaf, and the exporter never opened it.**
|
||
|
||
| | parent (what the export shipped) | **leaf `ptloopNN.rat`** |
|
||
|---|---|---|
|
||
| `ptloop01` | scale (100, 100), rot 0, pos (441, 270) fixed | **scale (100, 600), rot +30°**, x sweeping **−639 → −39 → 1521** |
|
||
| `ptloop02` | scale (100, 100), rot 0, pos (441, 270) fixed | **scale (100, 800), rot −45°**, x sweeping **1721 → 1111 → −839** |
|
||
|
||
Two ~1080 and ~1440 px quads leaning opposite ways and sweeping across the
|
||
frame, against two 400 px sprites drawn upright and static in the middle. **That
|
||
is exactly the signature I measured** — darker centre-left, brighter right,
|
||
nearly cancelling — and the Decoder's GPU capture puts their centres at x ≈ 467
|
||
and 992, which are the two cells where my signed difference peaked.
|
||
|
||
`ui_layout`'s own doc comment had said so: *"the rotated quads come from its two
|
||
**nested** `.rat` leaf records, which the census never opened."* **Neither did
|
||
this exporter.** It opened a leaf in exactly one place — `highlight_name`, for
|
||
focus records — and nowhere else.
|
||
|
||
### It is not two elements. It is 45
|
||
|
||
| screen | elements with a dropped leaf |
|
||
|---|---|
|
||
| `main_menu`, `extras`, `press_start` (+ JP twins) | every button — `ptbtn01.rat` … `ptbtn13.rat`, `ptbtn00.rat` |
|
||
| `title`, `extras` | `ptloop01.rat`, `ptloop02.rat` |
|
||
| `build_00/01/12/15` | `pgloading_loop1/3/4/5.rat` |
|
||
| `title_jp` | `ptlogo_eff2.rat` — **two** elements |
|
||
|
||
⚠️ The buttons are the benign case and `screen.rs` already knew it: *"a BASE
|
||
record's leaf duplicates the parent's placement and the two can differ by a unit
|
||
(`ptbtn04`: parent y=401, leaf y=402). There the parent wins."* The `ptloop` case
|
||
is the opposite — the parent carries **no geometry at all** and the leaf carries
|
||
all of it. 🟡 And `title_jp`'s `ptlogo_eff2` is the element `DECISIONS` has
|
||
recorded since P1 as the single largest render disagreement in the export, *"the
|
||
one drawn element at a scale that is not a whole multiple of 100 %"*. It has a
|
||
two-element leaf. That is a lead, not a conclusion.
|
||
|
||
### Emitted, deliberately not drawn
|
||
|
||
`Element::leaf` now ships the decoded leaf, and one `read_leaf` closure serves
|
||
both it and the focus path — a second copy is how the case would go missing
|
||
again.
|
||
|
||
🔴 **`ScreenView` ignores it**, and that is the honest state. Parent and leaf each
|
||
carry their own alpha ramp on a **different span** — parent `0 → 255` over
|
||
t=70…238, leaf `255 → 0x80 → 255` over t=150…600 — so **how the two compose is a
|
||
decoding question**, and drawing the leaf on a guess would replace a visible
|
||
1.82 % gap with an invisible wrong one. `verify-screen` confirms nothing moved:
|
||
`title` still max 6 / over3 790, `main_menu` max 4, `title_jp` max 155.
|
||
|
||
✅ **Additive blending is refuted** — the Decoder tested `T8aD +0x04` bit `0x02`
|
||
as an additive selector and *"every measure worsens"*. My blend-mode hypothesis
|
||
from last iteration is dead, and the export carries no blend field because **none
|
||
has been found**: the per-draw capture records primitive type, index count,
|
||
shader hashes, texture bindings and vertex attribute 0, and **no
|
||
`RB_BLENDCONTROL`**.
|
||
|
||
🔵 **And this makes the port's biggest oracle gap the same item as the rotation
|
||
question already standing with the human.** `sylpheed-cli screen render`
|
||
deliberately does not rotate, which is why *both* renderers show it — the
|
||
Decoder measures its own residual as tiles running −38.6 then +33.8 and
|
||
cancelling, the same shape as mine. It is a **shared decode gap, not a defect in
|
||
my compositor**, and MISSION's *"Needs a human decision — rotation"* now has a
|
||
number attached: **1.82 % of the title's pixels, in a signature that can be
|
||
recognised.**
|
||
|
||
## The leaf composition is decoded and implemented — and it does **not** close the 1.82 %
|
||
|
||
The Decoder decoded the rule I refused to guess: **draw the leaf on its own
|
||
timeline; do not multiply the parent's alpha in.** Multiplying is *refuted*, not
|
||
merely unsupported — at the fitted time the parent has expired (its group returns
|
||
to 0 at t=250 and holds), so `leaf × parent / 255` predicts zero for both quads
|
||
and the sweeps would be invisible. They are drawn.
|
||
|
||
The fit is worth repeating because of its shape: the game's own composed alpha is
|
||
observable in the per-draw vertex colours (`C3FFFFFF`/`B6FFFFFF` = **195** and
|
||
**182**), fitting *only those two numbers* against the two leaf ramps gives one
|
||
consistent time **t = 355**, and the same t then **predicts** quad centres at 981
|
||
and 478 against **992.0** and **467.2** measured. No x entered the fit.
|
||
|
||
Implemented: `_draw_leaf` runs the leaf unclamped — like the spinning ring, and
|
||
for the same reason. Held at its own `rest.t` the leaf sits at **x = 1521**,
|
||
entirely off the right edge, so `holding` would delete the sweeps rather than
|
||
settle them.
|
||
|
||
### 🔴 And it changes nothing measurable. The title is still 1.82 %
|
||
|
||
| t | units | differing |
|
||
|---|---|---|
|
||
| 4.35 s | 261 | 1.82 % |
|
||
| **5.917 s** | **355** | **1.81 %** |
|
||
| 7.00 s | 420 | 1.79 % |
|
||
|
||
**At t=355 my interpolation puts the leaf's top-left at x ≈ −324 — off-screen
|
||
left — where the Decoder's model puts the quad's centre at 981.** Those cannot
|
||
both be right, and the gap is not something to tune away: it is a disagreement
|
||
about how the leaf's keyframes become a placed quad, most likely in the pivot and
|
||
the rotation about it. **Handed back with both numbers.**
|
||
|
||
So the honest state: the exporter no longer drops the data, the composition rule
|
||
is implemented as decoded, and **the port's largest oracle gap is exactly where
|
||
it was.** Fixing the export was necessary and was not sufficient.
|
||
|
||
### Two flagged elements deliberately not drawn
|
||
|
||
`authored/rendering.json` narrows drawing to `ptloop01`/`ptloop02` — what the
|
||
decode covers — while the exporter keeps flagging all 15.
|
||
|
||
* **`title_jp/ptlogo_eff2`** — parent 125 % at (412,96), leaf 100 % at (402,66).
|
||
The same shape, and the element `DECISIONS` has recorded since P1 as the
|
||
largest render disagreement in the export. The Decoder said plainly *"I have
|
||
not tested it."* Drawing it would extend a decode past the case it was fitted
|
||
on — the failure this port has made four times this session.
|
||
* **`pgloading_loop5`** — leaf scale **(0, 0)**. A zero scale is one of the three
|
||
historical failures this corpus names, so it is exactly where a guess costs
|
||
most.
|
||
|
||
⚠️ **And neither can be adjudicated here.** `title_jp` has no oracle capture, and
|
||
`verify-screen` compares against `sylpheed-cli`, **which draws no leaves at all**
|
||
— so *any* leaf drawing increases that divergence whether it is right or wrong.
|
||
Its `max` went 155 → 232 when they were drawn, and **that number is not evidence
|
||
in either direction.** Recognising it as uninformative is the reason it did not
|
||
become a justification.
|
||
|
||
## The −324 was the old keyframe association, and the corrected one is available **today**
|
||
|
||
The Decoder found it and it is not geometry: my leaf path pairs each pose with
|
||
the *next* pose's time. Fed my pairing into its own interpolation, it reproduces
|
||
**−324 to the digit**. Corrected, t=355 gives top-left **781** and centre
|
||
**980.5** against **992.0** measured — so there was never a 1 300 px pivot
|
||
mystery to look for, and I am glad I said the two numbers instead of hunting one.
|
||
|
||
### 🔴 Correcting BLOCKED: this was never gated on a branch landing
|
||
|
||
`docs/port/BLOCKED.md` has carried the keyframe-record-layout row as *"NOT taken,
|
||
and it cannot be taken yet … `sylpheed-formats` is a path dependency, so this
|
||
reaches the port only when that branch lands on `main`."*
|
||
|
||
**That is wrong.** The corrected association is in this checkout, behind an
|
||
environment variable:
|
||
|
||
```rust
|
||
// Set `SYLPHEED_KF_TIME_SHIFT=1` to read `W[k-1]` as block `k`'s time
|
||
// ("the word is the time the NEXT pose is reached") instead of `W[k]`.
|
||
```
|
||
|
||
It has been switchable the whole time. **I read that file twice this session —
|
||
once for `rotation_deg`, once for the leaf note — and did not notice the switch.**
|
||
|
||
### The experiment, run: mixed, and not decisive for the reason that matters
|
||
|
||
Re-exported with `SYLPHEED_KF_TIME_SHIFT=1` and asked the oracle:
|
||
|
||
| screen | default | shifted |
|
||
|---|---|---|
|
||
| `main_menu` | 13.21 / 0.06 % | 13.81 / **0.10 %** — worse |
|
||
| `extras` | 13.38 / 0.20 % | 13.95 / **0.24 %** — worse |
|
||
| `title` | 21.07 / 1.82 % | **20.41** / 1.86 % — RMSE better, area worse |
|
||
| `publisher_logo` | 10.77 / 1.00 % | **9.05 / 0.75 %** — better |
|
||
| `developer_logos` | 9.37 / 0.39 % | **8.86 / 0.33 %** — better |
|
||
|
||
⚠️ **And it does not adjudicate the association, because the port's renderer is
|
||
built for the other one.** Under the shift the **untimed keyframe moves from last
|
||
to first** — the leaf reads `t=None` at x=−639, then t=150, t=540 — while
|
||
`pose_at` is written around *"the final keyframe carries no `t`, so it is given a
|
||
synthetic time `exit_ramp_units` after the last timed frame."* So this measures a
|
||
**renderer/association mismatch**, not the association. BLOCKED said as much:
|
||
the change touches `pose_at`, `settle_units`, `spin_period_units`,
|
||
`exit_ramp_units` and the plate.
|
||
|
||
**Export reverted to the default.** Adopting the shift is a real piece of work —
|
||
re-deriving the exit ramp, the settle, the spin period and the plate against a
|
||
layout where the untimed frame is the *first* — and doing it hastily at the end of
|
||
an iteration is how a 1.8 % gap becomes five wrong ones.
|
||
|
||
### ⚠️ The methodological point, which is the Decoder's and is the best thing here
|
||
|
||
Its rule matched because **alpha at t=355 sits inside a long segment where a
|
||
one-keyframe shift barely moves it, while x sweeps 1 560 px over the same span.**
|
||
It confirmed on the insensitive quantity; I was wrong on the sensitive one; and
|
||
neither of us saw it until the two were compared.
|
||
|
||
> **Check a new interpretation against the fastest-moving field you have, not the
|
||
> one that happens to agree.**
|
||
|
||
That is a different failure from the ones this session has collected — not an
|
||
uncontrolled instrument, but a control chosen where it could not fail.
|
||
|
||
❔ A residual **11.5 px** (980.5 against 992.0) is left over and is **not** to be
|
||
fitted. A rotation about a declared pivot rather than the centre would displace by
|
||
roughly that; if it still matters once the association is adopted, it gets
|
||
measured rather than derived.
|
||
|
||
## Re-running the P5/P6 gate after eight iterations of changes
|
||
|
||
The leaf path, the plate pulse, the BGM level, the voice export, the focus fix
|
||
and a new diagnostic flag have all landed since the gate was last actually run.
|
||
The mission's test is *"a human presses a d-pad and Ⓐ and moves through those
|
||
screens"*, and I had been verifying pieces of that against captures without once
|
||
re-running the whole walk.
|
||
|
||
**It works.** Nine steps, unattended:
|
||
|
||
| step | | |
|
||
|---|---|---|
|
||
| ⬇ ×4 | `ptbtn01` → `ptbtn05` | focus moves, every frame drawn |
|
||
| Ⓐ | `EXTRAS` → screen `extras`, focus `ptbtn11` | |
|
||
| Ⓑ | back to `main_menu`, **focus restored to `ptbtn05`** | HANDOFF Q5's rule, live |
|
||
| ⬆, Ⓐ | `ptbtn04` `OPTIONS`, destination outside this archive | prints the gap rather than pretending |
|
||
|
||
Filmstrip shared as `1788027380-788b1faafc3e`. Every shot is non-blank (frame
|
||
means 31.5–33.1).
|
||
|
||
### The sound is verified by a null control, not by a detector
|
||
|
||
I first tried an onset detector: count sharp level rises in the recording. It
|
||
found **one** onset in the walk — and **the same one** in the music bed alone,
|
||
which contains no cues. The 50 ms envelope cannot see a short cue over the bed.
|
||
⚠️ **The right response to a detector that cannot separate its control from its
|
||
subject is to stop using it**, not to widen the window until the numbers look
|
||
better. That is the shape this session has hit five times.
|
||
|
||
So the same technique that settled the voice: shadow the three cues with silence
|
||
through `data/mods/`, run the identical walk, and compare.
|
||
|
||
| | peak | RMS |
|
||
|---|---|---|
|
||
| walk, cues playing | **+0.0003 dBFS** | −18.36 |
|
||
| walk, cues silenced | **−4.74 dBFS** | −20.78 |
|
||
|
||
**Silencing three sound effects costs 4.74 dB of peak and 2.43 dB of RMS**, and
|
||
the walk's peak is *set by* a cue rather than by the music. The mod log confirms
|
||
exactly three files shadowed. No detector, no threshold, and the control is the
|
||
same run with one input changed.
|
||
|
||
⚠️ Recorded under the Dummy driver, as everything audio here is. It shows the
|
||
cues reach the Master bus at the right moments; it does not show they are the
|
||
cues the game plays — that is HANDOFF Q8, and `authored/audio.json` still carries
|
||
the offsets as measured rather than decoded.
|
||
|
||
## Pinned `formats-pin-2026-08-29c` — and the knob I tested last iteration was retired
|
||
|
||
🔴 **I tested the wrong switch.** `SYLPHEED_KF_TIME_SHIFT` is a **superseded
|
||
partial fix**: it got the association right but **left pose 0 untimed**, which is
|
||
exactly why the untimed keyframe appeared to "move from last to first". It does
|
||
not exist in the current parser. The real correction is the **default** in the
|
||
tagged crate, with the old reading behind `SYLPHEED_KF_TIME_LEGACY=1` — the
|
||
opposite polarity from what I had.
|
||
|
||
So last iteration's five rows measured a renderer/association mismatch **against
|
||
a knob nobody should use**. I suspected they were not decisive; I did not suspect
|
||
the knob itself was retired.
|
||
|
||
### The consequence is smaller than I budgeted for: there is no untimed keyframe
|
||
|
||
A placement group is an 8-byte header then `frames` × `{u32 time; 36-byte pose}`,
|
||
so pose 0's time is the group's lead-in word and **every pose is timed, including
|
||
the last.** Measured on the re-export: **866 keyframes, 0 untimed.**
|
||
|
||
`pose_at`'s premise — *"the final keyframe carries no `t`, so it is given a
|
||
synthetic time `exit_ramp_units` after the last timed frame"* — does not invert,
|
||
it **disappears**. The branch is now dead code rather than wrong code, which is
|
||
why nothing needed re-deriving to adopt this.
|
||
|
||
And the leaf reads as the Decoder's table says: t=0 x=−639, t=150 x=−39, t=540
|
||
x=1521. At t=355 that interpolates to **x = 781** — the top-left it predicted, and
|
||
the 1 300 px discrepancy is gone.
|
||
|
||
### Pinned by tag, which is what MISSION §2 is for
|
||
|
||
> *"The RE agent tags when it lands something you need and tells you over the
|
||
> message channel — that is how you stay current without floating."*
|
||
|
||
That is precisely what happened, so `crates/sylpheed-export/Cargo.toml` now pins
|
||
`formats-pin-2026-08-29c` by tag. ⚠️ **`BLOCKED.md` was wrong in both
|
||
directions** — it said the change "cannot be taken yet" *and* that it arrives
|
||
only when the branch lands on `main`. It arrives when the tag is pinned.
|
||
|
||
🔴 **The cost, stated rather than discovered later:** `sylpheed-cli` builds from
|
||
the **workspace** crate, so until this reaches `main` the exporter and the
|
||
reference renderer read **different decoders**, and `verify-screen` is comparing
|
||
two eras rather than detecting drift. `verify-capture` is unaffected — it
|
||
compares the port against oracle **captures** and never touches the CLI — and it
|
||
is the check that matters. Revert to the path dependency the day the tag is an
|
||
ancestor of `main`.
|
||
|
||
### What the oracle says
|
||
|
||
| screen | before | after |
|
||
|---|---|---|
|
||
| `publisher_logo` | 1.00 % | **0.75 %** |
|
||
| `developer_logos` | 0.39 % | **0.33 %** |
|
||
| `extras` | 0.20 %, region **736×525** | 0.19 %, region **398×295 at (441,230)** |
|
||
| `main_menu` | 0.06 % | 0.06 % |
|
||
| `title` | 1.82 % | 1.82 % |
|
||
|
||
The splashes improve outright. ⚠️ **`extras` is the interesting row**: the *area*
|
||
barely moved but its differing region **collapsed onto the sweep position**
|
||
(441, 270) — the residual is now localised to the one element still in question
|
||
rather than spread over the screen.
|
||
|
||
The title does not move. Its row is now posed at **t=355**, the Decoder's fitted
|
||
sweep time, because the leaf group ends at t=600 with the quads parked off-screen
|
||
at x=1521 — posing at the settle simply omits them. ⚠️ **t=355 is not the time
|
||
that minimises the difference**: t=390 measures **1.65 %**. Picking that would be
|
||
fitting the pose to the score, which is what this harness exists not to do.
|
||
|
||
## Refuted — my own "the single non-whole-multiple scale in the export"
|
||
|
||
`DECISIONS` has said since P1 that `ptlogo_eff2` is *"the single drawn element in
|
||
the whole export at a scale that is not a whole multiple of 100 % (125 %)"*.
|
||
**That census was parents-only.** Opening the 45 leaves finds **thirteen** distinct
|
||
non-whole-multiple scales — 75, 96, 99, 101, 103, 112, 125, 150, 204×208,
|
||
210×220, 250 — and 125 % is among the *rarest* at two occurrences.
|
||
`ptlogo1`/`ptlogo2` carry 101/103/112 on the **English** title.
|
||
|
||
The claim's real content was *"the only one **the port draws**"* — a fact about my
|
||
element set, not about the disc. Corrected.
|
||
|
||
🔴 **And `ptlogo_eff2` stays withheld, now for a stronger reason than caution.**
|
||
Its 125 % is a **pop**, not a steady scale: scale-0 → 125 % → scale-0 between
|
||
t=50 and t=107, ≈0.95 s. The leaf draws at 100 %, as **two superimposed copies**
|
||
at alpha 160 and 80, each rotating 360° over 960 units — 16 s per revolution.
|
||
**If parent scale gates the leaf it is a 0.95 s flash; if the leaf runs free it
|
||
spins for 16 s.** Nothing on the disc chooses, `title_jp` has no oracle capture,
|
||
and the Japanese-locale capture MISSION has parked is what would settle it.
|
||
|
||
## The 11.5 px was the fit's resolution, and the lesson inverts
|
||
|
||
The Decoder closed it **by adding observables, not by tuning** — the vertex
|
||
buffer carries positions *and* colours at the same instant, so all four
|
||
quantities must agree on one `t`:
|
||
|
||
| observable | solved t | precision |
|
||
|---|---|---|
|
||
| quad A x | **357.88** | ±0.12 units |
|
||
| quad B x | **357.58** | ±0.12 units |
|
||
| quad A alpha | 355.75 | ±1.54 units |
|
||
| quad B alpha | 354.09 | ±1.89 units |
|
||
|
||
Alpha moves only 0.27–0.33 levels per keyframe unit, so **one byte of
|
||
quantisation is worth 1.5–1.9 units, which at 4 px/unit is 6–8 px of sweep**.
|
||
That is the whole of the 11.5 px. At t = 357.7 the centres land within 0.70 px
|
||
and both alphas inside one level.
|
||
|
||
⚠️ **The lesson is the earlier one inverted, and this is the half worth keeping.**
|
||
Checking a wrong rule against alpha made it *look confirmed*. Here the same
|
||
insensitivity **manufactured a residual that did not exist**. So an insensitive
|
||
quantity does not merely fail to falsify — **it invents error.** Solve on the
|
||
fastest-moving field; check the slow one; never the reverse.
|
||
|
||
I was already looking for a pivot rule to explain 11.5 px when they wrote. There
|
||
was nothing to find.
|
||
|
||
### Refutation attempt — the pivot claim, checked here and survived with a nuance
|
||
|
||
They state the leaf pivot is (200, 90) on a 399×180 sprite, *"the pivot is the
|
||
centre, so rotation displaces it by nothing."* Checked against my own export:
|
||
pivot **[200, 90]**, sprite **399×180**, true centre **199.5, 90**.
|
||
|
||
✅ Survives. ⚠️ With one correction of no consequence: the sprite is **odd-width**,
|
||
so the pivot is the centre to within **half a pixel**, not exactly. Against their
|
||
−0.70/−0.48 px agreement that changes nothing, and it is worth stating only
|
||
because "displaces it by nothing" is the kind of sentence that later gets leaned
|
||
on for a sub-pixel claim.
|
||
|
||
`verify-capture` now poses the title at **t=357.7** rather than 355: RMSE
|
||
21.07 → **20.92**, differing 1.82 % → **1.81 %**. Marginal, and it is the right
|
||
pose for a stated reason rather than a better number.
|
||
|
||
### `ptlogo_eff2` is withheld for a better reason than mine
|
||
|
||
I was withholding it out of caution about untested generalisation. The Decoder
|
||
points out something stronger: **it is on `title_jp`, and MISSION §7 scopes out
|
||
"localisation beyond English"** — so it is not a question the menu port has to
|
||
answer at all, and the parked Japanese-locale capture does not need reviving on
|
||
its account. `authored/rendering.json` now says that first and the undecidability
|
||
second. **Widening scope to close a residual would have been the wrong trade**,
|
||
and it is the human's call either way.
|
||
|
||
## 🔴 The focus ring had silently stopped, and BLOCKED had listed it
|
||
|
||
`docs/port/BLOCKED.md` said the record-layout change touches five things:
|
||
`pose_at`, `settle_units`, `spin_period_units`, `exit_ramp_units` **and the
|
||
plate**. I checked `pose_at` and deleted `exit_ramp_units`, reported that, and
|
||
**did not work the rest of the list.**
|
||
|
||
`spin_period_units` required *"the first timed and the second untimed"*. Under
|
||
the corrected layout the ring reads `t=0 rot=0` and `t=120 rot=360` — **both
|
||
timed** — so the rule returned 0 and **the focus ring stopped spinning**. Nothing
|
||
reported it: a period of 0 is a legal *"this element does not spin"*.
|
||
|
||
Rewritten to take the **span** between the two poses. On the ring that is
|
||
120 − 0 = **120 units, the same number the old rule produced**, which is a small
|
||
piece of evidence that the corrected layout is self-consistent rather than merely
|
||
different.
|
||
|
||
**Verified the way P5 verified it — bit-identity one period apart**, on the ring's
|
||
own 60×60 box so the `ptloop` sweeps cannot confound it:
|
||
|
||
| separation | mean difference |
|
||
|---|---|
|
||
| **+120 units (one period)** | **0** |
|
||
| +120 units again | **0** |
|
||
| +30 units (quarter) | 8.61 |
|
||
| +60 units (half) | 8.88 |
|
||
|
||
⚠️ Getting there took three wrong instruments, and the sequence is the lesson.
|
||
A whole-frame `max` saturates on one rotating edge — adjacent frames scored 131
|
||
while their mean was 0.022. A live `--menu` filmstrip jitters by up to a frame,
|
||
which is ~3° of ring, and its cadence cannot be pinned. And a whole-frame
|
||
comparison is dominated by the sweeps, which move 480 px over one ring period.
|
||
**`--focus=<id>` was added so a `--screen` run can draw a focus record
|
||
deterministically**, which is what made the check reproducible at all.
|
||
|
||
## The plate's period is now the disc's 105, and it disagrees with the measurement
|
||
|
||
Under the corrected layout `ptbtn00f` runs **t=0 (alpha 0) → t=105 (alpha 0)** — a
|
||
closed cycle with every pose timed.
|
||
|
||
✅ **The ambiguity this entry carried is gone.** It used to say the cycle might
|
||
restart at t=6 rather than 0, giving 123 units, and that nothing separated the
|
||
two. There is now one reading.
|
||
|
||
🔴 **And the number is worse against the oracle, which is stated rather than
|
||
avoided.** 105 units is **1.750 s**; scaled by the factor the ring shows between
|
||
its declared 120 units and its measured 2.177 s (×1.089), **1.906 s** — about
|
||
**17 % below** every one of the corpus's four timings (2.12 / 2.19 / 2.34 /
|
||
2.31 s). The old 129 gave 2.34 s, at the top of that range, which is exactly why
|
||
it looked right.
|
||
|
||
**129 was the last timed keyframe plus `exit_ramp_units`, and that constant is
|
||
deleted.** A period built from a constant that no longer exists cannot stay, even
|
||
though it fitted better. So the port ships the disc's number **and says it is
|
||
wrong**, rather than keeping a number that agreed with the measurement for a
|
||
reason that has evaporated.
|
||
|
||
Verified: the plate is bit-identical 105 units apart (mean diff **0**) and differs
|
||
at 30 units (0.83). ❔ What would resolve the disagreement: whether the group
|
||
loops from its start at all, or holds at alpha 0 between cycles. Asked.
|
||
|
||
## The plate's period is 120, decoded — and it was falsified with my own ring number
|
||
|
||
The Decoder found it in the format: **a nested record is itself a RATC bundle,
|
||
and its header's `+0x08` is the loop length** — the same field
|
||
`ui_header_time_disc` already tests at the top level. A record's keyframes need
|
||
not fill it, and the slack is a hold at the final pose. **`ptbtn00f` is 105 units
|
||
of ramp inside a 120-unit cycle**, so the glow rests dark for 15 units. The five
|
||
`ptbtn0Nf` records fill their 120 exactly, which is what shows the slack belongs
|
||
to *that record* rather than to the format.
|
||
|
||
Disc-wide over 1 781 timed nested records: 92.3 % declare exactly their last
|
||
keyframe time, 7.7 % declare more, **0 declare less**. That last row is the
|
||
falsifier — a cycle cannot restart before its own last pose — and it never fires.
|
||
|
||
### The decisive test used this port's number, not theirs
|
||
|
||
Both candidates need the same emulator pacing factor, and **the ring measures it
|
||
independently**: declared 120 units → 2.177 s → **1.0885**.
|
||
|
||
| plate period | nominal | factor needed for the measured 2.12–2.34 s | |
|
||
|---|---|---|---|
|
||
| 105 units | 1.750 s | 1.211 … 1.337 | 🔴 excludes 1.0885 |
|
||
| **120 units** | 2.000 s | 1.060 … 1.170 | ✅ **contains it** |
|
||
|
||
**105 cannot reach the measured range under any pacing the ring also satisfies.**
|
||
Two different elements in different bundles, measured in separate runs, tied only
|
||
by both declaring 120.
|
||
|
||
⚠️ **My three readings of this number, in order, are the useful record:** 129
|
||
(`105 + exit_ramp_units`) fitted the measurement for a reason that later
|
||
evaporated; 105 (the group length) I shipped *knowing* it was 17 % short; 120 is
|
||
decoded. And the 123-vs-129 pair I once called unseparable **straddled the right
|
||
answer without containing it** — which is the sharpest argument I have seen
|
||
against treating "two candidates, nothing separates them" as if the truth must be
|
||
one of the two.
|
||
|
||
Verified: bit-identical **120 units** apart (mean diff 0), 0.061 at a quarter and
|
||
0.888 at half. Still authored, because the pinned tag does not expose `+0x08`
|
||
yet — **delete the entry the day a tag does.**
|
||
|
||
### Their `rest()` flag, checked rather than assumed
|
||
|
||
They warned that a focus record is exactly the kind of element `rest()`
|
||
mishandles, since a pulse's last hold is not its resting state. Censused: **34
|
||
focus-record elements in the export, and only 2 have a varying alpha** — both
|
||
`ptbtn00f`, EN and JP. Their `rest.alpha` is **80, identical to their peak**,
|
||
which is precisely the pathology described. The port does not hit it because the
|
||
plate is drawn through the loop path, and the other **32 are constant-alpha, so
|
||
`rest()` is safe for them**. Bounded, not hoped.
|
||
|
||
## ✅ A settled screen is ONE instant, and it collapsed three residuals at once
|
||
|
||
The Decoder's finding, applied: **`rest()` returns each element's last hold
|
||
keyframe chosen independently of every other element.** That is right for
|
||
anything that ends the screen settled and **exactly wrong for a transient** —
|
||
the title's `ptlogo_back2eff1` is a two-frame flash (0 until t52, 255 at t54–56,
|
||
0 by t58), so its last hold *is* the flash peak and `rest()` left it burning.
|
||
There are five of them, and `rest()` drew all five at once.
|
||
|
||
The settled instant is **the longest interval containing no keyframe time**, over
|
||
a bundle's **top-level** elements. Reproduced independently here before adopting:
|
||
title `[160, 236]`, midpoint **198** — the Decoder's number to the unit. ⚠️ The
|
||
top-level restriction is what makes it match: including the `ptloop` leaves gives
|
||
`[269, 540]` instead.
|
||
|
||
### Against the oracle
|
||
|
||
| screen | before | after |
|
||
|---|---|---|
|
||
| **`title`** | 20.92 RMSE, **1.81 %** | **14.61 RMSE, 0.26 %** |
|
||
| **`publisher_logo`** | 9.05, **0.75 %** | **2.17, 0.01 %** |
|
||
| **`developer_logos`** | 8.86, **0.33 %** | **3.05, 0.01 %** |
|
||
| `main_menu` | 0.08 % | 0.08 % — unchanged, window too narrow |
|
||
| `extras` | 0.19 % | 0.19 % — unchanged, window too narrow |
|
||
|
||
**Seven times fewer differing pixels on the title, seventy-five times fewer on
|
||
the publisher splash**, whose differing region is now a **13×18 box**. This is
|
||
the largest correctness gain the port has had, and none of it is mine: it is a
|
||
decode, computed from the keyframe table with no reference to any capture.
|
||
|
||
### ⚠️ It is applied only where the window is wide, and that bar is not invented
|
||
|
||
The widths in this export split with **nothing in between**: `press_start` 214,
|
||
`publisher_logo` 190, `developer_logos` 145, `title` 76 — then `main_menu` 12,
|
||
`extras` 12, the loading screens 8 and 4. A 12-unit "settle" on a menu that
|
||
builds in until t=70 is a gap between staggered ramps, not a settled pose.
|
||
|
||
The bar is **30 units**: the Decoder's disc-wide census puts the knee there (30 %
|
||
of bundles ≥ 30, 42 % under 10, the latter mostly `loop*` fragments meant to be
|
||
in motion), and this export's own screens sit **4× either side of it with nothing
|
||
between 12 and 46**. Two independent populations agreeing on where to cut is what
|
||
makes it a bar rather than a preference.
|
||
|
||
Checked unbroken: the boot pacing is unmoved (`developer_logos@4.26`,
|
||
`title@7.91`, developer agrees) and the scripted walk still runs end to end with
|
||
focus restored.
|
||
|
||
## Their census, and a framing of mine they sharpened
|
||
|
||
I reported *"34 focus-record elements in the export, only 2 with a varying
|
||
alpha"*. Disc-wide it is **210 varying, 202 with `rest()` at the peak**, across
|
||
1 130 focus records — 116 in `GP_DEBRIEFING_PILOTLOG`, 54 in `GP_MOVIE_THEATER`,
|
||
30 in `GP_HANGAR_ARSENAL`, 8 in `GP_LEADERBOARD`, and **2 in `GP_TITLE`**.
|
||
|
||
**My 2 is right because `GP_TITLE` has 2.** ⚠️ But *"only 2 have a varying alpha"*
|
||
reads as a fact about the format and is a fact about one pak — and the pathology
|
||
sits in exactly the screens a wider port reaches next. The sentence was true as
|
||
measured and false as remembered, which is the failure this corpus keeps
|
||
finding, and it was mine this time.
|
||
|
||
⚠️ **And they corrected a framing I had:** I called `rest.alpha == peak` "the
|
||
pathology". It is worse than that — **a pulsing element has no resting pose at
|
||
all.** The question `rest()` answers is *malformed* rather than mis-answered,
|
||
because the element's state is a phase, not a value. `pose_at(t)` with `t` inside
|
||
the record's own declared cycle is the only well-formed query on one.
|
||
|
||
🔴 Worth carrying for whenever this port grows: `GP_LEADERBOARD`'s
|
||
`py_ranking_btn01f` swings 255 → 127 → 255 with no two adjacent keyframes equal,
|
||
so `rest()` falls through to its longest-dwell rule and returns **244** — neither
|
||
peak nor trough. **A glow stuck at its peak is visibly wrong; one stuck at 244 of
|
||
a 127–255 range looks entirely plausible, and nothing reports it.**
|
||
|
||
✅ And a free second instance of the loop-length decode from a pak neither of us
|
||
was looking at: `py_ranking_btn01f`'s ramp ends at **t=90 inside a declared 120**
|
||
— 30 units of hold, the same shape as the plate's 105-in-120.
|
||
|
||
## Their "do not draw all five flashes" flag — checked, and it does not apply here
|
||
|
||
The Decoder armed a draw capture before the title exists and caught the build-in
|
||
on the console: `ptlogo_back2eff1` in frames 130–131, `eff2` at 133, `eff4` at
|
||
133–135 — and **`eff3` never drawn at all.** Not a miss: a flash's peak is 2
|
||
keyframe units, which at that run's pacing is **0.85 of a presented frame**, so
|
||
which flashes get sampled is a matter of phase. The console shows a *subset* on
|
||
any given play, and the warning was that drawing all five would read heavier than
|
||
the real thing.
|
||
|
||
**Checked rather than reasoned about.** Sweeping the port's build-in with
|
||
`--no-hold`:
|
||
|
||
| t | flashes drawn |
|
||
|---|---|
|
||
| 54, 56 | `eff1` |
|
||
| 58 | `eff2` |
|
||
| 60 | `eff2`, `eff3` |
|
||
| 62 | `eff3`, `eff4` |
|
||
| 64 | `eff4`, `eff5` |
|
||
| 66, 68 | `eff5` |
|
||
|
||
**The port draws them sequentially, never more than two at once**, and the two
|
||
only overlap where their declared windows abut. That is the stagger the disc
|
||
declares, not a pile-up — the pile-up was the `rest()` bug, and it is fixed.
|
||
|
||
⚠️ **So the difference from the console is presentation rate, not content.** At
|
||
60 fps each 2-unit flash gets ~2 frames; at the console's pacing it gets 0.85, so
|
||
some are skipped. **A frame-by-frame comparison of the build-in against a console
|
||
capture will therefore show flashes the console missed, and that is a fact to
|
||
know rather than a bug to fix.** It is also why the settled-frame comparison —
|
||
the one `verify-capture` makes — is unaffected: at t=198 none of the five is
|
||
drawn.
|
||
|
||
### Three things of theirs worth recording
|
||
|
||
✅ **My top-level restriction was verified, not merely accepted**: top-level
|
||
`[160, 236]` width 76, versus `[269, 540]` width 271 with the `ptloop` leaves
|
||
included — an instant *after every top-level element has exited*. Worth having on
|
||
their page, because the rule as described permits the wrong reading.
|
||
|
||
✅ **The 120-unit loop is confirmed from the guest's own vertex data**, not
|
||
inferred from pixels: the glow quad's per-vertex colour alpha *is* the element's
|
||
fade alpha, giving an observed range of **0…80 against a decoded peak of 80**,
|
||
exact and unfitted, over 20 cycle starts. Fitting the decoded ramp gives RMS
|
||
13.16 alpha levels against **38.18 for the same ramp reversed** — if the shape
|
||
carried no information those would be equal.
|
||
|
||
📌 **A trap noted for whenever this port grows draw-stream tooling:** a 2D draw's
|
||
identity is its **vertex geometry, not its bound texture**, because these sprites
|
||
sample large shared pages. Matching on texture dimensions told them first that no
|
||
flash is ever drawn, then that `ptbase2` and `pteff04` are drawn in frames 75–105
|
||
— which are the intro movie, whose YUV planes are 640×360 targeting 1280×720.
|
||
Two errors, opposite directions, one pass, neither loud.
|
||
|
||
## ✅ The `publisher_logo` residual was a missing black hold, and we had both dismissed it
|
||
|
||
I had carried this as *"0.03 s outside a composite bound, probably a property of
|
||
the bound rather than the game"*, and the Decoder agreed. **We were both wrong,
|
||
and the way it was settled is the point: I stopped reasoning about the bound and
|
||
filmed the transition.**
|
||
|
||
At 0.05 s the port fell straight out of the publisher's fade into the developer
|
||
logos — mean 5.06 → 0.32 at t=4.20, then **5.65 at t=4.25**. There was **no black
|
||
frame at all**, where the oracle measures a 0.17–0.23 s pure-black plateau
|
||
(HANDOFF Q7). The bound was fine. The port was missing a fifth of a second of
|
||
black, and had been since P3.
|
||
|
||
**Authored at 12 units**, because on the boot path there is nothing to read it
|
||
from: `publisher_logo` and `developer_logos` each carry a single `palogo_eff0` —
|
||
a 1280×720 primitive with **one keyframe at t=0**, static, not a transition ramp.
|
||
The menus' quad declares black for 12 units and 12/60 = **0.200 s** sits in the
|
||
middle of the measured range, so the number is the disc's where a screen has one.
|
||
|
||
Filmed after: **t=4.25, 4.30, 4.35, 4.40 all at mean 0** — four black frames,
|
||
0.20 s — then the developer logos at 4.45.
|
||
|
||
| | before | after |
|
||
|---|---|---|
|
||
| publisher interval | 4.26 s, **DIFFERS** | **4.47 s, agrees** |
|
||
| developer interval | 3.62 s, agrees | 3.73 s, agrees |
|
||
|
||
The settled-frame comparisons are untouched, as they should be — this is pacing,
|
||
not pixels.
|
||
|
||
⚠️ **The lesson is about the shape of the dismissal, not the number.** *"A 0.03 s
|
||
miss against a bound composed from two measured ranges plus jitter slack is more
|
||
likely a property of the bound"* is a **plausible** explanation, it was offered
|
||
and accepted by both of us, and it was wrong. The composite bound was the reason
|
||
the miss looked small — the underlying gap was 0.2 s — and a plausible
|
||
explanation for a small number is exactly how a real defect stays hidden. **The
|
||
film cost one command.**
|
||
|
||
## `ptlogo_back2eff3` — recorded, deliberately not acted on
|
||
|
||
The Decoder has reproduced across two independent build-ins that the console
|
||
**never draws `eff3`**: 0 draws against ~5 expected, while `eff1` gets 4, `eff2`
|
||
3 and `eff4` 6. Three explanations are ruled out — sampling phase (`eff3` is
|
||
non-zero over six units against a 2.23-unit step, and frames at t=60.1 and 62.3
|
||
sit inside it drawing `eff2` and `eff4` instead), a draw the log cannot see, and
|
||
a bad position guess (no quad anywhere is within ±30 of the expected 408 width;
|
||
the spectrum jumps 262 → 748).
|
||
|
||
❔ **But *why* is not established** — nothing in `eff3`'s record differs from its
|
||
neighbours: same kind `0x0`, same keyframe shape, same `u4`/`u8`, same scale.
|
||
|
||
🔴 **So the port keeps drawing it, and that is a decision rather than an
|
||
oversight.** Dropping an element the disc declares, on a measurement with no
|
||
mechanism behind it, is authoring a behaviour neither agent can derive — and
|
||
**nothing this port gates on would notice either way**: the flashes live only in
|
||
the build-in, and `verify-capture` compares the settled frame at t=198 where none
|
||
of the five is drawn. Acting would buy no measurable fidelity and cost an
|
||
unexplained exception in `authored/`.
|
||
|
||
**What would change it:** a mechanism in the record, or a gate that measures the
|
||
build-in against a capture. Until then the port is visibly wrong for two frames
|
||
during a build-in nobody compares, which is the cheaper of the two wrongs.
|
||
|
||
## The narrow settle windows are harmless, and I can now say why
|
||
|
||
Adopting the settle instant left `main_menu` and `extras` on per-element `rest()`
|
||
— their windows are 12 units, below the 30-unit bar — and I recorded that as a
|
||
gap. **It is not one.** `rest()` is malformed only for a **transient**, and the
|
||
transients are precisely on the screens whose windows are wide:
|
||
|
||
| screen | window | transients |
|
||
|---|---|---|
|
||
| `title` | 76 | `ptlogo1`/`ptlogo2` ×4, `pteff01`, `ptlogo_back2eff1…5`, `ptlogoall_eff` |
|
||
| `publisher_logo` | 190 | `palogo_sqex_eff` |
|
||
| `developer_logos` | 145 | `palogo_*_eff` ×3 |
|
||
| **`main_menu`** | **12** | **none** |
|
||
| **`extras`** | **12** | **none** |
|
||
|
||
The discriminator is *returning to dark far before the screen's own end* —
|
||
`ptlogo_back2eff1` is dark again at t=58 while the title runs to t=269 — as
|
||
opposed to the ordinary fade-in-hold-fade-out that every menu element has, where
|
||
`rest` at 255 **is** the settled pose and the final 0 is the exit.
|
||
|
||
⚠️ **I am not claiming that as a rule.** Two screens having narrow windows *and*
|
||
no transients could be coincidence; it is n = 2, and n = 2 is where I have gone
|
||
wrong before. What it does mean is that **nothing measurable is being left on the
|
||
table by the 30-unit bar today.**
|
||
|
||
## Refuted, mine — "the menu residual is localised on the `ptloop` sweeps"
|
||
|
||
I have written that twice. It came from reading the **bounding box** of the
|
||
differing pixels, which sat at (441, 230) — the sweep position. Tested by sweeping
|
||
the leaf's phase against the live-menu captures:
|
||
|
||
| `main_menu`, sweep phase | differing |
|
||
|---|---|
|
||
| t=60 (barely on screen) | 0.063 % |
|
||
| t=350 (mid-screen) | **0.183 %** |
|
||
| t≥600 (parked off-screen) | **0.061 %** |
|
||
|
||
✅ Two things fall out. **The capture shows no sweeps**: the port matches best
|
||
when they are off-screen and three times worse when they cross the middle. And
|
||
with them parked the residual's box is **834×358 at (445, 167)** — the *button
|
||
column*, which is where the focus signature lives, not the sweep position.
|
||
|
||
On `extras` the same test moves the box (398×295 at the sweep position at t=70,
|
||
736×525 spread at t=700) while the **magnitude barely changes: 0.192 % against
|
||
0.200 %**.
|
||
|
||
🔴 **So the box moves with the sweeps and the residual does not.** A bounding box
|
||
over scattered pixels tells you where the outermost differing pixels are, **not
|
||
where the difference is** — and I had been quoting it as if it localised a cause.
|
||
`verify-capture` prints that box, so this is a caution about reading my own tool.
|
||
|
||
### And a first piece of evidence on whether the sweeps loop
|
||
|
||
The disc gives one pass, t=0…600, ending parked off-screen at x=1521.
|
||
`ORACLE-CAPTURES.md` says the title's sweeps *"move continuously"*, which I had
|
||
taken as implying a loop. **The idle main-menu capture matches best with them
|
||
off-screen**, which is evidence they run once and park.
|
||
|
||
⚠️ One capture, one screen, and "best match" is a weak instrument for an absence
|
||
— but it is the first evidence either way, and it points against looping. The
|
||
loop-length field the Decoder decoded (`+0x08` of a nested record's header) would
|
||
settle it outright; it is not in the pinned tag.
|
||
|
||
## Refuted — "the developer splash is one composited quad, the bounding box of the three logos"
|
||
|
||
The Decoder observed the game submitting **one 525×259 quad at (378, 155)** on
|
||
the developer splash and read it as the bounding box of the three logos, warning
|
||
that drawing three sprites there draws something the console does not. **The
|
||
arithmetic does not support it**, and the port keeps drawing three.
|
||
|
||
| | bounding box |
|
||
|---|---|
|
||
| the **three logos** | **500×421 at (390, 164)** |
|
||
| `gamearts_eff` + `seta_eff` | **521×261 at (379, 154)** |
|
||
| the observed quad | **525×259 at (378, 155)** |
|
||
|
||
**A 259-tall quad cannot contain the three logos**, which span y 164…585:
|
||
`palogo_anima` alone starts at y = 449, thirty-five pixels below that quad's
|
||
bottom edge. The observed quad matches the union of the two `_eff` **glows** to
|
||
about four pixels in every dimension.
|
||
|
||
⚠️ And those two are **transients** — my own census flagged them, dark again by
|
||
t=45 — so a frame containing that quad is a **build-in** frame, not the settled
|
||
screen. Consistent with a draw capture that starts early, which is exactly what
|
||
theirs does.
|
||
|
||
I cannot see their draw stream, so I have sent the arithmetic rather than a
|
||
verdict. What I will not do is stop drawing an element on a claim whose stated
|
||
identification excludes that element from its own bounding box.
|
||
|
||
## The black hold is 9 units, not 12 — measured in draws rather than luminance
|
||
|
||
I authored 12 from HANDOFF Q7's luminance plateau of 0.17–0.23 s, supported by
|
||
the menus' transition quad declaring black for 12. The Decoder counted **submitted
|
||
quads** instead, which is the better instrument: luminance cannot separate the
|
||
outgoing fade's tail from true black.
|
||
|
||
Frames 21–125 submit `palogo_sqex` fading to alpha 7; **frames 126–129 submit no
|
||
sprite quad at all**; 130–153 fade the developer splash in from alpha 34. Four
|
||
presented frames at 2.284 units/frame — a rate derived from the **disc as its own
|
||
clock**, because that run ran at 13.1 fps against 28 elsewhere — gives **9.1
|
||
units = 0.152 s**, ±1 frame 6.9–11.4.
|
||
|
||
⚠️ **It disagrees with the luminance figure and the disagreement is the point.**
|
||
0.114–0.190 s against 0.17–0.23 s overlaps only at the top, and the true black is
|
||
**shorter** than 9 even so: both boundary frames still carry picture. My 12 was
|
||
also supported *by analogy* — a different screen's quad on a different path,
|
||
where the boot splashes carry no quad at all. **A number that fits by analogy
|
||
loses to one measured in place.**
|
||
|
||
`verify-dwell`'s bound moved with it. Both screens still agree: publisher 4.42 s,
|
||
developer 3.78 s.
|
||
|
||
## The title's sweeps loop — measured, and the field could not have told us
|
||
|
||
The disc gives one pass (`ptloop01` t=0…600, `ptloop02` t=0…720), each ending
|
||
parked off-screen, and the port ran them once. **The oracle says they loop**:
|
||
across two title dwells the sweep quad oscillates over its whole x range and
|
||
resets hard to the same start — one reset in the first dwell, two in the second.
|
||
|
||
🔴 **And the loop-length field could not have settled it, which corrects a hope I
|
||
had stated.** Both records declare exactly their last keyframe time — **slack
|
||
zero** — and *"loops at 600"* and *"runs once for 600 and stops"* write the
|
||
identical header. 92.3 % of records on the disc are in that state, so the field
|
||
discriminates only where there **is** slack, as the plate's 105-in-120 had.
|
||
|
||
Implemented and verified on the two sweeps' **least common multiple**, since they
|
||
have different periods: 600 and 720 realign at **3600 units = 60 s**.
|
||
|
||
| separation | mean difference |
|
||
|---|---|
|
||
| **+3600 units (LCM)** | **0** |
|
||
| +1800 units | 0.438 |
|
||
| +600 units (`ptloop01` only) | 0.100 |
|
||
|
||
⚠️ **Scoped to the title**, because that is where it is measured. The menus
|
||
declare the same 600/720 and nothing on the disc distinguishes them, but my own
|
||
weak evidence points the other way there — sweeping the phase against
|
||
`live-main-menu.png`, the port matches best with the sweeps **off-screen**
|
||
(0.061 %) and three times worse mid-screen (0.183 %), and if they looped the
|
||
sweep is on screen for roughly 73 % of the cycle. **Two weak signals in opposite
|
||
directions is a reason to scope, not to pick.**
|
||
|
||
## The menus' residual is the tone floor, not structure — and `extras` is not really 3× worse
|
||
|
||
`extras` sits at 0.19 % differing against `main_menu`'s 0.06 %, on two screens of
|
||
the same family, and that gap wanted explaining.
|
||
|
||
**Signed difference (port − capture), by cell:**
|
||
|
||
| | x=0 | x=320 | x=640 | x=960 |
|
||
|---|---|---|---|---|
|
||
| `extras` y=169 | **+12.13** | −3.64 | +2.43 | **+10.60** |
|
||
| `extras` y=338 | **+12.29** | +1.36 | +1.42 | **+9.05** |
|
||
| `main_menu` y=169 | **+11.63** | −0.68 | +3.01 | **+10.24** |
|
||
| `main_menu` y=338 | **+11.03** | +3.93 | +2.92 | **+8.84** |
|
||
|
||
**The two screens are nearly identical**, and the port is uniformly **+9 to +12
|
||
brighter in the dark outer columns** — which is exactly the transfer curve I
|
||
measured earlier: γ > 1 in the darks, capture darker than render. There is no
|
||
dipole, no displacement, no missing element.
|
||
|
||
So the 0.06 % / 0.19 % gap is **not a difference in fidelity**. The thresholded
|
||
count only sees pixels differing by more than 64 levels, which are text and
|
||
sprite **edges**; the two screens simply have different amounts of high-contrast
|
||
edge. The *level* disagreement, which is what a tone term produces, is the same
|
||
on both.
|
||
|
||
⚠️ **This is the bounding-box lesson again in a different costume.** I had two
|
||
numbers, 0.06 and 0.19, and took the ratio as meaningful. It is a count of
|
||
threshold crossings, and a count of threshold crossings is not a measure of how
|
||
wrong a screen is.
|
||
|
||
### A diagnostic trap of my own, worth writing down
|
||
|
||
My first pass at this reported **10 of 18 elements "transparent at rest"** on
|
||
`extras` — the buttons, the title, the frames — and looked exactly like a
|
||
missing-element bug. It was not. **`--screen=NAME` without `--time` renders at
|
||
t = 0**, and `pose_at` clamps `t` to `minf(t, settle_units)`, so t=0 stays t=0 and
|
||
every element is still at its first keyframe. Passing `--time=2.0` draws 18 of 18.
|
||
|
||
The tool was right and my invocation was wrong, and the failure looked like a
|
||
serious defect rather than an empty argument. Same family as the instrument traps
|
||
this session has collected — and mine was the one that reported a *worse* problem
|
||
than existed, which is the direction that wastes an iteration rather than hiding
|
||
one.
|
||
|
||
## Refutation attempt — their 239.8-unit figure, checked from my export
|
||
|
||
The Decoder converted the boot's black gap using the disc as its own clock:
|
||
*"`palogo_sqex` declares alpha ≥ 1 for **239.8 units** and is drawn in 105 frames
|
||
→ 2.284 units/frame."* That 239.8 comes from their reading of the record; I have
|
||
the same element in my export and can compute it independently.
|
||
|
||
`palogo_sqex` ramps 0 → 255 over t=15…30 and 32 → 0 over t=251…255. Under the
|
||
linear ramp the port already uses, alpha first reaches 1 at **t = 15.0588** and
|
||
last exceeds it at **t = 254.8750**:
|
||
|
||
**239.816 units.**
|
||
|
||
✅ **Survives, to four significant figures.** It matters more than a spot-check:
|
||
that number is the *denominator* of the units-per-frame conversion behind the
|
||
9-unit black hold I just authored, so an error in it would have propagated
|
||
straight into a constant I ship. Two derivations from different sides of the same
|
||
record agreeing to 0.02 % is what makes that constant safe to hold.
|
||
|
||
## 🔴 The loading screens are black at *every* instant — which proves the layer rule wrong for a layerless element
|
||
|
||
`build_12` and `build_15` have rendered blank since P3, and I had filed it as a
|
||
`rest()` casualty: `pgloading_eff00` is a 1280×720 quad whose `rest` is opaque
|
||
black. **It is not that**, and the corrected keyframe association makes it
|
||
provable rather than suspected.
|
||
|
||
Every element's declared alpha window on `build_12`:
|
||
|
||
| element | opaque / visible | gone by |
|
||
|---|---|---|
|
||
| **`pgloading_eff00`** (black quad) | **t=0 … 38 at alpha 255** | clears at t=48 |
|
||
| `pgloading_loop4` | t=8…32 | 38 |
|
||
| `pgloading_loop1` | t=16…32 | 40 |
|
||
| `pgloading_line` | t=18…26 | 32 |
|
||
| `pgloading_str` | t=22…28 | 34 |
|
||
| `pgloading_loop3` | t=24…26 | 34 |
|
||
| `baseeff`, `eff01`, `eff02`, `loop5` | t≈16…32 | 32–40 |
|
||
|
||
**The quad is fully opaque across the entire span in which any content is up, and
|
||
it only clears at t=48 — by which time every other element has faded to zero.**
|
||
Rendered at t = 20, 30, 36, 40, 42, 44, 46, 50 units with the timeline *playing*,
|
||
the frame is **mean 0 at every one**.
|
||
|
||
So this is not a bad choice of pose. **Under the port's current layer rule there
|
||
is no instant at which this screen shows anything**, and a loading screen that is
|
||
black for its whole life is not what the game does. That is a proof by
|
||
contradiction, not a preference.
|
||
|
||
### The rule under suspicion is mine, and it is narrow
|
||
|
||
`pgloading_eff00` carries `layer_source: "none"` — no layer key at all — and the
|
||
exporter sorts a layerless element **last**, i.e. on top. Its `paint_order` is
|
||
`[6, 7, 8, 9, 1, 5, 4, 2, 3, 0]` with element 0, the quad, drawn last.
|
||
|
||
⚠️ **Every other full-frame primitive in the export has a layer key** —
|
||
`main_menu`, `extras` and `title` all give their `pteff00` `0x00008030` — so this
|
||
rule only ever bites here, which is exactly why it survived eight milestones
|
||
behind two screens nobody draws.
|
||
|
||
❔ **Where a layerless element sorts is a decoding question and I am not
|
||
answering it.** If the game sorts it *first* — behind everything — the screen
|
||
renders and the quad becomes a backdrop rather than a cover, which is what a
|
||
1280×720 black rectangle at the bottom of a loading screen would sensibly be.
|
||
That reading is *consistent* with the contradiction above, which is not the same
|
||
as being established, and I have asked rather than flipped the sort.
|
||
|
||
✅ **What this does settle:** `verify-screen`'s `BLANK` verdict on those two rows
|
||
was the right call. It reports that both renderers drew nothing and that the row
|
||
proves nothing — and `sylpheed-cli` agrees with the port here precisely because
|
||
it shares the assumption under suspicion. Two renderers agreeing, again.
|
||
|
||
## Their `eff3` retraction — my refusal was right, and my refutation found the same bug
|
||
|
||
The Decoder has withdrawn *"the game never draws `eff3`"*. It draws all five, in
|
||
the declared stagger, in both title entries. **And the mechanism was the one my
|
||
developer-splash refutation had already caught one layer down**: a draw batches
|
||
several quads and the log dumps only the first 8 vertices, so min/max over a
|
||
line's vertex list *merges* them. `eff3` (788…1196) lies entirely inside `eff4`
|
||
(447…1196), so the union is exactly `eff4`'s extent and `eff3` vanished with
|
||
nothing anomalous to see. My `525×259` was `gamearts_eff` merged with `seta_eff`
|
||
by the identical mechanism.
|
||
|
||
⚠️ **The part worth carrying is theirs**: three explanations were reported "ruled
|
||
out", and all three were aimed at the wrong failure — the *"a draw the log cannot
|
||
see"* check counted draws with **no** geometry when the hiding place was draws
|
||
with **partial** geometry. **Refuting three wrong hypotheses is not evidence for
|
||
a fourth**, and a list of failure modes written by whoever built the instrument
|
||
is the least likely to contain that instrument's blind spot.
|
||
|
||
Nothing in the port changes: `eff3` was never dropped, and the developer splash
|
||
still draws three sprites.
|
||
|
||
## The forced backdrop: two of sixteen screens were black for their whole life
|
||
|
||
`build_12` and `build_15` — the two dressed loading screens — rendered as **pure
|
||
black at every instant of their declared timeline**. Not at rest, where a wrong
|
||
`rest.t` could explain it: at t = 20, 30, 36, 40, 42, 44, 46 and 50 units with
|
||
the timeline *playing*, mean 0 in every frame.
|
||
|
||
That is not a defect you can attribute to a pose. A screen that is black for its
|
||
entire life is impossible on its face, and it is the kind of impossibility that
|
||
survives a render-vs-render diff: `verify-screen` scored those two rows
|
||
`max 0 mean 0 over3 0 OK` — **the strongest verdict that script has, awarded for
|
||
comparing nothing against nothing.** Both renderers were black because both
|
||
share `implied_layer_key`. The blank guard now in `verify-screen` was written
|
||
after that, and it is what turned the pass into a row that says it proves
|
||
nothing.
|
||
|
||
### The rule, and whose it is
|
||
|
||
It is the **Decoder's**, decoded from the file rather than inferred from the
|
||
render:
|
||
|
||
> An element that covers the screen and is **fully opaque** at some instant
|
||
> cannot paint above anything visible at that instant. Where the elements
|
||
> visible during its opaque span are **all** of them, its position is forced to
|
||
> first.
|
||
|
||
`pgloading_eff00` is a full-screen quad at alpha 255 from t=0 to t=38, clearing
|
||
at t=48; every other element on those screens peaks around t=8–32 and is gone by
|
||
t=32–40 — entirely inside the opaque span. Under a layer-key sort it painted
|
||
over all nine of them, at every instant they existed. Hence black.
|
||
|
||
### What is implemented, and the two limits that are not negotiable
|
||
|
||
`forced_backdrop_first` in `crates/sylpheed-export/src/screen.rs`, as a post-pass
|
||
over `ui_layout::derived_paint_order`. Two restrictions are copied from the
|
||
Decoder verbatim because each one was found by a test that failed:
|
||
|
||
* 🔴 **Elements with no sprite only.** Applied to sprites, the rule claimed 22
|
||
`.t32` textures must sort first *against their own layer keys*. An element's
|
||
alpha says nothing about whether its **texture** covers the screen — most of a
|
||
sprite may be transparent. The assertion that caught this was one the Decoder
|
||
had nearly deleted as over-strict.
|
||
* 🔴 **Not a name heuristic.** `*base*` first / `*eff*` last matches 77 of 80 and
|
||
fails on exactly the three families that cross it: `palogo_eff0`,
|
||
`pgloading_eff00`, `pzeff00`. `palogo_eff0.prm` is named like an overlay and is
|
||
*measured* painting first. The name is not the rule; occlusion is.
|
||
|
||
⚠️ Reach: it assumes straight alpha-over. Blend mode is undecoded, and an
|
||
additive quad at alpha 255 would not occlude. It is a **lower bound on one
|
||
element's position**, not an ordering — 80 elements are forced, 50 are
|
||
constrained but not forced, and this says nothing about those 50.
|
||
|
||
### The controls
|
||
|
||
Both are the Decoder's prior measurements off the running game. No new oracle run
|
||
was made for this change, by either agent.
|
||
|
||
| primitive | measured | our opaque instants | outcome |
|
||
|---|---|---|---|
|
||
| `palogo_eff0.prm` | **first** | 256 (they measured 211) | ✅ forced first |
|
||
| `pteff00.prm` | **last** | **2** (they measured 2) | ✅ still last |
|
||
|
||
`pteff00` is the one that would break if this were implemented as "push every
|
||
layerless element down". It is the fade cover: opaque at t=0 and again at t=269,
|
||
its screen's entry and exit, and transparent for the 253 instants between. The
|
||
constraint never binds it, and it remains last on all four title-family screens.
|
||
|
||
The `palogo_eff0` count differs — 256 against 211 — because we take the opaque
|
||
span to the **screen's** last keyframe (255) and they stop at 210. It changes no
|
||
verdict here, since the element is opaque across the whole span either way, but
|
||
the two spans are not the same span and only one of them can be the screen's.
|
||
Filed in BLOCKED.
|
||
|
||
An element **holds its final pose to the end of the screen**; it does not vanish
|
||
at its own last keyframe. Reading `palogo_eff0`'s span as `0..=0` — it declares a
|
||
single keyframe — would make the splash's backdrop a one-instant event rather
|
||
than the thing on screen for the whole splash. Rendering `build_12` confirms the
|
||
hold directly: the frame is constant from t=30 to t=60 with the timeline running.
|
||
|
||
### What changed, measured
|
||
|
||
* `build_12`/`build_15`: mean 0 at every instant → ramps in over t=0…30 and
|
||
holds (mean 1.95, max 214.5). The two BLANK rows are gone from `verify-screen`.
|
||
* The splashes are unmoved against the **oracle**: `publisher_logo` 0.01 %,
|
||
`developer_logos` 0.01 % differing region, unchanged before and after.
|
||
⚠️ That is **non-regression, not confirmation** — `verify-capture` poses at the
|
||
settle instant, and the ordering does not necessarily bind there. The evidence
|
||
for the rule is the Decoder's two controls and the impossibility of a
|
||
permanently black screen, not this row.
|
||
* Six `verify-screen` rows now DIFFER: the six screens the rule touches. The
|
||
reference `sylpheed-cli` builds from the workspace `sylpheed-formats`, which
|
||
does not have the rule. **That disagreement is expected and must not be tuned
|
||
away** — it ends when a pinned tag carries the Decoder's change, at which point
|
||
this post-pass is deleted rather than kept in two places.
|
||
|
||
It also explains 36 builds the Decoder had filed as "coming out one colour":
|
||
`pzeff00.prm` is forced first in 32 of 32 of them. Those were wiped by our own
|
||
sort.
|
||
|
||
## Refutation attempt — the forced-backdrop rule's quantifier, and whether it misses a case
|
||
|
||
The rule fires only when **all** other elements are visible during the opaque
|
||
span. That is a strict quantifier, and a strict quantifier fails quietly: an
|
||
element that is a full-screen opaque backdrop but misses the bar by one dark
|
||
element would keep its layer-key position and go on hiding the screen, exactly
|
||
the defect the rule was written to fix. So the question worth asking of somebody
|
||
else's rule is not "is it right" but **"is it enough"** — and that one I can test
|
||
without an oracle, over every layerless full-screen element in `GP_TITLE`.
|
||
|
||
| screen | element | opaque instants | others visible | forced |
|
||
|---|---|---|---|---|
|
||
| `build_12`, `build_15` | `pgloading_eff00` | 39 | **9 / 9** | ✅ |
|
||
| `developer_logos` ×2 | `palogo_eff0` | 211 | **6 / 6** | ✅ |
|
||
| `publisher_logo` ×2 | `palogo_eff0` | 256 | **2 / 2** | ✅ |
|
||
| `title`, `title_jp` | `pteff00` | 2 | 3 / 23 | — |
|
||
| `main_menu` ×2 | `pteff00` | 2 | 7 / 15 | — |
|
||
| `extras` ×2 | `pteff00` | 2 | 5 / 17 | — |
|
||
| every screen | `pteff02` | **0** | — | — |
|
||
|
||
**The rule survives, and the margin is the reason.** Nothing sits near the
|
||
boundary. Every element that fires does so at **100 %** of the others; every
|
||
element that does not is at 13–47 %, and `pteff02` never reaches alpha 255 at all,
|
||
so no quantifier could fire on it. There is no borderline case in this archive
|
||
for a stricter or looser reading to disagree about — which is the answer I could
|
||
not have gotten by re-checking the six screens where it already fired.
|
||
|
||
It also reproduces the Decoder's **second** control number exactly: they report
|
||
`pteff00.prm` forced below **3 of 23**, and `title` measures 3 of 23 here. With
|
||
their opaque-instant count of 2 already matched, two of their three published
|
||
figures for that control now come out of an independent implementation
|
||
unchanged. The third — `palogo_eff0`'s 211 against our 256 — remains the span
|
||
disagreement filed in `BLOCKED.md`, and note that the 211 is the number our
|
||
`developer_logos` row *does* reproduce. That is worth saying plainly: **the
|
||
disagreement is not a constant offset**, so "they stop 45 instants early" is not
|
||
the explanation, and whatever it is differs per screen.
|
||
|
||
⚠️ What this does **not** test: whether the rule is right about the 50 elements it
|
||
calls constrained-but-not-forced, and whether alpha-over is the blend mode. Both
|
||
are the Decoder's to settle. It tests completeness within one archive, which is
|
||
the half I can reach.
|
||
|
||
## The 256/211 was never a disagreement — and my own census had already said so
|
||
|
||
The Decoder answered the span question, and the answer is that **both numbers are
|
||
right**. `palogo_eff0.prm` appears on *both* splashes: the publisher pair
|
||
(entries 10, 13) runs to t=255 and gives **256** instants, the developer pair
|
||
(11, 14) runs to t=210 and gives **211**. I computed the publisher; their page
|
||
quoted the developer.
|
||
|
||
Worth noting how that came out, because it is the one part I can claim: the
|
||
refutation census in the section above reported **256 on `publisher_logo` and 211
|
||
on `developer_logos`, in the same table**, and concluded *"the disagreement is not
|
||
a constant offset, so 'they stop 45 instants early' is not the explanation"*. The
|
||
resolution was already sitting in my own output before their reply arrived. What
|
||
I got wrong was **filing it as a disagreement at all** — I compared one of my two
|
||
numbers against one of theirs and did not check the other row of my own table
|
||
against it. A per-screen quantity needs the screen named next to it, and my
|
||
`BLOCKED.md` row named neither.
|
||
|
||
### The span convention, confirmed rather than assumed
|
||
|
||
> The span is `0 ..= max keyframe time over EVERY element in the build`, and an
|
||
> element **holds its final pose** past its own last keyframe.
|
||
|
||
That is exactly what `forced_backdrop_first` implements, so nothing changes in
|
||
the port. Two things they add that are worth having in writing:
|
||
|
||
* the hold **is not a convenience**: a group holds at its last keyframe rather
|
||
than looping, and the header's `+0x08` never falls short of the last keyframe —
|
||
the slack *is* that hold;
|
||
* `+0x08` and the elements' maximum are **interchangeable**, zero disagreements
|
||
disc-wide. We use the elements' maximum. It stays, and this paragraph is the
|
||
note that the two were *checked* equivalent rather than assumed so.
|
||
|
||
### The hold decides 55 % of verdicts, and the oracle picks it
|
||
|
||
Reported by them over 130 keyless full-screen primitives (their measurement, not
|
||
reproduced here — their page is `docs/re/structures/ui-forced-backdrop.md`, ⚠️ not
|
||
yet on `main` as of this commit):
|
||
|
||
| alternative convention | verdicts changed |
|
||
|---|---|
|
||
| span = the header's declared `+0x08` | 0 |
|
||
| span = the primitive's own last keyframe | **72** |
|
||
| elements **gone** after their last keyframe | **72** |
|
||
|
||
So the reading I very nearly shipped — span = the element's own last keyframe —
|
||
would have changed **55 %** of the verdicts disc-wide. My first implementation
|
||
did exactly that, and `palogo_eff0` is the case that catches it: a *single*
|
||
keyframe at t=0, opaque for one instant, nothing else up yet, so the rule calls
|
||
it **free** — against a game measured painting it first. The convention is not a
|
||
matter of taste; the oracle rules one out.
|
||
|
||
✅ None of our six verdicts rests on it. `pgloading_eff00` is first under all four
|
||
conventions and `pteff00` is free under all four; only `palogo_eff0` moves, and
|
||
only under the convention its own measured order excludes.
|
||
|
||
### The sharper form of the `verify-screen` failure
|
||
|
||
Theirs, and it is better than how I wrote it: those two solid-black frames
|
||
**were not two witnesses**. Both renderers read `implied_layer_key`, so their
|
||
agreement carried *no information* — a correlated failure is indistinguishable
|
||
from a confirmation. What caught it was not a second opinion but that the agreed
|
||
answer was **impossible on its face**. *"Is this result even possible?"* beats
|
||
*"do two implementations agree?"* whenever the two share an ancestor, and in this
|
||
project they nearly always do.
|
||
|
||
### The boot gate still holds
|
||
|
||
Re-run after the reorder, since the rule changed the paint order of the first two
|
||
screens in the boot path: `publisher_logo` → `developer_logos` → `ADV` (skipped at
|
||
8.12 s) → `title` + `press_start` overlay, plate at full alpha at t=236, complete
|
||
at 10.83 s holding on the title. No script errors.
|