agrees with the timeline P2's gate is the buttons sliding in, and `tools/screen-strip` renders the strip that shows it. But the useful result came out of checking where the animation settles. On 8 of 12 screens the settled timeline is BYTE-IDENTICAL to the declared `rest` pose -- the port walks the keyframes with an authored time unit and arrives, to the pixel, where the pinned decoders independently say the screen rests. On `main_menu` the two differ in exactly one region, 400x470 at (440,108): the bounding box of `ptframe1` and `ptframe2` and nothing else. `rest` puts both at their first keyframe, off-position and transparent. The capture of the running game shows them -- the bright circuit bracket around the menu. Cropping the same region from the capture and from both renders puts the ring and its elbow trace in the timeline render pixel-aligned with the game's, and absent from the rest render. Geometry, so it does not depend on the capture's gamma or on its having been taken with NEW GAME focused. `ui_layout::rest_plateau` excludes a trailing run of identical keyframes because it is normally the exit. On an element with NO exit animation the trailing run IS the hold. The condition that identifies these exactly, with no false positives here, is "the final untimed keyframe has the same pose as the last timed one" -- six elements, and `rest()` misses all six. Filed in BLOCKED.md for the RE agent: the decoders are pinned and are not this port's to fix, and `sylpheed-cli screen render` is missing the bracket too. Worth saying plainly what this does to P1: the port and the reference agreed on `main_menu` to 3/255 and BOTH were missing two elements the game draws. Two renderers reading one field through one decoder agreeing is not evidence the field is right. BLOCKED.md had already said that about the pivot; here it bit. The title is NOT settled and P2 does not claim it. `rest` and the timeline disagree there by 142-247/255, the only live title capture composites the PRESS A plate over build 4 so it cannot be diffed against the title alone, and both of the port's modes draw a cyan glow slab the game does not have -- a third problem, P3's. Recorded as an open question rather than resolved by tuning. Also reconciled against the RE agent's new work: Q8 is answered -- the SE waves are located in `Static.slb` (move/confirm/back), which unblocks P6's audio; and the title's transitions are a lookup by NAME, giving P3/P5 the game's own screen vocabulary as candidate `goto` targets, marked as the name match it is.
394 lines
20 KiB
Markdown
394 lines
20 KiB
Markdown
# 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/<subdir>/<screen>/<name>.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 `<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.
|
||
|
||
---
|
||
|
||
## P2 — keyframe animation, 2026-08-28
|
||
|
||
### The time unit is authored, in one file, and says loudly that it is not on the disc
|
||
|
||
`authored/timing.json`. HANDOFF Q1 is answered — linear ramp, 2 units per
|
||
rendered frame, 1 unit = 1/60 s — but that conversion is **measured off the
|
||
running game**, not read from a file, which is exactly the case the
|
||
derived/authored split exists for. It is expressed as
|
||
`keyframe_units_per_second: 60` rather than seconds-per-unit so the value is
|
||
exact instead of a repeating decimal, and it carries the two independent lines
|
||
that support it. `t` stays raw everywhere in `export/`; seconds appear only
|
||
where this file is applied, which is one line of `boot.gd`.
|
||
|
||
`exit_ramp_seconds` is deliberately **null**. See below.
|
||
|
||
### The timeline stops at the last *timed* keyframe, and never plays the exit
|
||
|
||
The last keyframe of every group carries **no `t`** — the disc has no time slot
|
||
there. Across this export that final frame is an *exit* pose: for 116 of 134
|
||
elements it differs from the last timed keyframe **in alpha only** (a fade-out),
|
||
for 12 it is the loading splash's scale-and-slide exit, and for 6 it is
|
||
identical (no exit animation at all).
|
||
|
||
So the group is `pre-roll → ramp in → hold → [exit]`, and the port plays it up to
|
||
the hold and stops. Playing into the exit would mean **inventing how long the
|
||
ramp takes**, because the disc does not say. That duration is the screen
|
||
transition — HANDOFF Q7 measured it at ~0.4 s — and it belongs to P3, with its
|
||
own evidence. This is why `exit_ramp_seconds` is null rather than 0.4: P2 has no
|
||
business holding it.
|
||
|
||
### The interpolation is checked by where it lands, not by inspection
|
||
|
||
For **8 of the 12** screens the settled timeline is **byte-identical** to the
|
||
`--pose=rest` render. That is the useful assertion: the port walks the keyframes
|
||
with an authored time unit and arrives, to the pixel, at the pose the pinned
|
||
decoders independently identify as the resting one. `tools/screen-strip` reports
|
||
this per screen, so a change to the interpolation that drifts by one unit shows
|
||
up as a diff rather than as nothing.
|
||
|
||
The four that differ do so for two distinct reasons, below.
|
||
|
||
## `rest` misidentifies six elements, and the running game says so
|
||
|
||
On `main_menu`, the settled timeline and `rest` differ in exactly one region:
|
||
**400×470 at (440,108)** — the bounding box of `ptframe1` and `ptframe2`, and
|
||
nothing else on the screen.
|
||
|
||
`rest` puts both at their **first** keyframe: off-position and fully
|
||
transparent. The keyframes say they slide (620,108)→(440,108) and (403,267)→
|
||
(583,267) while fading 0x00→0xff, and then hold that pose for their last three
|
||
keyframes including the untimed one.
|
||
|
||
`/reborn/docs/re/captures/main-menu-oracle.png`, a capture of the running game,
|
||
**shows them**: the bright circuit-frame bracket around the menu, with a ring at
|
||
the bottom right. Cropping the same 250×180 region from the capture and from
|
||
both renders puts the ring and its elbow trace in the port's timeline render
|
||
**pixel-aligned with the game's**, and absent from the `rest` render. That is
|
||
geometry, not luminance, so it does not depend on the capture's gamma or on the
|
||
fact that it was taken with `NEW GAME` focused.
|
||
|
||
### Why the decoders get it wrong, precisely
|
||
|
||
`ui_layout::rest_plateau` excludes a run of identical keyframes that **ends the
|
||
group**, because that run is normally the exit — the comment cites the pause
|
||
menu, where taking the trailing run erased the word PAUSE. That exclusion is
|
||
right in general and wrong for an element with **no exit animation**, where the
|
||
trailing run *is* the hold. The rule then falls back to an earlier run, which
|
||
for a slide-in is the invisible pre-roll.
|
||
|
||
The condition that identifies the affected elements exactly, with no false
|
||
positives in this export, is:
|
||
|
||
> the final untimed keyframe has the **same pose** as the last timed keyframe
|
||
|
||
Six elements match it and `rest` misses all six: `ptframe1`/`ptframe2` on
|
||
`main_menu` and `main_menu_jp`, and `pteff02` on `title` and `title_jp`. This is
|
||
a **finding for the RE agent** about `sylpheed-formats`, not something this port
|
||
fixes: the decoders are pinned and must not be reimplemented here. The port
|
||
simply does not use `rest` — it derives the arrived pose from the keyframes,
|
||
which needs no heuristic — and `verify-screen` still asks for `--pose=rest` so
|
||
that renderer-vs-renderer diffing compares like with like.
|
||
|
||
Note what this says about P1: the port and the reference renderer **agreed** on
|
||
`main_menu` to 3/255, and both were missing two elements the game draws. Two
|
||
renderers reading the same field through the same decoder agreeing is not
|
||
evidence that the field is right. `docs/BLOCKED.md` had already said that about
|
||
the pivot; here it bit for real.
|
||
|
||
## The title is not settled, and P2 does not claim it
|
||
|
||
`title` and `title_jp` differ between the two modes by much more (max 142 and
|
||
247), and there the disagreement is **not** the six-element bug alone. `rest`
|
||
picks a mid-timeline hold for several glows (`pteff01`, `ptlogoall_eff`,
|
||
`ptlogoall_eff2`, `ptlogo_back2eff5`) where the timeline runs on to a much
|
||
brighter pose.
|
||
|
||
I could not settle which is right, and did not try to make the numbers agree:
|
||
|
||
* No element's alpha ever reverses direction anywhere in this export, so the
|
||
title's 4.48 s timeline is a slow one-way ramp, not a pulse — which removes the
|
||
obvious reason to expect a loop, but does not prove there is none.
|
||
* The only live title capture composites the **`PRESS Ⓐ` plate (build 2) over
|
||
the title (build 4)**, so it cannot be diffed against build 4 alone. Mean
|
||
luminance is oracle 64.1, `rest` 62.8, timeline 80.0 — which looks like it
|
||
favours `rest`, except that the plate *adds* brightness and `rest` is carrying
|
||
a 25 % black dim quad (`pteff02`) that is itself one of the six misidentified
|
||
elements. The comparison is confounded in both directions and settles nothing.
|
||
* **Both modes are visibly wrong anyway.** Side by side with the capture, the
|
||
port draws a washed-out cyan glow slab across the logo that the running game
|
||
does not have — in `rest` mode too. That is a third problem, independent of
|
||
this one, and it is P3's.
|
||
|
||
So: the timeline is the default because it is derived from the disc's own
|
||
keyframes with one measured constant and no heuristic, and because it is proven
|
||
right on the screen this milestone gates. On the title it is **unverified**, and
|
||
P3 should not assume P2 settled it.
|