port: make the Godot menu shell the agent's primary objective

Sets a new mission: boot the real disc through developer splash -> intro video
-> title -> main menu -> submenus in Godot 4, interactively, with no gameplay,
no 3D and no emulator.

docs/port/MISSION.md defines it -- eight gated milestones, each finished by an
ARTIFACT rather than by compiling, plus the Ready Room as an explicitly gated
stretch goal with a one-iteration probe that decides go/no-go. GP_READY_ROOM is
1106 entries with 6 recoverable names and is ISL-scripted, so it is either a
week or a quarter, and the agent must not start it on its own authority.

Architecture, per the user's decision: the Godot project is INDEPENDENT of the
Rust viewer and never reads a disc format. An offline Rust exporter converts the
disc into open formats; Godot reads only those. No GDExtension, no Rust in the
Godot project, and sylpheed-viewer is off limits -- it stays the human's
verification tool with its static-data rule intact.

docs/port/FORMAT.md specifies the open format, versioned, because modding is the
port's second goal and that makes the layout a deliverable rather than a temp
directory: JSON over XML (Godot parses JSON natively; its XMLParser is SAX),
names never hashes, provenance in every generated file, and unknowns listed
rather than guessed.

The discipline the whole thing rests on is the derived/authored split. `export/`
is regenerated wholesale and never hand-edited; `authored/` is hand-written and
survives a re-export. Three things this milestone needs are NOT on the disc in
any decoded form -- which button does what, paint order, and menu sound cues --
so they live in `authored/` with a stated `why`. Deleting an authored entry
because the exporter can now emit it IS the measure of progress.

`export/` is gitignored: it is generated from the user's own disc and this stays
a clean-room repo.

Container: adds a pinned Godot 4 (windowed under Xvfb for screenshots, plus a
headless wrapper). ffmpeg already carries libtheora, which is the video target --
Godot 4 plays only Ogg Theora natively and the disc's ADV.wmv is WMV3/WMA Pro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sylpheed RE agent
2026-08-28 17:11:35 +02:00
parent 5cdff5e515
commit b5a193839c
6 changed files with 507 additions and 60 deletions

4
.gitignore vendored
View File

@@ -21,3 +21,7 @@ Thumbs.db
# Trunk build output
dist/
__pycache__/
# The Godot port export is generated from the user's own disc.
# Code, schemas and authored mappings are versioned; game assets are not.
export/

View File

@@ -55,6 +55,28 @@ python3 tools/re-capture/gmem.py find hex:820af844 400
* A Bevy system-parameter conflict is invisible to the type checker and panics
at startup. If you touch viewer systems, *run the binary*, don't just build it.
## The Godot port
The primary objective — see `docs/port/MISSION.md`. Two binaries are installed:
```bash
godot-headless --path port/ --script res://tools/check.gd # no display needed
DISPLAY=:99 godot --path port/ # windowed, under Xvfb
screenshot ~/shots/godot.png # then capture it
```
* **Godot is pinned to one version** in the Dockerfile. An engine bump changes
rendering, and this project diffs Godot's output against a reference renderer —
so do not upgrade it to fix a bug without saying that is what you did.
* **`sylpheed-cli screen render` is the reference.** When Godot draws a screen,
render the same build with the CLI and compare. Where they disagree, one of
them is wrong; say which, and why, rather than tuning until they match.
* **ffmpeg has libtheora**, which is the transcode target for video. Keep the
exact command in the exporter config — a modder who dislikes the quality should
be able to re-run it, not reverse-engineer what you did.
* **Never commit anything under `export/`.** It is generated from the user's own
disc and gitignored. Code, schemas, `authored/` mappings and docs only.
## Reporting
State what you measured, what you assumed, and what you could not settle. If a

View File

