recover: the F5/F6 port work from the deleted auto/port-p6-audio

A snapshot of the non-game files as of 0148cb8 ("port: F5/F6 hand-off --
one-minute human checks, and a refutation attempt that survived",
2026-09-04), the tip of auto/port-p6-audio. The branch was deleted from the
server on 2026-09-17 during the consolidation cleanup; issue #7 asks for
this work as a reviewable PR, so it is recovered here before the commits
are garbage collected.

Contents: the 84 files the branch changed relative to its fork point
b305aa4, which is this commit's parent. The tree is therefore 0148cb8's
tree with the 854 exported game assets left out -- export-probe/,
export-probe2/, three .wav renders of game audio and adv-v2-screenlog.tsv.
Game data stays out of git; the exporter regenerates those from the disc.
docs/port/DECISIONS.md still refers to them by name.

Not recovered: the branch's own 366 commits. Keeping them would make those
assets reachable again, so this is one snapshot instead. The original
commits stay unreferenced in the server's object store, and in this clone
under the local branch archive/port-p6-audio, until either is garbage
collected.

Refs #7. The OPTIONS work that issue #6 asks for is a subset of this
branch, also recovered as recover/options-menu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylpheed port agent
2026-09-04 13:45:42 +00:00
committed by sim
parent b305aa4a5a
commit 50be9578c5
84 changed files with 34736 additions and 293 deletions

View File

@@ -0,0 +1,129 @@
# Agreed retro — Port and Decoder, 2026-08-31
Two self-reviews, one round of mutual attack, and the result both agents accept.
* Decoder's own review: `docs/agents/RETRO-2026-08-31.md` on `auto/frame-blend-draw-path`.
* Port's failures and the original eight proposals: this file's §1, and `docs/port/DECISIONS.md`.
⚠️ **Nothing here is applied to `PROTOCOL.md`.** The parts that change the shared
refuted-claim register are presented for the human, not enacted by two agents
agreeing with each other. Both agents remain paused.
## 1. The single most expensive thing we did
**We let claims that rest on our own renderer sit in the register as settled
refutations.**
`REFUTED.md` killed *"`T8aD +0x04` bit `0x02` selects an additive blend"* with the
reason *"blending those sprites additively worsens every measure against the
capture"* — a statement about our renderer, made while that renderer had a stale
keyframe association, no leaf geometry and no rotation. The field is real. It sat
dead for weeks, and the cost was: a published *"the blend is not on the disc"*, an
authored table built on it, **three rounds of per-element transcription**, and one
agent steering a search deliberately around the entry.
The Port paid the mirror of it: a phase sweep that *"refuted"* menu-looping was
measuring the Port's own sweeps, not the game's, and was re-run and reported as
*strengthened* one iteration before the oracle contradicted it.
📌 **Neither of us was careless. The rule was in `PROTOCOL.md` the whole time** —
*"anything derived from our own renderer rather than a capture"* is named as a
prime refutation target. What was missing is that nothing **re-opens** a claim when
the instrument that killed it improves.
## 2. The gap underneath our controls
`PROTOCOL.md` already requires running an instrument through a control. **We both
did, and it did not help**, because:
> **Our controls verified capability, not configuration.**
* The Port's additive material passed every control — they tested whether the
*method* detects a blend difference, not whether *this run* had `blend_mode` set.
It was left at Godot's default, `MIX`. The change predicted a large move and
delivered **0.03**, and would have been publishable as a careful negative.
* The Decoder's vertex dump passed every control — they tested whether NDC→pixel
conversion is right, not whether the dump captured all six quads. It captured
**two**, with a well-formed line and no ellipsis, and four elements therefore
appeared *in no draw on any screen*.
## 3. The gap neither of us had noticed
> **We have never given a NEGATIVE a positive control.**
Every *"undecodable, with reach"* page lists **where we looked**. Not one shows
that the search method **can find a property that is there**. *"Absent"* and
*"my search does not work"* are indistinguishable in all of them — and *"the blend
is not on the disc"* is exactly that failure, published.
## 4. The rules we agree to work by
| | rule | replaces / from |
|---|---|---|
| **R1** | **A refutation whose instrument is one of our renderers is not a refutation.** It is *"our renderer disagrees"* — 🟡, not ❌. Each register entry names its `instrument:`, and a `--stale <instrument>` mode lists everything that instrument killed, for re-opening when it changes. | Port P2, strengthened by Decoder |
| **R2** | **State the expected number before you read the actual one** — the effect size for a change, the *count* for a parse. *"This draw declares 24 indices, so I expect 6 quads."* | Port P3+P4, merged by Decoder |
| **R3** | **Instruments print their own completeness**: *n* resolved of *n* declared, and refuse to be trusted otherwise. | Port P4 |
| **R4** | **A negative carries a positive control.** Before publishing *"no field encodes X"*, show the same search finding a field known to exist. | Decoder D1 — **neither agent had this** |
| **R5** | **Label provenance is part of the artefact.** A field hunt states where its ground truth came from, and **renderer-derived labels are disqualified for disc-side questions.** | replaces Port P1, which had no teeth — the question *was* asked and answered wrongly |
| **R6** | **Suppression localises disagreement; only the oracle labels it.** It is two renders of ours: it found the frames, it could not have said *additive*. | Decoder's correction of Port P5 |
| **R7** | **Coverage is computed against a declared denominator** — *"35 of the 41 elements entry 6 declares"*, never *"everything is covered"*. | Port P6 + Decoder |
| **R8** | **Hold the role line even when the answer looks obvious.** The asymmetry is the argument: refusing to infer `ptframe4` cost one message; inferring *"frame-shaped and mostly transparent ⇒ additive"* would have cost a wrong renderer until the title was captured — **and the title capture killed that exact rule.** | Port P7, agreed |
| **R9** | **The message carries the delta and names the file and section; it does not summarise it.** Short messages are safe only when the pointer is precise. | Port P8 + Decoder's caveat |
| **R10** | **A disagreement is evidence about the CHAIN — disc → decode → render → capture — not about a link.** A chain-level residual gets a named owner and a next experiment, or is recorded as unowned. | Decoder F |
| **R11** | **A cross-agent pointer must fail loudly when it goes stale.** Every staleness incident here was silent. | Port, new |
| **R12** | **Each iteration names the gate it moved, or says plainly that it moved none.** | Port, new — see §5 |
## 5. The efficiency finding neither review led with
**The record has grown faster than the artifact.** `DECISIONS.md` is past 13 000
lines. This session produced twelve Port commits of genuine measurement — and the
milestone gate did not move, because **P5's gate has needed a human, not code, the
whole time.** Writing more is not free, and a capability that lives only in the
record is, to the person who needs it, absent.
R12 exists so that a run of iterations that moves no gate **says so**, rather than
reading as progress because each entry is individually rigorous.
## 6. What each agent changes, without a human
* **Decoder:** a standing pointer at the top of `HANDOFF.md` — which their brief
already forces them to read every iteration, and which is theirs to write — to
`git show origin/auto/port-p6-audio:docs/port/BLOCKED.md`. **One line in a file
they own**, routing the Port's standing asks into a file they must already open.
This closes a gap `BLOCKED.md` records as having cost three sessions.
* **Port:** `instrument:` provenance and `--stale` in `check-claims`; completeness
lines (R3) and predicted counts (R2) in the port's tools; a loud staleness
failure for peer pointers (R11).
## 7. What needs the human
1. ✅ **The register re-classification (R1) — DONE 2026-09-01, by the human**, on
`docs/re/REFUTED.md` at the Decoder's tip. All **222** entries now carry an
`⟨instrument⟩`; the file opens with a reading guide naming which instruments
are ours; R1 is now standing text in `PROTOCOL.md`; and
`tools/stale-instrument` is the `--stale` query — run it whenever you improve
a renderer, a reader or the harness, and it lists what that instrument killed.
**Ten entries moved ❌ → 🟡**, each naming what would settle it: eight
`render-vs-capture`, one `our-reader`, one `harness`.
Three things the pass turned up that neither self-review had:
* **The `rest()` question is open, and had been reading as settled in both
directions.** *"rest = last keyframe"* was refuted by the sibling argument;
that refutation was then refuted by correlating our render against
captures. Both legs run through our renderer, so under R1 neither survives
— and which one you believed depended on which entry you found first.
🔴 **This one is load-bearing for the port**: `rest()` decides the pose
every plateau-less element is drawn at.
* **A withdrawal never reached its sibling.** *"2 391 frames, max glyph 0"*
was withdrawn because a long-lived `x11grab` stream degrades and then
repeats a stale frame. The 1 674-sample negative three lines above it —
same probe, same instrument, comparable duration — was left standing as a
*reinstated measurement*. §1's lesson, inside the register itself.
* **83 of 222 entries — 37 % — record no instrument at all.** Not disputed,
not safe: **unauditable**. `stale-instrument unrecorded` is the backfill
queue, and it is larger than every other group combined.
2. **P5's gate** — a person clicking through the port. Unchanged, and it is the
only thing standing between the milestone and done.

View File

@@ -78,6 +78,13 @@ rec.set_recording_active(false)
rec.get_recording().save_to_wav("user://master.wav")
```
**This is implemented.** `godot --path port -- --menu … --audio=/tmp/p6.wav`
installs the effect, records for the whole run, and saves on exit — in
`_exit_tree` rather than beside each `quit()`, because there are eight of those
and the one that would get missed is an error path, i.e. exactly the run whose
audio somebody wants to look at. The run prints the driver name beside the file
it wrote.
Then feed that WAV through §1 against the source. That closes the loop: it
proves the asset is right **and** that the engine reached it, which no amount of
file comparison can show on its own.
@@ -109,9 +116,287 @@ silent**, because silence is the failure that looks like success: a WAV of
exactly the right duration, full of zeroes, because the application opened a
different sink. A duration check alone would pass it.
## 5. A multichannel capture must pass a provenance check BEFORE it is analysed
`tools/port/check-capture FILE.wav` — run it first, every time.
⚠️ **This section exists because a capture of the game's own 6-channel output was
analysed at length and the file was corrupt.** It got three controls, a
drift test and a written-up negative, and every one of those was sound; none of
them could see that channels were missing, because the corruption was upstream of
everything they tested.
**PulseAudio was remapping between two mismatched channel maps, and a 6-channel
remap silently drops and duplicates.** The Decoder proved it with a control that
needs no emulator and no disc — six channels each carrying a different tone,
through the same sink and the same `parec` invocation
(`docs/re/audio-capture-channel-map-trap.md`):
| ch | played | recorded |
|---|---|---|
| 0 | 400 | 400 |
| 1 | 800 | **3200** |
| 2 | 200 | 200 |
| 3 | 1600 | **800** |
| 4 | 3200 | **800** |
| 5 | 6400 | **200** |
**Two source channels were gone entirely** and two were duplicates. Setting the
sink's `channel_map` to the guest's own (`FL,FR,FC,LFE,RL,RR`) and passing the
same map to `parec` returns all six.
### The signature is an exact duplicate pair, and only a hash finds it
Duration is right. Channel count is right. `Corked: no`. There is no error
anywhere, and the **per-channel levels look entirely reasonable** — which is the
whole difficulty. In the tool's own known-bad control, all six channels report a
peak of **−18.063656 dB, identical to six decimals, while containing three
duplicate pairs.** A level check cannot see this. Hashing each channel can.
Two channels of a real surround mix are never byte-identical over tens of
seconds. On the corrupt game capture the tool reports:
```
ch2 peak -4.466272 ba497de78217c438a3e430c5ef6b951b
ch5 peak -4.466272 ba497de78217c438a3e430c5ef6b951b
🔴 ch2 and ch5 are BYTE-IDENTICAL
```
⚠️ **It is a necessary check, not a sufficient one.** Passing says the file has no
duplicated channels. It says nothing about whether the right thing was recorded —
that is what §1's correlation against a known source is for, and a capture should
survive **both** before anything is concluded from it.
### Two more conditions, learned the same way
* **Start the recorder before the process you are capturing**, so `t = 0`
precedes it and the window certainly contains the moment of interest.
* **Log what was on screen, with timestamps keyed to the recording's own clock.**
A capture that matches nothing is then diagnosable rather than ambiguous; the
corrupt one could not be told apart from "recorded the wrong phase of the boot"
by any amount of analysis at this end.
And the failure this page already warns about, in a second costume:
`run-canary` is silent **twice over** — `SDL_AUDIODRIVER=dummy` *and*
`--mute=true`. Fix only the first and Canary attaches a healthy 6-channel stream
at 100 % volume, reports `Corked: no`, and emits a 19 MB WAV of zeroes.
## 7. A capture can be starved — right duration, holes punched through it
`check-capture` tests this too, and it is the second way a recording looks
perfect and carries nothing.
**A monitor sink advances at wall-clock rate and substitutes silence whenever the
producer is late.** An emulator running below real time therefore yields a file
of exactly the right duration, the right channel count, no duplicated channels —
chopped into fragments with holes between them, thousands of times over.
Measured independently on the capture that prompted this (the Decoder's numbers
on the untruncated original in brackets):
| | |
|---|---|
| frames silent on **all six** channels | **35.6 %** [39.3 %] |
| alternating runs | **10 482** [10 595] |
| median burst / gap | **13.5 ms / 3.9 ms** [13.6 / 3.9] |
| period | **17.4 ms → 57 Hz** [≈17.5 ms → 57 Hz] |
⚠️ **This destroys envelope correlation by construction.** What dominates the
envelope of such a file is the dropout schedule, not the content — so §6's method
was working correctly on a file that could not carry the signal, and the negative
it produced said nothing about the game.
### Two thresholds I invented were wrong, and the controls caught both
1. **Counting exact-zero frames.** Real audio crosses zero constantly, so a clean
voice track scored **5 947 "gaps" of median 0.0 ms** and was called starved. A
gap is a **run**, not a sample: only runs of ≥ 1 ms count.
2. **Gap count and median length.** A genuine music-and-effects bed has **454
gaps at a median of 1.4 ms** — quiet 16-bit passages really are zero for
milliseconds — so neither statistic separates it from a starved file.
3. 🔴 **The gap RATE alone.** This one shipped, and the Decoder found it: raising
the client buffer keeps cutting the rate while total silence **bottoms out and
then doubles**, because an over-large buffer starves in a few enormous holes
instead of many small ones. Its `PULSE_LATENCY_MSEC=500` capture scores
**1.3 gaps/s — better than a genuine music bed at 3.3 — while being 50 %
silence**, and a 20/s bar passed it.
**It takes two numbers, because either one alone is blind to the failure next
door** — the same shape as a level table that cannot see a duplicated channel.
Reproduced on a file held here (`bigholes`: a real bed with 350 ms holes punched
into it) so the regime is controlled rather than quoted:
| control | all-channel silence | gaps/s | verdict |
|---|---|---|---|
| real music+SFX bed | 1.1 % | 3.3 | **PASS** |
| voice track, mono, real pauses | 53.2 % | 0.3 | **PASS** |
| bed with 350 ms holes | **46.3 %** | 3.2 | **FAIL** |
| the starved capture | **35.6 %** | 30.9 | **FAIL** |
Rate alone cannot separate rows 2 and 3; silence alone cannot separate rows 1 and
3. **The pair does:** fail when ≥ 10 % of the file is silent on every channel
*and* there is at least 1 gap per second. Real audio is either mostly not silent,
or silent in a few long stretches — not both at once.
### A format it cannot read is refused, not guessed at
Everything in the starvation check assumes 16-bit signed. An ALSA `type file` tee
writes **float32** (`SND_PCM_FORMAT_FLOAT_LE`), and read as s16 that produces a
*plausible-looking* file — the Decoder measured one, and its only tell was
per-channel peaks alternating **exactly**, which is the two halves of each float
landing in alternate channels.
So an unreadable format ends the run at **`PARTIAL`** (exit 2), not `PASS`:
channels were checked, starvation was not, and the tool says which. A checker
that claims a check it skipped is the shape of every failure this file documents.
⚠️ **`WAVE_FORMAT_EXTENSIBLE` (tag `0xFFFE`) is accepted at 16 bits**, and the
first version of the guard was not — it rejected one of this tool's own controls,
a file `ffprobe` correctly calls `pcm_s16le`. **A format guard that refuses a
legitimate capture is the same defect as one that mis-reads an illegitimate one**,
pointing the other way. The check turns on `wBitsPerSample`, which is what
actually decides the sample layout; a float tee is 32-bit and is still caught.
### The control sweep, which is the tool's real specification
**Run it: `tools/port/check-capture-controls`.** 🔴 Until 2026-08-30 this table was prose — the specification existed and nothing executed it, so a regression in `check-capture` or a drifting threshold would have gone unremarked in a tool whose own history is *two invented thresholds that were both wrong and were caught only by controls*. This document states the principle it was breaking: **"a control that does not execute is not a control."**
⚠️ The verdicts below are **compressed**. `check-capture` emits two — one for channel provenance, one for starvation — and the sweep asserts the pair, because the voice control is `PASS` on channels and `UNJUDGED` on starvation *by design* and a single word cannot say that. A starved file **short-circuits** before the channel check, which the sweep records as `n/a` rather than as a failure: *the check did not run* and *the check failed* are different facts.
⚠️ The **starved capture cannot be rebuilt** — that artifact was transient and is gone. The sweep reports it `MISSING` rather than omitting it, and deliberately does not synthesise one from the statistics published above: a control fitted to the answer it must give is not a control either.
| file | verdict |
|---|---|
| real music+SFX bed | `PASS` |
| voice track, mono, 53 % real pauses | `PASS` |
| six distinct tones (PCM and extensible) | `PASS` |
| bed with 350 ms holes punched in | **`FAIL`** |
| the starved capture | **`FAIL`** |
| the same tones as float32 | **`PARTIAL`** |
### ⚠️ The regime this tool cannot judge, and says so
**High silence with very few gaps is what a real voice track looks like (53.2 %
in 0.3 gaps/s) and also what an over-buffered capture looks like.** No statistic
here separates them. The tool prints `UNJUDGED` and tells you to check the file
against a known source rather than passing it silently — because inventing a bar
for a regime with no control in it is how the two bars above came to be wrong.
⚠️ **A control that does not execute is not a control.** An earlier version
returned immediately for a single-channel file, so the mono voice track — one of
the four controls — was never actually run through the check it was meant to
control. Mono now skips only the duplicate test.
### 🟡 The monitor-sink route may be fixable after all — retry before rebuilding
An earlier version of this section said the route *"cannot be fixed by
configuration"*. **Withdrawn.** That inferred from the holes that the guest runs
below real time, without testing the alternative: **the client buffer is simply
tiny.** Xenia asks SDL for 256 samples — **5.33 ms** at 6 ch — against a stock
`daemon.conf` with no fragment tuning.
| client buffer | silence | gaps/s |
|---|---|---|
| Xenia default (~5.3 ms) | 39.3 % | 30.5 |
| `PULSE_LATENCY_MSEC=200` | **15.6 %** | 3.5 |
| `PULSE_LATENCY_MSEC=500` | 50.1 % | 1.3 |
⚠️ Not clean, and not like-for-like — 88 s against 347 s, and the short run covers
the splash logos where silence is real. But **the capture route deserves a retry
at ~200 ms before anyone spends a session on a Canary rebuild.**
### The tap, if configuration is not enough
`parec` reads a monitor that advances at wall-clock rate and substitutes silence,
so **every moment the emulator runs below real time is a hole**, and the timebase
is warped non-uniformly — deleting the silences compresses time unevenly rather
than repairing it. The route that would work is an **internal tap at
`SDLAudioDriver::SubmitFrame`**, which sees every frame the guest produces in
guest order with no wall clock in the loop.
⚠️ That needs a Canary rebuild, and the Decoder has costed it: `build-canary`
targets a source root that does not exist in that container, the warm build tree
is configured against the same missing path, so any change is a full reconfigure
plus a full compile on a box with ~700 MB free and a history of parallel builds
OOM-killing the host. **A whole session for one probe** — the human's call, not
an agent's.
### And a header that never got patched
A streaming writer leaves `data` declaring **0 bytes**. `check-capture` says so
and tells you the duration is unverified — which is not pedantry: the file shared
here was **copied while it was still being written**, and the provenance claim
that came with it was wrong about both its length and what it contained.
## 6. Finding one component inside a mix — and why §1's method cannot
🔴 **This section begins with a retraction.** Two captures of the game's own
output were analysed with sliding envelope cross-correlation and declared not to
contain the intro's audio. **The instrument was never controlled for the actual
task**, and when it finally was, it failed:
> Can it find the movie's bed inside a synthetic mix of that bed plus the three
> voice streams? **r = 0.415** — below the `r > 0.8` bar those negatives were
> judged against.
The first negative happened to be right (the file was independently proved
corrupt by a tone control). **It was right by luck, and the reasoning behind it
was not supported.** A filter that fails its own known-positive is dead, not
tuneable.
### What was wrong: the threshold, not the idea
`r > 0.8` was calibrated on **clean-against-clean** comparisons, where it is
correct — a transcode against its source scores 1.000. A *component inside a
mix* can never score that, because everything else in the mix is uncorrelated
noise from the component's point of view. Judging one task by the other's bar
guarantees a false negative.
**Judge on the LAG and the MARGIN instead.** A real match lands at the *right*
lag with a clear gap to the runner-up; a false one is a plateau. And **band-limit
first**, so the component you are hunting dominates what you measure.
### The calibration, on a known-present and a known-absent pair
Both bands, both directions, envelope at 0.1 s, minimum 60 s overlap:
| hunting | band | against | *r* | lag | **margin** |
|---|---|---|---|---|---|
| the movie bed | 40–180 Hz | mix containing it | 0.663 | **0.0 s** ✓ | **+0.111** |
| the movie bed | 40–180 Hz | voice-only mix | 0.262 | −31.9 s ✗ | +0.005 |
| voice stream 2 | 300–3000 Hz | mix containing it | 0.810 | **0.0 s** ✓ | **+0.248** |
| voice stream 2 | 300–3000 Hz | the bed alone | 0.358 | −58.4 s ✗ | +0.005 |
**A 20–50× separation in the margin, and the lag is right or absurd.** That is a
decision rule set by controls rather than by tuning until the data agreed —
which is the distinction that matters, and the one the first version of this
method skipped.
⚠️ **Reach.** The known-positive is a *synthetic* mix at equal gains. A real game
mix weights its components differently, so this bounds the method rather than
modelling the real case exactly. It is enough to separate present from absent; it
is not a level measurement.
## What none of this establishes
That it *sounds right*. Every method here shows correspondence to a source, not
that the source is the audio the game plays at that moment, and not that levels
are sane in a mix. A ten-second human listen still answers something no
measurement above does — so when a result rests on one of these, say which one.
## 4. What the exporter checks, so nobody has to remember to
`sylpheed-export` measures **peak level and duration** of every audio file it
writes and records both in `manifest.json`; `sylpheed-export check` refuses a
tree whose peak is ≤ −90 dBFS (silent) or ≥ 0 dBFS (clipping).
Those are content checks in a format validator on purpose. Silence is the failure
this page opens by naming — right duration, right channel count, right size, full
of zeroes — and every structural check passes it. Clipping is the other one, and
the BGM can produce it, because a music bank is two stems summed at unity gain
(HANDOFF Q10).
⚠️ Neither says the audio is the **right** audio. `docs/port/BLOCKED.md` says
which bindings are measured and which are still authored, and no measurement on
this page can move a row there.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -147,7 +147,7 @@ for a long time.
> grossly (`ptlogo_back2`, 1118×262, pivot 500,117 where half is 559,131). It is
> not a problem for this port — the exporter emits the declared pivot and never
> derives one — but it is a claim a consumer should not lean on. Raised in
> `docs/BLOCKED.md`.
> `docs/port/BLOCKED.md`.
**`sprite`** / **`focus_sprite`** are paths relative to `export/`. The highlight
pairs **by name** on the sprite — `ptbtn01.t32` ↔ `ptbtn01f.t32` — which is 🟡 a
@@ -212,10 +212,25 @@ from: the quad is `pivot × 2`, and its colour is the keyframe's `fade_argb`.
**`keyframes`** carry the on-disc time verbatim in `t`. A keyframe is the
**start of a ramp toward the next**, not a pose that is held, and the ramp is
linear. The **last keyframe of a group has no `t`** — the disc has no time slot
there — and a file that puts one on it is wrong, not merely odd. The unit of `t`
is measured, not on the disc, and so lives in `authored/` and is applied in
exactly one place.
linear.
🔴 **Every keyframe has a `t`, including the last**, and this paragraph said the
exact opposite until 2026-08-29. A placement group is an 8-byte header followed
by `frames` × `{u32 time; 36-byte pose}`, so **pose 0's time is the group's
lead-in word** and no pose is untimed. The old reading — that a group's data
stopped four bytes short of its final block's time slot — paired every pose with
the *next* pose's time, and `sylpheed-export check` enforced it as a rule. A file
with an untimed keyframe is now the wrong one.
⚠️ Two things went with that correction. The **exit ramp is gone**: there is no
untimed final keyframe to give a synthetic time to, so `authored/timing.json`'s
`exit_ramp_units` — an authored *measured* constant since P3 — is **deleted**,
which is what MISSION §3 means by a deletion being the measure of progress. And
`rest.t` moved on several screens: `publisher_logo` settles at t=30 rather than
t=235.
The unit of `t` is still measured rather than on the disc, so it stays in
`authored/` and is applied in exactly one place.
**`rotation_deg`** is screen-plane rotation in degrees, clockwise-positive,
decoded from the keyframe's `+12`. **The game renders it**, confirmed twice by
@@ -257,7 +272,7 @@ keyframe *k* means the screen spends that time *arriving at* `k+1`.
> A consumer that wants the pose after arrival should therefore take **the last
> timed keyframe**, not `rest`. `rest` is kept in the format because it is what
> the pinned decoders say and removing it would hide the disagreement — see
> `docs/DECISIONS.md`. The format is unchanged at **v2**: no field changed
> `docs/port/DECISIONS.md`. The format is unchanged at **v2**: no field changed
> meaning, this is a warning about one of them.
**`paint_order`** is back-to-front, as declaration indices, and is a permutation
@@ -309,13 +324,56 @@ reaches which entry is Q4 and is not).
"disc": "/disc",
"screens": [{ "name": "main_menu", "file": "screens/title/main_menu.json",
"sprites": 18, "missing_sprites": [] }],
"video_transcode": "ffmpeg -i ADV.wmv -c:v libtheora -q:v 8 -c:a libvorbis -q:a 5 ADV.ogv",
"videos": [{ "name": "ADV", "file": "video/ADV.ogv",
"command": "ffmpeg -i …", "why": "HANDOFF Q9: …" }],
"audio": [{ "kind": "se", "name": "move", "file": "audio/se/move.ogg",
"command": "ffmpeg -i …", "why": "HANDOFF Q8, measured: …",
"peak_dbfs": -3.2, "duration_s": 0.533,
"name_match": "SE_UI_CURSOR" },
{ "kind": "bgm", "name": "main_menu", "file": "audio/bgm/main_menu.ogg",
"command": "ffmpeg -i …", "why": "AUTHORED, an arbitrary choice: …",
"peak_dbfs": -1.1, "duration_s": 173.8, "loop_mode": "restart" }],
"warnings": ["GP_READY_ROOM not exported -- out of scope"]
}
```
`video_transcode` will record the exact command so a modder can re-run it rather
than reverse-engineer what was done. It is absent until P4 writes a video.
`videos` and `audio` are **absent** until a milestone writes one, rather than
present and empty: an empty array reads as "we looked and there is none", and
that is not what an export taken before P4 or P6 means.
### `command` and `why`, on every media entry
`command` is the exact ffmpeg invocation that produced the file. MISSION §6: a
modder who dislikes the quality re-runs one line rather than reverse-engineering
what was done to their asset. `why` is where the value came from, in the
project's three-way vocabulary — **decoded** off the disc, **measured** off the
running game, or **chosen**. A `why` that does not say which of those it is has
not done its job.
### `audio`, field by field
| field | |
|---|---|
| `kind` | `se`, `bgm` or `voice`. The runtime dispatches on it, so it is a field rather than a prefix on `name` that a consumer would have to parse |
| `name` | the **role**, not the disc asset: `move`, `confirm`, `back`, `main_menu`. Which bank plays a role is authored and expected to change; a rename on the disc side must not be a change to the Godot project. ⚠️ **`voice` is the exception and keys by MOVIE NAME** (`ADV`, `S00A`), because there is no role to name: the binding of recording to picture came off the disc's own movie manifest, so unlike a music bed nothing about it was chosen |
| `peak_dbfs` | measured off the finished file. **Required.** Silence is the audio failure that looks like success — right duration, right channel count, right size, full of zeroes — and clipping is the other one, which the BGM can produce because it is a sum of two stems at unity gain. `sylpheed-export check` refuses a tree whose peak is ≤ −90 dBFS, and applies a **kind-dependent** upper bound. 🔴 This paragraph used to state a flat *≥ 0 dBFS* and was wrong about the port's own export: `confirm` ships at **+0.18** and the `ADV` voice at **+0.31**, so a consumer implementing a validator from this file would have rejected a valid tree. The rule is: a **`bgm`** is a sum *we* produced, so a peak at or above full scale is our arithmetic and is refused outright; an **`se`** or **`voice`** is a single wave off the disc, mastered near full scale, and a lossy decode of it overshoots by a fraction of a dB — those are allowed to **+1.0 dB**. ⚠️ The +1.0 is a judgement, not a measurement: a few tenths is reconstruction overshoot and a whole dB is not, and if a cue ever trips it the right response is to measure the overshoot distribution, not to loosen the bound |
| `duration_s` | measured off the finished file, so that a claim about a cue's length can be checked against the finding that produced it |
| `name_match` | the game's own cue identifier **guessed by name**. Absent means nobody claimed one — never that the binding is unknown. The binding is the measured part; the name is not |
| `loop_mode` | what the runtime does at the end of the file, where that was authored. Absent on a cue: a cue ends |
**A `voice` entry is a cutscene's dialogue, and it is a separate file on
purpose.** On this disc a movie's `.wmv` carries music and effects only; the
voice is a byte region of one continuous XMA stream in `sound.pak`, bound by the
movie manifest. A consumer plays the two together, **from the same instant** —
there is no offset and none is authored. A movie with no `voice` entry is
genuinely unvoiced, which is the honest answer for most `hokyu_*` cutscenes;
nothing is substituted, and the manifest carries a warning naming the movie.
⚠️ The `why` on a `voice` entry names every region chunk the exporter **dropped**
and its measured length. That is not commentary: which chunks of a region are the
track is an open decoding question (see `docs/port/BLOCKED.md`), and a consumer
reading a shorter file than it expected should be able to see what was left out
rather than infer it.
## Changes from v2

