docs: the P1 diff, and the one question it raised

`main_menu` -- the milestone's gate -- agrees with the reference renderer to
within 3/255 on every channel of every pixel, RMSE 0.38 %, no pixel above 4 %.
Nine of the twelve screens are at or under that; two are byte-identical.

Three exceed it, and each gets a cause rather than a wider tolerance:

* `title` (max 6): every disagreement is INSIDE A TIE -- the derived order and
  the CLI's measured-off-the-game order differ only among elements with
  identical layer keys. That is exactly the residual HANDOFF Q3 documents and
  the export already declares in `unresolved: paint_order_ties`. Cost: 904 px at
  4-6/255 in one glow band. The port keeps the stable sort; fitting it to one
  screen's capture would be tuning.

* `title_jp` (max 154): `ptlogo_eff2` at 125 % is the only drawn element in the
  whole export at a scale that is not a whole multiple of 100 %, and `title_jp`
  is the only screen over 6/255. Same fact twice. `ui_layout::blit` samples the
  source at the destination pixel's top-left corner; a GPU samples at its
  centre, and at 125 % those disagree on one column in five.

  I think the CLI is the one that is wrong -- corner-sampled nearest is a
  half-pixel bias toward the top-left that no rasteriser produces. But that is a
  reading, not a measurement: it needs a framebuffer capture of the Japanese
  title screen, so it is filed in BLOCKED.md as a question. The port is NOT
  changing to match, because matching would mean reproducing a half-pixel offset
  on purpose to make a number smaller.

* `extras` (max 4): two pixels.

Also reconciled: the pivot question predicted a P1 diff could not distinguish
the declared pivot from half the texture, because both renderers use the
declared one. That held. Recorded so the agreement is not later mistaken for
evidence -- and P2 will not settle it either.

BLOCKED.md's `/reborn` HEAD updated to 690683d, with a note that the mount is
read-only by design and `git -C /reborn pull` fails rather than being skipped.
This commit is contained in:
Sylpheed port agent
2026-08-28 19:31:21 +00:00
parent 65fe6e04a2
commit 6e46b6a136
3 changed files with 208 additions and 2 deletions

View File

@@ -42,8 +42,27 @@ assuming a value is on the disc.
| `port/` | the Godot 4 project |
| `authored/` | decisions that are **not** on the disc, each with its reason |
| `export/` | generated, gitignored, never hand-edited |
| `tools/` | verification harnesses that hold the port to the reference renderer |
| `docs/` | the mission, the format spec, the agent's loop prompt |
## Verifying
`sylpheed-cli screen render` -- built from the same `sylpheed-formats` revision
the exporter is pinned to -- is the reference renderer. `tools/verify-screen`
draws every exported screen both ways and reports the largest per-channel
difference in the frame:
```
tools/verify-screen # every screen in the manifest
tools/verify-screen main_menu # one of them
```
Where the two disagree, one of them is wrong; `docs/DECISIONS.md` says which and
why, rather than tuning the port until the number goes down.
## Status
Pre-P0. Nothing runs yet.
**P1.** The exporter writes `GP_TITLE`'s twelve screen builds and their sprites,
and the Godot project draws any of them statically at 1280x720 from that tree
alone. `main_menu` matches the reference renderer to within 3/255 on every
channel of every pixel. Next: P2, keyframe animation.

View File

