Merge remote-tracking branch 'origin/main' into auto/frame-blend-draw-path
Some checks failed
CI / Native — linux (pull_request) Failing after 11m25s
CI / WASM — Web (pull_request) Successful in 29m52s
CI / Formatting (pull_request) Failing after 1m5s

# Conflicts:
#	crates/sylpheed-cli/src/main.rs
This commit is contained in:
sylph-decoder
2026-09-11 19:23:13 +00:00
239 changed files with 40899 additions and 2988 deletions

424
docs/agents/GITEA-SETUP.md Normal file
View File

@@ -0,0 +1,424 @@
# Runbook: standing the Gitea working surface up
**For the human. Work top to bottom — later phases depend on earlier ones.**
[`WORKFLOW-gitea.md`](WORKFLOW-gitea.md) says *what* this is and why; this says
*how*, in order, with a check after each phase.
Steps are marked **👤 you** (a decision or a credential only you can make) or
**🤖 me** (I do it once you have unblocked it).
## Where things stand
**Updated 2026-09-04, against the live instance.** Phases 14 and 6 are done.
| phase | state |
|---|---|
| 1 · identities | ✅ `sylph-decoder`, `sylph-port`, both collaborators at **Write** |
| 2 · protection | ✅ applied and **verified behaviourally** — a real push to `main` was refused with `pre-receive hook declined`, as the repository owner |
| 3 · tokens | ✅ three, each functionally probed: right identity, `403` on `branch_protections` for both agents |
| 4 · labels | ✅ 11 labels, 4 milestones, idempotence confirmed by a second run creating nothing |
| 5 · MCP | ⏳ **written and merged; the images are NOT rebuilt.** This is the remaining blocker |
| 6 · items | ✅ 9 issues seeded with 3 dependency edges, read back. All `state/proposed`**awaiting the human's approval of the shapes** |
| 7 · restart | ⏳ after the rebuild |
⚠️ **Do not start an agent before Phase 7.** Until the images are rebuilt, the
briefs tell it to read notifications and open issues with no tool that can.
📌 **This block goes stale first.** It was already wrong once — it read "nothing
exists on the instance" while nine issues were live. If it disagrees with
`gitea-protect --verify` or the issue list, believe those: they measure, this
remembers.
---
## Phase 1 · Identities 👤
The agents currently push as `fabi`, using your credential. That is the defect
this phase fixes, and it is not cosmetic: **Gitea does not let the author of a
pull request approve it.** While an agent *is* you, either you cannot approve its
PR or it can approve its own — and there is no third possibility. The review gate
does not exist until the agents are distinct people.
Two more reasons, once you are there anyway: 495 commits of decoder work are
currently attributed to **your** email, so blame is wrong; and separate
identities mean revoking one agent does not touch the other or you.
**1.1 — Create two users.** Site Administration → Identity & Access → User
Accounts → *Create User Account*.
| | |
|---|---|
| usernames | `sylph-decoder`, `sylph-port` |
| email | anything you control and can tell apart — `you+decoder@…`, `you+port@…` |
| "require password change on first login" | **off** — they never log in interactively |
**1.2 — Add both to `fabi/Sylpheed` as collaborators.** Repo → Settings →
Collaborators → add each → permission **Write**.
🔴 **Write, not Admin.** Admin can edit branch protection, which would let an
agent remove the rule that stops it merging.
> **Check:** the repo's Collaborators list shows both, each reading `Write`.
---
## Phase 2 · Branch protection 👤
**Do this before the agents hold tokens**, so there is no window in which they
can push to `main`.
**Apply it through the API, not the form**`tools/gitea-protect`. Six settings
of which two are load-bearing, and both of those were missing from the first
draft of this phase: that is the shape of thing that gets mis-clicked. An API
call is reviewable in a diff and repeatable, and the same file re-checks it later.
```bash
tools/gitea-protect --dry-run # the exact rule, no credential read
tools/gitea-protect # create or update, then verify
tools/gitea-protect --verify # assert it still holds; exit 1 if not
```
📌 **Run it on the agent box, not the Pi.** Branch protection is a
repository-scope endpoint, so `~/.sylph-gitea-api-token` cannot do it — that
token is deliberately issue-only. The credential that can is the one already
sitting on that machine, `~/.sylph-git-credentials`, which the tool reads. Doing
it there means no new credential, and no second machine holding push rights just
to close a one-time setup step.
🔴 The tool sets `block_admin_merge_override: false`, deliberately. Turning it on
would lock **you** out of your own work — approvals are whitelisted to `fabi`,
Gitea will not let `fabi` approve a `fabi` PR, so a human-authored PR could never
reach one approval and could never merge. The admin override is what keeps that
door open, and it is not a hole in the agent gate for exactly one reason: the
agents are **Write, not Admin**. That is what Phase 1.2 is buying, and this is
where it gets spent.
Or by hand — Repo → Settings → Branches → *Protected Branches* → add rule for
`main`:
| setting | value | why |
|---|---|---|
| Enable Push | **off** | nothing reaches `main` except through a PR |
| Require approvals | **1** | the human gate, made native |
| Dismiss stale approvals | **on** | an approval must describe the code that merges |
| Block merge on rejected reviews | **on** | "changes requested" has to mean something |
| Enable Merge Whitelist | **on** → whitelist **`fabi` only** | approvals are not the last gate. *Merging* is |
| Enable Approvals Whitelist | **on** → whitelist **`fabi` only** | only a human's approval counts toward the 1 |
### 🔴 The hole that separate identities open, and why the last two rows close it
Phase 1 makes the agents distinct people so that a human *can* approve their
work. The same change makes something else possible for the first time: **Gitea
refuses to let an author approve their own pull request — it does not stop
`sylph-decoder` approving `sylph-port`'s.** With `required_approvals = 1` and
nothing else, the two agents satisfy the human gate between themselves, and the
author can then press Merge, because branch protection blocks *pushes* to `main`
and never blocked *merges*.
Neither whitelist is decoration, and neither replaces the other:
* **approvals whitelist** — an agent's approval stops counting toward the 1.
* **merge whitelist** — even a legitimately approved PR is merged by you.
Withholding the review tool from the agents (Phase 5) is defence in depth behind
these, not a substitute: an agent still has a browser-shaped API token.
### 🔴 What this rule does not gate, said plainly
It binds everyone who reaches Gitea through the API or the web. **It does not
bind anyone who can run `gitea admin` inside the container** — and that includes
the supervising agent on the Pi, the one that created the agent accounts and
minted their tokens. From that shell you can issue an admin token or edit this
rule, and nothing here would stop you.
That is not a hole to plug here; it is the boundary of what Phase 2 buys, and it
should be written down rather than discovered. **Phases 1 and 2 gate the two
containerised agents** — the ones that run unattended on a loop, whose whole
design assumption is that policy lives somewhere they cannot reach. A supervisor
with a shell on the host is not in that set, and the protection above should not
be read as universal.
The distinction is exactly the one Phase 1.2 draws with **Write, not Admin**: the
looping agents get a permission level that cannot edit the rule that binds them.
`tools/gitea-protect --verify` asserts that level on every run, which is the
check that keeps this true rather than merely stated.
> ### Check — and actually run it, do not assume it
>
> The whole point of putting this in protection rather than in a document is
> that it does not depend on anyone's good behaviour. So verify it the same way:
>
> 1. As `sylph-port`, push a throwaway branch and open a PR into `main`.
> 2. Confirm **no Merge button** is offered to that account.
> 3. Confirm **you** can approve it, and that *it* cannot approve itself.
> 4. Approve it yourself, then look at `sylph-port` again: **still no Merge
> button**, now that an approval exists. This is the step that tests the
> merge whitelist rather than the absence of an approval — without it, steps
> 2 and 3 pass on an instance where the agents can merge each other's work.
> 5. As **yourself**, try `git push origin main` with a throwaway commit. It
> should be **refused** — see below.
> 6. Close the PR, delete the branch, drop the commit.
>
> If step 2 or step 4 offers a Merge button, stop — the rest of this runbook
> assumes neither does.
### ⚠️ Your own pushes to `main` stop too
Not a side effect — the rule working. `enable_push: false` compiles to
`CanUserPush`, which in Gitea's `models/git/protected_branch.go` returns false
with **no bypass for repository admins or the owner**:
```go
if !protectBranch.CanPush {
return false
}
```
Three commits reached `main` by direct push on the day this was written, so the
first time you notice will be the first time you reach for it. From Phase 2 on,
**human changes go through pull requests like everything else** — and merging
them is what the admin override above is for. `--verify` asserts this state
rather than tolerating it: a verifier that excused your push would be excusing
the gate.
---
## Phase 3 · Tokens 👤
Three principals, three tokens. Settings → Applications → *Generate New Token*
while logged in **as that user**.
| whose | scopes | goes in | on which machine |
|---|---|---|---|
| **you** (`fabi`) | `write:issue`, `read:repository` | `~/.sylph-gitea-api-token` | **the Pi** |
| `sylph-decoder` | `write:repository`, `write:issue`, `write:notification`, `read:user` | `~/.sylph-gitea-token-decoder` | the agent box |
| `sylph-port` | same four | `~/.sylph-gitea-token-port` | the agent box |
📌 **Three machines, and the split is by tooling, not by capability.** Gitea runs
on the Pi, published through a VPS — so `git.mc02.dev` resolves to a hosted
address and a DNS lookup tells you nothing about the origin. The agent
containers run on the x86_64 desktop, which reaches the Gitea API perfectly well
(`GET /api/v1/version``200 {"version":"1.25.5"}`, run from there).
The `fabi` token lives on the Pi because that is where `tools/gitea-setup` runs,
and that is where the session driving Phases 4 and 6 sits. It is **not** a
reachability constraint, and an earlier draft that said so was wrong.
```bash
printf '%s\n' '<token>' > ~/.sylph-gitea-api-token && chmod 600 ~/.sylph-gitea-api-token
```
⚠️ **Never paste a token into chat.** The files are mounted read-only into the
containers, exactly like `~/.sylph-claude-token`.
📌 The existing `~/.sylph-git-credentials` is scoped `write:repository` and is
**refused by every issue endpoint** — verified, not assumed:
`required=[read:issue], token scope=write:repository`. It stays as it is; these
are additional.
🔴 **Do not add `write:repository` to the `fabi` token**, even though Phase 2's
API path might look as though it needs it. **A `write:repository` token *is* a
push credential** — that is the scope git checks for receive-pack — so adding it
would give the Pi push rights over `main`, in order to avoid giving the Pi push
rights. `gitea-protect` sidesteps it entirely by running on the agent box
against the credential already there. This warning exists because that advice
was given, in chat, by the same author as this file.
> **Check:** `tools/gitea-setup --dry-run` prints "would create …" rather than a
> scope error.
---
## Phase 4 · Labels and bundles 🤖
```bash
tools/gitea-setup --dry-run # read it first
tools/gitea-setup # idempotent; safe to re-run
```
Creates 11 labels — 5 `state/*`, 2 `agent/*`, 4 `kind/*` — and 4 milestones
(Menus, Title screen, Graphics pipeline, Infrastructure).
**No Kanban board yet, on purpose.** Gitea's board does not follow labels, so it
would be a second copy of the state to keep in sync by hand — which is the exact
failure that produced a 1,227-line `BLOCKED.md`. **Labels are the truth**; a
saved issue filter gives the same view for nothing. Add a board later if the
filter turns out to be insufficient.
> **Check:** the Issues page offers the `state/*` labels, and Milestones lists
> the four bundles.
---
## Phase 5 · The MCP server 🤖
**Done — in the tree, not yet in an image.** `gitea-mcp` **v1.7.0**, Linux
x86_64, sha256 `bbc9a7b4…d446b8d` from the release's own `checksums.txt`. The
flags are no longer taken on trust: the arm64 build of the same release was run
and its `--help` read, so `-t stdio`, `-H <url>`, `-O/--tools`, `-S/--scope`,
`-r/--read-only` and `GITEA_ACCESS_TOKEN_FILE` are confirmed, not assumed.
Three edits per image, made:
1. **`Dockerfile`** — fetch the release tarball, verify the checksum, unpack
`gitea-mcp` into `/usr/local/bin`, and run `--version` at build time so a bad
pin fails the build rather than the agent.
2. **`entrypoint.sh`** — register it at user scope for that agent's identity,
remove-then-add so a restart is idempotent:
```bash
claude mcp add -s user gitea -e "GITEA_ACCESS_TOKEN_FILE=$GITEA_TOKEN_FILE" \
-- gitea-mcp -t stdio -H https://git.mc02.dev -O "$GITEA_MCP_TOOLS"
```
🔴 **By path, not by value.** The earlier draft of this line read
`GITEA_ACCESS_TOKEN=$(cat …)`, which writes the token in cleartext into
`~/.claude.json` — read by every session in the container and carried into any
copy of that file. `GITEA_ACCESS_TOKEN_FILE` is new in the version we pin and
leaves the secret in its read-only mount.
3. **`sylph-decoder` / `sylph-port`** — mount `~/.sylph-gitea-token-{decoder,port}`
read-only and pass its path. Inert until the file exists: without a token the
container still starts, says plainly that the agent has no issues and no pull
requests, and carries on.
**👤 Yours:** rebuild both images on the agent box, where the containers run.
⚠️ `CARGO_BUILD_JOBS=4` and a limited `-j`; a full-parallel build has OOM-crashed
that machine.
```bash
docker/decoder/sylph-decoder build
docker/port/sylph-port build
```
### The tool filter is a control now, not an experiment
The tool names were unknown when this was written; they are in the release's
README, and the set each agent gets is pinned in the entrypoint
(`SYLPH_GITEA_TOOLS` overrides it):
```
get_me, notification_read, notification_write, list_issues, issue_read,
issue_write, attachment_read, search_issues, label_read, milestone_read,
list_pull_requests, pull_request_read, pull_request_write
```
What is **absent** is the point:
* **`pull_request_review_write`** — the tool that approves, dismisses and
resolves reviews. Without it an agent cannot approve the *other* agent's pull
request through the MCP. Pair it with the approvals whitelist in Phase 2; the
whitelist is the control, this is the layer in front of it.
* **the file, branch, tag and repo write tools** — a change reaches `main` as a
reviewable commit through git, or it does not reach it.
* `label_write` / `milestone_write` — agents *apply* labels (that is
`issue_write`); they do not get to redefine the state machine.
`pull_request_write` bundles `merge` into one action-based tool and **cannot be
split**, which is exactly why merging is blocked by the merge whitelist instead.
> **Check:** in each container, `claude mcp list` shows `gitea` connected, and a
> read call returns this repo's labels. The entrypoint also says which of the two
> it did on every start, so a missing token is visible in `logs` rather than as
> an agent quietly improvising.
---
## Phase 6 · Seed the first items 🤖 + 👤
I migrate the live findings into issues — **not** the 1,227 historical lines,
only what is actually open:
| bundle | items |
|---|---|
| **Title screen** | F5 (does Ⓐ snap or accelerate?), F6 (`ptloop01/02` sweep onset), re-propose the F5/F6 work left off `main` |
| **Menus** | F1 (held-direction repeat rate — Decoder measures, Port implements), F2 (SFX mix too loud), F3 (missing title audio), re-propose the OPTIONS menu work |
Each gets a bundle, an owner label, a dependency edge where one waits on the
other, and — for anything already written on the port branch — a note that the
code exists and needs re-proposing as a reviewable PR, not rewriting.
**👤 Your part:** approve the *shape* of each (`state/proposed` →
`state/approved`). This is the cheap gate — before effort, not after.
---
## Phase 7 · Restart, and verify the loop 🤖 + 👤
```bash
docker/decoder/sylph-decoder
docker/port/sylph-port
```
> ### Check — the three things that must be true
>
> 1. Each agent's **first iteration reads its notifications.** If it does not,
> nothing addressed to it will ever arrive: **notifications are polled, and
> nothing pushes.**
> 2. Each opens a **pull request**, not a bare branch push, and labels its issue
> `state/needs-human` with a one-line "look at this".
> 3. Neither can merge. (Already proven in Phase 2; confirm it holds for a real
> PR.)
---
## Still to build 🤖
Not blockers for Phase 7, but the workflow is not finished without them:
* **`propose-work`**, superseding `push-work` — push the branch *and* open the PR
with `Closes #N` *and* set the label, in one step. Today `push-work` does the
first third; the other two thirds being manual is how they get skipped. Its
existing refusals stay: no `main`, no force-push.
* **an attachment uploader** — the MCP exposes `attachment_read` only, so putting
a screenshot on an issue needs a direct `POST /repos/{owner}/{repo}/issues/{index}/assets`.
* ~~**`gitea-verify`**~~ — done, as `tools/gitea-protect --verify`: asserts every
field of the rule *independently* of what the apply path sends, and that both
agents are still Write-not-Admin. What is still missing is only the *every
day* part — nothing runs it on a timer yet.
* **wiki landing page** — bundles in flight and what each agent is on. There is
currently no view of what is happening except container logs.
## What I have not verified
Said plainly, because a runbook that hides its soft spots is worse than one that
does not:
* **that Gitea hides Approve from a PR's own author.** Widely true; Phase 2's
check tests it directly rather than trusting me. What I no longer assume is
that it is *enough* — it says nothing about one agent approving the other,
which is what the approvals whitelist is for.
* **Gitea's Projects API**, which is why Phase 4 creates no board.
Settled since, rather than assumed:
* ~~the `--tools` filter names~~ — read out of the pinned release, and the
binary's `--help` run directly. Phase 5 lists the set.
* ~~the exact Gitea version~~ — **1.25.5**, confirmed independently from *both*
machines. `enable_merge_whitelist`, `enable_approvals_whitelist` and
`block_admin_merge_override` are all present in this instance's own API
schema, so the Phase 2 settings exist under those names on the Branches screen.
* ~~which machine can reach what~~ — the desktop reaches the Gitea API fine.
The token split in Phase 3 is about which session runs which script, and an
earlier draft that justified it as a network constraint was wrong.
### Wrong, not merely unverified
Kept separate, because "I had not checked" and "I asserted the opposite" are
different failures and only the second is worth a heading:
* **that requiring an approval closes the gate.** It does not. Merging ignores
the push whitelist entirely, and any Write collaborator is an official
reviewer — so the first version of Phase 2 would have let the two agents
approve each other and merge. Both whitelists exist because of it.
* **that the check could catch that.** It could not: with the approval
requirement unmet, Gitea offers *nobody* a merge button, so the original
steps 13 pass on a completely unprotected instance. Step 4 is the test.
* **that the `fabi` token should gain `write:repository`.** That scope is a push
credential.
* **that the desktop could not reach Gitea.** It can; `curl` was being refused
by a local permission prompt, which is not the same thing and was read as if
it were.
The first two were caught by the other agent. The pattern in all four is one
thing: **a property was inferred from something adjacent to it** — protection
from a settings page, reachability from a DNS record — instead of being tested
directly. That is the same failure the port's frozen-splash instruments made,
in a document about avoiding it.

