scaffold the Godot port, as its own repo with its own agent

The port is deliberately separate from the reverse-engineering project: its own
repository, its own clone, its own container. Two writers in one working tree
means files change under whoever is mid-edit and a `git add -A` by one sweeps up
the other's work -- which happened today in the Reborn tree, so this is set up
not to repeat it.

The wall: Godot never reads a disc format. An offline Rust exporter converts the
user's disc into JSON + PNG + Ogg, and the Godot project reads only that. No
GDExtension, no Rust in port/. Beyond the practical reason -- Godot cannot read
IPFB, RATC, T8aD, XMA or WMV -- there is the design one: modding is a goal, and
if the runtime reads the original formats then modding means reverse
engineering, whereas if it reads JSON it means opening a file.

The decoders come from sylpheed-formats PINNED BY REVISION (8b6dbcf), not
vendored and not reimplemented. `sylpheed_formats::media` in particular already
owns every case where one playable thing is not one archive entry: entries that
span segment files, banks 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. That last one is the easiest thing in this project to get
subtly wrong, so the mission says outright not to re-derive it.

docs/MISSION.md is the objective (P0-P7, each gated by an artifact rather than
by compiling). docs/BLOCKED.md lists what cannot proceed until the RE agent
answers Q1-Q10, and says plainly that none of it may be guessed -- this agent
has no emulator and no oracle, so a value it invents is indistinguishable from a
decoded one a month later.

The container is deliberately small: 3 cpus / 4 GB against the RE container's
6 / 7, and an image with no C++ toolchain, no Vulkan stack and no emulator. Two
full-size containers do not fit on this box beside a desktop.

