# The open export format — v3 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 v2](#changes-from-v2) and [Changes from v1](#changes-from-v1) are 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/3", "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/3", "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. **`focus`** is the focused state, and it **supersedes `focus_sprite`**. A focused button is not a sprite swap: `ptbtn0Nf.rat` is a nested `.rat` **leaf** declaring *two* elements — the spinning ring `ptbtneff01.t32` and the bright label — and **the parent bundle declares no element for the record at all**, so the leaf is the only source of placement for both and there is nothing for it to inherit. ```json "focus": { "record": "ptbtn04f.rat", "elements": [ { "id": "ptbtneff01", "sprite": "…/ptbtneff01.png", "pivot": [21, 23], "rest": { "pos": [500, 396], "rotation_deg": 0, "t": 120, … }, "keyframes": [ { "t": 120, "rotation_deg": 0, … }, { "rotation_deg": 360, … } ] }, { "id": "ptbtn04f", "sprite": "…/ptbtn04f.png", "rest": { "pos": [535, 395], … } } ] } ``` Elements are back-to-front in the leaf's own declaration order — ring first, then label. Positions are **absolute design-space top-left**, not offsets from the button. > The label's `(−7, −7)` against its base is load-bearing, not noise: > `ptbtn0Nf.t32` is 13 px larger per axis, and −7 keeps the two **concentric** > (535 + 96/2 = 583 against 542 + 83/2 = 583.5). Drawing the highlight at the > base position pushes it 7 px down-right and off-centre. > ⚠️ **Leaf placement is authoritative for an `f` record and NOT for a base > record.** A base record's leaf *duplicates* its parent's placement and the two > can disagree by a unit (`ptbtn04`: parent y=401, leaf y=402) — there the parent > wins. The `f` record is the case where the parent declares nothing. > ❔ **The ring spins, and its period is unresolved.** Its two keyframes differ > in `rotation_deg` alone, 0 → 360. But the second is untimed, and what an > untimed keyframe means *inside a leaf* — as opposed to at screen level, where > it is the exit ramp — is untested. A consumer should draw the resting angle > rather than invent a spin rate. This is listed in `unresolved`. **`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. **`rotation_deg`** is screen-plane rotation in degrees, clockwise-positive, decoded from the keyframe's `+12`. **The game renders it**, confirmed twice by the RE agent on different screens and different elements: the title's `ptloop` sweeps declare +30 / −45 and a GPU capture submits their quads at +30.26 / −45.28, and the main menu's focus ring ramps 0 → 360 with position, scale, alpha and tint all constant — a capture caught it mid-spin. Rotation is **about the declared pivot**, which is measured rather than assumed: the `ptloop` sweeps scale 600 %/800 % vertically, where the pivot term is worth 450 and 630 px, and the capture puts both quad centres at y 359.1/360.0 against the pivot formula's 360.0 (top-left predicts 810/990, centre-as-position predicts 270). > ⚠️ `sylpheed-cli screen render` does **not** draw rotation yet — its `blit` is > axis-aligned. A rotation disagreement between it and a consumer that does draw > rotation means the CLI is behind, not that the consumer is wrong. **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": [] }], "videos": [{ "name": "ADV", "file": "video/ADV.ogv", "command": "ffmpeg -i …", "why": "HANDOFF Q9: …" }], "audio": [{ "kind": "se", "name": "move", "file": "audio/se/move.ogg", "command": "ffmpeg -i …", "why": "HANDOFF Q8, measured: …", "peak_dbfs": -3.2, "duration_s": 0.533, "name_match": "SE_UI_CURSOR" }, { "kind": "bgm", "name": "main_menu", "file": "audio/bgm/main_menu.ogg", "command": "ffmpeg -i …", "why": "AUTHORED, an arbitrary choice: …", "peak_dbfs": -1.1, "duration_s": 173.8, "loop_mode": "restart" }], "warnings": ["GP_READY_ROOM not exported -- out of scope"] } ``` `videos` and `audio` are **absent** until a milestone writes one, rather than present and empty: an empty array reads as "we looked and there is none", and that is not what an export taken before P4 or P6 means. ### `command` and `why`, on every media entry `command` is the exact ffmpeg invocation that produced the file. MISSION §6: a modder who dislikes the quality re-runs one line rather than reverse-engineering what was done to their asset. `why` is where the value came from, in the project's three-way vocabulary — **decoded** off the disc, **measured** off the running game, or **chosen**. A `why` that does not say which of those it is has not done its job. ### `audio`, field by field | field | | |---|---| | `kind` | `se`, `bgm` or `voice`. The runtime dispatches on it, so it is a field rather than a prefix on `name` that a consumer would have to parse | | `name` | the **role**, not the disc asset: `move`, `confirm`, `back`, `main_menu`. Which bank plays a role is authored and expected to change; a rename on the disc side must not be a change to the Godot project. ⚠️ **`voice` is the exception and keys by MOVIE NAME** (`ADV`, `S00A`), because there is no role to name: the binding of recording to picture came off the disc's own movie manifest, so unlike a music bed nothing about it was chosen | | `peak_dbfs` | measured off the finished file. **Required.** Silence is the audio failure that looks like success — right duration, right channel count, right size, full of zeroes — and clipping is the other one, which the BGM can produce because it is a sum of two stems at unity gain. `sylpheed-export check` refuses a tree whose peak is ≤ −90 dBFS or ≥ 0 dBFS | | `duration_s` | measured off the finished file, so that a claim about a cue's length can be checked against the finding that produced it | | `name_match` | the game's own cue identifier **guessed by name**. Absent means nobody claimed one — never that the binding is unknown. The binding is the measured part; the name is not | | `loop_mode` | what the runtime does at the end of the file, where that was authored. Absent on a cue: a cue ends | **A `voice` entry is a cutscene's dialogue, and it is a separate file on purpose.** On this disc a movie's `.wmv` carries music and effects only; the voice is a byte region of one continuous XMA stream in `sound.pak`, bound by the movie manifest. A consumer plays the two together, **from the same instant** — there is no offset and none is authored. A movie with no `voice` entry is genuinely unvoiced, which is the honest answer for most `hokyu_*` cutscenes; nothing is substituted, and the manifest carries a warning naming the movie. ⚠️ The `why` on a `voice` entry names every region chunk the exporter **dropped** and its measured length. That is not commentary: which chunks of a region are the track is an open decoding question (see `docs/port/BLOCKED.md`), and a consumer reading a shorter file than it expected should be able to see what was left out rather than infer it. ## Changes from v2 v2 was written before the keyframe's `+12` was decoded and before anyone could reach a `.rat` leaf through the public decoder API. | Change | Why | |---|---| | `rotation_deg` on every keyframe and on `rest` | Decoded at keyframe `+12`, and **the game draws it** — confirmed on two different screens with two different elements against GPU captures. Dropping it would have made the focus ring's whole animation invisible. | | `focus` (a record with its own elements) added; `focus_sprite` kept but demoted | The focused state is two elements in a nested leaf, not one sprite. v2's single `focus_sprite` could not carry the ring at all, and drew the highlight label 7 px off-centre by inheriting the base's position. `focus_sprite` stays because it is still the 54-pair naming convention and a consumer may want the bare texture. | | `unresolved` gains `focus_ring_spin_period` | The ring's rotation ramps 0 → 360 across two keyframes whose second is untimed. The screen-level rule for an untimed keyframe (the exit ramp) is not established to apply inside a leaf, so the period is unknown and is not being invented. | ## 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. |