# Decisions One entry per decision that outlives the container it was made in. Newest last. A decision that lives only in an agent's context is lost when that container dies, which is what this file is for. --- ## P0 — the exporter, 2026-08-28 ### The exporter reads one authored file, and stamps its provenance into the output `export/` is derived and `authored/` is hand-written, and the natural reading of that is that the exporter never touches `authored/`. But a screen has to be *called* something, and the disc does not name its builds — the identification of build 5 as the main menu is HANDOFF Q2, **measured against a live capture**, not a field. Two ways to handle that: 1. the exporter emits `build_05.json` and the runtime renames it from `authored/screen_names.json`; 2. the exporter reads that map and writes `main_menu.json` directly. Chose **2**, with a condition: every name it applies carries `name_source: "authored"` and a `name_why` quoting the evidence, and `check` **rejects** an authored name with no `why`. The file that lands in `export/` is therefore still honest about which of its fields is a measurement — which is the property the derived/authored split exists to protect — while a human opening the tree sees `main_menu.json` rather than having to resolve a rename in their head. A build nobody has identified exports as `build_NN` with `name_source: "index"`, which is a locator and not a claim. This is the **only** authored input the exporter takes. Everything else in `authored/` is applied by the runtime over `export/`. ### Sprites are per screen, not a flat pool `main_menu` and `extras` both ship a `ptbase.t32` and they are different pictures. A flat `sprites/` directory would have silently collided; whichever screen exported second would have won, and the loser would have drawn the wrong background with no error anywhere. `sprites///.png`. ### The format is executable `sylpheed-export check --out export` validates a tree against `docs/FORMAT.md` with no disc in hand. It exists because "the export is correct" is otherwise an assertion, and because the P0 gate is *"validates against FORMAT.md"* — which is not a thing anyone can confirm by reading. It reads the tree the way Godot will: as a stranger, with no access to the disc, the decoders, or the exporter's internals. It deliberately does **not** check the export against the disc — that is what `sylpheed-cli screen render` is for, at P1. Checked that it bites, rather than assuming: five mutations of a valid `main_menu.json` — a broken `paint_order` permutation, a dangling `focus_sprite`, a reversed `buttons` list, a `#rrggbbaa` colour, an invented `name_source` — are each caught with a specific message. ### The highlight sprite pairs by name; `opt ` is exported but not believed FORMAT v1 said `focus_sprite` came from the element's `opt ` link. That reading was **measured and refuted** by the RE agent, and this export shows why plainly: on the main menu, `opt ` chains `ptloop01 → ptloop02 → ptbtn01` — two decorations and then a button. It is a linked list of something, and it is not focus. The highlight is paired by **sprite name** instead (`ptbtn01.t32` ↔ `ptbtn01f.t32`), which is HANDOFF's convention and holds for all 54 real pairs on the disc. It resolves all five main-menu buttons. The raw link is still exported as `opt_link`, renamed so that nothing downstream mistakes it for navigation, and so that whoever eventually decodes it has the data. Note this is 🟡 a naming convention, not a decoded field. It is authored in effect, and lives in the exporter only because it is a rule over disc data rather than a value we chose. ### The paint order is exported, not authored Q3 decoded it — a `u16` layer key at `+0x0A` of each `T8aD` sprite header, stable-sorted with declaration index. So it is read in the exporter, per the contract's own rule for a decoded answer, and `paint_order` in `export/` is a derived field. `"paint_order"` is gone from `unresolved`; **`paint_order_ties` replaces it**, because the tie-break is still unknown and costs one element's blend on one screen. Where an element has no `T8aD` header the key comes from the decoders' table of keys **measured off the running game**. That is a different kind of fact, so it is labelled: `layer_source` is `"sprite"`, `"implied"` or `"none"`, and a consumer that needs to know whether a layer is read or measured can tell. ### Colours are exported as two fields with the byte order in the name There are two modulate colours and they multiply: `tint` is RGBA, `fade` is **ARGB** and its high byte is the alpha that ramps. v1's single `"#ffffffff"` could not carry both and silently discarded the ramping alpha. They are exported as `tint_rgba` and `fade_argb`, raw hex, byte order in the key — because getting it backwards is silent and looks like an art bug rather than a parse bug. ### `t` stays raw HANDOFF Q1 is answered — linear ramp, 2 units per rendered frame, working conversion 1 unit = 1/60 s — but that conversion is **measured off the running game, not read from the file**, and the finding itself flags the 27.6 present- frames/second measurement as the part worth re-testing. If the game turns out to present at 60 Hz, every duration halves. So `t` is exported exactly as the disc spells it, `keyframe_time_unit` stays in `unresolved`, and the conversion will live in one authored place at P2. One constant to change, in a file that says it is a decision. ### The final keyframe has no `t`, and `check` enforces that 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.