View File

@@ -97,15 +97,15 @@ A milestone is done when its **artifact** exists, not when the code compiles.
|---|---|---|
| **P0** | Exporter skeleton; one screen and its sprites to `export/` | `export/screens/title/main_menu.json` validates against FORMAT.md and the PNGs open |
| **P1** | Godot renders that screen statically at 1280×720 | A Godot screenshot beside `sylpheed-cli screen render` of the same build — they should agree, and where they do not, say which is wrong |
| **P2** | Keyframe animation | Buttons slide in. **Blocked on HANDOFF Q1** (the time unit). Do not invent it |
| **P2** | Keyframe animation | Buttons slide in. ~~Blocked on HANDOFF Q1 (the time unit). Do not invent it~~ — **Q1 is answered**: ramp linear, 2 units per rendered frame, 1 unit = 1/60 s. Gate met |
| **P3** | Splash → title, with the transition | Both screens back to back, unattended |
| **P4** | Intro video | `ADV.wmv` plays with audio (§6) |
| **P5** | Main menu: navigation, focus states, Ⓐ into a submenu, B back | ~~A human clicks through it~~ — ✅ **GATE MET 2026-09-02.** A human walked it: *"Menu walk and navigation is fine. Video skips too. Extras open."* [`../agents/PLAYTEST-2026-09-02-menus.md`](../agents/PLAYTEST-2026-09-02-menus.md) |
| **P6** | Audio — menu BGM and move/confirm SFX | Sound on the P5 gate. **Looping is blocked on HANDOFF Q10** |
| **P6** | Audio — menu BGM and move/confirm SFX | Sound on the P5 gate. ~~Looping is blocked on HANDOFF Q10~~ — **Q10 is answered**: two stems of one performance, played together. 🔴 **Gate NOT claimed**: the same play-test found the SFX mix wrong (F2), and "sound on the P5 gate" means the right sound. |
| **P7** | New-game intro video after NEW GAME | Plays, then returns to a defined state |
Work the lowest unfinished milestone. When one is blocked on an RE answer, say so
in `docs/BLOCKED.md`, and take the next milestone that is not.
in `docs/port/BLOCKED.md`, and take the next milestone that is not.
## 6. The video problem

158
docs/port/RUNNING.md Normal file
View File

@@ -0,0 +1,158 @@
# Running the port
**P5's gate is *"a human clicks through it"*, and until now there was no page
telling a human how.** The commands existed — in `boot.gd`'s header comment and
scattered through a twelve-thousand-line `DECISIONS.md`. A capability that lives
only in the record is, to the person who needs it, absent.
Everything below has been run. Where a number is quoted it was measured in this
container, and where the container distorts it that is said rather than left for
the reader to discover.
## 1. Build the asset tree
The Godot project reads `export/`, never the disc.
```bash
cargo run --release -p sylpheed-export -- export --disc /disc --out export
```
Roughly four minutes, most of it transcoding two movies. It **rewrites `export/`
wholesale** — never hand-edit anything in there; hand-written decisions live in
`authored/` beside it, and survive a re-export.
## 2. The P5 walk, from a cold start
```bash
godot --path port -- --boot --play
```
This is the one a human should judge. It boots the way the game does — two
splashes, the `ADV` intro, the title — hands over to the menu on Ⓐ, and then
**stays live and waits for input**.
| you press | what should happen |
|---|---|
| Ⓐ on the title | the main menu opens on **NEW GAME** |
| ⬆ / ⬇ | one item, wrapping at both ends |
| ⬅ / ➡ | **nothing** — measured, and implemented as an explicit no-op |
| Ⓐ on **EXTRAS** | the EXTRAS submenu, opening on **MISSION SELECT** |
| Ⓑ in EXTRAS | back to the main menu, **on the item you left** |
| Ⓑ on the main menu | back to the title |
| Ⓐ on the title again | the menu, **still on the item you left** |
That last row is the one worth checking deliberately: the main menu **remembers
its cursor**, and every submenu **resets** to its own opening item. Both are
measured, and they disagree on purpose.
⏱ **The intro is ~157 s.** To skip straight to the menu:
```bash
godot --path port -- --menu=main_menu
```
and to drive it unattended:
```bash
godot --path port -- --menu=main_menu --script=down,down,down,down,accept,cancel
```
🔴 **This example used to say `down,down,accept,cancel`, and it walked the wrong
path.** Two ⬇ from the opening item lands on **`TUTORIAL`**, whose destination this
export does not carry — so the example exercised a *not-carried* message and
returned, never opening a submenu at all. **`EXTRAS` is the fifth item**, so it
takes four. The one submenu P5's gate rests on was the one the runbook's own
command did not reach. Verified 2026-08-31 by running both.
🔴 `--script` **without** `--play` or `--menu` refuses and says so. It used to
parse, be stored, and do nothing.
## 3. What is knowingly missing — not bugs
Four of the five main-menu destinations are **measured but not in this export**:
they live in other archives (`GP_SAVE_LOAD`, `GP_OPTIONS`, …). Pressing Ⓐ on them
prints what it would have opened and why it cannot:
```
(LOAD GAME) opens a screen this export does not carry:
The save-slot list is GP_SAVE_LOAD, not in this export. Destination MEASURED.
```
**EXTRAS is the only Ⓐ-into-a-submenu this milestone can walk**, which is why the
P5 gate rests on it.
`NEW GAME` is a deliberate gap of a different kind: the real chain is
NEW GAME → DIFFICULTY → SELECT DATA → the `S00A` movie, and the port **jumps to
the movie**, printing the two screens it skipped. That is a gap, stated out loud;
nobody should read the port's behaviour there as the game's.
## 4. What this container distorts
* **No GPU.** 720p Theora decodes **+6.7 % … +6.9 % slower than real time** here
(5 runs, both movies, on a quiet box). The boot's printed seconds carry that
deficit. It is a property of the machine, not of the port.
* **No sound card.** Godot falls back to a dummy driver, so **you will hear
nothing**. The audio is present and measurable —
`docs/port/AUDIO-VERIFICATION.md` answers every audio question without a
device, and `tools/port/verify-menu-audio` asserts it — but *"I heard it"* is
not available in here.
* **A leaked-object warning at exit** is engine-side, not the port's. Measured:
releasing every reference the port owns moves the count from 8 to 8.
## 5. Modding
`data/mods/` shadows `export/` by path. Each override is announced as it is read,
and at the end of a run any file that **can never apply** is listed:
```
mod: sprites/title/main_menu/ptbase.png <- data/mods/...
mods: 1 file(s) in data/mods can shadow NOTHING -- no such path in the export:
inert: sprites/title/TYPO_menu/pteff05.png
```
A file whose path exists in the export but was simply not read this run is **not**
listed. See `docs/port/MODDING.md` for the five rules the asset tree keeps.
## 6. Where the work is, and what P5's gate is waiting on
**P5's gate is the only one that needs a person, and it is not waiting on code.**
Everything above runs from `auto/port-p6-audio`.
🔴 **This section used to quote counts — "256 commits ahead, 58 files" — and they
were stale the moment they were committed, because committing them incremented
the count.** By the time anyone read it, it said 256 and the answer was 258. A
number written into a document meant to inform a decision **decays with every
commit either agent makes**, and the Decoder hit the same thing in their own
merge-state page one message after recording the class.
**So what follows are the invariants, which do not move, and the commands to
re-derive anything that does.**
| invariant | check | **passes when** |
|---|---|---|
| `main` is an **ancestor** of this branch — a fast-forward, nothing to resolve | `git merge-base --is-ancestor origin/main HEAD; echo $?` | prints **`0`**. ⚠️ The command itself prints **nothing** on success — without the `echo` a reader cannot tell success from failure |
| `main` is an ancestor of the Decoder's branch too | `git merge-base --is-ancestor origin/main origin/auto/build-ordinal-audit; echo $?` | prints **`0`**, same caveat |
| the two change sets touch **zero files in common** | `comm -12 <(git diff --name-only origin/main...HEAD \| sort) <(git diff --name-only origin/main...origin/auto/build-ordinal-audit \| sort) \| wc -l` | prints **`0`** |
| merging both produces **no conflicts** | `git merge-tree --write-tree HEAD origin/auto/build-ordinal-audit \| wc -l` | prints **`1`** — one line is the tree id; conflicts would follow it. **Read-only: this merges nothing** |
**Last run here: `0`, `0`, `0`, `1`.** A command published without a pass
condition is half a check — the reader gets a number and no way to know whether it
is the right one — so each row states what the right one is.
📌 **So the sentence is not "N commits behind", which sounds like something to
schedule. It is: two fast-forwards over disjoint file sets, mergeable in either
order with zero conflicts.** Counts if you want them:
`git rev-list --count origin/main..HEAD`.
### What a person is actually being asked to do
1. `godot --path port -- --boot --play`, then walk §2's table.
2. Say whether it behaves as described. **Not whether it matches the game** —
that comparison is the oracle's job and is already asserted by
`tools/port/check-all`.
3. If it does, P5's gate is met and nothing else is blocking P6, which asserts its
own audio and has no human step.
⚠️ **You will hear nothing** (§4), and the intro takes ~157 s. `--menu=main_menu`
skips straight to the part being judged.

View File

