ScreenView now draws two builds at once, which it never had to before. It is a second ScreenView in the same SubViewport rather than a subordinate screen inside one: each build has its own timeline, its own textures and its own hold, which is the entire content of the finding, and Node2D siblings already paint in tree order. `paint_order` still means what it meant -- an ordering WITHIN a build. The delay is authored in flow.json on the BOOT STEP, not on the `title` screen. What was measured is the boot title; whether the plate is there when the title is reached again -- (B) from the menu, or after the attract movie -- is not, and hanging it on the screen would quietly claim that it is. REFUTATION, and it is the substance of this commit: the RE agent's authoring instruction does not reproduce the RE agent's own measurement, and the gap is 3.97 s. The instruction is "when build 4 has settled, wait 2.13 s, composite build 2". But build 2 has a group and this port plays groups -- ptbtn00 is alpha 0x00 at t=214, still 0x00 at t=236 while it slides 10 px up, and 0xff only at t=238, which is 3.967 s at 60 units/s. So the plate is first VISIBLE at settle+6.10 s, while what was measured -- the glyph counter leaving 154 -- is visibility at settle+2.13 s. Both groups starting together puts it 0.38 s BEFORE settle; build 2 starting at settle puts it at settle+3.97 s; landing on the measurement needs build 2's group to start 2.51 s after build 4's, which is not a landmark of anything. The measurement is untouched -- it is an observation of the running game and I have no standing to doubt it. What is refuted is the step that turns it into an authoring rule. So the port ships the instruction, prints the discrepancy on every boot, and files the row. Same call as the BGM sub-waves: a port that quietly picks the number that looks right destroys the evidence, because a corrected boot looks exactly like a correct one. Also refuted, and it was mine: BLOCKED.md has said since P2 that "no element's alpha reverses direction anywhere in this export, so nothing pulses". ptbtn00 reverses -- 0x00 -> 0xff -> 0x00 -- and it was in the export the whole time. The claim had been checked against the screens P2 happened to be animating. The port still draws no pulse, because no reading of this group yields the measured 2.24 s: the whole group is 4.47 s and from its first keyframe 0.90 s. Gate: `--boot --capture=` writes one frame of the composited end state, instead of the 600-PNG filmstrip that was previously the only boot artifact. `--screen=title --overlay=press_start` raises the same composite in two seconds for anyone who does not want to sit through 137 s of Theora. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WM5XL4HfrHuxz8RiMWdCMC
1753 lines
89 KiB
Markdown
1753 lines
89 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.
|