diff --git a/README.md b/README.md index 1ed04b0..0e27e5e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/BLOCKED.md b/docs/BLOCKED.md index 2812e54..97a20f9 100644 --- a/docs/BLOCKED.md +++ b/docs/BLOCKED.md @@ -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. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d427900..13f4c5f 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 `/../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.