@@ -68,6 +68,26 @@ RUN for t in clang clang++ lld ld.lld llvm-ar llvm-ranlib llvm-nm clang-cpp; do
# Python consumer to protect, so installing into it is the honest simple option.
RUN pip3 install --no-cache-dir --break-system-packages duckdb
# ── Godot 4 ──────────────────────────────────────────────────────────────────
# The port's runtime (docs/port/MISSION.md). Two binaries, deliberately:
#
# godot the editor/windowed build, run under Xvfb, for screenshots
# that can be diffed against `sylpheed-cli screen render`
# godot-headless --headless, for script-only checks that need no display
#
# The official builds are self-contained binaries, not a package, so they are
# fetched rather than apt-installed. Pinned: an engine version bump changes
# rendering, and this project compares screenshots against a reference renderer.
ARG GODOT_VERSION=4.3
RUN cd /tmp \
&& curl -fsSLO "https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}-stable/Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip" \
&& unzip -q "Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip" \
&& mv "Godot_v${GODOT_VERSION}-stable_linux.x86_64" /usr/local/bin/godot \
&& chmod +x /usr/local/bin/godot \
&& printf '#!/bin/sh\nexec /usr/local/bin/godot --headless "$@"\n' > /usr/local/bin/godot-headless \
&& chmod +x /usr/local/bin/godot-headless \
&& rm -f "Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip"
# ── Node + Claude Code ───────────────────────────────────────────────────────
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \

View File