Its launcher sets the git identity through GIT_AUTHOR_*/GIT_COMMITTER_* rather
than writing [user] into .git/config -- the config route captures every commit
made in that tree, including a human's, which is how six of today's commits
ended up attributed to the RE agent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-08-28 17:56:44 +02:00
commit 9d96be609e
19 changed files with 1061 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Generated from the user's own disc. This repo stays clean-room: code,
# schemas, authored mappings and docs only -- never game assets.
/export/
# Rust
/target/
**/*.rs.bk
# Godot
.godot/
/port/.godot/
*.import

8
Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[workspace]
resolver = "2"
members = ["crates/sylpheed-export"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"

49
README.md Normal file
View File

@@ -0,0 +1,49 @@
# Sylpheed Godot
A clean-room Godot 4 port of *Project Sylpheed: Arc of Deception*, starting with
the menu shell: developer splash → intro video → title → main menu → submenus.
**You need your own copy of the game.** Nothing in this repository is game
content. An offline exporter reads the disc you supply and writes an open,
moddable asset tree; the Godot project reads only that tree and never touches a
disc format.
```
your disc ──▶ crates/sylpheed-export ──▶ export/ ──▶ port/ (Godot 4)
(Rust; decoders come from JSON + PNG reads ONLY
sylpheed-formats) + OGG + OGV open formats
```
## Why the wall
Two reasons, and the second is the interesting one:
1. Godot cannot read IPFB archives, RATC bundles, T8aD textures, XMA banks or
WMV video, and it should not learn to.
2. **Modding is a goal of this port.** If the runtime reads the original formats,
modding means reverse engineering. If it reads JSON and PNG, modding means
opening a file.
## Where the knowledge comes from
The decoders live in [`sylpheed-formats`][formats], pinned by revision — a
separate project, where the reverse engineering happens. Its
`docs/port/HANDOFF.md` is the contract: what has been decoded, what was measured
off the running game, and what is known to be undecodable. Read it before
assuming a value is on the disc.
[formats]: https://git.mc02.dev/fabi/Syplheed-Reborn
## Layout
| | |
|---|---|
| `crates/sylpheed-export/` | disc → open formats. Regenerates `export/` wholesale |
| `port/` | the Godot 4 project |
| `authored/` | decisions that are **not** on the disc, each with its reason |
| `export/` | generated, gitignored, never hand-edited |
| `docs/` | the mission, the format spec, the agent's loop prompt |
## Status
Pre-P0. Nothing runs yet.

12
authored/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Authored decisions
Everything here is a decision **we** made, not something the disc said. It is
hand-written, committed, and survives a re-export — unlike `export/`, which is
regenerated wholesale and must never be hand-edited.
Every entry carries a `why`. When the RE agent decodes the real answer, **delete
the entry** and let the exporter emit it; that deletion is the measure of
progress.
See `docs/FORMAT.md` for the schemas and `docs/BLOCKED.md` for which HANDOFF
question each placeholder is standing in for.

View File

@@ -0,0 +1,22 @@
[package]
name = "sylpheed-export"
description = "Convert a Project Sylpheed disc into the open asset tree the Godot port reads"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
# The decoders, PINNED BY REVISION. Not vendored and not reimplemented: they are
# disc-wide verified in their own repository, and floating the pin would let a
# decoder change land mid-milestone -- exactly the confusion this prevents.
#
# `sylpheed_formats::media` in particular owns the cases where one playable thing
# is not one archive entry (segment-spanning reads, multi-sub-wave banks, and the
# continuous cutscene-voice stream). Do not re-derive those here.
sylpheed-formats = { git = "https://git.mc02.dev/fabi/Syplheed-Reborn.git", rev = "8b6dbcf" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
clap = { version = "4", features = ["derive"] }
image = { version = "0.25", default-features = false, features = ["png"] }

View File

@@ -0,0 +1,33 @@
//! Convert a Project Sylpheed disc into the open asset tree the Godot port reads.
//!
//! The one rule this binary exists to enforce: **Godot never sees a disc format.**
//! Everything proprietary is decoded here and written out as JSON, PNG, Ogg
//! Vorbis and Ogg Theora, so the runtime — and anyone modding it — reads formats
//! a person can open.
//!
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
use anyhow::Result;
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser)]
#[command(about, version)]
struct Args {
/// Extracted disc root (the directory holding `dat/` and `hidden/`).
#[arg(long)]
disc: PathBuf,
/// Output tree. Rewritten wholesale — never hand-edit it.
#[arg(long, default_value = "export")]
out: PathBuf,
}
fn main() -> Result<()> {
let args = Args::parse();
let source = sylpheed_formats::media::DirectorySource::new(&args.disc);
// P0 starts here: enumerate GP_TITLE's screen builds and write one out.
// Nothing is implemented yet -- this proves the pinned decoders resolve.
let _ = (&source, &args.out);
println!("sylpheed-export: scaffold only; see docs/MISSION.md milestone P0");
Ok(())
}

100
docker/Dockerfile Normal file
View File

@@ -0,0 +1,100 @@
# Autonomous port agent for the Sylpheed Godot menu shell.
#
# DELIBERATELY SMALL. The reverse-engineering container next door is 4.36 GB
# because it builds Xenia Canary and drives it under a software Vulkan stack.
# This agent has no emulator, no oracle and no C++ build: it converts already-
# decoded assets and drives Godot. Keeping it light is what lets both containers
# run on one 12-core / 15 GB box without the memory pressure that has crashed it.
#
# What it needs, and nothing else: Rust (the exporter), Godot 4 (the runtime),
# ffmpeg (the transcode), and a headless display to screenshot Godot for
# comparison against the reference renderer.
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
TZ=Etc/UTC
RUN apt-get update && apt-get install -y --no-install-recommends \
# toolchain for the exporter and for building sylpheed-cli from /reborn
build-essential pkg-config git curl ca-certificates \
libssl-dev \
# Godot 4 needs these even headless; the windowed run needs the X libs
libx11-6 libxcursor1 libxinerama1 libxrandr2 libxi6 libgl1 \
libasound2t64 libpulse0 libfontconfig1 \
# the transcode target (libtheora + libvorbis ship in Ubuntu's ffmpeg)
ffmpeg \
# headless display + the screenshot path, for diffing Godot's output
# against `sylpheed-cli screen render`
xvfb x11-utils openbox imagemagick \
# everyday
python3 jq ripgrep unzip file less nano tini sudo procps \
# expect drives Claude Code's one-time interactive gates
expect \
&& rm -rf /var/lib/apt/lists/*
# ── Godot 4 ──────────────────────────────────────────────────────────────────
# Pinned. An engine version bump changes rendering, and this project compares
# screenshots against a reference renderer — so an upgrade must be a deliberate,
# stated act rather than a silent drift.
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 \
&& npm install -g @anthropic-ai/claude-code \
&& npm cache clean --force \
&& rm -rf /var/lib/apt/lists/*
# ── The agent user ───────────────────────────────────────────────────────────
# NOT root: Claude Code refuses --dangerously-skip-permissions with root
# privileges. Ubuntu 24.04 ships its own `ubuntu` account at uid 1000, so the
# common case — matching a host user who is also 1000 — collides with it.
ARG AGENT_UID=1000
ARG AGENT_GID=1000
RUN if getent passwd "${AGENT_UID}" >/dev/null; then \
userdel -r "$(getent passwd "${AGENT_UID}" | cut -d: -f1)" 2>/dev/null || true; \
fi; \
if getent group "${AGENT_GID}" >/dev/null; then \
groupdel "$(getent group "${AGENT_GID}" | cut -d: -f1)" 2>/dev/null || true; \
fi; \
groupadd -g "${AGENT_GID}" agent \
&& useradd -m -u "${AGENT_UID}" -g "${AGENT_GID}" -s /bin/bash -d /sylph-home/port agent \
&& mkdir -p /sylph-home/port /work /reborn \
&& chown -R "${AGENT_UID}:${AGENT_GID}" /sylph-home \
&& echo 'agent ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/agent
COPY bin/ /usr/local/bin/
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/* /usr/local/bin/entrypoint.sh
USER agent
WORKDIR /work
# CARGO_TARGET_DIR points OUTSIDE the bind-mounted repo so the host and the
# container do not invalidate each other's incremental state on every switch.
ENV RUSTUP_HOME=/sylph-home/port/.rustup \
CARGO_HOME=/sylph-home/port/.cargo \
CARGO_TARGET_DIR=/sylph-home/port/target-container \
PATH=/sylph-home/port/.cargo/bin:/usr/local/bin:/usr/bin:/bin
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain stable --profile minimal --component clippy --component rustfmt
RUN mkdir -p /sylph-home/port/target-container /sylph-home/port/.claude
ENV HOME=/sylph-home/port \
DISPLAY=:97 \
SCREEN_GEOMETRY=1280x720x24 \
PROJECT_DIR=/work
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
CMD ["bash"]

17
docker/bin/build-export Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Build and run the exporter against the disc.
#
# build-export build only
# build-export --run build, then export to ./export
#
# Jobs are capped: this box runs two agent containers and a desktop, and an
# unbounded parallel build has crashed it. Do not raise this to "use all cores".
set -euo pipefail
cd "${PROJECT_DIR:-/work}"
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-3}"
cargo build --release -p sylpheed-export
if [ "${1:-}" = "--run" ]; then
shift
disc="${SYLPHEED_DISC:?set SYLPHEED_DISC to the extracted disc root}"
exec "$CARGO_TARGET_DIR/release/sylpheed-export" --disc "$disc" --out export "$@"
fi

72
docker/bin/claude-autonomous Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/expect -f
# Start Claude Code for an unattended run, answering the first-run gates.
#
# Claude Code has three one-time interactive prompts, and every one of them is a
# silent, permanent hang for an agent with nobody at the keyboard — no error, no
# log line, just a container that looks healthy and does nothing:
#
# 1. the theme picker (first run, or whenever the installed version is
# newer than lastOnboardingVersion)
# 2. "do you trust this folder?" (per workspace)
# 3. the Bypass Permissions disclaimer (for --dangerously-skip-permissions)
#
# `seed-claude-config.py` pre-sets the config keys for 1 and 2. The disclaimer
# has no such key — it is meant to be accepted by a person once — so it is
# answered here instead. That is the honest reading of `sylph-agent loose`: the
# operator accepted it by choosing to run this, and the container is exactly the
# sandbox the warning asks for.
#
# ── Why the patterns are single words ──
# Claude Code draws its UI with ABSOLUTE COLUMN escapes between words, so the
# prompt arrives on the wire as
#
# 2.\x1b[8GYes,\x1b[13GI\x1b[15Gaccept
#
# A multi-word pattern like {Yes, I accept} therefore never matches, and the
# wrapper sits there looking like it is not running at all. Match one word.
set timeout 90
log_user 1
# Give the pty a wide, tall geometry. A detached `docker run -t` defaults to
# 80x24, and Claude Code hard-wraps to the terminal width — which truncates the
# Remote Control URL to "https://claude.ai/code/session_01…" in the one place
# you need to read it, and makes `docker logs` nearly unusable generally.
set stty_init "rows 50 cols 200"
set answered_theme 0
set answered_trust 0
set answered_bypass 0
spawn -noecho claude --dangerously-skip-permissions {*}$argv
expect {
-re {Choose} {
if {!$answered_theme} { set answered_theme 1; send "\r" }
exp_continue
}
-re {trust} {
if {!$answered_trust} {
set answered_trust 1
send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n"
send "1\r"
}
exp_continue
}
-re {accept} {
if {!$answered_bypass} {
set answered_bypass 1
send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n"
send "2\r"
}
exp_continue
}
timeout {
# No new gate for a while: the session is up (or never had one). Stop
# matching so nothing later in the run can be answered by accident.
}
eof { exit }
}
# Hand the terminal over for the rest of the run.
interact

67
docker/bin/push-work Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Push the current topic branch to origin — the ONLY sanctioned way out of the
# container.
#
# Why a wrapper instead of plain `git push`:
#
# * **`main` and shared branches are refused.** The agent commits to
# `auto/<topic>`; a human merges. A token that can push anywhere is one
# confused iteration away from rewriting the consolidated line.
# * **Force-push is refused**, always. Nothing here needs it, and history
# rewriting is the one mistake that cannot be undone by merging.
# * It pushes the CURRENT branch only, by name, so a stray `--all` cannot
# publish another agent's worktree branch mid-experiment.
#
# Credentials come from a file mounted read-only at ~/.git-credentials (see
# `sylph-agent`). They are never printed, never logged, and never passed on a
# command line.
#
# push-work push the current branch
# push-work --dry-run say what it would do
set -euo pipefail
DRY=0
[ "${1:-}" = "--dry-run" ] && DRY=1
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
echo "push-work: not inside a git repository" >&2; exit 1; }
cd "$repo_root"
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$branch" = "HEAD" ]; then
echo "push-work: detached HEAD — check out a branch first" >&2; exit 1
fi
case "$branch" in
auto/*) ;;
*)
echo "push-work: refusing to push '$branch'." >&2
echo " Only auto/* topic branches may leave the container; a human merges" >&2
echo " them into main. Move your work: git switch -c auto/<topic>" >&2
exit 1 ;;
esac
if [ ! -s "$HOME/.git-credentials" ]; then
echo "push-work: no credentials mounted at ~/.git-credentials." >&2
echo " The host must start the container with SYLPH_GIT_CREDENTIALS pointing" >&2
echo " at a file containing one line:" >&2
echo " https://<user>:<token>@git.mc02.dev" >&2
exit 1
fi
# `store` reads the file we mounted; nothing is written back (it is read-only).
git config --local credential.helper "store --file=$HOME/.git-credentials"
ahead=$(git rev-list --count "origin/$branch..$branch" 2>/dev/null || git rev-list --count HEAD)
echo "push-work: $branch — $ahead commit(s) to publish"
if [ "$DRY" = 1 ]; then
echo "push-work: --dry-run, stopping here"
exit 0
fi
# --force-with-lease is deliberately NOT offered. If this is rejected as
# non-fast-forward, someone else moved the branch: fetch and merge, do not
# overwrite.
git push --set-upstream origin "$branch"
echo "push-work: pushed $branch"

13
docker/bin/screenshot Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Capture the current display to a PNG.
#
# screenshot out.png
#
# Used to diff Godot's rendering against `sylpheed-cli screen render`. Captures
# the whole 1280x720 root window, which is exactly the design space the screens
# are authored in, so a capture and a composite are directly comparable without
# cropping or scaling.
set -euo pipefail
out="${1:?usage: screenshot OUT.png}"
import -display "${DISPLAY:-:97}" -window root "$out"
identify -format 'captured %wx%h -> %f\n' "$out"

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Mark Claude Code's onboarding as complete in ~/.claude.json.
Claude Code re-runs its first-run wizard whenever `lastOnboardingVersion` does
not match the installed version. In a terminal that is a one-key prompt; for an
unattended agent it is a silent, permanent hang on the theme picker — no error,
no log line, and the container looks like it started fine.
It also pre-accepts the workspace's trust prompt. That is a SECOND, separate
first-run gate: even past onboarding, Claude Code asks "is this a project you
trust?" per directory, and this repo's settings pre-approve 442 tool permissions
so the prompt is emphatic about it. Unattended, it is another silent hang.
Pre-accepting is safe here precisely because the trust question is being
answered by the person who built and launched the container, for their own
repository — it is not a judgement being made on their behalf about someone
else's code.
Usage: seed-claude-config.py <path to .claude.json> <installed version> [workspace...]
"""
import json
import os
import sys
def main() -> int:
if len(sys.argv) < 3:
print(f"usage: {sys.argv[0]} <config.json> <version> [workspace...]",
file=sys.stderr)
return 2
path, version = sys.argv[1], sys.argv[2]
workspaces = sys.argv[3:]
cfg = {}
if os.path.exists(path) and os.path.getsize(path):
try:
with open(path) as f:
cfg = json.load(f)
except (OSError, ValueError):
# A corrupt or partial config is not worth failing the container
# over — start from empty rather than block the run.
cfg = {}
if not isinstance(cfg, dict):
cfg = {}
cfg["hasCompletedOnboarding"] = True
cfg["lastOnboardingVersion"] = version
cfg.setdefault("theme", "dark")
# The THIRD interactive gate: --dangerously-skip-permissions shows a
# "Bypass Permissions mode / you accept all responsibility" confirmation on
# first use. Key name taken from the shipped binary's own strings, not
# guessed. Accepting it here is the whole point of `sylph-agent loose` —
# the container is the sandbox that warning asks you to provide.
cfg["bypassPermissionsModeAccepted"] = True
# A FOURTH gate, and this one fires mid-session rather than at startup, so
# the pty wrapper has already handed over by then: an upsell asking whether
# to try the fullscreen renderer. It is shown while
# `fullscreenUpsellSeenCount` is below an internal threshold, so park it far
# above. Found by reading the shipped binary's strings, same as the others.
cfg["fullscreenUpsellSeenCount"] = 9999
# An auto-update mid-run would restart the process and lose the loop's
# scheduled wake-up, so pin the version the container was built with.
cfg["autoUpdates"] = False
projects = cfg.setdefault("projects", {})
if not isinstance(projects, dict):
projects = cfg["projects"] = {}
for ws in workspaces:
entry = projects.setdefault(ws, {})
if not isinstance(entry, dict):
entry = projects[ws] = {}
entry["hasTrustDialogAccepted"] = True
entry.setdefault("projectOnboardingSeenCount", 1)
entry["hasClaudeMdExternalIncludesApproved"] = True
entry["hasClaudeMdExternalIncludesWarningShown"] = True
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(cfg, f, indent=2)
os.replace(tmp, path)
return 0
if __name__ == "__main__":
sys.exit(main())

27
docker/entrypoint.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Bring up the headless display, then hand over.
#
# Xvfb and openbox are started as children of PID 1 (tini), NOT of the agent's
# shell, so they outlive any single command. The RE container learned this the
# hard way: a display owned by a shell gets reaped when that shell exits, which
# reads as "Xvfb dies on its own every few minutes".
set -euo pipefail
: "${DISPLAY:=:97}"
: "${SCREEN_GEOMETRY:=1280x720x24}"
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
Xvfb "$DISPLAY" -screen 0 "$SCREEN_GEOMETRY" -nolisten tcp &
for _ in $(seq 50); do
xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break
sleep 0.1
done
openbox >/dev/null 2>&1 &
fi
echo "[entrypoint] display $DISPLAY ready ($SCREEN_GEOMETRY)"
if [ -d /reborn ]; then
echo "[entrypoint] /reborn mounted read-only — HANDOFF.md is the contract"
fi
exec "$@"

160
docker/sylph-port Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# Launcher for the Godot port agent.
#
# ./sylph-port build build the image
# ./sylph-port shell interactive shell
# ./sylph-port loose [task] detached, self-running on a fixed interval
# ./sylph-port logs -f follow it
# ./sylph-port attach chat with it (Ctrl-P Ctrl-Q to leave it running)
# ./sylph-port remote a link to chat with it from anywhere
# ./sylph-port stop stop it
#
# Env:
# SYLPH_PORT_CPUS / SYLPH_PORT_MEM_GB override the cap (default 3 / 4)
# SYLPH_REBORN path to the Syplheed-Reborn checkout (read-only mount)
# SYLPH_DISC extracted disc root
# SYLPH_GIT_CREDENTIALS file with `https://<user>:<token>@host` for push-work
# SYLPH_LOOP_INTERVAL fixed loop cadence (default 45m)
#
# ── Two hard-won constraints ────────────────────────────────────────────────
#
# 1. THIS REPO IS ITS OWN CLONE. It is deliberately NOT the tree the RE agent
# or a human is working in. Sharing a working tree between two writers means
# files change under whoever is mid-edit, and a `git add -A` by one sweeps up
# the other's work. That happened; do not re-create it.
#
# 2. IDENTITY GOES IN THE ENVIRONMENT, NOT `.git/config`. Writing `[user]` into
# a repo's config captures every commit made in that tree, including a
# human's. GIT_AUTHOR_*/GIT_COMMITTER_* apply to this container's commits and
# nobody else's.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$HERE/.." && pwd)"
IMAGE="${SYLPH_PORT_IMAGE:-sylpheed-port:latest}"
NAME="${SYLPH_PORT_NAME:-sylpheed-port}"
# Half of what the RE container takes. That container builds a C++ emulator and
# drives it; this one converts assets and runs Godot. Two full-size containers
# do not fit on a 12-core / 15 GB box beside a desktop -- memory is the binding
# constraint, and an over-committed build has crashed this machine before.
CPUS="${SYLPH_PORT_CPUS:-3}"
MEM_GB="${SYLPH_PORT_MEM_GB:-4}"
REBORN="${SYLPH_REBORN:-$(cd "$REPO/../Syplheed-Reborn" 2>/dev/null && pwd || true)}"
DISC="${SYLPH_DISC:-$(cd "$REPO/../sylph_extract" 2>/dev/null && pwd || true)}"
docker_args() {
local _out=(
--name "$NAME"
--hostname sylph-port
--cpus "$CPUS"
--memory "${MEM_GB}g"
--memory-swap "${MEM_GB}g" # no swap escape hatch: a swapping build
# thrashes the whole host
--pids-limit 2048
-v "$REPO:/work"
-v "sylpheed-port-target:/sylph-home/port/target-container"
-v "sylpheed-port-claude:/sylph-home/port/.claude"
-e "PROJECT_DIR=/work"
)
# The RE corpus, READ-ONLY. `docs/port/HANDOFF.md` is the contract, and the
# agent also builds sylpheed-cli from here for the reference renderer. Mounted
# ro so a port iteration cannot edit the other agent's repository.
if [ -n "$REBORN" ] && [ -d "$REBORN" ]; then
_out+=(-v "$REBORN:/reborn:ro")
else
echo "==> NOTE: no Syplheed-Reborn checkout found; the agent cannot read" >&2
echo " HANDOFF.md or build the reference renderer. Set SYLPH_REBORN." >&2
fi
if [ -n "$DISC" ] && [ -d "$DISC" ]; then
_out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc")
else
echo "==> NOTE: no extracted disc found; the exporter has nothing to read." >&2
echo " Set SYLPH_DISC to the directory holding dat/ and hidden/." >&2
fi
# Commits are attributed to the port agent, via the environment so that
# nothing is written into the repository's config. See constraint 2 above.
_out+=(
-e "GIT_AUTHOR_NAME=Sylpheed port agent"
-e "GIT_AUTHOR_EMAIL=port-agent@localhost"
-e "GIT_COMMITTER_NAME=Sylpheed port agent"
-e "GIT_COMMITTER_EMAIL=port-agent@localhost"
)
local gitcred="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
if [ -f "$gitcred" ]; then
_out+=(-v "$gitcred:/sylph-home/port/.git-credentials:ro")
else
echo "==> NOTE: no git credentials at $gitcred — the agent cannot push," >&2
echo " so its work dies with the container." >&2
fi
[ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
printf '%s\n' "${_out[@]}"
}
mapfile -t ARGS < <(docker_args)
case "${1:-}" in
build)
exec docker build -t "$IMAGE" \
--build-arg "AGENT_UID=$(id -u)" --build-arg "AGENT_GID=$(id -g)" "$HERE"
;;
shell)
TTY=(-i); [ -t 0 ] && TTY=(-it)
exec docker run --rm "${TTY[@]}" "${ARGS[@]}" "$IMAGE" bash
;;
loose)
shift
TASK="${1:-}"
if [ -z "$TASK" ]; then
if [ -f "$REPO/docs/loop-task.md" ]; then
TASK="$(cat "$REPO/docs/loop-task.md")"
else
TASK="Work the milestones in docs/MISSION.md."
fi
fi
# A FIXED interval, not self-pacing: the one thing an agent deep in a
# milestone reliably forgets is the bookkeeping after it, and a forgotten
# wake-up silently ends the loop.
INTERVAL="${SYLPH_LOOP_INTERVAL-45m}"
echo "==> loose | cpus=$CPUS mem=${MEM_GB}g pacing=${INTERVAL:-self}"
echo "==> repo: $REPO"
echo "==> reborn: ${REBORN:-<none>} (read-only)"
docker run -d -i -t "${ARGS[@]}" "$IMAGE" \
"/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
echo
echo " running detached as '$NAME'."
echo " ./sylph-port remote link to chat with it from anywhere"
echo " ./sylph-port logs -f follow it"
echo " ./sylph-port attach chat with it locally"
echo " ./sylph-port stop stop it"
;;
logs) shift; exec docker logs "$@" "$NAME" ;;
attach) exec docker attach "$NAME" ;;
stop) exec docker rm -f "$NAME" ;;
remote)
echo "waiting for the session to register" >&2
for _ in $(seq 60); do
url=$(docker exec "$NAME" sh -c \
'grep -ho "https://claude.ai/code/session_[A-Za-z0-9]*" \
/sylph-home/port/.claude/**/*.jsonl 2>/dev/null | tail -1' 2>/dev/null || true)
[ -n "$url" ] && { echo "$url"; exit 0; }
sleep 2
done
echo "no session link yet — try ./sylph-port logs -f" >&2
exit 1
;;
*)
sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'
;;
esac

