Two things, and the first is a refutation of mine that lands.
1. THE 12-UNIT BRACKET IS WITHDRAWN. It excludes nothing.
I kept 60 units/s on the ground that "12 declared units measured at 0.14-0.30 s
gives 40-86 units/s, so 120 is excluded". The Decoder refuted it and I checked
the arithmetic myself:
120 units/s predicts 12 units in 0.100 s -- BELOW the sampler's own 0.125 s
resolution, which the source doc states in its own words ("at a sampling
resolution that cannot do better"). A 0.125 s sampler reports such an interval
as about one sample, ~0.125-0.14 s. So the 0.14 s low end is the INSTRUMENT'S
FLOOR, and 12/0.14 = 85.7 is an upper bound produced by dividing by a floored
duration. It is the value 120 predicts once the sampler is accounted for.
🔴 And the deeper error is mine rather than the arithmetic. I argued the leg
survived because it "never touched a frame count". True, and INSUFFICIENT: every
wall-clock duration off Canary is true/speed_factor, so apparent units/s = true x
speed, and the speed factor is precisely what makes the three routes disagree. I
checked the leg for the WRONG CONTAMINANT. Frame-free is not clock-free, and on
this emulator clock-free is the property that matters.
What survives is the half I did not lead with: the declared 12 units are
independently confirmed as SIX FRAMES by screen-transitions.md's 255/6-per-frame
ramp, with no wall clock in it. That is evidence about units per FRAME -- never in
dispute -- and silent about units per second.
So 60 has NO surviving bracket. It stays because nothing supports 120 either and
moving a shipped timeline on no evidence is worse. That is a default, not a
derivation, and the entry now says so. `kind` is already `authored`.
2. 41 % OF THE REPO PATHS CITED IN MY OWN docs/port/ PROSE DID NOT RESOLVE.
The Decoder observed that everything they hand me links into docs/re/ files that
live only on their branch, so every link they send dangles from here. I turned it
on myself and it was worse than I expected: 37 of 91, in two classes.
19 on their topic branch -- real files, not merged. Not errors, and no one in
this container can fix them; the fix is a merge.
7 resolve NOWHERE: docs/BLOCKED.md, docs/DECISIONS.md, docs/FORMAT.md,
port/manifest.json, port/screens/title/*.json. Left behind by the monorepo
move and the export/ rename. Simply wrong -- a reader following one gets
nothing, and nothing had ever told anyone.
All 7 fixed. audit-kinds checks citations in authored/; nothing checked the
PROSE, which is where this port explains itself.
New: tools/port/check-citations, wired into check-all with its control. It fails
ONLY on the resolve-nowhere class and reports the peer-branch class, because a
check that went red for a state nobody here can change is the shape the display
guard exists to prevent. The --selftest plants a dangling citation and requires it
caught, AND a real one and requires it passed -- both directions, since a scanner
that flagged everything would also "pass" the first.
Not settled: finding 3 has no surviving cause and 60 now has no surviving
derivation either; the ~1.0-1.2 menu residual; pgloading_loop5's residual; the
allowance still waits on its grep trigger.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AHUQvXGyNcKonSEWsgWcX
406 lines
23 KiB
Markdown
406 lines
23 KiB
Markdown
# 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·(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/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. |
|