A human play-test heard music under the boot intro and no voices. The obvious reading -- the 5.1 fold dropped the centre channel -- is wrong. `ADV.wmv` carries music and effects only; a cutscene's voice is a separate continuous XMA stream in `sound.pak`, bound to the movie by the manifest in `tables.pak`. Nothing was dropped. The exporter had never been asked for it, so every fidelity measurement in AUDIO-VERIFICATION.md would have come back clean. `audio::export_voice` resolves it with `media::resolve_movie_voice_region` and never by filename: `RT01A`'s voice lives inside `VOICE_ADV.slb`, so a name match is correct on exactly the two movies this port would have spot-checked. Decoded, not authored -- so it runs outside the `authored/audio.json` block. THE FIRST VERSION CONCATENATED THE REGION'S CHUNKS AND WAS WRONG. It produced 359 s of dialogue for a 137 s movie. Decoding and timing each chunk shows two of them equal to six decimals and each spanning the whole movie -- HANDOFF Q10's decoded two-stem shape on a second asset kind -- so they are summed at 1/n. The error was visible only because the first version recorded the decoded length against the movie's instead of clamping to it; the clamp `media`'s own doc comment invites, and which `sylpheed-viewer` applies, would have produced a file of exactly the right duration containing the wrong audio. The dropped leading chunk matches no duration in its region and is NOT closed here. It is the same signature as `BGM_103`'s third sub-wave, already open in BLOCKED.md, now corroborated on an independent asset kind. Raised with the Decoder; the manifest names every chunk dropped and its length. Also in this commit, and separable: * `--skip-at=SECONDS` -- `--script` structurally cannot press during a movie, because `_script_settled` waits while `_player != null`. That is why "does (A) skip the intro" had been read out of the source rather than measured. * MISSION section 6 pins a 5.1->stereo matrix and this exporter has shipped a different one since P4 -- the same weighting, 7.65 dB quieter -- and said so nowhere. Re-measured with the right instrument (float decode, whole file, count the samples that would clamp, not a peak reading): the pinned matrix puts ADV at +4.26 dBFS on 4406 samples, while S00A never clips. So the pin overloads one movie and the constant is over-broad for the other. NOT changed -- the level of a mix is what section 6 reserves to a human. The export now carries a warning with the numbers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
21 KiB
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 and Changes from v1 are at the bottom, with a reason for each.
Design rules, in priority order:
- 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.
- Names, never hashes. Where the disc's own name was never recovered — the
six
*2Darchives andGP_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. - 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.
- 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
{
"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
{
"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.
⚠️
0x3002is not a general button test. It is one member of a0x3000family with sub-bits, andGP_READY_ROOMuses0x3000/0x3004/0x300c/0x3008with zero0x3002. Every screen in this milestone isGP_TITLE, where the mapping is decoded. A consumer meetingrole: "unknown"should readkind_raw, not assume.
⚠️
kind & 0x4is 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 akind & 0x4element when another element in the same screen has the sameidand does not have that bit, and only then: 174 elements on the disc are0x4with no such template, and a blanket skip erases them. Both are visible in this format fromkind_rawandid.
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.t32elements 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 indocs/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.
"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.t32is 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
frecord 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. Thefrecord is the case where the parent declares nothing.
❔ The ring spins, and its period is unresolved. Its two keyframes differ in
rotation_degalone, 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 inunresolved.
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 renderdoes not draw rotation yet — itsblitis 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.
⚠️
restis 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/ptframe2on both main menus, andpteff02on both titles. A live capture of the running main menu showsptframe1/ptframe2on screen;restsays they are invisible.A consumer that wants the pose after arrival should therefore take the last timed keyframe, not
rest.restis kept in the format because it is what the pinned decoders say and removing it would hide the disagreement — seedocs/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
{
"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
{
"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/<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. |