monorepo: one repository for the decoders, the port and the corpus
Merges the Godot port into the reverse-engineering repository, preserving both
histories -- 1019 commits of corpus plus the port's 31, brought in by subtree
merge and then moved into place so git can follow each file across the rename.
The reason is not tidiness. The two-repo split forced the exporter to depend on
the decoders by pinned revision, and that created a whole class of failure that
now disappears: a sha reachable only from a topic branch, orphaned by a
squash-merge, breaking a fresh checkout silently at build time. It also forced a
live read-only mount of one agent's working tree into another's container, which
is why a contract file could move mid-iteration. With a path dependency, a
decoder change and the exporter change it requires land in the same commit or
not at all.
Canary stays separate: it is a fork tracking upstream.
New structure for the long term:
docs/game/ how the game is NAVIGATED -- menus, modals, prompts, alerts,
and in-game flight. Written so nobody rediscovers it. Mostly
open questions on purpose; the in-game tutorials are the
resource for the flight half.
docs/port/MODDING.md
modding as a constraint on the exporter TODAY, not a later
feature: one logical asset in one file (the disc splits nearly
everything, and resolving that is the exporter's job), names a
person recognises, PNG/OGG/OGV/JSON only, base-and-overrides so
re-exporting is always safe, provenance in every file.
data/base + data/mods
generated tree and drop-in overrides, both gitignored
exchange/ transient inter-agent files, deliberately outside history
docs/agents/ the team protocol
Both the README and the navigation doc lead with the correction that cost the
most: the oracle is the real game under Xenia Canary. Reborn's renderer is a
hypothesis under test, it has been wrong, and treating it as ground truth
propagated into three documents and both agents before a human caught it.
Scripted modding stays possible without being built: no screen name is hardcoded
in GDScript and there is no native code in port/, which is what Godot Mod Loader
needs to be able to substitute behaviour later.
This commit is contained in:
110
docs/port/AUDIO-VERIFICATION.md
Normal file
110
docs/port/AUDIO-VERIFICATION.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Verifying audio without an audio device
|
||||
|
||||
Neither container has a sound card, so "does it actually play?" cannot be
|
||||
answered by listening. It can be answered by measurement, and the two things
|
||||
usually meant by that question need different measurements.
|
||||
|
||||
**Separate them before reaching for a tool:**
|
||||
|
||||
| question | needs Godot? | needs a device? |
|
||||
|---|---|---|
|
||||
| Is the transcoded file faithful to the source? | no | no |
|
||||
| Does Godot actually route it to an output? | yes | no |
|
||||
| What does the *game* play on a menu move? | no (Canary) | a virtual one |
|
||||
|
||||
## 1. Transcode fidelity — file against file
|
||||
|
||||
This is the question P4 actually raised, and it needs neither an engine nor a
|
||||
device. Decode both, subtract, and measure what is left.
|
||||
|
||||
```bash
|
||||
# Source, for a reference level
|
||||
ffmpeg -hide_banner -t 25 -i ADV.wmv \
|
||||
-af "aformat=channel_layouts=stereo,astats=measure_perchannel=none" -f null - 2>&1 \
|
||||
| grep "RMS level"
|
||||
|
||||
# The difference signal: source minus transcode
|
||||
ffmpeg -hide_banner -t 25 -i ADV.wmv -t 25 -i ADV.ogv -filter_complex \
|
||||
"[0:a]aformat=channel_layouts=stereo[a];\
|
||||
[1:a]aformat=channel_layouts=stereo,volume=-1[b];\
|
||||
[a][b]amix=inputs=2:normalize=0,astats=measure_perchannel=none" -f null - 2>&1 \
|
||||
| grep "RMS level"
|
||||
```
|
||||
|
||||
A faithful transcode puts the difference **40 dB or more below** the source.
|
||||
|
||||
### Three ways this measurement lies
|
||||
|
||||
Run it wrong and it reports a disaster that is not there. All three of these
|
||||
were hit on the first attempt:
|
||||
|
||||
* **Alignment.** A one-sample offset makes the difference nearly as loud as the
|
||||
source. Cross-correlate and compensate *before* subtracting, or the number is
|
||||
meaningless. A first run gave source −25.3 dB against difference −34.2 dB —
|
||||
only 9 dB down, which looks catastrophic and proves nothing.
|
||||
* **Channel layout.** The source and the transcode do not have the same channel
|
||||
count. You are not comparing like with like unless both sides are downmixed
|
||||
the same way, and `astats` will give you a confident number regardless. See
|
||||
[`movie-audio-channels`][mac] for which profile a given movie is in — that is
|
||||
a disc fact and lives in the RE corpus, not here.
|
||||
* **A file still being written.** `ffprobe` reported the `.ogv` as 33 s against
|
||||
the source's 137 s — apparent catastrophic truncation, actually a transcode in
|
||||
progress. Check `mtime` and packet count before believing a duration, and
|
||||
write to a temp name and rename on completion so a reader cannot see a partial
|
||||
file at all.
|
||||
|
||||
⚠️ **The downmix is an unrecorded decision, and it is not ours to make quietly.**
|
||||
Nothing in the manifest says a fold happened or on what weighting; it is whatever
|
||||
ffmpeg defaulted to, and that default can change between versions. Centre-channel
|
||||
dialogue folds into L/R, so this changes how speech sits against music — an
|
||||
aesthetic judgement, not a container detail. Pin it explicitly and record it,
|
||||
exactly as MISSION §6 requires of the transcode command itself.
|
||||
|
||||
[mac]: https://git.mc02.dev/fabi/Syplheed-Reborn/src/branch/main/docs/re/structures/movie-audio-channels.md
|
||||
|
||||
## 2. Engine routing — Godot writes a WAV instead of a device
|
||||
|
||||
Godot does not need a sound card to produce audio you can inspect. Put an
|
||||
`AudioEffectRecord` on the **Master** bus and it captures the mixed output from
|
||||
inside a headless run:
|
||||
|
||||
```gdscript
|
||||
var bus := AudioServer.get_bus_index("Master")
|
||||
var rec := AudioEffectRecord.new()
|
||||
AudioServer.add_bus_effect(bus, rec)
|
||||
rec.set_recording_active(true)
|
||||
# ... play the scene ...
|
||||
rec.set_recording_active(false)
|
||||
rec.get_recording().save_to_wav("user://master.wav")
|
||||
```
|
||||
|
||||
Then feed that WAV through §1 against the source. That closes the loop: it
|
||||
proves the asset is right **and** that the engine reached it, which no amount of
|
||||
file comparison can show on its own.
|
||||
|
||||
Confirm the dummy driver is what is actually in use rather than assuming it —
|
||||
`AudioServer.get_driver_name()` — and say so in the write-up, because "recorded
|
||||
under a dummy driver" is a weaker claim than "heard", and the difference matters.
|
||||
|
||||
## 3. A virtual device, when something insists on a real one
|
||||
|
||||
For anything that opens a device rather than a bus — the emulator, most
|
||||
obviously — a PulseAudio **null sink** is a real device that records to a file:
|
||||
|
||||
```bash
|
||||
pactl load-module module-null-sink sink_name=cap sink_properties=device.description=cap
|
||||
PULSE_SINK=cap <the application>
|
||||
parec -d cap.monitor --file-format=wav /tmp/captured.wav
|
||||
```
|
||||
|
||||
This is the route to capturing what the *game* plays — the menu move and confirm
|
||||
cues behind HANDOFF Q8 — rather than what we think it should play. It needs
|
||||
`pulseaudio-utils` in the image, so it is a rebuild, not something to reach for
|
||||
mid-iteration.
|
||||
|
||||
## What none of this establishes
|
||||
|
||||
That it *sounds right*. Every method here shows correspondence to a source, not
|
||||
that the source is the audio the game plays at that moment, and not that levels
|
||||
are sane in a mix. A ten-second human listen still answers something no
|
||||
measurement above does — so when a result rests on one of these, say which one.
|
||||
239
docs/port/BLOCKED.md
Normal file
239
docs/port/BLOCKED.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Waiting on the RE agent
|
||||
|
||||
What this port cannot do until an answer lands in
|
||||
[`/reborn/docs/port/HANDOFF.md`](https://git.mc02.dev/fabi/Syplheed-Reborn).
|
||||
Recorded so it is not re-discovered every iteration.
|
||||
|
||||
**None of these may be guessed.** A value invented here is indistinguishable from
|
||||
a decoded one a month from now. Where a milestone can proceed with a placeholder,
|
||||
the placeholder goes in `authored/` with a `why` naming the question it stands in
|
||||
for, so it is deleted rather than forgotten when the answer arrives.
|
||||
|
||||
Last reconciled against HANDOFF.md on **2026-08-29**, at `/reborn` HEAD `9a0ca0d`.
|
||||
(`/reborn` is mounted read-only, so `git -C /reborn pull` fails by design; the
|
||||
mount is refreshed outside this container and HEAD is read, not fetched.)
|
||||
|
||||
## Still open — these block work
|
||||
|
||||
| Milestone | Needs | HANDOFF | State |
|
||||
|---|---|---|---|
|
||||
| ~~P6 audio~~ | ~~which cue fires on move / confirm / back~~ | Q8 | ✅ **answered 2026-08-28** — the RE agent retracted "cannot be extracted". The waves are located in `Static.slb` by playing them: **move `0x1ec0`** (8 192 B, 0.533 s), **confirm `0x5d6c0`** (12 288 B, 1.016 s), **back `0x0ec0`** (4 096 B, 0.344 s), and ⬅➡ play nothing. Move and back reproduce across two boots. 🟡 that the cursor's wave is the cue *named* `SE_UI_CURSOR` is still a name match, and Ⓐ's wave is not separated between `SE_UI_DECIDE` and `SE_UI_SUB_WIN_OPN`. P6 can now export real audio; the exporter has to grow an SE path. |
|
||||
| P6 audio | which BGM the menu plays | Q10 | ❔ **not on the disc.** All 32 banks are named `BGM_001`…`BGM_109` with no semantic name anywhere. The port is choosing a track, and that choice is authored. |
|
||||
| P6 looping | where a menu loop restarts | Q10 | ❔ `BGM_001` fades out at 167.663 s into 6.15 s of silence, and no loop-point field has been identified. A menu loop is authored. |
|
||||
| P4/P7 video | whether Ⓐ skips a movie | Q9 | 🟡 unsettled — the corpus says Ⓐ skips every time, the boot harness never taps during a movie because it breaks the title. P4 can play the movie; it cannot yet say what a button press does during one. |
|
||||
| P5 `NEW GAME` | what Ⓐ on `NEW GAME` opens | Q4 | ❔ untested: Ⓐ on it **hangs the emulator**. The other four destinations are measured. |
|
||||
| P3 sequencing | what code decides to advance the boot sequence | Q6 | 🟡 the order is observed and the attract cycle timed (~8–10 s idle → fade → `ADV.wmv` in full → title). The *driver* is not decoded. P3 can reproduce the observed behaviour and must say it is reproducing an observation. |
|
||||
|
||||
## Answered since this file was last written — no longer blocking
|
||||
|
||||
Q1 (keyframe time unit — linear ramp, 2 units per rendered frame, 1 unit = 1/60 s
|
||||
*measured*), Q2 (which build is which screen), Q3 (paint order — a `u16` layer key
|
||||
at `+0x0A`, **decoded**), Q5 (navigation: ⬆⬇ wrap, ⬅➡ nothing, Ⓑ up with focus
|
||||
restored), Q7 (transitions: a fade through black, fade-in decoded, ~0.4 s fade-out
|
||||
measured), Q9 (`ADVERTISE_MOVIE` → `ADV.wmv` is boot intro *and* attract; `MS00A` →
|
||||
`S00A.wmv` is the new-game intro), Q10 (a bank is two stems played **together** —
|
||||
do not concatenate), S1 (Ready Room: no-go).
|
||||
|
||||
Also newly available, and useful to P3/P5 when they author the flow: the title
|
||||
part's transitions are a **lookup by name**, and the game's own screen
|
||||
vocabulary includes `TITLE_SCREEN`, `TITLE_MENU`, `LOADING`, `DIFFICULTY`,
|
||||
`EXTRA_MENU`, `TUTORIAL_MENU`. Three of those are corroborated by measurements
|
||||
taken before the function was opened (`DIFFICULTY` is what `NEW GAME` opens,
|
||||
`EXTRA_MENU` is `EXTRAS`, `TUTORIAL_MENU` the lesson list). 🟡 **Candidate, not
|
||||
decoded** — the RE agent is explicit that the strings are what the call sites
|
||||
*reference*, not proven arguments, and the same list mixes in `TEXT_FONT` and
|
||||
`GAMMA_RGB`. So `authored/flow.json` may use these as `goto` names — which is
|
||||
better than inventing names — but must mark them as a name match, not a
|
||||
measurement.
|
||||
|
||||
Three of those are **measured**, not decoded, and so are authored here rather
|
||||
than exported:
|
||||
|
||||
| Authored because it is not on the disc | HANDOFF | Where it lives |
|
||||
|---|---|---|
|
||||
| `1 keyframe unit = 1/60 s` | Q1 | not yet written — P2 |
|
||||
| initial menu focus (not stable across boots; pick one and say so) | Q5 | not yet written — P5 |
|
||||
| the ~0.4 s fade-out and the 0.17–0.23 s black hold | Q7 | not yet written — P3 |
|
||||
|
||||
## What the port needs next — sent to the RE agent 2026-08-29
|
||||
|
||||
Ordered by what it costs the port, not by what it costs to answer.
|
||||
|
||||
### 1. How should the exporter recognise the developer-logo splash? (P3, blocking)
|
||||
|
||||
The splash is the **first thing P3 draws** and it is not in `export/`. It
|
||||
declares its sprites directly and has no `.rat` layout child, so `is_build`
|
||||
rejects it; `sylpheed-cli` reaches it only via `--all`, which the CLI's own help
|
||||
says **renumbers `--build`**. So the port cannot address it by build index
|
||||
without the index meaning something different from everywhere else in this
|
||||
format.
|
||||
|
||||
What I need is a **predicate**, not an index: something the exporter can apply to
|
||||
say "this bundle is a composable screen" that admits the splash and does not
|
||||
admit the 1 894 two-element fragments `--all` also lets in. If the honest answer
|
||||
is "there is no such rule, take `GP_TITLE` entries 11/14", that is a usable
|
||||
answer — I will export it under a synthetic name with `name_source` saying it was
|
||||
located by entry index and not by a rule.
|
||||
|
||||
### 2. Is the ~0.4 s fade-out the whole ramp, or a segment of it? (P3, blocking)
|
||||
|
||||
Q7 measures the screen fade-out at ~0.4 s and the black hold at 0.17–0.23 s.
|
||||
The port needs to know **which quantity that 0.4 s is**, because the last
|
||||
keyframe of a group carries no `t` and the port refuses to invent one:
|
||||
|
||||
* the ramp from the hold to the exit pose — i.e. the missing duration of that
|
||||
final untimed keyframe; or
|
||||
* hold → exit → fully black, the 0.4 s covering several keyframes; or
|
||||
* something the game does independently of the group.
|
||||
|
||||
Under the first reading the port writes one authored constant and plays the
|
||||
group to its end. Under the third it must not.
|
||||
|
||||
### 3. Focus: drawn OVER the base element, or INSTEAD of it? (P5, cheap, avoid rework)
|
||||
|
||||
`sylpheed-cli --focus` is documented as drawing the focused record **over** its
|
||||
base. The port **replaces** the sprite. Those are different operations and the
|
||||
port picked its one without evidence.
|
||||
|
||||
Evidence that the port is wrong: rendering `main_menu` with `ptbtn01` focused —
|
||||
which is how `main-menu-oracle.png` was taken — makes the RMSE against that
|
||||
capture **worse**, 5.92 % → 7.00 %. The capture also shows a **ring marker**
|
||||
beside `NEW GAME` that the port draws nowhere. Cheap to answer from a capture
|
||||
that already exists, and it decides how P5 is built.
|
||||
|
||||
### 4. Rotation — should the port draw it, and about what? (P2/P3, needs a joint decision)
|
||||
|
||||
`67fa1a1` decodes `rotation_deg` at keyframe `+12` and explicitly does **not**
|
||||
render it: `ui_layout::blit` is axis-aligned. `ptloop01`/`ptloop02` on the title
|
||||
declare +30° and −45°, and the framebuffer submits them at +30.26 and −45.28.
|
||||
|
||||
A canvas rotation is a few lines in Godot, so the port *can* draw these. But
|
||||
then the port is deliberately more correct than the reference renderer, and
|
||||
`verify-screen` — the port's whole verification method — starts reporting a large
|
||||
diff on the title that means "the port is right". That is a bad state to be in
|
||||
silently, so I would rather agree it than do it.
|
||||
|
||||
Two sub-questions: **is the rotation about the declared pivot** or about the
|
||||
element's centre or corner? And would you rather `blit` grow a rotating path so
|
||||
the diff stays meaningful? The format would go to **v3** to carry
|
||||
`rotation_deg`; that is my side and I will do it either way, since carrying a
|
||||
decoded field the renderer ignores is better than dropping it.
|
||||
|
||||
### 5. Is `main-menu-oracle.png` gamma-correct? (not blocking, but it calibrates everything)
|
||||
|
||||
With the background in, the port sits at 5.92 % RMSE against that capture and is
|
||||
visibly **darker and less saturated** than it across the whole frame. If the
|
||||
capture path applies a gamma or a colour transform the game does not, then RMSE
|
||||
against captures has a floor and the port should stop chasing it. If it does
|
||||
not, something is still missing. The port cannot tell these apart from inside.
|
||||
|
||||
## Questions this port has raised
|
||||
|
||||
### ~~Does a keyframe group loop, or hold its last pose?~~ — answered
|
||||
|
||||
**Answered 2026-08-28 by the RE agent: groups hold.** `ptloop01`/`ptloop02` park
|
||||
their sprites at x=1521 and x=−839, both off a 1280-wide design, and 18 s of
|
||||
settled title sits at sd ≤ 0.01. `loop*.rat` is a misleading name — these
|
||||
animate once during build-in and then rest off-screen.
|
||||
|
||||
The port's own error here was different and is fixed: it settled at the last
|
||||
*timed* keyframe rather than at the hold. See `docs/DECISIONS.md`.
|
||||
|
||||
Kept for the record:
|
||||
|
||||
Raised at P2 and **unsettled**. The port holds the last timed keyframe, which is
|
||||
right for an entry animation (the main menu settles at t=80, 1.33 s) and is
|
||||
proven on the screen P2 gates. The **title** runs to t=269 — 4.48 s — and there
|
||||
the port's settled pose and the decoders' `rest` disagree badly (max 142/255).
|
||||
|
||||
What is known: no element's alpha reverses direction anywhere in this export, so
|
||||
nothing pulses, which removes the obvious reason to expect a loop without
|
||||
disproving one. What would settle it: **a capture of build 4 alone**. The one
|
||||
live title capture composites the `PRESS Ⓐ` plate (build 2) over it, so it
|
||||
cannot be diffed against the title by itself.
|
||||
|
||||
⚠️ Independently, **both** of the port's modes draw a washed-out cyan glow over
|
||||
the title logo that the running game does not have. That is a third problem and
|
||||
it is P3's; it is noted here so nobody reads the loop question as its cause.
|
||||
|
||||
Not blocking anything today; raised because the port found them and a guess here
|
||||
would be believed later.
|
||||
|
||||
### ~~`rest_plateau` misfires on elements with no exit animation~~ — fixed
|
||||
|
||||
**Fixed 2026-08-28** in `sylpheed-formats`, and this port's pin moved
|
||||
`8b6dbcf → 5414db3` to take it. The rule adopted is **not** the condition this
|
||||
port proposed, which was too loose: a trailing run is the hold exactly when it
|
||||
is **visible**. The port's condition would have erased the word PAUSE on
|
||||
`pgptitle.rat`, whose trailing run is two identical *transparent* frames.
|
||||
|
||||
Kept for the record, since the reasoning is still what found it:
|
||||
|
||||
**This one is a decoder bug, not a question**, and it was the highest-value item
|
||||
on this page for the RE agent. `ui_layout::rest_plateau` excludes a run of
|
||||
identical keyframes that ends the group, on the grounds that it is the exit. For
|
||||
an element that **has no exit animation** the trailing run *is* the hold, and the
|
||||
rule falls back to an earlier run — for a slide-in, the invisible pre-roll.
|
||||
|
||||
The condition that identifies the affected elements exactly, with no false
|
||||
positives across this export, is: **the final untimed keyframe has the same pose
|
||||
as the last timed one.** Six elements match; `rest()` misses all six.
|
||||
|
||||
`ptframe1` and `ptframe2` on the main menu are the visible case, and
|
||||
`docs/re/captures/main-menu-oracle.png` settles it — the game draws the circuit
|
||||
bracket that `rest` calls invisible. `sylpheed-cli screen render` is missing it
|
||||
too, so this is not only a port concern.
|
||||
|
||||
The port needs nothing here: it derives the arrived pose from the keyframes and
|
||||
does not use `rest`. Filed because `rest()` is used elsewhere and because a
|
||||
capture already proves it.
|
||||
|
||||
### Does the game sample a scaled sprite at the pixel corner or the pixel centre?
|
||||
|
||||
Found at P1, by the only screen it could have been found on. `title_jp`'s
|
||||
`ptlogo_eff2` is the **single drawn element in the whole export** at a scale that
|
||||
is not a whole multiple of 100 % (125 %), and `title_jp` is the only one of the
|
||||
twelve screens whose Godot-vs-CLI diff exceeds 6/255.
|
||||
|
||||
The two renderers pick different source texels at a non-integer ratio.
|
||||
`sylpheed_formats::ui_layout::blit` samples at the destination pixel's **top-left
|
||||
corner** (`sxi = col * sw / dw`); a GPU samples at its **centre**
|
||||
(`floor((col+0.5)*sw/dw)`). At 125 % they disagree on one column in five — ~30
|
||||
pixels above 100/255, strung along thin diagonal edges. At every whole multiple
|
||||
of 100 % they agree exactly, which is why the other eleven screens are clean.
|
||||
|
||||
The port has **not** changed to match: matching would mean reproducing a half-
|
||||
pixel bias on purpose to make a number smaller. The question for the RE agent,
|
||||
when it is cheap: **a framebuffer capture of the Japanese title screen** would
|
||||
settle it outright, and it is the kind of thing a capture answers in one look.
|
||||
|
||||
Cost of being wrong either way: a one-texel edge on one glow, on a screen the
|
||||
English boot path never shows. This is filed, not urgent.
|
||||
|
||||
### The pivot is not half the texture on `GP_TITLE`
|
||||
|
||||
`sylpheed-formats`'s `ui_layout::Element::pivot_x` is documented as "for a `.t32`
|
||||
element this is exactly half the decoded texture's dimensions (verified 7/7 on
|
||||
the tutorial bundle)". Counting it over the whole of `GP_TITLE` as exported:
|
||||
|
||||
* **55 of 93** sprite-bearing `.t32` elements match within ±1 px.
|
||||
* **38 do not**, and several are not close: `ptlogo_back2` is 1118×262 with pivot
|
||||
(500, 117) where half is (559, 131); `ptmsg` is 223×38 with pivot (123, 19)
|
||||
where half is (111.5, 19) — the Y matches and the X does not.
|
||||
|
||||
This changes nothing today: the exporter emits the **declared** pivot and never
|
||||
derives one, and the pivot only affects drawing when scale ≠ 100 %. But it does
|
||||
matter, because scale is genuinely animated here — **177 keyframes** across
|
||||
`GP_TITLE` are not 100 %, including on the title screen the port must draw at P1.
|
||||
|
||||
The question for the RE agent, when it is cheap to answer: **does the running
|
||||
game anchor a scale to the declared pivot, or to half the texture?** The two
|
||||
differ by up to 59 px on `ptlogo_back2`, which is visible. Until then the port
|
||||
follows the decoders and uses the declared pivot, which is also what
|
||||
`sylpheed-cli screen render` does — so a P1 diff cannot distinguish them, and
|
||||
agreement between the two is not evidence.
|
||||
|
||||
**P1 has now been run and that prediction held.** The port and the CLI agree on
|
||||
every scaled element across all twelve screens; the question is untouched by it.
|
||||
It will stay untouched by P2 as well, since P2 animates the same two renderers'
|
||||
shared assumption. Only a capture answers this.
|
||||
811
docs/port/DECISIONS.md
Normal file
811
docs/port/DECISIONS.md
Normal file
@@ -0,0 +1,811 @@
|
||||
# Decisions
|
||||
|
||||
One entry per decision that outlives the container it was made in. Newest last.
|
||||
A decision that lives only in an agent's context is lost when that container
|
||||
dies, which is what this file is for.
|
||||
|
||||
---
|
||||
|
||||
## P0 — the exporter, 2026-08-28
|
||||
|
||||
### The exporter reads one authored file, and stamps its provenance into the output
|
||||
|
||||
`export/` is derived and `authored/` is hand-written, and the natural reading of
|
||||
that is that the exporter never touches `authored/`. But a screen has to be
|
||||
*called* something, and the disc does not name its builds — the identification of
|
||||
build 5 as the main menu is HANDOFF Q2, **measured against a live capture**, not
|
||||
a field.
|
||||
|
||||
Two ways to handle that:
|
||||
|
||||
1. the exporter emits `build_05.json` and the runtime renames it from
|
||||
`authored/screen_names.json`;
|
||||
2. the exporter reads that map and writes `main_menu.json` directly.
|
||||
|
||||
Chose **2**, with a condition: every name it applies carries `name_source:
|
||||
"authored"` and a `name_why` quoting the evidence, and `check` **rejects** an
|
||||
authored name with no `why`. The file that lands in `export/` is therefore still
|
||||
honest about which of its fields is a measurement — which is the property the
|
||||
derived/authored split exists to protect — while a human opening the tree sees
|
||||
`main_menu.json` rather than having to resolve a rename in their head. A build
|
||||
nobody has identified exports as `build_NN` with `name_source: "index"`, which is
|
||||
a locator and not a claim.
|
||||
|
||||
This is the **only** authored input the exporter takes. Everything else in
|
||||
`authored/` is applied by the runtime over `export/`.
|
||||
|
||||
### Sprites are per screen, not a flat pool
|
||||
|
||||
`main_menu` and `extras` both ship a `ptbase.t32` and they are different
|
||||
pictures. A flat `sprites/` directory would have silently collided; whichever
|
||||
screen exported second would have won, and the loser would have drawn the wrong
|
||||
background with no error anywhere. `sprites/<subdir>/<screen>/<name>.png`.
|
||||
|
||||
### The format is executable
|
||||
|
||||
`sylpheed-export check --out export` validates a tree against `docs/FORMAT.md`
|
||||
with no disc in hand. It exists because "the export is correct" is otherwise an
|
||||
assertion, and because the P0 gate is *"validates against FORMAT.md"* — which is
|
||||
not a thing anyone can confirm by reading.
|
||||
|
||||
It reads the tree the way Godot will: as a stranger, with no access to the disc,
|
||||
the decoders, or the exporter's internals. It deliberately does **not** check the
|
||||
export against the disc — that is what `sylpheed-cli screen render` is for, at P1.
|
||||
|
||||
Checked that it bites, rather than assuming: five mutations of a valid
|
||||
`main_menu.json` — a broken `paint_order` permutation, a dangling
|
||||
`focus_sprite`, a reversed `buttons` list, a `#rrggbbaa` colour, an invented
|
||||
`name_source` — are each caught with a specific message.
|
||||
|
||||
### The highlight sprite pairs by name; `opt ` is exported but not believed
|
||||
|
||||
FORMAT v1 said `focus_sprite` came from the element's `opt ` link. That reading
|
||||
was **measured and refuted** by the RE agent, and this export shows why plainly:
|
||||
on the main menu, `opt ` chains `ptloop01 → ptloop02 → ptbtn01` — two decorations
|
||||
and then a button. It is a linked list of something, and it is not focus.
|
||||
|
||||
The highlight is paired by **sprite name** instead (`ptbtn01.t32` ↔
|
||||
`ptbtn01f.t32`), which is HANDOFF's convention and holds for all 54 real pairs on
|
||||
the disc. It resolves all five main-menu buttons. The raw link is still exported
|
||||
as `opt_link`, renamed so that nothing downstream mistakes it for navigation, and
|
||||
so that whoever eventually decodes it has the data.
|
||||
|
||||
Note this is 🟡 a naming convention, not a decoded field. It is authored in
|
||||
effect, and lives in the exporter only because it is a rule over disc data rather
|
||||
than a value we chose.
|
||||
|
||||
### The paint order is exported, not authored
|
||||
|
||||
Q3 decoded it — a `u16` layer key at `+0x0A` of each `T8aD` sprite header,
|
||||
stable-sorted with declaration index. So it is read in the exporter, per the
|
||||
contract's own rule for a decoded answer, and `paint_order` in `export/` is a
|
||||
derived field. `"paint_order"` is gone from `unresolved`; **`paint_order_ties`
|
||||
replaces it**, because the tie-break is still unknown and costs one element's
|
||||
blend on one screen.
|
||||
|
||||
Where an element has no `T8aD` header the key comes from the decoders' table of
|
||||
keys **measured off the running game**. That is a different kind of fact, so it
|
||||
is labelled: `layer_source` is `"sprite"`, `"implied"` or `"none"`, and a
|
||||
consumer that needs to know whether a layer is read or measured can tell.
|
||||
|
||||
### Colours are exported as two fields with the byte order in the name
|
||||
|
||||
There are two modulate colours and they multiply: `tint` is RGBA, `fade` is
|
||||
**ARGB** and its high byte is the alpha that ramps. v1's single `"#ffffffff"`
|
||||
could not carry both and silently discarded the ramping alpha. They are exported
|
||||
as `tint_rgba` and `fade_argb`, raw hex, byte order in the key — because getting
|
||||
it backwards is silent and looks like an art bug rather than a parse bug.
|
||||
|
||||
### `t` stays raw
|
||||
|
||||
HANDOFF Q1 is answered — linear ramp, 2 units per rendered frame, working
|
||||
conversion 1 unit = 1/60 s — but that conversion is **measured off the running
|
||||
game, not read from the file**, and the finding itself flags the 27.6 present-
|
||||
frames/second measurement as the part worth re-testing. If the game turns out to
|
||||
present at 60 Hz, every duration halves.
|
||||
|
||||
So `t` is exported exactly as the disc spells it, `keyframe_time_unit` stays in
|
||||
`unresolved`, and the conversion will live in one authored place at P2. One
|
||||
constant to change, in a file that says it is a decision.
|
||||
|
||||
### The final keyframe has no `t`, and `check` enforces that
|
||||
|
||||
The disc has no time slot on the last keyframe of a group. A file that carries
|
||||
one there has invented it. `check` rejects it — this is the one place where the
|
||||
temptation to emit a plausible number is strongest and the resulting error is
|
||||
completely invisible.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Godot draws the screen, 2026-08-28
|
||||
|
||||
### The Godot side reads the manifest, not a path
|
||||
|
||||
`ExportTree` is the only class that knows where `export/` is: `SYLPHEED_EXPORT`
|
||||
if set, otherwise `<project>/../export`. Screens are addressed by their manifest
|
||||
**name** (`main_menu`), never by a file path, so the runtime never encodes the
|
||||
archive's subdirectory and a re-export that moves a file does not break it. It
|
||||
also checks `format` on both the manifest and each screen, and refuses a tree it
|
||||
was not built to read rather than half-drawing one.
|
||||
|
||||
Textures are read as bytes and decoded with `load_png_from_buffer` at runtime.
|
||||
They are deliberately **not** Godot-imported resources: `export/` is gitignored
|
||||
and regenerated wholesale, and a `.import` sidecar per sprite would be derived
|
||||
state living next to derived state, invalidated on every re-export.
|
||||
|
||||
### One CanvasItem draws the whole screen
|
||||
|
||||
`ScreenView._draw` walks `paint_order` and draws each element itself, rather
|
||||
than making a node per element and leaning on `z_index`. The export's
|
||||
`paint_order` is already back-to-front, so honouring it is a loop; expressing
|
||||
the same order through sixteen nodes' z-indices would hide the one thing that is
|
||||
still unresolved about it — the **ties** — behind Godot's own sibling rules,
|
||||
where a change in the export would silently become a change in Godot's tree
|
||||
order instead of a visible change in the draw sequence.
|
||||
|
||||
### P1 draws `rest` and nothing else
|
||||
|
||||
Every element is drawn at its resting pose. No keyframe interpolation: that is
|
||||
P2, and it depends on the keyframe time unit, which is **measured** rather than
|
||||
decoded. A milestone whose gate is a pixel diff must not have a measured
|
||||
constant inside it, or the diff stops being evidence about the port.
|
||||
|
||||
For the same reason `focused_id` is empty at P1. Initial focus was measured as
|
||||
unstable boot to boot (HANDOFF Q5), so choosing one is an authored decision and
|
||||
it belongs to P5, where a human is pressing keys.
|
||||
|
||||
### Nearest-neighbour, and why that is not a preference
|
||||
|
||||
`TEXTURE_FILTER_NEAREST`. The export is a 1:1 copy of the disc's own texels and
|
||||
elements draw at up to 500 %; a bilinear filter invents detail the disc does not
|
||||
have. It is also what the reference renderer does — `ui_layout::blit` maps
|
||||
destination to source by integer division — so a filter difference cannot
|
||||
masquerade as a placement difference in the diff.
|
||||
|
||||
### The capture is the SubViewport, not the window
|
||||
|
||||
The screen is drawn into a `SubViewport` sized to the export's own `design`
|
||||
rectangle and shown through a container that scales it to the window. The first
|
||||
attempt captured `get_viewport()` and got **1235×695**: there is a window manager
|
||||
on the Xvfb display and its title bar had eaten 45×25 px of a screen the export
|
||||
declares as 1280×720. A gate that compares a rescaled 1235×695 capture against a
|
||||
1280×720 composite measures the compositor.
|
||||
|
||||
So `--capture` grabs the SubViewport texture: exactly the design rectangle,
|
||||
independent of the window, directly comparable with `screen render` with no crop
|
||||
and no resample. The windowed run is still worth doing — it is what proves a
|
||||
human sees the screen — but it is not what the numbers come from.
|
||||
|
||||
## P1 gate — the diff, and what it found
|
||||
|
||||
`tools/verify-screen` renders every screen in the manifest both ways and reports
|
||||
the largest per-channel difference anywhere in the frame. Both renderers are held
|
||||
to the same inputs: the reference CLI built by `build-reference-cli` from the
|
||||
revision the exporter is **pinned** to (not `/reborn/target/`, which is a live
|
||||
mount that moves mid-iteration), `--black` because the screen carries its own
|
||||
background, and `--primitives --animated` because those are what make the CLI
|
||||
draw the same element set the port draws at rest.
|
||||
|
||||
| screen | build | max per-channel Δ | |
|
||||
|---|---|---|---|
|
||||
| `main_menu` | 5 | **3** | the P0/P1 gate screen |
|
||||
| `main_menu_jp` | 8 | 3 | |
|
||||
| `extras` / `extras_jp` | 6 / 9 | 4 / 3 | |
|
||||
| `press_start` / `press_start_jp` | 2 / 3 | 1 | |
|
||||
| `build_00` / `build_01` | 0 / 1 | 3 | |
|
||||
| `build_10` / `build_11` | 10 / 11 | **0** | byte-identical |
|
||||
| `title` | 4 | 6 | paint-order tie, below |
|
||||
| `title_jp` | 7 | 154 | sampling phase, below |
|
||||
|
||||
`main_menu` — the milestone's own gate — agrees to **≤3/255 on every channel of
|
||||
every pixel**, RMSE 0.38 %, with **no** pixel differing by more than 4 %. 3/255
|
||||
is what integer-truncating compositing in the CLI and float rounding on a GPU
|
||||
differ by; there is no structural disagreement anywhere in the frame.
|
||||
|
||||
Three screens exceed that, and each has a named cause rather than a threshold.
|
||||
|
||||
### `title`: a tie in the paint order — neither renderer is wrong
|
||||
|
||||
Build 4 is the one screen where the CLI uses a paint order **measured off the
|
||||
running game** instead of deriving it. Compared against the order this port
|
||||
exports, every single disagreement is **inside a tie** — the two orders differ
|
||||
only among elements carrying *identical* layer keys (`0x8083`, the `back2` glow
|
||||
group, and `0x80a0`):
|
||||
|
||||
```
|
||||
derived : … 15, 16, 17, 18, 0, 1, 2, 3, 4, 5, 7, …
|
||||
measured: … 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5, …
|
||||
```
|
||||
|
||||
That is exactly the residual HANDOFF Q3 documents and this export already
|
||||
declares in `unresolved: ["paint_order_ties"]`. It is worth stating what it
|
||||
costs: **904 px** in the glow band at (445,117)–(1195,313), all of them 4–6/255.
|
||||
The port keeps the stable sort, per HANDOFF's own recommendation. Nothing to fix,
|
||||
and nothing to tune — a "fix" here would be fitting the port to one screen's
|
||||
capture.
|
||||
|
||||
Two of the reordered indices (`0x80a0`) are `kind & 0x4` template instances that
|
||||
both renderers skip, so the only real reorder outside the glow group is
|
||||
`ptlogo2` against `ptlogo_tm`, which do not overlap.
|
||||
|
||||
### `title_jp`: nearest-neighbour sampling phase — the CLI is the one I would call wrong
|
||||
|
||||
`title_jp` is the **only** screen in the export with a drawn element at a scale
|
||||
that is not a whole multiple of 100 %: `ptlogo_eff2` at 125 %. It is also the
|
||||
only screen with a difference above 6/255. The two facts are the same fact.
|
||||
|
||||
At a non-integer ratio the two renderers pick different source texels:
|
||||
|
||||
* `ui_layout::blit` samples the source at the destination pixel's **top-left
|
||||
corner** — `sxi = col * sw / dw`.
|
||||
* A GPU samples at the destination pixel's **centre** — `floor((col+0.5)·sw/dw)`.
|
||||
|
||||
At 125 % those disagree on one column in five, which is why the differing pixels
|
||||
are ~30 above 100/255 strung along thin diagonal edges rather than a shifted
|
||||
region. At every whole multiple of 100 % they agree exactly, which is why the
|
||||
other eleven screens are clean.
|
||||
|
||||
**Which is wrong:** the CLI, I think. Corner-sampled nearest is a half-
|
||||
destination-pixel bias toward the top-left that no rasteriser produces, and the
|
||||
Xenon GPU that drew this screen sampled at pixel centres. But I have no
|
||||
framebuffer capture of `title_jp` and the disagreement is sub-pixel on one glow,
|
||||
so this is a reading, not a measurement — recorded in `docs/BLOCKED.md` rather
|
||||
than acted on. **The port is not changing to match**, because matching the CLI
|
||||
here would mean deliberately reproducing a half-pixel offset in order to make a
|
||||
number smaller.
|
||||
|
||||
### `extras`: two pixels
|
||||
|
||||
Two pixels at 4/255. Rounding.
|
||||
|
||||
### What the diff cannot tell us
|
||||
|
||||
The pivot question in `docs/BLOCKED.md` predicted that a P1 diff could not
|
||||
distinguish "anchor scale to the declared pivot" from "anchor to half the
|
||||
texture", because both renderers use the declared pivot. That prediction held:
|
||||
the port and the CLI agree on every scaled element, and that agreement is **not
|
||||
evidence** about which anchor the game uses. It stays open.
|
||||
|
||||
### ~~`pteff05.t32` and `pteff04.t32` have no sprite, and that is correct~~
|
||||
|
||||
**RETRACTED 2026-08-29. This was wrong, and it was the most consequential thing
|
||||
on this page.** See "The menu had no background" below.
|
||||
|
||||
---
|
||||
|
||||
## P2 — keyframe animation, 2026-08-28
|
||||
|
||||
### The time unit is authored, in one file, and says loudly that it is not on the disc
|
||||
|
||||
`authored/timing.json`. HANDOFF Q1 is answered — linear ramp, 2 units per
|
||||
rendered frame, 1 unit = 1/60 s — but that conversion is **measured off the
|
||||
running game**, not read from a file, which is exactly the case the
|
||||
derived/authored split exists for. It is expressed as
|
||||
`keyframe_units_per_second: 60` rather than seconds-per-unit so the value is
|
||||
exact instead of a repeating decimal, and it carries the two independent lines
|
||||
that support it. `t` stays raw everywhere in `export/`; seconds appear only
|
||||
where this file is applied, which is one line of `boot.gd`.
|
||||
|
||||
`exit_ramp_seconds` is deliberately **null**. See below.
|
||||
|
||||
### The timeline stops at the last *timed* keyframe, and never plays the exit
|
||||
|
||||
The last keyframe of every group carries **no `t`** — the disc has no time slot
|
||||
there. Across this export that final frame is an *exit* pose: for 116 of 134
|
||||
elements it differs from the last timed keyframe **in alpha only** (a fade-out),
|
||||
for 12 it is the loading splash's scale-and-slide exit, and for 6 it is
|
||||
identical (no exit animation at all).
|
||||
|
||||
So the group is `pre-roll → ramp in → hold → [exit]`, and the port plays it up to
|
||||
the hold and stops. Playing into the exit would mean **inventing how long the
|
||||
ramp takes**, because the disc does not say. That duration is the screen
|
||||
transition — HANDOFF Q7 measured it at ~0.4 s — and it belongs to P3, with its
|
||||
own evidence. This is why `exit_ramp_seconds` is null rather than 0.4: P2 has no
|
||||
business holding it.
|
||||
|
||||
### The interpolation is checked by where it lands, not by inspection
|
||||
|
||||
For **8 of the 12** screens the settled timeline is **byte-identical** to the
|
||||
`--pose=rest` render. That is the useful assertion: the port walks the keyframes
|
||||
with an authored time unit and arrives, to the pixel, at the pose the pinned
|
||||
decoders independently identify as the resting one. `tools/screen-strip` reports
|
||||
this per screen, so a change to the interpolation that drifts by one unit shows
|
||||
up as a diff rather than as nothing.
|
||||
|
||||
The four that differ do so for two distinct reasons, below.
|
||||
|
||||
## `rest` misidentifies six elements, and the running game says so
|
||||
|
||||
On `main_menu`, the settled timeline and `rest` differ in exactly one region:
|
||||
**400×470 at (440,108)** — the bounding box of `ptframe1` and `ptframe2`, and
|
||||
nothing else on the screen.
|
||||
|
||||
`rest` puts both at their **first** keyframe: off-position and fully
|
||||
transparent. The keyframes say they slide (620,108)→(440,108) and (403,267)→
|
||||
(583,267) while fading 0x00→0xff, and then hold that pose for their last three
|
||||
keyframes including the untimed one.
|
||||
|
||||
`/reborn/docs/re/captures/main-menu-oracle.png`, a capture of the running game,
|
||||
**shows them**: the bright circuit-frame bracket around the menu, with a ring at
|
||||
the bottom right. Cropping the same 250×180 region from the capture and from
|
||||
both renders puts the ring and its elbow trace in the port's timeline render
|
||||
**pixel-aligned with the game's**, and absent from the `rest` render. That is
|
||||
geometry, not luminance, so it does not depend on the capture's gamma or on the
|
||||
fact that it was taken with `NEW GAME` focused.
|
||||
|
||||
### Why the decoders get it wrong, precisely
|
||||
|
||||
`ui_layout::rest_plateau` excludes a run of identical keyframes that **ends the
|
||||
group**, because that run is normally the exit — the comment cites the pause
|
||||
menu, where taking the trailing run erased the word PAUSE. That exclusion is
|
||||
right in general and wrong for an element with **no exit animation**, where the
|
||||
trailing run *is* the hold. The rule then falls back to an earlier run, which
|
||||
for a slide-in is the invisible pre-roll.
|
||||
|
||||
The condition that identifies the affected elements exactly, with no false
|
||||
positives in this export, is:
|
||||
|
||||
> the final untimed keyframe has the **same pose** as the last timed keyframe
|
||||
|
||||
Six elements match it and `rest` misses all six: `ptframe1`/`ptframe2` on
|
||||
`main_menu` and `main_menu_jp`, and `pteff02` on `title` and `title_jp`. This is
|
||||
a **finding for the RE agent** about `sylpheed-formats`, not something this port
|
||||
fixes: the decoders are pinned and must not be reimplemented here. The port
|
||||
simply does not use `rest` — it derives the arrived pose from the keyframes,
|
||||
which needs no heuristic — and `verify-screen` still asks for `--pose=rest` so
|
||||
that renderer-vs-renderer diffing compares like with like.
|
||||
|
||||
Note what this says about P1: the port and the reference renderer **agreed** on
|
||||
`main_menu` to 3/255, and both were missing two elements the game draws. Two
|
||||
renderers reading the same field through the same decoder agreeing is not
|
||||
evidence that the field is right. `docs/BLOCKED.md` had already said that about
|
||||
the pivot; here it bit for real.
|
||||
|
||||
## The title is not settled, and P2 does not claim it
|
||||
|
||||
`title` and `title_jp` differ between the two modes by much more (max 142 and
|
||||
247), and there the disagreement is **not** the six-element bug alone. `rest`
|
||||
picks a mid-timeline hold for several glows (`pteff01`, `ptlogoall_eff`,
|
||||
`ptlogoall_eff2`, `ptlogo_back2eff5`) where the timeline runs on to a much
|
||||
brighter pose.
|
||||
|
||||
I could not settle which is right, and did not try to make the numbers agree:
|
||||
|
||||
* No element's alpha ever reverses direction anywhere in this export, so the
|
||||
title's 4.48 s timeline is a slow one-way ramp, not a pulse — which removes the
|
||||
obvious reason to expect a loop, but does not prove there is none.
|
||||
* The only live title capture composites the **`PRESS Ⓐ` plate (build 2) over
|
||||
the title (build 4)**, so it cannot be diffed against build 4 alone. Mean
|
||||
luminance is oracle 64.1, `rest` 62.8, timeline 80.0 — which looks like it
|
||||
favours `rest`, except that the plate *adds* brightness and `rest` is carrying
|
||||
a 25 % black dim quad (`pteff02`) that is itself one of the six misidentified
|
||||
elements. The comparison is confounded in both directions and settles nothing.
|
||||
* **Both modes are visibly wrong anyway.** Side by side with the capture, the
|
||||
port draws a washed-out cyan glow slab across the logo that the running game
|
||||
does not have — in `rest` mode too. That is a third problem, independent of
|
||||
this one, and it is P3's.
|
||||
|
||||
So: the timeline is the default because it is derived from the disc's own
|
||||
keyframes with one measured constant and no heuristic, and because it is proven
|
||||
right on the screen this milestone gates. On the title it is **unverified**, and
|
||||
P3 should not assume P2 settled it.
|
||||
|
||||
---
|
||||
|
||||
## P2, corrected — the pin moved, and the settle rule was wrong, 2026-08-28
|
||||
|
||||
### Answering the RE agent's question: which six, and on what screens
|
||||
|
||||
They asked, having found only two elements on the English main menu satisfying
|
||||
the condition this port proposed. The six span the whole 12-screen export:
|
||||
|
||||
| element | screens | trailing run |
|
||||
|---|---|---|
|
||||
| `ptframe1`, `ptframe2` | `main_menu`, `main_menu_jp` | alpha `0xff` — **visible** |
|
||||
| `pteff02` | `title`, `title_jp` | alpha `0x00` — **transparent** |
|
||||
|
||||
So four of the six are the pair they already found, once per language build, and
|
||||
their alpha rule accepts exactly those. The other two are `pteff02`, whose
|
||||
trailing run is transparent, so their rule **excludes** it and leaves `rest` at
|
||||
`0x40`.
|
||||
|
||||
**That exclusion is right, and their own measurement proves it.** `pteff02` is
|
||||
the 25 % dim quad; they measured the title render going from **+13.14 to +0.55**
|
||||
against the plate-free capture once the dim is drawn. `rest` must therefore stay
|
||||
at `0x40` and must *not* move to the transparent trailing run — which is what
|
||||
their rule does. Two investigations converging from opposite directions.
|
||||
|
||||
The condition this port proposed was **too loose**; the alpha discriminator is
|
||||
the correct rule and the port has no amendment to offer.
|
||||
|
||||
### The pin moved 8b6dbcf → 5414db3
|
||||
|
||||
Its own commit, and what I wanted from it is the fixed `ui_layout::rest()`.
|
||||
Pinned at `5414db3` rather than `4bc9706` where the fix was written, because
|
||||
`5414db3` is where it carries its disc-wide check — 30 of 13 991 elements move,
|
||||
4 become visible, **0 become invisible**.
|
||||
|
||||
The re-export is the evidence the change was contained: **two files changed, and
|
||||
within them exactly four `rest` blocks** — `ptframe1`/`ptframe2` on both main
|
||||
menus moving from `(620,108)/(403,267)` at `t=16` and alpha `0x00` to
|
||||
`(440,108)/(583,267)` at `t=62` and alpha `0xff`. Every diff line pairs; the
|
||||
other ten screens are byte-identical, `pteff02` did not move, and no sprite
|
||||
changed.
|
||||
|
||||
### The settle rule was wrong, and their title finding is what showed it
|
||||
|
||||
P2 shipped "hold the last **timed** keyframe", on the reasoning that the exit is
|
||||
the final untimed frame. **That is wrong**, and the title is the counter-example:
|
||||
`pteff02` holds at `t=46` with the dim at alpha `0x40` and then ramps to `0x00`
|
||||
by `t=236`. The exit is not only the untimed frame — it can be a long run of
|
||||
timed ones. Running to the end drops the dim and makes the whole screen ~13/255
|
||||
too bright, which is exactly the luminance excess P2 recorded (oracle 64.1,
|
||||
`rest` 62.8, timeline 80.0) and could not explain.
|
||||
|
||||
A group is `pre-roll → ramp in → hold → ramp out → post-roll`, and a screen that
|
||||
has arrived sits on **the hold**. So the timeline now plays in and stops at
|
||||
`rest`, which is the decoders' identification of that hold and carries its own
|
||||
`t`. `settle_units()` is `rest.t`.
|
||||
|
||||
The check is that the disagreement vanishes: on **all twelve** screens the
|
||||
settled timeline is now byte-identical to the `--pose=rest` render, where before
|
||||
this change four of them differed by up to 247/255. The timeline's endpoint
|
||||
*should* be `rest` — the animation is what the timeline adds, not a different
|
||||
destination — so this is the property to want, and it now holds without a
|
||||
special case.
|
||||
|
||||
That also retires P2's open question about looping, from the other side: the RE
|
||||
agent measured that groups hold rather than loop (`ptloop01`/`ptloop02` park
|
||||
off-screen at x=1521 and x=−839; 18 s of settled title sits at sd ≤ 0.01).
|
||||
|
||||
## The reference renderer was stale for three diff runs
|
||||
|
||||
Worth recording as a process failure, because it defeated the project's whole
|
||||
verification method for a while and it failed *silently*.
|
||||
|
||||
After bumping the pin I rebuilt the reference CLI, and `build-reference-cli`
|
||||
reported success at rev `5414db3`. `verify-screen` then showed `main_menu`
|
||||
jumping from 3/255 to **72/255**. The natural reading — the port had regressed —
|
||||
was wrong. The port was right and **the reference was a revision behind**: the
|
||||
shared `CARGO_TARGET_DIR` still held a `sylpheed-cli` built from `8b6dbcf`, and
|
||||
cargo reported `Finished in 0.13s` and left it in place. Building into a clean
|
||||
target directory produced a binary that resolves `ptframe1` to `(440,108) t=62`;
|
||||
the shared one still said `(620,108) t=16`.
|
||||
|
||||
The old check — "does `screen list` run?" — cannot catch this, because a stale
|
||||
binary runs perfectly.
|
||||
|
||||
Two changes:
|
||||
|
||||
* `build-reference-cli` builds into `$CARGO_TARGET_DIR/reference-cli/$rev`, a
|
||||
tree **keyed by the pinned revision**, so a new pin has no artifacts to reuse.
|
||||
A stable copy is placed alongside for consumers.
|
||||
* It then checks the binary **against `export/`**: both come from the same pin,
|
||||
so if the CLI resolves `ptframe1`'s rest differently from what the exporter
|
||||
wrote, the two halves of the verification are not the same revision and it
|
||||
fails loudly. It compares the two rather than asserting a literal, so it stays
|
||||
true when the pin moves again.
|
||||
|
||||
`docker/bin/` is baked into the image, so this takes effect on the next image
|
||||
build; until then the repo copy has to be invoked by path. The RE agent hit the
|
||||
same class of trap this session from the other side (`./target/debug` stale
|
||||
against a redirected `CARGO_TARGET_DIR`). It is worth naming the general shape:
|
||||
**a build system reporting success is not evidence that the artifact you are
|
||||
about to trust is the code you pinned.**
|
||||
|
||||
### What this did not change
|
||||
|
||||
`title` (6/255), `extras` (4/255) and `title_jp` (154/255) are unchanged, and
|
||||
their diagnoses stand — a paint-order tie, two pixels, and nearest-neighbour
|
||||
sampling phase at 125 % scale. The title's swoosh defect the RE agent localised
|
||||
(drawn thick and white where the game draws it thin and pink) is untouched by
|
||||
any of this and remains P3's.
|
||||
|
||||
---
|
||||
|
||||
## The menu had no background, and P1 called that correct, 2026-08-29
|
||||
|
||||
The pin moved `5414db3 → f817dd5` for `56cc7ac`, "a RATC child's name is stated,
|
||||
not inferred". `ratc::parse` had named each child by scanning backwards for the
|
||||
last printable run of bytes before its magic. For `pteff05.t32` the three
|
||||
trailing payload bytes are `38 41 58` — `8AX` — which beat the real name, so the
|
||||
child registered under a name no element declares and resolved to no sprite.
|
||||
|
||||
`pteff05.t32` is the **full-resolution background of all five menu screens**.
|
||||
|
||||
So every render this port has produced of a menu screen has been missing its
|
||||
background, and P1 wrote that up as a property of the disc: *"the bundle declares
|
||||
them and carries zero RATC children for either, so there is no texture on the
|
||||
disc to export."* That sentence was false. The bundle carries the child; the
|
||||
decoder was handing back the wrong name for it. Retracted above rather than
|
||||
edited away.
|
||||
|
||||
### What the re-export shows
|
||||
|
||||
Six new sprites and nothing else: `pteff05.png` on `main_menu`, `extras` and
|
||||
their Japanese twins, `pteff04.png` on both titles. Per screen the JSON gains a
|
||||
`sprite` line and `layer_source` moves `"implied" → "sprite"` — the layer key is
|
||||
now **read from the file** instead of taken from the decoders' table of keys
|
||||
measured off the running game. That is the derived/authored ratchet turning the
|
||||
right way, in the exporter rather than in `authored/`.
|
||||
|
||||
`pteff05.png` is **1280×720**; `ptbase.png`, which had been carrying the
|
||||
background alone, is 640×360 drawn at 200 %. The screen was being shown its own
|
||||
art at half resolution.
|
||||
|
||||
### Measured against the live capture, not against the other renderer
|
||||
|
||||
Whole-frame RMSE of the settled `main_menu` against
|
||||
`captures/main-menu-oracle.png`:
|
||||
|
||||
| | RMSE |
|
||||
|---|---|
|
||||
| before this pin | 8.05 % |
|
||||
| with the real background | **5.92 %** |
|
||||
|
||||
A 26 % reduction, and it is the right kind of evidence: the reference renderer
|
||||
was missing the same element for the same reason, so a renderer-vs-renderer diff
|
||||
could not have found this. It is the third time on this project that the
|
||||
capture caught something both renderers agreed on — the bracket, the title dim
|
||||
quad, and now the background.
|
||||
|
||||
`verify-screen` after the bump is unchanged in character: everything at 3–4/255
|
||||
except `title` (6, the paint-order tie) and `title_jp` (155, the sampling phase).
|
||||
Both renderers gained the background together.
|
||||
|
||||
### One thing the comparison says that I did not expect
|
||||
|
||||
Rendering with `--focus=ptbtn01`, which is how the capture was taken, makes the
|
||||
RMSE **worse** — 5.92 % → 7.00 %. The port *replaces* an element's sprite with
|
||||
its `*f` twin; `sylpheed-cli`'s own `--focus` is documented as drawing the
|
||||
focused record **over** the base element. Those are different operations, and
|
||||
the capture shows a ring marker beside `NEW GAME` that the port does not draw.
|
||||
|
||||
This is P5's, not P2's, and it is not being guessed at here. Raised in
|
||||
`docs/BLOCKED.md`.
|
||||
|
||||
---
|
||||
|
||||
## P3 — splash → title, unattended, 2026-08-29
|
||||
|
||||
### The splash is located by entry index, because no rule can find it
|
||||
|
||||
The RE agent looked for a content predicate and there is none: design size fails
|
||||
(every extra composable bundle sampled is 1280×720, the same as every screen) and
|
||||
element count fails (fragments run 2…15 elements in `GP_OPTIONS`/`GP_SAVE_LOAD`
|
||||
while the splash halves are 3 and 7 — the ranges overlap).
|
||||
|
||||
So `screen_builds` is now `is_build` **plus an authored allow-list of entry
|
||||
indices**, in `authored/screen_names.json` under `also_export`, each with a `why`
|
||||
that says it is a locator and not a claim. This is safe in `GP_TITLE` and would
|
||||
not be in general: there, widening adds exactly four bundles and all four are
|
||||
real screens with zero fragments. That is why it is an allow-list rather than a
|
||||
loosened predicate.
|
||||
|
||||
**There were two splash screens and the port had neither.** Entries 11/14 are the
|
||||
developer logos (GAME ARTS / SETA / studio anima); entries **10/13 are the SQUARE
|
||||
ENIX publisher wordmark, the first thing the boot shows**, and nothing in this
|
||||
project had noticed them. Both pairs are region twins — ™ on 10, ® on 13 — and
|
||||
the port shows one of each, not both.
|
||||
|
||||
### `authored/screen_names.json` is now keyed by pak entry, not by ordinal
|
||||
|
||||
Widening the enumeration renumbers the ordinals, and a name that moves when the
|
||||
enumeration rule changes is not a name. The file had always called the entry
|
||||
"the stronger locator"; it is now the only stable one. In `GP_TITLE` the two
|
||||
coincide across all 16 entries, which is also the numbering `sylpheed-cli screen
|
||||
--build N --all` takes — so `verify-screen` now passes `--all`, and without it
|
||||
`--build 10` would have landed on entry 12.
|
||||
|
||||
The two previously-unnamed plates therefore renamed `build_10`/`build_11` →
|
||||
`build_12`/`build_15`. Their names were always locators; now they locate the
|
||||
right thing.
|
||||
|
||||
### The exit is the group playing itself out, not a black rect over a freeze
|
||||
|
||||
HANDOFF's answer to ask 2 was (a), and it came with a test that discriminates
|
||||
rather than a plausibility argument. Under "a black quad over a frozen screen"
|
||||
every region is scaled by the same 1−α, so the button-region / background-region
|
||||
brightness **ratio** stays constant through the fade. Measured, it falls
|
||||
6.495 → 5.574 → 3.105 → 2.125 → 1.935 — a 3.4× monotonic drop. The screen plays
|
||||
out: `pteff00.prm` ramps to opaque black while the labels, `ptmsg`, `pteff10`
|
||||
and `pteff12` ramp to transparent, and `ptframe1`/`ptframe2` hold.
|
||||
|
||||
Implemented by giving the final untimed keyframe a **synthetic time**,
|
||||
`exit_ramp_units` after the last timed one, and then interpolating it like any
|
||||
other. One code path: the difference between arriving and leaving is only how far
|
||||
`t` is allowed to run, not a second kind of animation.
|
||||
|
||||
`exit_ramp_units = 24` (~0.4 s) is authored, and `authored/timing.json` carries
|
||||
the RE agent's own reach caveat rather than smoothing it: the filmstrip is
|
||||
downsampled and the button region contains some background, so this pins the
|
||||
**direction**, not 0.4 s to ±0.05 s, and it is one transition pair.
|
||||
|
||||
### Nothing waits on a timer the disc does not carry
|
||||
|
||||
`dwell` in `authored/flow.json` is deliberately empty. Each screen's dwell is its
|
||||
own keyframe group — the publisher wordmark reaches its hold at t=235 (3.92 s),
|
||||
the developer logos at t=190 (3.17 s), both read from the disc. Adding a hold on
|
||||
top would be inventing a number nobody measured. The pacing you see is the
|
||||
disc's own, and the file says where a measured number would go.
|
||||
|
||||
### The last screen holds
|
||||
|
||||
A screen plays itself out because something is taking its place. Nothing takes
|
||||
the title's place yet, so the sequencer holds there. A boot that ends by fading
|
||||
to black is a boot that looks like it crashed. P4 puts the intro video in front
|
||||
of the title and P5 gives the title somewhere to go.
|
||||
|
||||
### `flow.json` reproduces an observation and says so
|
||||
|
||||
Q6 closed with a negative: the order is in none of the four places it could have
|
||||
been, and a transition is a call with a name argument chosen by code. So this
|
||||
file is authored and its header says plainly that it reproduces what was watched,
|
||||
not what any file states. The intro video's place in the real boot is **named as
|
||||
a gap** rather than the order being quietly rewritten to hide it.
|
||||
|
||||
## P3 gate
|
||||
|
||||
`godot --path port -- --boot --film=/tmp/boot` runs unattended:
|
||||
|
||||
```
|
||||
publisher_logo → developer_logos at 4.65 s → title at 8.57 s
|
||||
boot sequence complete after 13.05 s, holding on title
|
||||
```
|
||||
|
||||
The filmstrip shows each screen fading in, holding, and fading through black into
|
||||
the next, and the title staying up. `verify-screen` covers all **16** screens
|
||||
now; the four new splash bundles come in at max 1–2/255 against the reference
|
||||
renderer. The three known differences are unchanged: `title` 6 (paint-order tie),
|
||||
`main_menu` 4, `title_jp` 155 (sampling phase at 125 % scale).
|
||||
|
||||
## Answers taken from the RE agent without re-deriving them
|
||||
|
||||
* **Focus stays "replace".** Over-vs-instead is unobservable: the focused sprite
|
||||
covers the base at 100 % of base-visible pixels, and the two compositions
|
||||
differ by RMSE 1.1 inside the button rect — under the gamma floor. The port's
|
||||
guess was right for the wrong reason, and the actual gap is that
|
||||
`ptbtn0Nf.rat` declares **two** sprites — `ptbtneff01.t32`, a glowing ring, and
|
||||
then the bright label — where `ptbtn0N.rat` declares one. The ring is P5's, and
|
||||
its placement inside the record is **not decoded**, so it will be authored from
|
||||
the capture and marked as such.
|
||||
* **RMSE against captures has a floor, so stop chasing it.** The capture is
|
||||
`≈ 255·(render/255)^γ` with γ ≈ 1.49 on the menu and `EXTRAS`, 1.34 on the
|
||||
title, and it is a ramp *the game installed* (`VdGetCurrentDisplayGamma` at
|
||||
video init), not a capture-path artefact to subtract. Its reach is narrow —
|
||||
the flat patches it was fitted on are almost all dark — so the port will not
|
||||
extrapolate it across the range, and will not apply it to rendered output on
|
||||
this evidence. It is a comparison constant, not a rendering one.
|
||||
* **Rotation is escalated to a human and the port has not acted.** The RE half is
|
||||
answered — rotate about the **declared pivot**, measured against the GPU
|
||||
capture — and it has zero effect on the five screens at rest. The port will
|
||||
carry `rotation_deg` in a future FORMAT v3 because carrying a decoded field the
|
||||
renderer ignores beats dropping it, but it will not draw it until the
|
||||
divergence question is settled.
|
||||
|
||||
---
|
||||
|
||||
## P4 — the intro video, 2026-08-29
|
||||
|
||||
### Theora at 720p is fine here, and no runtime dependency is requested
|
||||
|
||||
MISSION §6 anticipated that Theora might be too poor at 720p and permitted the
|
||||
FFmpeg-GDExtension fallback to be **proposed**. It is not needed, and this was
|
||||
measured rather than judged by eye alone. SSIM against the decoded source over a
|
||||
10 s sample: **0.9863 at `-q:v 6`, 0.9896 at 8, 0.9924 at 10**. At 200 % zoom on
|
||||
the reel's hardest case — fine serif text and soft gradients over near-black,
|
||||
where Theora breaks first — q8 is indistinguishable from the source.
|
||||
|
||||
`-q:v 8`, and **no GDExtension is being proposed or adopted**.
|
||||
|
||||
`-ac 2` because the source is **6-channel** WMA Pro and Godot's Theora path is
|
||||
not a surround one. That downmix is a decision, so it lives in the recorded
|
||||
command where a modder can see and change it rather than in prose.
|
||||
|
||||
### The exact command is in the manifest, per MISSION §6
|
||||
|
||||
`export/manifest.json` gains a `videos` array, each entry carrying the verbatim
|
||||
`ffmpeg` line that produced it. A modder who dislikes the quality re-runs one
|
||||
line instead of reverse-engineering what was done to their video — which is the
|
||||
whole reason this project converts the disc rather than reading it at runtime.
|
||||
|
||||
### A cache, and why that is not a hand-edit
|
||||
|
||||
`export/` is regenerated wholesale, but re-encoding 232 s of video on every run
|
||||
costs ~4 minutes to produce a byte-identical file, and an exporter nobody re-runs
|
||||
is worse than a cache. So each movie gets a `.cmd` sidecar recording the command
|
||||
and the source size, and the encode is skipped only when both match exactly. Any
|
||||
change to either re-encodes. This is derived state validating derived state, not
|
||||
a hand-edit.
|
||||
|
||||
### The player renders into the design viewport, not beside it
|
||||
|
||||
First attempt parented the `VideoStreamPlayer` to the Boot node. It played, and
|
||||
every captured frame was **black**: the capture reads the SubViewport, and the
|
||||
player was rendering to the window. Worth stating as more than a capture bug —
|
||||
everything this port draws composes in the export's own 1280×720 design space,
|
||||
and a movie outside that space is outside the coordinate system every screen is
|
||||
expressed in.
|
||||
|
||||
### Ⓐ skips, because Q9 measured it
|
||||
|
||||
The only input the port handles so far. HANDOFF Q9: one Ⓐ press skips a movie,
|
||||
measured — the title was reached at 57 s against a 193 s baseline. Menu
|
||||
navigation is still P5.
|
||||
|
||||
## P4 gate
|
||||
|
||||
`godot --path port -- --boot --film=…` runs
|
||||
`publisher_logo → developer_logos → ADV.ogv → title`, unattended. The filmstrip
|
||||
shows the SQUARE ENIX ident, then the reel's live-action-styled CG, then the
|
||||
title. The movie's place in the boot is **measured, not decoded** — Q9 decodes
|
||||
`ADVERTISE_MOVIE → ADV.wmv` from the movie manifest, but *where it sits in the
|
||||
boot order* is what the RE agent watched, and `authored/flow.json` says so.
|
||||
|
||||
### What I cannot verify from here
|
||||
|
||||
**Audible playback.** This container has no audio device — Godot falls back to
|
||||
the dummy driver. What is verified is that the Vorbis stream exists in the
|
||||
transcode, is 2-channel, and decodes. Whether Godot emits it audibly is
|
||||
unconfirmed and is stated as unconfirmed rather than assumed from the stream's
|
||||
presence. It is a cheap check for anyone with a sound device and an impossible
|
||||
one here.
|
||||
|
||||
---
|
||||
|
||||
## RETRACTION — `sylpheed-cli` is not the oracle, 2026-08-29
|
||||
|
||||
**This corrects a framing that runs through everything above, so it is a
|
||||
retraction rather than an edit.** Every place this file called
|
||||
`sylpheed-cli screen render` *"the reference renderer"* — and it does so
|
||||
repeatedly, starting at P1 — overstated what it is.
|
||||
|
||||
The correction comes from the human, via the RE agent, in their words: Reborn
|
||||
"was/is just a GUI explorer and extraction CLI for verifying the decoding of the
|
||||
various files. It may very well be wrong." **The oracle is the Xenia Canary
|
||||
capture and the game.**
|
||||
|
||||
So `tools/verify-screen` is a **consistency check between two decoders that
|
||||
share their assumptions**, and a regression detector. It is not a correctness
|
||||
check, and agreement in it is not evidence of correctness.
|
||||
|
||||
### The embarrassing part is that this file already knew
|
||||
|
||||
After the `ptframe1` case, P2's write-up says: *"Two renderers reading one field
|
||||
through one decoder agreeing is not evidence that the field is right."* Then P1's
|
||||
numbers kept being quoted as though 3/255 against `sylpheed-cli` meant the port
|
||||
was right. Having the principle written down did not stop me leaning on the
|
||||
agreement — which is worth recording, because that is the failure mode, not
|
||||
ignorance of the principle.
|
||||
|
||||
**Three times** both renderers agreed and both were wrong, all three caught by a
|
||||
capture and catchable by nothing else:
|
||||
|
||||
| | what both got wrong | how it surfaced |
|
||||
|---|---|---|
|
||||
| `pteff05` | the menu screens had **no background** | the RE agent decoded the RATC child name |
|
||||
| scale 0 | drawn at full size instead of collapsed | RE agent's control run |
|
||||
| `rest()` | `ptframe1`/`ptframe2` invisible; the menu bracket missing | `main-menu-oracle.png` |
|
||||
|
||||
### What changes
|
||||
|
||||
* `tools/verify-screen` says all of this in its own header, calls the CLI the
|
||||
**comparison** renderer, and a `DIFFERS` row now means "we moved apart, find
|
||||
out which of us moved" rather than "the port is wrong".
|
||||
* The correctness question moves to the captures. The RE agent has committed
|
||||
nine of them with an index at `docs/re/captures/ORACLE-CAPTURES.md`, covering
|
||||
all five screens in scope — including a **main menu with `OPTIONS` focused**,
|
||||
whose difference from the unfocused menu isolates exactly what focus changes.
|
||||
* Three cautions travel with any capture comparison, and they are the RE agent's:
|
||||
the captures are **not gamma-neutral** (γ ≈ 1.49 menu, 1.34 title — there is a
|
||||
floor, do not chase it); **geometry is sound** (best alignment 0,0 at corr
|
||||
0.9466, so a positional disagreement is real); and each is **one moment of a
|
||||
still-animating screen**, so compare settled poses or regions known to be at
|
||||
rest.
|
||||
|
||||
### What does not change
|
||||
|
||||
The port keeps running `verify-screen` over all 16 screens every iteration. A
|
||||
consistency check is still worth having — it is total, it is cheap, and it is
|
||||
what catches a divergence the RE agent introduces on their side. It is simply
|
||||
not a grade, and this file will stop quoting it as one.
|
||||
347
docs/port/FORMAT.md
Normal file
347
docs/port/FORMAT.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# 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/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": [] }],
|
||||
"video_transcode": "ffmpeg -i ADV.wmv -c:v libtheora -q:v 8 -c:a libvorbis -q:a 5 ADV.ogv",
|
||||
"warnings": ["GP_READY_ROOM not exported -- out of scope"]
|
||||
}
|
||||
```
|
||||
|
||||
`video_transcode` will record the exact command so a modder can re-run it rather
|
||||
than reverse-engineer what was done. It is absent until P4 writes a video.
|
||||
|
||||
## Changes from 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. |
|
||||
86
docs/port/MODDING.md
Normal file
86
docs/port/MODDING.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Modding is a requirement, not a later feature
|
||||
|
||||
The port has two goals, and the second one constrains the first: **the exported
|
||||
asset tree is a product**, not a build artefact. Someone who has never read this
|
||||
repository should be able to open the tree, understand what they are looking at,
|
||||
change something, and see it in the game.
|
||||
|
||||
That is a design constraint on the **exporter**, today — not a milestone to add
|
||||
later. Custom *behaviour* (scripting) is a later milestone, but nothing built now
|
||||
may make it harder.
|
||||
|
||||
## The five rules
|
||||
|
||||
**1. One logical asset, one file. Never split.**
|
||||
|
||||
A sprite is one PNG. A track is one OGG. A screen is one JSON. This is a real
|
||||
constraint and not a platitude, because **the disc does the opposite everywhere**:
|
||||
a `.pak` entry spans segment files, a bank holds several sub-waves, and a
|
||||
cutscene voice is a byte region of a continuous stream chunked across entries
|
||||
whose boundaries do not match the cues. All of that is the *exporter's* problem
|
||||
to resolve. If a modder has to reassemble anything, the export is unfinished.
|
||||
|
||||
**2. Names a person recognises.**
|
||||
|
||||
`screens/title/main_menu.json`, not `0x90822a39.json`. Where the disc's own name
|
||||
was never recovered — the six `*2D` archives, `GP_READY_ROOM` — emit a stable
|
||||
synthetic name **and say in the file that the real one is unknown**, so a modder
|
||||
can tell a recovered name from an invented one.
|
||||
|
||||
**3. Modern, editable formats only.**
|
||||
|
||||
| kind | format | why |
|
||||
|---|---|---|
|
||||
| data, layout, config | **JSON** | Godot parses it natively (`JSON.parse_string`); every tool speaks it |
|
||||
| images | **PNG** | RGBA8, lossless, opens anywhere |
|
||||
| audio | **Ogg Vorbis** | Godot-native, no licence trap |
|
||||
| video | **Ogg Theora** | the only format Godot 4 plays natively |
|
||||
| text | **UTF-8** | never UTF-16BE, whatever the disc did |
|
||||
|
||||
Not XML: Godot's `XMLParser` is a SAX-style API needing a hand-written binding
|
||||
per schema, where JSON is one call. Not a custom binary container, ever — that
|
||||
would rebuild the exact wall this port exists to remove.
|
||||
|
||||
**4. Base and overrides, never one merged pile.**
|
||||
|
||||
```
|
||||
data/
|
||||
base/ generated from your disc by the exporter. Gitignored. Rewritten
|
||||
wholesale -- never hand-edit it, your changes will vanish.
|
||||
mods/ drop-in overrides. Yours. The exporter never touches this.
|
||||
```
|
||||
|
||||
A mod replaces a file by shadowing its path. A modder edits nothing under
|
||||
`base/`, so re-exporting is always safe, and "did I break it?" is answered by
|
||||
disabling a mod rather than by re-extracting the disc.
|
||||
|
||||
**5. Provenance in every generated file.**
|
||||
|
||||
Source archive, entry index, exporter version. It is what lets someone check a
|
||||
file against the disc instead of trusting it — and what stops the export drifting
|
||||
into an unverifiable fork of the original.
|
||||
|
||||
## Not blocking scripted mods later
|
||||
|
||||
Behaviour modding is a later milestone. Two decisions now keep the door open:
|
||||
|
||||
* **The loader is data-driven and screen-agnostic.** No screen name is hardcoded
|
||||
in GDScript. A screen is *whatever the JSON describes*, so a mod that adds a
|
||||
new screen needs no engine change.
|
||||
* **No GDExtension, no native code in `port/`.** [Godot Mod Loader][gml] — the
|
||||
established option, Godot 4.1–4.3, used by Brotato and Dome Keeper — works by
|
||||
substituting **GDScript** at load time. A port whose logic lives in GDScript
|
||||
stays moddable by it; one that hides logic in native code does not.
|
||||
|
||||
Adopting a mod loader is a decision for that milestone. Making it *possible* is a
|
||||
constraint on this one.
|
||||
|
||||
[gml]: https://github.com/GodotModding/godot-mod-loader
|
||||
|
||||
## What this rules out, explicitly
|
||||
|
||||
* Atlases or packed archives of sprites — one file per sprite.
|
||||
* Any format needing our code to read it.
|
||||
* Hash-named files.
|
||||
* Hand-edited files under `data/base/`.
|
||||
* Splitting one playable thing across files to mirror how the disc stored it.
|
||||
188
docs/port/PORT-MISSION.md
Normal file
188
docs/port/PORT-MISSION.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Primary objective — the menu shell, running in Godot
|
||||
|
||||
**Status:** active, set 2026-08-28.
|
||||
|
||||
Build a Godot 4 project that boots the player's own disc through the sequence the
|
||||
real game uses, and let a person move through it:
|
||||
|
||||
```
|
||||
developer logo splash → intro video → title / PRESS Ⓐ → main menu → submenus
|
||||
```
|
||||
|
||||
No gameplay. No 3D. No HUD. No emulator. Done means a human presses a d-pad and
|
||||
Ⓐ and moves through those screens with the right art, animation, music and
|
||||
transitions.
|
||||
|
||||
## 1. You are one of two agents
|
||||
|
||||
A **container agent** does the reverse engineering, in the
|
||||
[Syplheed-Reborn][reborn] repository. It runs the emulator; you do not. You build
|
||||
the port from what it publishes.
|
||||
|
||||
The contract is `docs/port/HANDOFF.md` in that repository. **Read it before
|
||||
assuming any value is on the disc.** Every answer there is one of three things,
|
||||
and the distinction decides what you do:
|
||||
|
||||
| | meaning | what you do |
|
||||
|---|---|---|
|
||||
| **decoded** | a field on the disc, with a disc-wide check | read it in the exporter |
|
||||
| **measured** | not on the disc, but the running game does *this* | put it in `authored/`, cite the finding |
|
||||
| **undecodable** | looked for, provably not there | put it in `authored/`, say it is a decision |
|
||||
|
||||
If HANDOFF.md does not answer something you need, **say so and move to another
|
||||
milestone**. Do not guess and do not reverse engineer it yourself — you have no
|
||||
emulator and no oracle, so a guess here is indistinguishable from a fact and will
|
||||
be believed later.
|
||||
|
||||
[reborn]: https://git.mc02.dev/fabi/Syplheed-Reborn
|
||||
|
||||
## 2. The wall
|
||||
|
||||
The Godot project **never reads a disc format**. No IPFB, no RATC, no T8aD, no
|
||||
XMA, no WMV. If Godot cannot read something, the exporter's job is to emit it
|
||||
differently — not to bridge the gap at runtime.
|
||||
|
||||
* **No GDExtension. No Rust in `port/`.**
|
||||
* The decoders come from `sylpheed-formats`, **pinned by TAG**:
|
||||
`sylpheed-formats = { git = "...", tag = "formats-pin-2026-08-29" }`.
|
||||
|
||||
Pin a tag, never a bare sha. A sha reachable only from an `auto/*` branch is
|
||||
orphaned when that branch is deleted or — worse — **squash-merged**, because
|
||||
squash creates *new* commits: `main` looks like it contains the work while the
|
||||
pin becomes unreachable and this project stops building for a fresh checkout.
|
||||
A tag is a permanent ref, it says what it is in `Cargo.toml`, and it fails
|
||||
loudly at *fetch* rather than silently at build.
|
||||
* **Do not float the pin** to a branch. It would not do what it sounds like:
|
||||
Cargo resolves a git dependency once and writes the sha into `Cargo.lock`, so
|
||||
floating gives you staleness you cannot see instead of staleness you can read.
|
||||
* Bump deliberately, as its own commit, saying what you wanted from the new
|
||||
state. The RE agent tags when it lands something you need and tells you over
|
||||
the message channel — that is how you stay current without floating.
|
||||
|
||||
**In particular, do not reimplement media assembly.** `sylpheed_formats::media`
|
||||
already handles the cases where one playable thing is not one archive entry: a
|
||||
`.pak` entry that spans segment files, a bank with several sub-waves, and the
|
||||
cutscene voices — which are one continuous XMA stream chunked into `VOICE_*.slb`
|
||||
entries whose boundaries do **not** match the cues, so *a `.slb` need not hold
|
||||
the track its name claims*. That last one is the single easiest thing in this
|
||||
project to get subtly wrong. Use `resolve_movie_voice_region`.
|
||||
|
||||
## 3. Derived vs authored
|
||||
|
||||
| | `export/` | `authored/` |
|
||||
|---|---|---|
|
||||
| produced by | the exporter | you, by hand |
|
||||
| contains | what the disc says | what we decided |
|
||||
| hand-edited | **never** | always |
|
||||
| in git | **no** — gitignored | yes |
|
||||
| on re-export | overwritten wholesale | untouched |
|
||||
|
||||
Tempted to hand-fix a file under `export/`? The fix belongs in the exporter or in
|
||||
`authored/`. Every `authored/` entry carries a `why`.
|
||||
|
||||
When the RE agent later decodes something you had authored, **delete the authored
|
||||
entry** and let the exporter emit it. That deletion is the measure of progress.
|
||||
|
||||
## 4. Never commit game assets
|
||||
|
||||
`export/` is generated from the user's own disc and is gitignored. Code, schemas,
|
||||
`authored/` mappings and docs only. If you are about to commit a sprite PNG or a
|
||||
transcoded video, stop.
|
||||
|
||||
## 5. Milestones
|
||||
|
||||
A milestone is done when its **artifact** exists, not when the code compiles.
|
||||
|
||||
| | Milestone | Gate |
|
||||
|---|---|---|
|
||||
| **P0** | Exporter skeleton; one screen and its sprites to `export/` | `export/screens/title/main_menu.json` validates against FORMAT.md and the PNGs open |
|
||||
| **P1** | Godot renders that screen statically at 1280×720 | A Godot screenshot beside `sylpheed-cli screen render` of the same build — they should agree, and where they do not, say which is wrong |
|
||||
| **P2** | Keyframe animation | Buttons slide in. **Blocked on HANDOFF Q1** (the time unit). Do not invent it |
|
||||
| **P3** | Splash → title, with the transition | Both screens back to back, unattended |
|
||||
| **P4** | Intro video | `ADV.wmv` plays with audio (§6) |
|
||||
| **P5** | Main menu: navigation, focus states, Ⓐ into a submenu, B back | A human clicks through it |
|
||||
| **P6** | Audio — menu BGM and move/confirm SFX | Sound on the P5 gate. **Looping is blocked on HANDOFF Q10** |
|
||||
| **P7** | New-game intro video after NEW GAME | Plays, then returns to a defined state |
|
||||
|
||||
Work the lowest unfinished milestone. When one is blocked on an RE answer, say so
|
||||
in `docs/BLOCKED.md`, and take the next milestone that is not.
|
||||
|
||||
## 6. The video problem
|
||||
|
||||
`ADV.wmv` is **WMV3 video with WMA Pro audio**, 1280×720 at 30 fps, 137 s. Godot 4
|
||||
plays only **Ogg Theora** natively.
|
||||
|
||||
Transcode with ffmpeg, and **record the exact command in the export manifest** so
|
||||
a modder who dislikes the quality can re-run it rather than reverse-engineer what
|
||||
you did. Theora at 720p is not great; if the result is visibly poor, **say so and
|
||||
propose** the FFmpeg-GDExtension fallback — do not adopt a runtime dependency on
|
||||
your own authority.
|
||||
|
||||
Only the boot intro and the one new-game intro are in scope. The disc holds
|
||||
3.3 GB of video; transcoding all of it is not this milestone.
|
||||
|
||||
### The downmix is decided: pin it explicitly
|
||||
|
||||
**Human decision, 2026-08-29.** The cinematics are 5.1 (see
|
||||
[`movie-audio-channels`][mac] for the disc-wide split — 28 surround, 69 stereo,
|
||||
and *both* movies this milestone needs are surround). Fold to stereo with an
|
||||
**explicit matrix**, not ffmpeg's default:
|
||||
|
||||
```
|
||||
-af "pan=stereo|FL=0.707*FC+1.0*FL+0.707*FLC+0.707*BL+0.707*SL|FR=0.707*FC+1.0*FR+0.707*FRC+0.707*BR+0.707*SR"
|
||||
```
|
||||
|
||||
Centre at −3 dB into both channels, which is the standard ITU fold and keeps
|
||||
dialogue sitting correctly against the music. Record the full command in the
|
||||
manifest, per the rule above.
|
||||
|
||||
Pinned rather than left to the default because a default is a decision nobody
|
||||
made: it is invisible in the output, it can change between ffmpeg versions, and
|
||||
it silently alters how speech sits in the mix. Adjust the matrix if it sounds
|
||||
wrong — but adjust it *deliberately*, as a commit.
|
||||
|
||||
[mac]: https://git.mc02.dev/fabi/Syplheed-Reborn/src/branch/main/docs/re/structures/movie-audio-channels.md
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
3D, gameplay, HUD, missions, save/load, localisation beyond English, the Ready
|
||||
Room, and any reverse engineering. If you want an answer the disc has not given
|
||||
you, that is a request to the container agent, not a task for you.
|
||||
|
||||
## 8. Tooling policy — MCP servers and third-party skills
|
||||
|
||||
Surveyed 2026-08-28. **No Godot MCP server, for now**, and the reason is not
|
||||
that they are bad:
|
||||
|
||||
* The mature ones ([godot-ai][ga], and most of the field) need a **live Godot
|
||||
editor** running with a plugin that talks WebSocket to a Python server. This
|
||||
agent is headless in a container; that is a daemon, an editor process and a
|
||||
second language runtime added to an unattended loop, all of which can fail in
|
||||
ways that look like a port bug.
|
||||
* Their headline feature is **scene-tree introspection and node manipulation** —
|
||||
built for someone hand-authoring scenes in the editor. This port *generates*
|
||||
its screens from exported JSON at runtime. The agent writes a loader, not a
|
||||
scene tree, so the feature that justifies the complexity does not apply here.
|
||||
* What the agent actually needs to verify its work already exists:
|
||||
`godot-headless` to run the project and `screenshot` to diff against
|
||||
`sylpheed-cli screen render`. The verification loop is the valuable part, and
|
||||
it is a bash job.
|
||||
|
||||
**Third-party skill packs** ([godot-claude-skills][gcs], [GodotPrompter][gp],
|
||||
[Godot-Claude-Skills][rcs]) are the opposite trade: pure context, no runtime, no
|
||||
daemon. They are worth revisiting. They are **not installed now** because a skill
|
||||
is *instructions injected into an agent running with approvals disabled*, which
|
||||
is a supply-chain decision and not one to make by default — and because P0/P1 are
|
||||
a Rust exporter and a static sprite draw, which need no advanced GDScript.
|
||||
|
||||
**If you want one, propose it**: name the pack, say which milestone it unblocks,
|
||||
and let a human vendor and review it. Do not install from a marketplace on your
|
||||
own authority.
|
||||
|
||||
Revisit this if GDScript quality becomes the bottleneck — most likely at P2,
|
||||
where keyframe ramps meet tweens.
|
||||
|
||||
[ga]: https://github.com/hi-godot/godot-ai
|
||||
[gcs]: https://github.com/alexmeckes/godot-claude-skills
|
||||
[gp]: https://github.com/jame581/GodotPrompter
|
||||
[rcs]: https://github.com/Randroids-Dojo/Godot-Claude-Skills
|
||||
93
docs/port/port-loop-task.md
Normal file
93
docs/port/port-loop-task.md
Normal file
@@ -0,0 +1,93 @@
|
||||
Build the Godot menu port, one milestone at a time.
|
||||
|
||||
## Your objective
|
||||
|
||||
`docs/MISSION.md` — read it every iteration. It defines the milestones P0…P7 and
|
||||
the gate each must pass, the wall between the exporter and Godot, and the
|
||||
derived/authored split.
|
||||
|
||||
**You do not reverse engineer.** A separate container agent does that, in the
|
||||
Syplheed-Reborn repository, mounted read-only at `/reborn`. You have no emulator
|
||||
and no oracle, so a guess of yours is indistinguishable from a fact and will be
|
||||
believed later. If you need an answer the disc has not given you, write it in
|
||||
`docs/BLOCKED.md` and move to another milestone.
|
||||
|
||||
## Read these first, every iteration
|
||||
|
||||
1. `docs/MISSION.md` — milestones, gates, scope.
|
||||
2. `/reborn/docs/port/HANDOFF.md` — **the contract.** What is decoded, what was
|
||||
measured off the running game, and what is known undecodable.
|
||||
|
||||
**It is a live read-only mount of the RE agent's working tree**, so it updates
|
||||
itself and there is nothing to pull — `git -C /reborn pull` cannot work (the
|
||||
mount is read-only) and should not: it would move another agent's checkout.
|
||||
`git -C /reborn log -1` shows where they are.
|
||||
|
||||
Because it is live, **it can move under you mid-iteration.** Anything you
|
||||
copied out of it earlier — `docs/BLOCKED.md` especially — may already be
|
||||
stale. Re-check it against HANDOFF before trusting it.
|
||||
3. `docs/FORMAT.md` — the open format. It is versioned and it is yours to
|
||||
revise, but a change is a deliberate act with a version bump.
|
||||
4. `docs/BLOCKED.md` — what you are waiting on, so you do not re-discover it.
|
||||
|
||||
`/reborn/docs/re/disc-atlas.html` maps how the assets reference each other.
|
||||
|
||||
## Each iteration
|
||||
|
||||
1. **Pick the lowest unfinished milestone.** If it is blocked on an RE answer,
|
||||
record that in `docs/BLOCKED.md` and take the next one that is not.
|
||||
2. **Build the smallest thing that reaches its gate.** The gate is an artifact —
|
||||
a validating JSON file, a screenshot, a clickable build — never "it compiles".
|
||||
3. **Keep derived and authored apart.** `export/` is regenerated wholesale and
|
||||
never hand-edited. A fix you are tempted to make there belongs in the exporter
|
||||
or in `authored/`, and every `authored/` entry carries a `why`.
|
||||
4. **Write down what you decided**, in `docs/`. A decision that lives only in
|
||||
your context is lost when the container dies.
|
||||
5. **Commit** to `auto/<topic>`, one logical change per commit.
|
||||
6. **Publish**: `push-work`. Every iteration that produced a commit.
|
||||
7. **Say plainly what you did not settle**, and stop.
|
||||
|
||||
## Hard rules
|
||||
|
||||
* **Never commit game assets.** `export/` is gitignored and generated from the
|
||||
user's own disc. Code, schemas, `authored/` mappings and docs only.
|
||||
* **No Rust in `port/`, no GDExtension.** If Godot cannot read something, the
|
||||
exporter emits it differently.
|
||||
* **Do not vendor or reimplement `sylpheed-formats`** — it is pinned by revision.
|
||||
In particular do not reimplement media assembly: `sylpheed_formats::media`
|
||||
already handles segment-spanning entries, multi-sub-wave banks and the
|
||||
continuous cutscene-voice stream, and that last one is the easiest thing here
|
||||
to get subtly wrong.
|
||||
* **`/reborn` is READ-ONLY.** Never commit there, never edit it. It belongs to
|
||||
the other agent and you share no working tree with it.
|
||||
* **Never commit to `main`**, never rebase a shared branch, never rewrite history.
|
||||
* **Do not adopt a runtime dependency on your own authority.** Propose it.
|
||||
|
||||
## Verifying
|
||||
|
||||
* `sylpheed-cli screen render` (built from `/reborn`) is the reference renderer.
|
||||
When Godot draws a screen, diff against the CLI's composite of the same build.
|
||||
Where they disagree, one of them is wrong — say which, and why, rather than
|
||||
tuning until they match.
|
||||
* Godot runs headless (`godot-headless`), and windowed under Xvfb with
|
||||
`screenshot` for a capture.
|
||||
* A regenerated `export/` that comes out byte-identical is strong evidence a
|
||||
change was additive. When it does change, check that every diff line pairs.
|
||||
|
||||
## Publishing
|
||||
|
||||
`push-work` pushes the current branch to origin. It refuses anything that is not
|
||||
`auto/*` and never force-pushes, so the consolidated line stays a human's
|
||||
decision. Run it **every iteration that produced a commit** — not at the end of
|
||||
some longer arc, which is exactly when a container dies.
|
||||
|
||||
If it reports no credentials, say so in your reply and continue working. Do not
|
||||
improvise another route out.
|
||||
|
||||
## Pacing
|
||||
|
||||
One milestone step plus its write-up is a good iteration; a marathon is not. Stop
|
||||
with a clean commit, a push, and an honest list of what is still open.
|
||||
|
||||
The loop runs on a fixed interval set by the harness, so you do **not** need to
|
||||
arm the next wakeup yourself. Spend that attention on the write-up instead.
|
||||
Reference in New Issue
Block a user