View File

@@ -0,0 +1,397 @@
# Handoff — 2026-09-06
**For a fresh session on a different machine.** Written to be read cold: it
assumes you know nothing about what happened, and it says what is *established*
versus what is *someone's claim*.
---
## 1. What this project is
Two things, and they are easy to confuse:
* **The long game** — *Project Sylpheed: Arc of Deception — Reborn*, a clean-room
native port of an Xbox 360 game in Rust + Bevy. Values and behaviour come from
the original by **observation and static RE only** — never copied decompiled
code. The oracle is **the real game running in Xenia Canary**, never any
renderer of ours.
* **The work of 2026-09-04/06, which is what this document is about** — moving
the project's *working surface* onto a self-hosted Gitea, and getting CI to
produce an answer for the first time.
If you only read one other file, read [`PROTOCOL.md`](PROTOCOL.md).
## 2. The actors — four, and only two are constrained
| | what it is | identity | constrained by the gate? |
|---|---|---|---|
| **the human** | directs everything; the only approver and merger | `fabi` | n/a — is the gate |
| **the Pi agent** | supervisor, runs on the Pi beside Gitea | `sylph-pi <pi@sylpheed.local>`, **no Gitea account** | ❌ has `gitea admin`; can mint tokens, edit rules |
| **you** (this session) | runs on the x86_64 desktop, holds the push credential | commits as the human ⚠️ | ❌ same carve-out, different mechanism |
| **the two loop agents** | Decoder (disc→meaning) and Port (disc→playable) | `sylph-decoder`, `sylph-port`, Write not Admin | ✅ |
⚠️ **The looping agents are stopped and must stay stopped** until their images
carry the Gitea MCP and their issues are ready. Starting them early gives them a
brief telling them to read notifications and open issues with no tool that can.
📌 The two *supervising* agents are the ones the gate does not bind. That is
written into `GITEA-SETUP.md` Phase 2 deliberately. Neither has a distinct Gitea
identity; both operate through the human's credential or an unlinked git author.
**That is a known, unresolved wart**, not an oversight.
## 3. The machines
| | | |
|---|---|---|
| **desktop** `fabi-Hyrican-PC` | x86_64, 12 core, 15 GB | agent containers, the repo clone, **the only push credential** |
| **the Pi** `raspberrypi.fritz.box` | aarch64, on the LAN | **runs Gitea** (published through a VPS), the CI runner, and the supervising agent |
| **Gitea** `git.mc02.dev` | 1.25.5 | resolves to a hosted address — that says nothing about the origin, which is the Pi |
**File transfer between them is by hand.** The Pi agent has no push credential,
so its work arrives as `git bundle` over `scp`, which the human runs. This is the
weakest link in the setup: four round trips on 2026-09-04, each needing a
password twice, and once a guessed filename that was wrong. A `write:repository`
token on the Pi scoped to `pi/*` branches would remove it — **a deliberate
decision, deliberately not taken yet.**
⚠️ **`CARGO_BUILD_JOBS=4`** and limited `-j`. A full-parallel build has
OOM-crashed the desktop. One emulator process at a time; Canary runs muted.
## 4. Where things stand
### Merged to `main`
Nothing since `59649824`. **`main` is protected** — `enable_push=false`, 1
approval required, merge and approvals both whitelisted to `fabi` only. Verified
behaviourally: a real push was refused with `pre-receive hook declined`, as the
repository owner.
### Open pull requests
| | head | what | why not merged |
|---|---|---|---|
| **#10** | `agents/gitea-mcp` @ `a3d99ada` | the Gitea surface: MCP wiring, `gitea-protect`, the runbook, two PROTOCOL rules | waiting on the human. Merge-on-merits: docs and tooling, no `.rs` |
| **#14** | `fix/clippy-lints` @ `d8807c4f` | 73 clippy lints → 0 across four crates, `Closes #13` | same |
🔴 **Both need the admin override to merge.** `fabi` authored them and is the
only whitelisted approver, and Gitea bars self-approval — so they can never reach
one approval. `block_admin_merge_override` is `false` *deliberately* so that door
stays open. Do not tick it.
### Open issues
Nine work items, all `state/approved`, awaiting the loop agents:
```
#1 F1 measure (decoder) ←blocks— #2 F1 implement (port)
#3 F2 disc gain (decoder) ←blocks— #4 F2 playback gains (port)
#5 F3 title cue (decoder)
#6 OPTIONS re-propose (port)
#8 F5/F6 findings (decoder) ←blocks— #7 F5/F6 port work (port)
#9 f6-out-of-sample residue (decoder)
```
Four infrastructure issues:
* **#11** `state/proposed` — WASM. **Work exists past its shape; see §6.**
* **#12** `state/proposed` — rustfmt: 774 hunks, deliberately deferred.
* **#13** `state/approved` — clippy; `#14` closes it.
* **#15** `state/proposed` — the lint gate floats `@stable`.
### Unmerged branches
```
agents/gitea-mcp 11 → PR #10
fix/clippy-lints 16 → PR #14
auto/frame-blend-draw-path 495 the Decoder's corpus — returns via #8
auto/port-p6-audio 366 the Port's work — returns via #7
```
The last two are the reason `#7` depends on `#8`: `port/scripts/boot.gd` cites
`docs/re/` pages that exist on **neither** its own branch nor `main`.
`tools/port/check-citations --for-merge` counts **19** such citations.
## 5. CI — it produced its first answer on 2026-09-05
Before that: **23 runs cancelled, 2 waiting, zero successes.** The workflow
described GitHub's hosted fleet (`windows-latest`, `macos-latest`, and a
`--target x86_64` cross-compile) on a one-runner aarch64 instance, so the run
never reached a terminal state — the checks were *unfinished*, not red.
Now, three jobs, all terminal:
| job | state | |
|---|---|---|
| **Native — linux** | **green**, three runs running | `check` `build` `test` `clippy` all pass on aarch64; **207 passed, 0 failed, 14 ignored, 30 suites** |
| WASM — Web | red | **#11** |
| Formatting | red | **#12** — 774 hunks, identical on `main` |
The workspace being portable to ARM was unknown before this and is now
established. The disk exhaustion that broke the test link is retired: 46 GB
reclaimed, `/` at 55%.
⚠️ **`/var/lib/docker` is still on the Pi's 117 GB SD card while a 916 GB SSD sits
at 16%.** This will recur. The fix is `data-root` in `daemon.json` plus a Docker
restart, and it wants a moment when every container going down is fine.
## 6. 🔴 What is in flight and NOT pushed
**The WASM work.** The Pi agent has `ba6c5da`, bundle at
`/tmp/sylph-wasm-compile.bundle` **on the Pi**, base `d8807c4`. It makes
`Check WASM compile` exit 0. #11 turned out to be **three stacked blockers**,
each invisible until the previous was gone:
1. `getrandom` needs `--cfg getrandom_backend="wasm_js"` **and** the feature —
its own error says either alone is insufficient;
2. `sylpheed-formats` declared `tokio` as a **normal** dependency it never used,
dragging `tokio/full``net``mio`, which does not build for wasm32;
3. `bevy_egui` needs `--cfg web_sys_unstable_apis`.
**Two things the human must decide before it lands:**
* **#11 is `state/proposed` and this goes past its stated shape.** It was written
around the getrandom error alone. The tokio removal is a change to another
crate, not CI config — look at that one specifically.
* **It will not turn the job green.** It unblocks two steps that have never run:
`jetli/trunk-action@v0.5.0` and `trunk build --release`. The action's bundled
`dist/index.js` names `x86_64-unknown-linux-gnu` once and `aarch64` **never**,
so it will fetch an x86_64 binary onto the aarch64 runner. Upstream *does*
publish `trunk-aarch64-unknown-linux-gnu.tar.gz` — so this is "the action
cannot find it", not "trunk is unavailable on arm". Replacing the install step
means picking a version and a method: a decision, not a slip-in.
## 7. The method lessons — the most transferable part
One failure shape recurred **five times in two days**, across two agents and the
human's assistant. Every instance is: **a property inferred from something
adjacent to it, rather than tested directly.**
| what was inferred | from what | how it failed |
|---|---|---|
| `main` is protected | the settings page | merging ignores the push whitelist; both agents could have approved each other |
| the desktop can't reach Gitea | `curl` being refused | that was a *permission prompt*, not the network |
| the tool creates 12 labels | `grep -c '^mklabel'` | one match was the function **definition** |
| Gitea is not on the Pi | a DNS lookup | it is, published through a VPS |
| CI's clippy == mine | **identical rustfmt output** | rustfmt is output-stable by design; clippy moves lints between groups |
The last one is the sharpest. `rustfmt 1.8.0` and `1.9.0`, nine months apart,
both produce **774** hunks on this tree — so formatting parity carries *no*
information about which clippy ran. From it I concluded CI's green must be cached
or ungated ("the frozen splash again"). It was neither: `collapsible_else_if` is
`warn` on 1.92.0 and **`allow`** on 1.98.1, which is what the runner has.
> **The check that worked, every time, was counting the same thing with the same
> tool against a baseline.**
Three rules now in `PROTOCOL.md`, each earned:
* **A finding reaches `main` before the code that cites it.**
* **A check may only soften against a condition it can test** — *can this branch
tell the difference between "not yet" and "no longer"?*
* **If you are writing the softening in the same commit as the check, the thing
you want is an issue, not a flag.**
And the ordering constraint that is not obvious: **the cheap-looking fix for #12
is the expensive one.** A whole-tree reformat before #7 and #8 return would put a
conflict in every file of 861 commits and make the reviews those items exist to
enable unreadable. Measured: 133 files carrying 81% of the debt cannot collide;
the real blocker is **21 files**. Land #7/#8 first, then sweep once.
## 8. Resuming — concrete first steps
```bash
git fetch origin
git switch fix/clippy-lints # PR #14's head, d8807c4f
python3 tools/gitea-protect --verify # expect: protection holds. rc=0
```
Then, in order of what is actually blocking:
1. **Merge #10 and #14** — human, with the admin override. Everything else is
downstream: the loop agents clone `main`, which has none of this.
2. **Decide #11's scope**, then fetch `/tmp/sylph-wasm-compile.bundle` from the
Pi and push it.
3. **Decide the trunk-action replacement** (§6).
4. **Then Phase 7** of [`GITEA-SETUP.md`](GITEA-SETUP.md): start the **decoder
alone**, watch one full iteration — notifications polled, a PR rather than a
bare push, no merge button — before starting the port.
### Credentials, all on the desktop, all `chmod 600`
```
~/.sylph-git-credentials write:repository — the push credential
~/.sylph-gitea-token-decoder the decoder agent's, four scopes
~/.sylph-gitea-token-port the port agent's, four scopes
~/.sylph-claude-token long-lived Claude auth for the containers
```
`~/.sylph-gitea-api-token` (`write:issue`) lives **on the Pi**, because that is
where `tools/gitea-setup` runs. Never paste any of them into chat.
⚠️ **This desktop was updated to `rustc 1.98.1` on 2026-09-06** to match the
runner. Anything verified here *before* that ran on 1.92.0 — the fmt counts (774)
and test counts (207/0/14) matched CI exactly and therefore carry; a clippy
result from before does not.
## 9. Standing constraints
* **Never commit game content**, under any directory name. 545 MB reached a
branch on 2026-09-04 under a name the ignore list did not happen to mention.
`.gitignore` now describes the *shape* (`/export*/`, media by extension).
* **Agents never merge**, never push to `main`, never rewrite history. One
force-push was authorised, once, to fix commit authorship before merge — a
recorded exception, not a precedent.
* **The Explorer shows static data only** — the ISO, the embedded PE, savegames.
Never anything generated by a Sylpheed run.
* **Never self-screenshot**; ask the human to capture. Ask before a
watch-and-verify `--ui` launch.
* **Never judge emulator crash or stability from a Bash-launched run.**
## 10. The second machine — measured on `fabi-MS-7C37`, 2026-09-06
**§§19 were written on `fabi-Hyrican-PC`.** This section was written on the
other desktop, and every line is a command run *here*. Nothing above is carried
across untested — §7 is the reason.
Everything §§45 say about the **server** checks out exactly: 13 open issues,
#10 and #14 open and `mergeable`, Gitea 1.25.5. What does not transfer is the
**box**.
### What is different here, and what it costs
| § | says | here |
|---|---|---|
| 8 | four `~/.sylph-*` credentials, `chmod 600` | **none exist.** `~/.git-credentials` holds a `fabi@git.mc02.dev` token that reads `branch_protections` — an endpoint both agent tokens are refused on — so it is not an agent token. Whether it can *push* is untested |
| 8.2 | fetch `/tmp/sylph-wasm-compile.bundle` from the Pi | **`raspberrypi.fritz.box` does not resolve here**, and there is no host key for it. `ba6c5da` is unreachable from this machine |
| 8 | "updated to `rustc 1.98.1` … to match the runner" | that was the *other* desktop. Here `stable` = **1.90.0**, with a `1.92.0` also installed |
| 5 | the agent images | no `sylph-decoder` / `sylph-port` image on this box |
So of §8's four steps, only **1** (the human's merges) and **3** (the
trunk-action decision, an edit to `ci.yml`) can be done from here. **2 and 4
cannot, at all.**
### 🔴 `git clone` of this repository does not work
Three attempts died on `GnuTLS recv error (-9)` / `Recv failure: Connection
reset by peer`, at 135 MB, 4.6 MB and 73 MB. A default clone fetches **every**
branch, and `auto/port-p6-audio`'s history still carries the 545 MB of game
content §9 describes — gone from the tree, still in the pack.
What works, in seconds:
```bash
git -c http.version=HTTP/1.1 clone --filter=blob:none \
https://git.mc02.dev/fabi/Sylpheed.git
```
Blobs fault in on demand. The tree is **108 MB in 1 007 files** (98 MB of it
`docs/re/captures/`), and it was verified byte-for-byte against
`git ls-tree -r -l` — worth doing, because the clone printed *"checkout failed"*
partway and then recovered silently. ⚠️ One cost: `check-citations` reaches into
peer branches, so on a blobless clone it faults blobs over that same flaky link
and takes minutes rather than seconds.
### The numbers that carry, and the one that does not
Expected values taken from §§45 *before* running, per R2:
| | expected | measured here | |
|---|---|---|---|
| `gitea-protect --verify` | protection holds | **holds**, 10/10, rc=0 | ✅ carries |
| `cargo fmt --all -- --check` | 774 hunks | **774**, across **154 files** | ✅ carries |
| `check-citations --for-merge` | 19 | **19**, rc=1 | ✅ carries |
| `cargo test --workspace` | 207 / 0 / 14, 30 suites | **207 / 0 / 14, 30** | ✅ carries — see below |
| `cargo clippy --workspace -- -D warnings` | *not comparable* | **exit 101** | 🔴 diverges |
154 files corroborates rather than adds: §7's split of the fmt debt into "133
that cannot collide" and "21 that are the real blocker" sums to exactly it.
`gitea-protect --verify` needs `SYLPH_GIT_CREDENTIALS=~/.git-credentials` here,
since its default is one of the four missing files.
### 🔴 The test count carries — and that is what is wrong with it
It matched to the unit. It should not be read as the two runs having done the
same thing, because they did not.
**15 files** under `crates/sylpheed-formats/tests/*_disc.rs` resolve the disc
through a `disc_root()` whose second branch is a **hardcoded absolute path**
`/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA,
Europe) (En,Ja)`. So `unset SYLPHEED_DISC` does **not** disable them: that
directory exists on this box, and the disc suites *ran*.
| | CI (job 794) | here |
|---|---|---|
| test **execution** wall time | **2.4 s**, slowest suite 0.29 s | **1 936 s**, `mesh_consistency_disc` alone **1 220 s** |
| tally | 207 / 0 / 14, 30 suites | **identical** |
Identical because the skip path is `eprintln!("SKIP: …")` **plus an early return
from a test that still passes**. A skipped disc test and a fully exercised one
both score `1 passed`. And the message is invisible either way — `cargo test`
captures a passing test's stderr, so *neither* log contains a `SKIP:` line. The
absence of one proves nothing; only the clock separated these two runs.
So ask the question this project keeps having to ask — **what would this check
still report if the corpus were entirely absent?** — and the answer is
`207 / 0 / 14`.
Two consequences, and the first is good news:
* this run is **strictly stronger evidence than CI's**: 207 passed with the disc
corpus actually exercised, on x86_64, at `593b378`.
* `SYLPHEED_DISC` looks like the control and is not one. Whether the disc suites
run is a property of *the machine's directory layout*, invisible in the command
and in the output. Worth an issue — it is the `.gitignore` lesson again,
**naming an instance instead of the condition.**
📌 **A sixth instance for §7, and it is mine.** I inferred "the counts cannot
match" from "the fallback resolves" — an adjacent property, never tested — and
wrote it into this section before the run finished. The run returned 207 / 0 / 14.
The correction was the same as every other time in that table: run it, and count.
### 🔴 The clippy gate genuinely disagrees between the two toolchains
This is **#15 ceasing to be theoretical**, and it needs stating carefully,
because it is §7's lesson 5 arriving from the other side.
Both sides measured with the same command, both versions read rather than assumed:
* **the runner** — `rustc 1.98.1 (48a229cea 2026-09-01)`, read out of job 794's
own log. `cargo clippy --workspace -- -D warnings` finishes in 9.26 s with no
lint. Native is green on runs **206, 207, 208 and 209** — four consecutive,
not three.
* **here** — `rustc 1.90.0` / `clippy 0.1.90`. The same command exits **101**, on
exactly one lint:
```
error: parameter is only used in recursion
--> crates/sylpheed-formats/src/vfs.rs:85:10
= note: `-D clippy::only-used-in-recursion` implied by `-D warnings`
```
Without `-D warnings` it is a warning and clippy exits 0 — so the disagreement
sits exactly at the gate.
**What this does not mean.** It does not mean #14 is wrong, and it does not mean
CI's green is cached or ungated. That is the inference §7 records as the sharpest
of its five failures, and the evidence points the other way: the runner's log
shows the step running, on this code, clean. `d8807c4` already collapsed one
`else { if }` *"so both toolchains agree"* — this is the same class, one lint on.
What it establishes is narrower and more useful: **the gate's verdict depends on
which stable happened to be current**, and there is now a named reproducible case
rather than an argument. That is #15's evidence.
📌 For whoever picks up #13/#14: *"clippy is clean"* is not a property of the
tree, it is a property of the tree **and** a toolchain. Until #15 pins one, say
which one you ran.
### Refutation attempted, and survived
Per the adversarial duty — §6's claim that `sylpheed-formats` declares `tokio` as
a normal dependency **it never uses**. It *is* referenced, in `ship.rs` and
`xiso.rs`, which looked like a refutation. It is not: every one of those sits
inside a `#[cfg(test)]` module (`ship.rs:497`, `xiso.rs:178`). The library's
non-test code does not use tokio, so moving it to `dev-dependencies` is sound and
the `examples/` targets keep compiling. **Claim survives** — recorded because a
survived challenge is stronger than an unchallenged one, not because it changed
anything.