@@ -1,44 +1,91 @@
Work the Project Sylpheed reverse-engineering backlog, one item at a time.
Build the Godot menu port of Project Sylpheed, one milestone at a time.
## Read these first, every iteration, before proposing anything
## Your objective
They are short on purpose, and they are the reason this prompt is short:
`Syplheed-Reborn/docs/port/MISSION.md` — read it every iteration. It defines the
milestones (P0…P7 plus a gated S1), the gate each one must pass, and the two
decisions already made. Work the **lowest unfinished milestone**; do not skip
ahead because a later one looks more interesting.
1. `Syplheed-Reborn/docs/re/REFUTED.md`**claims already tested and dead.**
If your idea is on that list, it is finished; pick another. Grep it for your
nouns before you design anything.
2. `Syplheed-Reborn/docs/re/METHOD.md` — the traps this corpus has already paid
for. Most wasted iterations are one of these repeated.
3. `Syplheed-Reborn/docs/re/INDEX.md` — what is already **decoded**. Do not
re-derive a row that is already ✅. Re-deriving a known format is not a
finding; asking whether its values *resolve* is.
4. `Syplheed-Reborn/docs/re/BACKLOG.md` — the open items. It is long; skim the
`##` headings and open only the one you pick.
5. `Syplheed-Reborn/docker/agent/AGENT.md` — the container's tooling.
Reverse engineering has not stopped, but it is no longer the goal. An RE item
earns attention when the port is blocked on it, and the write-up still goes in
`docs/re/` under the usual convention.
**These files are the memory.** If a finding, a dead end or a trap lives only in
your context, it is lost at the end of the run. Write it down where the next
iteration will find it — that is what makes this a corpus rather than a
transcript.
## Read these first, every iteration
Short on purpose, and the reason this prompt is short:
1. `docs/port/MISSION.md` — the objective, the milestones, what is out of scope.
2. `docs/port/FORMAT.md` — the export schema. It is **versioned**; changing it is
a deliberate act with a version bump, not a silent edit.
3. `docs/re/REFUTED.md` — claims already tested and dead. Grep it for your nouns
before designing anything.
4. `docs/re/METHOD.md` — the traps this corpus has already paid for.
5. `docs/re/INDEX.md` — what is already decoded. Re-deriving a ✅ row is not a
finding.
6. `docker/agent/AGENT.md` — the container's tooling.
`docs/re/disc-atlas.html` is the map of how the assets reference each other —
useful when you need to find what feeds what.
**These files are the memory.** A finding that lives only in your context is lost
when the container dies.
## Each iteration
1. **Pick one item** from `BACKLOG.md`, preferring the one whose "first step" is
cheapest and most decisive. If you are mid-item, continue it rather than
starting another.
2. **Do the smallest experiment that could settle it**, and try to *refute* your
hypothesis before believing it. Run the known-positive through any new filter
first; a filter that fails its own control is dead, not tuneable.
3. **Write the result down** in `docs/re/` under the ✅/🟡/❔ convention, with the
evidence and the *reach* of any negative. A withdrawn result is a real
result — record it, with the reasoning.
* If you **refuted** something, add a line to `REFUTED.md`.
* If you were bitten by a general trap, add a line to `METHOD.md`.
* If you **closed** a format, update its `INDEX.md` row.
4. **Commit** to `auto/<topic>`, one logical change per commit.
5. **Publish**: `push-work`. Your branch must leave the container or the work
dies with it. See "Publishing" below.
6. **Say plainly what you did not settle**, and stop the iteration.
1. **Pick the lowest unfinished milestone** from MISSION.md. If you are mid-item,
continue it rather than starting another.
2. **Build the smallest thing that reaches its gate.** The gate is an *artifact*
a validating JSON file, a screenshot, a human-clickable build — never "it
compiles".
3. **Keep derived and authored apart.** `export/` is regenerated wholesale and is
never hand-edited; `authored/` is hand-written and survives a re-export. If you
are tempted to hand-fix a file under `export/`, the fix belongs in the exporter
or in `authored/`. Every `authored/` entry carries a `why`.
4. **Write down what you learned** — in `docs/port/` for port decisions, in
`docs/re/` for anything about the disc.
* Refuted something → a line in `REFUTED.md`.
* Bitten by a general trap → a line in `METHOD.md`.
* Closed a format → update its `INDEX.md` row.
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 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.
* **No Rust in the Godot project, no GDExtension.** If Godot cannot read
something, the exporter emits it differently. The wall between the two halves is
the design, not an inconvenience.
* **Do not touch `crates/sylpheed-viewer`.** The Explorer is the human's
verification tool, it is independent of the port, and it keeps its
static-data-only rule. Do not refactor it to share code with the exporter.
* **Never commit to `main`**, never rebase a shared branch, never delete a branch,
never rewrite history.
* **Do not touch another agent's worktree.** `git worktree list` first; branches
marked `+` are checked out elsewhere.
* **One emulator at a time** — `run-canary` enforces it with a lockfile.
* **Measure the oracle; never infer it.** If you need to know what the real game
does, run it. This applies with full force to the keyframe time unit and to any
transition timing.
* **Do not improvise around a blocker.** If a milestone needs a decision only the
user can make — a new dependency, a scope change, the Ready Room go/no-go —
write what you found, note it in MISSION.md, and move to the next thing you can
actually finish.
## Verifying
* `build-reborn test` wires up `SYLPHEED_DISC`; without it the disc tests
self-skip and a green run means almost nothing.
* `sylpheed-cli screen render` is the reference renderer. When Godot draws a
screen, diff it against the CLI's composite of the same build — they should
agree, and where they do not, one of them is wrong and you must say which.
* Godot runs headless in this container (`godot --headless`), and
`screenshot` captures the windowed one under Xvfb.
* A regenerated artifact that comes out byte-identical is strong evidence a
change was additive. When it does change, check that every diff line pairs.
## Publishing
@@ -47,35 +94,13 @@ transcript.
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 (no remote rewrite, no credential helper of your
own, no alternate transport). A push that is blocked is a blocked push.
## Hard rules
* **Never commit to `main`**, never rebase a shared branch, never delete a
branch, never rewrite history.
* **Do not touch another agent's worktree.** `git worktree list` first; branches
marked `+` are checked out elsewhere.
* **One emulator at a time** — `run-canary` enforces it with a lockfile.
* **Measure the oracle; never infer it.** An iteration that reasons about the
game without running it is a red flag unless it is a pure static-format task.
* **Verify with an artifact**, not with "it compiles": `build-reborn test` (it
wires up `SYLPHEED_DISC` — without it the disc tests silently self-skip and a
green run means almost nothing), `sylpheed-cli mesh render` / `screen render` /
`save info`, a screenshot. A regenerated artefact that comes out
byte-identical is a strong check that a change is additive; when it does
change, check that every diff line pairs exactly.
## When you are blocked
If an item needs something the container cannot do — hardware Vulkan, a decision
only the user can make — **do not improvise around it**. Write what you found,
note the blocker in `BACKLOG.md`, and move to the next item.
If it reports no credentials, say so in your reply and continue working. Do not
improvise another route out: no remote rewrite, no credential helper of your own,
no alternate transport. A push that is blocked is a blocked push.
## Pacing
One experiment plus its write-up is a good iteration; a marathon is not. Stop
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.
An emulator session must fit inside ONE turn — a Stop hook kills xenia when the