@@ -0,0 +1,228 @@
# The blend map is deleted — and adopting the decoded field found a counter-example
**Status:** ✅ **adopted.** ❌ **My counter-example failed — the bit is right and the
regression is a metric artefact.** See the last two sections.
Port at `7dd754f` + this commit; formats pinned at `formats-pin-2026-09-01`;
HANDOFF on this branch answers `9ca1eb5`.
`PORT-MISSION` §3: *"When the RE agent later decodes something you had authored,
delete the authored entry and let the exporter emit it. That deletion is the
measure of progress."* This is that deletion.
## What changed
| | before | after |
|---|---|---|
| source | `authored/rendering.json` → `additive_elements`, keyed by **screen name** | `blend_additive` per element, emitted by the exporter |
| origin | transcribed from the Decoder's per-draw `RB_BLENDCONTROL0` log | **decoded** — `T8aD +0x04` bit `0x02` |
| reach | three screens somebody drove the game to | every screen on the disc |
The pin bump is its own commit (`7dd754f`). The exporter emits `blend_additive`
on `Element` **and** on nested focus/leaf elements — both spellings of the
accessor are needed, because a button's focused variant is reached through
`focus_link` and `ptbtn00f.t32` is in `build.sprites` while no element carries it
as `sprite`. `ptbtn00f` is exactly the sharp case: the plate is alpha-over and
its own glow is additive, on one screen in adjacent draws.
## The check before the swap — the map was a subset, not the answer
Over `main_menu`, `extras`, `press_start` and `title`:
| | count |
|---|---|
| map says additive **and** the disc agrees | **15** |
| map says additive and the disc does **not** | **0** — no contradictions |
| disc says additive and the map did not | **17** |
Nothing transcribed was wrong. It was **incomplete and was being read as
complete**. The 17 include:
* `pteff03` / `pteff03a` — the sweep **leaves**. `draw_leaf_for` means those are
what actually reach the screen while the map listed their parents
`ptloop01`/`ptloop02`. (Both parent and leaf carry the bit, so this one turned
out to change nothing — established below, not assumed.)
* **twelve on `title`**, where the map was deliberately empty. The port has been
drawing every title effect alpha-over.
**And it answers `BLOCKED.md` H6 with no capture at all.** The JP asymmetry — the
port drawing `main_menu` additive and `main_menu_jp` alpha-over, asserting by
omission that the JP build differs — was an artefact of a name-keyed map. The bit
is on the disc for every screen at once.
## 🔴 The regression, which is one element
Scored against the oracle captures, on the GPU, before and after:
| screen | before | after | Δ |
|---|---|---|---|
| **`main_menu`** | 10.88 | **13.02** | **+2.14** |
| **`main_menu_options`** | 11.56 | **13.57** | **+2.01** |
| `extras` | 13.10 | 13.10 | — |
| `title` | 14.11 | 14.11 | — |
| `title_plate`, `title_band`, both splashes | unchanged | unchanged | — |
**The scores are deterministic** — two further runs gave 13.02 / 13.10 / 13.57
to the digit — so this is a real change, not sampling noise.
### It is `pteff10`, isolated
* `main_menu`'s only newly-additive **top-level** element is `pteff10`.
* `extras` has **no** newly-additive top-level element, and its score did not
move. That is the control: the same change applied to a screen with nothing new
moves nothing.
* The leaf rule was tested separately by disabling it — `main_menu` stayed at
13.02, so `pteff03`/`pteff03a` are **not** the cause. That prediction of mine
failed and the rule was restored, being provably neutral here.
`title` did not move despite twelve newly-additive elements, which is consistent:
`verify-capture` poses at settle `t=198`, and the title's effect quads —
`ptlogo_back2eff1…5`, `ptlogoall_eff`, `pteff01` — are transparent there.
### 🔴 WHY I THOUGHT THIS WAS A COUNTER-EXAMPLE — and it was not. Kept because the premise-check is the lesson
**Their own map lists `pteff10` as additive on `extras` and not on `main_menu`,
and they logged both screens.** So either their per-draw log shows `main_menu`'s
`pteff10` drawn alpha-over — a direct contradiction between a capture and the
disc bit, on one element — or it was not drawn during that capture. The oracle
comparison independently prefers alpha-over there.
❌ **Wrong, and the premise was the failure.** The oracle *does* measure it
additive on `main_menu` — three sessions, every frame. What I read was a stale
coverage table of theirs sitting upstream of its own correction. **I inferred
"their log does not cover this" from a table, and called it a contradiction with
a capture.** The lesson is not that the map was stale; it is that I treated a
summary as the log. See the resolution at the foot of this page.
## Why the change ships anyway, stated rather than assumed
1. `main_menu` carries a **±3.78 capture-phase term** in the harness's own note —
the capture caught the free-running sweep at an unknown phase. **+2.14 is
inside that stated uncertainty** and cannot adjudicate a disc fact.
`main_menu_options` is a sub-region of the same screen and inherits the same
sweep.
2. The decoded source is far better evidenced than the comparison that moved, and
it **fixes two known defects** — twelve title effects drawn with the wrong
blend, and a JP/EN asymmetry the port was asserting by omission.
3. Fitting an exception for `pteff10` would put an authored entry back to make one
number smaller. That is the move this project keeps having to undo.
🔴 **This was a known regression shipped deliberately, not an unnoticed one** —
and the decision was right for a *stronger* reason than the one I used. Not only
is +2.14 inside the ±3.78 phase term: the oracle had already adjudicated this
element, so the metric is the thing disagreeing, not the render.
## What this does not claim
* That the bit is wrong. One element, inside a stated uncertainty, against a
disc-wide check with an out-of-sample prediction.
* That `pteff10` on `main_menu` and on `extras` are the same sprite. Not checked.
* That the leaf rule is right — only that it is **neutral here**, so nothing in
this page rests on it.
---
# ❌ The counter-example failed, and the regression is RMSE's area-weighting
## The oracle had already adjudicated `pteff10`
`blend-bit-vs-oracle.txt` carries it on **both** screens — entry 5 (main menu)
and entry 6 (extras), `+0x04 = 0x8832`, bit set, both labels read out of the
guest command stream — and HANDOFF records it *"additive, in all three menu
sessions, every frame."*
**My premise was a stale coverage table**, not a reading of the log. The
correction existed; the wrong table was still visible upstream of it. So the
adversarial attempt lands as **survived**: the claim is stronger for having been
challenged, and the challenge cost one message.
⚠️ **And the regression was flagged on this exact element before I adopted it** —
🟡 in HANDOFF: nearly exact under alpha-over in our render, additive in the game,
*"the one row here your renderer does not independently corroborate."*
## But their explanation makes a prediction, so I checked it
If additive and alpha-over *nearly coincide* on a dim glow (max alpha 130) over a
dark background, the score should barely move. **Mine moved 20 %.** That is a
real tension and it was worth one measurement.
Diffing the two port renders — the only change between them is `pteff10`'s blend:
| | |
|---|---|
| pixels identical | 373 588 (**40.5 %**) |
| pixels differing by > 3 | 330 794 (**35.9 %**) |
| **maximum difference, anywhere** | **32 levels** |
| pixels differing by ≥ 60 | **0** |
| bounding box | x 288–993, full height |
**Their explanation holds and my scepticism does not.** No pixel moves by more
than 32/255 — 12.5 % — which is "nearly coincide" measured rather than asserted.
What moved the RMSE is **area**: a third of the frame shifting slightly.
## The thing worth keeping: RMSE cannot tell broad-and-shallow from narrow-and-deep
`raw-rmse` is area-weighted, so **a shallow change over a third of the frame
moves it far more than a deep change over a few hundred pixels** — and the number
alone does not say which you have. 10.88 → 13.02 reads like a serious regression
and is a difference invisible to an eye.
This bears on every `verify-capture` number in the corpus, not just this one. A
row that moves should be asked *"broad or deep?"* before it is called a
regression, and that costs one histogram.
## What I did NOT do: exclude the element
The Decoder suggested considering `pteff10` excluded from the metric with the
reason stated, rather than letting 13.02 sit as debt. **I have not**, and the
measurement above is why: max-32 over a wide area is a *characterised* difference,
not an unscoreable one. Excluding it would remove the only signal that would fire
if the bit were ever wrong on some other screen — and the reason to exclude
("the metric can't distinguish these") is now known to be false; it distinguishes
them fine, it just weights them by area.
**The 13.02 stands, with its explanation attached.** That is cheaper than an
exclusion nobody will revisit.
---
# 🔴 Provenance: the oracle behind this adoption had a container-only instrument
Recorded 2026-09-01 at shutdown, from the Decoder's own audit of their `/canary`
checkout, not from anything visible on this side.
This page deleted an authored map on the strength of `ui-blend-mode-decoded.md` —
35 elements against `RB_BLENDCONTROL0` read out of the guest command stream, zero
errors both ways, plus an out-of-sample prediction. **That evidence was not
reproducible by anyone who cloned this repository.** The Canary logger commit
that emits `blend=` per draw lived only in their container; without it a draw log
records **no blend state at all**, so the oracle could not be re-derived at all —
not approximately, not at reduced confidence. Not at all.
It is reproducible now: four container-only logger commits are exported as
patches under `tools/canary-patches/` **on `auto/frame-blend-draw-path`**, with a
rebuild recipe. ⚠️ Named without a resolvable path on purpose — that directory is
not in this checkout, and citing it as one would be the exact defect
`check-citations` exists to catch.
## What this does and does not change
* **It does not weaken the adoption.** The measurement was real when made and is
now reproducible. Nothing here is retracted.
* **It does change what "decoded" was resting on.** For the window between the
adoption and the export, this port had deleted an authored entry in favour of a
field whose supporting oracle no one else could regenerate. The map was the
*worse* of the two — a screen-name table that could not answer for a screen
nobody drove to — so the trade was still right. But it was a trade made against
an instrument, and the instrument's reach was smaller than the finding's.
📌 **The generalisation, and it is theirs:** a finding is only as portable as the
tool that produced it, and a reproduce recipe that reads as complete is the
dangerous kind. Theirs *looked* complete — it named shas — which is why four
commits sat unexported while one was noticed.
⚠️ And the port cannot check this class from here. `check-citations` scans repo
paths; an instrument living in another container is not a path at all. **The only
defence available on this side is asking what produced a number before adopting
it**, which is not a check and does not run.

View File

@@ -0,0 +1,85 @@
# The committed captures are CROPS, not resamples — so pixel comparisons are like-for-like
**Status:** ✅ **measured, and it refutes a consequence rather than a finding.**
Written 2026-09-02 by the Port; HANDOFF on this branch answers `9ca1eb5`.
## The claim under test
The Decoder read Canary's cvars — `present_letterbox` defaults true,
`present_safe_area_x/y` default to 100 — and concluded that the guest's
1280×720 is scaled to the host window and letterboxed, which would explain the
1279×675 game surface the corpus has measured without ever accounting for. The
consequence drawn:
> *"Everything either of us measures off a PNG carries the resample — every RMSE
> against a capture, every glyph count, every surface mean, and the
> `motion-census` numbers on both sides."*
That is a caveat on a very large amount of shared evidence, so it is worth one
measurement before anyone starts qualifying results with it.
## Pre-registered (R2)
> If the captures carry a scale, then **scaling** this port's 1280×720 render
> down to 1279×675 should match a capture better than **cropping** it. If they
> are crops, the reverse.
## Measured
`live-splash-publisher.png`, 1279×675, against the port's own 1280×720 render of
the same screen:
| | RMSE vs the capture |
|---|---|
| render **cropped** to 1279×675 | **558.1** (0.85 %) |
| render **scaled** to 1279×675 | **10 118.8** (15.4 %) |
**Cropping is 18× better.** A 0.9375 vertical scale would put every feature at
the wrong row; it does not, and the residual under scaling is exactly what that
misalignment looks like.
🔴 **So these captures do not carry a vertical resample**, and the corpus's
pixel comparisons against them are like-for-like.
## What is refuted and what is not
**Refuted: the consequence.** *"Everything measured off a PNG carries the
resample"* is false for the committed captures. Every RMSE, glyph count and
surface mean taken against them compares pixels to pixels, not pixels to pixels
through an uncharacterised filter.
**Not refuted: the cvar reading.** Canary may well letterbox by default; that is
a statement about the emulator's configuration and this measurement says nothing
about it. What it says is that **the capture path used for the corpus did not
go through it** — the presenter was bypassed, the window was 1:1, or the
screenshot tool cropped the letterbox away before saving. Which of those, nobody
here has established.
📌 **A second, independent line already agreed and nobody connected it.**
`ui-render-tone-curve.md` records that every committed capture aligns against
our render at exactly `dy = 0, dx = 0` with correlation 0.9466. A vertical scale
of 0.9375 cannot produce a zero-offset alignment. The evidence for "crop" was
already in the corpus, one page away from the surface-size puzzle it explains.
And 1279×675 is what a crop looks like: one column and forty-five rows removed,
top-aligned, which is what the corpus said years-of-notes ago — *"that is the
screenshot tool's crop."*
## What this does not change
* **The gamma result stands and is the more useful half.** No transform on either
side of the boundary: `VdGetCurrentDisplayGamma` is `kStub`, and the splash's
own pixel shader is four ALU ops with no `pow`, no ramp, no lookup.
* **The vertex-stream path is still the better instrument** where a question can
be asked of it. It carries no shader, no render target, no resolve and no
presenter, and that is why the Decoder's per-frame alphas are the game's
values rather than pixels we measured.
* **`motion-census` was never at risk.** A resample preserves change, so those
numbers would have been comparable either way — the Decoder said so, and it is
right regardless of this result.
## Reach
One capture, one screen, one comparison. It refutes "all captures carry a
resample" because a single counter-example is enough for a universal, and it
does **not** establish that no capture anywhere carries one.

View File

