docs: FORMAT v2, what P0 decided, and BLOCKED reconciled

FORMAT is bumped to v2 with a "Changes from v1" table giving a reason per row.
v1 was written before HANDOFF answered Q1 and Q3 and before the two-colour
modulate was known; each change is a thing v1 could not have said. The paint
order moves from `unresolved` into the export, because Q3 decoded it — a u16
layer key at +0x0A, stable-sorted — and a decoded answer is read in the
exporter. `paint_order_ties` replaces it, because the tie-break is still
unknown.

Where a layer key is not in the file it 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".

BLOCKED is reconciled against HANDOFF at /reborn e81dcad. Seven of its ten rows
are answered and are moved out; what remains is Q8 (no cue-to-event binding),
the two Q10 unknowns (which BGM, and where a loop restarts), Q9's unsettled
skippability, Q4's untested NEW GAME, and Q6's undecoded boot driver.

It also raises one question back, found by counting the export rather than by
reverse engineering anything: the decoders document a .t32 element's pivot as
"exactly half the decoded texture's dimensions (verified 7/7 on the tutorial
bundle)", and on GP_TITLE that holds for 55 of 93 sprite-bearing .t32 elements.
38 do not, some grossly — ptlogo_back2 is 1118x262 with pivot (500,117) where
half is (559,131). It changes nothing today, because the exporter emits the
declared pivot and the pivot only matters when scale != 100%. But scale IS
animated here — 177 keyframes across GP_TITLE are not 100%, including on the
title screen P1 has to draw — so the question of what the running game anchors a
scale to is worth an answer before P2. Noted there that the port and
`sylpheed-cli screen render` make the same choice, so a P1 diff cannot
distinguish them and their agreement is not evidence.
This commit is contained in:
Sylpheed port agent
2026-08-28 18:43:52 +00:00
parent 8dd0577e01
commit 8d2c092788
3 changed files with 340 additions and 53 deletions

116
docs/DECISIONS.md Normal file
View File

@@ -0,0 +1,116 @@
# 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.