205
docs/port/FORMAT.md Normal file
View File

@@ -0,0 +1,205 @@
# The open export format — v1
The format the disc is converted *into*, and the one the Godot project and any
modding tool read. Versioned: every file names its schema, and a breaking change
bumps the number rather than editing this page quietly.
**Design rules, in priority order:**
1. **A human can read and edit it.** That is the whole point — modding is the
second goal of the port.
2. **Names, never hashes.** If we never recovered the disc's name, say so in the
file rather than passing off a synthetic id as real.
3. **Provenance travels with the data.** Every generated file says where on the
disc it came from and which exporter version wrote it, so it stays auditable
against the original 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 and never silently defaulted.
## Layout
```
export/ # DERIVED. Regenerable. Gitignored. Never hand-edited.
manifest.json # what this export is: disc id, exporter version, contents
screens/
title/
splash_developer.json
title.json
main_menu.json
submenu_extras.json
sprites/ # PNG, RGBA8, one per T8aD surface
audio/
music/BGM_001.ogg
sfx/…
cues.json # cue name → sound id → bank, straight off the disc table
video/
ADV.ogv
text/en/…
authored/ # AUTHORED. Hand-written. Committed. Survives a re-export.
flow.json # boot sequence + what each button does
paint_order.json # per-screen z-order overrides
cue_bindings.json # which cue fires on move / confirm / back
```
`export/` is wiped and rewritten wholesale. `authored/` is never touched by the
exporter. Godot loads `export/` first and then applies `authored/` over it.
## Common header
Every generated file starts with the same three keys:
```json
{
"format": "sylpheed.screen/1",
"exporter": "sylpheed-export 0.1.0",
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5 }
}
```
`source.entry` is the pak **entry index**, which is the stable locator — not the
display ordinal, which renumbers whenever the enumeration rule changes.
## `screens/*.json`
```json
{
"format": "sylpheed.screen/1",
"exporter": "sylpheed-export 0.1.0",
"source": { "archive": "dat/GP_TITLE.pak", "entry": 5 },
"name": "main_menu",
"name_source": "authored",
"design": [1280, 720],
"elements": [
{
"id": "ptbase",
"sprite": "sprites/ptbase.png",
"role": "decoration",
"pivot": [320, 180],
"rest": { "pos": [320, 180], "scale": [1.0, 1.0], "tint": "#ffffffff" },
"keyframes": []
},
{
"id": "ptbtn01",
"sprite": "sprites/ptbtn01.png",
"focus_sprite": "sprites/ptbtn01f.png",
"role": "button",
"pivot": [42, 22],
"rest": { "pos": [542, 162], "scale": [1.0, 1.0], "tint": "#ffffffff" },
"keyframes": [
{ "t": 28, "pos": [542, 142], "scale": [1.0, 1.0], "tint": "#ffffffff" },
{ "t": 34, "pos": [542, 157], "scale": [1.0, 1.0], "tint": "#ffffffff" },
{ "t": 64, "pos": [542, 162], "scale": [1.0, 1.0], "tint": "#ffffffff" }
]
}
],
"buttons": ["ptbtn01", "ptbtn02", "ptbtn03", "ptbtn04", "ptbtn05"],
"unresolved": ["paint_order"]
}
```
**`role`** is derived from the element kind, which is a decoded field:
`0x3002``button`, `0x10``primitive`, `0x0``decoration`. Anything else
exports as `"unknown"` with the raw value in `kind_raw` — do not invent a name
for a kind nobody has decoded.
**`buttons`** is the navigation order: the `button`-role elements sorted by
resting Y. This is geometric, not a decoded neighbour graph — the disc's real
navigation structure is unknown, and `opt ` is **not** a focus link (that was
measured and refuted). The ordering is right for a vertical menu and should not
be trusted for anything else.
**`keyframes`** carry the on-disc time value verbatim in `t`. A keyframe is the
**start of a ramp toward the next one**, not a pose that is held. The unit of `t`
is not yet confirmed — see MISSION.md §7 — so `t` stays raw and the consumer
applies the conversion in one place.
**`rest`** is the resting pose: the longest run of consecutive keyframes with an
unchanged value, falling back to longest-dwell for groups that ramp throughout.
It is neither the first nor the last keyframe.
**`unresolved`** lists what this file does *not* answer. A consumer that needs
one of these must get it from `authored/`.
## `authored/flow.json`
The part the disc has not told us. Every entry states its evidence.
```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 guess" },
"ptbtn02": { "label": "LOAD GAME", "goto": "GP_LOAD",
"why": "label read off the sprite; GamePart id 3 from the .rdata table" }
},
"back": null,
"why_back": "no B-handler observed; the main menu may be a root with no back"
}
}
}
```
`goto` may name another 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 not).
## `authored/paint_order.json`
```json
{
"format": "sylpheed.paint_order/1",
"screens": {
"main_menu": {
"order": ["ptbase", "pteff05", "ptframe1", "ptbtn01", "…"],
"why": "hand-authored. The declaration table is NOT the paint order -- a
per-draw capture of the running title paints element 13 first and
elements 0/1 late. Deriving the order from the bundle is open."
}
}
}
```
When paint order is derived from the file, this file is deleted and `screens/*`
gains an `order` array. **That deletion is the measure of progress** — until it
happens, every screen here is a hand-tuned approximation wearing the right label.
## `export/audio/cues.json`
Straight off the disc's own cue index; no interpretation.
```json
{
"format": "sylpheed.cues/1",
"source": { "archive": "dat/tables.pak", "record": "SOUNDS" },
"families": { "SE": [1, 901], "BGM": [1001, 1109], "VOICE": [1500, 7331] },
"cues": { "SE_CURSOR_MOVE": 12 },
"banks": { "12": "audio/sfx/se_012.ogg" }
}
```
Which cue a menu *event* fires is not on the disc as far as we know, so it is in
`authored/cue_bindings.json`, not here.
## `export/manifest.json`
```json
{
"format": "sylpheed.manifest/1",
"exporter": "sylpheed-export 0.1.0",
"disc": { "title": "Project Sylpheed: Arc of Deception", "region": "USA/Europe" },
"video_transcode": "ffmpeg -i ADV.wmv -c:v libtheora -q:v 8 -c:a libvorbis -q:a 5 ADV.ogv",
"screens": ["title/splash_developer", "title/title", "title/main_menu"],
"warnings": ["GP_READY_ROOM not exported -- see MISSION.md §6"]
}
```
`video_transcode` records the exact command used, so a modder who dislikes the
quality can re-run it rather than reverse-engineer what we did.

