# 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//*.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///*.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. |