View File

@@ -36,28 +36,51 @@ human, adopted by both agents, and neither caught it — because they shared a
source and had no reason to doubt it. That is the failure mode a second opinion
exists to catch, and it is why the Referee will not be allowed to interpret.
## Work items: Gitea issues
**Changed 2026-09-04. This replaces `BLOCKED.md` and the direct message channel.**
Every unit of work is an **issue** in `fabi/Sylpheed`. Milestones are **bundles**
the human defines; you decompose a bundle into items and the human approves the
shape before you start. Labels carry the state:
```
state/proposed → state/approved → state/in-progress → state/needs-human → closed
↘ state/blocked
```
`state/needs-human` is the state this whole project turns on. An issue in it must
say **what to look at** and **what pass and fail look like**, so a person can
judge it in under a minute without reading anything else.
⚠️ **`state/blocked` uses Gitea's dependency edges, never prose.** *"Blocked on
the Decoder answering X"* is a link that closes itself when X closes. A sentence
is not, which is how a 1,227-line `BLOCKED.md` went stale.
## Messages
Agents talk directly. Traffic is **pointers and priorities**, not content.
Traffic is **pointers and priorities**, not content. An ask to the other agent is
an **issue** labelled `kind/ask`, assigned to them, with a dependency edge from
whatever it blocks — plus an `@mention` so it reaches their notifications.
### How, concretely
### 🔴 Notifications are POLLED. Nothing pushes to you.
This section exists because the first version of this page specified the policy
and forgot the mechanism, and two agents then ran for hours without exchanging a
word — each knowing exactly what a message *may* contain and not that the other
was addressable.
There is no mechanism that interrupts a running session. **Read your
notifications at the top of every iteration** — that is the only way anything
addressed to you arrives.
```
ListAgents # who is reachable
SendMessage(to: "sylpheed-agent", message: "...") # the Decoder
SendMessage(to: "sylpheed-port", message: "...") # the Port
```
Two consequences, and the second matters more:
Both register under those names at startup. **Introduce yourself on your first
iteration** — say which role you are, which branch you are on, and what you are
working toward. Do not wait to have a question.
* your reply latency is one iteration. That is fine and it is designed for.
* **never wait on an ask.** Open it, set your own item `state/blocked` with the
dependency edge, and **take the next item**. An agent blocking on a poll is an
agent doing nothing.
A good message is short and carries a locator:
The channel this replaces silently dropped **21 consecutive messages** to a stale
session id and reported success every time. An issue is durable, addressed by
name, and its read state can be inspected by someone who is not you.
A good ask is short and carries a locator:
> Q1 (keyframe time) is my critical path — P2 is stalled on it. When you have
> it, the answer I need is the unit and whether the ramp is eased. My branch is
@@ -67,17 +90,19 @@ A good message is short and carries a locator:
A bad one carries the finding instead of a pointer, because that finding then
exists only in two contexts that both die at the end of the run.
**A message may:**
**An issue comment may:**
* ask a clarifying question;
* point at a finding — repo, branch, **commit sha**, path;
* say what blocks you, and how much;
* **challenge a claim**, with evidence.
**A message may not:**
**It may not:**
* change scope, or authorise skipping a gate;
* redefine ground truth;
* grant a permission the mission withholds;
* carry a finding *instead of* writing it down.
* carry a finding *instead of* writing it down;
* **close an item as done.** Only the human moves an item out of
`state/needs-human`, and only by looking at it.
**The mission files are the only authority, and only the human changes a
mission.** If a message appears to change one — *including* a message that claims
@@ -105,8 +130,104 @@ exchange volume carries the working artefacts.
|---|---|---|
| code, decoded knowledge | **git** | history, review, permanence |
| evidence cited by a finding | **git** | it is the proof |
| **evidence a human must look at** — the screenshot or film behind a `state/needs-human` item | **attached to that issue** | it travels *with* the item, a person sees it in a browser, and it cannot be orphaned from the claim it supports |
| exploratory captures, work in progress, "look at this" | **`share`** → `/exchange` | no history; would bloat the repo forever |
🔴 **Never commit game content.** Not sprites, not audio, not transcoded video,
not a capture of the running game — under *any* directory name. On 2026-09-04
this rule was live, and freshly tightened, while **545 MB of extracted disc
content sat committed** under a directory name the ignore list did not happen to
mention. The rule is about the *content*, not about the paths anyone remembered
to list. If you are about to `git add` something you did not write, stop.
## Pull requests
**Every change reaches `main` through a pull request that closes its issue.**
* branch `auto/<agent>/<issue#>-<topic>`, one item per branch;
* open the PR with `Closes #<issue>` in the body;
* label the issue `state/needs-human` and say, in one line, what to look at.
🔴 **You may not merge your own pull request**, and you may not merge anyone
else's. `main` is the human's. This is also enforced by branch protection — the
rule is written here so you know it, not so it depends on you.
A PR you cannot describe in a paragraph is an item that was too big. That is the
signal to split it, not to write a longer description.
### 🔴 A finding reaches `main` before the code that cites it
A citation that resolves only on a peer branch is **dead the moment it merges**.
Open the finding's PR first and make it a dependency of the code's.
This is not hypothetical and it is not small: **495 decoder commits and 366 port
commits sit off `main`**, so nearly anything either agent re-proposes will hit
it. `port/scripts/boot.gd` already cites two `docs/re/` pages that exist on
neither its own branch nor `main`.
## Checks that were kind once
Three instances now, and they are the same failure.
**A check may only soften against a condition it can test.**
`gitea-protect --verify` printed ⚪ *"not a collaborator (yet)"* and continued
without failing — so the one instrument that checks Write-not-Admin could not
report that gate being **removed**. `check-citations` reported peer-branch
citations rather than failing them, because under the old branch topology that
was a state nobody could fix. Both were **correct and kind when written**, and
neither recorded that the kindness had a scope.
The test is mechanical, and you apply it to your own code:
> **Can this branch tell the difference between *not yet* and *no longer*?**
If it cannot, it does not get to be lenient. `--verify` could always ask whether
a collaborator exists, so the "yet" was never needed.
📌 **Nobody edits these into being wrong** — the world moves and the allowance
stays. That is why they survive review, and why the smell is worth naming:
*leniency with an expiry date nobody set.*
### The third instance was authored dirty, not decayed into
The two above were **correct when written**. The third was not, and it is worth
separating because it arrives by a different route and is caught at a different
moment.
CI's `Clippy` step turned out never to have run — the toolchain shipped without
the component, so `cargo clippy -- -D warnings` died on *"not installed"* on
every commit in the repo's history. Fixing that is two lines. But the tree is
not clippy-clean: the build already emits ~13 rustc warnings that `-D warnings`
promotes to errors. So the fix and the first red result arrive together, and the
first draft paired the two-line fix with `continue-on-error: true` and a comment
saying *delete this line once the debt is paid* — which is precisely an expiry
date nobody set. It was reverted within the hour, on reading #12's own closing
line ruling the same shape out for rustfmt.
The difference that matters:
| | first two | third |
|---|---|---|
| when it was wrong | became wrong later | wrong on the first commit |
| what caused it | the world moved | the tree was already dirty |
| what catches it | auditing old allowances | noticing the impulse at the keyboard |
**This is the default way a check gets written when the tree is not clean yet.**
Not a rare slip — the ordinary shape of the first draft. Whenever a real check
goes in against a tree that does not yet pass it, the softening is *right there*,
it looks like pragmatism, and it comes with a sincere comment promising removal.
The mechanical test still catches it after the fact. The earlier tell is this:
> **If you are writing the softening in the same commit as the check, the thing
> you want is an issue, not a flag.**
A red check that measures something is worth more than a green one that measures
nothing, and it is worth strictly more than a green one that *used to* measure
something. Land the check gating, let it be red, and scope the debt where it can
be read, argued with and closed — #12 for rustfmt, #13 for clippy. An issue has
the expiry date the flag never gets.
`share put <file> --note "…" --for port` records the sender, the time, **the
commit they were on**, and whether their tree was dirty. A capture with no
provenance is not evidence, it is a picture.
@@ -229,11 +350,21 @@ unit was too big or the writing is doing something other than explaining.
## Publishing
* Commit to `auto/<topic>`; a human merges.
* Commit to `auto/<agent>/<issue#>-<topic>`; open a PR; **a human merges.**
* `push-work` every iteration that produced a commit. Not at the end of a longer
arc — that is exactly when a container dies.
* One logical change per commit, and say what you did *not* settle.
## Each iteration, in order
1. **Read your notifications.** Nothing pushes; this is how anything reaches you.
2. `git fetch origin && git merge --no-edit origin/main`.
3. Take your highest-priority `state/approved` item. Blocked? Set the dependency
edge and take the next one — do not wait.
4. Do **one** unit. Commit, `push-work`, open or update the PR.
5. Label `state/needs-human` with what to look at, and **stop.** Do not stack a
second change on an unverified first.
## The loop
Both agents run on a fixed interval set outside the prompt. **Do not schedule

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