171
docs/port/MISSION.md Normal file
View File

@@ -0,0 +1,171 @@
# Primary objective — a playable menu shell in Godot
**Status:** active, set 2026-08-28. This supersedes "work the RE backlog" as the
agent's primary objective. Reverse engineering continues, but it is now *in
service of* this goal: an item earns attention because the port needs it.
## What we are building
A Godot 4 project that boots to an interactive main menu using the player's own
disc, through the sequence the real game uses:
```
developer logo splash → intro video → title / PRESS Ⓐ → main menu → submenus
```
No gameplay. No 3D. No emulator. The success condition is a human pressing a
d-pad and Ⓐ and moving through those screens with the right art, the right
animation, the right music and the right transitions.
**Ready Room is a gated stretch goal.** See §6 — do the probe first, then stop
and report rather than committing to it.
## 1. The two halves, and the wall between them
The Godot project **never reads a disc format**. It reads only open formats. The
conversion happens offline, in Rust, and the wall between the two is the point:
```
disc ──▶ crates/sylpheed-export ──▶ export/ ──▶ port/ (Godot 4)
(Rust, uses sylpheed-formats) JSON + PNG + OGG reads ONLY
+ Ogg Theora open formats
```
* **No GDExtension. No Rust in the Godot project.** If Godot needs something it
cannot read, the exporter's job is to emit it in a form Godot can, not to
bridge the two runtimes.
* **`crates/sylpheed-viewer` is not involved.** The Explorer is the human's tool
for exploring and verifying the RE work. It stays Rust/Bevy, it keeps its
static-data rule, and it is neither a dependency of nor a dependent on the
port. Do not refactor it "to share code with" the port.
* `sylpheed-formats` is shared, because it is the decoder and there must be one.
## 2. The format is a deliverable, not a dump
The second goal of this port is **modding**. That makes the exported layout part
of the product, not a temp directory:
* **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 for every
schema. JSON is also what every external tool a modder might reach for already
speaks.
* **Names, never hashes.** A file is `screens/title/main_menu.json`, not
`0x90822a39.json`. Where the disc's own name was never recovered — the six
`*2D` archives and `GP_READY_ROOM`, see [the atlas](../re/disc-atlas.html) —
emit a stable synthetic id **and record in the file that the real name is
unknown**. A modder must be able to tell a recovered name from one we invented.
* **A tree that means something.** `screens/`, `sprites/`, `audio/music/`,
`audio/sfx/`, `video/`, `text/<lang>/` — organised by what a person is looking
for, not by which archive it came out of.
* **Provenance in every generated file.** Source archive, entry index, and the
exporter version. This is what keeps the export auditable against the disc
instead of drifting into an unverifiable fork.
The schema lives in [FORMAT.md](FORMAT.md) and is **versioned**. Changing it is a
deliberate act with a version bump, not a silent edit.
## 3. Derived vs authored — keep these apart
This is the discipline that matters most, and it is the corpus's own rule applied
to a new artefact.
| | `export/` | `authored/` |
|---|---|---|
| produced by | running the exporter | a human or the agent, by hand |
| contains | what the disc says | what we decided |
| edited by hand | **never** | always |
| committed to git | **no** (see §4) | yes |
| when regenerated | overwritten wholesale | untouched |
Some things this milestone needs are **not on the disc in any form we have
decoded**, and they must live in `authored/` with a stated reason:
* **Which button does what.** `ptbtn01…05` are sprites with baked-in labels. A
human can read "NEW GAME" off the image; there is no field that says so. The
mapping button → GamePart id is an authored guess until code says otherwise.
* **Paint order.** Solved at runtime, unsolved from the file — the declaration
table is provably not it. For these six screens, author the z-order and mark it
authored. Do **not** let a hand-tuned order leak into `export/` and later get
mistaken for a disc fact.
* **Sound-cue bindings.** Which cue fires on move / confirm / back.
Every `authored/` entry carries a `why` field. When a later iteration decodes the
real answer, the authored entry is deleted and the exporter emits it instead —
and that transition is the measure of progress.
## 4. Do not commit game assets
`export/` is generated from the user's own disc and **must be gitignored**. The
repository stays a clean-room port: code, schemas, authored mappings and
documentation. Anyone building it supplies their own disc. If you find yourself
committing a PNG of a game sprite or a transcoded video, stop.
## 5. Milestones, each with a gate
Do these in order. A milestone is done when its **artifact** exists, not when the
code compiles.
| | Milestone | Gate (the artifact) |
|---|---|---|
| **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 screenshot from Godot beside `sylpheed-cli screen render` of the same build — they should agree |
| **P2** | Keyframe animation | The main menu's buttons slide in. **Measure the time unit first** (§7) |
| **P3** | Splash → title, with the transition | Godot runs the two screens back to back unattended |
| **P4** | Intro video | `ADV.wmv` plays in Godot with audio (§7) |
| **P5** | Main menu: d-pad navigation, focus states, Ⓐ into a submenu, B back | A human clicks through it |
| **P6** | Audio — menu BGM and move/confirm SFX | Sound on the gate in P5 |
| **P7** | New-game intro video after NEW GAME | Plays, then returns to a defined state |
| **S1** | *Gated* — Ready Room. Run the probe in §6 and **stop** | A written go / no-go |
## 6. The Ready Room probe — do this early, it is one iteration
`GP_READY_ROOM.pak` is the largest UI archive on the disc, 1 106 entries, and
only **6 of its names resolve**. It is also ISL-scripted. That could mean a week
or a quarter, and one cheap test tells you which.
**The probe:** our screen catalog enumerates bundles by **content**, not by name
`is_build` / `is_composable` read the bytes. Unrecoverable *paths* therefore do
not necessarily mean unrenderable *screens*.
```bash
sylpheed-cli screen list "$SYLPHEED_DISC/dat/GP_READY_ROOM.pak"
sylpheed-cli screen render --build <n> "$SYLPHEED_DISC/dat/GP_READY_ROOM.pak" /tmp/rr.png
```
Report: how many builds it finds, whether any composite looks like a Ready Room,
and whether the screens are 2D at all or whether the room is 3D with a UI overlay
— because if it is 3D, this milestone is out of scope by definition and the
answer is no-go, not "try harder".
**Then stop and write the go/no-go.** Do not start Ready Room work on your own
authority.
## 7. Two decisions already made, and one measurement owed
**Video: transcode to Ogg Theora.** `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 **keep the exact command in the exporter's config** so it can be re-run
with different settings. Theora at 720p is not great; if the result is visibly
poor, say so and propose the FFmpeg-GDExtension fallback — do not silently adopt a
dependency. Only `ADV.wmv` and the one new-game intro are in scope; the disc holds
3.3 GB of video and transcoding all of it is not this milestone.
**Screens: `GP_TITLE.pak` is the whole title-side tree.** Build 4 is the title
with the animating wordmarks, build 5 the five-button main menu, builds 6/8/9 are
submenus, and the developer splash is the `palogo` bundle in the same archive.
Buttons are identifiable as data: element kind `0x3002`, ordered top-to-bottom by
resting Y, each paired with an `f`-suffixed highlighted variant. You do **not**
need the undecoded navigation graph to make a vertical menu work.
**The measurement owed: what is a keyframe time?** Values run 16…269 on the title
build. 60 Hz frames would make the title intro ~4.5 s, which is plausible and
untested. Settle it against a capture before building animation on it — this is
exactly the kind of number that silently poisons everything downstream.
[`ui-resting-pose.md`](../re/structures/ui-resting-pose.md) already establishes
that a keyframe is the **start of a ramp**, not a pose that is held.
## 8. What is explicitly out of scope
3D, gameplay, HUD, missions, the emulator, save/load, localisation beyond English,
and any archive outside `GP_TITLE`, `tables.pak`, `sound.pak` and `dat/movie/`
— except for the Ready Room probe in §6.