Files
Sylpheed/docs/port/FORMAT.md
MechaCat02 a23c321831 port: land the play-tested work, and only that
Takes the port branch up to 77320d5e -- the state the human play-tested on
2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio`
is 366 commits and 938 files, and most of that must not land.

WHAT COMES IN (76 files, all human-confirmed working):
  * the logo splash animation. 08ed3dd1 found it: `pose_at` ASSIGNED the settle
    instant instead of clamping to it, so the splash never animated at all --
    and the same bug manufactured a passing harness result, because the harness
    photographed t past the settle. Confirmed by play-test: "cannot notice any
    obvious difference from the actual game."
  * gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad
    binding), stick latched with hysteresis at the game's own 61% digitise
    threshold. This is what made (A), video-skip and Extras work at all.
  * menu navigation and flow, menu audio, the exporter, the authored
    declarations, and 23 verification tools under tools/port/.

WHAT IS DELIBERATELY LEFT ON THE BRANCH:
  * everything after c0ae460a -- the F5/F6 title-timing investigation, whose own
    tip commit calls itself a "hand-off for one-minute human checks". Unchecked
    by definition; it goes through the new review gate like anything else.
  * the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested.
  * the F1 repeat mechanism, which its own commit calls "deliberately inert".

WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED:
  545 MB of extracted game content was committed on that branch -- 850 sprite,
  audio and transcoded video files under `export-probe/` and `export-probe2/`,
  plus 246 MB of loose .wav and .tsv at the repo root. This repository's own
  rule, in this file, is "never game content".

  The rule was not missing. It was written, and it was tightened on that very
  branch, with a careful comment explaining why BOTH `export/` and `data/base/`
  had to be listed -- while the exporter was writing to a third name that
  nobody had thought to list. Enumerating names is the thing that failed. So
  the ignore rules now describe the SHAPE: any top-level `export*/`, game media
  by extension, and loose capture output at the root. Verified both ways -- it
  catches all four offenders and ignores nothing currently tracked.

Verified: `cargo check --workspace` clean; all nine GDScript files parse in
project context, with a positive control (an injected syntax error is detected,
3 lines) so the clean result means something. `tools/port/check-all` was NOT
run -- it needs the container, the export tree and a display.
2026-09-04 16:17:14 +02:00

406 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<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/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·(s1)`. 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/port/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.
🔴 **Every keyframe has a `t`, including the last**, and this paragraph said the
exact opposite until 2026-08-29. A placement group is an 8-byte header followed
by `frames` × `{u32 time; 36-byte pose}`, so **pose 0's time is the group's
lead-in word** and no pose is untimed. The old reading — that a group's data
stopped four bytes short of its final block's time slot — paired every pose with
the *next* pose's time, and `sylpheed-export check` enforced it as a rule. A file
with an untimed keyframe is now the wrong one.
⚠️ Two things went with that correction. The **exit ramp is gone**: there is no
untimed final keyframe to give a synthetic time to, so `authored/timing.json`'s
`exit_ramp_units` — an authored *measured* constant since P3 — is **deleted**,
which is what MISSION §3 means by a deletion being the measure of progress. And
`rest.t` moved on several screens: `publisher_logo` settles at t=30 rather than
t=235.
The unit of `t` is still measured rather than on the disc, so it stays 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/port/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, and applies a **kind-dependent** upper bound. 🔴 This paragraph used to state a flat *≥ 0 dBFS* and was wrong about the port's own export: `confirm` ships at **+0.18** and the `ADV` voice at **+0.31**, so a consumer implementing a validator from this file would have rejected a valid tree. The rule is: a **`bgm`** is a sum *we* produced, so a peak at or above full scale is our arithmetic and is refused outright; an **`se`** or **`voice`** is a single wave off the disc, mastered near full scale, and a lossy decode of it overshoots by a fraction of a dB — those are allowed to **+1.0 dB**. ⚠️ The +1.0 is a judgement, not a measurement: a few tenths is reconstruction overshoot and a whole dB is not, and if a cue ever trips it the right response is to measure the overshoot distribution, not to loosen the bound |
| `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/<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. |