@@ -9,7 +9,9 @@ a decoded one a month from now. Where a milestone can proceed with a placeholder
the placeholder goes in `authored/` with a `why` naming the question it stands in
for, so it is deleted rather than forgotten when the answer arrives.
Last reconciled against HANDOFF.md on **2026-08-28**, at `/reborn` HEAD `e81dcad`.
Last reconciled against HANDOFF.md on **2026-08-28**, at `/reborn` HEAD `bc6354d`.
(`/reborn` is mounted read-only, so `git -C /reborn pull` fails by design; the
mount is refreshed outside this container and HEAD is read, not fetched.)
## Still open — these block work
@@ -46,6 +48,28 @@ than exported:
Not blocking anything today; raised because the port found them and a guess here
would be believed later.
### Does the game sample a scaled sprite at the pixel corner or the pixel centre?
Found at P1, by the only screen it could have been found on. `title_jp`'s
`ptlogo_eff2` is the **single drawn element in the whole export** at a scale that
is not a whole multiple of 100 % (125 %), and `title_jp` is the only one of the
twelve screens whose Godot-vs-CLI diff exceeds 6/255.
The two renderers pick different source texels at a non-integer ratio.
`sylpheed_formats::ui_layout::blit` samples at the destination pixel's **top-left
corner** (`sxi = col * sw / dw`); a GPU samples at its **centre**
(`floor((col+0.5)*sw/dw)`). At 125 % they disagree on one column in five — ~30
pixels above 100/255, strung along thin diagonal edges. At every whole multiple
of 100 % they agree exactly, which is why the other eleven screens are clean.
The port has **not** changed to match: matching would mean reproducing a half-
pixel bias on purpose to make a number smaller. The question for the RE agent,
when it is cheap: **a framebuffer capture of the Japanese title screen** would
settle it outright, and it is the kind of thing a capture answers in one look.
Cost of being wrong either way: a one-texel edge on one glow, on a screen the
English boot path never shows. This is filed, not urgent.
### The pivot is not half the texture on `GP_TITLE`
`sylpheed-formats`'s `ui_layout::Element::pivot_x` is documented as "for a `.t32`
@@ -68,3 +92,8 @@ differ by up to 59 px on `ptlogo_back2`, which is visible. Until then the port
follows the decoders and uses the declared pivot, which is also what
`sylpheed-cli screen render` does — so a P1 diff cannot distinguish them, and
agreement between the two is not evidence.
**P1 has now been run and that prediction held.** The port and the CLI agree on
every scaled element across all twelve screens; the question is untouched by it.
It will stay untouched by P2 as well, since P2 animates the same two renderers'
shared assumption. Only a capture answers this.

View File

