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:
21
README.md
21
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 4–6/255.
|
||||||
|
The port keeps the stable sort, per HANDOFF's own recommendation. Nothing to fix,
|
||||||
|
and nothing to tune — a "fix" here would be fitting the port to one screen's
|
||||||
|
capture.
|
||||||
|
|
||||||
|
Two of the reordered indices (`0x80a0`) are `kind & 0x4` template instances that
|
||||||
|
both renderers skip, so the only real reorder outside the glow group is
|
||||||
|
`ptlogo2` against `ptlogo_tm`, which do not overlap.
|
||||||
|
|
||||||
|
### `title_jp`: nearest-neighbour sampling phase — the CLI is the one I would call wrong
|
||||||
|
|
||||||
|
`title_jp` is the **only** screen in the export with a drawn element at a scale
|
||||||
|
that is not a whole multiple of 100 %: `ptlogo_eff2` at 125 %. It is also the
|
||||||
|
only screen with a difference above 6/255. The two facts are the same fact.
|
||||||
|
|
||||||
|
At a non-integer ratio the two renderers pick different source texels:
|
||||||
|
|
||||||
|
* `ui_layout::blit` samples the source at the destination pixel's **top-left
|
||||||
|
corner** — `sxi = col * sw / dw`.
|
||||||
|
* A GPU samples at the destination pixel's **centre** — `floor((col+0.5)·sw/dw)`.
|
||||||
|
|
||||||
|
At 125 % those disagree on one column in five, which is why the differing pixels
|
||||||
|
are ~30 above 100/255 strung along thin diagonal edges rather than a shifted
|
||||||
|
region. At every whole multiple of 100 % they agree exactly, which is why the
|
||||||
|
other eleven screens are clean.
|
||||||
|
|
||||||
|
**Which is wrong:** the CLI, I think. Corner-sampled nearest is a half-
|
||||||
|
destination-pixel bias toward the top-left that no rasteriser produces, and the
|
||||||
|
Xenon GPU that drew this screen sampled at pixel centres. But I have no
|
||||||
|
framebuffer capture of `title_jp` and the disagreement is sub-pixel on one glow,
|
||||||
|
so this is a reading, not a measurement — recorded in `docs/BLOCKED.md` rather
|
||||||
|
than acted on. **The port is not changing to match**, because matching the CLI
|
||||||
|
here would mean deliberately reproducing a half-pixel offset in order to make a
|
||||||
|
number smaller.
|
||||||
|
|
||||||
|
### `extras`: two pixels
|
||||||
|
|
||||||
|
Two pixels at 4/255. Rounding.
|
||||||
|
|
||||||
|
### What the diff cannot tell us
|
||||||
|
|
||||||
|
The pivot question in `docs/BLOCKED.md` predicted that a P1 diff could not
|
||||||
|
distinguish "anchor scale to the declared pivot" from "anchor to half the
|
||||||
|
texture", because both renderers use the declared pivot. That prediction held:
|
||||||
|
the port and the CLI agree on every scaled element, and that agreement is **not
|
||||||
|
evidence** about which anchor the game uses. It stays open.
|
||||||
|
|
||||||
|
### `pteff05.t32` and `pteff04.t32` have no sprite, and that is correct
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user