@@ -0,0 +1,79 @@
# F5 and F6 — ready for a human check
**Why this file exists.** PROTOCOL's "work in units a human can check in a
minute" asks for the question, what to look at, and what pass/fail mean,
written down in one place. The investigation for F5 and F6 is long and lives in
[`f6-what-starts-the-sweep.md`](f6-what-starts-the-sweep.md),
[`plate-arrives-on-time-but-never-blinks.md`](plate-arrives-on-time-but-never-blinks.md)
and the commit history (`ac1371c`, `af10a2e`, `2b802e0`, `94d761e`,
`1628f0e`, `edf8979`). Nobody should have to read that to know what to watch.
**Both are implemented, verified on film (not stills), and out-of-sample
tested** — a fresh boot the predictions had no hand in producing matched on
every figure the port actually relies on
(`edf8979`, `docs/re/f6-out-of-sample-RESULT.md`). Neither is signed off by a
person yet. That is the ask.
## F6 — the glow no longer starts before the plate
**One sentence:** watch a boot; the traveling glow along the blue lines should
be invisible until the `PRESS Ⓐ` plate is on its way in, not visible from the
first logo frame.
**What changed:** the renderer was discarding the parent element's own alpha
ramp (`0:0 70:0 100:255 238:255 250:0`) when drawing the glow's nested leaf.
The glow now only draws once the parent has faded in — invisible to `t=70`,
full by `t=100` — instead of being visible from `t=0`.
**Pass:** on a fresh boot, the sweeping glow is not visible during the two
publisher/developer splash-adjacent early frames of the title build-in; it
fades in alongside (not before) the rest of the plate's approach.
**Fail:** the glow is visible streaking across the screen before anything else
on the title has appeared.
**Not covered by this unit:**
* The glow's *speed*, *path*, or whether it should be one streak or several —
Unit e in `f6-what-starts-the-sweep.md` flags that the port draws exactly
two full-height streaks and the human's original report described something
that could be a population of smaller lights on individual PCB traces. That
is unresolved and is a different question from *when* it starts.
* Any fixed "lead time" between the glow and the plate — that number turned
out not to be reproducible (0.0996–0.141 across three captures) and nothing
is authored against it. F6 as originally reported ("starts too early") does
not depend on that number; the fix is the alpha gate above.
## F5 — Ⓐ during the build-in snaps, it does not speed up
**One sentence:** during the title build-in (after the boot logos, before the
`PRESS Ⓐ` plate normally appears), press Ⓐ once and watch the wordmark —
it should cut straight to its finished pose in one frame, not animate faster
toward it.
**What changed:** nothing new to watch for — this is a **confirmation ask**.
The port's existing behavior (an instant cut, both light-sweep leaves
restarting at their own declared opening pose in the same frame) was verified
by frame-by-frame reading of submitted alpha values, which is the only
instrument that can actually tell a one-frame cut from a several-frame
acceleration — the human said themselves they could not tell by eye.
**Pass:** the artwork looks the same as the port's current behavior — an
instant jump, not a visible speed-up.
**Fail:** the artwork clearly animates faster (rather than cutting) toward the
finished pose, which would mean the July measurement should be revisited.
**Not covered:** the exact frame Ⓐ targets, which is established as
undecodable-with-no-observable-consequence (`docs/re/f5-snap-target-undecodable-with-reach.md` /
the boot.gd comment above `settle_time()`) — there is a window `[160, 238)`
where any value in it renders identically forever, so nothing is riding on the
literal.
## What the port did this iteration
Nothing changed in code. This iteration re-read the state left by prior
iterations, ran `tools/port/check-all` to confirm nothing regressed, and
attempted to refute the F6 census claim ("the port renders exactly two
moving lights, nothing else on the title declares positional travel") by
re-deriving it independently from `export/screens/title/title.json` — every
top-level element and every nested `.rat` leaf. **It survived**: only
`ptloop01`→`pteff03` and `ptloop02`→`pteff03a` declare any positional travel
at all; no other element or leaf has a `pos` keyframe that moves.

View File

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

View File

@@ -0,0 +1,99 @@
# F1 — the menu repeats on a held direction: mechanism shipped, **rate deliberately not**
**Status:** ✅ mechanism implemented and wired. 🔴 **inert on purpose** — it does
nothing until a measured repeat rate exists. Written 2026-09-02 by the Port.
## What was reported
Two statements from the human, both about the **real game**, a play-test apart:
> *"Moving stick up/down and holding only moves one item. In game it actually
> continues to move when holding up/down, just at a medium pace so player does
> not need to move pad middle↔up/down, but also slow enough to see which item is
> selected and move to target."*
> *"Confirmed D-Pad does repeat when holding too."*
## The file predicted its own refutation
`gamepad.gd` carried this, written when the latch was added:
> *"Whether the real game repeats while a direction is held, and how fast, is
> unknown… If the game does repeat, this is a difference a human will notice as
> 'I have to flick it again', and the fix is a measured repeat interval — not a
> guessed one."*
That is exactly what happened, in the words it predicted. **So
one-step-per-deflection is no longer the conservative reading — it is a known
defect**, and keeping it is choosing a wrong behaviour over an approximate one.
## What was built
| | |
|---|---|
| `Gamepad.held_direction()` | −1 / 0 / +1, polled from the **devices** |
| `Gamepad.repeat_due(delta)` | one step or 0, per frame |
| `Boot._menu_repeat(delta)` | calls it under the same guards a real press gets |
### Why it polls devices and not `Input.is_action_pressed`
`ui_up`/`ui_down` are bound to the stick axis at **Godot's 0.50 action
deadzone**, while this port steps at the game's measured **0.61** (`ENTER`).
Polling the action would repeat throughout the 0.50–0.61 band — the exact band
`ENTER` exists to exclude — so the repeat would contradict the threshold on the
same stick, on the same frame.
That is the input-map lesson from 2026-09-01 arriving in a new place: **assert
the device, not the layer above it.** The stick reads from the latch
`accepts()` already maintains, so the first step and the repeat cannot disagree
about hysteresis; the d-pad reads `JOY_BUTTON_DPAD_UP/DOWN` directly, which the
human's second report makes load-bearing rather than defensive.
### Why the guards are duplicated rather than shared
`_menu_repeat` re-applies the same four conditions `_unhandled_input` applies —
no movie playing, a menu exists, its stack is non-empty, no transition pending.
A repeat that could fire during a movie or mid-transition would be a **second,
subtly different input path**, and the first thing this port learned about input
is that a second path is where the defect hides.
## 🔴 And the rate is not shipped
An earlier draft of this change had `REPEAT_DELAY = 0.40` and
`REPEAT_INTERVAL = 0.20`, with a paragraph explaining that they were authored.
**They were removed rather than commented out**, on an explicit instruction:
> *"Take the RATE from the Decoder — do NOT ship a placeholder interval. An
> invented rate here is indistinguishable from a measured one later, and this is
> the exact field where that already cost us."*
The instruction is right and the draft was the named failure mode: the
explanation would have merged, the numbers would have felt roughly right, and
nothing downstream could have separated them from a measurement. `REPEAT_DELAY`
is `-1.0`; `repeat_due()` returns 0 while `repeat_rate_known()` is false.
**One thing about the rate IS measured, and it narrows the question.** The game
digitises the left stick to four direction bits at 61 % deflection, so it cannot
see deflection magnitude at all — the repeat it drives *cannot* be
faster-the-harder-you-push. That excludes the one competing model, so only two
constants are open and a single measurement closes both.
## ⚠️ Adopting the rate breaks a green check, for the right reason
`tools/port/verify-input` asserts *"a held stick is ONE step, not six"*. That row
passes today **because the feature is inert**, i.e. it asserts the absence of the
repeat. When a rate is adopted a held stick should produce further steps and that
row will go red.
It is not wrong and it should not be deleted in a hurry: it was written for the
2026-09-01 jitter defect, so it will *look* like that bug returning. It has to be
re-stated as "one step per deflection **plus** the measured repeat", with the
jitter case still covered inside the delay window.
## What this does not claim
* That the repeat feels right. It cannot — it does not run.
* Any rate, or any bound on one. "Medium pace" is a direction, not a number, and
it is not recorded anywhere as data.
* That the d-pad and the stick repeat at the *same* rate. Both repeat; nobody
has said they match, and the code currently assumes one rate for both.

View File

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

View File

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

95
docs/port/p7-gate.md Normal file
View File

@@ -0,0 +1,95 @@
# P7 — the new-game intro plays and returns to a defined state ✅
**Status:** ✅ **gate met, with an artifact.** Run 2026-09-01 by the Port at
`4be90c2` + this commit; HANDOFF on this branch answers `9ca1eb5`.
`PORT-MISSION.md` P7: *"New-game intro video after NEW GAME | Plays, then returns
to a defined state."*
The path had been **wired** for some time — `authored/flow.json` gives `ptbtn01`
a `then_video: "S00A"`, a `skipped_chain`, and `after_video: {goto: "title"}`,
and `S00A.ogv` is in the export. **Nobody had run it.** A milestone is done when
its artifact exists, not when the wiring reads correctly, and this file is the
difference.
## Pre-registered (R2)
> `--menu --script=accept` with focus on `NEW GAME` announces the skipped
> `DIFFICULTY, SELECT DATA` chain, plays `S00A`, and returns to `title`.
> Unskipped, `S00A` is 93.78 s of media, so it should end **on its own** at
> ≈94 s and hand off.
## What happened
```
menu on main_menu, focus ptbtn01
script[1] accept at 1.00 s
(NEW GAME) -> the real chain is DIFFICULTY -> SELECT DATA, then the movie.
Neither screen is in this export.
-> video S00A at 0.97 s (/work/export/video/S00A.ogv)
+ voice S00A
video ended at 94.13 s
-> title (authored: authored)
(after the movie) -> title
overlay press_start raised, settles at t=236
script complete after 96.83 s on title
```
**94.13 s against 93.78 s of declared media — +0.35 s, 0.4 %.** It ends on its
own, at the right time, and hands off. Both predictions held.
Artifacts: `s_00_start.png` (main menu, `NEW GAME` focused) and `s_01_accept.png`
(the title with the plate up) — written to the scratchpad by `--shots`, not
committed, because they are frames of the user's own disc.
## It genuinely decodes — checked, because "ends at the right time" does not prove it
A player that consumed 94 s of *time* while showing one frame would also "end at
94 s". So the frame counts, at two window lengths:
| window | frames shown | of 2813 |
|---|---|---|
| 2.03 s | 45 | 2 % |
| 18.91 s | **244** | 9 % |
Frames scale with the window — it is decoding, not stalled. **Sub-linearly
though**, 22.2 fps early against 12.9 fps over the longer window, and that is
the same software fill ceiling documented in
[`port-frame-rate.md`](port-frame-rate.md); these two runs predate the GPU.
⚠️ **The counts are upper bounds and the port says so itself** — the log reads
*"at most 244 of 2813 frame(s) shown"*. It cannot see inside `VideoStreamPlayer`,
so it reports what it can bound rather than a number it cannot support.
## A free corroboration of the fill-rate finding
The unskipped run reports `main_menu: 5677 frames in 94.13 s — **60.3 fps**`.
The screen never changes during a movie, so the rate line attributes the whole
playback to it.
**The same process, in the same container, on the same screen: 60.3 fps while a
full-screen video texture is on top, and 9.7 fps while drawing the menu's five
additive full-screen quads.** That is the fill-rate conclusion arriving from a
direction it was not designed for — one large textured quad is cheap, five
additive ones are not — and it cost nothing to obtain.
## Two things this run surfaced that are not P7
* 🔴 **`4 ObjectDB instances were leaked at exit`.** Godot's own warning, on
every run of this path. Small and at shutdown, so it costs a player nothing,
but it is a real signal that something in the video/overlay teardown is not
freed. **Not chased, recorded.**
* 🟡 **The menu bed keeps playing under the movie.** The port prints this itself
and it is already an open ask — *does the menu music duck?* — in
`BLOCKED.md`. Left audible on purpose rather than guessed at.
## What this does not claim
* That the skipped chain is right. `DIFFICULTY` and `SELECT DATA` live in
archives this milestone does not export; the port **announces the skip** rather
than pretending the sequence is complete, which is the honest half of a gate it
cannot fully reach.
* That `after_video: title` is what the game does. It is **authored**, and its
`why` says so: the game goes into Mission 1, and gameplay is out of scope
(`PORT-MISSION` §7). "Returns to a defined state" is the gate; `title` is the
state we defined.

View File

@@ -0,0 +1,458 @@
# H3 — the `PRESS Ⓐ` plate: which half the lateness is in
**Status:** ✅ **both halves answered, and TWO of my own conclusions on this page
were refuted within the hour** — the `5 units/frame` reading and the claim that
`clock: "shared"` collapses. Both are struck in place rather than deleted,
because both were confident and both were wrong for reasons worth keeping.
The answers are the Decoder's `h3-units-per-frame-measured.md`,
`origin/auto/frame-blend-draw-path` at `a482d9a`.
Written 2026-09-01 by the Port, against `export/` regenerated from this
checkout, `HEAD` = `6db49f5` (merge of `origin/main` `1af103d` and
`origin/human/r1-retro-tick`). HANDOFF on this branch answers `9ca1eb5`.
The [play-test](../agents/PLAYTEST-2026-09-01.md) finding 3 says the plate
arrives late and names three candidate causes: the **unit→seconds constant**
(the Decoder's), the **clock origin** and **`rest.t`** (both ours). The brief
asks which half it is, and how that was established. This page is the answer.
> **Short version, after the answer landed.** `rest.t` and the clock origin are
> eliminated and stay eliminated — those were the port's half and the evidence
> below still holds. **Units per *frame* is 2, not the 5 I inferred**; my
> derivation was sound arithmetic on an unsound premise and is struck below.
> **The anchor is t=160**, as I suspected. And my consequent claim that
> `clock: "shared"` therefore collapses is **withdrawn** — I computed the
> collapse against the one number now known to be in dispute. What is left open
> is **units per *second***, which is `2 × guest fps`, and whether the guest is
> 30 Hz or 60 Hz is not settled by anyone. At 60 Hz the plate lands at 1.97 s
> instead of 3.93 s, which is the size of what the human reported.
## What the export actually declares
`export/screens/title/press_start.json`, build 2, one element `ptbtn00`:
| t | alpha | note |
|---|---|---|
| 0 | `0x00` | invisible, y = 560 |
| 214 | `0x00` | still invisible, y = 550 — it has drifted up unseen |
| **236** | `0xff` | **full** |
| 238 | `0xff` | last opaque frame |
| 244 | `0x00` | gone again |
`rest.t = 236`. `settle_window = [214, 236, 225]`.
🔴 **The corpus says the plate reaches `a=255` at `t=238`. It reaches it at
`t=236`.** 238 is the last frame at full alpha, not the arrival. Every
derivation quoting `238 − 118 = 120 units = 2.000 s` is really `236 − 118 =
118 units = 1.967 s`. The port has been printing both halves of the
contradiction in one sentence on every boot — *"plate reaches full alpha at
t=236 … 120 units after …"* — which is what an unchecked literal beside a
computed value looks like. Corrected in `boot.gd` and in `authored/flow.json`.
It moves the reconciliation by 0.033 s and overturns nothing.
## Candidate 1 — `rest.t`. **Eliminated.**
`rest.t` cannot set the plate's arrival, because the arrival is a **declared
keyframe**, not a rest pose. `ptbtn00` is transparent until `t=214` and opaque
at `t=236` under its own ramp; `rest.t = 236` only chooses where `holding`
parks it afterwards, and 236 *is* that ramp's own peak. Setting `rest.t` to any
other value moves where the plate stops, never when it starts.
Confirmed against the running port rather than by reading: see the film below,
where the plate's onset is bracketed by the declared `t=214` with `rest.t`
untouched.
⚠️ This does **not** rehabilitate `rest.t` generally. It is still wrong for
transients — `ptlogo_back2eff1` is a two-frame flash (0 at t52, `0xff` at
t54–56, 0 by t58) and its `rest.t = 54` is the flash *peak*, so `rest()` leaves
five of these burning at once. It is simply not in this defect's causal path.
## Candidate 2 — the clock origin. **Eliminated, and measured.**
Two things had to be true and both are:
**(a) The port's two builds share one clock exactly.** `_advance` sets
`view.time_units = 0.0` and raises the overlay in the same call
(`_overlay_due = _elapsed`), and the sequence path assigns
`overlay.time_units = view.time_units` outright rather than integrating a second
delta. Verified over a filmed boot: **85 of 85 title frames have
`view_units == overlay_units` to three decimals**, from 7.812 to 679.182. There
is no drift and no offset to find.
**(b) The title step begins when the previous step ends.** The film puts the
video's end and the title's start at the same logged instant (7.86 s / 7.92 s
across two runs), with no black hold between them.
## Candidate 3 — the unit→seconds constant. **NOT eliminated. It is the live one.**
🔴 **This section said "eliminated by sign" and that was wrong.** The argument
was: for the port to be late the constant must be *larger* than 60 units/s,
while the oracle presents at ~28.1 fps and the corpus measured the idle title at
28.5 fps — both slower than nominal, so the game's units run slower, not faster.
**That conflates two different quantities.** A presentation rate converts a
*measured wall-clock duration* into units. It says nothing about **how many
units elapse per game frame**, which is the constant in question
(`keyframe_units_per_second`, and `keyframe_time_unit` is listed as `unresolved`
on every screen this export emits). The correction is recorded rather than
edited away because the elimination it produced was confident and wrong.
And the Decoder's splash draw capture, landed the same day on
`origin/auto/frame-blend-draw-path` at `3cc1b51`
(`docs/re/data/splash-quad-timeline.txt`), gives an **independent handle on that
constant** — from a screen with no bearing on the plate:
| interval, publisher splash | guest, observed | this export, declared | units/frame |
|---|---|---|---|
| companion (`Q7`) onset → sharp logo (`Q0`) onset | f4 → f7, **3 frames** | t=0 → t=15, **15 units** | **5.0** |
| companion onset → its own peak | f4 → f7 (a=240), **3 frames** | t=0 → t=15 (a=255), **15 units** | **5.0** |
| sharp logo onset → full alpha | f7 → f10, **3 frames** | t=15 → t=30, **15 units** | **5.0** |
Three intervals, three ways, **5 units per guest frame** — against the declared
**2**.
## 🔴 STRUCK. It is 2 units per frame, and my 5 was an artefact of two things
Measured by the Decoder on `ptbtn00` itself — the plate, on the title, not a
splash — in `h3-units-per-frame-measured.md` (`a482d9a`), against a
pre-registration committed before the capture was read:
```
label 5372 5373 5374 5375 [5376] 5377 5378 [5379] 5380
alpha 46 69 92 115 — 197 220 — 255
step +23 +23 +23
```
`255 × 2 / 22 = 23.18`, and the plate's declared ramp is `t=214 → 236`, i.e.
`T = 22`. **Three consecutive gap-free steps of exactly 23 is 2.0 units per
frame on the nose.** Their prediction was 11 frames for the ramp and it measured
10, inside a stated ±1. My 4.4-frame prediction is excluded by more than 2×.
**Why my three intervals all read 5, and why the agreement between them was
worthless.** Two independent errors, both mine to have made:
1. **An alpha step is not a clock rate.** For a linear segment,
`Δα per frame = 255 × (units per frame) / T`. Two elements with different
declared segment lengths `T` show different `Δα` at an *identical* clock.
Splash B's quads step 34 with `T=15`; the plate steps 23 with `T=22`; both
are 2 units/frame. Reading a step as a rate is what produced the 2.7×.
2. **My "onsets" were not onsets.** I took each quad's *first submission* as its
`α = 0` time. On splash A, `Q7` and `Q0` are both already at **α = 85** when
first submitted. So all three intervals started late — and by a *different*
amount per element, because the bias scales with `T`. That the three then
agreed with each other is not corroboration; they share the error.
The three "independent" intervals were one measurement made three times with the
same two biases. That is the shape I should have checked for and did not.
⚠️ The Decoder records the published `splash-quad-timeline.txt` having no `T`
column as their own defect, now fixed. That does not make the inference mine any
less: **the arithmetic was sound and the premise was not, and a premise handed
to me is still a premise I used.**
⚠️ **Two caveats, and they are why this is asked rather than acted on.** The
capture's frame index **skips** — 5, 8 and 12 are absent from Q7's eight draws —
and whether those are frames where the quad was genuinely not submitted or
presents the logger dropped changes the arithmetic. And the present rate during
a splash is not established.
### 🔴 And it collides with the oracle's own plate measurement
The two captures cannot both be read at face value:
| capture | implies |
|---|---|
| `title-plate-delay-measured.md` — 118 units in 2.135 s | **~55 units/s** |
| `splash-quad-timeline.txt` — 15 units in 3 frames | **~150 units/s** at 30 fps |
A factor of **2.7** between two measurements taken off the same game by the same
agent, on two different screens. Either the two screens' keyframes are in
different units, or one of the two readings has an anchor wrong — which is the
same shape as the `t=118` / `t=160` question below. **Both are oracle
measurements and neither is the port's to resolve.** Asked in
[`BLOCKED.md`](BLOCKED.md) H3.
## The film — and the instrument had to be fixed first
`--film` scheduled frame `n` for `n × interval` and reported nothing. One
1280×720 `save_png` under llvmpipe costs ~0.24 s, so **a request for 0.05 s
delivered 247 frames in 60 s where 1 200 were asked for** — an achieved 4.1 fps
against a requested 20 — and the deficit accumulated silently into the frame
index. `f_071.png` still looked exactly like the frame that was meant to be
3.55 s in.
[`TEMPORAL-VERIFICATION.md`](../agents/TEMPORAL-VERIFICATION.md) §1 is explicit
that this is not a slow capture but a *different* one, and that an instrument
which cannot report its own completeness may not be trusted (R3). So `--film`
now writes `<film>_frames.tsv` — one row per frame carrying the elapsed second
it was **actually** taken at, the second it was **requested** for, the lag
between them, and both builds' clocks — appended and flushed as it goes, so a
run killed by `timeout` still leaves a complete index. It prints the achieved
rate against the requested one every 40 frames. The schedule is deliberately
**not** rebased onto `_elapsed`: catching up would hide the shortfall, which is
the defect.
The run this page rests on: requested 20 fps, **achieved 15.9 fps for the first
120 frames and 11.3 fps by frame 200**, lag reaching 8.7 s. Stated because the
numbers below are quoted against `view_units` — the port's own timeline, which
the index records per frame — and never against a wall-clock instant.
### Pre-registered, before looking (R2)
> Filming the boot, measured from the frame the title step begins, the plate
> region stays at its background floor until **t=214**, rises over **22 units**,
> and plateaus at **t=236**. Accept ±2 frames at each end.
### What it did
Region = the plate's own quad, x 127–640, y 525–575 (`pos [383,550]`,
`pivot [256,25]`, sprite 513×50). Mean of the crop, one row per filmed frame:
| `view_units` | region mean | |
|---|---|---|
| 7.8 → 143.8 | 0.13765, **flat to 5 decimals** | nothing there |
| 151.9 → 207.8 | 0.13773 → 0.14341 | a slow 4 % drift, build 4's own |
| **207.8 → 215.3** | 0.14341 → **0.15348** | ⬅ the step; brackets the declared **214** |
| 215.3 → 239.8 | → 0.21254 | the ramp |
| 239.8 → 279.3 | → 0.24766 | plate full at 236; the rest is `ptbtn00f` |
The onset is bracketed between 207.8 and 215.3 at a frame spacing of ~7.5 units,
i.e. **within one frame of the declared 214**, and `rest.t` was never touched.
Prediction held.
After 236 the region keeps oscillating — peaks near `u` 279 and 407, troughs
near 343 and 479, a period of **~128 units** against `ptbtn00f`'s declared
`loop_length_units = 120`. That is the focus glow looping, not the plate, and it
is an ordering-and-period check that survives the drifting capture rate.
## So where the port lands against the oracle
`docs/re/title-plate-delay-measured.md` is **not in this checkout** — it is on
`origin/auto/no-disc-and-menu-captures`, added at `fb536df` and cited by
`authored/flow.json` at `5b0a6e6`. Read there:
| | run 1 | run 2 | **the port** |
|---|---|---|---|
| settled → plate | 2.138 s | 2.132 s | **1.967 s** (118 units at 60/s) |
| first drawn → plate | 3.781 s | 4.263 s | **3.933 s** (236 units) |
On the interval the oracle measured deliberately, the port is **0.17 s early**.
On the interval the oracle explicitly says not to use, the port sits **between
the two runs**. No number anyone has taken makes this port's plate late.
**The human watched both and says late, and that observation stands.** At
60 units/s the port reproduces every figure the plate capture carries — which is
the point: *reproducing that capture is not the same as being right*, because
the splash capture says the unit is 2.5× off and would put the port 2.4 s late.
The two cannot both hold. The human's eye agrees with the splash capture.
## 🔴 WITHDRAWN: my claim that the t=160 anchor collapses `clock: "shared"`
The section below asked which anchor the glyph counter corresponds to and said,
of the t=160 answer, *"the shared-clock premise does not reproduce the
measurement at all and `clock: "shared"` is open"*. **The Decoder answered
t≈160–176 — candidate B — and then repeated my consequence back to me**, noting
correctly that what it costs the port's model is the port's problem and not
evidence against their measurement.
**They were right to report it and I was wrong to have claimed it.** I computed
the collapse by comparing the declared 76 units against **2.135 s**, and 2.135 s
is the single number the same page identifies as in dispute — their two captures
disagree about frames→seconds by ~2.9×. *Falsifying a model with the quantity
that is itself under dispute* is the error, and it is the second time on this
page I reasoned confidently from a premise I had not checked.
Against the **new** capture, in its own labels rather than in seconds,
shared-clock holds:
| declared, shared clock | capture | at the plate ramp's own measured 2.2 units/label |
|---|---|---|
| `ptcopyright` full (t=160) → plate α=0 (t=214) = **54 units** | 5350 → 5370, **20 labels** | 44 units |
| `ptcopyright` full (t=160) → plate α=255 (t=236) = **76 units** | 5350 → 5380, **30 labels** | 66 units |
| the plate's own ramp (t=214 → 236) = **22 units** | 5370 → 5380, **10 labels** | — (this is the calibration) |
Both intervals come out **short of declared by 13–19 %, in the same direction**,
against a claimed collapse of ~1.7×. And the residual has a named candidate the
Decoder supplies: **empty labels advance the clock by more than one step**, they
are about one label in five, and the two intervals contain different fractions of
them (3 of 20, and 6 of 30). Modelled with empties carrying ~4 units the two
reconstruct at 46 and 72 against 54 and 76.
**So `clock: "shared"` is not falsified and the port keeps it.** It is not
*confirmed* to better than ~20 % either, and this page does not claim that. What
it is no longer is "collapsed on the port's own account".
## A second anchor question, also the Decoder's — ✅ ANSWERED: it is t=160
**What does the oracle's "title settled" correspond to on the declared
timeline?** It is defined operationally — *"glyph counter first reads its
no-plate value 154"* — and our export offers **two** anchors, 42 units apart:
| anchor | what it is | settled → plate | vs measured 2.135 s |
|---|---|---|---|
| **t = 118** | `pteff01`, `pteff02`, `ptlogoall_eff` end their ramps together | 118 units = 1.967 s | −8 % |
| **t = 160** | `ptcopyright` reaches full alpha — the **last** element to finish building in, and the only one made of **glyphs** | 76 units = 1.267 s | −41 % |
The port's reconciliation picked 118. The port's own `settle_time()` returns
**160** and the boot prints `settles at t=160` — so the two notions disagree
inside one binary. And the oracle's anchor is a *glyph* counter, while the thing
that finishes at 160 is a *line of text*.
If the anchor is 160, the shared-clock premise does not reproduce the
measurement at all and `clock: "shared"` in `authored/flow.json` — which the
port authored from arithmetic and nobody has ever measured — is open. If it is
118, the reconciliation stands and H3 is not a timing defect.
**Answered from the draw stream** (`a482d9a`): `ptcopyright` reaches α=255 at
label 5350, which calibrates to **t ≈ 168** on the plate's own ramp (t ≈ 176 at a
flat 2.0/label). Candidate B is 8–16 units away; candidate A is 50–58. It is B.
📌 And a caution from the same stream that this port should hold onto: **the
sweep leaves never settle.** The two off-screen-wide quads translate
monotonically through every label examined and are still moving when the plate
arrives. *"The title has settled"* can only ever mean **the build-in elements
have finished**, never *the screen has stopped changing* — which is the same
distinction that `rest.t` keeps getting wrong.
### 🔴 ALL FOUR NAMED CAUSES ARE NOW DEAD, AND THE HUMAN'S OBSERVATION IS NOT
**The rate is measured: 56.8 units per guest second** (`units-per-second-measured.md`,
control passing at 1.15 %, two elements agreeing at one clock — `ptbtn00` at
657.9 α/s and `ptcopyright` at 650.4 α/s, which puts `ptcopyright`'s segment at
`T = 22.25`, a round declared length nobody fitted). **30 and 120 are both
excluded.**
At 56.8 units/s the plate's `t = 236` lands at **4.15 s** after clock zero,
against the port's **3.93 s**. The port is fractionally **early**. So:
| candidate | verdict |
|---|---|
| `rest.t` | eliminated — the arrival is a declared keyframe |
| clock origin | eliminated — 85/85 frames share one clock |
| the anchor (t=118 vs t=160) | answered: t=160, and `clock: "shared"` survives it |
| **the unit→seconds constant** | **eliminated — 56.8 measured; the port is early, not late** |
**Nothing named in the play-test explains what the human saw, and this page says
so rather than quietly closing H3 green.** The observation stands and is now
unattributed. Two things it could still be, neither of them the plate's own
timing and neither established here:
* **what "late" was measured against.** Every number above is relative to the
title's clock zero. A player experiences the plate relative to the *boot*, and
the port's boot reaches the title at 7.86 s only because `--skip-at` presses Ⓐ.
🔴 **On the play-test build Ⓐ was not bound to the pad at all**, so that human
could not skip the 137 s intro — the run they judged is not the run any of
these measurements describe.
* **the splash dwells.** The rate's reach is the **title**. The splashes are a
different `GamePart` and nothing yet shows they tick at 56.8; the Decoder has
reading their `T` off the disc as a next item.
### The route that is dead regardless
🔴 **`units = 2 × frames` must not be used anywhere.** The same animation takes
21 frame labels in one capture and 33 in another, and a splash logo steps
`+136,+34` in one run and `+17,+51,+34,+34,+17,+17` in the other; a fixed
per-frame increment cannot do that. The 2 was one run's frame pacing.
✅ **Audited, and the port never did this.** `boot.gd` advances
`time_units += delta * units_per_second` off delta time. The retirement cost this
port a *justification* in `authored/timing.json`, not a behaviour — and the
justification's second leg (12 declared units against a 0.14–0.30 s measured
black plateau, 40–86 units/s, no frames in the chain) never depended on it.
### What was still open, before the rate landed
`units/second = units/frame × guest fps`. The first factor is now **2**. The
second is not established: **2 × 30 = 60** (what this port uses) and
**2 × 60 = 120** (which puts the plate at **1.97 s** instead of 3.93 s — the size
of what the human reported). The Decoder's capture ran at 27.2 labels/s, which is
Canary's presentation rate and cannot separate a 30 Hz guest at full speed from a
60 Hz guest at half. **They asked the port not to change 60 units/s on their
account yet, and it has not.** Asked in [`BLOCKED.md`](BLOCKED.md) H3.
## Refutation attempts this iteration
| claim | whose | outcome |
|---|---|---|
| the title's settle window is `[160, 236]` | Decoder, `5b0a6e6` | ✅ **survived** — our exporter still computes `[160, 236, 198]` under the corrected record layout |
| *"`ptlogo1` rests at t=251 and stops moving at t=42"* | Decoder, `5b0a6e6`, and the headline evidence for `rest.t ≠ settle` | ❌ **refuted on its evidence** — in the current export `ptlogo1.rest.t` is **42**, equal to when it stops moving. The record-layout fix repaired exactly this element. The *conclusion* survives on other elements (`ptlogo_back2eff1`, `pteff00`); the example no longer supports it |
| *"`ptbtn00` reaches a=255 at t=238"* → `120 units` | the Port's own | ❌ **refuted** — 236, so 118 units |
| *"build 4 is still fading up from black until t=261; `pteff00` is 7 % opaque at 243"* | the Port's own | ❌ **refuted** — `pteff00` is opaque at t=0, clear by **t=16**, transparent until 261, then fades **to** black by 269. At t=243 it is 0 % opaque. The comment had the direction backwards |
## What this does not settle
* Whether the human's "late" is a real offset the corpus has not measured. It
is not reconciled by anything here, and no measurement contradicts it either.
* The `t=118` / `t=160` anchor. Asked, not guessed.
* Whether the plate **pulses** after arrival. The port holds it; the corpus
measures a ~2.24 s pulse. Filed already in `flow.json`'s `no_pulse_why`.
* Finding 4 (the splash fade/blur) is the Decoder's this iteration — it said so
on the message channel and is on `auto/frame-blend-draw-path`.
---
# The splash blur is an ASSET, not a pass — and this port already draws it
Added the same iteration, after the Decoder answered play-test finding 4 from
GPU state (*no post-process on either splash*, `auto/frame-blend-draw-path` at
`3cc1b51`) and the human added the observation that **the logos go from blurred
to clear/sharp, in about one second**.
Those two are not in tension. They resolve each other:
> **Every logo ships a second, pre-blurred copy of itself, ~21 × 20 px larger
> and concentric, which is drawn alone first and crossfades out as the sharp
> logo fades in.** That is "blurred → sharp" with no post-process pass, no
> blur shader and no second render target.
Measured off `export/sprites/title/`:
| logo | sharp | companion | position offset | concentric? |
|---|---|---|---|---|
| `palogo_gamearts` | 500 × 71 | 521 × 91 | (−11, −10) | ✅ |
| `palogo_seta` | 240 × 89 | 261 × 110 | (−10, −11) | ✅ |
| `palogo_anima` | 388 × 136 | 407 × 156 | (−11, −9) | ✅ |
| `palogo_sqex` | 666 × 68 | 686 × 89 | (−10, −11) | ✅ |
## 🔴 "The port applies no blur at all" is false, and it came from this page's own side
[`BLOCKED.md`](BLOCKED.md) H2 and the play-test both record *"The port draws the
splash from the declared keyframe alphas only. It applies **no blur at all**."*
The port does not apply a post-process blur — but it draws all seven quads of
the developer splash including the three blurred companions, and it has been
doing so all along. A frozen sweep across the build-in, one deterministic
capture every 3 units (the human's "take a series of quick screenshots within
the short animation span", done without a clock at all):
| `t` | units | drawn |
|---|---|---|
| 0.00 s | 0 | background only |
| 0.05 – 0.25 s | 3 – 15 | background + **the three blurred companions alone** |
| 0.30 – 0.70 s | 18 – 42 | **all seven** — the crossfade |
| 0.75 – 0.80 s | 45 – 48 | background + the three sharp logos |
Blurred first, then both, then sharp. The mechanism is reproduced. The claim
that it was not came from describing the renderer instead of running it.
## Cross-check of the Decoder's quad mapping — ✅ survived, independently
`splash-quad-timeline.txt` names its quads by NDC rectangle off the guest's
vertex stream. This export's declared rectangles, converted independently:
| | this export | the guest | agreement |
|---|---|---|---|
| `palogo_sqex` | x[−0.517, +0.523] y[−0.106, +0.083] | `Q0` x[−0.520, +0.520] y[−0.100, +0.080] | **~2 px** |
| `palogo_sqex_eff` | x[−0.533, +0.539] y[−0.133, +0.114] | `Q7` x[−0.530, +0.540] y[−0.130, +0.120] | **~4 px** |
Two decoders, two paths — a `.pak` read statically and a vertex stream logged
live — agreeing to a few pixels on both the sharp quad and the blurred one.
That is corroboration of the mapping and of `Q7` being the companion.
## What is still open on the splash
* **The extent.** The rectangles agree; the *alphas* are where the 5-units-per-
frame discrepancy above came from, and until that is settled it is not
possible to say whether the game holds the blurred copy longer than we do.
That is the same question as H3 and is asked once, there.
* Whether `palogo_eff0` (kind `0x10`, a full-screen quad, like the title's
`pteff00`) is the splash's fade veil. Not examined this iteration.

View File

@@ -0,0 +1,185 @@
# ❌ HALF OF THIS PAGE IS WRONG: the plate DOES blink, and always did
> 🔴 **"It never blinks" is refuted, by the port's own code and by measurement.**
> The pulse has been implemented since **2026-08-30** — `authored/timing.json`
> `looping_focus_records` carries `press_start/ptbtn00` →
> `{record_element: ptbtn00f, period_units: 120, kind: measured}`, taken off the
> running game. The Decoder's independent 2026-09-02 figures (period 120 title
> units, peak α80, drawn ~51 of 60 frames) match the declared record
> `0:0 6:6 29:74 35:80 50:80 58:74 97:6 105:0` on all three counts.
>
> **Verified now rather than argued.** 502 samples from a filmed boot, folded onto
> the declared 120-unit period, reproduce the declared curve with **one solved
> gain**: 4.2 % rms residual against the pulse's own amplitude, and **both flat
> regions land where declared** — a flat top across phases 36–47 (the α80
> plateau) and a flat zero across 108–119. A sine of the same period fits
> **5.7× worse**, so this is the declared *shape*, not merely the right period.
>
> ✅ **The port needed no change.** The section below proposed one and held; the
> hold was right for the wrong reason.
>
> ### 🔴 How I got it wrong, which is the part worth keeping
>
> I measured **"does it return to dark"**. It never does, *by design*: the pulse
> is an additive glow over a base plate held at α255, so the total swings between
> bright and brighter. I recorded an **11.6 % ripple with a ~120-unit period** —
> the pulse, at exactly its declared period — and attributed it to the title's
> background sweep leaking through my glyph mask.
>
> **I tested a property the feature was never supposed to have.** And the
> authored entry said so in its own words before I started: *"IT NEVER GOES OFF …
> while the screen is held the base sits at its own hold and `ptbtn00f`'s cycle
> runs over it."* I read the disc's `ptbtn00` keyframes, saw `244:0`, and never
> read the port's own configuration for the element I was measuring.
>
> ⚠️ The **arrival** half of this page stands — the plate is on time, and that
> was measured against declared keyframes rather than against an assumption.
## The original page, kept for the arrival half
**Status:** ✅ **the "arrives late" observation does not reproduce as a timing
error** — measured on the current build, and the plate's onset sits on its
declared keyframe. 🔴 **A different divergence in the same element is real and
was not being looked for: the port holds `PRESS Ⓐ` lit permanently, where the
disc declares a 30-unit pulse.** Written 2026-09-02 by the Port at `6263686`.
`PLAYTEST-2026-09-02.md` asks for exactly this, in its own words: *"Worth
re-asking now: the animation fix changed what the whole boot looks like, so the
original observation may simply no longer reproduce."* And there is a specific
reason to re-ask rather than assume — **the previous plate numbers were taken
through the `pose_at` bug**, the same way the 0.01 % that manufactured H2's false
green was.
## What the disc declares
`export/screens/title/press_start.json`, one element `ptbtn00`:
```
0:0 214:0 236:255 238:255 244:0 rest [383,550], sprite 513x50
```
Invisible until **214**, full at **236–238**, **gone by 244**. A 30-unit pulse.
## The instrument, and the two controls it went through first
Filmed a real boot (`--skip-at=1 --linger=8 --film-interval=0.05`), no `--time`,
no pinning. Then **two attempts at isolating the plate failed before one worked**,
which is the part worth keeping:
1. 🔴 **A rect around the plate.** Contaminated — the rect overlaps the developer
splash earlier in the boot, so the onset detector fired on the wrong screen.
2. 🔴 **A control band 55 px above the plate.** Still wrong. The title's
background sweep **moves**, so a spatially displaced control samples it at a
different phase and cannot cancel it. It showed a "rise" that was the sweep.
3. ✅ **The plate sprite's own transparent holes.** Same rectangle, same rows,
interleaved with the glyphs at pixel scale — 7 672 glyph pixels against 10 412
hole pixels, both built from the sprite's alpha channel. A co-located
background sample, so `glyph − hole` is the plate and nothing else.
⚠️ Control 2 is the same defect as everything else on this project: **an
instrument that cannot see the thing it is measuring separately from the thing
it is measuring against.** It produced a plausible curve. The tell was that the
"plate" and the background rose together in phase.
## Result 1 — the arrival is correct
`glyph − hole`, dark baseline **0.003**:
| overlay units | 205 | **214** | **222** | 230 | **238** | 246 |
|---|---|---|---|---|---|---|
| plate | 0.005 | **0.005** | **0.222** | 0.434 | **0.586** | 0.601 |
**Flat through 213.9 and risen by 222.1.** The declared onset `t=214` sits inside
that bracket, and the bracket is 8 units wide because that is the film's sampling
interval, not a measurement of anything. By `t=238` the plate is at 88 % of its
lit level, against a declared full alpha at 236.
**The plate is not late.** Whatever the human saw in the 2026-09-01 play-test,
the port's own clock puts `ptbtn00` on its declared keyframe.
## Result 2 — 🔴 and it then stays lit forever
The declared ramp returns to **0 at t=244**. It does not:
| | |
|---|---|
| dark baseline, `t < 214` | **0.003** |
| minimum at any point after `t = 250` | **0.587** (at `ou` 342) |
| maximum after `t = 250` | **0.664** |
| span measured | `ou` 6 → **1974** — **8.1 declared cycles** |
Over eight cycles of its own declared timeline the plate **never returns within
88 % of dark**. The 11.6 % ripple that is there is *not* the plate: it is in
phase with the hole channel, i.e. it is the title's background sweep leaking
through the anti-aliased glyph edges.
### Why, and it is a failure mode this port already wrote down
`ptbtn00`'s `rest.t = 236` and its settle instant is `t=236` — **the peak of the
pulse**. `holding` parks the element there, so the port shows a `PRESS Ⓐ` plate
that lights once and stays on.
`plate-arrival-halves.md` names this exact class already, about a different
element:
> *"It is still wrong for transients — `ptlogo_back2eff1` is a two-frame flash
> (0 at t52, `0xff` at t54–56, 0 by t58) and its `rest.t = 54` is the flash
> **peak**, so `rest()` leaves five of these burning at once."*
**The plate is an instance of the family that page predicted, and nobody had
checked the plate itself** — because that page was written to *eliminate* `rest.t`
as a cause of lateness, which it correctly did, and having eliminated it as the
cause of *one* defect nobody asked what else it was doing.
## What I have NOT changed, and why
Nothing. The port still holds the plate.
Reading the disc's keyframes is mine; **whether the running game pulses its
`PRESS Ⓐ` plate is not.** The keyframes describe a blink and blinking is what
`PRESS START` prompts conventionally do, but "conventionally" is not evidence and
this project has been burned by exactly that kind of inference. The change is
**proposed, not made**, and the Decoder has been asked for the one fact that
settles it: does the plate pulse in the guest, and with what period?
⚠️ **This is not a regression from the animation fix.** Before the fix `pose_at`
*assigned* the settle instant, which parks the plate at `t=236` too. The plate
has been held for as long as the port has drawn it; the fix neither caused this
nor was supposed to.
## What this does not claim
* That finding 3 was wrong when it was made. It says the port measures on time
**now**, on a build whose boot the fix visibly changed.
* That holding is wrong. It says the port's picture and the disc's keyframes
disagree, and names who can adjudicate.
* Anything about the plate's **absolute** alpha or position. Onset timing and
whether it extinguishes, only.
---
# ✅ The pulse is now MEASURED — and still not implemented
**2026-09-02.** This page established that the port holds `PRESS Ⓐ` permanently
lit where the disc declares a 30-unit pulse, and closed by saying the port would
not move until someone measured whether the guest pulses it. It has been
measured, as a by-product of F6:
| | |
|---|---|
| element | **`ptbtn00f`** — the focus variant, not `ptbtn00` |
| period | **120 title units**, from 60.0 frames over 16 consecutive cycles with **zero variance**, against a 600-frame sweep loop |
| peak alpha | **80**, not 255 |
| duty | drawn **51 of every 60** frames |
The period is phase-free: it is exactly 1/10 of the sweep's declared 600-unit
loop, and 60 leaf units × the leaf/title rate of 0.5 gives the declared 120.
**Not implemented, and deliberately so.** The current focus is the title's
animation timing (F5/F6), both now closed; the plate's blink is neither, and the
human's standing note on the plate is that its *delay* is accepted. Picking this
up is a decision for them, not a gap to fill quietly.
⚠️ Note for whoever does: the port draws the plate through `ptbtn00`, and the
pulsing element is `ptbtn00f`, reached by `focus_link`. That is the same element
whose absence from a declaration-order walk has now caused trouble twice.