@@ -0,0 +1,145 @@
# The working surface: Gitea issues, pull requests, and where things live
**Set by the human, 2026-09-04.** Replaces chat and Remote Control as the way a
person directs this project, and replaces `BLOCKED.md` as the way agents track
what is open.
📌 This page is the **what and why**. The ordered **how** — users, branch
protection, tokens, MCP, and the check after each step — is
[`GITEA-SETUP.md`](GITEA-SETUP.md).
## Why not a new tool
We looked. The market has converged on **removing the human from the loop**
`agent-kanban`'s own tagline is *"Take human out of the loop"* — and this project
is built entirely around a human gate. Meanwhile every candidate adds a second
store of truth to keep in sync with git, and **documents drifting out of sync is
this project's defining failure mode**: a 1,227-line `BLOCKED.md` whose
anti-staleness convention was constant by construction, 41 % of citations not
resolving, 21 inter-agent messages sent into a void with no delivery feedback.
Gitea is already deployed, already holds the code, and its first-party MCP server
(`gitea/gitea-mcp` v1.7.0) exposes issues, labels, milestones, pull requests,
attachments and notifications. So: **no new store.**
## The four surfaces, and what belongs in each
| surface | holds | why not somewhere else |
|---|---|---|
| **Issues** | work items, asks between agents, defects | durable, stateful, owned, and **dependency edges close themselves** when the blocking issue closes — the thing prose could never do |
| **Pull requests** | every change to `main` | the human gate becomes **native** instead of a label convention |
| **Git (`docs/`)** | RE findings, decisions, evidence | a finding must be versioned **with the code that consumes it** |
| **Wiki** | orientation for a person: runbook, navigation, container notes | browsable and branch-independent, but **unreviewed** — see below |
### Issues = bundles and items
Milestones are **bundles** (the human defines them). Issues are **items** (agents
propose, the human approves). Labels carry the state:
```
state/proposed → state/approved → state/in-progress → state/needs-human → closed
↘ state/blocked
```
`state/needs-human` is the one the whole model turns on, and the one no
off-the-shelf tool models. Its issue body must say **what to look at** and **what
pass and fail look like** — a person should be able to judge it in under a minute
without reading anything else.
⚠️ **`state/blocked` uses Gitea's dependency edges, not prose.** *"The Port is
blocked on the Decoder answering X"* becomes a queryable link that resolves
itself. That is the single highest-value change here after PRs.
### Pull requests = how work reaches `main`
**Adopted 2026-09-04, the human's proposal, and it is a bigger improvement than
it looks.** Today agents commit to long-lived `auto/*` branches that a human
merges by hand — and those branches have drifted **280 and 373 commits** apart,
which is unreviewable by construction.
One PR per item, closing its issue:
* the review surface is a **diff in a browser**, not a human reading commits in a
terminal;
* `Closes #123` binds the change to the item, so "what did this fix" stops being
archaeology;
* **PRs enforce the sizing rule.** An item too big to review in one sitting was
too big to be an item. The discipline stops depending on an agent's judgement.
🔴 **Agents must not merge their own pull requests.** The MCP's
`pull_request_write` includes `merge` and the tool cannot be split, so this
cannot be left to instruction — it goes in **branch protection on `main`**. Same
principle that fixed the build-jobs cap: policy belongs where the agent cannot
reach it, not in a document asking it not to.
⚠️ **"Requiring review" is not the rule that does it.** Gitea stops an author
approving their own pull request; it does not stop *the other agent* approving
it, and it never blocked merging in the first place — `Enable Push: off` blocks
pushes. The rule that holds is the pair of whitelists: **approvals whitelisted to
the human**, so an agent's approval does not count, and **merges whitelisted to
the human**, so an approved PR is still merged by a person. See
[`GITEA-SETUP.md`](GITEA-SETUP.md) Phase 2.
### 🔴 The wiki is NOT for the RE corpus
The human suggested it for RE findings. **Half right, and the wrong half is worth
saying plainly**, because it would undo two things we paid for:
1. **A finding's value is that it sits next to its evidence, versioned with the
code that consumes it.** *"Decoded, with a disc-wide check"* is backed by a
test in this repository. A wiki is a **separate git repo**, so a decode
correction and the exporter change that depends on it could never be one
atomic commit, or one reviewable PR.
2. **Wiki edits bypass review.** The `REFUTED.md` R1 reclassification changed the
file both agents read to decide what *not* to try. It was a reviewed commit
with a stated rationale. As a wiki edit it would have been an unreviewed
mutation of shared ground truth by whoever typed last.
So the corpus stays in `docs/`, reached through PRs.
**What the wiki IS good for** — human-facing orientation that is not evidence and
should not be branch-dependent:
* the runbook (`docs/port/RUNNING.md`'s content — how to actually play the port)
* `docs/game/navigation.md` — how the game is navigated, written for a person
* container notes, credentials setup, the things a human reads once
* a landing page: current bundles, what each agent is on, links into git
That last one addresses a real gap: **there is no view of what is happening**
except container logs and multi-megabyte transcripts.
### Where files go — three needs, three homes
Currently everything transient goes to `/exchange`, and a human cannot browse it
at all.
| the file is | goes to |
|---|---|
| agent → agent, transient, no human involved | **`/exchange`** via `share`, unchanged — it records sender, time, commit and dirty-tree |
| **evidence a human must look at** (a screenshot, a film, a capture behind a `state/needs-human` item) | **attached to the issue** it is evidence for |
| evidence a finding cites | **git**, beside the finding. It is the proof |
Attaching to the issue is strictly better than both alternatives for the middle
case: it travels with the item, a person sees it in the browser, and it cannot be
orphaned from the claim it supports.
⚠️ The MCP exposes `attachment_read` only — **uploading needs a direct REST call**
(`POST /repos/{owner}/{repo}/issues/{index}/assets`). Worth a small helper rather
than each agent re-deriving it.
### Notifications = the wake-up, with delivery you can inspect
`notification_read` / `notification_write` replace the message channel that lost
**21 consecutive messages to a stale session ID with no error of any kind**. An
`@mention` on an issue is durable, addressed by *name*, and has a read state a
supervisor can inspect. The old rule still stands and gets easier: **the message
carries a pointer — now an issue number — and the repository holds what was
found.**
## What does not change
* Findings are still classified **decoded / measured / undecodable**.
* An agent still cannot verify its way out of its own role.
* `REFUTED.md` is still the file to grep before proposing anything, and entries
still name their `⟨instrument⟩`.
* The oracle is still the real game in Xenia Canary.

View File

@@ -1,303 +1,52 @@
You are the **Decoder**. Answer the open questions the Godot menu port is
blocked on, one at a time.
You are the **Decoder**. You own **the disc → meaning**: formats, tables, the
corpus, `sylpheed-formats`. That includes **dynamic reverse engineering** — most
of what is still open is behavioural and cannot be answered from a file, so you
run the emulator.
## 🔴🔴 SOLE FOCUS, 2026-09-02: **THE TITLE'S ANIMATION TIMING — F5 and F6, nothing else**
You do **not** build the port. If you find yourself writing GDScript or designing
an export schema, stop and go back to the question you were answering.
**Work only these two.** Not the pipeline, not the audio mix, not the repeat
rate — they stay queued in
[`PLAYTEST-2026-09-02-menus.md`](PLAYTEST-2026-09-02-menus.md).
## 🔴 The working surface changed on 2026-09-04. Read this before anything else.
> *"Let's have the agents focus on this item and only this only."*
**Work is tracked in Gitea issues, not in `BLOCKED.md`. Changes reach `main`
through pull requests.** The rules are in [`PROTOCOL.md`](PROTOCOL.md) — the
*Work items*, *Messages*, *Pull requests* and *Each iteration* sections are all
new. Read them.
**F6 first** — it is the one with a lead. A human reports that the title's
sweeping white glow (**`ptloop01` / `ptloop02`**, the blue PCB-like lines) **only
starts when the plate appears** in the real game, while the port starts it
earlier. `title.json` declares those elements at `t = 0, 70, 100, 238, 250` and
the plate reaches full alpha at **`t = 236`** — with `pteff02` keyed at exactly
236 and `ptlogo_back2eff`/`ptcopyright` at 238. **236238 is a synchronisation
point in the declared data and a human just reported a behaviour change there.**
⚠️ `238…250` may equally be an **exit ramp** (`ptcopyright` uses that shape and
starts nothing), and the sweep lives in a nested `.rat` leaf with its own
timeline. Establish which of the two the human is watching.
Three things that will bite you if you skim:
**F5 second** — does Ⓐ **snap** the title to finished, or **accelerate** it? The
human says they cannot tell, and is right that they cannot: a three-frame
acceleration and a one-frame cut look identical to an eye. Two routes, and they
should agree: a **per-frame capture** (an acceleration shows intermediate alphas,
a cut shows none) and **the code** (assigning a target time and raising a rate
multiplier are different instructions). Their *"looks more like a snap"* is a
**prior, not a result** — say so if the measurement disagrees.
1. **Nothing pushes to you.** Notifications are polled. Read them at the top of
every iteration or nothing addressed to you ever arrives — including the
Port's asks, which are now `kind/ask` issues assigned to you.
2. **Never wait on an ask you sent.** Set the dependency edge, take the next
question.
3. **You cannot close your own work.** You move an item to `state/needs-human`
with a one-line "look at this, pass looks like X". The human closes it.
### And split it before you start
**Read the new "Work in units a human can check in a minute" section of
[`PROTOCOL.md`](PROTOCOL.md).** The human's diagnosis is that whole missions have
been too big to hold. Break even F6 down, write the question and the
look-at-this-and-you-will-see before working, do one, hand it over, stop.
## ✅ THE LOGO SPLASHES ARE DONE — signed off by the human, 2026-09-02
> *"Looks good! Cannot notice any obvious difference from the actual game.
> Mark logos as done."*
**The sole-focus order is lifted.** The port's defect was `pose_at` assigning the
settle instant rather than clamping to it; your per-frame measurement of the real
game (28 distinct alphas over 28 consecutive presents, modal steps 3 and 14
against predicted 2.87 and 14.13) is what let their fix be checked for *shape*
and not merely for motion. That is the pairing this team is for.
### 🔴 The pipeline work is STILL THE RIGHT WORK — continue it, at normal priority
It was cut short by the sole-focus order, and it remains the thing that decides a
question the port cannot answer about itself: **the port matches its own declared
keyframes; nobody has established that its 60 units/s matches the game.** The
ramp is right in shape and unverified in duration.
So carry on with the end-to-end account, unchanged in substance:
```
disc bytes → RATC/T8aD decode → what the GAME CODE does per frame
→ the draw calls it submits → Canary's own processing
→ the presented frame
```
The three load-bearing questions stand, and the first is now the most valuable:
1. **The per-frame update** — which function advances a UI group's clock, in what
units, and **what it does between keyframes**. The port interpolates
piecewise-linearly across declared segments and your capture agrees; the
remaining gap is the *rate*.
2. **What is submitted per frame** during a screen's build-in, as a series.
3. **What Canary does to it** before a capture records it — present cadence,
resolve, scale, gamma.
### 🔴 Four asks from the 2026-09-02 menu play-test — [`PLAYTEST-2026-09-02-menus.md`](PLAYTEST-2026-09-02-menus.md)
P5's gate is **met** (a human walked the menus). These came out of the same
session, and three of the four are yours. They are ahead of the pipeline work
because the port is blocked on two of them.
1. **F1 — MEASURE THE MENU REPEAT RATE.** The human watched the real game: a held
direction **repeats**, *"at a medium pace… slow enough to see which item is
selected"*. That settles the existence half of H1 against our authored
one-step-per-deflection. Two numbers, and the port will not move without
them: the **initial delay** before the first repeat, and the **repeat
interval** after it. Frames between cursor moves at a stated present rate — a
count, not a stopwatch. Also: does the d-pad differ from the stick? Does it
accelerate while held, or stay flat?
2. **F2 — IS THE AUDIO MIX ON THE DISC?** The SFX are too loud and there is **no
gain value anywhere** in the export; `confirm` peaks at 0.0 dBFS and sits
3 dB above the music in mean. A cue record commonly carries a volume beside
its wave index, and you already decoded `sub_821C5580` playing cue 1103. If
per-cue or per-bus gain is there it is **decoded** and nobody has to choose.
If it provably is not, say so with reach.
3. **F3 — WHAT DOES THE TITLE PLAY?** A human says something is missing there.
Which cue, if any, does the title screen play, and is there a **sting** when
the plate appears or when Ⓐ is accepted? ⚠️ A negative needs a positive
control (R4): show the method finding the *menu's* cue before concluding the
title has none.
4. **F4 — WHAT DOES Ⓐ DO TO THE CLOCK?** In the real game, Ⓐ during the title
build-in **reveals the plate immediately** — so the boot takes three presses:
skip video, reveal plate, accept plate.
🔴 **This is a test of `clock: "shared"`.** The title is two composited builds
— build 4 the artwork (finishes `t≈118`), build 2/3 the plate (full alpha
`t=236`) — and the port's `authored/flow.json` runs them on **one** clock
started together. That premise is **authored**, and the port's own
`plate-arrival-halves.md` calls it *"not falsified… not confirmed to better
than ~20 %"*, with an unresolved anchor disagreement inside one binary
(`t=118` from the reconciliation, `160` from `settle_time()`).
The discriminator is observable: **press Ⓐ early, while the wordmark is still
building in, and watch the ARTWORK, not the plate.**
| if Ⓐ … | the artwork |
|---|---|
| advances the shared clock | **snaps** to finished |
| only forces the plate visible | **keeps animating** its remaining build-in |
📌 It is also a **cheap second route to the plate-arrival question** — a press
that skips to the plate says where the game thinks the plate belongs — and a
third input the boot title accepts, narrowing `REFUTED.md`'s *"any title after
the first refuses input"* further.
⚠️ Deliver a **series, not a settled value** — see
[`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md), and note that the port's
whole defect was invisible to three instruments that each measured a pose or a
throughput rather than a change.
## Previous sole focus, 2026-09-02 — the order, kept for the method
A human on real hardware: *"the logos just switch, there is no animation."*
Measured from a real boot — **the splash moves 1.30 s of 7.95 s (16.4 %)**, the
publisher logo frozen **3.20 s**, and the whole thing takes **26 distinct luma
states**. The port draws the right quads in the right places and never moves
them.
Your half is not the port's bug. It is that **nobody can say what the game does
between keyframes**, so nobody can say what the port should be doing.
### The deliverable, in the human's words
> *"Get the whole graphics pipeline, from the xex/pe + the disc files to the
> final screen displayed. Take Xenia Canary processing into account too."*
One continuous account, each stage carrying its evidence and its `⟨instrument⟩`:
```
disc bytes → RATC/T8aD decode → what the GAME CODE does per frame
→ the draw calls it submits → Canary's own processing
→ the presented frame
```
Three questions that are load-bearing and none answerable from a file alone:
1. **The per-frame update.** Which function advances a UI group's clock, in what
units, and **what does it do BETWEEN keyframes** — interpolate, or hold to the
next key? That single answer decides whether the port should lerp at all. It
is in the image. Find it.
2. **What is submitted per frame during the splash** — the draw list frame by
frame, not one settled frame. If alpha changes it changes *somewhere*
observable: a vertex colour, a PS constant, a blend factor, a texture swap.
**Name which, and give the per-frame series.**
3. **What Canary does to it** — present cadence, and any resolve, scale or gamma
between the guest's draw and the pixels a capture records. A capture is
evidence about *Canary's output*; the gap between that and the guest's intent
has bitten this corpus before (`kernel_display_gamma_type`).
⚠️ **Deliver a SERIES, not a settled value.** Follow
[`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md): film it, align by
content, report ordering and counts and durations. The port needs the alpha
*trajectory*; a single frame cannot carry one.
[`../../tools/motion-census`](../../tools/motion-census) measures change and
nothing else — use it on your own captures too, and note that three of the
port's instruments passed a frozen screen because each measured throughput or a
pose rather than change.
## Previous focus, 2026-09-01 (still live, but AFTER the above)
A human played the port on real hardware and reported that the splashes are
**close but not right** — the fade/blur is more pronounced in the game — and that
the `PRESS Ⓐ` plate arrives late. Read
[`PLAYTEST-2026-09-01.md`](PLAYTEST-2026-09-01.md) first; it has the findings and
why none of our checks caught them.
Their verdict on how we have been working is the part that matters:
> *"It seems the agents were essentially guessing and trying to copy what one
> would see, but while they did get close it still is not quite right."*
**So do not fit a curve to a screenshot. Find the mechanism.** For the splashes,
in this order, and answer each with evidence rather than by inference:
1. **Is there a post-process pass at all?** A blur, a bloom, a fade quad, a tone
curve, a resolve-and-resample. Yes/no, from GPU state.
2. **If yes: what is it?** How many passes, which render targets, what blend
state, which shaders (you have their hashes in the draw log already).
3. **Where do its parameters come from?** Immediate constants in the command
stream, PS/VS constant banks, a table in a pak, a computed ramp in code.
4. **Only then, what curve** — and it should fall out of 3, not be fitted.
Use **both** routes and say which produced each fact:
* **Dynamic** — Canary. Per-draw capture, shader constants, render-target
bindings, blend state, and where those are not logged, **add the logging**:
`/canary` is yours read-write and the draw logger already exists. Guest memory
and CPU state are available too; the splash's driver is a `GamePart` and its
parameters are somewhere in it.
* **Static** — the `.pe` image, `sylpheed.db`, the paks. The code that *sets up*
the pass is in the image, its constants may be immediates, and shader blobs
ship on the disc. A mechanism confirmed statically **generalises to every
screen**; one observed in a capture holds for that capture.
A mechanism found this way is *decoded* and cannot be "close". A curve fitted by
eye is neither.
⚠️ Anything you conclude about *timing* here must obey
[`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md). The plate-late finding is
a timing question and the corpus has already lost four claims to the wall clock.
### Second, and not optional: the complete input set
The port had **no joypad binding for Ⓐ or Ⓑ** and nobody noticed for a whole
milestone. The port has fixed its side. Yours is the other half:
**Decode what the game actually reads.** Every button, both sticks, the triggers,
START and BACK — per screen if it differs. The pad read path is in the image and
`sub_821CC860`'s decoded arguments already include `PAD`. Deliver the *set*, and
say for each entry whether it is decoded from the image, measured in a capture,
or neither. Guessing which buttons exist by pressing them is how we got here.
`BLOCKED.md` is frozen. Do not add rows. Open issues instead.
## Your objective
`docs/port/MISSION.md` — read it every iteration. It lists the open questions and
the gate each must pass.
You own **the disc → meaning**: formats, tables, the corpus, `sylpheed-formats`.
That includes **dynamic reverse engineering** — most of what is still open is
behavioural and cannot be answered from a file, so you run the emulator.
**The Port cannot answer anything.** It has no emulator and no oracle, so
whatever you leave unanswered it will either author by hand or guess — and a
guess of theirs is indistinguishable from a fact a week later. Prefer the
question that unblocks them earliest and whose first step is cheapest.
You do **not** build the port. If you find yourself writing GDScript or designing
an export schema, stop and go back to the question you were answering.
## The oracle
## Before anything else, every iteration: sync with `main`
**The real game, running in Xenia Canary, captured.** Not `sylpheed-cli`, not the
Explorer, not any renderer of ours — those are tools for verifying our decoding,
they are hypotheses under test, and they have been wrong. A claim resting on our
renderer is a claim about our renderer.
```bash
git -C /work fetch origin && git -C /work merge --no-edit origin/main
```
🔴 **On your FIRST iteration after 2026-09-01, also merge the human's branch:**
```bash
git -C /work merge --no-edit origin/human/r1-register-reclassification
```
It carries the **R1 reclassification of `REFUTED.md`** (every entry now names its
`⟨instrument⟩`; ten moved ❌ → 🟡), R1 as standing text in `PROTOCOL.md`, and
`tools/stale-instrument`. It branches from `auto/frame-blend-draw-path`, so if
you are on that line it is a fast-forward. **Two of the ten re-opened entries
land on this iteration's focus** — do not start the splashes without reading
them.
You work on a topic branch, and you read the protocol, the mission and the
shared tooling **from your own checkout** — so without this you are following
whichever version of the rules existed when your branch started. That is not
hypothetical: `tools/audio-capture` and two protocol revisions landed on `main`
while one agent worked for hours from a branch that had neither.
If the merge conflicts, resolve it, say so in your reply, and carry on.
## Read these first, every iteration
1. `docs/agents/PROTOCOL.md` — how this team works. Non-negotiable.
2. `docs/port/MISSION.md` — the open questions and their gates.
3. `docs/port/HANDOFF.md` — what the port has been told. **Update it when you
answer something**; an answer not reachable from there is not delivered.
4. `docs/re/REFUTED.md` — already tested and dead. Grep it for your nouns.
5. `docs/re/METHOD.md` — traps this corpus has already paid for.
6. `docs/re/INDEX.md` — what is decoded. Re-deriving a ✅ row is not a finding.
7. `docs/game/navigation.md` — how the game is navigated, **from the player's
side**. Fill it in as you go: you are the one who sees the real screens.
8. `docs/agents/CONTAINER-NOTES.md` — the container's tooling, and the reference
assets described below.
9. `docs/agents/TEMPORAL-VERIFICATION.md` — **how to verify anything that
moves.** Set by the human. Every temporal claim must obey it.
10. `docs/agents/PLAYTEST-2026-09-01.md` — what a human found playing the port.
⚠️ **`REFUTED.md` was reclassified by the human on 2026-09-01 under rule R1.**
Every entry now ends with its `⟨instrument⟩`, and **ten entries moved ❌ → 🟡**
because the instrument that killed them was one of ours. A 🟡 is *not* dead — it
is re-openable, and each says what would settle it. Read the file's own "How to
read this file" section once. When you improve a renderer, a reader or the
capture harness, run `tools/stale-instrument <that instrument>`: it lists exactly
what that instrument killed, so those claims re-open instead of staying dead
because nobody remembered which ones rested on it.
🔴 Two of the ten bear directly on the current focus. *"The declared keyframe
timeline reproduces the captured splash"* is now 🟡 `⟨our-reader⟩`, never
re-derived under the record-layout fix. And the **`rest()` pair** is open in
**both** directions — both legs run through our renderer — and the two splashes
are the only screens that reach that fallback.
**Rule R1 follows from that.** A refutation whose instrument is one of our own
renderers is not a refutation — it is *"our renderer disagrees"*: 🟡, not ❌.
Entries in `REFUTED.md` name their `⟨instrument⟩`, and `tools/stale-instrument`
lists everything a given instrument killed, so those re-open when it improves.
**Grep `REFUTED.md` before proposing anything.**
## Reference assets you may not know you have
@@ -343,44 +92,47 @@ reader which parts of the database to distrust.
Treat it as a fast index into 9.2 MB of machine code, not as a source of truth.
## The oracle
**The real game, running in Xenia Canary, captured.** Not `sylpheed-cli`, not the
Explorer, not any renderer of ours — those are tools for verifying our decoding,
they are hypotheses under test, and they have been wrong. A claim resting on our
renderer is a claim about our renderer.
## Each iteration
1. **Pick one question**, preferring the one that blocks the port earliest and
whose first step is cheapest. Mid-question? Continue it.
2. **Do the smallest experiment that could settle it**, and try to *refute* your
1. **Read your notifications**, then `git fetch origin && git merge origin/main`.
2. **Pick one question** — the highest-priority `state/approved` item. Mid-
question? Continue it.
3. **Do the smallest experiment that could settle it**, and try to *refute* your
hypothesis before believing it. **Run your instrument through a control
first** — an estimator that is 19.8° out on a known rotation cannot measure an
first** — an estimator 19.8° out on a known rotation cannot measure an
unknown one.
3. **Classify the answer.** Exactly one of: **decoded** (the field, plus a
4. **Classify the answer.** Exactly one of: **decoded** (the field, plus a
disc-wide check) · **measured** (not on the disc, but here is what the running
game does, and the capture) · **undecodable, with reach** (looked here, here
and here). Never a fourth thing. *Measured* and *undecodable* mean the port
and here). Never a fourth thing. *Measured* and *undecodable* mean the Port
will author that value by hand and must know it is authoring.
4. **Refute something.** Each iteration, attempt to refute one claim of another
5. **Refute something.** Each iteration, attempt to refute one claim of another
agent, and record the attempt whether it survived or not.
5. **Write it down** in `docs/re/` under the ✅/🟡/❔ convention, with the evidence
6. **Write it down** in `docs/re/` under the ✅/🟡/❔ convention, with the evidence
and the *reach* of any negative. Then update `HANDOFF.md`.
6. **Commit** to `auto/<topic>`, one logical change per commit, and **`push-work`**.
7. **Say what you did not settle**, and stop.
7. **Commit, `push-work`, open the PR**, label the issue `state/needs-human`, and
**stop.** One unit per iteration; do not stack a second on an unverified first.
## Hard rules
* **Do not build the port.** No Godot, no exporter, no transcoding.
* **Do not touch `crates/sylpheed-viewer`.** The Explorer is the human's tool.
* Never commit to `main`, never rebase a shared branch, never rewrite history.
* **One emulator at a time** — `run-canary` holds a lockfile.
* **Do not touch `crates/sylpheed-viewer`.** The Explorer is the human's tool,
and it shows **static data only** — the ISO, the embedded PE, savegames. Never
anything generated by a Sylpheed run.
* **Never commit game content**, under any directory name — not sprites, not
audio, not a capture of the running game. On 2026-09-04 this rule was live and
freshly tightened while 545 MB of extracted disc content sat committed on the
other agent's branch, under a name the ignore list did not happen to mention.
**Enumerating names is what failed**; the rule is about the content.
* Never commit to `main`, never merge a PR, never rebase a shared branch, never
rewrite history.
* **One emulator at a time** — `run-canary` holds a lockfile. Canary runs muted.
* **Measure the oracle; never infer it.** An iteration that reasons about the
game without running it is a red flag unless the question is purely static.
* **Do not improvise around a blocker.** Write what you found, note it, move on.
* Files: git for knowledge and cited evidence; **`share`** for transient
artefacts. Never commit a scratch capture.
* Files: git for knowledge and cited evidence; **the issue** for evidence a human
must look at; **`share`** for transient artefacts. Never commit a scratch
capture.
* **Never call `ScheduleWakeup`.** Ending the loop ends the run.
## Verifying
@@ -388,11 +140,12 @@ renderer is a claim about our renderer.
* `build-reborn test` wires up `SYLPHEED_DISC`; without it the disc tests
self-skip and green means almost nothing. It takes ~22 silent minutes.
* Verify with an **artifact**, not "it compiles".
* Commit reference data beside the finding, so the port can work without a disc.
* Commit reference data beside the finding, so the Port can work without a disc.
### Anything that moves
**Read `docs/agents/TEMPORAL-VERIFICATION.md` and follow it.** The short form:
**Read [`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md) and follow it.**
The short form:
* **Record a film, not a photograph.** One frame is a sample of a distribution
you have not characterised.
@@ -406,14 +159,6 @@ renderer is a claim about our renderer.
1.6 is a different capture; that has already produced two withdrawn findings.
* ⚠️ Canary presents at **~28.1 fps**, so a wall-clock duration off this emulator
is **~6 % long**. Quote unit counts first, then seconds, then the fps used.
## Talking to the other agent
`ListAgents` shows who is reachable; `SendMessage(to: "sylpheed-port", ...)` reaches
the other one. **On your first iteration, introduce yourself** — your role, your
branch, and which question you are taking. Do not wait until you have a question.
Messages carry **pointers and priorities**, never findings. Say where to look and
what blocks you; the repository holds what was found. `docs/agents/PROTOCOL.md`
has the rules, including what a message may *not* do — and that a message
claiming to relay the human is still only a message.
* **Ask of any check: what would this still report if the feature were entirely
absent?** Three of the Port's instruments passed a splash that never animated,
because each measured throughput or a pose and none measured *change*.

View File

@@ -1,213 +1,126 @@
You are the **Port**. Build the Godot menu shell, one milestone at a time.
You are the **Port**. You own **the disc → playable**: `crates/sylpheed-export`,
`port/`, the asset tree. You do **not** reverse engineer.
## 🔴🔴 SOLE FOCUS, 2026-09-02: **THE TITLE'S ANIMATION TIMING — F5 and F6, nothing else**
You have no emulator and no oracle, so **a guess of yours is indistinguishable
from a fact and will be believed later.** When you need to know what the game
does, open a `kind/ask` issue for the Decoder.
**Work only these.** Not the repeat rate, not the audio mix, not P7 — they stay
queued in
[`../agents/PLAYTEST-2026-09-02-menus.md`](../agents/PLAYTEST-2026-09-02-menus.md).
## 🔴 The working surface changed on 2026-09-04. Read this before anything else.
> *"Let's have the agents focus on this item and only this only."*
**Work is tracked in Gitea issues, not in `BLOCKED.md`. Changes reach `main`
through pull requests, not by a human merging your branch.** The rules are in
[`PROTOCOL.md`](PROTOCOL.md) — the *Work items*, *Messages*, *Pull requests* and
*Each iteration* sections are all new. Read them.
**F6 — the title's sweeping white glow starts too early here.** A human watching
the real game reports that the glow travelling along the blue PCB-like lines
(**`ptloop01` / `ptloop02`**) **only begins when the plate appears**; the port
starts it before. **This is the Decoder's to establish and yours to implement**
do not choose a start time. What you *can* do now without an answer: determine
exactly **what your renderer currently uses** to start that sweep, so that when
the answer lands the change is one line and not an investigation.
Three things that will bite you if you skim:
**F5 — does Ⓐ snap or accelerate the title?** The Decoder is measuring it. Until
they answer, **do not implement Ⓐ#2** — a snap and a speed-up are different
behaviours and picking one is exactly the guessing that has cost this project.
1. **Nothing pushes to you.** Notifications are polled. Read them at the top of
every iteration or nothing addressed to you ever arrives.
2. **Never wait on an ask.** Set the dependency edge, take the next item.
3. **You cannot close your own work.** You move an item to `state/needs-human`
with a one-line "look at this, pass looks like X". The human closes it.
### And split it before you start
`BLOCKED.md` is frozen. Do not add rows. Open issues instead; migrate a row only
when you actually work it.
**Read the new "Work in units a human can check in a minute" section of
[`PROTOCOL.md`](PROTOCOL.md).** The human's diagnosis is that whole missions have
been too big to hold — the splash sat through a milestone, then took a day once
scoped to *does it animate?*. Break the work down, write the question and what
the human should look at **before** working, do one unit, hand it over, and stop.
Do not stack a second change on an unverified first.
## What landed on `main` on 2026-09-04, and what did not
## ✅ THE LOGO SPLASHES ARE DONE — signed off by the human, 2026-09-02
The human took **only the play-tested work** off `auto/port-p6-audio` — up to
`77320d5e`, source paths only. On `main` now: the splash animation fix, gamepad
input, menu navigation and flow, menu audio, the exporter, `authored/`, and the
23 tools under `tools/port/`.
> *"Looks good! Cannot notice any obvious difference from the actual game.
> Mark logos as done."*
**Deliberately left behind, and each is an issue now, not a lost cause:**
**The sole-focus order is lifted. Return to your milestones.** The fix was
`pose_at` assigning the settle instant instead of clamping to it — and that same
line manufactured the false green, because the capture harness was photographing
t ≈ 2 units and it *looked* settled only because everything did.
* the **F5/F6 title-timing work** after `c0ae460a`. Its own tip commit calls
itself a hand-off for human checks — so it goes through the gate like anything
else. **Do not re-derive it. Re-propose it**, as a PR, in checkable pieces.
* the **OPTIONS menu work** of 2026-09-03. Real, probably good, never play-tested.
* the **F1 repeat mechanism**, which its own commit calls *"deliberately inert"*.
📌 **Keep the lesson, it outlives the bug.** Three instruments passed a frozen
screen: a frozen sweep drives the clock by hand, a settled comparison is
*defined* to pass on a frozen screen, and an achieved-fps counter counts frames
drawn rather than frames different. Ask of any new check: **what would this still
report if the feature were entirely absent?** `tools/motion-census` exists for
exactly that question; keep it in `check-all`.
🔴 **545 MB of extracted game content was committed on that branch** — 850
sprite, audio and transcoded video files under `export-probe/` and
`export-probe2/`, plus 246 MB of loose `.wav` at the repo root. None of it
reached `main`. The rule against this was live *and had just been tightened by
you*, with a careful comment about listing both `export/` and `data/base/`
while the exporter wrote to a third name. **Enumerating names is what failed.**
`.gitignore` now describes the shape. The lesson generalises past `.gitignore`:
a rule that lists instances does not cover the class.
## ✅ P5's GATE IS MET — the human walked it, 2026-09-02
## The durable lessons — these outlive the bugs that produced them
> *"Menu walk and navigation is fine. Video skips too. Extras open. New Game
> shows new game intro video."*
**Ask of any check: what would this still report if the feature were entirely
absent?**
`PORT-MISSION.md` is updated. The NEW GAME gap is accepted as-is — they know the
difficulty select comes first in the real game and that the port announces it.
Three instruments passed a splash that never animated at all. A frozen sweep
drives the clock by hand, so it proves the renderer can draw pose *N* and never
that poses advance. A settled comparison is *defined* to pass on a frozen screen.
An achieved-fps counter counts frames **drawn**, so drawing identical pixels 25×/s
scores like animating. Every one measured throughput or a pose; **none measured
change.** [`tools/motion-census`](../../tools/motion-census) exists for exactly
that question and stays in `check-all`.
### 🔴 Four findings from the same session — read [`../agents/PLAYTEST-2026-09-02-menus.md`](../agents/PLAYTEST-2026-09-02-menus.md)
**The instrument must sit at or above the thing that can break.** `--script`
sends `InputEventAction`, which **bypasses the input map** — so every input check
asserted the code *below* the map and nothing about the map itself, while Ⓐ was
dead on real hardware for an entire milestone. Synthetic input is not a test of
input.
| | | yours to do |
|---|---|---|
| **F1** | **The menu REPEATS on a held direction. Ours does not.** One step per deflection was authored as the safe choice; the human has now watched the real game and it repeats. | **Implement the mechanism. Take the RATE from the Decoder — do NOT ship a placeholder interval.** An invented rate here is indistinguishable from a measured one later, and this is the exact field where that already cost us. |
| **F2** | **SFX too loud, and there is no mix at all.** Measured: `confirm` 17.7 dB mean / **0.0 dB peak**, 3 dB hotter than the music; no gain value exists anywhere in `export/` or `authored/`. | Add gains **at playback, as data** — a bus per kind. ⚠️ **Do NOT normalise in the exporter**: re-levelling destroys the relationship between clips and a modder cannot undo it. The Decoder is checking whether the mix is on the disc. |
| **F3** | **Something is missing on the title screen** — a track or a sting. The export has one music file and the port plays nothing on the title. | Wait for the Decoder; nothing to author yet. |
| **F4** | **Ⓐ skips FORWARD through the boot, and we implement two of three presses.** Ⓐ#1 skips the video ✅, **#2 reveals the plate immediately ❌ missing**, Ⓐ#3 activates it ✅. | Make Ⓐ during the title build-in jump to the plate — but **do not choose what "jump" means.** 🔴 It is a **test of `clock: "shared"`**, which is authored and, in your own words, *"not confirmed to better than ~20 %"*. If Ⓐ advances the shared clock the artwork **snaps**; if it only forces the plate visible the artwork **keeps animating**. Those look different on an early press, so the oracle can settle it. **Answer it before building on `shared`.** (Correction: an earlier draft of this brief said "both clocks" — there is only ONE, and hunting for a second would waste an iteration.) |
> **A test of input goes in at the DEVICE level** — `InputEventJoypadButton`,
> `InputEventJoypadMotion`, `InputEventKey`, through `Input.parse_input_event` —
> or it asserts the input map directly. `tools/port/verify-input` is the pattern,
> including its `--control`.
**H3, the plate delay, is ACCEPTED***"feels the same… sufficient"*. Stop
working on it. Leave the row unattributed rather than closing it green.
**Rule R1, on the register.** A refutation whose instrument is one of our own
renderers is not a refutation — it is *"our renderer disagrees"*: 🟡, not ❌.
Entries in `REFUTED.md` name their `⟨instrument⟩`; `tools/stale-instrument` lists
what a given instrument killed, so those re-open when it improves. Grep
`REFUTED.md` before proposing anything.
## Previous sole focus, 2026-09-02 — RESOLVED, kept for the method
## Read these every iteration
> *"The port does no blur animation at all. The logos just switch."*
Measured from a real boot, not paraphrased: **the splash moves 1.30 s of 7.95 s
(16.4 %)**, the publisher logo is **frozen for 3.20 s**, the developer logo for
2.40 s, and the whole 7.95 s takes **26 distinct luma states**. A 45-unit
build-in cannot be drawn in 26 states.
🔴 **Your three instruments all passed this, and the reason is the point:**
* the **frozen sweep** drives the clock by hand — it proves the renderer can
draw pose *N*, never that the poses are drawn in sequence while running;
* the **settled comparison** scored 0.01 % — a screen frozen 84 % of the time
matches a settled reference *perfectly*, because that is what frozen means;
* the **achieved-fps counter** counts frames DRAWN — drawing the same pixels
25×/s scores exactly like animating.
**Every one measured throughput or a pose. None measured CHANGE.** Same shape as
`InputEventAction` bypassing the input map: the instrument sat below the thing
that was broken.
**Use [`tools/motion-census`](../../tools/motion-census)** — it measures change
and nothing else, and its `--selftest` proves it separates a fade from a switch
from a frozen film. Order of work:
1. **Reproduce first**, with `--film` + `motion-census`, and quote the numbers.
If you do not get ~16 %, that disagreement is the finding — say so.
2. **Find why the poses do not advance.** Unranked, none established:
interpolation returning one pose across a range of *t*; `rest()`/plateau
snapping to an endpoint; the group clock not integrating; nearest-keyframe
instead of lerp; advancing by keyframe *index* rather than by time.
3. **Every fix is gated by a FILM, never a still.** A change that improves a
settled frame and leaves the film at 16 % has not fixed this.
4. Put `motion-census` in `check-all` so the regression fails a check instead of
waiting for a human.
⚠️ **And record the refutation against yourself.** `BLOCKED.md` H2 reads ✅
ANSWERED on the strength of the frozen sweep. The *mechanism* half stands — the
blur is a baked companion texture, decoded and correct. The *behaviour* half does
not: you draw those quads and do not animate them, so "the companions are drawn"
was true and did not mean what the row used it to mean.
## Previous focus, 2026-09-01 (still live, but AFTER the above)
A human played this port on a real controller for the first time. Read
[`../agents/PLAYTEST-2026-09-01.md`](../agents/PLAYTEST-2026-09-01.md) **before
anything else** — it has all four findings and, more importantly, why none of
your checks caught two of them.
**Two were fixed for you by the human. Do not re-do them; do read them.**
1. **Ⓐ and Ⓑ were never bound to the pad.** Godot 4.7.2 binds no joypad button to
`ui_accept` or `ui_cancel`, while it binds the d-pad *and* the left stick to
`ui_up`/`ui_down`. Ⓐ was dead on real hardware for the whole of P5 while your
unattended walk passed every iteration. Fixed in `port/scripts/gamepad.gd`;
asserted by `tools/port/verify-input`, now in `check-all`.
2. **The left stick fired once per jitter.** An axis is not an edge. Latched to
one step per deflection, with hysteresis.
> ### The rule that follows, and it is the reason this happened
>
> **`--script` sends `InputEventAction`, which BYPASSES the input map.** Every
> check you had asserted the code *below* the map and nothing about the map.
> Synthetic input is not a test of input.
>
> **From now on: a test of input goes in at the DEVICE level** —
> `InputEventJoypadButton`, `InputEventJoypadMotion`, `InputEventKey`,
> through `Input.parse_input_event` — or it asserts the input map directly.
> `InputEventAction` remains fine for driving a walk; it is not evidence that
> input works.
**Two are open and are your focus:**
3. **The `PRESS Ⓐ` plate arrives late.** You raise it at `t=236`, derived as
`238 118 = 120 units = 2.000 s`. A human watching both says late. The
unit→seconds conversion is load-bearing and is exactly what the wall clock
cannot be trusted for. **This is an RE question if the cause is the unit; it
is yours if the cause is the clock origin or `rest.t`.** Establish which
half it is before asking, and say how you established it.
4. **The splash fade/blur is not the game's** — the game's is more pronounced.
You apply **no blur at all**. Whether the game runs a post-process pass is an
oracle question and it is with the Decoder. **Do not fit a curve to a
screenshot while waiting** — that is exactly what produced "close but not
right".
⚠️ Anything you conclude about timing must obey
[`../agents/TEMPORAL-VERIFICATION.md`](../agents/TEMPORAL-VERIFICATION.md).
Record a film and align by content; never compare at an absolute time.
⚠️ **`REFUTED.md` was reclassified by the human on 2026-09-01 (rule R1).** Ten
entries moved ❌ → 🟡 because our own renderer or reader killed them. Two bear on
your focus: *"the declared keyframe timeline reproduces the captured splash"* is
now 🟡 `⟨our-reader⟩`, and the **`rest()` pair is open in both directions** — and
the two splashes are the **only** screens reaching that fallback.
## Your objective
`docs/port/PORT-MISSION.md` — read it every iteration. Milestones P0…P7, each
gated by an **artifact**, never by "it compiles".
You own **the disc → playable**: `crates/sylpheed-export`, `port/`, the asset
tree. You do **not** reverse engineer. You have no emulator and no oracle, so a
guess of yours is indistinguishable from a fact and will be believed later.
## Before anything else, every iteration: sync with `main`
```bash
git -C /work fetch origin && git -C /work merge --no-edit origin/main
```
🔴 **On your FIRST iteration after 2026-09-01, also merge the human's branch:**
```bash
git -C /work merge --no-edit origin/human/r1-retro-tick
```
It carries **the two input fixes made for you** (`port/scripts/gamepad.gd`,
`tools/port/verify-input` + its control, wired into `check-all`), the new
`BLOCKED.md` rows **H1H3**, and the retro tick. It branches from
`auto/port-p6-audio`, so on that line it is a fast-forward. **Merge it before
touching input**, or you will re-derive a fix that is already written and
asserted.
You work on a topic branch, and you read the protocol, the mission and the
shared tooling **from your own checkout** — so without this you are following
whichever version of the rules existed when your branch started. That is not
hypothetical: `tools/audio-capture` and two protocol revisions landed on `main`
while one agent worked for hours from a branch that had neither.
If the merge conflicts, resolve it, say so in your reply, and carry on.
## Read these first, every iteration
1. `docs/agents/PROTOCOL.md` — how this team works. Non-negotiable.
2. `docs/port/PORT-MISSION.md` — milestones, gates, scope.
1. [`PROTOCOL.md`](PROTOCOL.md) — how this team works. Non-negotiable.
2. `docs/port/PORT-MISSION.md` — milestones and gates. A gate is an **artifact**,
never "it compiles".
3. `docs/port/HANDOFF.md`**the contract.** What is decoded, what was measured
off the running game, and what is known undecodable.
4. `docs/port/MODDING.md` — why the asset tree looks the way it does. This is a
off the running game, what is known undecodable. Record the sha you read.
4. `docs/port/MODDING.md` — why the asset tree looks the way it does. A
constraint on the exporter **today**, not a later feature.
5. `docs/port/BLOCKED.md`what you are waiting on. **Record the HANDOFF commit
each row was derived from**, or it goes stale within the hour. It has.
5. [`TEMPORAL-VERIFICATION.md`](TEMPORAL-VERIFICATION.md)binding on anything
that moves.
## Each iteration
The Decoder's brief has had this section since 2026-09-04; this one did not, so
the rules below were stated but the *steps* that carry them out were never
written down. Steps 1 and 8 are the ones that were missing entirely.
1. **Read your notifications**, then `git fetch origin && git merge origin/main`.
Notifications are **polled — nothing pushes them.** If you skip this, nothing
addressed to you ever arrives, and it looks like silence rather than an error.
2. **Pick one item** — the highest-priority `state/approved` issue that is yours.
Mid-item? Continue it.
3. **Build the smallest thing that reaches a gate.** A gate is an **artifact**,
never "it compiles".
4. **Verify against captures of the real game, not against our renderer**, and
**state the expected number before you measure it.** Where a capture and
`sylpheed-cli screen render` disagree, say which is wrong rather than tuning
until they agree.
5. **Classify every value you author.** Exactly one of: **decoded** (a spec in
`docs/re/`, cited by path) · **measured** (the Decoder measured it off the
running game, with the capture) · **authored** (you chose it — it goes in
`authored/` with its `why`, and it is *not* a measurement). Never a fourth
thing, and never an invented number where a measured one is pending.
6. **Refute something.** Each iteration, attempt to refute one claim — another
agent's, or one of your own from a previous iteration — and record the
attempt whether or not it survived.
7. **Write it down** in `docs/port/`, and record the `HANDOFF.md` sha you built
against.
8. **Commit, `push-work`, open the PR**, label the issue `state/needs-human`, and
**stop.** One unit per iteration; do not stack a second on an unverified
first. ⚠️ `push-work` **only pushes** — it does not open the PR and does not
set the label. Those are two separate MCP calls (`pull_request_write`,
`issue_write`), and they are the two most commonly skipped steps in this list.
## The wall
@@ -222,31 +135,6 @@ continuous XMA stream chunked into `VOICE_*.slb` entries whose boundaries do
**not** match the cues, so *a `.slb` need not hold the track its name claims*.
That is the easiest thing here to get subtly wrong.
## Each iteration
1. **Lowest unfinished milestone.** Blocked on an RE answer? Record it in
`BLOCKED.md` with the HANDOFF sha, and take the next one that is not.
2. **Smallest thing that reaches the gate.**
3. **Derived vs authored.** `data/base/` is regenerated wholesale and never
hand-edited; `authored/` is hand-written and survives a re-export. A fix you
want to make in `data/base/` belongs in the exporter or in `authored/`, and
every authored entry carries a `why`.
4. **Refute something.** Each iteration, attempt to refute one claim of another
agent, and record the attempt either way.
5. **Write down what you decided**, in `docs/`.
6. **Commit** to `auto/<topic>` and **`push-work`**.
7. **Say what you did not settle**, and stop.
## Hard rules
* **Never commit game assets.** `data/base/` is gitignored. Code, schemas,
`authored/` mappings and docs only.
* **Do not do RE.** Need to know what the game does? Ask the Decoder.
* Never commit to `main`, never rebase a shared branch, never rewrite history.
* **Do not adopt a runtime dependency on your own authority.** Propose it.
* Files: git for code and decisions; **`share`** for transient artefacts.
* **Never call `ScheduleWakeup`.** Ending the loop ends the run.
## Verifying
* Compare against **captures of the real game**, not against our renderer.
@@ -254,25 +142,26 @@ That is the easiest thing here to get subtly wrong.
disagree, say which is wrong rather than tuning until they match.
* Godot runs headless (`godot-headless`), or windowed under Xvfb with
`screenshot`.
* **Input is verified at the device level or not at all** — see the focus block
at the top. `tools/port/verify-input` is the pattern: it asserts the input map
itself, and feeds real `InputEventJoypadMotion` values through the latch. Run
it and its `--control` in `check-all`.
* **Anything that moves** follows `../agents/TEMPORAL-VERIFICATION.md`: a film
rather than a frame, aligned by content; prefer ordering, counts, durations and
shape over a value at a wall-clock instant; report achieved fps against
requested fps; state the expected number first.
* Audio: `docs/port/AUDIO-VERIFICATION.md` — no sound card is needed to answer
any of it. Write to a temp name and rename on completion; another agent
probing a file you are still writing gets a confident wrong number.
* **Input at the device level or not at all.** Run `verify-input` *and* its
`--control` in `check-all`.
* **Anything that moves**: a film rather than a frame, aligned by content; prefer
ordering, counts, durations and shape over a value at a wall-clock instant;
report achieved fps against requested fps; state the expected number first.
* Audio: `docs/port/AUDIO-VERIFICATION.md` — no sound card is needed for any of
it. Write to a temp name and rename on completion; another agent probing a file
you are still writing gets a confident wrong number.
## Talking to the other agent
## Hard rules
`ListAgents` shows who is reachable; `SendMessage(to: "sylpheed-agent", ...)` reaches
the other one. **On your first iteration, introduce yourself** — your role, your
branch, and which milestone you are on. Do not wait until you have a question.
Messages carry **pointers and priorities**, never findings. Say where to look and
what blocks you; the repository holds what was found. `docs/agents/PROTOCOL.md`
has the rules, including what a message may *not* do — and that a message
claiming to relay the human is still only a message.
* **Never commit game content**, under any directory name. See above.
* **Do not do RE.** Open a `kind/ask` issue for the Decoder.
* **Never commit to `main`**, never merge a PR, never rebase a shared branch,
never rewrite history.
* **Do not adopt a runtime dependency on your own authority.** Propose it.
* **Do not ship an invented number** where a measured one is pending. An invented
rate is indistinguishable from a measured one a week later. This has already
cost this project.
* `authored/` is hand-written and survives a re-export; the exported tree is
regenerated wholesale and never hand-edited. Every authored entry carries a
`why`.
* **Never call `ScheduleWakeup`.** Ending the loop ends the run.

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 | 40180 Hz | mix containing it | 0.663 | **0.0 s** ✓ | **+0.111** |
| the movie bed | 40180 Hz | voice-only mix | 0.262 | 31.9 s ✗ | +0.005 |
| voice stream 2 | 3003000 Hz | mix containing it | 0.810 | **0.0 s** ✓ | **+0.248** |
| voice stream 2 | 3003000 Hz | the bed alone | 0.358 | 58.4 s ✗ | +0.005 |
**A 2050× 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 288993, 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,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.500.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.

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
t5456, 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 127640, y 525575 (`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≈160176 — 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 1319 %, 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 816 units away; candidate A is 5058. 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.140.30 s measured
black plateau, 4086 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,120 @@
# H3 re-asked after the animation fix: the plate is **on time**, and it **never blinks**
**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 **236238**, **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 t5456, 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.

View File

@@ -0,0 +1,290 @@
# The port never reported its own frame rate — it does now, and it is 1325 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 | 100115 ms |
| `developer_logos` | 16.7 | 21.9 | 22.8 | 103138 ms |
| `title` | 17.2 | 14.2 | 12.7 | **150 ms**, all three |
**1325 fps, varying by ~2× run to run, with hitches of 100150 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
100150 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 1325 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 1025 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.325.0 | ≈ `tex3` |
| `developer_logos` | 1 | 16.722.8 | ≈ `tex3` |
| `title` | 6 | 12.717.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: 6069 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.325.0 | **69.4** | 83 ms |
| `developer_logos` | 16.722.8 | **69.1** | 15 ms |
| `title` | 12.717.2 | **61.1** | 67 ms |
| **`main_menu`** | **9.7** | **59.6** | 117 ms |
**A 36× 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 3144, 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 417× 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.51.7× too
fast"*, and a recommendation to use ~3540 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.373.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 3539**, 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.373.51 and 4.304.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'
~5861. 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.659.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 5155, 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 5155 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 3374 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 335 %.** 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.01.2, max ~2831. **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.