@@ -114,3 +114,161 @@ The disc has no time slot on the last keyframe of a group. A file that carries
one there has invented it. `check` rejects it — this is the one place where the
temptation to emit a plausible number is strongest and the resulting error is
completely invisible.
---
## P1 — Godot draws the screen, 2026-08-28
### The Godot side reads the manifest, not a path
`ExportTree` is the only class that knows where `export/` is: `SYLPHEED_EXPORT`
if set, otherwise `<project>/../export`. Screens are addressed by their manifest
**name** (`main_menu`), never by a file path, so the runtime never encodes the
archive's subdirectory and a re-export that moves a file does not break it. It
also checks `format` on both the manifest and each screen, and refuses a tree it
was not built to read rather than half-drawing one.
Textures are read as bytes and decoded with `load_png_from_buffer` at runtime.
They are deliberately **not** Godot-imported resources: `export/` is gitignored
and regenerated wholesale, and a `.import` sidecar per sprite would be derived
state living next to derived state, invalidated on every re-export.
### One CanvasItem draws the whole screen
`ScreenView._draw` walks `paint_order` and draws each element itself, rather
than making a node per element and leaning on `z_index`. The export's
`paint_order` is already back-to-front, so honouring it is a loop; expressing
the same order through sixteen nodes' z-indices would hide the one thing that is
still unresolved about it — the **ties** — behind Godot's own sibling rules,
where a change in the export would silently become a change in Godot's tree
order instead of a visible change in the draw sequence.
### P1 draws `rest` and nothing else
Every element is drawn at its resting pose. No keyframe interpolation: that is
P2, and it depends on the keyframe time unit, which is **measured** rather than
decoded. A milestone whose gate is a pixel diff must not have a measured
constant inside it, or the diff stops being evidence about the port.
For the same reason `focused_id` is empty at P1. Initial focus was measured as
unstable boot to boot (HANDOFF Q5), so choosing one is an authored decision and
it belongs to P5, where a human is pressing keys.
### Nearest-neighbour, and why that is not a preference
`TEXTURE_FILTER_NEAREST`. The export is a 1:1 copy of the disc's own texels and
elements draw at up to 500 %; a bilinear filter invents detail the disc does not
have. It is also what the reference renderer does — `ui_layout::blit` maps
destination to source by integer division — so a filter difference cannot
masquerade as a placement difference in the diff.
### The capture is the SubViewport, not the window
The screen is drawn into a `SubViewport` sized to the export's own `design`
rectangle and shown through a container that scales it to the window. The first
attempt captured `get_viewport()` and got **1235×695**: there is a window manager
on the Xvfb display and its title bar had eaten 45×25 px of a screen the export
declares as 1280×720. A gate that compares a rescaled 1235×695 capture against a
1280×720 composite measures the compositor.
So `--capture` grabs the SubViewport texture: exactly the design rectangle,
independent of the window, directly comparable with `screen render` with no crop
and no resample. The windowed run is still worth doing — it is what proves a
human sees the screen — but it is not what the numbers come from.
## P1 gate — the diff, and what it found
`tools/verify-screen` renders every screen in the manifest both ways and reports
the largest per-channel difference anywhere in the frame. Both renderers are held
to the same inputs: the reference CLI built by `build-reference-cli` from the
revision the exporter is **pinned** to (not `/reborn/target/`, which is a live
mount that moves mid-iteration), `--black` because the screen carries its own
background, and `--primitives --animated` because those are what make the CLI
draw the same element set the port draws at rest.
| screen | build | max per-channel Δ | |
|---|---|---|---|
| `main_menu` | 5 | **3** | the P0/P1 gate screen |
| `main_menu_jp` | 8 | 3 | |
| `extras` / `extras_jp` | 6 / 9 | 4 / 3 | |
| `press_start` / `press_start_jp` | 2 / 3 | 1 | |
| `build_00` / `build_01` | 0 / 1 | 3 | |
| `build_10` / `build_11` | 10 / 11 | **0** | byte-identical |
| `title` | 4 | 6 | paint-order tie, below |
| `title_jp` | 7 | 154 | sampling phase, below |
`main_menu` — the milestone's own gate — agrees to **≤3/255 on every channel of
every pixel**, RMSE 0.38 %, with **no** pixel differing by more than 4 %. 3/255
is what integer-truncating compositing in the CLI and float rounding on a GPU
differ by; there is no structural disagreement anywhere in the frame.
Three screens exceed that, and each has a named cause rather than a threshold.
### `title`: a tie in the paint order — neither renderer is wrong
Build 4 is the one screen where the CLI uses a paint order **measured off the
running game** instead of deriving it. Compared against the order this port
exports, every single disagreement is **inside a tie** — the two orders differ
only among elements carrying *identical* layer keys (`0x8083`, the `back2` glow
group, and `0x80a0`):
```
derived : … 15, 16, 17, 18, 0, 1, 2, 3, 4, 5, 7, …
measured: … 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, …
```
That is exactly the residual HANDOFF Q3 documents and this export already
declares in `unresolved: ["paint_order_ties"]`. It is worth stating what it
costs: **904 px** in the glow band at (445,117)(1195,313), all of them 46/255.
The port keeps the stable sort, per HANDOFF's own recommendation. Nothing to fix,
and nothing to tune — a "fix" here would be fitting the port to one screen's
capture.
Two of the reordered indices (`0x80a0`) are `kind & 0x4` template instances that
both renderers skip, so the only real reorder outside the glow group is
`ptlogo2` against `ptlogo_tm`, which do not overlap.
### `title_jp`: nearest-neighbour sampling phase — the CLI is the one I would call wrong
`title_jp` is the **only** screen in the export with a drawn element at a scale
that is not a whole multiple of 100 %: `ptlogo_eff2` at 125 %. It is also the
only screen with a difference above 6/255. The two facts are the same fact.
At a non-integer ratio the two renderers pick different source texels:
* `ui_layout::blit` samples the source at the destination pixel's **top-left
corner** — `sxi = col * sw / dw`.
* A GPU samples at the destination pixel's **centre** — `floor((col+0.5)·sw/dw)`.
At 125 % those disagree on one column in five, which is why the differing pixels
are ~30 above 100/255 strung along thin diagonal edges rather than a shifted
region. At every whole multiple of 100 % they agree exactly, which is why the
other eleven screens are clean.
**Which is wrong:** the CLI, I think. Corner-sampled nearest is a half-
destination-pixel bias toward the top-left that no rasteriser produces, and the
Xenon GPU that drew this screen sampled at pixel centres. But I have no
framebuffer capture of `title_jp` and the disagreement is sub-pixel on one glow,
so this is a reading, not a measurement — recorded in `docs/BLOCKED.md` rather
than acted on. **The port is not changing to match**, because matching the CLI
here would mean deliberately reproducing a half-pixel offset in order to make a
number smaller.
### `extras`: two pixels
Two pixels at 4/255. Rounding.
### What the diff cannot tell us
The pivot question in `docs/BLOCKED.md` predicted that a P1 diff could not
distinguish "anchor scale to the declared pivot" from "anchor to half the
texture", because both renderers use the declared pivot. That prediction held:
the port and the CLI agree on every scaled element, and that agreement is **not
evidence** about which anchor the game uses. It stays open.
### `pteff05.t32` and `pteff04.t32` have no sprite, and that is correct
Both renderers skip them. The bundle declares them and carries **zero** RATC
children for either, so there is no texture on the disc to export — this is a
property of the disc, not a gap in the exporter, and `ScreenView` reports it as
`no sprite in the export` rather than dropping it silently.