View File

@@ -0,0 +1,290 @@
# The port never reported its own frame rate — it does now, and it is 13–25 fps here
**Status:** ✅ **instrument added and measured.** ❌ **The candidate it raised for
play-test finding 4 is DEAD — tested on real hardware, not argued away. See the
final section.** The port's draw path never had a case to answer. Written 2026-09-01 by the Port at
`977965e`; HANDOFF on this branch answers `9ca1eb5`.
## The gap this closes
[`TEMPORAL-VERIFICATION.md`](../agents/TEMPORAL-VERIFICATION.md) §1 is
unambiguous: a capture that asked for one rate and delivered another *"is not a
slow capture, it is a **different** capture"*, and an instrument that cannot
report its own completeness may not be trusted.
That rule has been applied to `--film` (which I fixed for exactly this), to the
Decoder's harnesses, and to the oracle. **It had never once been applied to the
thing being shipped.** The port had no idea what rate it drew at and no way to
say.
It matters here specifically, because the splashes are the current focus and the
open complaint about them is that ours is *less pronounced* than the game's.
**A fade drawn in 45 frames and the same fade drawn in 12 are different
animations**, and nothing in this port could have told them apart.
## The instrument
`boot.gd` now counts frames per screen and prints at every boot transition, at
the end of the boot, and at every menu arrival:
```
publisher_logo: 107 frames in 4.29 s -- 25.0 fps achieved, uncapped, worst gap 108 ms
```
`worst gap` sits beside the mean deliberately: a hitch is what reads as wrong. A
screen averaging 55 fps with one 400 ms stall looks broken, and a mean hides that
by construction.
🔴 **Its first version printed `-9223372036854775808 requested`.**
`DisplayServer.screen_get_refresh_rate()` returns a *float* and is `-1.0` when
the display cannot say — which Xvfb cannot — and `%d` on that underflows to
`INT64_MIN`. A rate line whose own denominator is nonsense is worse than no rate
line. It now names the cap or says `uncapped`.
## What it measures, in this container
Three boots, same command, nothing else running:
| screen | run 1 | run 2 | run 3 | worst gap |
|---|---|---|---|---|
| `publisher_logo` | 17.3 fps | 19.6 | 25.0 | 100–115 ms |
| `developer_logos` | 16.7 | 21.9 | 22.8 | 103–138 ms |
| `title` | 17.2 | 14.2 | 12.7 | **150 ms**, all three |
**13–25 fps, varying by ~2× run to run, with hitches of 100–150 ms.** The
title's 150 ms is identical across all three runs, which looks like a one-off
cost rather than load — the video player is torn down immediately before it.
### 🔴 And the menu is worse than any of them
The report was **boot-only** on its first version and said so nowhere — the boot
walks through `_advance`, while `--menu` arrives through `_menu_arrive`. So the
mode a human actually spends time in, and the one where a slow frame is *felt as
input lag* rather than seen as a coarse fade, reported nothing. An instrument
covering half the application while its own page claims "every boot" is exactly
the shape this port keeps finding in other people's work. Fixed in the same
commit:
```
main_menu: 20 frames in 2.05 s -- 9.7 fps achieved, uncapped, worst gap 150 ms
```
**9.7 fps.** The main menu is the heaviest screen in the port — five additive
elements, five spinning focus rings, a full-screen background — and it is the one
the play-test spent its time on. At 9.7 fps a press takes up to 103 ms to appear
and a spinning ring advances in ~10 visible steps per revolution.
⚠️ **This is `llvmpipe` software rasterisation under Xvfb in a loaded container.
It is not a measurement of the human's hardware and must not be quoted as one.**
What it establishes is that the port *can* run this slowly and never said so.
## The consequence, and why it is a live candidate for finding 4
The port's timeline is driven by `time_units += delta * units_per_second`, so
**the durations stay correct at any frame rate** — the fade still takes 0.75 s.
What changes is how many distinct alphas that fade is *drawn* at:
| rendered at | steps in the 45-unit build-in | steps in the glow's 15-unit rise |
|---|---|---|
| **16.7 fps (measured)** | **12.5** | **4.2** |
| **22.8 fps (measured)** | **17.1** | **5.7** |
| 30 Hz | 22.5 | 7.5 |
| 60 Hz | 45.0 | 15.0 |
The pre-blurred companion glow — the thing that *is* the splash's blur — rises
over 15 units. **In this container it is drawn at four to six distinct alphas.**
At 60 Hz it would be fifteen.
A soft crossfade rendered in four steps, inside a 750 ms animation carrying a
100–150 ms hitch, is a plausible mechanism for *"close, but not quite right"* and
for *"the game's is more pronounced"* — and it is the **first candidate for
finding 4 that is not already dead.** Every other one is: the keyframes are
vindicated against the vertex stream, the companion quads are drawn, the blend
space matches, the settled pose scores 0.01 % against the capture, and there is
no post-process pass to add.
🔴 **It is a candidate, not a cause.** It depends entirely on what the machine
running the port manages, and I cannot measure the human's. **The line now
prints on every boot**, so the next play-test answers it for free: if it says 60
fps and the splash still looks wrong, this is dead too.
## What is NOT affected, and it was worth checking
**Every timing result this port has published stands.** They are all derived from
`_elapsed`, which is `+= delta` — a sum of frame times, correct at any rate — and
from `time_units`, which is the same sum scaled. So:
* the splash dwells (4.270 s and 3.527 s) are unaffected — measured across runs
whose frame rates differed by 2×, and they agreed to ±0.03 s;
* the plate's arrival, the shared-clock check and the film's own index are all
`_elapsed`-based.
That is the reassuring half of the same design: the port is *correct* at 13 fps
and merely *coarse*. Had the timeline been frame-counted, every number in this
corpus would have been wrong by a factor that changed between runs — which is
precisely the failure the Decoder found in the emulator's own rate and withdrew a
finding over.
## What this does not claim
* That 13–25 fps is what a player sees. It is what this container manages.
* That the port has a performance defect. Textures are cached at `load_screen`
and not decoded per frame — that was checked and is not the cause.
* That capping or vsyncing would help. It would not raise the rate here, and
changing presentation behaviour on my own authority is not mine to do.
---
# The control: it is the software rasteriser, and the port has no case to answer
Written the iteration after the section above, because *"is 9.7 fps llvmpipe or
something in our draw path"* was left open and it is not a question to leave open
after publishing a candidate cause.
## Pre-registered (R2)
> If the rasteriser is the limit, a near-empty Godot scene in this same container
> will also run at roughly 10–25 fps. If my draw path is the limit, it will run
> far faster — take **>100 fps** as the discriminator.
Same container, same Xvfb, same 1280×720 viewport, same `[rendering]` settings,
almost nothing drawn:
```
FPSPROBE mode=empty: 651 frames in 4.03 s -- 161.6 fps, worst gap 54 ms
```
**161.6 fps.** The engine loop, the viewport and the present path are not the
limit, by a factor of sixteen over the menu.
## 🔴 And the first control was not a control
Its `fill` modes drew **untextured** `draw_rect`s while every element the port
draws is a **texture**. A control that does not do what its subject does bounds
nothing — and it showed: the port's splashes were achieving ~21 Mpx/s against
that control's ~50, which read as the port being mysteriously slow and was really
the control being mysteriously fast. Adding a matched textured mode:
| mode | full-screen quads | achieved |
|---|---|---|
| `empty` | 0 | **161.6 fps** |
| `fill3` | 3, untextured | 31.5 |
| **`tex3`** | **3, textured** | **23.9** |
| `fill7` | 7, untextured | 12.7 |
| **`tex7`** | **7, textured** | **11.2** |
## The port sits inside the bracket, ordered by large-quad count
| screen | full-screen-ish quads | measured | bracket |
|---|---|---|---|
| `publisher_logo` | 1 | 17.3–25.0 | ≈ `tex3` |
| `developer_logos` | 1 | 16.7–22.8 | ≈ `tex3` |
| `title` | 6 | 12.7–17.2 | between `tex3` and `tex7` |
| **`main_menu`** | **5** | **9.7** | ≈ `tex7` (11.2) |
**Every screen lands between the two matched controls, in the order the quad
count predicts.** The port is drawing large textured alpha quads on a software
rasteriser at exactly the rate a software rasteriser draws large textured alpha
quads.
⚠️ Note the control's own spread: `fill3` measured **18.0** on one run and
**31.5** on another. The container's load swings by ~1.75×, so the absolute
numbers here are noise-dominated and only the **ordering and the bracket** are
load-invariant — which is what `TEMPORAL-VERIFICATION.md` §3 says to prefer, and
the reason this conclusion rests on those rather than on any single figure.
## 🔴 So the finding-4 candidate is downgraded, and I am saying so plainly
The section above called the frame rate *"the first candidate for finding 4 that
is not already dead"*. **That now looks wrong, and it was mine.**
The quantisation argument still holds — a fade drawn in four steps is not a fade
drawn in fifteen — but it only bites at these frame rates, and these frame rates
are a property of **software rasterisation in this container**. Five to seven
full-screen quads at 720p is nothing to any GPU; on real hardware the port would
hit vsync and the fade would get its full 45 steps.
So unless the human ran the port software-rendered, this is not what they saw,
and **every candidate for play-test finding 4 is now dead or near-dead.** That is
an honest dead end rather than a lead, and it is written as one: leaving a
plausible-sounding cause standing when its own control has undercut it is exactly
how *"close but not right"* got explained four different wrong ways.
**The one thing that would revive it costs the human nothing**: the rate line now
prints on every boot. If their next run says 60 fps, this is finished.
## What the port does NOT need
* No draw-path optimisation. 161.6 fps empty says the loop is fine, and the
screens sit on the fill curve exactly where their quad counts put them.
* No texture caching work — already done at `load_screen`, checked last
iteration.
* No `max_fps` or vsync change. It would not raise the rate here, and it is a
presentation decision rather than a defect.
---
# ❌ Settled on hardware: 60–69 fps, and the candidate is dead
The human activated a **hardware GPU** in both containers on 2026-09-01, which
made the one open half of this page directly testable. Godot picks it up with no
change on our side:
```
Vulkan 1.4.312 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce GTX 1070 Ti
```
## Pre-registered (R2)
> Five to seven full-screen textured quads at 720p is trivial for that card. I
> expect the port well above 60 fps on every screen, and the splash fade to get
> its full 45 steps.
| screen | llvmpipe (before) | **GPU (after)** | worst gap |
|---|---|---|---|
| `publisher_logo` | 17.3–25.0 | **69.4** | 83 ms |
| `developer_logos` | 16.7–22.8 | **69.1** | 15 ms |
| `title` | 12.7–17.2 | **61.1** | 67 ms |
| **`main_menu`** | **9.7** | **59.6** | 117 ms |
**A 3–6× jump, and every screen is now at or above 60 fps.** The prediction held.
## What that does to the fade
| animation | declared | steps drawn at 69 fps |
|---|---|---|
| splash build-in, 45 units = 0.750 s | 45 alphas | **52** |
| companion glow's rise, 15 units = 0.250 s | 15 alphas | **17** |
**More frames than declared units, so every declared alpha is drawn.** The
quantisation this page raised does not exist on this hardware — not reduced,
*absent*.
## ❌ So the candidate is dead, and it died the right way
It was raised as a mechanism, downgraded by a matched control, and is now closed
by a direct measurement on the hardware in question. **Every candidate for
play-test finding 4 is now dead**: the keyframes are vindicated against the
vertex stream, the pre-blurred companion quads are drawn, the blend space
matches, the settled pose scores 0.01 % against the capture, there is no
post-process pass, and the frame rate draws every declared step.
🔴 **The port has nothing left that is known to be wrong about the splashes, and
that is a statement about our knowledge rather than about the port.** The human
saw something. Nothing we can measure reproduces it.
**The next play-test is now the highest-value thing available on this focus**,
and it is cheap: the rate line prints on every boot, so it will say 60-something
rather than 10-something, and whatever remains will be visible against a port
that is no longer coarse.
## The one figure that did not improve
`main_menu`'s **worst gap is 117 ms** on the GPU against 150 ms on llvmpipe —
essentially unchanged while the mean improved 6×. A hitch that survives a 6× fill
speed-up is not fill. It is most likely first-frame cost (texture upload, shader
compilation) and it sits at the start of the screen, but that is **stated as
untested**: nobody has separated it from load. It is small, it is once per
screen, and it is recorded rather than chased.

