merge the Godot port's history into the monorepo
Brought in with a subtree merge rather than a copy, so the port's 31 commits survive as history rather than arriving as one anonymous import. Landed under godot-import/ and moved into the final layout in the next commit, which keeps git's rename detection able to follow each file across the move.
This commit is contained in:
110
godot-import/docs/AUDIO-VERIFICATION.md
Normal file
110
godot-import/docs/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
godot-import/docs/BLOCKED.md
Normal file
239
godot-import/docs/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
godot-import/docs/DECISIONS.md
Normal file
811
godot-import/docs/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
godot-import/docs/FORMAT.md
Normal file
347
godot-import/docs/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. |
|
||||
188
godot-import/docs/MISSION.md
Normal file
188
godot-import/docs/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
godot-import/docs/loop-task.md
Normal file
93
godot-import/docs/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