23
docs/BLOCKED.md Normal file
View File

@@ -0,0 +1,23 @@
# 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.
| Milestone | Needs | HANDOFF question |
|---|---|---|
| P2 keyframe animation | the unit of a keyframe time, and the ramp shape | Q1 |
| P3 splash → title | which build is which screen state | Q2 |
| P1/P3 correct layering | paint order for these six screens | Q3 |
| P5 button actions | which button opens which GamePart | Q4 |
| P5 navigation | initial focus, wrap-around, what B does | Q5 |
| P3 sequencing | the boot order and what drives it | Q6 |
| P3 transitions | what happens visually between screens, and its timing | Q7 |
| P6 audio | which BGM per screen; which cue on move/confirm/back | Q8 |
| P4/P7 video | which movie is the boot intro vs the new-game intro | Q9 |
| P6 looping | whether a music bank's sub-waves are intro+loop or variations | Q10 |
**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,
put the placeholder in `authored/` with a `why` naming the question it is standing
in for, so it is deleted rather than forgotten when the answer arrives.

139
docs/FORMAT.md Normal file
View File

@@ -0,0 +1,139 @@
# The open export format — v1
The format the disc is converted *into*, and the one the Godot project and any
modding tool read. **This is a starting point, and it is yours to revise** — but
it is versioned, so a change is a deliberate act with a version bump, not a
silent edit.
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. 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.
## Layout
```
export/ # DERIVED. Regenerable. Gitignored. Never hand-edited.
manifest.json
screens/title/*.json
sprites/*.png
audio/music/*.ogg audio/sfx/*.ogg audio/cues.json
video/*.ogv
authored/ # AUTHORED. Hand-written. Committed. Survives re-export.
flow.json # boot sequence + what each button does
paint_order.json # per-screen z-order
cue_bindings.json # which cue fires on move / confirm / back
```
Godot loads `export/` first, then applies `authored/` over it.
## Common header
```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** — 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": "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] },
{ "t": 34, "pos": [542, 157] },
{ "t": 64, "pos": [542, 162] }
]
}
],
"buttons": ["ptbtn01", "ptbtn02", "ptbtn03", "ptbtn04", "ptbtn05"],
"unresolved": ["paint_order", "keyframe_time_unit"]
}
```
**`role`** comes from the decoded element kind: `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 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 and `opt ` is *not* a focus link (measured and refuted). It
is right for a vertical menu and should not be trusted for anything else.
**`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. The unit of `t` is HANDOFF
Q1 and is unanswered — keep `t` raw so the conversion lives in exactly one place.
**`rest`** is the resting pose: the longest run of consecutive keyframes with an
unchanged value, falling back to longest-dwell. Neither the first nor the last.
**`unresolved`** lists what this file does not answer; a consumer needing one of
those must get it from `authored/`.
## `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",
"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"]
}
```
`formats_rev` pins which decoders produced this export, and `video_transcode`
records the exact command so a modder can re-run it rather than reverse-engineer
what was done.

117
docs/MISSION.md Normal file
View File

@@ -0,0 +1,117 @@
# 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 revision**. Do not vendor
them, do not reimplement them, and do not float the pin — a decoder change
landing mid-milestone is exactly the confusion this pin prevents.
* Bump the pin deliberately, as its own commit, saying what you wanted from it.
**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.
## 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.

85
docs/loop-task.md Normal file
View File

@@ -0,0 +1,85 @@
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. `git -C /reborn
pull` first; the RE agent publishes continuously.
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.

20
port/project.godot Normal file
View File

@@ -0,0 +1,20 @@
; Godot 4 project for the Sylpheed menu shell.
;
; It reads ONLY the open asset tree produced by crates/sylpheed-export -- no
; disc formats, no GDExtension, no Rust. See ../docs/MISSION.md.
config_version=5
[application]
config/name="Sylpheed"
config/features=PackedStringArray("4.3")
run/main_scene="res://scenes/boot.tscn"
[display]
; The screens are authored at 1280x720 and every coordinate in the export is in
; that space, so the viewport matches it exactly and scaling happens once, at
; the window edge.
window/size/viewport_width=1280
window/size/viewport_height=720
window/stretch/mode="canvas_items"
window/stretch/aspect="keep"