View File

@@ -0,0 +1,87 @@
# The re-opened `rest()` pair cannot change a single pixel this port draws
**Status:** ✅ **measured, and it bounds a question rather than answering it.**
Written 2026-09-01 by the Port at `94e44b5`; HANDOFF on this branch answers
`9ca1eb5`.
The loop brief carries a standing warning: the **`rest()` pair is open in both
directions**, and *"the two splashes are the only screens reaching that
fallback."* R1 re-opened both legs, and 8 further claims died to
`⟨render-vs-capture⟩` — an instrument that no longer exists in that form.
`ScreenView.settle_time()` uses `rest.t`, so this port has been sitting on a
question with no instrument behind either leg. **It turns out not to matter, and
that is worth establishing rather than waiting on.**
## What reaches the fallback
`Element::rest()` takes the longest **plateau** — a run of consecutive identical
keyframes — and falls back to the longest dwell when there is none. So an element
reaches the fallback exactly when no two consecutive keyframes are identical.
Census over all sixteen exported screens, at every nesting depth:
| screen | elements reaching the fallback |
|---|---|
| `publisher_logo` (and `_r`) | `palogo_sqex_eff` |
| `developer_logos` (and `_r`) | `palogo_anima_eff` |
| **`title_jp`** | **`ptlogo_eff3`** |
**Five elements in the whole export**, and only three distinct ones.
## 🔴 The brief's claim is narrowly refuted
*"The two splashes are the **only** screens reaching that fallback"* — `title_jp`
reaches it too, through `ptlogo_eff3`.
⚠️ Stated as a measurement of the **current** export and nothing more. That
sentence may have been true when written: the record-layout fix re-timed
keyframes across the corpus, and a plateau is exactly the kind of thing it could
create or destroy. This does not say the claim was wrong when made; it says it is
not true now.
## And every one of them is invisible where it is read
The fallback only matters if the element it picks a pose for is actually drawn.
Asked of the port directly, at each screen's own settled instant:
```
publisher_logo t=140 drew 2 not drawn: palogo_sqex_eff (transparent at t=140)
developer_logos t=117 drew 4 not drawn: palogo_anima_eff (transparent at t=117)
title_jp --pose=rest drew 23 not drawn: ptlogo_eff3 (transparent at rest)
```
**Every element that reaches the `rest()` fallback is fully transparent at the
instant anything reads its rest pose.**
> So no `rest()` rule — the current plateau-plus-dwell, `last`, `maxalpha` or
> `lastall` — can change any pixel this port draws. The pair is open, and for the
> port it is **moot**.
That is why the splash rows score 0.01 % against their captures while resting on
a heuristic nobody can currently defend: the heuristic is not load-bearing there.
## Why this was worth an iteration rather than a wait
The three elements are the **pre-blurred companion glows** and one title sparkle
— transients that exist to be seen briefly and then leave. An element with no
plateau is, almost by definition, one that never holds still, and a screen's
settled instant is chosen to be where things are holding still. The two
conditions are close to mutually exclusive, which is why the intersection is
empty and why it was worth checking rather than assuming either way.
⚠️ **This does not rehabilitate `rest.t`.** It is still the wrong answer for
transients — `ptlogo_back2eff1` is a two-frame flash whose `rest.t = 54` is the
flash *peak*, and `rest()` would leave five of those burning at once on the
title. That is a **plateau** case, not a fallback case, and it is untouched by
anything here.
## What this does not claim
* That the `rest()` pair is settled. It is open, and this page does not touch it.
* That `rest.t` is right. `settle_time()` still takes the maximum over elements,
and what that means for *when the boot advances* is a separate question — a
screen still plays to `exit_time()` afterwards, so the dwell is governed by
that, not by `rest.t`. **Measured for the drawn picture; argued for the dwell.**
* Anything about the other 7 claims R1 re-opened against
`⟨render-vs-capture⟩`. Not mine to re-derive.

View File

@@ -0,0 +1,160 @@
# The splash: frozen, then fixed, and now checked for SHAPE as well as motion
**Status:** ✅ **animates, and the ramp matches the declared curve.** Written
2026-09-02 by the Port; HANDOFF on this branch answers `9ca1eb5`.
The 2026-09-02 play-test found the splash frozen. The cause and the fix are in
the commit history; this page is the part that comes after — **is the animation
now the right animation?** `motion-census` says explicitly that it cannot answer
that: *"a wrong ramp that moves every frame passes here."*
## Pre-registered (R2)
`palogo_sqex_eff` declares `0:a=0 → 15:a=255 → 30:a=212 → 45:a=0` — three
segments, three gradients:
| segment | declared |
|---|---|
| 0 → 15 | **+17.0** per unit |
| 15 → 30 | **−2.87** per unit |
| 30 → 45 | **−14.13** per unit |
> So a film should show three straight runs with breakpoints at **15** and
> **30**, the middle slope about **1/5** the magnitude of the last, and the rise
> about **1.2×** the last.
## Measured, from a film of a real boot
Region: `686x11+299+319` — the companion's top strip, which **no other element
overlaps**, so the number is that element's own alpha and not a composite.
| view units | strip mean | slope/unit |
|---|---|---|
| 5.58 | 0.02809 | |
| 10.00 | 0.05086 | **+0.00515** |
| 13.04 | 0.06611 | **+0.00503** |
| 16.12 | 0.07530 | +0.00298 ← crossing the breakpoint |
| 19.21 | 0.07260 | **−0.00087** |
| 25.38 | 0.06695 | **−0.00092** |
| 28.39 | 0.06482 | −0.00071 |
| 31.39 | 0.05719 | −0.00254 ← crossing the breakpoint |
| 34.39 | 0.04471 | **−0.00416** |
| 40.39 | 0.01910 | **−0.00428** |
| 43.39 | 0.00655 | −0.00418 |
| 46.39 | 0.00000 | gone |
**Breakpoints land where declared**: the rise stops between 13.0 and 16.1, the
gentle fall steepens between 28.4 and 31.4.
| ratio | declared | measured |
|---|---|---|
| middle : last | 0.203 | **0.213** |
| rise : last | 1.20 | **1.20** |
Within 5 % and exact respectively. **The port interpolates piecewise-linearly
across the declared segments** — which is what the Decoder independently measured
the game doing (28 distinct alphas over 28 consecutive presents, modal steps −3
and −14 against predicted −2.87 and −14.13).
⚠️ **Ratios, not absolutes, and deliberately.** The strip mean is an alpha
scaled by whatever the sprite's own pixels are; its absolute value carries the
texture. A ratio between segments divides that out, which is why the shape is
checkable from a composite at all. Anyone quoting 0.00515 as an alpha is
misreading it.
## What this still does not establish
* That it **looks** right. Three instruments have now agreed with a picture a
human called wrong, and the fourth agreeing does not change the standing of
the fifth. A play-test is the check.
* ~~Anything about the **developer** splash's three logo/companion pairs. One
element, one screen.~~ ✅ **Closed below, and the developer splash turned out to
be the far better test.**
* The **absolute** alpha. The shape matches; whether the port's alpha equals the
game's at a given unit is `verify-capture`'s question, and it answers 0.01 %
at the settled pose only.
---
# ✅ The developer splash: three elements, TWO declared shapes, on one screen
The publisher check above had a structural weakness I named at the time: one
element, one screen, one shape. If the port applied *some* single ramp to
everything, that check would pass.
**The developer splash cannot be fooled that way, because it declares two
different shapes at once:**
| element | declared alpha | middle segment |
|---|---|---|
| `palogo_gamearts_eff` | `0:0 15:255 30:255 45:0` | **flat** |
| `palogo_seta_eff` | `0:0 15:255 30:255 45:0` | **flat** |
| `palogo_anima_eff` | `0:0 15:255 30:212 45:0` | **decays 17 %** |
Same screen, same frames, same clock, same code path. The two flat elements are
the control for the one that is not — no second run, no second renderer, and
nothing for a phase or rate error to hide behind, because any such error hits all
three identically.
## Pre-registered, before the film was read
> In units 15→30, `gamearts` and `seta` hold flat while `anima` falls gently.
> All three fall steeply 30→45.
## Measured, off a real filmed boot
Companion-only strips (each companion's rectangle minus the logo sitting inside
it), mean luma, `--film-interval=0.03`:
```
segment units 16-29 units 31-44
gamearts +0.00000 -0.00101
seta +0.00000 -0.00151
anima -0.00001 -0.00003
```
**The two flat ones are flat to five decimals. The decaying one decays.** And all
three fall over 31–44, so the flatness is not a dead element.
## The quantitative version, and the falsification arm
Each trace normalised by a **single solved gain** — one scalar per element, not a
per-point fit — against the declared curve, over the whole `t=0..45` ramp:
| element | fitted against | max err | rms err |
|---|---|---|---|
| `gamearts` | **its own (flat)** | **0.49 %** | 0.23 % |
| `seta` | **its own (flat)** | **0.50 %** | 0.27 % |
| `anima` | **its own (decay)** | **2.18 %** | 1.19 % |
| `gamearts` | anima's decay | 8.32 % | 4.80 % |
| `seta` | anima's decay | 8.08 % | 4.86 % |
| `anima` | flat | 8.82 % | 4.94 % |
**Both directions.** Every element fits its own declared shape 4–17× better than
it fits the other one available on the same screen. A port that drew one ramp for
all three would sit at ~8 % on at least one row; none does.
`anima`'s 2.18 % is the loosest row and the reason is its signal: its
companion-only strip means ~0.0005 against gamearts's ~0.015, thirty times
dimmer, so its noise floor is thirty times higher in these units. It still
separates from the wrong curve by 4×.
## What this adds over the publisher check
The publisher check established that the ramp has the declared *breakpoints and
slope ratios*. This establishes that the ramp is **per-element** — that the port
reads each element's own keyframes rather than applying a screen-wide curve. That
is a different failure mode, and it is the one that would have survived the
publisher check unnoticed.
## What it still does not establish
* That it **looks** right. This is instrument five. The standing of a human's
eyes is unchanged by it.
* The **absolute** alpha, for the same reason as above — a solved gain is
deliberately scale-free, so this is a shape result and says nothing about
whether the port's alpha equals the game's at a given unit.
* Anything about the **logo** elements. Both checks measure the pre-blurred
*companions*, because those are the strips that can be isolated from the sharp
logo underneath them.

View File

