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 bc6354d, 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 a79acdd29a
commit 146a604500
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 | | `port/` | the Godot 4 project |
| `authored/` | decisions that are **not** on the disc, each with its reason | | `authored/` | decisions that are **not** on the disc, each with its reason |
| `export/` | generated, gitignored, never hand-edited | | `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 | | `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 ## 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 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. 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 ## 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 Not blocking anything today; raised because the port found them and a guess here
would be believed later. 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` ### The pivot is not half the texture on `GP_TITLE`
`sylpheed-formats`'s `ui_layout::Element::pivot_x` is documented as "for a `.t32` `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 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 `sylpheed-cli screen render` does — so a P1 diff cannot distinguish them, and
agreement between the two is not evidence. 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 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 temptation to emit a plausible number is strongest and the resulting error is
completely invisible. 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.