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.
280 lines
14 KiB
Markdown
280 lines
14 KiB
Markdown
# The open export format — v2
|
||
|
||
The format the disc is converted *into*, and the one the Godot project and any
|
||
modding tool read. **It is versioned, so a change is a deliberate act with a
|
||
version bump**, not a silent edit. [Changes from v1](#changes-from-v1) is at the
|
||
bottom, with a reason for each.
|
||
|
||
Design rules, in priority order:
|
||
|
||
1. **A human can read and edit it.** Modding is a goal of this port, which makes
|
||
the layout part of the product rather than a temp directory.
|
||
2. **Names, never hashes.** Where the disc's own name was never recovered — the
|
||
six `*2D` archives and `GP_READY_ROOM` — emit a stable synthetic id **and say
|
||
in the file that the real name is unknown**. A modder must be able to tell a
|
||
recovered name from an invented one.
|
||
3. **Provenance travels with the data.** Source archive, entry index, exporter
|
||
version, decoder revision. This is what keeps the export auditable against the
|
||
disc instead of drifting into an unverifiable fork.
|
||
4. **Say what is unknown.** A field we could not decode is absent and listed in
|
||
`unresolved` — never guessed, never silently defaulted.
|
||
|
||
**JSON, not XML.** Godot parses JSON natively with `JSON.parse_string`; its
|
||
`XMLParser` is a SAX-style API that would need a hand-written binding per schema.
|
||
|
||
**The format is executable.** `sylpheed-export check --out export` validates a
|
||
tree against this document with no disc in hand, reading it the way Godot will —
|
||
as a stranger. Where the prose here and `crates/sylpheed-export/src/check.rs`
|
||
disagree, that is a bug in one of them and worth saying which.
|
||
|
||
## Layout
|
||
|
||
```
|
||
export/ # DERIVED. Regenerable. Gitignored. Never hand-edited.
|
||
manifest.json
|
||
screens/title/*.json
|
||
sprites/title/<screen>/*.png
|
||
audio/music/*.ogg audio/sfx/*.ogg audio/cues.json
|
||
video/*.ogv
|
||
authored/ # AUTHORED. Hand-written. Committed. Survives re-export.
|
||
screen_names.json # which build is which screen
|
||
flow.json # boot sequence + what each button does
|
||
cue_bindings.json # which cue fires on move / confirm / back
|
||
```
|
||
|
||
Sprites are **per screen**, not a flat pool: a sprite name is unique within a
|
||
bundle and not across them, and `main_menu`'s `ptbase.t32` and `extras`'
|
||
`ptbase.t32` are different pictures.
|
||
|
||
`authored/screen_names.json` is the one authored file the *exporter* reads; the
|
||
rest are applied by the runtime over `export/`.
|
||
|
||
## Common header
|
||
|
||
```json
|
||
{
|
||
"format": "sylpheed.screen/2",
|
||
"exporter": "sylpheed-export 0.1.0",
|
||
"formats_rev": "8b6dbcf",
|
||
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 }
|
||
}
|
||
```
|
||
|
||
`source.entry` is the pak **entry index** — the stable locator. `source.build` is
|
||
the index into that pak's list of screen builds (what `sylpheed-cli screen
|
||
--build N` takes), which is stable only as long as the enumeration rule is.
|
||
`formats_rev` pins which decoders produced the file.
|
||
|
||
## `screens/*.json`
|
||
|
||
```json
|
||
{
|
||
"format": "sylpheed.screen/2",
|
||
"exporter": "sylpheed-export 0.1.0",
|
||
"formats_rev": "8b6dbcf",
|
||
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 },
|
||
"name": "main_menu",
|
||
"name_source": "authored",
|
||
"name_why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English…",
|
||
"design": [1280, 720],
|
||
"elements": [
|
||
{
|
||
"index": 10,
|
||
"id": "ptbtn01",
|
||
"declared": "ptbtn01.rat",
|
||
"role": "button",
|
||
"kind_raw": "0x3002",
|
||
"sprite": "sprites/title/main_menu/ptbtn01.png",
|
||
"focus_sprite": "sprites/title/main_menu/ptbtn01f.png",
|
||
"opt_link": "ptbtn01f.rat",
|
||
"pivot": [42, 22],
|
||
"layer_source": "sprite",
|
||
"layer": "0x00008110",
|
||
"rest": { "pos": [542, 162], "scale": [100, 100],
|
||
"tint_rgba": "0xffffffff", "fade_argb": "0xffffffff", "t": 64 },
|
||
"keyframes": [
|
||
{ "t": 28, "pos": [542, 142], "scale": [100, 100],
|
||
"tint_rgba": "0xffffffff", "fade_argb": "0x00ffffff" }
|
||
]
|
||
}
|
||
],
|
||
"paint_order": [1, 3, 4, 2, 5, 8, 9, 6, 7, 15, 10, 11, 12, 13, 14, 0],
|
||
"buttons": ["ptbtn01", "ptbtn02", "ptbtn03", "ptbtn04", "ptbtn05"],
|
||
"unresolved": ["keyframe_time_unit", "paint_order_ties", "fade_out_duration"]
|
||
}
|
||
```
|
||
|
||
### `name` / `name_source` / `name_why`
|
||
|
||
`name_source` is `"authored"` or `"index"` and nothing else. `"authored"` means
|
||
the name came from `authored/screen_names.json` and **requires** a `name_why`
|
||
saying who decided it and on what evidence. `"index"` means nobody has
|
||
identified this build and the name is `build_NN` — a locator, not a claim.
|
||
|
||
### `elements[]`
|
||
|
||
`index` is the declaration index and is also the key `paint_order` uses; it
|
||
always equals the element's position in the array. `id` is `declared` with its
|
||
extension stripped.
|
||
|
||
**`role`** comes from the decoded element kind: `0x3002` → `button`, `0x10`
|
||
without a sprite → `primitive`, `0x0` → `decoration`. Anything else is
|
||
`"unknown"` with the raw value in `kind_raw`. Do not invent a name for a kind
|
||
nobody has decoded.
|
||
|
||
> ⚠️ `0x3002` is **not** a general button test. It is one member of a `0x3000`
|
||
> family with sub-bits, and `GP_READY_ROOM` uses `0x3000` / `0x3004` / `0x300c` /
|
||
> `0x3008` with zero `0x3002`. Every screen in this milestone is `GP_TITLE`,
|
||
> where the mapping is decoded. A consumer meeting `role: "unknown"` should read
|
||
> `kind_raw`, not assume.
|
||
|
||
> ⚠️ **`kind & 0x4` is a repeated instance of a template.** On the title screen
|
||
> those are motion-trail ghosts and are *not* on screen at rest — the draw
|
||
> capture shows one quad where the bundle declares three. A runtime should skip a
|
||
> `kind & 0x4` element **when another element in the same screen has the same
|
||
> `id` and does not have that bit**, and only then: 174 elements on the disc are
|
||
> `0x4` with no such template, and a blanket skip erases them. Both are visible
|
||
> in this format from `kind_raw` and `id`.
|
||
|
||
**`pivot`** is the declared pivot, and it is the **anchor scale grows about** —
|
||
`pos` is the element's top-left at 1:1, and at scale `s` the drawn top-left is
|
||
`pos − pivot·(s−1)`. At 100 % the pivot cancels, which is why it went unnoticed
|
||
for a long time.
|
||
|
||
> 🟡 The decoders document the pivot as "exactly half the decoded texture's
|
||
> dimensions (verified 7/7 on the tutorial bundle)". **That does not hold on
|
||
> `GP_TITLE`**: 38 of its 93 sprite-bearing `.t32` elements disagree, some
|
||
> grossly (`ptlogo_back2`, 1118×262, pivot 500,117 where half is 559,131). It is
|
||
> not a problem for this port — the exporter emits the declared pivot and never
|
||
> derives one — but it is a claim a consumer should not lean on. Raised in
|
||
> `docs/BLOCKED.md`.
|
||
|
||
**`sprite`** / **`focus_sprite`** are paths relative to `export/`. The highlight
|
||
pairs **by name** on the sprite — `ptbtn01.t32` ↔ `ptbtn01f.t32` — which is 🟡 a
|
||
naming convention that holds for all 54 real pairs on the disc, not a decoded
|
||
field.
|
||
|
||
**`opt_link`** is the raw `opt ` link inside the element's `.rat` record, carried
|
||
through unresolved. ⚠️ **It is not a focus link.** That reading was measured and
|
||
refuted: on the main menu it chains `ptloop01 → ptloop02 → ptbtn01`, across two
|
||
decorations and into a button. It is exported so whoever decodes it has it, and
|
||
named so nothing downstream mistakes it for navigation.
|
||
|
||
**`layer` / `layer_source`** are the paint-order key. `"sprite"` means it was
|
||
read from the `u16` at `+0x0A` of the element's `T8aD` header — a decoded disc
|
||
field. `"implied"` means the element carries no header and the key came from the
|
||
decoders' table of keys **measured off the running game**. `"none"` means neither
|
||
is known, and the element sorts last. A consumer that needs to know whether a
|
||
layer is a fact or a measurement reads `layer_source`.
|
||
|
||
**`size`** appears only on a `primitive`, which has no texture to take a size
|
||
from: the quad is `pivot × 2`, and its colour is the keyframe's `fade_argb`.
|
||
|
||
**`keyframes`** carry the on-disc time verbatim in `t`. A keyframe is the
|
||
**start of a ramp toward the next**, not a pose that is held, and the ramp is
|
||
linear. The **last keyframe of a group has no `t`** — the disc has no time slot
|
||
there — and a file that puts one on it is wrong, not merely odd. The unit of `t`
|
||
is measured, not on the disc, and so lives in `authored/` and is applied in
|
||
exactly one place.
|
||
|
||
**Two colours multiply.** `tint_rgba` is RGBA and is `0xffffffff` on essentially
|
||
every keyframe; `fade_argb` is **ARGB**, and its high byte is the alpha that ramps
|
||
during a fade. The byte order is in the key name because getting it backwards is
|
||
silent and looks like an art bug. The drawn modulate is their per-channel product.
|
||
|
||
**`rest`** is the resting pose: **the hold** — the longest run of consecutive
|
||
keyframes with an identical pose that does not end the group. Neither the first
|
||
nor the last keyframe, and not the longest-dwell frame either: a long gap after
|
||
keyframe *k* means the screen spends that time *arriving at* `k+1`.
|
||
|
||
> ⚠️ **`rest` is a heuristic over the keyframes, and it misfires.** The rule
|
||
> excludes a run that ends the group, because that run is usually the exit. On
|
||
> an element with **no exit animation** the trailing run *is* the hold, and the
|
||
> rule then falls back to an earlier run — usually the invisible pre-roll. Six
|
||
> elements in this export are affected, and the condition that identifies them
|
||
> exactly is *"the final untimed keyframe has the same pose as the last timed
|
||
> one"*: `ptframe1`/`ptframe2` on both main menus, and `pteff02` on both titles.
|
||
> A live capture of the running main menu shows `ptframe1`/`ptframe2` on screen;
|
||
> `rest` says they are invisible.
|
||
>
|
||
> A consumer that wants the pose after arrival should therefore take **the last
|
||
> timed keyframe**, not `rest`. `rest` is kept in the format because it is what
|
||
> the pinned decoders say and removing it would hide the disagreement — see
|
||
> `docs/DECISIONS.md`. The format is unchanged at **v2**: no field changed
|
||
> meaning, this is a warning about one of them.
|
||
|
||
**`paint_order`** is back-to-front, as declaration indices, and is a permutation
|
||
of them. It is the stable sort by `layer`. See `unresolved: paint_order_ties`.
|
||
|
||
**`buttons`** is navigation order: `button`-role elements sorted by resting Y.
|
||
This is **geometric, not a decoded neighbour graph** — the disc's real navigation
|
||
structure is unknown. It is right for a vertical menu and should not be trusted
|
||
for anything else.
|
||
|
||
**`unresolved`** lists what this file does not answer; a consumer needing one of
|
||
those must get it from `authored/`. An empty list is a claim that nothing is
|
||
missing; an absent list is a gap, and `check` rejects it.
|
||
|
||
## `authored/screen_names.json`
|
||
|
||
Which build is which screen, keyed by archive and build index, each with a
|
||
`why`. The exporter reads this and stamps `name` / `name_source` / `name_why`
|
||
into the screen file. A build with no entry exports as `build_NN`.
|
||
|
||
## `authored/flow.json`
|
||
|
||
```json
|
||
{
|
||
"format": "sylpheed.flow/1",
|
||
"boot": ["splash_developer", "intro_video", "title", "main_menu"],
|
||
"screens": {
|
||
"main_menu": {
|
||
"actions": {
|
||
"ptbtn01": { "label": "NEW GAME", "goto": "new_game_intro",
|
||
"why": "label read off the sprite; target is a placeholder for HANDOFF Q4" }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`goto` may name an exported screen or a **GamePart id** from the executable's own
|
||
table (29 entries at `.rdata 0x820A1630` — that table is a disc fact; which button
|
||
reaches which entry is Q4 and is not).
|
||
|
||
## `export/manifest.json`
|
||
|
||
```json
|
||
{
|
||
"format": "sylpheed.manifest/1",
|
||
"exporter": "sylpheed-export 0.1.0",
|
||
"formats_rev": "8b6dbcf",
|
||
"disc": "/disc",
|
||
"screens": [{ "name": "main_menu", "file": "screens/title/main_menu.json",
|
||
"sprites": 18, "missing_sprites": [] }],
|
||
"video_transcode": "ffmpeg -i ADV.wmv -c:v libtheora -q:v 8 -c:a libvorbis -q:a 5 ADV.ogv",
|
||
"warnings": ["GP_READY_ROOM not exported -- out of scope"]
|
||
}
|
||
```
|
||
|
||
`video_transcode` will record the exact command so a modder can re-run it rather
|
||
than reverse-engineer what was done. It is absent until P4 writes a video.
|
||
|
||
## Changes from v1
|
||
|
||
v1 was written before HANDOFF answered Q1 and Q3, and before the two-colour
|
||
modulate was known. Each change below is a thing v1 could not have said.
|
||
|
||
| Change | Why |
|
||
|---|---|
|
||
| `rest.tint` (one `#rrggbbaa`) → `tint_rgba` **and** `fade_argb` | There are two modulate colours on the disc, in *different byte orders*, and they multiply. One field could not carry both, and a single `#rrggbbaa` silently discarded the alpha that every fade ramps. |
|
||
| `scale` is percent integers, not floats | It is a percent integer on the disc. Emitting `1.0` invents a precision the file does not have. |
|
||
| `paint_order` added, `"paint_order"` dropped from `unresolved` | Q3 decoded it: a `u16` layer key at `+0x0A`, stable-sorted. It is now derived, so it belongs in `export/` rather than `authored/`. `paint_order_ties` remains unresolved. |
|
||
| `layer` / `layer_source` added | Some keys are read from the file and some are measured off the running game. A consumer must be able to tell which. |
|
||
| `focus_sprite` now pairs by sprite **name**; `opt_link` exported raw | v1 implied `opt ` was the focus link. That was refuted. Pairing by name is the convention that survives. |
|
||
| `kind_raw` on every element, not only on `unknown` | The `0x3002` button test is not general and `kind & 0x4` changes whether an element draws at all. Both need the raw value present unconditionally. |
|
||
| `index`, `declared`, `parent`, `size`, `layer` added | Needed to reconstruct the screen: `paint_order` keys on `index`, primitives have no texture to take a size from, and `declared` keeps the disc's own spelling next to the derived `id`. |
|
||
| `name_why` required whenever `name_source` is `authored` | Rule 2. A name presented without its evidence is indistinguishable from a recovered one. |
|
||
| sprites moved from `sprites/*.png` to `sprites/<subdir>/<screen>/*.png` | Sprite names collide across builds. `main_menu` and `extras` both ship a `ptbase.t32`, and they are different pictures. |
|
||
| `unresolved` is required, and may be empty | An empty list is a claim; an absent one is a gap. |
|