@@ -0,0 +1,194 @@
# The proposed splash rate contradicts the splash dwells — NOT adopted
**Status:** ✅ **RESOLVED — the rate was withdrawn.** The Decoder withdrew it the
same day (`splash-rate-withdrawn.md`, `1e7343e` *"WITHDRAW 'the unit rate is
per-GamePart' — it was the emulator's frame rate"*), and struck the section that
carried it. §1 of `splash-declared-vs-captured.md` — the keyframe vindication —
stands, because it never divides by a duration.
**The port never moved, so nothing has to be undone.** What follows is the
refutation as it was made, kept because the shape of the error is reusable: a
duration measured in emulator frames is the emulator's rate, not the game's, and
the tell was that it made a part outlast its whole.
---
**Original status:** 🔴 **refutation attempt, and it lands.** Two of the Decoder's own
measurements, of **the same two screens**, disagree by 1.7×. The port has **not**
changed `keyframe_units_per_second` and is still at 60. Written 2026-09-01 by the
Port at `0a9bf4e`; HANDOFF on this branch answers `9ca1eb5`.
## What was proposed
`docs/re/splash-declared-vs-captured.md` (`origin/auto/frame-blend-draw-path`)
reports that one rate cannot cover every screen:
| screen | evidence | units/guest-second |
|---|---|---|
| title | `ptbtn00` ramp, `T=22` | 56.8 |
| splash | `palogo_gamearts` ramp, `T=15` | 39.1 |
| splash | **160-unit hold in 4.514 guest s** | **35.4** |
with the conclusion that *"a splash played at 60 units/s runs 1.5–1.7× too
fast"*, and a recommendation to use ~35–40 for the splashes. The hold leg is
offered as the safe one, and the argument for it is good: **a hold carries no
`T`** — it is a declared duration measured directly, with no alpha slope and no
interpolation in the chain.
## Which hold it is — identified, not assumed
Exactly one interval in either splash is 160 units. From `export/`:
| screen | element | `a=255` from → to | hold |
|---|---|---|---|
| `developer_logos` | `palogo_gamearts` (and `_seta`, `_anima`) | t=30 → t=190 | **160 units** |
| `publisher_logo` | `palogo_sqex` | t=30 → t=235 | 205 units |
So the 160-unit hold is the **developer** splash's full-alpha plateau, and it
sits **inside** that screen's declared group of `t = 0…210`.
## The contradiction
`authored/timing.json` already carries a measurement of that same screen —
`docs/re/structures/boot-splash-dwells-are-declared.md`, the Decoder's, over
**three cold boots**:
```
publisher declared t=0..255 corpus 4.30 / 4.60 / 4.37 s
developer declared t=0..210 corpus 3.51 / 3.50 / 3.37 s
```
🔴 **The 160-unit hold is measured at 4.514 s. The 210-unit group that contains
it is measured at 3.37–3.51 s. A sub-interval cannot outlast the interval
containing it.**
That is not two methods disagreeing about a rate. It is an arithmetic
impossibility, and one of the two measurements is wrong.
| | implied units/s |
|---|---|
| developer whole group, 210 units in 3.46 s (3 boots) | **60.7** |
| publisher whole group, 255 units in 4.42 s (3 boots) | **57.7** |
| developer 160-unit hold in 4.514 s (1 run) | 35.4 |
**The two dwell measurements corroborate ~60 on exactly the two screens the new
figure puts at 35–39**, they agree with each other to 5 %, and the developer
figure agrees with its declared value to 1.1 % — two of its three runs to 0.3 %.
At 35.4 units/s the declared groups would run **5.93 s** and **7.20 s**, against
corpus dwells of 3.37–3.51 and 4.30–4.60. The port would show each splash for
about 70 % longer than three cold boots measured them lasting.
## The one escape route, named rather than dismissed
The new figure is quoted in **guest seconds** and the dwell corpus in wall-clock
seconds. If those clocks differ by 1.7× the two are not comparable and there is
no contradiction.
**It does not look like the answer.** The known gap between the two is Canary
presenting at ~28.1 fps against a nominal 30 — about **6 %**, not 71 %, and in
the wrong direction to close a factor of 1.7. But this is the Decoder's
instrument and the Decoder's clock, so it is asked rather than ruled out here.
## What the port did
**Nothing.** `keyframe_units_per_second` stays at **60**, one value, for every
screen.
This is deliberate and it is the conservative half in both directions: 60 is what
three cold boots of both splashes support, and it is what the port has been
shipping, so not moving costs nothing that was not already being paid. Adopting
35.4 would slow both splashes by 70 % on the strength of a number that the same
agent's earlier measurement of the same screens says is impossible.
⚠️ **And the proposal's structural claim may well be right even if this figure is
not.** *"One rate cannot cover every screen"* is a claim about the format, and it
is supported independently by the title's 56.8 sitting 5 % off the splashes'
~58–61. If a per-screen rate is real, the port will need the mechanism — a field
or a `GamePart` constant — and not two authored numbers. The Decoder has *"where
the per-GamePart rate actually comes from"* as its next item, which is the right
question.
## What this does not claim
* That the title's 56.8 is wrong. It rests on a different screen and a
gap-free ramp, and nothing here touches it.
* That the splash **keyframes** are wrong. They are now vindicated — see below.
* That the dwell corpus is right and the new capture wrong. Only that they cannot
both be, and that the port must not move on the strength of the one that
contradicts the other.
## ✅ Separately, and it is good news: the splash keyframes are vindicated
`docs/re/splash-declared-vs-captured.md` also settles the R1-re-opened *"the
declared keyframe timeline reproduces the captured splash"*, **in favour of the
timeline** — disc table against vertex stream, no renderer in the chain: 50
captured alphas, 39 exact under truncation, worst error **one alpha level in
255**.
That entry was 🟡 `⟨our-reader⟩` and is the one the play-test's finding 4 leaned
on. **The port's splash keyframes were never the defect**, which means the
remaining candidate for what the human saw on the splashes is the *rate* — and
the rate is precisely what this page declines to change.
---
# ✅ And what the splashes actually do, measured on the shipping boot
With the rate settled at 60, the keyframes vindicated against the vertex stream,
and `verify-capture` scoring both splashes at **0.01 %** against the oracle
frames, the one thing nobody had checked was the **real-time boot path** — the
port's own timing, end to end, rather than a frozen pose or a model.
## Pre-registered (R2)
> At 60 units/s, `publisher_logo` holds for 255 + 9 = **264 units = 4.400 s** and
> `developer_logos` for 210 + 9 = **219 units = 3.650 s** — the figures
> `authored/timing.json` states the port emits. Accept ±0.10 s.
Three boots, `--skip-at=1`, no film (so nothing competes with the renderer):
| | run 1 | run 2 | run 3 | mean | declared | residual |
|---|---|---|---|---|---|---|
| `publisher_logo` | 4.28 | 4.26 | 4.27 | **4.270 s** | 255 u = 4.250 s | **+1.2 units** |
| `developer_logos` | 3.50 | 3.57 | 3.51 | **3.527 s** | 210 u = 3.500 s | **+1.6 units** |
**The prediction failed, by 0.130 s and 0.123 s — and the port was right.**
## 🔴 The failure was in the claim, not the code
`authored/timing.json` sets **`black_hold_units = 0`**, deliberately, with its
own argument attached: a uniform black hold is *positively excluded* — the
Decoder's five replicates show the same origin giving different values to
different destinations — so only an ordered-pair key survives and nothing may be
authored until one is measured.
So there is no 9-unit hold to add, and the port has never added one. Yet two
places asserted it did:
* `authored/timing.json`, `dwell_why`: *"The port emits 4.400 s and 3.650 s —
each declared value plus the 9-unit black hold, exactly. So the pacing was
right all along and nothing changes in the code."*
* `port/scripts/boot.gd`, in the `_advance` block: the same sentence.
**The `why` asserted a behaviour that the same file refused three keys below,
and the code comment repeated it.** Both are corrected in place. Nothing in the
port changed — this commit fixes a false statement about our own behaviour, and
the port has been shipping 4.270 / 3.527 since P3.
## What it does not settle
Against the corpus dwells (means **4.42** and **3.46** s) neither figure
dominates: the port is 3.4 % short on the publisher and 2.0 % long on the
developer, where the claimed values would be 0.5 % short and 5.5 % long. **So
this does not show a hold does not belong there** — it shows nobody had checked
whether the port did what it said. `black_hold_why`'s ordered-pair ask stands
unchanged.
⚠️ Worth naming as a pattern rather than an incident: this is the third time in
this corpus that a `why` described behaviour the code did not have. The previous
two were `exit_ramp_units` and the `dwell` slot that *"was read NOWHERE for eight
milestones"*. The common shape is a value **authored, documented, and never
exercised end-to-end** — and the only thing that catches it is running the
shipping path and timing it.

View File

@@ -0,0 +1,356 @@
# If 120 units/s is right, the switch is one constant — audited, and pre-registered
**Status:** ✅ **SETTLED by a designed experiment.** The game's clock is
**frame-based, 1 unit per present**; this port's is time-based; **they agree at
60 Hz and the port keeps its own design.** 120 is withdrawn by its author. The
port never moved, so nothing has to be undone.
🔴 **Two things on this page are mine and wrong: the falsifier (conceded
mid-page) and the "no hold / two errors that cancel" finding (withdrawn at the
foot).** Written
2026-09-01 by the Port at `b42ff38`; HANDOFF on this branch answers `9ca1eb5`.
The Decoder now measures **120 units/s** with a content-hash experiment whose
controls are the ones the withdrawn version lacked. **It is their third position
on this number in one day**, and they said plainly that a second independent boot
before a timeline is rewritten is the defensible call, and that they would rather
this port held for another iteration than swung twice on their say-so.
**Agreed, and the port has not moved.** What follows is the work that is worth
doing *now* regardless of which value wins.
## Why hold
1. **Three positions in a day**, two of them already withdrawn by their author.
2. **Reach is one boot.** They say so.
3. They do **not** offer the 2.13 s reconciliation as support — it needs a ~47 %
emulator speed fitted post-hoc, and they label it as the thing this corpus
keeps losing claims to.
4. Doubling a shipped timeline is the change a play-test would notice most, and
the current value is the one a human has already seen.
None of that is an argument that 60 is right. **60 has no surviving derivation
either** — its bracket was withdrawn this morning. Both numbers are now
undefended; the port keeps the one it ships because switching on a single capture
is a worse failure than holding on none.
## The audit they asked for, and it comes out clean
> *"Every duration in SECONDS I have ever handed you is half what it should be. If
> your timeline is authored in units and converted once, this is a single
> constant. If seconds are baked in anywhere, they all move."*
Every numeric constant in `authored/`, and every float in the port's code:
| where | value | seconds? | moves with the constant? |
|---|---|---|---|
| `timing.json` `keyframe_units_per_second` | 60 | — | **it IS the constant** |
| `timing.json` `black_hold_units` | 0 | no — **units** | ✅ derived |
| `timing.json` `dwell_seconds` | `null` | n/a | — |
| `flow.json` `dwell` | prose only, "NOT SET" | n/a | — |
| `audio.json` `loop_start_s` / `loop_end_s` | 9.44 / 61.87 | **yes** | ✅ **correctly not** — these are positions in an audio file, real-time by nature, with no keyframe unit in them |
| `gamepad.gd` `ENTER` / `RELEASE` | 0.61 / 0.4 | no — deflection | — |
**No seconds are baked into the timeline anywhere.** Every second the port prints
or acts on is computed as `units / units_per_second` at the point of use —
`settle_time()`, `exit_time()`, `_overlay_quit_at`, the boot's own log lines. The
switch is one number in one file.
## 🔴 Except one, and it was hiding behind a comment about not drifting
`tools/port/verify-dwell` had:
```python
# Read from the authored file so it cannot drift again, and REPORT the shortfall
PORT_HOLD = float(...get("black_hold_units", 0)) / 60.0
```
**The value was read from the file. The rate was a literal.** The value could not
drift; the conversion could, and would have gone silently wrong the moment
`keyframe_units_per_second` moved — which is under active dispute right now, so
it is a live hazard rather than a tidy-up. Harmless only because the hold is
currently 0.
Fixed to read `keyframe_units_per_second` from the same file it already opens.
⚠️ The shape is worth more than the line: **a comment asserting that something
cannot drift, one expression above a hardcoded copy of the thing that drifts.**
That is the third time in this corpus a `why` has described a property the code
did not have.
## Pre-registered: what a switch to 120 would do (R2)
Written **before** any second boot, so the switch is checkable rather than a
leap. At 120 units/s every declared interval halves in seconds; unit counts and
`2 units/present` are untouched.
| | declared | at 60 (shipping) | **at 120** |
|---|---|---|---|
| `PRESS Ⓐ` plate, full | t=236 | 3.933 s | **1.967 s** |
| plate ramp onset | t=214 | 3.567 s | **1.783 s** |
| publisher splash group | 255 units | 4.250 s | **2.125 s** |
| developer splash group | 210 units | 3.500 s | **1.750 s** |
| title build-in end | t=118 | 1.967 s | **0.983 s** |
| `ptcopyright` full | t=160 | 2.667 s | **1.333 s** |
**The falsifier is the splash dwells.** Three cold boots measured the publisher
at 4.30 / 4.60 / 4.37 s and the developer at 3.51 / 3.50 / 3.37 s. At 120 the
port would show them for **2.13 s** and **1.75 s** — roughly half what those
boots recorded.
🔴 So 120 and the dwell corpus cannot both be right **in wall-clock seconds**, and
that is the same collision that killed the earlier 35 units/s proposal from the
other direction. Either those dwells carry the emulator's speed factor — which
would make them worth exactly as little as the 2.13 s route the Decoder has
already declined to lean on — or 120 is wrong. **Naming the falsifier now is the
point of writing this before the boot rather than after.**
## What would move the port
A second independent boot agreeing with the content-hash result, **and** a
statement about whether the cold-boot dwell corpus survives the same speed-factor
objection that the 2.13 s route does not. The first without the second leaves a
2× contradiction standing between two numbers this port would then be holding
simultaneously.
---
# 🔴 My falsifier was malformed — and following it through found something worse
## Conceded: it compared two different quantities
I set the falsifier as *"at 120 the publisher splash runs 2.125 s, against three
cold boots measuring 4.30/4.60/4.37"*. **2.125 s is the declared *animation*
length. 4.3 s is how long the *screen* is up.** The screen holds after the
timeline ends, so those are not the same quantity and **the comparison would have
found a contradiction at any units-per-second at all.**
The Decoder also checked the defence I offered them — that the dwell corpus might
be emulator-contaminated — and declined it: their capture reproduces those boots
(publisher 4.263 s, developer 3.457 s, four runs agreeing). **They could have
waved my numbers away and did not.**
## But the port has NO hold, and that is the part nobody had said
| | declared | port measured | animation at 60 | **hold** |
|---|---|---|---|---|
| `publisher_logo` | 255 units | 4.270 s | 4.250 s | **+0.020 s** |
| `developer_logos` | 210 units | 3.527 s | 3.500 s | **+0.027 s** |
**The port's screen time *is* its animation time.** The game, by the Decoder's
counts, is on screen for 219 presents and animates for ~128 of them — about
**42 % hold**.
So if 120 is right, this port is making **two errors that cancel**:
* it animates every splash **2× too slow**, and
* it omits the hold **entirely**,
and the two sum to almost exactly the right total screen time. That is why the
dwell check has been passing, and **`authored/timing.json` cites that agreement as
proof the pacing is right** — *"So the pacing was right all along and nothing
changes in the code."* Conditional on 120, that sentence is a coincidence of
compensating errors. Corrected in place.
⚠️ A passing check on a *sum* cannot see two errors of opposite sign inside it.
That is the fourth member of today's family — after the non-inverting latch check,
the unguarded buffer assumption, and the segmentation that nearly produced a
convenient answer.
## 🔴 And 120 explains both open play-test findings. 60 explains neither.
This is not a measurement and it is not mine to call decisive. It is the one
thing the port can contribute that no emulator capture can: **what a human
watching both actually reported.**
| | at 60 (shipping) | at 120 |
|---|---|---|
| **finding 3** — *"the plate arrives late"* | full at **3.933 s** | **1.967 s** |
| **finding 4** — *"the game's fade is more pronounced"* | build-in **0.750 s**, then the screen leaves at once | build-in **0.375 s**, then the screen **holds ~1.7 s** |
At 60 the port's logo drifts in slowly and leaves immediately. At 120 it snaps in
and sits — which is what *"more pronounced"* describes, and the hold is what makes
a splash read as a splash rather than a transition.
**Finding 4 is explained twice over by the same constant**: the fade is 2× slow
*and* the hold is missing, and both follow from one number.
Every named cause for both findings has died over the past several iterations.
**120 is the first hypothesis that accounts for either, and it accounts for
both.**
## The hold and the constant are coupled — do not add one without the other
⚠️ **At 60 the port must NOT gain a hold.** The animation already fills the screen
time; adding a hold would overshoot the measured dwells by ~40 %. The missing
hold is only a defect *if* 120 is right. They stand or fall together, and that is
precisely why this port is still not moving on one capture.
## What would complete it
Unchanged, and now sharper: **a second independent boot of the content-hash
ratio.** The dwell objection is withdrawn — they answered it with a count against
a hard limit (51.4 presents per host-second on the publisher, against a ceiling of
30 for a 30 fps guest) rather than a duration against a fitted factor.
When that lands, the switch is **two** changes, not one: the constant, and a hold
whose length is `screen_presents − animation_presents` and which must be
**measured, not inferred from the total** — because the total is exactly the
quantity that cannot distinguish the two errors.
---
# 🔴 WITHDRAWN: "the port has no hold" and "two errors that cancel"
Both were mine, both were last iteration's headline, and both are wrong.
## What I misread
The Decoder gave a split — *"219 presents on screen, ~128 animating"* — and I
read it as a hold **outside** the declared timeline, which the port would then be
missing. **It is a split *within* the timeline.** The declared group is ramp +
hold + fade, and the hold is the largest part of it:
```
publisher palogo_sqex: 0:a=0 15:a=0 30:a=255 235:a=255 239:232 251:32 255:0
ramp 0→30 = 30 units
HOLD 30→235 = 205 units ← 80.4 % of the screen
fade 235→255 = 20 units
```
## The port plays it. Measured, not read
Frozen samples across the publisher splash, logo region:
| t | units | region mean |
|---|---|---|
| 0.25 s | 15 | 0.390957 |
| 1.00 s | 60 | **0.405488** |
| 2.00 s | 120 | **0.405488** |
| 3.00 s | 180 | **0.405488** |
| 3.80 s | 228 | **0.405488** |
| 4.20 s | 252 | 0.038142 |
**Identical to six decimals across 168 units.** The port holds, for 80 % of the
screen, exactly as declared.
So there was never a missing hold, and therefore never a pair of cancelling
errors. **`authored/timing.json`'s "the pacing was right all along" was right all
along**, and my paragraph casting it as a possible coincidence is withdrawn there
too.
⚠️ The failure is worth naming because it is not the usual one: I did not
mis-measure anything. **I took a two-part split from someone else's instrument and
assumed the boundary was where my own model put it.** Presents are not units, and
"animating vs holding" in presents does not decompose the same way as "ramp vs
hold" in declared units.
# ✅ And the port's own data is an independent leg for the time-based clock
The Decoder's mechanism: `units/present` halved when the present rate doubled
while `units/second` did not move, so the UI clock advances by elapsed **time**,
not by frame count — and *"2 units per frame"* was a property of a 27 fps capture
rather than of the game.
**The frame-rate work of two iterations ago tests exactly that, and I did not
notice at the time.** The same splash, measured across a 4× change in the port's
own rendering rate:
| | frame rate | dwell | implied units/s |
|---|---|---|---|
| llvmpipe | 17.3 fps | 4.28 s | 59.6 |
| llvmpipe | 19.6 fps | 4.26 s | 59.9 |
| llvmpipe | 25.0 fps | 4.27 s | 59.7 |
| **GPU** | **69.4 fps** | **4.26 s** | **59.9** |
**Frame rate varies 4.0×; the dwell varies by 0.5 %.** That is the signature of a
time-based clock, and it puts the rate at 59.6–59.9 every time.
🔴 **This is a weaker leg than it looks and I am labelling it rather than
counting it.** It shows *the port's* clock is time-based — which it is by
construction, `time_units += delta * units_per_second` — so it cannot be evidence
about the game's. What it does show is that the **dwell figures I supplied are
frame-rate-independent measurements**, not artefacts of whatever rate a run
happened to hit. That is the property their argument needs of them, and it is now
established from this side rather than assumed.
# Where finding 3 stands
**Open, with no surviving named cause.** Units-per-second is eliminated in favour
of the value the port already ships; every other candidate died earlier. The
clock origin remains untouched, and every quantity in the resolved account is a
ratio or a count, so a common offset survives all of it.
---
# ✅ Settled: the game is frame-based, the port is time-based, and that is correct
`--framerate_limit=30` — the run this page asked for — refuted the time-based
reading on every discriminating row, **against its author's own expectation**:
| | predicted if time-based | **measured at 30 fps** |
|---|---|---|
| modal alpha step | 34 | **17** (unchanged) |
| units/second | 60 | **30.2** (halved) |
| publisher dwell | 4.25 s | **8.450 s** (doubled) |
Both controls passed *first*: the limiter demonstrably took effect (28.4
presents/host-s against 51–55, interval mass moving to two vblanks, 422 of 468),
and all 8 splash quad rects were identical, so nothing but the frame rate
differed. **`255 × 1 / 15 = 17`** at 28.4, 51.4 and 54.8 presents/s alike.
## What that changes for the port: nothing — but for a reason worth writing down
The game advances **1 unit per presented frame**. This port advances
`time_units += delta * units_per_second`. **They are different mechanisms that
agree at exactly one frame rate: 60 Hz** — which is the only rate the console
ever asked the game to be right at.
🔴 **Do not make the port frame-based to match the game.** A time-based port
reproduces a 60 Hz console on hardware that is not 60 Hz; a frame-based one would
drift on every machine that is not — and this port has measured *itself* between
**9.7 and 69.4 fps** depending on the renderer. Matching the game's mechanism
would import a fragility the game never had to survive, because the game only
ever ran on one box.
## 🔴 But it sharpens what `60` is claiming, and makes it falsifiable
If units/second **is** the present rate, then `keyframe_units_per_second = 60` is
no longer "the unit is 1/60 s". It is:
> **the game presented these screens at 60 Hz on the console.**
That is a harder claim and a checkable one. **It is also supported for the first
time:** Canary unlimited presents at 51–55 Hz and the splash dwell is
4.30 / 4.60 / 4.37 s over three cold boots. A natively 30 Hz game would present
at ~30 in Canary too — the `--framerate_limit` run proves it, since forcing 30
made that same splash take 8.45 s. **It does not take 8.45 s unforced.**
⚠️ Still `authored`, not promoted to `measured`: this is inference over three
measurements rather than a measurement of units per second. It becomes `measured`
when someone reads the console's present rate for these screens directly.
## And it closes the constant as a cause of finding 3 — in the direction that matters
| console rate | units/s | plate `t=236` |
|---|---|---|
| 30 Hz | 30 | **7.87 s** |
| **60 Hz (shipping)** | **60** | **3.93 s** |
Under the frame-based model the only alternative is 30 Hz, and it puts the plate
**later**, not earlier. **There is no console present rate that makes the plate
arrive sooner than this port already shows it.** The human reported it arriving
late; no value of this constant can produce that.
## The method note, and it is theirs
> *Four of my positions on this number were inference over a measured quantity;
> this one changed an input and watched what moved.*
The opportunistic comparison — two captures that happened to differ — pointed
**exactly the wrong way**, because nothing controlled what else differed between
them. One designed capture settled it against its author's expectation. That is
the difference between an observation and an experiment, and it cost this pair
five positions in a day to relearn.

View File

@@ -0,0 +1,401 @@
# `verify-screen` DIFFERS on six more screens — it is ADDITIVE, and the port is ahead of the reference
**Status:** ✅ **RESOLVED.** The reference gained an additive path
(`formats-pin-2026-09-01b`) and the divergence this page is about collapses **6×**.
Everything below stands; the last section is the measurement that closes it. Port `HEAD` `da7864e` + this
commit; HANDOFF at `9ca1eb5`.
> 🔴 **This page said, in its first version, that the cause was a blend-SPACE
> divergence — one renderer linearising and the other not. That was wrong, and
> the way it was wrong is the useful part.** Both renderers demonstrably blend in
> the encoded space. The transfer curve I built to support it averaged Godot's
> value per *reference value* bucket, which collapsed a **bimodal** population —
> a large majority differing by ≤1 level and a minority differing by 40+ — into a
> smooth-looking curve that resembled gamma and was an artefact of the binning.
> A mean over a mixed population is not a transfer function.
## The measurement that settles it
53 % of pixels agree to within **1 level** and 69 % to within 3. The rest are not
spread over a curve; they are **concentrated in the middle of the frame**:
```
|delta| 0 : 134 969 (cum 14.6 %) > 3 : 282 166 px = 30.6 %
|delta| 1 : 355 261 (cum 53.2 %) bbox: the whole frame, but
|delta| 2 : 103 348 (cum 64.4 %) occupancy is ~zero in columns
|delta| 3 : 45 856 (cum 69.4 %) 0,1,6,7 of 8 and heavy in 2..5
|delta| 40+: 16 844 (cum 100 %)
```
The ≤1 tier is integer truncation against float rounding — the reference computes
`(sc*sa + dc*(255-sa)) / 255` in `u32` and truncates; Godot rounds. The 30.6 %
is something else, and it is in the columns where the menu's content sits.
## What it is
**The port draws some elements ADDITIVE. The reference has no additive path at
all.**
`crates/sylpheed-formats/src/ui_layout.rs` has exactly two blend sites, lines
1072 and 1174, and both are alpha-over:
```rust
canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8;
```
and line 1169 carries the reason — an *"ADDITIVE selector and REFUTED — it moved
every metric against the …"*. The reference tried additive, refuted it against
its own composite metrics, and does not do it.
`authored/rendering.json` gives the port an additive set per screen, and its
`why` records that this is **transcribed, not authored**: the port proposed
additive from a two-background composite solve, kept it a proposal because
nothing on the disc selects a blend mode, and adopted it only when the Decoder
logged **`RB_BLENDCONTROL0` per draw in Canary** and drove the game to both
screens.
So the two renderers disagree **on purpose**: one implements a measurement of the
game's blend register, the other implements a refutation made from composite
metrics before that measurement existed.
## The prediction this makes, and it holds
If the divergence is the additive set, its size should scale with how many
elements are in that set. It does:
| screen | additive elements in `authored/rendering.json` | mean diff |
|---|---|---|
| `extras` | **9** — `pteff10 pteff20 ptframe3 ptframe4 pteff21 pteff22 pteff23 ptloop01 ptloop02` | **6.7422** |
| `main_menu` | **5** — `pteff12 ptframe1 ptframe2 ptloop01 ptloop02` | **3.9363** |
| `main_menu_jp` | **0** — not in the map | 0.7885 |
| `extras_jp` | **0** — not in the map | 0.6592 |
| `title` | **0** — present, deliberately empty | 0.4431 (the known sweep residual) |
Nine beats five beats zero, in order, with the two zero-rows an order of
magnitude below the two non-zero ones. That ordering was not fitted; the additive
map was written before this comparison existed.
## And which side is right: the port, on the evidence there is
* the additive set is a **measurement off the running game** — the blend control
register, per draw — and the reference's alpha-over is a refutation from
*renderer metrics*, which the protocol ranks below a capture;
* scored against `docs/re/captures/title-builds/live-main-menu.png`, Godot is
**RMSE 3151.96** and the reference **3769.61** — the port is 16 % closer.
⚠️ **That second line is an ordering and nothing more.** `verify-screen` poses
`--pose=rest` and its own header is emphatic that such a frame must never be
scored against a capture — that mistake produced a published finding once
already. Both sides carry the same pose contamination, so *which* is nearer is
still meaningful; *how near* is not. Nobody should quote 0.0481 as the port's
fidelity.
## The blend SPACE, separately: both are encoded, and so is the game
Worth keeping even though it turned out not to be the cause, because it closes
`BLOCKED.md` H4 and it was measured rather than recalled.
**Godot, measured with a control** — a white quad over black at three known
modulate alphas, through the port's own texture path
(`load_png_from_buffer` → `ImageTexture`), with the port's `[rendering]` settings:
| declared alpha | encoded-space prediction | linear-then-re-encode prediction | **measured** |
|---|---|---|---|
| 64 | 64 | 138 | **64** |
| 128 | 128 | 188 | **128** |
| 192 | 192 | 225 | **192** |
Pre-registered before running; exact on all three, with the alternative excluded
by 33–74 levels.
**The reference:** integer arithmetic straight on 8-bit values, no linearisation
anywhere in the file.
**The game:** the Decoder's `blend-space-rt-format.txt` — `RB_COLOR_INFO`
`color_format` is `k_8_8_8_8` on 2402/2402 splash draws and 33779/33791 of the
boot-to-title capture, `k_8_8_8_8_GAMMA` appears **zero** times, and
`color_exp_bias` is 0 throughout. `k_8_8_8_8_GAMMA` is the only format around
which Canary applies a gamma↔linear conversion.
**All three agree. Blend space is not a difference between anybody here**, and
the port needs no change for it.
## The other four rows — localised, and two hypotheses died getting there
`check-all` still fails on these four, and the allowance was not widened to
cover them. What follows is where they are, not yet why.
### 🔴 Hypothesis 1, refuted: a blend-space divergence
Covered above. Killed by a control on my own renderer.
### 🔴 Hypothesis 2, refuted: the port renders `rotation_deg` and the reference does not
This one looked strong. `ui_layout.rs:86` says so in its own words —
*"`rotation_deg` is decoded but NOT rendered"* — and the port does render it. A
census over all sixteen exported screens gave:
> **Every screen with a non-zero rotation at rest DIFFERS, and every screen with
> none agrees** — 6 of 6 either way, including both legacy rows.
**It is still wrong.** Widening the rule to *any* rotating element — a non-zero
rest rotation **or** a two-keyframe 360° spinner at any depth — breaks it:
`build_00` and `build_01` carry two spinners each and **agree** with the
reference. Asked directly, the port's own draw log says it draws both spinners
on those screens. So rotation is present, rendered, and produces no difference.
A rule that holds on 14 of 16 and fails on the two cases nobody had looked at is
the shape of a rule fitted to the rows it was built from. Recorded because the
next person will find the `ui_layout.rs:86` comment and reach for it too.
### ✅ What is actually established: `build_12` and `build_15` are ONE element
All **951** differing pixels lie inside `pgloading_loop5`
(`pgloading_ring.png`, 333×276 at `[1,444]`), and the diff's own bounding box —
`x 69..301, y 478..710` — sits entirely within it. No other element's rectangle
contains a differing pixel that this one does not.
And that closes the `build_00` / `build_01` question without any rule about
rotation: the port's draw log shows those two screens draw **7** elements and
`build_12` draws **10**. `pgloading_loop5` is one of the three extra. The
loading screens that agree are the ones that never draw the element the
disagreement is in.
Why the two renderers disagree *on that element* is not settled. It is small —
max 17, mean 0.0368 — and the element is unusual: its top-level keyframes hold
`a=0x7f` for eight units around `rest.t = 24`, while its **leaf** record
expands `pgloading_ring` from `scale 0` to `1000` over t=30…130. The port's
`draw_leaf_for` lists only `ptloop01` and `ptloop02`, so the port draws the
element and not that leaf. Whether the reference does the same is the open half.
### 🟡 `main_menu_jp` and `extras_jp` — consistent with the sweep leaves, not established
Both carry `ptloop01/pteff03` (rot 30, at x=1521) and `ptloop02/pteff03a`
(rot −45, at x=−839) — the **same sweep leaves** whose phase residual is the
already-named reason for `title`. Their means sit with `title`'s and nowhere
near their own EN twins':
| | mean |
|---|---|
| `title` (sweep residual, named since P1) | 0.4431 |
| `extras_jp` | 0.6592 |
| `main_menu_jp` | 0.7885 |
| `main_menu` (5 additive) | 3.9363 |
| `extras` (9 additive) | 6.7422 |
That is consistent with the JP rows being the sweep residual alone, with no
additive contribution — which is what the port's own map implies, since it lists
no additive set for them. **Consistent with, not established:** nobody has
isolated the sweep leaves on those two screens.
## ✅ H6 update — the asymmetry has a better answer than a measurement
The Decoder's reply: the blend is a **decoded disc field**, `T8aD +0x04` bit
`0x02` (set ⇒ additive, clear ⇒ premultiplied alpha-over), with a disc-wide check
and a surviving out-of-sample prediction. So the port can *derive* the blend per
element on every screen instead of transcribing a table, and the JP question
answers itself statically — no boot needed.
**Blocked on one thing:** `sylpheed-formats` does not expose `+0x04`.
`ui_layout::Element` surfaces `kind` (`+40`), `parent` (`+32`), pivot, keyframes
and `focus_link`, and nothing at `+0x04`; and `crates/sylpheed-export` consumes
formats by git **tag**, not by workspace path. Asked as `BLOCKED.md` H6.
🔴 **And a negative worth having, because it is the obvious thing to try:
`kind_raw` in this export is NOT that field.** Its bit `0x2` against the additive
map over four screens is **anti-correlated** — 0 of 14 mapped elements have it
set, and 9 unmapped ones do (`0x3002` on every button, `0x0` on every element the
map lists). Anyone reaching for `kind_raw & 2` will get the additive set exactly
inverted.
## 🔴 The asymmetry, until that lands
`main_menu_jp` contains **exactly the elements** `main_menu` marks additive —
`ptloop01 ptloop02 ptframe1 ptframe2 pteff10 pteff12` — and `extras_jp` contains
all nine of `extras`'. The port draws them **alpha-over** on the JP screens and
**additive** on the EN ones, purely because `authored/rendering.json` is keyed by
screen name and the Decoder's `RB_BLENDCONTROL0` log was taken on the EN screens.
**The port is therefore asserting, by omission, that the JP build blends the same
elements differently — and that is the less likely of the two possibilities.**
Extending the map would extrapolate a measurement onto a build nobody drove to,
which is not the port's to do; leaving it silent asserts the surprising thing by
default. So it is made explicit in `authored/rendering.json` and asked in
`BLOCKED.md`, and the map is **not** extended.
⚠️ It does not affect the deliverable: MISSION §7 puts localisation beyond
English out of scope, and the JP screens are not in the boot path.
## Why `check-all` stays red
Four rows are **not** explained by the additive set:
| screen | mean | over3 | note |
|---|---|---|---|
| `main_menu_jp` | 0.7885 | 3 248 | not in the additive map, yet differs |
| `extras_jp` | 0.6592 | 3 163 | same |
| `build_12` | 0.0368 | 462 | tiny, and localised — diff bbox `464x266+60+454` |
| `build_15` | 0.0368 | 462 | identical figures to `build_12` |
`build_12` and `build_15` producing byte-identical statistics suggests one shared
element rather than two coincidences. None of the four is diagnosed and none is
excused.
## What changed in `check-all`
The allowance is now **derived** rather than listed: a screen may differ if it
has a non-empty additive set in `authored/rendering.json`, plus the two named
legacy rows. That is strictly stronger than the hard-coded list — a screen that
differs *without* additive elements now fails, which a literal list could not
express, and the allowance cannot go stale against the map it is computed from.
## What this does not claim
* That the reference is wrong to have refuted additive from its own metrics. It
is superseded by a capture, which is a different thing.
* That the port's additive set is complete. It covers three screens; nobody has
logged the register on the rest.
* Anything about the four undiagnosed rows.
---
# 🔴 Re-validated on the GPU, and my prediction failed: these numbers are rasteriser-specific
The human activated a hardware GPU on 2026-09-01. **Every number on this page
above was measured under `llvmpipe`**, so they needed re-deriving before anyone
builds on them.
## Pre-registered (R2)
> Both renderers blend in encoded 8-bit space, so if the port's drawing is
> rasteriser-independent the diffs should be **identical, or within 1 level**.
> Anything materially different means a published conclusion here was
> GPU-specific.
## It failed
| screen | llvmpipe mean | **GPU mean** | change | max (llvmpipe → GPU) |
|---|---|---|---|---|
| `title` | 0.4431 | **0.5936** | **+34 %** | 41 → 41 |
| `main_menu` | 3.9363 | **4.1449** | +5.3 % | 97 → 97 |
| `extras` | 6.7422 | **6.9757** | +3.5 % | 113 → 113 |
| `title_jp` | 2.7715 | **2.9448** | +6.3 % | 233 → 233 |
| `main_menu_jp` | 0.7885 | **1.0157** | **+29 %** | 26 → 27 |
| `extras_jp` | 0.6592 | **0.8906** | **+35 %** | 26 → 26 |
| `build_12` / `build_15` | 0.0368 | **0.0454** | +23 % | 17 → 17 |
**Every mean rose, by 3–35 %.** So the diffs are *not* rasteriser-independent
and the prediction was wrong.
## What survives, and what does not
🔴 **The maxima are unchanged** — 41, 97, 113, 233, 17 identical, and 26 → 27 on
one row. The large differences are exactly where they were.
That is the shape of a **rounding population growing, not content moving**:
llvmpipe and the NVIDIA rasteriser round the last bit of a blend differently, so
the ≤1-level tier grows while the elements that genuinely differ do not move at
all. Consistent with both still blending in encoded space — which the control on
this page established for Godot generally, not for one rasteriser.
**What survives:**
* the **additive diagnosis**, because it rests on an *ordering*, and the ordering
holds on the GPU: `extras` 6.98 > `main_menu` 4.14 > `extras_jp` 0.89 and
`main_menu_jp` 1.02 > `title` 0.59 — nine additive elements, then five, then
none;
* the **`pgloading_loop5` localisation**, which is a bounding box;
* the **`build_00`/`build_01` agree** result — still 0 pixels over the bar;
* the **derived allowance**: the failing set is the same four rows.
**What does not, and is now labelled:**
* the histogram (*53 % within 1 level, 16 844 over 40*) was llvmpipe-specific and
the ≤1 tier is larger on the GPU;
* every absolute **mean** quoted above this section;
* the **RMSE-vs-capture** pair (3151.96 / 3769.61) was llvmpipe. The *ordering*
claim — the port is nearer than the reference — has not been re-derived on the
GPU and is not claimed here until it is.
## The rule this earns
**A renderer comparison carries its rasteriser as a hidden parameter.** Nothing
in this corpus recorded which one produced a diff, and for eight months there was
only one so it never mattered. Any diff quoted from here on should say what drew
it — the same discipline `TEMPORAL-VERIFICATION.md` already demands for capture
rate, applied to the thing that rasterises rather than the thing that clocks.
---
# ✅ Closed: the reference can draw additive now, and the divergence collapses 6×
The Decoder taught `ui_layout::blit` the additive blend at
`formats-pin-2026-09-01b`, on the argument this page made — that the comparison
was **structurally incapable**, and that the refutation which had kept additive
out was `⟨render-vs-capture⟩`, i.e. that renderer disagreeing with itself while
it had a stale keyframe association, no leaf geometry and no rotation.
**Measured without pulling their branch into mine**: a detached worktree at the
tag, `sylpheed-cli` built there, and `verify-screen` pointed at it through
`SYLPHEED_CLI`. My branch is untouched and the reference on `main` is unchanged.
## Pre-registered (R2)
> With the reference finally able to draw additive, the diffs caused by that gap
> should collapse. `main_menu`/`extras` and their JP twins should fall sharply
> from ~7, and whatever remains is a *different* cause.
| screen | alpha-over reference | **additive reference** | factor |
|---|---|---|---|
| `main_menu` | 7.2580, max 105 | **1.2068, max 28** | **6.0×** |
| `main_menu_jp` | 7.3440, max 108 | **1.2111, max 31** | **6.1×** |
| `extras` | 6.9757, max 113 | **1.0229, max 28** | **6.8×** |
| `extras_jp` | 7.0734, max 115 | **1.0255, max 30** | **6.9×** |
| `title` | 1.0335, max 88 | **0.5685, max 41** | 1.8× |
| `title_jp` | 4.4944, max 233 | **2.8225, max 233** | 1.6× |
| `build_12` / `build_15` | 0.0772, max 60 | **0.0463, max 17** | 1.7× |
| `build_00` / `build_01` | 0.0676, max 60 | **0.0366, max 4** | **DIFFERS → OK** |
**`build_00` and `build_01` stop differing entirely** — `over3` 3 422 → **0**.
## And the twins agree to a third of a percent
| | |
|---|---|
| `main_menu` 1.2068 vs `main_menu_jp` 1.2111 | **0.36 % apart** |
| `extras` 1.0229 vs `extras_jp` 1.0255 | **0.25 % apart** |
Under the name-keyed map these pairs were 4.1× and 7.8× apart. Nothing was fitted
to make that happen — the locale twins converged first when the *port* took the
decoded field, and again now that the *reference* draws the same blend.
## The residual is smaller and its causes are the documented ones
* `title_jp` 2.82 at max 233 remains the largest, and its named reason —
`--pose=rest` sparkle handling — is untouched by any of this.
* `title` 0.57 at max 41 is the `ptloop` sweep-phase residual, also untouched.
* `main_menu`/`extras` and twins now sit at ~1.0–1.2, max ~28–31. **New, small,
and undiagnosed** — additive was the dominant cause and not the only one.
* `build_12`/`build_15` do **not** return to their pre-change 0.0368: they land at
0.0463 with both renderers drawing `pgloading_loop5` additive. So that element
carries a small residual of its own beyond the blend.
## 🔴 What must happen to `check-all`, and why it has NOT happened yet
The allowance widened when the reference could not draw additive. **That
justification is gone**, so the allowance should be narrowed back and the check
should regain its teeth.
**It is not narrowed in this commit, deliberately.** `check-all` builds the
reference from the **workspace** `crates/sylpheed-formats`, and the additive path
is at a tag that has not landed on `main`. Tightening now would turn `check-all`
red against a reference that still cannot draw additive — a wall of failures
meaning one thing, which is the exact defect the display guard was added for.
**The trigger is mechanical**: when the additive path is on `main`, drop the
export-derived clause and leave the two named legacy rows. The set that should
then differ is measured above and is `title`, `title_jp`, `main_menu`, `extras`,
`main_menu_jp`, `extras_jp`, `build_12`, `build_15` — with `build_00`/`build_01`
expected to pass.