From 38541c153b33b777dc28c27b3f1dbdf730d11491 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 28 Aug 2026 17:56:44 +0200 Subject: [PATCH 01/29] 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 (47f423f), 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 --- .gitignore | 12 +++ Cargo.toml | 8 ++ README.md | 49 +++++++++ authored/README.md | 12 +++ crates/sylpheed-export/Cargo.toml | 22 ++++ crates/sylpheed-export/src/main.rs | 33 ++++++ docker/Dockerfile | 100 ++++++++++++++++++ docker/bin/build-export | 17 +++ docker/bin/claude-autonomous | 72 +++++++++++++ docker/bin/push-work | 67 ++++++++++++ docker/bin/screenshot | 13 +++ docker/bin/seed-claude-config.py | 85 +++++++++++++++ docker/entrypoint.sh | 27 +++++ docker/sylph-port | 160 +++++++++++++++++++++++++++++ docs/BLOCKED.md | 23 +++++ docs/FORMAT.md | 139 +++++++++++++++++++++++++ docs/MISSION.md | 117 +++++++++++++++++++++ docs/loop-task.md | 85 +++++++++++++++ port/project.godot | 20 ++++ 19 files changed, 1061 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 authored/README.md create mode 100644 crates/sylpheed-export/Cargo.toml create mode 100644 crates/sylpheed-export/src/main.rs create mode 100644 docker/Dockerfile create mode 100755 docker/bin/build-export create mode 100755 docker/bin/claude-autonomous create mode 100755 docker/bin/push-work create mode 100755 docker/bin/screenshot create mode 100755 docker/bin/seed-claude-config.py create mode 100755 docker/entrypoint.sh create mode 100755 docker/sylph-port create mode 100644 docs/BLOCKED.md create mode 100644 docs/FORMAT.md create mode 100644 docs/MISSION.md create mode 100644 docs/loop-task.md create mode 100644 port/project.godot diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..11bc8073 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..3d3d1ec1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +resolver = "2" +members = ["crates/sylpheed-export"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" diff --git a/README.md b/README.md new file mode 100644 index 00000000..1ed04b05 --- /dev/null +++ b/README.md @@ -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. diff --git a/authored/README.md b/authored/README.md new file mode 100644 index 00000000..3493aae1 --- /dev/null +++ b/authored/README.md @@ -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. diff --git a/crates/sylpheed-export/Cargo.toml b/crates/sylpheed-export/Cargo.toml new file mode 100644 index 00000000..fa21ccd8 --- /dev/null +++ b/crates/sylpheed-export/Cargo.toml @@ -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"] } diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs new file mode 100644 index 00000000..7af3d3a0 --- /dev/null +++ b/crates/sylpheed-export/src/main.rs @@ -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(()) +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..ca960bcd --- /dev/null +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/bin/build-export b/docker/bin/build-export new file mode 100755 index 00000000..3e4ebfe7 --- /dev/null +++ b/docker/bin/build-export @@ -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 diff --git a/docker/bin/claude-autonomous b/docker/bin/claude-autonomous new file mode 100755 index 00000000..bf644478 --- /dev/null +++ b/docker/bin/claude-autonomous @@ -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 diff --git a/docker/bin/push-work b/docker/bin/push-work new file mode 100755 index 00000000..74ac8a28 --- /dev/null +++ b/docker/bin/push-work @@ -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/`; 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/" >&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://:@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" diff --git a/docker/bin/screenshot b/docker/bin/screenshot new file mode 100755 index 00000000..99f94c0b --- /dev/null +++ b/docker/bin/screenshot @@ -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" diff --git a/docker/bin/seed-claude-config.py b/docker/bin/seed-claude-config.py new file mode 100755 index 00000000..53a6de05 --- /dev/null +++ b/docker/bin/seed-claude-config.py @@ -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 [workspace...] +""" +import json +import os +import sys + + +def main() -> int: + if len(sys.argv) < 3: + print(f"usage: {sys.argv[0]} [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()) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 00000000..b1f4ef26 --- /dev/null +++ b/docker/entrypoint.sh @@ -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 "$@" diff --git a/docker/sylph-port b/docker/sylph-port new file mode 100755 index 00000000..c0ebe90a --- /dev/null +++ b/docker/sylph-port @@ -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://:@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:-} (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 diff --git a/docs/BLOCKED.md b/docs/BLOCKED.md new file mode 100644 index 00000000..ec94dc81 --- /dev/null +++ b/docs/BLOCKED.md @@ -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. diff --git a/docs/FORMAT.md b/docs/FORMAT.md new file mode 100644 index 00000000..b5189206 --- /dev/null +++ b/docs/FORMAT.md @@ -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. diff --git a/docs/MISSION.md b/docs/MISSION.md new file mode 100644 index 00000000..3fde0acd --- /dev/null +++ b/docs/MISSION.md @@ -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. diff --git a/docs/loop-task.md b/docs/loop-task.md new file mode 100644 index 00000000..5e5471cf --- /dev/null +++ b/docs/loop-task.md @@ -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/`, 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. diff --git a/port/project.godot b/port/project.godot new file mode 100644 index 00000000..6cf99ddb --- /dev/null +++ b/port/project.godot @@ -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" From 0b18cd04845e2096b670e71b7f49fd73f0e0722a Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 28 Aug 2026 20:07:39 +0200 Subject: [PATCH 02/29] port: pin Godot 4.7.2, and decide the MCP/skills question The pin was 4.3, chosen without checking; 4.7.2 is the current stable (released 2026-08-18). Getting this right matters more here than usual because the project diffs Godot screenshots against a reference renderer, so the engine version is part of the measurement. No Godot MCP server. Not because they are bad -- the mature ones need a LIVE editor plus a WebSocket plugin and a Python server, which is a daemon, an editor process and a second runtime added to an unattended loop. And their headline feature, scene-tree introspection and node manipulation, is built for someone hand-authoring scenes in the editor. This port GENERATES screens from exported JSON: the agent writes a loader, not a scene tree, so the feature that justifies the complexity does not apply. What it actually needs to verify itself -- run headless, screenshot, diff against sylpheed-cli screen render -- is already a bash job. Third-party skill packs are the opposite trade: pure context, no runtime. Worth revisiting, deliberately not installed now, because a skill is instructions injected into an agent running with approvals disabled -- a supply-chain decision, not a default -- and because P0/P1 need no advanced GDScript. The agent may propose one, naming the milestone it unblocks, for a human to vendor and review. Co-Authored-By: Claude Opus 5 --- docker/Dockerfile | 2 +- docs/MISSION.md | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index ca960bcd..86417f6f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -38,7 +38,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # 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 +ARG GODOT_VERSION=4.7.2 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" \ diff --git a/docs/MISSION.md b/docs/MISSION.md index 3fde0acd..f5ed6198 100644 --- a/docs/MISSION.md +++ b/docs/MISSION.md @@ -115,3 +115,41 @@ Only the boot intro and the one new-game intro are in scope. The disc holds 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 From 9e8d5cbe39bec0415f61f7ad98ee001db6153efd Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 28 Aug 2026 20:31:32 +0200 Subject: [PATCH 03/29] port: fix the container exiting instead of starting the agent `loose` exited 126 with "File name too long": the entrypoint ended in `exec "$@"`, so the whole loop-task.md prompt was handed to exec as a command and the markdown was tried as a filename. The launcher was modelled on the RE container's but the entrypoint was written fresh and simpler, and it left out the half that matters -- the block that turns a `/loop ...` argument into a Claude Code invocation. Ported it over, with the two gates that make an unattended run possible: seeding ~/.claude.json so the first-run wizard does not stop on the theme picker, and claude-autonomous, which answers the Bypass Permissions disclaimer that has no config key to skip it. Also mounts the host's Claude credentials, which the container had no way to reach, and passes SYLPH_AUTONOMOUS=1 from `loose`. Verified: container stays up, display :97 ready, /reborn mounted read-only, Remote Control registered, Claude Code started in /work. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 1217 ++++++++++++++++++++++++++++++++++++++++++ docker/entrypoint.sh | 37 ++ docker/sylph-port | 5 +- 3 files changed, 1257 insertions(+), 2 deletions(-) create mode 100644 Cargo.lock diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..9d732528 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1217 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary-int" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825297538d77367557b912770ca3083f778a196054b3ee63b22673c4a3cae0a5" + +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "binrw" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4b52fe7fbd9e207b879c52c5af7efd861c2f4e234ab4f744b0bdc04a863623" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7196f6935a944081728167ce6e3a599bd38d6d80d21afe4ca17be2097a6682f" +dependencies = [ + "either", + "owo-colors", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bitbybit" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec187a89ab07e209270175faf9e07ceb2755d984954e58a2296e325ddece2762" +dependencies = [ + "arbitrary-int", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciso" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42222171e20c6a5e2c83cc5295f4a55c27c9397acff30dbae4f3baeffae47f51" +dependencies = [ + "arbitrary-int", + "async-trait", + "bitbybit", + "lz4_flex", + "maybe-async", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "pori" +version = "0.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a63d338dec139f56dacc692ca63ad35a6be6a797442479b55acd611d79e906" +dependencies = [ + "nom", +] + +[[package]] +name = "proc-bitfield" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "095c6eb206c97ddef87ce3d7e3e492b017093d80bce62317afdf0665df514ade" +dependencies = [ + "proc-bitfield-macros", + "static_assertions", +] + +[[package]] +name = "proc-bitfield-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89409e6b315ead7f4c4d9a79e27dc1e11272f930cbb1fb3d31f2fc64671deb77" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "sylpheed-export" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "image", + "serde", + "serde_json", + "sylpheed-formats", +] + +[[package]] +name = "sylpheed-formats" +version = "0.1.0" +source = "git+https://git.mc02.dev/fabi/Syplheed-Reborn.git?rev=8b6dbcf#8b6dbcfead4168a674015f0e4fd2c8d83f9ffe31" +dependencies = [ + "anyhow", + "binrw", + "flate2", + "futures", + "rayon", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tracing", + "ttf-parser", + "xdvdfs", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wax" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d12a78aa0bab22d2f26ed1a96df7ab58e8a93506a3e20adb47c51a93b4e1357" +dependencies = [ + "const_format", + "itertools", + "nom", + "pori", + "regex", + "thiserror 1.0.69", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "xdvdfs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1777c1ccae32a2185ac98adae015aed3fb06242084b5e7f8b08a811fe0e7b936" +dependencies = [ + "arrayvec", + "async-trait", + "bincode", + "ciso", + "encoding_rs", + "log", + "maybe-async", + "proc-bitfield", + "serde", + "serde-big-array", + "sha3", + "wax", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b1f4ef26..2ee5700c 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -24,4 +24,41 @@ if [ -d /reborn ]; then echo "[entrypoint] /reborn mounted read-only — HANDOFF.md is the contract" fi +# Seed ~/.claude.json from the host's read-only copy, then stamp onboarding as +# complete. Claude Code re-runs its first-run wizard whenever +# lastOnboardingVersion differs from the installed version, so a container with a +# newer Claude than the host stops on the theme picker -- no error, no log line, +# and an unattended agent sits there forever. +if [ -f "$HOME/.claude.host.json" ] && [ ! -s "$HOME/.claude.json" ]; then + cp "$HOME/.claude.host.json" "$HOME/.claude.json" 2>/dev/null || true +fi +CLAUDE_VER=$(claude --version 2>/dev/null | grep -oE '^[0-9][0-9.]*' || echo 0.0.0) +python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER" \ + "$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true +chmod 600 "$HOME/.claude.json" 2>/dev/null || true + +# ── Claude Code ────────────────────────────────────────────────────────────── +# Without this the loop prompt is handed to `exec` as a command, and the whole +# markdown file is tried as a filename: exit 126, "File name too long". +if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then + # Drop the image's default CMD first, or `claude` is handed the literal string + # "bash" as its prompt and answers a question nobody asked. + if [ "$#" -eq 1 ] && [ "$1" = "bash" ]; then + set -- + fi + # Remote Control registers the session with the account so the agent can be + # reached from claude.ai -- the point of a detached run being that nobody is + # sitting in front of it. The name is passed EXPLICITLY: the flag's value is + # optional, so a bare --remote-control swallows the /loop prompt after it. + if [ "${SYLPH_REMOTE:-1}" != "0" ]; then + set -- --remote-control "${SYLPH_REMOTE_NAME:-sylpheed-port}" "$@" + echo "[entrypoint] Remote Control as '${SYLPH_REMOTE_NAME:-sylpheed-port}'" + fi + # claude-autonomous wraps `claude --dangerously-skip-permissions` in a pty and + # answers the one-time first-run gates. The Bypass Permissions disclaimer has + # no config key that skips it, so unattended it hangs forever. + set -- claude-autonomous "$@" + echo "[entrypoint] starting Claude Code in $(pwd)" +fi + exec "$@" diff --git a/docker/sylph-port b/docker/sylph-port index c0ebe90a..8d756913 100755 --- a/docker/sylph-port +++ b/docker/sylph-port @@ -55,7 +55,8 @@ docker_args() { --pids-limit 2048 -v "$REPO:/work" -v "sylpheed-port-target:/sylph-home/port/target-container" - -v "sylpheed-port-claude:/sylph-home/port/.claude" + -v "${SYLPH_CLAUDE_HOME:-$HOME/.claude}:/sylph-home/port/.claude" + -v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/port/.claude.host.json:ro" -e "PROJECT_DIR=/work" ) @@ -127,7 +128,7 @@ case "${1:-}" in echo "==> loose | cpus=$CPUS mem=${MEM_GB}g pacing=${INTERVAL:-self}" echo "==> repo: $REPO" echo "==> reborn: ${REBORN:-} (read-only)" - docker run -d -i -t "${ARGS[@]}" "$IMAGE" \ + docker run -d -i -t "${ARGS[@]}" -e SYLPH_AUTONOMOUS=1 -w /work "$IMAGE" \ "/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null echo echo " running detached as '$NAME'." From be5ead4dfaea62ad55dc6fe933b0dc7daff7590a Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 28 Aug 2026 20:35:22 +0200 Subject: [PATCH 04/29] port: read the session link from the container log, not the transcript `sylph-port remote` never returned. Two bugs, one behind the other. The glob `/sylph-home/port/.claude/**/*.jsonl` ran under `sh`, which has no globstar, so it stayed literal and matched nothing -- the lookup could not have succeeded however long it waited. Fixing that exposed the real problem: the session transcript records every command run inside the container, including this lookup, so grepping it matched our own pattern string back and returned a truncated URL. The container log is the right source and has no such feedback loop. Co-Authored-By: Claude Opus 5 --- docker/sylph-port | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docker/sylph-port b/docker/sylph-port index 8d756913..e0c94aab 100755 --- a/docker/sylph-port +++ b/docker/sylph-port @@ -145,9 +145,12 @@ case "${1:-}" in 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) + # Read the container LOG, not the session transcript. The transcript + # records every command run inside the container -- including this + # lookup -- so grepping it matched our own pattern string back. + url=$(docker logs "$NAME" 2>&1 \ + | grep -aoE 'https://claude\.ai/code/session_[A-Za-z0-9]+' \ + | tail -1 || true) [ -n "$url" ] && { echo "$url"; exit 0; } sleep 2 done From f6fc2694781769238f31c8a6c80691572bed549d Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 18:43:52 +0000 Subject: [PATCH 05/29] =?UTF-8?q?export:=20P0=20=E2=80=94=20GP=5FTITLE's?= =?UTF-8?q?=20screens=20and=20sprites=20into=20the=20open=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sylpheed-export export` reads `dat/GP_TITLE.pak`, enumerates its twelve screen builds, and writes each as one `sylpheed.screen/2` document with its sprite PNGs beside it. `sylpheed-export check` validates that tree against docs/FORMAT.md with no disc in hand — the P0 gate is "validates against FORMAT.md", which is not something anyone can confirm by reading, so it is a program. Two readings from FORMAT v1 turned out to be wrong and are corrected here rather than carried: * The focus sprite does NOT come from the element's `opt ` link. That was measured and refuted upstream, and this export shows why plainly: on the main menu `opt ` chains ptloop01 -> ptloop02 -> ptbtn01, two decorations and then a button. The highlight pairs by sprite NAME instead (ptbtn01.t32 <-> ptbtn01f.t32), which is the convention HANDOFF blesses and which resolves all five main-menu buttons. The raw link is still exported, renamed `opt_link` so nothing downstream mistakes it for navigation. * There are TWO modulate colours in different byte orders, and they multiply. v1's single `#rrggbbaa` could not carry both and silently dropped the alpha that every fade ramps. They are now `tint_rgba` and `fade_argb`, with the byte order in the key name, because getting it backwards is silent and reads as an art bug rather than a parse bug. The exporter takes exactly one authored input: `authored/screen_names.json`, because the disc does not name its builds and "build 5 is the main menu" is a measurement (HANDOFF Q2), not a field. Every name it applies is stamped `name_source: "authored"` with the evidence in `name_why`, and `check` rejects an authored name that has no `why` — so the derived tree stays honest about which of its fields is a decision. Sprites are per screen, not a flat pool: `main_menu` and `extras` both ship a `ptbase.t32` and they are different pictures. Checked, not assumed: * two exports of the same disc are byte-identical; * five mutations of a valid main_menu.json — a broken paint_order permutation, a dangling focus_sprite, a reversed buttons list, a `#rrggbbaa` colour and an invented name_source — are each caught with a specific message. `t` stays raw. Q1 is answered, but the seconds conversion is measured off the running game and its own finding flags the frame-rate measurement as the part worth re-testing; if the game presents at 60 Hz every duration halves. One constant, at P2, in a file that says it is a decision. --- authored/screen_names.json | 45 ++++ crates/sylpheed-export/Cargo.toml | 2 +- crates/sylpheed-export/src/check.rs | 277 +++++++++++++++++++++ crates/sylpheed-export/src/main.rs | 213 ++++++++++++++-- crates/sylpheed-export/src/screen.rs | 351 +++++++++++++++++++++++++++ 5 files changed, 873 insertions(+), 15 deletions(-) create mode 100644 authored/screen_names.json create mode 100644 crates/sylpheed-export/src/check.rs create mode 100644 crates/sylpheed-export/src/screen.rs diff --git a/authored/screen_names.json b/authored/screen_names.json new file mode 100644 index 00000000..8ce84420 --- /dev/null +++ b/authored/screen_names.json @@ -0,0 +1,45 @@ +{ + "format": "sylpheed.screen_names/1", + "_": [ + "Which GP_TITLE build is which screen. AUTHORED: the disc does not name its", + "builds, so every name here is a decision. The identifications come from", + "HANDOFF Q2 (ui-title-build-map.md), which measured four of them against", + "framebuffer captures of the running game; the exporter stamps the name into", + "the screen file with name_source: \"authored\" so a reader can tell a", + "recovered name from an invented one.", + "", + "`build` is the index into the pak's list of screen builds -- what", + "`sylpheed-cli screen --build N` takes -- and is stable as long as the", + "enumeration rule is. The screen file also records the pak entry index,", + "which is the stronger locator.", + "", + "Delete an entry here the day the RE agent decodes a name field." + ], + "archives": { + "dat/GP_TITLE.pak": { + "2": { + "name": "press_start", + "why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture." + }, + "3": { "name": "press_start_jp", "why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need." }, + "4": { + "name": "title", + "why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture." + }, + "5": { + "name": "main_menu", + "why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.)" + }, + "6": { + "name": "extras", + "why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture." + }, + "7": { "name": "title_jp", "why": "HANDOFF Q2: the Japanese twin of build 4." }, + "8": { "name": "main_menu_jp", "why": "HANDOFF Q2: the Japanese twin of build 5." }, + "9": { "name": "extras_jp", "why": "HANDOFF Q2: the Japanese twin of build 6." } + } + }, + "unnamed": { + "dat/GP_TITLE.pak": "Builds 0/1 and 10/11 are a DELTASABER / SYLPHEED A.I. plate that was never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their build index rather than a name we would be inventing." + } +} diff --git a/crates/sylpheed-export/Cargo.toml b/crates/sylpheed-export/Cargo.toml index fa21ccd8..e1ea00b6 100644 --- a/crates/sylpheed-export/Cargo.toml +++ b/crates/sylpheed-export/Cargo.toml @@ -18,5 +18,5 @@ sylpheed-formats = { git = "https://git.mc02.dev/fabi/Syplheed-Reborn.git", rev serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/sylpheed-export/src/check.rs b/crates/sylpheed-export/src/check.rs new file mode 100644 index 00000000..abbeb528 --- /dev/null +++ b/crates/sylpheed-export/src/check.rs @@ -0,0 +1,277 @@ +//! `sylpheed-export check` — validate an export tree against `docs/FORMAT.md`. +//! +//! This is the executable form of FORMAT.md, and the reason it exists is that +//! "the export is correct" is otherwise an assertion. It reads `export/` the way +//! the Godot project will — as a stranger, with no access to the disc, the +//! decoders or this exporter's internals — and fails on anything a consumer +//! could not act on: +//! +//! * a document whose `format` is not the version this build writes; +//! * a required field missing, or a colour that is not `0x` + 8 hex digits; +//! * a `paint_order` that is not a permutation of the element indices; +//! * a `buttons` entry naming an element that is not a button, or out of +//! resting-Y order; +//! * a sprite path that does not exist, or a PNG that does not decode; +//! * a name presented as recovered when it was authored. +//! +//! It deliberately does **not** check that the export matches the disc. That is +//! what `sylpheed-cli screen render` is for. + +use anyhow::{bail, Result}; +use serde_json::Value; +use std::path::Path; + +const SCREEN_FORMAT: &str = "sylpheed.screen/2"; +const MANIFEST_FORMAT: &str = "sylpheed.manifest/1"; + +struct Ctx { + file: String, + errors: Vec, +} + +impl Ctx { + fn err(&mut self, msg: impl Into) { + self.errors.push(format!("{}: {}", self.file, msg.into())); + } + fn require<'a>(&mut self, v: &'a Value, key: &str) -> Option<&'a Value> { + match v.get(key) { + Some(Value::Null) | None => { + self.err(format!("missing required field `{key}`")); + None + } + Some(x) => Some(x), + } + } +} + +/// A colour is exported as `0x` + 8 hex digits, with its byte order in the key +/// name. Anything else means a consumer has to guess, which is the whole thing +/// the format exists to prevent. +fn is_hex32(v: Option<&Value>) -> bool { + v.and_then(Value::as_str) + .is_some_and(|s| s.len() == 10 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())) +} + +fn check_pose(c: &mut Ctx, where_: &str, p: &Value) { + for (key, want_len) in [("pos", 2usize), ("scale", 2)] { + match p.get(key).and_then(Value::as_array) { + Some(a) if a.len() == want_len && a.iter().all(Value::is_i64) => {} + _ => c.err(format!("{where_}: `{key}` must be {want_len} integers")), + } + } + for key in ["tint_rgba", "fade_argb"] { + if !is_hex32(p.get(key)) { + c.err(format!("{where_}: `{key}` must be 0x + 8 hex digits")); + } + } +} + +fn check_screen(root: &Path, rel: &str, errors: &mut Vec) -> Result<()> { + let raw = std::fs::read_to_string(root.join(rel))?; + let v: Value = serde_json::from_str(&raw)?; + let mut c = Ctx { + file: rel.to_string(), + errors: Vec::new(), + }; + + if v.get("format").and_then(Value::as_str) != Some(SCREEN_FORMAT) { + c.err(format!( + "format is {:?}, expected {SCREEN_FORMAT:?}", + v.get("format") + )); + } + for key in ["exporter", "formats_rev", "name", "name_source"] { + c.require(&v, key); + } + // Rule 2 of the format: a modder must be able to tell a recovered name from + // an invented one, so the provenance is mandatory and closed. + match v.get("name_source").and_then(Value::as_str) { + Some("authored") => { + if v.get("name_why").and_then(Value::as_str).is_none_or(str::is_empty) { + c.err("name_source is `authored` but there is no `name_why`"); + } + } + Some("index") => {} + other => c.err(format!("name_source must be `authored` or `index`, got {other:?}")), + } + if let Some(s) = v.get("source") { + for key in ["archive", "entry", "build"] { + if s.get(key).is_none() { + c.err(format!("source is missing `{key}`")); + } + } + } else { + c.err("missing required field `source`"); + } + match v.get("design").and_then(Value::as_array) { + Some(d) if d.len() == 2 && d.iter().all(Value::is_u64) => {} + _ => c.err("`design` must be two positive integers"), + } + + let Some(elements) = v.get("elements").and_then(Value::as_array) else { + c.err("missing required field `elements`"); + errors.append(&mut c.errors); + return Ok(()); + }; + + let mut indices = Vec::new(); + let mut buttons_by_y: Vec<(i64, String)> = Vec::new(); + for (i, el) in elements.iter().enumerate() { + let id = el.get("id").and_then(Value::as_str).unwrap_or("").to_string(); + let at = format!("element {i} ({id})"); + for key in ["index", "id", "declared", "role", "kind_raw", "pivot", "layer_source", "keyframes"] { + if el.get(key).is_none() { + c.err(format!("{at}: missing `{key}`")); + } + } + let Some(idx) = el.get("index").and_then(Value::as_u64) else { + c.err(format!("{at}: `index` is not an integer")); + continue; + }; + if idx as usize != i { + c.err(format!("{at}: `index` {idx} does not match its position {i}")); + } + indices.push(idx as usize); + + let role = el.get("role").and_then(Value::as_str).unwrap_or(""); + if !matches!(role, "button" | "decoration" | "primitive" | "unknown") { + c.err(format!("{at}: role {role:?} is not one FORMAT.md defines")); + } + // A role of `unknown` must still carry the raw kind, or the information + // is simply lost. + if role == "unknown" && el.get("kind_raw").is_none() { + c.err(format!("{at}: role `unknown` without `kind_raw`")); + } + // A primitive has no texture, so its quad size has to come from the file. + if role == "primitive" && el.get("size").is_none() { + c.err(format!("{at}: primitive without a `size`")); + } + + match el.get("layer_source").and_then(Value::as_str) { + Some("sprite") | Some("implied") => { + if !is_hex32(el.get("layer")) { + c.err(format!("{at}: layer_source claims a key but `layer` is not one")); + } + } + Some("none") => { + if el.get("layer").is_some() { + c.err(format!("{at}: layer_source `none` but a `layer` is present")); + } + } + other => c.err(format!("{at}: layer_source must be sprite/implied/none, got {other:?}")), + } + + for key in ["sprite", "focus_sprite"] { + if let Some(p) = el.get(key).and_then(Value::as_str) { + let path = root.join(p); + if !path.exists() { + c.err(format!("{at}: `{key}` points at {p}, which does not exist")); + } else if let Err(e) = image::open(&path) { + c.err(format!("{at}: `{key}` {p} does not decode as an image: {e}")); + } + } + } + + if let Some(r) = el.get("rest") { + check_pose(&mut c, &at, r); + if role == "button" { + if let Some(y) = r.get("pos").and_then(Value::as_array).and_then(|a| a[1].as_i64()) { + buttons_by_y.push((y, id.clone())); + } + } + } + if let Some(kfs) = el.get("keyframes").and_then(Value::as_array) { + for (k, kf) in kfs.iter().enumerate() { + check_pose(&mut c, &format!("{at} keyframe {k}"), kf); + } + // The last keyframe of a group carries no time slot on the disc, and + // an invented one is exactly the kind of value this format refuses. + if kfs.len() > 1 && kfs.last().is_some_and(|k| k.get("t").is_some()) { + c.err(format!("{at}: the final keyframe has a `t`; the disc has no time slot there")); + } + } + } + + // Paint order must be a permutation of the element indices, or the runtime + // either drops an element or draws one twice. + match v.get("paint_order").and_then(Value::as_array) { + Some(po) => { + let mut got: Vec = po.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect(); + if got.len() != po.len() { + c.err("`paint_order` holds a non-integer"); + } + let mut want = indices.clone(); + got.sort_unstable(); + want.sort_unstable(); + if got != want { + c.err("`paint_order` is not a permutation of the element indices"); + } + } + None => c.err("missing required field `paint_order`"), + } + + // `buttons` is navigation order and is defined as resting Y, ascending. If + // it is not sorted, it is not the thing FORMAT.md says it is. + match v.get("buttons").and_then(Value::as_array) { + Some(b) => { + let listed: Vec<&str> = b.iter().filter_map(Value::as_str).collect(); + buttons_by_y.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + let want: Vec<&str> = buttons_by_y.iter().map(|(_, n)| n.as_str()).collect(); + if listed != want { + c.err(format!( + "`buttons` is {listed:?} but resting-Y order is {want:?}" + )); + } + } + None => c.err("missing required field `buttons`"), + } + + if v.get("unresolved").and_then(Value::as_array).is_none() { + c.err("missing required field `unresolved` (an empty list is a claim; absence is a gap)"); + } + + errors.append(&mut c.errors); + Ok(()) +} + +/// Validate a whole export tree. Returns the number of screens checked. +pub fn run(root: &Path) -> Result { + let manifest_path = root.join("manifest.json"); + if !manifest_path.exists() { + bail!("{} has no manifest.json — is that an export tree?", root.display()); + } + let m: Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path)?)?; + let mut errors = Vec::new(); + if m.get("format").and_then(Value::as_str) != Some(MANIFEST_FORMAT) { + errors.push(format!("manifest.json: format is not {MANIFEST_FORMAT:?}")); + } + for key in ["exporter", "formats_rev", "screens", "warnings"] { + if m.get(key).is_none() { + errors.push(format!("manifest.json: missing `{key}`")); + } + } + let screens = m + .get("screens") + .and_then(Value::as_array) + .map(|s| s.to_vec()) + .unwrap_or_default(); + for s in &screens { + let Some(file) = s.get("file").and_then(Value::as_str) else { + errors.push("manifest.json: a screen entry has no `file`".into()); + continue; + }; + if !root.join(file).exists() { + errors.push(format!("manifest.json: lists {file}, which does not exist")); + continue; + } + check_screen(root, file, &mut errors)?; + } + + if !errors.is_empty() { + for e in &errors { + eprintln!(" ✗ {e}"); + } + bail!("{} problem(s) in {}", errors.len(), root.display()); + } + Ok(screens.len()) +} diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index 7af3d3a0..b1810836 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -5,29 +5,214 @@ //! Vorbis and Ogg Theora, so the runtime — and anyone modding it — reads formats //! a person can open. //! +//! The output tree is **derived**: regenerated wholesale, never hand-edited. The +//! only thing this program takes from `authored/` is the screen-name map, and +//! every name it applies is stamped `name_source: "authored"` in the file it +//! lands in, so the export stays auditable against the disc. +//! //! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope. -use anyhow::Result; +mod check; +mod screen; + +use anyhow::{Context, Result}; use clap::Parser; -use std::path::PathBuf; +use serde::Serialize; +use std::path::{Path, PathBuf}; +use sylpheed_formats::{pak::PakArchive, ui_layout}; + +/// The revision of `sylpheed-formats` this exporter is pinned to, recorded in +/// every file it writes. Keep in step with `Cargo.toml` — it is what makes an +/// export auditable a month later. +const FORMATS_REV: &str = "8b6dbcf"; +const EXPORTER: &str = concat!("sylpheed-export ", env!("CARGO_PKG_VERSION")); #[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, + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(clap::Subcommand)] +enum Cmd { + /// Convert the disc into `export/`. Rewrites the tree wholesale. + Export { + /// Extracted disc root (the directory holding `dat/` and `hidden/`). + #[arg(long, env = "SYLPHEED_DISC")] + disc: PathBuf, + /// Output tree. Rewritten wholesale — never hand-edit it. + #[arg(long, default_value = "export")] + out: PathBuf, + /// Authored decisions applied during export (currently the screen names). + #[arg(long, default_value = "authored")] + authored: PathBuf, + }, + /// Validate an export tree against `docs/FORMAT.md`, with no disc in hand. + /// + /// Reads the tree the way the Godot project will: as a stranger, with no + /// access to the disc, the decoders or this exporter's internals. + Check { + #[arg(long, default_value = "export")] + out: PathBuf, + }, +} + +#[derive(Serialize)] +struct ManifestScreen { + name: String, + file: String, + sprites: usize, + #[serde(skip_serializing_if = "Vec::is_empty")] + missing_sprites: Vec, +} + +#[derive(Serialize)] +struct Manifest { + format: &'static str, + exporter: &'static str, + /// Which decoders produced this export. Pinned by revision, not floated. + formats_rev: &'static str, + disc: String, + screens: Vec, + warnings: Vec, +} + +/// The authored `build index → name` map, keyed by archive path. +type NameMap = std::collections::BTreeMap>; + +#[derive(serde::Deserialize)] +struct NameEntry { + name: String, + #[serde(default)] + why: Option, +} + +fn load_names(authored: &Path) -> Result { + let path = authored.join("screen_names.json"); + if !path.exists() { + return Ok(NameMap::new()); + } + #[derive(serde::Deserialize)] + struct File { + archives: NameMap, + } + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("read {}", path.display()))?; + Ok(serde_json::from_str::(&raw) + .with_context(|| format!("parse {}", path.display()))? + .archives) +} + +/// Every RATC entry of a UI pak that parses as a screen build. +/// +/// The filter is `is_build` — a bundle with a `.rat` layout child. The developer +/// splash declares its sprites directly and has none, so it is invisible here; +/// that is P3's problem and is recorded as a manifest warning rather than +/// silently widened. +fn screen_builds(ar: &PakArchive) -> Vec<(usize, Vec)> { + let mut out = Vec::new(); + for (i, e) in ar.entries().iter().enumerate() { + let Ok(bytes) = ar.read(e) else { continue }; + if ui_layout::is_build(&bytes) { + out.push((i, bytes)); + } + } + out } 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"); + match Args::parse().cmd { + Cmd::Export { + disc, + out, + authored, + } => run_export(&disc, &out, &authored), + Cmd::Check { out } => { + let n = check::run(&out)?; + println!("{} screen(s) in {} validate against sylpheed.screen/2", n, out.display()); + Ok(()) + } + } +} + +fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { + let names = load_names(authored_dir)?; + + // Derived output is regenerated wholesale: clear it, so a screen that stops + // being exported stops existing rather than lingering as a stale file that + // still validates. + if out.exists() { + std::fs::remove_dir_all(&out).context("clear the output tree")?; + } + std::fs::create_dir_all(&out)?; + + let archive = "dat/GP_TITLE.pak"; + let pak = disc.join(archive); + let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?; + let builds = screen_builds(&ar); + println!("{archive}: {} screen build(s)", builds.len()); + + let archive_names = names.get(archive); + let mut screens = Vec::new(); + for (build_idx, (entry, bytes)) in builds.iter().enumerate() { + let authored = archive_names.and_then(|m| m.get(&build_idx.to_string())); + let (name, name_source, why) = match authored { + Some(e) => (e.name.clone(), "authored", e.why.clone()), + // Nobody has identified this build. Emit a stable synthetic id and + // say in the file that the name is not a recovered one. + None => (format!("build_{build_idx:02}"), "index", None), + }; + let ex = screen::export_build( + &out, + archive, + *entry, + build_idx, + bytes, + &name, + name_source, + why, + "title", + EXPORTER, + FORMATS_REV, + ) + .with_context(|| format!("export build {build_idx} of {archive}"))?; + println!( + " [{build_idx}] entry {entry:<3} -> {} ({} sprites{})", + ex.json_path, + ex.sprites, + if ex.missing.is_empty() { + String::new() + } else { + format!(", {} missing", ex.missing.len()) + } + ); + screens.push(ManifestScreen { + name: ex.name, + file: ex.json_path, + sprites: ex.sprites, + missing_sprites: ex.missing, + }); + } + + let manifest = Manifest { + format: "sylpheed.manifest/1", + exporter: EXPORTER, + formats_rev: FORMATS_REV, + disc: disc.display().to_string(), + screens, + warnings: vec![ + "P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive." + .into(), + "The developer-logo splash is not here: it declares its sprites directly and has \ + no .rat layout child, so `is_build` does not see it. P3." + .into(), + ], + }; + std::fs::write( + out.join("manifest.json"), + format!("{}\n", serde_json::to_string_pretty(&manifest)?), + )?; + println!("wrote {}/manifest.json", out.display()); Ok(()) } diff --git a/crates/sylpheed-export/src/screen.rs b/crates/sylpheed-export/src/screen.rs new file mode 100644 index 00000000..6f24ac95 --- /dev/null +++ b/crates/sylpheed-export/src/screen.rs @@ -0,0 +1,351 @@ +//! One UI build → one `sylpheed.screen/2` JSON document plus its sprite PNGs. +//! +//! Everything here is **derived**: it is what the bundle says, restated in a +//! format Godot can read. The two places a value is not read off the disc are +//! marked in the output itself — `name_source` when a screen's name came from +//! `authored/`, and `layer_source: "implied"` when the paint-order key came from +//! the decoders' measured table rather than from a `T8aD` header. A consumer can +//! tell the difference without reading this file. + +use anyhow::{Context, Result}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::Path; +use sylpheed_formats::{t8ad, ui_layout}; + +/// `elements[].role`, from the decoded element kind. +/// +/// ⚠️ `0x3002` is one member of a `0x3000` family and is **not** a general +/// button test — `GP_READY_ROOM` uses `0x3000`/`0x3004`/`0x300c`/`0x3008` and +/// has zero `0x3002`. Every screen in this milestone is `GP_TITLE`, where the +/// mapping is decoded; anything else exports as `unknown` with its raw kind. +fn role_of(kind: u32, has_sprite: bool) -> &'static str { + match kind { + 0x3002 => "button", + 0x10 if !has_sprite => "primitive", + 0x0 => "decoration", + _ => "unknown", + } +} + +#[derive(Serialize)] +pub struct Source { + /// Path of the archive within the disc root. + pub archive: String, + /// Pak **entry index** — the stable locator, not the display ordinal. + pub entry: usize, + /// Index into this pak's list of screen builds (what `screen --build` takes). + pub build: usize, +} + +/// A placement keyframe, carrying the on-disc time verbatim. +/// +/// `t` is in the disc's own units and is deliberately **not** converted here: +/// the seconds conversion is measured off the running game, not read from the +/// file, so it lives in `authored/timing.json` and is applied in exactly one +/// place. See HANDOFF Q1. +#[derive(Serialize)] +pub struct Keyframe { + /// On-disc time, absent on the final keyframe of a group — which carries no + /// time slot at all. Absent, never invented. + #[serde(skip_serializing_if = "Option::is_none")] + pub t: Option, + /// Top-left of the element at 1:1. Signed: elements animate in from off-screen. + pub pos: [i32; 2], + /// Percent, per axis. Scale grows the element **about its pivot**, not about + /// `pos` — at 100 % the two are identical, which is why it went unnoticed. + pub scale: [u32; 2], + /// Modulate colour, **RGBA** byte order. `0xffffffff` on essentially every + /// keyframe on the disc. + pub tint_rgba: String, + /// The second modulate colour, **ARGB** byte order — the high byte is the + /// alpha that ramps during a fade. Multiplies with `tint_rgba`. + pub fade_argb: String, +} + +#[derive(Serialize)] +pub struct Rest { + pub pos: [i32; 2], + pub scale: [u32; 2], + pub tint_rgba: String, + pub fade_argb: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub t: Option, +} + +#[derive(Serialize)] +pub struct Element { + /// Declaration index — the key the placement region and `paint_order` use. + pub index: usize, + /// The declared name with its extension stripped; stable within a screen. + pub id: String, + /// The name exactly as the declaration table spells it. + pub declared: String, + pub role: &'static str, + pub kind_raw: String, + /// Sprite PNG, relative to `export/`. Absent for an untextured primitive. + #[serde(skip_serializing_if = "Option::is_none")] + pub sprite: Option, + /// The highlighted-state sprite: this element's sprite with an `f` before + /// the extension, when the bundle carries one — `ptbtn01.t32` ↔ + /// `ptbtn01f.t32`. 🟡 **A naming convention, not a decoded field.** It holds + /// for all 54 real pairs on the disc (HANDOFF), and it is the only link + /// between a button and its highlight that has survived checking. + #[serde(skip_serializing_if = "Option::is_none")] + pub focus_sprite: Option, + /// The raw `opt ` link inside this element's `.rat` record. + /// + /// ⚠️ **This is not a focus link.** It was read as one, and that was + /// measured and refuted (HANDOFF, `ui-focus-and-effect-elements.md`) — on the + /// main menu it chains `ptloop01 → ptloop02 → ptbtn01`, across two + /// decorations and into a button. It is carried through unresolved and + /// unnamed so that whoever decodes it has it, and so that nothing downstream + /// mistakes it for navigation. + #[serde(skip_serializing_if = "Option::is_none")] + pub opt_link: Option, + pub pivot: [u32; 2], + /// Untextured primitives have no texture to take a size from; the quad is + /// `pivot × 2`, which is 1280×720 for 361 of the disc's 369 primitives. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option<[u32; 2]>, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent: Option, + /// Paint-order key. `"sprite"` = read from the `T8aD` header at `+0x0A`. + /// `"implied"` = **measured off the running game**, for elements that carry + /// no header. `"none"` = neither; sorts last. + pub layer_source: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub layer: Option, + /// This element is another element's focused state, not a screen element. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub focused: bool, + /// A `loopN` sprite animation rather than a placed element. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub animated: bool, + /// 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. + #[serde(skip_serializing_if = "Option::is_none")] + pub rest: Option, + pub keyframes: Vec, +} + +#[derive(Serialize)] +pub struct Screen { + pub format: &'static str, + pub exporter: String, + /// Revision of `sylpheed-formats` whose decoders produced this file. + pub formats_rev: &'static str, + pub source: Source, + pub name: String, + /// `"authored"` when the name came from `authored/screen_names.json`, + /// `"index"` when nobody has named this build yet. + pub name_source: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub name_why: Option, + pub design: [u32; 2], + pub elements: Vec, + /// Back-to-front paint order as declaration indices, from the decoded `u16` + /// layer key at `+0x0A` of each sprite header, stable-sorted so equal keys + /// keep declaration order. See `unresolved: paint_order_ties`. + pub paint_order: Vec, + /// Navigation order: `button`-role elements sorted by resting Y. + /// **Geometric, not a decoded neighbour graph** — right for a vertical menu + /// and not to be trusted for anything else. + pub buttons: Vec, + /// What this file does not answer. A consumer needing one of these must get + /// it from `authored/`. + pub unresolved: Vec<&'static str>, +} + +fn hex32(v: u32) -> String { + format!("0x{v:08x}") +} + +/// Strip the extension the declaration table spells, giving a stable id. +pub fn id_of(declared: &str) -> String { + declared + .rsplit_once('.') + .map(|(stem, _)| stem) + .unwrap_or(declared) + .to_string() +} + +/// What one screen's export produced, for the manifest. +pub struct Exported { + pub name: String, + pub json_path: String, + pub sprites: usize, + /// Sprites an element named that did not resolve or decode. + pub missing: Vec, +} + +/// Convert one build to JSON on disk, writing its sprite PNGs beside it. +/// +/// `sprite_dir` is per-screen: a sprite name is unique within a bundle but not +/// across builds, and two screens' `ptbase.t32` are different pictures. +#[allow(clippy::too_many_arguments)] +pub fn export_build( + out: &Path, + archive: &str, + entry: usize, + build_idx: usize, + bundle: &[u8], + name: &str, + name_source: &'static str, + name_why: Option, + subdir: &str, + exporter: &str, + formats_rev: &'static str, +) -> Result { + let b = ui_layout::parse_build(bundle).context("build did not parse")?; + + // Every sprite an element actually references, decoded once and written as a + // PNG under this screen's own directory. + let sprite_rel = |sprite: &str| format!("sprites/{subdir}/{name}/{}.png", id_of(sprite)); + let sprite_dir = out.join("sprites").join(subdir).join(name); + std::fs::create_dir_all(&sprite_dir)?; + let mut written: BTreeMap = BTreeMap::new(); + let mut missing = Vec::new(); + let mut write_sprite = |sprite: &str| -> Result { + if written.contains_key(sprite) { + return Ok(true); + } + let Some(&(off, size)) = b.sprites.get(sprite) else { + return Ok(false); + }; + let Some(img) = t8ad::parse(&bundle[off..off + size]) else { + return Ok(false); + }; + let buf = image::RgbaImage::from_raw(img.width, img.height, img.rgba) + .context("T8aD dimensions disagree with its pixel count")?; + buf.save(sprite_dir.join(format!("{}.png", id_of(sprite))))?; + written.insert(sprite.to_string(), ()); + Ok(true) + }; + + /// The highlighted twin of a sprite name: `ptbtn01.t32` → `ptbtn01f.t32`. + fn highlight_name(sprite: &str) -> Option { + let (stem, ext) = sprite.rsplit_once('.')?; + Some(format!("{stem}f.{ext}")) + } + + let mut elements = Vec::new(); + for el in &b.elements { + let mut sprite_out = None; + if let Some(s) = &el.sprite { + if write_sprite(s)? { + sprite_out = Some(sprite_rel(s)); + } else { + missing.push(s.clone()); + } + } + // The highlight pairs by NAME on the sprite, not through the `opt ` + // link: `opt ` is refuted as a focus link and points somewhere else + // entirely on half these elements. + let mut focus_sprite = None; + if let Some(h) = el.sprite.as_deref().and_then(highlight_name) { + if b.sprites.contains_key(&h) && write_sprite(&h)? { + focus_sprite = Some(sprite_rel(&h)); + } + } + + let (layer, layer_source) = match ui_layout::sprite_layer_key(&b, bundle, el) { + Some(k) => (Some(hex32(k)), "sprite"), + None => match ui_layout::implied_layer_key(&el.name) { + Some(k) => (Some(hex32(k)), "implied"), + None => (None, "none"), + }, + }; + + let kf = |k: &ui_layout::Keyframe| Keyframe { + t: k.time, + pos: [k.x, k.y], + scale: [k.scale_x, k.scale_y], + tint_rgba: hex32(k.tint), + fade_argb: hex32(k.fade), + }; + let role = role_of(el.kind, el.sprite.is_some()); + elements.push(Element { + index: el.index, + id: id_of(&el.name), + declared: el.name.clone(), + role, + kind_raw: format!("{:#x}", el.kind), + sprite: sprite_out, + focus_sprite, + opt_link: el.focus_link.clone(), + pivot: [el.pivot_x, el.pivot_y], + size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]), + parent: el.parent, + layer_source, + layer, + focused: el.focused, + animated: el.animated, + rest: el.rest().map(|k| Rest { + pos: [k.x, k.y], + scale: [k.scale_x, k.scale_y], + tint_rgba: hex32(k.tint), + fade_argb: hex32(k.fade), + t: k.time, + }), + keyframes: el.keyframes.iter().map(kf).collect(), + }); + } + + // Navigation order is geometric: buttons top-to-bottom by resting Y. A + // focused-state record is not itself a menu item. + let mut buttons: Vec<(i32, String)> = b + .elements + .iter() + .filter(|e| e.kind == 0x3002 && !e.focused) + .filter_map(|e| e.rest().map(|k| (k.y, id_of(&e.name)))) + .collect(); + buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + + let screen = Screen { + format: "sylpheed.screen/2", + exporter: exporter.to_string(), + formats_rev, + source: Source { + archive: archive.to_string(), + entry, + build: build_idx, + }, + name: name.to_string(), + name_source, + name_why, + design: [b.design_w, b.design_h], + elements, + paint_order: ui_layout::derived_paint_order(&b, bundle), + buttons: buttons.into_iter().map(|(_, n)| n).collect(), + unresolved: vec![ + // The time unit is measured off the running game, not on the disc. + "keyframe_time_unit", + // Where two elements share a layer key the game's order is + // unexplained; eight candidates refuted. Costs one element's blend + // on one screen. + "paint_order_ties", + // The last keyframe of a group carries no time slot, so the + // fade-OUT length is not in the file. + "fade_out_duration", + ], + }; + + let dir = out.join("screens").join(subdir); + std::fs::create_dir_all(&dir)?; + let json_path = format!("screens/{subdir}/{name}.json"); + std::fs::write( + out.join(&json_path), + format!("{}\n", serde_json::to_string_pretty(&screen)?), + )?; + + missing.sort(); + missing.dedup(); + Ok(Exported { + name: name.to_string(), + json_path, + sprites: written.len(), + missing, + }) +} From 9847adf383da0989e4a263f9ec4dae0e5af375fe Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 18:43:52 +0000 Subject: [PATCH 06/29] docs: FORMAT v2, what P0 decided, and BLOCKED reconciled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FORMAT is bumped to v2 with a "Changes from v1" table giving a reason per row. v1 was written before HANDOFF answered Q1 and Q3 and before the two-colour modulate was known; each change is a thing v1 could not have said. The paint order moves from `unresolved` into the export, because Q3 decoded it — a u16 layer key at +0x0A, stable-sorted — and a decoded answer is read in the exporter. `paint_order_ties` replaces it, because the tie-break is still unknown. Where a layer key is not in the file it 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". BLOCKED is reconciled against HANDOFF at /reborn d69272e. Seven of its ten rows are answered and are moved out; what remains is Q8 (no cue-to-event binding), the two Q10 unknowns (which BGM, and where a loop restarts), Q9's unsettled skippability, Q4's untested NEW GAME, and Q6's undecoded boot driver. It also raises one question back, found by counting the export rather than by reverse engineering anything: the decoders document a .t32 element's pivot as "exactly half the decoded texture's dimensions (verified 7/7 on the tutorial bundle)", and on GP_TITLE that holds for 55 of 93 sprite-bearing .t32 elements. 38 do not, some grossly — ptlogo_back2 is 1118x262 with pivot (500,117) where half is (559,131). It changes nothing today, because the exporter emits the declared pivot and the pivot only matters when scale != 100%. But scale IS animated here — 177 keyframes across GP_TITLE are not 100%, including on the title screen P1 has to draw — so the question of what the running game anchors a scale to is worth an answer before P2. Noted there that the port and `sylpheed-cli screen render` make the same choice, so a P1 diff cannot distinguish them and their agreement is not evidence. --- docs/BLOCKED.md | 77 ++++++++++++++---- docs/DECISIONS.md | 116 +++++++++++++++++++++++++++ docs/FORMAT.md | 200 +++++++++++++++++++++++++++++++++++++--------- 3 files changed, 340 insertions(+), 53 deletions(-) create mode 100644 docs/DECISIONS.md diff --git a/docs/BLOCKED.md b/docs/BLOCKED.md index ec94dc81..2812e542 100644 --- a/docs/BLOCKED.md +++ b/docs/BLOCKED.md @@ -4,20 +4,67 @@ 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. +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-28**, at `/reborn` HEAD `e81dcad`. + +## Still open — these block work + +| Milestone | Needs | HANDOFF | State | +|---|---|---|---| +| P6 audio | which cue fires on move / confirm / back | Q8 | ❔ open. The cue table is complete; the **event binding is not**. P6 cannot bind a sound to a keypress without inventing it. | +| 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). + +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 | + +## Questions this port has raised + +Not blocking anything today; raised because the port found them and a guess here +would be believed later. + +### 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. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 00000000..d4279002 --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,116 @@ +# 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///.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. diff --git a/docs/FORMAT.md b/docs/FORMAT.md index b5189206..91ce0115 100644 --- a/docs/FORMAT.md +++ b/docs/FORMAT.md @@ -1,9 +1,9 @@ -# The open export format — v1 +# The open export format — v2 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. +modding tool read. **It is versioned, so a change is a deliberate act with a +version bump**, not a silent edit. [Changes from v1](#changes-from-v1) is at the +bottom, with a reason for each. Design rules, in priority order: @@ -14,92 +14,196 @@ Design rules, in priority order: 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. + 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/*.png + sprites/title//*.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 - 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. +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/1", + "format": "sylpheed.screen/2", "exporter": "sylpheed-export 0.1.0", - "source": { "archive": "dat/GP_TITLE.pak", "entry": 5 } + "formats_rev": "8b6dbcf", + "source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 } } ``` -`source.entry` is the pak **entry index** — the stable locator. Not the display -ordinal, which renumbers whenever the enumeration rule changes. +`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/1", + "format": "sylpheed.screen/2", "exporter": "sylpheed-export 0.1.0", - "source": { "archive": "dat/GP_TITLE.pak", "entry": 5 }, + "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", - "sprite": "sprites/ptbtn01.png", - "focus_sprite": "sprites/ptbtn01f.png", + "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], - "rest": { "pos": [542, 162], "scale": [1.0, 1.0], "tint": "#ffffffff" }, + "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] }, - { "t": 34, "pos": [542, 157] }, - { "t": 64, "pos": [542, 162] } + { "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": ["paint_order", "keyframe_time_unit"] + "unresolved": ["keyframe_time_unit", "paint_order_ties", "fade_out_duration"] } ``` -**`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. +### `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. + +**`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. + +**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`. + +**`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 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. +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/`. +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` @@ -129,11 +233,31 @@ reaches which entry is Q4 and is not). "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"] } ``` -`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. +`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 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///*.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. | From 39ad4d7e60bd335ce97fc8252289b85bdca74de4 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 19:00:26 +0000 Subject: [PATCH 07/29] docker: fix build-export for the subcommand, and build the reference CLI at the pin Two tooling fixes, both needed before P1 can diff anything. `build-export` was calling `sylpheed-export --disc ... --out export`, which P0 broke when it moved the exporter behind `export` / `check` subcommands. It is a regression from f6fc269 and this is the fix. It now also runs `check` after exporting, because `export` writes a tree and `check` is the only thing that says the tree is readable by something other than the program that wrote it. `build-reference-cli` is new. `sylpheed-cli screen render` is the reference the Godot port gets diffed against, and the only copy of it in the container is /reborn/target/release, which has two problems: the release binary predates the `screen` subcommand entirely, and /reborn is a LIVE mount of the other agent's working tree -- it moved from d69272e to 51096ae during a single iteration. A reference renderer that runs different decoders than the exporter puts a free variable in every pixel diff, so this builds the CLI from the same pinned revision the exporter uses and drops it on the persistent target volume. Both are COPY'd into the image at Dockerfile:76, so neither takes effect in a running container until the image is rebuilt. The reference CLI is already built into the target volume by hand, so P1 is not blocked in the meantime. --- docker/bin/build-export | 10 ++++++-- docker/bin/build-reference-cli | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100755 docker/bin/build-reference-cli diff --git a/docker/bin/build-export b/docker/bin/build-export index 3e4ebfe7..6102bde3 100755 --- a/docker/bin/build-export +++ b/docker/bin/build-export @@ -2,7 +2,12 @@ # Build and run the exporter against the disc. # # build-export build only -# build-export --run build, then export to ./export +# build-export --run build, export to ./export, then validate it +# +# The validate step is not optional politeness: `export` writes a tree and +# `check` is the only thing that says the tree is readable by anything other +# than the program that wrote it. A build that exports and does not check has +# not shown anything. # # 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". @@ -13,5 +18,6 @@ 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 "$@" + "$CARGO_TARGET_DIR/release/sylpheed-export" export --disc "$disc" --out export "$@" + exec "$CARGO_TARGET_DIR/release/sylpheed-export" check --out export fi diff --git a/docker/bin/build-reference-cli b/docker/bin/build-reference-cli new file mode 100755 index 00000000..c7356f27 --- /dev/null +++ b/docker/bin/build-reference-cli @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Build `sylpheed-cli` from the SAME revision of sylpheed-formats the exporter +# is pinned to, and put it on the persistent target volume. +# +# build-reference-cli -> $CARGO_TARGET_DIR/release/sylpheed-cli +# +# Why not just use /reborn/target/release/sylpheed-cli: that binary is built +# from whatever /reborn's working tree is at, which is a LIVE mount of the other +# agent's checkout and moves under you mid-iteration. `sylpheed-cli screen +# render` is the reference the Godot port is diffed against, so if it runs +# different decoders than the exporter, a pixel disagreement has a free variable +# in it and proves nothing about the port. +# +# The pinned source lives in CARGO_HOME, which is on the container overlay and +# does not survive a fresh container -- cargo re-fetches it. The BINARY goes to +# CARGO_TARGET_DIR, which is a volume, so this is a one-off per image. +# +# Jobs are capped for the same reason as build-export. +set -euo pipefail +cd "${PROJECT_DIR:-/work}" +export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-3}" + +rev=$(sed -n 's/.*Syplheed-Reborn\.git", rev = "\([0-9a-f]*\)".*/\1/p' \ + crates/sylpheed-export/Cargo.toml | head -1) +[ -n "$rev" ] || { echo "build-reference-cli: no rev pin found in Cargo.toml" >&2; exit 1; } + +# The checkout only exists once cargo has fetched it; a fresh container has not. +find_checkout() { + find "${CARGO_HOME:?}/git/checkouts" -maxdepth 2 -type d -name "${rev}*" 2>/dev/null | head -1 +} +src=$(find_checkout) +if [ -z "$src" ]; then + echo "build-reference-cli: fetching the pinned decoders ($rev)" + cargo fetch + src=$(find_checkout) +fi +[ -n "$src" ] || { echo "build-reference-cli: no checkout for rev $rev" >&2; exit 1; } + +echo "build-reference-cli: building sylpheed-cli from $rev" +cargo build --release --manifest-path "$src/Cargo.toml" -p sylpheed-cli + +out="$CARGO_TARGET_DIR/release/sylpheed-cli" +"$out" screen list "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" >/dev/null \ + || { echo "build-reference-cli: built, but 'screen list' failed" >&2; exit 1; } +echo "build-reference-cli: $out (rev $rev, 'screen' subcommand present)" From 67136dc33e521289375696d41f5e7bb3d11d2606 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 28 Aug 2026 21:07:13 +0200 Subject: [PATCH 08/29] port: fix the credential mount, the /reborn pull, and cargo re-fetching Three fixes from the port agent's first infrastructure report. **A. The credential file is written, so it cannot be a read-only mount.** `credential.helper=store` rewrites its file after a successful auth: temp file, then rename over the target. Renaming onto a bind-mount point gives EBUSY, which surfaces as `fatal: unable to write credential store: Device or resource busy`. The push succeeds anyway, and that is the real hazard -- a `fatal:` line that is routinely wrong teaches the reader to ignore the one that is real. It also fired intermittently, so it read as flakiness rather than as a mount. Fixed by mirroring the pattern already used for .claude.json: mount it as `.git-credentials.host:ro` and have the entrypoint copy it to a writable ~/.git-credentials at 600. Mounting rw would also silence it, but then the container can clobber the host's real credential file; copying cannot. **B. `git -C /reborn pull` can never work, and should not.** /reborn is a live read-only mount of the RE agent's working tree -- it updates itself, and pulling would move another agent's checkout. The prompt now says so, and adds the consequence the agent found the hard way: because the mount is live, HANDOFF can move mid-iteration, so anything copied out of it (BLOCKED.md especially) may already be stale and must be re-checked rather than trusted. **C.** CARGO_HOME moves to a named volume; it was on the container overlay, so the pinned decoder source was re-fetched from the network on every fresh start. Also adds SYLPH_PORT_REPO, so this launcher can be run from a worktree without repointing the agent's checkout -- which is how these edits were made, the agent being mid-iteration on auto/p0-exporter in the shared tree. Co-Authored-By: Claude Opus 5 --- docker/entrypoint.sh | 8 ++++++++ docker/sylph-port | 20 ++++++++++++++++++-- docs/loop-task.md | 12 ++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 2ee5700c..73969dc5 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -32,6 +32,14 @@ fi if [ -f "$HOME/.claude.host.json" ] && [ ! -s "$HOME/.claude.json" ]; then cp "$HOME/.claude.host.json" "$HOME/.claude.json" 2>/dev/null || true fi +# Same reason as .claude.json above: `credential.helper=store` rewrites this +# file by rename-over-target, which fails with EBUSY on a bind mount. Copy it to +# a writable path; nothing is ever written back to the host's file. +if [ -f "$HOME/.git-credentials.host" ]; then + cp "$HOME/.git-credentials.host" "$HOME/.git-credentials" 2>/dev/null || true + chmod 600 "$HOME/.git-credentials" 2>/dev/null || true +fi + CLAUDE_VER=$(claude --version 2>/dev/null | grep -oE '^[0-9][0-9.]*' || echo 0.0.0) python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER" \ "$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true diff --git a/docker/sylph-port b/docker/sylph-port index e0c94aab..28af6b5b 100755 --- a/docker/sylph-port +++ b/docker/sylph-port @@ -11,6 +11,7 @@ # # Env: # SYLPH_PORT_CPUS / SYLPH_PORT_MEM_GB override the cap (default 3 / 4) +# SYLPH_PORT_REPO repo to mount at /work (default: this script's parent) # SYLPH_REBORN path to the Syplheed-Reborn checkout (read-only mount) # SYLPH_DISC extracted disc root # SYLPH_GIT_CREDENTIALS file with `https://:@host` for push-work @@ -30,7 +31,9 @@ set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO="$(cd "$HERE/.." && pwd)" +# The repo to mount at /work. Overridable so this script can be run from a +# worktree -- a human editing on `main` must not repoint the agent's checkout. +REPO="${SYLPH_PORT_REPO:-$(cd "$HERE/.." && pwd)}" IMAGE="${SYLPH_PORT_IMAGE:-sylpheed-port:latest}" NAME="${SYLPH_PORT_NAME:-sylpheed-port}" @@ -55,6 +58,9 @@ docker_args() { --pids-limit 2048 -v "$REPO:/work" -v "sylpheed-port-target:/sylph-home/port/target-container" + # CARGO_HOME on a volume, not the container overlay: without it the pinned + # decoder source is re-fetched from the network on every fresh container. + -v "sylpheed-port-cargo:/sylph-home/port/.cargo" -v "${SYLPH_CLAUDE_HOME:-$HOME/.claude}:/sylph-home/port/.claude" -v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/port/.claude.host.json:ro" -e "PROJECT_DIR=/work" @@ -86,9 +92,19 @@ docker_args() { -e "GIT_COMMITTER_EMAIL=port-agent@localhost" ) + # Mounted as `.host` and copied to a writable file by the entrypoint, exactly + # like .claude.json. `credential.helper=store` REWRITES its file after a + # successful auth -- it writes a temp file and renames over the target, and + # renaming onto a bind-mount point gives EBUSY, which surfaces as + # `fatal: unable to write credential store: Device or resource busy`. + # + # The push still succeeds, which is the actual danger: a `fatal:` line that is + # routinely wrong teaches the reader to ignore the one that is real. Mounting + # rw would also silence it, but then the container can clobber the host's + # credential file; copying cannot. local gitcred="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}" if [ -f "$gitcred" ]; then - _out+=(-v "$gitcred:/sylph-home/port/.git-credentials:ro") + _out+=(-v "$gitcred:/sylph-home/port/.git-credentials.host:ro") else echo "==> NOTE: no git credentials at $gitcred — the agent cannot push," >&2 echo " so its work dies with the container." >&2 diff --git a/docs/loop-task.md b/docs/loop-task.md index 5e5471cf..395115a5 100644 --- a/docs/loop-task.md +++ b/docs/loop-task.md @@ -16,8 +16,16 @@ believed later. If you need an answer the disc has not given you, write it in 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. + 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. From 11eac797a0f38c50465eae5ca8a54d6b28fda79f Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 28 Aug 2026 21:15:24 +0200 Subject: [PATCH 09/29] port: push-work must not configure the repository it runs in It set `git config --local credential.helper "store --file=$HOME/..."`, which PERSISTS in the repository. The repo is a bind mount the host also uses, so the host's git inherited `store --file=/sylph-home/port/.git-credentials` -- a path that exists only inside the container -- and every host push then failed with `unable to get credential storage lock: No such file or directory`. Same root as the EBUSY credential error, from the other side: a tool that configures a shared repository to suit itself breaks every other user of that repository. Now applied with `-c` to the single push invocation. Cleared the leaked setting from both checkouts by hand. Syplheed-Reborn had it too, from the RE container's copy of this script. Co-Authored-By: Claude Opus 5 --- docker/bin/push-work | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docker/bin/push-work b/docker/bin/push-work index 74ac8a28..5a1d13b3 100755 --- a/docker/bin/push-work +++ b/docker/bin/push-work @@ -49,8 +49,17 @@ if [ ! -s "$HOME/.git-credentials" ]; then 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" +# Applied to THIS COMMAND ONLY, via `-c`, never `git config --local`. +# +# Writing it to --local config persists it in the repository, and this repo is a +# bind mount the host also uses -- so the host's git inherited +# `store --file=/sylph-home/port/.git-credentials`, a path that exists only +# inside the container, and every host push then failed with +# `unable to get credential storage lock: No such file or directory`. +# +# A tool that configures a shared repository to suit itself breaks every other +# user of that repository. Keep it to the invocation. +CRED_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" @@ -63,5 +72,5 @@ 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" +git -c "credential.helper=$CRED_HELPER" push --set-upstream origin "$branch" echo "push-work: pushed $branch" From 19ad10e49c421864eb49b5f368b55fe531035ffc Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 19:31:03 +0000 Subject: [PATCH 10/29] port: Godot draws an exported screen at its resting pose P1. The project reads only `export/` -- the manifest, a screen's JSON and its PNGs -- and draws every element at `rest`, in the export's own `paint_order`. Three choices worth the words: * `ExportTree` addresses screens by manifest NAME, never by path, and checks `format` on the manifest and on each screen before drawing. Textures are decoded from bytes at runtime rather than Godot-imported: `export/` is gitignored and regenerated wholesale, and a `.import` per sprite would be derived state next to derived state, invalidated on every re-export. * One CanvasItem draws the whole screen. `paint_order` is already back-to-front, so honouring it is a loop; spreading it across sixteen nodes' z-indices would hide the one unresolved thing about that order -- the ties -- behind Godot's sibling rules. * The screen renders into a SubViewport sized to the export's `design` rect. Capturing the window instead gave 1280x720 of screen minus a window manager's title bar: 1235x695. A gate that rescales that to compare against a 1280x720 composite is measuring the compositor. Nearest-neighbour filtering, because the export is a 1:1 copy of the disc's texels, elements draw at up to 500 %, and it is what `ui_layout::blit` does -- so a filter difference cannot masquerade as a placement difference in the diff. No keyframe interpolation and no focus state: both depend on constants that are MEASURED rather than decoded (HANDOFF Q1, Q5), and a pixel-diff gate must not have one of those inside it. P2 and P5. --- port/project.godot | 7 ++ port/scenes/boot.tscn | 6 ++ port/scripts/boot.gd | 97 ++++++++++++++++++++ port/scripts/boot.gd.uid | 1 + port/scripts/export_tree.gd | 95 ++++++++++++++++++++ port/scripts/export_tree.gd.uid | 1 + port/scripts/screen_view.gd | 152 ++++++++++++++++++++++++++++++++ port/scripts/screen_view.gd.uid | 1 + 8 files changed, 360 insertions(+) create mode 100644 port/scenes/boot.tscn create mode 100644 port/scripts/boot.gd create mode 100644 port/scripts/boot.gd.uid create mode 100644 port/scripts/export_tree.gd create mode 100644 port/scripts/export_tree.gd.uid create mode 100644 port/scripts/screen_view.gd create mode 100644 port/scripts/screen_view.gd.uid diff --git a/port/project.godot b/port/project.godot index 6cf99ddb..b38d1c8b 100644 --- a/port/project.godot +++ b/port/project.godot @@ -18,3 +18,10 @@ window/size/viewport_width=1280 window/size/viewport_height=720 window/stretch/mode="canvas_items" window/stretch/aspect="keep" + +[rendering] + +; The screens carry their own background; anything the export does not paint is +; black, which is what `sylpheed-cli screen render --black` composites over and +; therefore what a capture is comparable against. +environment/defaults/default_clear_color=Color(0, 0, 0, 1) diff --git a/port/scenes/boot.tscn b/port/scenes/boot.tscn new file mode 100644 index 00000000..09782733 --- /dev/null +++ b/port/scenes/boot.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/boot.gd" id="1"] + +[node name="Boot" type="Node"] +script = ExtResource("1") diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd new file mode 100644 index 00000000..77628610 --- /dev/null +++ b/port/scripts/boot.gd @@ -0,0 +1,97 @@ +# Entry point. +# +# P1 shows one exported screen, statically, so that its pixels can be diffed +# against `sylpheed-cli screen render` of the same build. The boot sequence +# proper (splash -> intro -> title -> menu) is P3 and is not here. +# +# godot --path port -- --screen=main_menu +# godot --path port -- --screen=main_menu --capture=/tmp/godot.png +# +# 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. That is +# the same separation the project settings already make -- design space is +# fixed, the window is not -- and it makes `--capture` exact: the PNG is the +# design rectangle itself, never the window, so it is directly comparable with +# `screen render`'s composite with no cropping or rescaling. +extends Node + +const DEFAULT_SCREEN := "main_menu" + +var view: ScreenView = null +var viewport: SubViewport = null + + +func _ready() -> void: + var args := _args() + var export_tree := ExportTree.locate() + if export_tree.root == "": + push_error(export_tree.error) + get_tree().quit(2) + return + + var name: String = args.get("screen", DEFAULT_SCREEN) + var screen: Dictionary = export_tree.screen(name) + if screen.is_empty(): + push_error(export_tree.error) + print("screens in this export: ", ", ".join(export_tree.screen_names())) + get_tree().quit(2) + return + var design: Array = screen.get("design", [1280, 720]) + + var container := SubViewportContainer.new() + container.stretch = true + container.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(container) + + viewport = SubViewport.new() + viewport.size = Vector2i(int(design[0]), int(design[1])) + viewport.transparent_bg = false + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + container.add_child(viewport) + + view = ScreenView.new() + # The export is a 1:1 copy of the disc's texels and elements are drawn at up + # to 500 %. Nearest 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. + view.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + view.focused_id = args.get("focus", "") + viewport.add_child(view) + + if not view.load_screen(export_tree, name): + push_error(export_tree.error) + get_tree().quit(2) + return + + print("screen %s: %d elements, %d in paint order, design %dx%d" % [ + name, view.screen["elements"].size(), view.screen["paint_order"].size(), + design[0], design[1]]) + + if args.has("capture"): + await _capture(args["capture"]) + get_tree().quit(0) + + +func _capture(path: String) -> void: + # Two frames: the first is the one this callback is still inside of. + await RenderingServer.frame_post_draw + await RenderingServer.frame_post_draw + var img := viewport.get_texture().get_image() + print("drew %d: %s" % [view.drawn.size(), ", ".join(view.drawn)]) + if not view.skipped.is_empty(): + print("not drawn %d: %s" % [view.skipped.size(), ", ".join(view.skipped)]) + var err := img.save_png(path) + if err != OK: + push_error("cannot write %s (%d)" % [path, err]) + return + print("captured %dx%d -> %s" % [img.get_width(), img.get_height(), path]) + + +# Godot passes everything after `--` through untouched; take `--key=value`. +static func _args() -> Dictionary: + var out := {} + for arg in OS.get_cmdline_user_args(): + if arg.begins_with("--") and arg.contains("="): + var pair := arg.substr(2).split("=", true, 1) + out[pair[0]] = pair[1] + return out diff --git a/port/scripts/boot.gd.uid b/port/scripts/boot.gd.uid new file mode 100644 index 00000000..16d85b5c --- /dev/null +++ b/port/scripts/boot.gd.uid @@ -0,0 +1 @@ +uid://cskqmpkw2q6k2 diff --git a/port/scripts/export_tree.gd b/port/scripts/export_tree.gd new file mode 100644 index 00000000..bc010501 --- /dev/null +++ b/port/scripts/export_tree.gd @@ -0,0 +1,95 @@ +# Locating and reading the open export tree. +# +# The Godot project NEVER reads a disc format (docs/MISSION.md §2). Everything +# it draws comes from `export/`, which is derived, gitignored and regenerated +# wholesale by `crates/sylpheed-export`. This class is the only place that knows +# where that tree is on disk. +class_name ExportTree +extends RefCounted + +const FORMAT_SCREEN := "sylpheed.screen/2" +const FORMAT_MANIFEST := "sylpheed.manifest/1" + +var root: String = "" +var error: String = "" + + +# `SYLPHEED_EXPORT` wins, so a modder can point the game at their own tree +# without touching the project. Otherwise `/../export`, which is the +# layout this repository has. +static func locate() -> ExportTree: + var t := ExportTree.new() + var env := OS.get_environment("SYLPHEED_EXPORT") + var candidate := env + if candidate == "": + candidate = ProjectSettings.globalize_path("res://").path_join("../export").simplify_path() + if not FileAccess.file_exists(candidate.path_join("manifest.json")): + t.error = "no manifest.json under %s -- run `sylpheed-export` first" % candidate + return t + t.root = candidate + return t + + +func read_json(rel: String) -> Variant: + var path := root.path_join(rel) + var text := FileAccess.get_file_as_string(path) + if text == "": + error = "cannot read %s" % path + return null + var parsed: Variant = JSON.parse_string(text) + if parsed == null: + error = "%s is not JSON" % path + return null + return parsed + + +func manifest() -> Dictionary: + var m: Variant = read_json("manifest.json") + if m == null: + return {} + if m.get("format") != FORMAT_MANIFEST: + error = "manifest.json is %s, this build reads %s" % [m.get("format"), FORMAT_MANIFEST] + return {} + return m + + +# Screens are addressed by their manifest name, not by a path, so the caller +# never has to know the archive's subdirectory. +func screen(name: String) -> Dictionary: + var m := manifest() + if m.is_empty(): + return {} + for entry: Dictionary in m.get("screens", []): + if entry.get("name") == name: + var s: Variant = read_json(entry["file"]) + if s == null: + return {} + if s.get("format") != FORMAT_SCREEN: + error = "%s is %s, this build reads %s" % [name, s.get("format"), FORMAT_SCREEN] + return {} + return s + error = "no screen named %s in manifest.json" % name + return {} + + +func screen_names() -> PackedStringArray: + var names := PackedStringArray() + for entry: Dictionary in manifest().get("screens", []): + names.append(entry["name"]) + return names + + +# Textures live outside res://, so they are read as bytes and decoded at +# runtime rather than imported. Nearest-neighbour: the export is a 1:1 copy of +# the disc's own texels and several elements are drawn at 200 %, where a +# bilinear filter would invent detail the disc does not have. +func texture(rel: String) -> Texture2D: + var bytes := FileAccess.get_file_as_bytes(root.path_join(rel)) + if bytes.is_empty(): + error = "cannot read sprite %s" % rel + return null + var img := Image.new() + if img.load_png_from_buffer(bytes) != OK: + error = "%s is not a PNG" % rel + return null + return ImageTexture.create_from_image(img) diff --git a/port/scripts/export_tree.gd.uid b/port/scripts/export_tree.gd.uid new file mode 100644 index 00000000..5f7e6d54 --- /dev/null +++ b/port/scripts/export_tree.gd.uid @@ -0,0 +1 @@ +uid://kyd3xrt1lpnj diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd new file mode 100644 index 00000000..34fecc11 --- /dev/null +++ b/port/scripts/screen_view.gd @@ -0,0 +1,152 @@ +# Draws one exported screen at its resting pose. +# +# P1 is static: every element is drawn at `rest`, the pose the screen holds +# once it has finished arriving (docs/FORMAT.md). Keyframe animation is P2 and +# is deliberately not here -- the keyframe time unit is measured rather than +# decoded, and this milestone must not depend on it. +# +# One CanvasItem draws the whole screen in `_draw`, rather than a node per +# element. The export's `paint_order` is already back-to-front, so honouring it +# is a loop; z-indexing sixteen nodes to reproduce the same order would be the +# same information expressed less directly, and would hide a tie behind Godot's +# own sibling rules. +class_name ScreenView +extends Node2D + +## Skip `kind & 0x4` template instances that duplicate a plain element. +## docs/FORMAT.md: those are motion-trail ghosts and are not on screen at rest. +## The narrow form of the rule matters -- 174 elements on the disc carry the bit +## with no template to duplicate, and a blanket skip would erase them. +const KIND_TEMPLATE_INSTANCE := 0x4 + +var tree: ExportTree = null +var screen: Dictionary = {} +var textures: Dictionary = {} +var skipped: Array[String] = [] +var drawn: Array[String] = [] + +## Which button is highlighted, by element id. P1 leaves it empty: initial focus +## was measured as unstable boot to boot (HANDOFF Q5) and picking one is an +## authored decision that belongs to P5. +var focused_id: String = "" + + +func load_screen(t: ExportTree, name: String) -> bool: + tree = t + screen = t.screen(name) + if screen.is_empty(): + push_error(t.error) + return false + var design: Array = screen.get("design", [1280, 720]) + # The export's coordinates are in this space and the viewport matches it, so + # a mismatch means the export is not what this project was built to draw. + var viewport := Vector2i( + ProjectSettings.get_setting("display/window/size/viewport_width"), + ProjectSettings.get_setting("display/window/size/viewport_height")) + if Vector2i(int(design[0]), int(design[1])) != viewport: + push_warning("screen %s is authored at %sx%s, viewport is %s" % [name, design[0], design[1], viewport]) + _load_textures() + queue_redraw() + return true + + +func _load_textures() -> void: + textures.clear() + for element: Dictionary in screen.get("elements", []): + for key in ["sprite", "focus_sprite"]: + var rel: String = element.get(key, "") + if rel != "" and not textures.has(rel): + var tex := tree.texture(rel) + if tex == null: + push_warning(tree.error) + else: + textures[rel] = tex + + +# `tint_rgba` is RGBA and `fade_argb` is ARGB -- different byte orders, on +# purpose, because the disc spells them differently and a silent swap looks like +# an art bug rather than a parse bug. They multiply per channel. +static func modulate_of(pose: Dictionary) -> Color: + var tint := _rgba(pose.get("tint_rgba", "0xffffffff")) + var fade := _argb(pose.get("fade_argb", "0xffffffff")) + return Color(tint.r * fade.r, tint.g * fade.g, tint.b * fade.b, tint.a * fade.a) + + +static func _rgba(hex: String) -> Color: + var v := hex.hex_to_int() + return Color8((v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff) + + +static func _argb(hex: String) -> Color: + var v := hex.hex_to_int() + return Color8((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff, (v >> 24) & 0xff) + + +## The drawn rectangle of an element at a pose. +## +## `pos` is the top-left at 1:1 and `pivot` is the anchor scale grows about, so +## the top-left moves by `-pivot*(s-1)` and the size is the natural size times +## `s`. At 100 % the pivot cancels, which is why it can be got wrong invisibly. +static func placement(pose: Dictionary, pivot: Vector2, natural: Vector2) -> Rect2: + var pos := _vec(pose.get("pos", [0, 0])) + var s := _vec(pose.get("scale", [100, 100])) / 100.0 + return Rect2(pos - pivot * (s - Vector2.ONE), natural * s) + + +static func _vec(a: Array) -> Vector2: + return Vector2(float(a[0]), float(a[1])) + + +# An element is a ghost only when another element on the same screen carries the +# same id *without* the template bit -- the template it is a repeat of. +func _template_instance_ids() -> Dictionary: + var plain := {} + for element: Dictionary in screen.get("elements", []): + if int(String(element.get("kind_raw", "0x0")).hex_to_int()) & KIND_TEMPLATE_INSTANCE == 0: + plain[element.get("id", "")] = true + var ghosts := {} + for element: Dictionary in screen.get("elements", []): + var kind := int(String(element.get("kind_raw", "0x0")).hex_to_int()) + if kind & KIND_TEMPLATE_INSTANCE != 0 and plain.has(element.get("id", "")): + ghosts[int(element.get("index", -1))] = true + return ghosts + + +func _draw() -> void: + if screen.is_empty(): + return + var elements: Array = screen.get("elements", []) + var ghosts := _template_instance_ids() + skipped.clear() + drawn.clear() + for index: int in screen.get("paint_order", []): + var element: Dictionary = elements[index] + var id: String = element.get("id", "") + if ghosts.has(index): + skipped.append("%s (template instance)" % id) + continue + var pose: Dictionary = element.get("rest", {}) + var colour := modulate_of(pose) + if colour.a <= 0.0: + skipped.append("%s (transparent at rest)" % id) + continue + var pivot := _vec(element.get("pivot", [0, 0])) + var rel: String = element.get("sprite", "") + if focused_id == id and element.get("focus_sprite", "") != "": + rel = element["focus_sprite"] + if rel != "": + var tex: Texture2D = textures.get(rel) + if tex == null: + skipped.append("%s (sprite failed to load)" % id) + continue + draw_texture_rect(tex, placement(pose, pivot, tex.get_size()), false, colour) + drawn.append(id) + elif element.get("role", "") == "primitive" and element.has("size"): + # A primitive has no texture; the quad is its declared size and its + # colour is the pose's own modulate. + draw_rect(placement(pose, pivot, _vec(element["size"])), colour, true) + drawn.append(id) + else: + # A .t32 element whose sprite the exporter could not produce. Saying + # so is the point -- a silently missing element looks like art. + skipped.append("%s (no sprite in the export)" % id) diff --git a/port/scripts/screen_view.gd.uid b/port/scripts/screen_view.gd.uid new file mode 100644 index 00000000..2844ec78 --- /dev/null +++ b/port/scripts/screen_view.gd.uid @@ -0,0 +1 @@ +uid://cf6hspvq602s3 From 65fe6e04a2b1d5efb72bf030758e989d084b83b8 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 19:31:21 +0000 Subject: [PATCH 11/29] tools: verify-screen -- diff every exported screen against the reference renderer The P1 gate is a pixel comparison, so it needs to be a command rather than a paragraph. `tools/verify-screen` renders each 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 on purpose: * the reference CLI is the one `build-reference-cli` builds from the revision the exporter is PINNED to -- not `/reborn/target/`, which is a live mount of the other agent's checkout and moves mid-iteration. A pixel disagreement against a moving decoder has a free variable in it and proves nothing. * `--black`, because the screen carries its own background and the CLI's default dim slate stands in for a 3D scene that is not on this screen. * `--primitives --animated`, because the port draws every element at rest and those two flags are what make the CLI draw the same set. NOT `--focus`: nothing is focused at rest. The threshold is 3/255 -- what integer-truncating compositing in the CLI and float rounding on a GPU differ by. Above that is a placement, order or colour disagreement that needs a reason, and the script says DIFFERS rather than pretending a wider tolerance is a result. --- tools/verify-screen | 77 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100755 tools/verify-screen diff --git a/tools/verify-screen b/tools/verify-screen new file mode 100755 index 00000000..94d9c3e9 --- /dev/null +++ b/tools/verify-screen @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Diff Godot's drawing of an exported screen against `sylpheed-cli screen +# render` of the same build -- the P1 gate. +# +# tools/verify-screen # every screen in the manifest +# tools/verify-screen main_menu title # named screens +# +# Writes .godot.png, .ref.png and .diff.png into +# $OUT (default: a directory under /tmp) and prints, per screen, the largest +# per-channel difference anywhere in the frame. +# +# The two renderers are held to the same inputs on purpose: +# +# * the REFERENCE CLI is the one built by `build-reference-cli`, from the same +# `sylpheed-formats` revision the exporter is pinned to. /reborn's own +# target/ is a live mount of the other agent's checkout and moves mid-run; a +# pixel disagreement against a moving decoder proves nothing. +# * `--black` because Godot clears to black and the screen carries its own +# background. The CLI's default dim slate stands in for a 3D scene behind an +# in-mission screen, which is not this screen. +# * `--primitives --animated` because the port draws every element at its +# resting pose, and those two flags are what make the CLI draw the same set. +# `--focus` is NOT passed: nothing is focused at rest (HANDOFF Q5 measured +# initial focus as unstable boot to boot, so choosing one is P5's decision). +# +# A difference here is not automatically the port's fault. Say which renderer is +# wrong and why -- do not tune until they match. +set -euo pipefail +cd "${PROJECT_DIR:-/work}" + +CLI="${SYLPHEED_CLI:-${CARGO_TARGET_DIR:-/sylph-home/port/target-container}/release/sylpheed-cli}" +DISC="${SYLPHEED_DISC:-/disc}" +OUT="${OUT:-${TMPDIR:-/tmp}/verify-screen}" +export DISPLAY="${DISPLAY:-:97}" + +[ -x "$CLI" ] || { echo "no reference CLI at $CLI -- run build-reference-cli" >&2; exit 2; } +[ -f export/manifest.json ] || { echo "no export/manifest.json -- run build-export --run" >&2; exit 2; } +mkdir -p "$OUT" + +# Godot needs one scan to register the `class_name` globals; without it every +# script fails to parse and the run dies with no frame drawn. +[ -d port/.godot ] || godot --headless --path port --import >/dev/null 2>&1 + +screens=("$@") +if [ ${#screens[@]} -eq 0 ]; then + mapfile -t screens < <(python3 -c ' +import json; print("\n".join(s["name"] for s in json.load(open("export/manifest.json"))["screens"]))') +fi + +status=0 +for name in "${screens[@]}"; do + build=$(python3 -c ' +import json,sys +m=json.load(open("export/manifest.json")) +f=next(s["file"] for s in m["screens"] if s["name"]==sys.argv[1]) +print(json.load(open("export/"+f))["source"]["build"])' "$name") + + "$CLI" screen render "$DISC/dat/GP_TITLE.pak" "$OUT/$name.ref.png" \ + --build "$build" --black --primitives --animated >/dev/null + + godot --path port --resolution 1280x720 -- \ + "--screen=$name" "--capture=$OUT/$name.godot.png" >"$OUT/$name.log" 2>&1 + + convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \ + -compose difference -composite -colorspace Gray -auto-level "$OUT/$name.diff.png" + read -r max mean <<<"$(convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \ + -compose difference -composite -format "%[fx:maxima*255] %[fx:mean*255]" info:)" + + # 3/255 is what integer-truncating compositing in the CLI and float rounding + # in a GPU differ by. Anything above that is a placement, order or colour + # disagreement and needs a reason, not a threshold. + verdict=OK + awk "BEGIN{exit !($max > 3)}" && { verdict=DIFFERS; status=1; } + printf '%-16s build %-3s max %-5s mean %-8s %s\n' "$name" "$build" "$max" "${mean:0:6}" "$verdict" +done +echo "artifacts in $OUT" +exit $status From 6e46b6a13669efb07723afbea1a32c192ba998e8 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 19:31:21 +0000 Subject: [PATCH 12/29] docs: the P1 diff, and the one question it raised `main_menu` -- the milestone's gate -- agrees with the reference renderer to within 3/255 on every channel of every pixel, RMSE 0.38 %, no pixel above 4 %. Nine of the twelve screens are at or under that; two are byte-identical. Three exceed it, and each gets a cause rather than a wider tolerance: * `title` (max 6): every disagreement is INSIDE A TIE -- the derived order and the CLI's measured-off-the-game order differ only among elements with identical layer keys. That is exactly the residual HANDOFF Q3 documents and the export already declares in `unresolved: paint_order_ties`. Cost: 904 px at 4-6/255 in one glow band. The port keeps the stable sort; fitting it to one screen's capture would be tuning. * `title_jp` (max 154): `ptlogo_eff2` at 125 % is the only drawn element in the whole export at a scale that is not a whole multiple of 100 %, and `title_jp` is the only screen over 6/255. Same fact twice. `ui_layout::blit` samples the source at the destination pixel's top-left corner; a GPU samples at its centre, and at 125 % those disagree on one column in five. I think the CLI is the one that is wrong -- corner-sampled nearest is a half-pixel bias toward the top-left that no rasteriser produces. But that is a reading, not a measurement: it needs a framebuffer capture of the Japanese title screen, so it is filed in BLOCKED.md as a question. The port is NOT changing to match, because matching would mean reproducing a half-pixel offset on purpose to make a number smaller. * `extras` (max 4): two pixels. Also reconciled: the pivot question predicted a P1 diff could not distinguish the declared pivot from half the texture, because both renderers use the declared one. That held. Recorded so the agreement is not later mistaken for evidence -- and P2 will not settle it either. BLOCKED.md's `/reborn` HEAD updated to 690683d, with a note that the mount is read-only by design and `git -C /reborn pull` fails rather than being skipped. --- README.md | 21 +++++- docs/BLOCKED.md | 31 ++++++++- docs/DECISIONS.md | 158 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1ed04b05..0e27e5e4 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,27 @@ assuming a value is on the disc. | `port/` | the Godot 4 project | | `authored/` | decisions that are **not** on the disc, each with its reason | | `export/` | generated, gitignored, never hand-edited | +| `tools/` | verification harnesses that hold the port to the reference renderer | | `docs/` | the mission, the format spec, the agent's loop prompt | +## Verifying + +`sylpheed-cli screen render` -- built from the same `sylpheed-formats` revision +the exporter is pinned to -- is the reference renderer. `tools/verify-screen` +draws every exported screen both ways and reports the largest per-channel +difference in the frame: + +``` +tools/verify-screen # every screen in the manifest +tools/verify-screen main_menu # one of them +``` + +Where the two disagree, one of them is wrong; `docs/DECISIONS.md` says which and +why, rather than tuning the port until the number goes down. + ## Status -Pre-P0. Nothing runs yet. +**P1.** The exporter writes `GP_TITLE`'s twelve screen builds and their sprites, +and the Godot project draws any of them statically at 1280x720 from that tree +alone. `main_menu` matches the reference renderer to within 3/255 on every +channel of every pixel. Next: P2, keyframe animation. diff --git a/docs/BLOCKED.md b/docs/BLOCKED.md index 2812e542..97a20f90 100644 --- a/docs/BLOCKED.md +++ b/docs/BLOCKED.md @@ -9,7 +9,9 @@ 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-28**, at `/reborn` HEAD `e81dcad`. +Last reconciled against HANDOFF.md on **2026-08-28**, at `/reborn` HEAD `bc6354d`. +(`/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 @@ -46,6 +48,28 @@ than exported: Not blocking anything today; raised because the port found them and a guess here would be believed later. +### 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` @@ -68,3 +92,8 @@ 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. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d4279002..13f4c5f8 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -114,3 +114,161 @@ 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 `/../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 + +Both renderers skip them. The bundle declares them and carries **zero** RATC +children for either, so there is no texture on the disc to export — this is a +property of the disc, not a gap in the exporter, and `ScreenView` reports it as +`no sprite in the export` rather than dropping it silently. From 980b717fb03a0af3e0a0b0138657ed2ed2712e3a Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 19:51:57 +0000 Subject: [PATCH 13/29] port: play the keyframe timeline, with the time unit authored in one place P2. A keyframe is the start of a linear ramp toward the next; `ScreenView` walks them at `time_units` and `boot.gd` advances that in real time, or freezes it with `--time=`. `authored/timing.json` holds the ONE constant this needs. HANDOFF Q1 is answered -- linear, 2 units per rendered frame, 1 unit = 1/60 s -- but that conversion was MEASURED off the running game, not read from a file, so it is authored rather than exported and it says so at length. Expressed as units-per-second, because 60 is exact and 0.01666... is a decimal a reader has to recognise. The timeline stops at the last TIMED keyframe and never plays the exit. Every group's final keyframe carries no `t` -- across this export it is a fade-out for 116 of 134 elements, a scale-and-slide exit for 12, and identical for 6 -- so playing into it would mean inventing how long the ramp takes. That duration is the screen transition, it is measured at ~0.4 s, and it is P3's to author with its own evidence. `exit_ramp_seconds` is therefore null on purpose, not missing. `--pose=rest` keeps the P1 behaviour available: since the port's default is now the timeline and the two DISAGREE, renderer-vs-renderer diffing has to be able to ask for the same assumption the reference renderer makes. The interpolation is checked by where it lands: on 8 of the 12 screens the settled timeline is byte-identical to the rest render. --- authored/timing.json | 39 +++++++++++++++ port/scripts/boot.gd | 41 ++++++++++++++- port/scripts/export_tree.gd | 11 +++++ port/scripts/screen_view.gd | 99 ++++++++++++++++++++++++++++++++++--- 4 files changed, 182 insertions(+), 8 deletions(-) create mode 100644 authored/timing.json diff --git a/authored/timing.json b/authored/timing.json new file mode 100644 index 00000000..52bc67ae --- /dev/null +++ b/authored/timing.json @@ -0,0 +1,39 @@ +{ + "format": "sylpheed.timing/1", + + "keyframe_units_per_second": 60, + "why": [ + "HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a", + "`t` is. The unit was MEASURED off the running game, not decoded: a declared", + "15-unit fade lands on round(255*k/15) for all seven of its samples with k", + "stepping 2,4,6,8,10,12,14 on seven consecutive submitted frames -- so 2", + "units per rendered frame -- and the idle title presents at 28.3-28.8 fps,", + "a 30 Hz game, giving 60 units per second. A second line agrees: the", + "transition quad is declared black for 12 units, and a capture measured the", + "pure-black plateau at 0.17-0.23 s, where 12/60 = 0.20 s.", + "", + "Expressed as units-per-second rather than seconds-per-unit so the value is", + "exact rather than a repeating decimal a reader has to recognise.", + "", + "DELETE THIS FILE when a field on the disc is found that states the unit.", + "Nothing here is on the disc." + ], + "kind": "measured", + "source": "/reborn docs/port/HANDOFF.md Q1, docs/re/ui-keyframe-time-unit.md", + + "ramp": "linear", + "ramp_why": [ + "Also HANDOFF Q1, and part of the same measurement: the fade lands on the", + "linear value at every one of the seven sampled frames, so there is no ease." + ], + + "exit_ramp_seconds": null, + "exit_ramp_why": [ + "NOT SET, deliberately. The last keyframe of every group carries no time --", + "the disc has no time slot there -- so the duration of the ramp INTO the", + "exit pose is unknown. HANDOFF Q7 measured the screen fade-out at ~0.4 s,", + "but that is the transition, which is P3's to author with its own evidence.", + "P2 plays the timed keyframes and holds; it never plays the exit ramp,", + "because it would have to invent how long it takes." + ] +} diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 77628610..1ac135fb 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -6,6 +6,13 @@ # # godot --path port -- --screen=main_menu # godot --path port -- --screen=main_menu --capture=/tmp/godot.png +# godot --path port -- --screen=main_menu --time=0.5 --capture=/tmp/at-half.png +# godot --path port -- --screen=main_menu --pose=rest --capture=/tmp/rest.png +# +# `--time` is in SECONDS and freezes the timeline there; without it the screen +# animates in real time from t=0. `--pose=rest` draws the export's declared +# resting pose instead of the timeline -- what the reference renderer draws, so +# that a renderer-vs-renderer diff compares like with like. # # 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. That is @@ -56,6 +63,17 @@ func _ready() -> void: # filter difference cannot masquerade as a placement difference in the diff. view.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST view.focused_id = args.get("focus", "") + if args.get("pose", "") == "rest": + view.pose_mode = ScreenView.Pose.REST + + # The keyframe unit is MEASURED, not on the disc, so it is authored and read + # in exactly one place -- here. + var timing: Variant = export_tree.authored("timing.json") + if timing == null: + push_error(export_tree.error) + get_tree().quit(2) + return + view.units_per_second = float(timing["keyframe_units_per_second"]) viewport.add_child(view) if not view.load_screen(export_tree, name): @@ -63,20 +81,39 @@ func _ready() -> void: get_tree().quit(2) return - print("screen %s: %d elements, %d in paint order, design %dx%d" % [ + var settle := view.settle_time() + print("screen %s: %d elements, %d in paint order, design %dx%d, settles at t=%d (%.3f s)" % [ name, view.screen["elements"].size(), view.screen["paint_order"].size(), - design[0], design[1]]) + design[0], design[1], settle, settle / view.units_per_second]) + + if args.has("time"): + _frozen = true + view.time_units = float(args["time"]) * view.units_per_second + view.queue_redraw() if args.has("capture"): await _capture(args["capture"]) get_tree().quit(0) +var _frozen := false + + +func _process(delta: float) -> void: + if _frozen or view == null: + return + view.time_units += delta * view.units_per_second + view.queue_redraw() + + func _capture(path: String) -> void: # Two frames: the first is the one this callback is still inside of. await RenderingServer.frame_post_draw await RenderingServer.frame_post_draw var img := viewport.get_texture().get_image() + print("t = %.2f units (%.3f s), pose = %s" % [ + view.time_units, view.time_units / view.units_per_second, + "rest" if view.pose_mode == ScreenView.Pose.REST else "timeline"]) print("drew %d: %s" % [view.drawn.size(), ", ".join(view.drawn)]) if not view.skipped.is_empty(): print("not drawn %d: %s" % [view.skipped.size(), ", ".join(view.skipped)]) diff --git a/port/scripts/export_tree.gd b/port/scripts/export_tree.gd index bc010501..1a6c615a 100644 --- a/port/scripts/export_tree.gd +++ b/port/scripts/export_tree.gd @@ -30,6 +30,17 @@ static func locate() -> ExportTree: return t +# `authored/` sits beside `export/`, never inside it: it is hand-written and +# committed, and a re-export must not be able to touch it. +func authored(name: String) -> Variant: + var path := root.path_join("../authored").simplify_path().path_join(name) + var text := FileAccess.get_file_as_string(path) + if text == "": + error = "cannot read %s" % path + return null + return JSON.parse_string(text) + + func read_json(rel: String) -> Variant: var path := root.path_join(rel) var text := FileAccess.get_file_as_string(path) diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd index 34fecc11..33b36cb7 100644 --- a/port/scripts/screen_view.gd +++ b/port/scripts/screen_view.gd @@ -1,9 +1,16 @@ -# Draws one exported screen at its resting pose. +# Draws one exported screen, either at a moment on its timeline or at the +# `rest` pose the export declares. # -# P1 is static: every element is drawn at `rest`, the pose the screen holds -# once it has finished arriving (docs/FORMAT.md). Keyframe animation is P2 and -# is deliberately not here -- the keyframe time unit is measured rather than -# decoded, and this milestone must not depend on it. +# TIMELINE is the real behaviour and the default. A keyframe is the start of a +# LINEAR ramp toward the next, and the unit of `t` comes from +# `authored/timing.json` -- it is measured, not on the disc, which is why it is +# authored and applied in exactly one place. +# +# REST reproduces what the export's `rest` field says, which is what +# `sylpheed-cli screen render` draws. It is kept so `tools/verify-screen` can +# hold both renderers to the same assumption. The two modes DISAGREE on six +# elements in this export, and the running game sides with the timeline -- see +# `docs/DECISIONS.md`. # # One CanvasItem draws the whole screen in `_draw`, rather than a node per # element. The export's `paint_order` is already back-to-front, so honouring it @@ -19,6 +26,17 @@ extends Node2D ## with no template to duplicate, and a blanket skip would erase them. const KIND_TEMPLATE_INSTANCE := 0x4 +enum Pose { TIMELINE, REST } + +## Which pose to draw. TIMELINE walks the keyframes at `time_units`; REST draws +## the export's declared `rest` and is there for renderer-vs-renderer diffing. +var pose_mode: Pose = Pose.TIMELINE + +## Position on the timeline, in the disc's own keyframe units. `t` is left raw +## everywhere; seconds appear only where `units_per_second` is applied. +var time_units: float = 0.0 +var units_per_second: float = 60.0 + var tree: ExportTree = null var screen: Dictionary = {} var textures: Dictionary = {} @@ -97,6 +115,74 @@ static func _vec(a: Array) -> Vector2: return Vector2(float(a[0]), float(a[1])) +## The pose of one element at `time_units`. +## +## The timed keyframes are the whole timeline. Before the first, the element +## holds its first pose (the pre-roll a staggered menu needs -- the five buttons +## start at t=28,30,32,34,36). After the last TIMED keyframe it holds that pose. +## +## It never plays into the final, untimed keyframe. That frame is the screen's +## EXIT pose, and the disc gives no time slot for the ramp into it, so playing +## it would mean inventing a duration. The exit is the transition, and it is +## P3's, with its own measured evidence. See `authored/timing.json`. +func pose_at(element: Dictionary, t: float) -> Dictionary: + var frames: Array = element.get("keyframes", []) + var timed: Array = [] + for k: Dictionary in frames: + if k.has("t"): + timed.append(k) + if timed.is_empty(): + # No timed frame at all: the group is a single static pose. + return frames[0] if not frames.is_empty() else element.get("rest", {}) + if t <= float(timed[0]["t"]): + return timed[0] + for i in range(timed.size() - 1): + var a: Dictionary = timed[i] + var b: Dictionary = timed[i + 1] + var t0 := float(a["t"]) + var t1 := float(b["t"]) + if t < t1: + # A keyframe is the start of a ramp toward the next, and the ramp is + # linear -- measured, `authored/timing.json`. + return _lerp_pose(a, b, 0.0 if t1 <= t0 else (t - t0) / (t1 - t0)) + return timed[timed.size() - 1] + + +# Channels are integers on the disc. The running game's own fade lands on +# `round(255*k/15)`, so rounding -- not truncation -- is what was measured. +static func _lerp_pose(a: Dictionary, b: Dictionary, f: float) -> Dictionary: + return { + "pos": [_ilerp(a["pos"][0], b["pos"][0], f), _ilerp(a["pos"][1], b["pos"][1], f)], + "scale": [_ilerp(a["scale"][0], b["scale"][0], f), _ilerp(a["scale"][1], b["scale"][1], f)], + "tint_rgba": _hex_lerp(a["tint_rgba"], b["tint_rgba"], f), + "fade_argb": _hex_lerp(a["fade_argb"], b["fade_argb"], f), + } + + +static func _ilerp(a: float, b: float, f: float) -> int: + return int(round(a + (b - a) * f)) + + +# Byte-wise, so it works for both orders without knowing which one it has. +static func _hex_lerp(a: String, b: String, f: float) -> String: + var x := a.hex_to_int() + var y := b.hex_to_int() + var out := 0 + for shift in [24, 16, 8, 0]: + out |= (_ilerp((x >> shift) & 0xff, (y >> shift) & 0xff, f) & 0xff) << shift + return "0x%08x" % out + + +## The last moment anything on this screen is still moving, in keyframe units. +func settle_time() -> float: + var last := 0.0 + for element: Dictionary in screen.get("elements", []): + for k: Dictionary in element.get("keyframes", []): + if k.has("t"): + last = maxf(last, float(k["t"])) + return last + + # An element is a ghost only when another element on the same screen carries the # same id *without* the template bit -- the template it is a repeat of. func _template_instance_ids() -> Dictionary: @@ -125,7 +211,8 @@ func _draw() -> void: if ghosts.has(index): skipped.append("%s (template instance)" % id) continue - var pose: Dictionary = element.get("rest", {}) + var pose: Dictionary = element.get("rest", {}) if pose_mode == Pose.REST \ + else pose_at(element, time_units) var colour := modulate_of(pose) if colour.a <= 0.0: skipped.append("%s (transparent at rest)" % id) From dbbcae1df28c43f8318846951c884266b04a7fa0 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 19:51:57 +0000 Subject: [PATCH 14/29] docs: the timeline lands on `rest` -- except on six elements, where the game agrees with the timeline P2's gate is the buttons sliding in, and `tools/screen-strip` renders the strip that shows it. But the useful result came out of checking where the animation settles. On 8 of 12 screens the settled timeline is BYTE-IDENTICAL to the declared `rest` pose -- the port walks the keyframes with an authored time unit and arrives, to the pixel, where the pinned decoders independently say the screen rests. On `main_menu` the two differ in exactly one region, 400x470 at (440,108): the bounding box of `ptframe1` and `ptframe2` and nothing else. `rest` puts both at their first keyframe, off-position and transparent. The capture of the running game shows them -- the bright circuit bracket around the menu. Cropping the same region from the capture and from both renders puts the ring and its elbow trace in the timeline render pixel-aligned with the game's, and absent from the rest render. Geometry, so it does not depend on the capture's gamma or on its having been taken with NEW GAME focused. `ui_layout::rest_plateau` excludes a trailing run of identical keyframes because it is normally the exit. On an element with NO exit animation the trailing run IS the hold. The condition that identifies these exactly, with no false positives here, is "the final untimed keyframe has the same pose as the last timed one" -- six elements, and `rest()` misses all six. Filed in BLOCKED.md for the RE agent: the decoders are pinned and are not this port's to fix, and `sylpheed-cli screen render` is missing the bracket too. Worth saying plainly what this does to P1: the port and the reference agreed on `main_menu` to 3/255 and BOTH were missing two elements the game draws. Two renderers reading one field through one decoder agreeing is not evidence the field is right. BLOCKED.md had already said that about the pivot; here it bit. The title is NOT settled and P2 does not claim it. `rest` and the timeline disagree there by 142-247/255, the only live title capture composites the PRESS A plate over build 4 so it cannot be diffed against the title alone, and both of the port's modes draw a cyan glow slab the game does not have -- a third problem, P3's. Recorded as an open question rather than resolved by tuning. Also reconciled against the RE agent's new work: Q8 is answered -- the SE waves are located in `Static.slb` (move/confirm/back), which unblocks P6's audio; and the title's transitions are a lookup by NAME, giving P3/P5 the game's own screen vocabulary as candidate `goto` targets, marked as the name match it is. --- docs/BLOCKED.md | 54 +++++++++++++++++++- docs/DECISIONS.md | 119 ++++++++++++++++++++++++++++++++++++++++++++ docs/FORMAT.md | 16 ++++++ tools/screen-strip | 52 +++++++++++++++++++ tools/verify-screen | 17 +++++-- 5 files changed, 251 insertions(+), 7 deletions(-) create mode 100755 tools/screen-strip diff --git a/docs/BLOCKED.md b/docs/BLOCKED.md index 97a20f90..28654480 100644 --- a/docs/BLOCKED.md +++ b/docs/BLOCKED.md @@ -9,7 +9,7 @@ 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-28**, at `/reborn` HEAD `bc6354d`. +Last reconciled against HANDOFF.md on **2026-08-28**, at `/reborn` HEAD `8b4965f`. (`/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.) @@ -17,7 +17,7 @@ mount is refreshed outside this container and HEAD is read, not fetched.) | Milestone | Needs | HANDOFF | State | |---|---|---|---| -| P6 audio | which cue fires on move / confirm / back | Q8 | ❔ open. The cue table is complete; the **event binding is not**. P6 cannot bind a sound to a keypress without inventing it. | +| ~~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. | @@ -34,6 +34,18 @@ measured), Q9 (`ADVERTISE_MOVIE` → `ADV.wmv` is boot intro *and* attract; `MS0 `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: @@ -45,9 +57,47 @@ than exported: ## Questions this port has raised +### Does a keyframe group loop, or hold its last pose? + +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 + +**This one is a decoder bug, not a question**, and it is 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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 13f4c5f8..cfb13fa2 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -272,3 +272,122 @@ Both renderers skip them. The bundle declares them and carries **zero** RATC children for either, so there is no texture on the disc to export — this is a property of the disc, not a gap in the exporter, and `ScreenView` reports it as `no sprite in the export` rather than dropping it silently. + +--- + +## 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. diff --git a/docs/FORMAT.md b/docs/FORMAT.md index 91ce0115..446ebd9d 100644 --- a/docs/FORMAT.md +++ b/docs/FORMAT.md @@ -187,6 +187,22 @@ 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`. diff --git a/tools/screen-strip b/tools/screen-strip new file mode 100755 index 00000000..86bda871 --- /dev/null +++ b/tools/screen-strip @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Render one screen at several points on its timeline and montage them -- the +# P2 gate artifact, and the way to eyeball any animation question later. +# +# tools/screen-strip main_menu # a default spread +# tools/screen-strip main_menu 0.45 0.6 0.9 1.35 # explicit seconds +# +# Also writes .rest.png and .settled.png and reports where they +# differ. That difference is the interesting number: the timeline is expected to +# land EXACTLY on the declared resting pose for every element whose `rest` the +# decoders identify correctly, so a clean run shows a difference confined to the +# elements we know it misses, and nothing else. A difference anywhere else means +# the interpolation is wrong. +set -euo pipefail +cd "${PROJECT_DIR:-/work}" + +name="${1:?usage: screen-strip SCREEN [SECONDS...]}"; shift +times=("$@") +[ ${#times[@]} -eq 0 ] && times=(0.45 0.52 0.57 0.62 0.67 0.75 0.90 1.35) +OUT="${OUT:-${TMPDIR:-/tmp}/screen-strip}" +export DISPLAY="${DISPLAY:-:97}" +mkdir -p "$OUT" +[ -d port/.godot ] || godot --headless --path port --import >/dev/null 2>&1 + +shot() { # shot + local out="${!#}" + godot --path port --resolution 1280x720 -- "--screen=$name" "${@:1:$#-1}" \ + "--capture=$out" >"$OUT/$name.log" 2>&1 +} + +labelled=() +for t in "${times[@]}"; do + shot "--time=$t" "$OUT/$name.t$t.png" + convert "$OUT/$name.t$t.png" -resize 320x180 -bordercolor gray30 -border 1 \ + -background black -fill white -pointsize 13 label:"t = ${t}s" \ + -gravity center -append "$OUT/$name.lab$t.png" + labelled+=("$OUT/$name.lab$t.png") +done +montage "${labelled[@]}" -tile 4x -geometry +4+4 -background black "$OUT/$name.strip.png" + +shot --pose=rest "$OUT/$name.rest.png" +shot --time=99 "$OUT/$name.settled.png" +convert "$OUT/$name.settled.png" "$OUT/$name.rest.png" -compose difference -composite "$OUT/$name.d.png" +max=$(convert "$OUT/$name.d.png" -format "%[fx:maxima*255]" info:) +convert "$OUT/$name.d.png" -colorspace Gray -threshold 0 "$OUT/$name.m.png" +if [ "${max%.*}" = "0" ]; then + box="(identical)" +else + box=$(convert "$OUT/$name.m.png" -trim -format "%wx%h%X%Y" info: 2>/dev/null) +fi +echo "$name: strip -> $OUT/$name.strip.png" +echo "$name: settled timeline vs declared rest -- max ${max}/255, differing region $box" diff --git a/tools/verify-screen b/tools/verify-screen index 94d9c3e9..3fd2181e 100755 --- a/tools/verify-screen +++ b/tools/verify-screen @@ -18,10 +18,17 @@ # * `--black` because Godot clears to black and the screen carries its own # background. The CLI's default dim slate stands in for a 3D scene behind an # in-mission screen, which is not this screen. -# * `--primitives --animated` because the port draws every element at its -# resting pose, and those two flags are what make the CLI draw the same set. -# `--focus` is NOT passed: nothing is focused at rest (HANDOFF Q5 measured -# initial focus as unstable boot to boot, so choosing one is P5's decision). +# * `--primitives --animated` because those are what make the CLI draw the same +# element set. `--focus` is NOT passed: nothing is focused at rest (HANDOFF +# Q5 measured initial focus as unstable boot to boot, so choosing one is +# P5's decision). +# * `--pose=rest` on the Godot side. Since P2 the port's DEFAULT is to play the +# timeline, and the settled timeline is deliberately NOT what `rest` says -- +# the export's `rest` misses `ptframe1`/`ptframe2` on the main menu, and the +# running game shows them (docs/DECISIONS.md). Both renderers read `rest` +# through the same decoder, so asking for it here keeps this a test of the +# PORT against the reference. It is not the test of whether `rest` is right; +# that one is the oracle capture, and the port already departs from it. # # A difference here is not automatically the port's fault. Say which renderer is # wrong and why -- do not tune until they match. @@ -59,7 +66,7 @@ print(json.load(open("export/"+f))["source"]["build"])' "$name") --build "$build" --black --primitives --animated >/dev/null godot --path port --resolution 1280x720 -- \ - "--screen=$name" "--capture=$OUT/$name.godot.png" >"$OUT/$name.log" 2>&1 + "--screen=$name" --pose=rest "--capture=$OUT/$name.godot.png" >"$OUT/$name.log" 2>&1 convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \ -compose difference -composite -colorspace Gray -auto-level "$OUT/$name.diff.png" From 959b43cb532ea417247f5238cd643a1e2fd4e118 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 21:44:08 +0000 Subject: [PATCH 15/29] deps: bump the sylpheed-formats pin 47f423f -> b436e5a for the rest() fix What I wanted from it: `ui_layout::rest()`. At 47f423f a trailing run of identical keyframes was always treated as the exit, so an element with no exit animation rested at its invisible pre-roll -- `ptframe1`/`ptframe2`, the main menu's circuit bracket, which a capture of the running game plainly shows. Pinned at b436e5a rather than 5e6cf0d where the fix was written, because b436e5a is where it carries its disc-wide check: 30 of 13 991 elements move, 4 become visible, 0 become invisible. The rule shipped is the RE agent's, not the condition this port proposed -- mine was too loose and would have erased the word PAUSE on `pgptitle.rat`, whose trailing run is two identical TRANSPARENT frames. A trailing run is the hold exactly when it is VISIBLE. The re-export shows the change was contained: two files differ, and within them exactly four `rest` blocks -- ptframe1/ptframe2 on both main menus, t=16 alpha 0x00 at the pre-roll position becoming t=62 alpha 0xff at the arrived one. Every diff line pairs. The other ten screens are byte-identical, no sprite changed, and `pteff02` correctly did not move. --- Cargo.lock | 2 +- crates/sylpheed-export/Cargo.toml | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d732528..2f3a93c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -959,7 +959,7 @@ dependencies = [ [[package]] name = "sylpheed-formats" version = "0.1.0" -source = "git+https://git.mc02.dev/fabi/Syplheed-Reborn.git?rev=8b6dbcf#8b6dbcfead4168a674015f0e4fd2c8d83f9ffe31" +source = "git+https://git.mc02.dev/fabi/Syplheed-Reborn.git?rev=5414db3#5414db34bfc0d0b0743e07e6e89f345540dc2e94" dependencies = [ "anyhow", "binrw", diff --git a/crates/sylpheed-export/Cargo.toml b/crates/sylpheed-export/Cargo.toml index e1ea00b6..bb6ed871 100644 --- a/crates/sylpheed-export/Cargo.toml +++ b/crates/sylpheed-export/Cargo.toml @@ -13,7 +13,15 @@ license.workspace = true # `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" } +# +# Pin moved 8b6dbcf -> 5414db3 on 2026-08-28. WHAT I WANTED FROM IT: the fix to +# `ui_layout::rest()`. At 8b6dbcf a trailing run of identical keyframes was +# always treated as the exit, so an element with no exit animation rested at its +# invisible pre-roll -- `ptframe1`/`ptframe2`, the main menu's circuit bracket, +# which a capture of the running game plainly shows. 5414db3 is the revision at +# which that fix carries its disc-wide check (30 of 13 991 elements move, 4 +# become visible, 0 become invisible), not merely the one where it was written. +sylpheed-formats = { git = "https://git.mc02.dev/fabi/Syplheed-Reborn.git", rev = "5414db3" } serde = { version = "1", features = ["derive"] } serde_json = "1" From 9f740d8cede7ead04ebc2df9a8fc22f969080630 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 21:44:08 +0000 Subject: [PATCH 16/29] port: settle at the hold, not at the last timed keyframe P2 shipped the wrong rule and the title is the counter-example. I had reasoned that the exit is the final untimed keyframe, so anything timed was still the entry. It is not: `pteff02` on the title holds at t=46 with the 25 % dim quad at alpha 0x40 and then ramps to 0x00 by t=236. The exit can be a long run of TIMED keyframes, and running to the end drops the dim and leaves the whole screen ~13/255 too bright -- exactly the luminance excess P2 recorded (oracle 64.1, rest 62.8, timeline 80.0) and filed as an unexplained "glow slab". A group is pre-roll -> ramp in -> hold -> ramp out -> post-roll, and a screen that has arrived sits on the hold. `settle_units()` is now `rest.t`, the decoders' own identification of that hold. The check is that a disagreement vanishes: on ALL TWELVE screens the settled timeline is now byte-identical to the `--pose=rest` render, where before this four differed by up to 247/255. The timeline's endpoint should BE `rest` -- the animation is what it adds, not a different destination -- so this is the property to want, and it holds with no special case. Credit where due: this came out of the RE agent measuring the title's dim quad against a plate-free capture, in pursuit of a different question. --- port/scripts/screen_view.gd | 45 ++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd index 33b36cb7..9f6837f7 100644 --- a/port/scripts/screen_view.gd +++ b/port/scripts/screen_view.gd @@ -117,14 +117,20 @@ static func _vec(a: Array) -> Vector2: ## The pose of one element at `time_units`. ## -## The timed keyframes are the whole timeline. Before the first, the element -## holds its first pose (the pre-roll a staggered menu needs -- the five buttons -## start at t=28,30,32,34,36). After the last TIMED keyframe it holds that pose. +## 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 plays in and +## stops at `rest`, which is the decoders' identification of that hold and +## carries its own `t`. ## -## It never plays into the final, untimed keyframe. That frame is the screen's -## EXIT pose, and the disc gives no time slot for the ramp into it, so playing -## it would mean inventing a duration. The exit is the transition, and it is -## P3's, with its own measured evidence. See `authored/timing.json`. +## It is emphatically NOT "play to the last timed keyframe". The exit is not +## only the final untimed frame -- it can be a long run of TIMED ones. The +## title's `pteff02` holds at `t=46` with the 25 % dim quad at alpha 0x40 and +## then ramps to 0x00 by `t=236`; running to the end drops the dim and makes the +## whole screen ~13/255 too bright. That was measured against a plate-free +## capture of the running title, and it is what corrected this rule. +## +## Before the first keyframe the element holds its first pose -- the pre-roll a +## staggered menu needs, with the five buttons starting at t=28,30,32,34,36. func pose_at(element: Dictionary, t: float) -> Dictionary: var frames: Array = element.get("keyframes", []) var timed: Array = [] @@ -134,6 +140,10 @@ func pose_at(element: Dictionary, t: float) -> Dictionary: if timed.is_empty(): # No timed frame at all: the group is a single static pose. return frames[0] if not frames.is_empty() else element.get("rest", {}) + # Stop at the hold. Past it the group is ramping out, which is the screen + # transition and belongs to whatever is driving the transition -- not to a + # screen that has arrived and is sitting there. + t = minf(t, settle_units(element)) if t <= float(timed[0]["t"]): return timed[0] for i in range(timed.size() - 1): @@ -173,13 +183,26 @@ static func _hex_lerp(a: String, b: String, f: float) -> String: return "0x%08x" % out -## The last moment anything on this screen is still moving, in keyframe units. +## Where one element stops, in keyframe units: its hold. +## +## `rest.t` when the export gives one. An element whose `rest` carries no time is +## a single static pose, and there the last timed keyframe is the same answer. +static func settle_units(element: Dictionary) -> float: + var rest: Dictionary = element.get("rest", {}) + if rest.has("t"): + return float(rest["t"]) + var last := 0.0 + for k: Dictionary in element.get("keyframes", []): + if k.has("t"): + last = maxf(last, float(k["t"])) + return last + + +## The moment the whole screen has arrived: the last element to reach its hold. func settle_time() -> float: var last := 0.0 for element: Dictionary in screen.get("elements", []): - for k: Dictionary in element.get("keyframes", []): - if k.has("t"): - last = maxf(last, float(k["t"])) + last = maxf(last, settle_units(element)) return last From 11b11ebd2df9db03fc393fbfa20e58cfe3e8f0c4 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 21:44:08 +0000 Subject: [PATCH 17/29] build: key the reference CLI's target dir by the pinned revision The reference renderer was a revision behind for three consecutive diff runs and nothing said so. After the pin bump, `build-reference-cli` reported success at rev b436e5a and `verify-screen` showed main_menu jumping 3/255 -> 72/255. The natural reading was that the port had regressed. It had not: the port was right and the REFERENCE was stale. The shared CARGO_TARGET_DIR still held a `sylpheed-cli` built from 47f423f, cargo said "Finished in 0.13s" and left it there. A clean target dir built a binary resolving 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. A stale binary runs perfectly. So: build into `$CARGO_TARGET_DIR/reference-cli/$rev`, keyed by the pin, where a new revision has no artifacts to reuse. Then verify 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 needs an image rebuild to reach PATH; until then invoke the repo copy by path. --- docker/bin/build-reference-cli | 43 ++++++++++++++++++++++++++++++---- tools/verify-screen | 5 +++- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/docker/bin/build-reference-cli b/docker/bin/build-reference-cli index c7356f27..b86ddee9 100755 --- a/docker/bin/build-reference-cli +++ b/docker/bin/build-reference-cli @@ -36,10 +36,45 @@ if [ -z "$src" ]; then fi [ -n "$src" ] || { echo "build-reference-cli: no checkout for rev $rev" >&2; exit 1; } +# Build into a target directory KEYED BY THE REVISION. +# +# This is not tidiness. Sharing one target dir across pins silently served a +# stale binary: after the pin moved 8b6dbcf -> 5414db3, cargo reported +# "Finished in 0.13s" and left in place a `sylpheed-cli` built from the OLD +# decoders. `screen list` still worked, so the old check passed, and the +# reference renderer this whole project verifies against was a revision behind +# for three consecutive diff runs. A per-rev tree cannot do that: a new pin has +# no artifacts to reuse. echo "build-reference-cli: building sylpheed-cli from $rev" -cargo build --release --manifest-path "$src/Cargo.toml" -p sylpheed-cli +tree="$CARGO_TARGET_DIR/reference-cli/$rev" +CARGO_TARGET_DIR="$tree" cargo build --release --manifest-path "$src/Cargo.toml" -p sylpheed-cli -out="$CARGO_TARGET_DIR/release/sylpheed-cli" -"$out" screen list "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" >/dev/null \ +stable="$CARGO_TARGET_DIR/reference-cli/sylpheed-cli" +mkdir -p "$(dirname "$stable")" +cp -f "$tree/release/sylpheed-cli" "$stable" + +"$stable" screen list "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" >/dev/null \ || { echo "build-reference-cli: built, but 'screen list' failed" >&2; exit 1; } -echo "build-reference-cli: $out (rev $rev, 'screen' subcommand present)" + +# And check the binary is actually the pinned code, not merely a working one. +# `screen info` prints each element's resting placement, which is decoder +# output; if this disagrees with what the exporter wrote from the same pin, the +# two halves of the verification are not the same revision and every diff below +# is meaningless. Compare rather than assert a value, so this stays true when +# the pin moves again. +if [ -f "${PROJECT_DIR:-/work}/export/screens/title/main_menu.json" ]; then + cli_rest=$("$stable" screen info "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" --build 5 \ + | sed -n 's/.*ptframe1\.t32.*rest (\([0-9]*\),\([0-9]*\)).*/\1,\2/p') + exp_rest=$(python3 -c ' +import json,sys +d=json.load(open(sys.argv[1])) +e=next(e for e in d["elements"] if e["id"]=="ptframe1") +print("%d,%d" % tuple(e["rest"]["pos"]))' "${PROJECT_DIR:-/work}/export/screens/title/main_menu.json") + if [ "$cli_rest" != "$exp_rest" ]; then + echo "build-reference-cli: STALE OR MISMATCHED BINARY" >&2 + echo " the CLI resolves ptframe1 rest to ($cli_rest) but export/ says ($exp_rest)." >&2 + echo " Both should come from rev $rev. Delete $tree and rebuild." >&2 + exit 1 + fi +fi +echo "build-reference-cli: $stable (rev $rev, agrees with export/ on ptframe1)" diff --git a/tools/verify-screen b/tools/verify-screen index 3fd2181e..6ca45069 100755 --- a/tools/verify-screen +++ b/tools/verify-screen @@ -35,7 +35,10 @@ set -euo pipefail cd "${PROJECT_DIR:-/work}" -CLI="${SYLPHEED_CLI:-${CARGO_TARGET_DIR:-/sylph-home/port/target-container}/release/sylpheed-cli}" +# `reference-cli/`, not `release/`: the reference binary is built per pinned +# revision so a pin change cannot silently reuse the previous revision's build. +# See docker/bin/build-reference-cli. +CLI="${SYLPHEED_CLI:-${CARGO_TARGET_DIR:-/sylph-home/port/target-container}/reference-cli/sylpheed-cli}" DISC="${SYLPHEED_DISC:-/disc}" OUT="${OUT:-${TMPDIR:-/tmp}/verify-screen}" export DISPLAY="${DISPLAY:-:97}" From 0a831b6da68fb9a927e521e0cfdb6e23cebe9685 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 21:44:08 +0000 Subject: [PATCH 18/29] docs: answer the RE agent's question, and record the stale-reference failure Their question was which screens the other four of "six" elements are on. The six span the whole 12-screen export: ptframe1/ptframe2 on main_menu and main_menu_jp (trailing run alpha 0xff, VISIBLE -- their rule accepts these, and they are the four it revealed disc-wide), and pteff02 on title and title_jp (trailing run alpha 0x00, TRANSPARENT -- their rule excludes it). That exclusion is correct, and their own measurement is what proves it: pteff02 is the 25 % dim quad, and they measured the title render going from +13.14 to +0.55 against the plate-free capture once it is drawn. So `rest` must stay at 0x40 and must not move to the transparent trailing run -- which is what their alpha rule does. Two investigations converging from opposite directions, and no third discriminator is needed. Also recorded: the reference renderer was stale for three diff runs and reported success throughout, and the shape worth naming is that a build system reporting success is not evidence the artifact you are about to trust is the code you pinned. The RE agent hit the same class of trap this session from the other side. BLOCKED.md: the loop/hold question is answered (groups hold -- ptloop01/ptloop02 park off-screen at x=1521 and x=-839), and the rest_plateau entry is closed as fixed, noting the adopted rule is theirs and not the looser one this port proposed. --- docs/BLOCKED.md | 24 ++++++++-- docs/DECISIONS.md | 111 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/docs/BLOCKED.md b/docs/BLOCKED.md index 28654480..3551e217 100644 --- a/docs/BLOCKED.md +++ b/docs/BLOCKED.md @@ -57,7 +57,17 @@ than exported: ## Questions this port has raised -### Does a keyframe group loop, or hold its last pose? +### ~~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 @@ -77,9 +87,17 @@ 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 +### ~~`rest_plateau` misfires on elements with no exit animation~~ — fixed -**This one is a decoder bug, not a question**, and it is the highest-value item +**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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index cfb13fa2..00cdaf21 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -391,3 +391,114 @@ 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. From 2135d209843c04114380714920f1c9afbed85116 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 08:04:06 +0000 Subject: [PATCH 19/29] deps: bump the pin to 1a82ade -- the menu screens get their background WHAT I WANTED FROM IT: 0ed33bc, "a RATC child's name is stated, not inferred". A child was named 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. Every menu render this port has produced has been missing its background, and P1 wrote that up as a fact about the disc -- "the bundle carries zero RATC children for either, so there is no texture on the disc to export". That is retracted in docs/DECISIONS.md rather than edited away. The re-export is contained: six new sprites and nothing else. pteff05 on main_menu/extras and their JP twins, pteff04 on both titles. Each gains a `sprite` line and moves `layer_source` from "implied" to "sprite" -- the layer key is now read from the file instead of the decoders' table of keys measured off the running game, which is the ratchet turning the right way. pteff05.png is 1280x720; ptbase.png, which had been carrying the background alone, is 640x360 drawn at 200 %. Measured against the live capture rather than the other renderer: settled main_menu RMSE 8.05 % -> 5.92 %. The reference renderer was missing the same element for the same reason, so no renderer-vs-renderer diff could have found this -- the third time a capture has caught something both renderers agreed on. --- Cargo.lock | 2 +- crates/sylpheed-export/Cargo.toml | 13 +++++- docs/DECISIONS.md | 71 ++++++++++++++++++++++++++++--- 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f3a93c5..f919478a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -959,7 +959,7 @@ dependencies = [ [[package]] name = "sylpheed-formats" version = "0.1.0" -source = "git+https://git.mc02.dev/fabi/Syplheed-Reborn.git?rev=5414db3#5414db34bfc0d0b0743e07e6e89f345540dc2e94" +source = "git+https://git.mc02.dev/fabi/Syplheed-Reborn.git?rev=f817dd5#f817dd59393b1437d4ea70b58124c1309f6c076f" dependencies = [ "anyhow", "binrw", diff --git a/crates/sylpheed-export/Cargo.toml b/crates/sylpheed-export/Cargo.toml index bb6ed871..45b25dd4 100644 --- a/crates/sylpheed-export/Cargo.toml +++ b/crates/sylpheed-export/Cargo.toml @@ -14,6 +14,17 @@ license.workspace = true # is not one archive entry (segment-spanning reads, multi-sub-wave banks, and the # continuous cutscene-voice stream). Do not re-derive those here. # +# Pin moved 5414db3 -> f817dd5 on 2026-08-29. WHAT I WANTED FROM IT: `56cc7ac`, +# "a RATC child's name is stated, not inferred". A child was named by scanning +# backwards for the last printable run before its magic; for `pteff05.t32` the +# three trailing payload bytes are `38 41 58` = `8AX` and beat the real name, so +# the FULL-RESOLUTION BACKGROUND OF ALL FIVE MENU SCREENS registered under a +# name no element declares and resolved to no sprite. This port exported those +# screens without their background and said so in every render as "pteff05 (no +# sprite in the export)" -- which docs/DECISIONS.md then wrote up as correct. +# It was not. f817dd5 is the last commit touching `crates/` on that branch. +# +# Previous pin note, kept because the reasoning still holds: # Pin moved 8b6dbcf -> 5414db3 on 2026-08-28. WHAT I WANTED FROM IT: the fix to # `ui_layout::rest()`. At 8b6dbcf a trailing run of identical keyframes was # always treated as the exit, so an element with no exit animation rested at its @@ -21,7 +32,7 @@ license.workspace = true # which a capture of the running game plainly shows. 5414db3 is the revision at # which that fix carries its disc-wide check (30 of 13 991 elements move, 4 # become visible, 0 become invisible), not merely the one where it was written. -sylpheed-formats = { git = "https://git.mc02.dev/fabi/Syplheed-Reborn.git", rev = "5414db3" } +sylpheed-formats = { git = "https://git.mc02.dev/fabi/Syplheed-Reborn.git", rev = "f817dd5" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 00cdaf21..11ae0af8 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -266,12 +266,10 @@ 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 +### ~~`pteff05.t32` and `pteff04.t32` have no sprite, and that is correct~~ -Both renderers skip them. The bundle declares them and carries **zero** RATC -children for either, so there is no texture on the disc to export — this is a -property of the disc, not a gap in the exporter, and `ScreenView` reports it as -`no sprite in the export` rather than dropping it silently. +**RETRACTED 2026-08-29. This was wrong, and it was the most consequential thing +on this page.** See "The menu had no background" below. --- @@ -502,3 +500,66 @@ 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`. From 74b61b6ba4534432c44645901bad7f8aca723166 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 08:04:38 +0000 Subject: [PATCH 20/29] docs: record what the port needs from the RE agent, ranked by cost to the port Five items, written down here rather than only in a message, because a request that lives in one conversation dies with the container. Two are blocking P3 (how to recognise the splash bundle, and what the ~0.4 s fade-out actually measures), one is cheap and prevents P5 rework (focus drawn over vs instead of the base -- the port picked replace without evidence, and the oracle says it picked wrong), one needs a joint decision rather than a unilateral one (whether the port should draw the newly-decoded rotation and thereby become deliberately more correct than the renderer it verifies against), and one calibrates whether RMSE against captures has a floor at all. --- docs/BLOCKED.md | 74 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/docs/BLOCKED.md b/docs/BLOCKED.md index 3551e217..65861483 100644 --- a/docs/BLOCKED.md +++ b/docs/BLOCKED.md @@ -9,7 +9,7 @@ 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-28**, at `/reborn` HEAD `8b4965f`. +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.) @@ -55,6 +55,78 @@ than exported: | 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 From ab3ca243c5848e2d92d5281ab5be314046d694a2 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 08:27:41 +0000 Subject: [PATCH 21/29] export: reach the splash by authored entry index, and key names by entry There were TWO splash screens and this port had neither. Entries 11/14 are the developer logos; entries 10/13 are the SQUARE ENIX publisher wordmark, the first thing the boot shows, which nothing in this project had noticed. They have no .rat layout child so `is_build` cannot see them, and the RE agent established that no CONTENT rule can either: design size fails (every extra composable bundle sampled is 1280x720, same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD, the splash halves are 3 and 7 -- the ranges overlap). So `screen_builds` is `is_build` plus an authored allow-list of ENTRY INDICES, each carrying a `why` that says it is a locator and not a claim. An allow-list rather than a loosened predicate because this is safe in GP_TITLE and would not be in general: there, widening adds exactly four bundles and all four are real screens, zero fragments. screen_names.json is rekeyed from enumeration ordinal to pak entry. Widening renumbers ordinals, and a name that moves when the enumeration rule changes is not a name -- the file always called the entry the stronger locator, and it is now the only stable one. The two unnamed plates therefore renamed build_10/11 -> build_12/15; they were always locators, and now they locate the right thing. All 16 export and validate. --- authored/flow.json | 53 +++++++++++++++++++ authored/screen_names.json | 77 +++++++++++++++++++++++----- authored/timing.json | 40 +++++++++++---- crates/sylpheed-export/src/main.rs | 82 +++++++++++++++++++++++++----- 4 files changed, 214 insertions(+), 38 deletions(-) create mode 100644 authored/flow.json diff --git a/authored/flow.json b/authored/flow.json new file mode 100644 index 00000000..3575f4ea --- /dev/null +++ b/authored/flow.json @@ -0,0 +1,53 @@ +{ + "format": "sylpheed.flow/1", + + "_": [ + "The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a", + "negative -- the order is in none of the four places it could have been. It is", + "not in config.ini's empty [SYSTEM], not in the movie manifest (which carries", + "assets, not transitions), not in a persistent GamePart field (the requested id", + "lives only as a stack argument in flight), and `GP_ADVERTISE_DEMO` has zero", + "xrefs of any kind. A transition is a call with a name argument, chosen by code.", + "", + "So this file REPRODUCES AN OBSERVATION. The sequence below is what the RE", + "agent watched the game do, not what any file on the disc says it does. Nothing", + "here may be presented as decoded." + ], + + "boot": [ + { + "screen": "publisher_logo", + "why": "The SQUARE ENIX wordmark is the first thing the boot shows -- RE agent, 2026-08-29. Entry 10 of the pair; 13 is its region twin and the port shows one, not both." + }, + { + "screen": "developer_logos", + "why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2." + }, + { + "screen": "title", + "why": "HANDOFF Q2/Q6: the boot reaches the title after the splashes. The intro video (ADVERTISE_MOVIE -> ADV.wmv) plays between the splash and the title in the real boot and is SKIPPED here -- it is P4, and the sequencer names the gap rather than pretending the order is different." + } + ], + + "dwell": { + "_": [ + "DELIBERATELY EMPTY. Each screen's dwell is its own keyframe group -- the", + "publisher wordmark reaches its hold at t=235 (3.92 s) and the developer", + "logos at t=190 (3.17 s), both read from the disc. Holding beyond that would", + "be a number nobody has measured, so the sequencer holds for zero extra time", + "and the pacing is the disc's own.", + "", + "When a capture times the real boot, the extra hold per screen goes here." + ] + }, + + "screens": { + "_": [ + "What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the", + "destination screens and the RE agent later decoded that a transition is a", + "lookup by NAME, giving a candidate vocabulary (TITLE_SCREEN, TITLE_MENU,", + "LOADING, DIFFICULTY, EXTRA_MENU, TUTORIAL_MENU). Those are the right `goto`", + "targets when this is written, marked as the name match they are." + ] + } +} diff --git a/authored/screen_names.json b/authored/screen_names.json index 8ce84420..89df17c8 100644 --- a/authored/screen_names.json +++ b/authored/screen_names.json @@ -1,17 +1,18 @@ { "format": "sylpheed.screen_names/1", "_": [ - "Which GP_TITLE build is which screen. AUTHORED: the disc does not name its", + "Which GP_TITLE pak entry is which screen. AUTHORED: the disc does not name its", "builds, so every name here is a decision. The identifications come from", - "HANDOFF Q2 (ui-title-build-map.md), which measured four of them against", - "framebuffer captures of the running game; the exporter stamps the name into", - "the screen file with name_source: \"authored\" so a reader can tell a", - "recovered name from an invented one.", + "HANDOFF Q2 (ui-title-build-map.md), which measured them against framebuffer", + "captures of the running game; the exporter stamps the name into the screen", + "file with name_source: \"authored\" so a reader can tell a recovered name from", + "an invented one.", "", - "`build` is the index into the pak's list of screen builds -- what", - "`sylpheed-cli screen --build N` takes -- and is stable as long as the", - "enumeration rule is. The screen file also records the pak entry index,", - "which is the stronger locator.", + "KEYED BY PAK ENTRY INDEX, not by the enumeration ordinal. It used to be the", + "ordinal; widening the enumeration to reach the splash renumbers ordinals, and", + "a name that moves when the enumeration rule changes is not a name. The entry", + "was always described here as the stronger locator -- now it is the only", + "stable one.", "", "Delete an entry here the day the RE agent decodes a name field." ], @@ -21,7 +22,10 @@ "name": "press_start", "why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture." }, - "3": { "name": "press_start_jp", "why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need." }, + "3": { + "name": "press_start_jp", + "why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need." + }, "4": { "name": "title", "why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture." @@ -34,12 +38,57 @@ "name": "extras", "why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture." }, - "7": { "name": "title_jp", "why": "HANDOFF Q2: the Japanese twin of build 4." }, - "8": { "name": "main_menu_jp", "why": "HANDOFF Q2: the Japanese twin of build 5." }, - "9": { "name": "extras_jp", "why": "HANDOFF Q2: the Japanese twin of build 6." } + "7": { + "name": "title_jp", + "why": "HANDOFF Q2: the Japanese twin of build 4." + }, + "8": { + "name": "main_menu_jp", + "why": "HANDOFF Q2: the Japanese twin of build 5." + }, + "9": { + "name": "extras_jp", + "why": "HANDOFF Q2: the Japanese twin of build 6." + }, + "10": { + "name": "publisher_logo", + "why": "The SQUARE ENIX PUBLISHER wordmark -- the FIRST thing the boot sequence shows, before the developer logos. Measured by the RE agent 2026-08-29, render grid at docs/re/captures/title-builds/splash-both-halves-rendered.png. Entries 10/13 are region twins distinguished by the trademark glyph; 10 carries the (TM)." + }, + "13": { + "name": "publisher_logo_r", + "why": "The region twin of entry 10, carrying (R) where 10 carries (TM). Named so it is not mistaken for a second screen the boot path needs." + }, + "11": { + "name": "developer_logos", + "why": "The GAME ARTS / SETA / studio anima logos -- the developer splash, shown after the publisher wordmark. HANDOFF Q2 and the RE agent's 2026-08-29 render grid; draws 7/7 elements." + }, + "14": { + "name": "developer_logos_r", + "why": "The region twin of entry 11, as 13 is to 10." + } } }, "unnamed": { - "dat/GP_TITLE.pak": "Builds 0/1 and 10/11 are a DELTASABER / SYLPHEED A.I. plate that was never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their build index rather than a name we would be inventing." + "dat/GP_TITLE.pak": "Entries 0/1 and 12/15 are plates never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their entry index rather than a name we would be inventing." + }, + "also_export": { + "dat/GP_TITLE.pak": { + "10": { + "name": "publisher_logo", + "why": "LOCATED BY ENTRY INDEX, not by a rule. These four bundles declare their sprites directly and have no .rat layout child, so `is_build` cannot see them -- and the RE agent established that NO content rule can: design size fails (every extra composable bundle sampled is 1280x720, the same as every screen) and element count fails (fragments run 2..15 elements in GP_OPTIONS/GP_SAVE_LOAD while these are 3 and 7 -- the ranges overlap). Safe here and not in general: in GP_TITLE the widened set adds exactly these four and all four are real screens, zero fragments." + }, + "11": { + "name": "developer_logos", + "why": "As entry 10: located by index because no content rule distinguishes a splash from a fragment. 7 elements, all drawn." + }, + "13": { + "name": "publisher_logo_r", + "why": "As entry 10, region twin." + }, + "14": { + "name": "developer_logos_r", + "why": "As entry 11, region twin." + } + } } } diff --git a/authored/timing.json b/authored/timing.json index 52bc67ae..2ae18c5c 100644 --- a/authored/timing.json +++ b/authored/timing.json @@ -1,6 +1,5 @@ { "format": "sylpheed.timing/1", - "keyframe_units_per_second": 60, "why": [ "HANDOFF Q1. The disc says a keyframe is at `t=30`; it does not say what a", @@ -20,20 +19,41 @@ ], "kind": "measured", "source": "/reborn docs/port/HANDOFF.md Q1, docs/re/ui-keyframe-time-unit.md", - "ramp": "linear", "ramp_why": [ "Also HANDOFF Q1, and part of the same measurement: the fade lands on the", "linear value at every one of the seven sampled frames, so there is no ease." ], - - "exit_ramp_seconds": null, + "exit_ramp_seconds": 0.4, "exit_ramp_why": [ - "NOT SET, deliberately. The last keyframe of every group carries no time --", - "the disc has no time slot there -- so the duration of the ramp INTO the", - "exit pose is unknown. HANDOFF Q7 measured the screen fade-out at ~0.4 s,", - "but that is the transition, which is P3's to author with its own evidence.", - "P2 plays the timed keyframes and holds; it never plays the exit ramp,", - "because it would have to invent how long it takes." + "HANDOFF Q7 + the RE agent's 2026-08-29 answer. MEASURED, not on the disc.", + "", + "Every element of a screen ends on exactly ONE untimed keyframe, so there is", + "exactly one unknown duration per screen -- the ramp INTO that final keyframe.", + "This is that duration. ~0.4 s, which is 24 units at 60 units/s.", + "", + "The alternative readings were tested and refuted. It is not a black quad laid", + "over a frozen screen: under that model a black rect scales every region by the", + "same 1-alpha, so the button-region / background-region brightness RATIO would", + "be constant through the fade. Measured on the RE agent's filmstrip it falls", + "6.495 -> 5.574 -> 3.105 -> 2.125 -> 1.935, a 3.4x monotonic drop. The screen", + "itself plays out: pteff00.prm ramps to opaque black while the button labels,", + "ptmsg, pteff10 and pteff12 all ramp to transparent, and ptframe1/2 hold.", + "", + "REACH, quoted from the RE agent rather than smoothed over: 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. Treat the", + "number as approximate and the model as established." + ], + "exit_ramp_units": 24, + "dwell_seconds": null, + "dwell_why": [ + "NOT SET, and not needed. A screen's dwell is its OWN keyframe group: the", + "publisher wordmark reaches its hold at t=235 (3.92 s) and the developer logos", + "at t=190 (3.17 s), both read from the disc. Adding a hold on top of that would", + "be inventing a number nobody measured, so the sequencer holds for zero extra", + "time and the pacing you see is the disc's own.", + "", + "If a capture ever times the real boot, this is where that number goes." ] } diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index b1810836..5e5778bd 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -78,7 +78,11 @@ struct Manifest { warnings: Vec, } -/// The authored `build index → name` map, keyed by archive path. +/// The authored `pak entry index → name` map, keyed by archive path. +/// +/// Keyed by **entry**, not by the enumeration ordinal. The file itself always +/// called the entry "the stronger locator"; it is now also the only stable one, +/// because widening the enumeration to reach the splash renumbers the ordinals. type NameMap = std::collections::BTreeMap>; #[derive(serde::Deserialize)] @@ -96,6 +100,8 @@ fn load_names(authored: &Path) -> Result { #[derive(serde::Deserialize)] struct File { archives: NameMap, + #[serde(default)] + also_export: AlsoExport, } let raw = std::fs::read_to_string(&path) .with_context(|| format!("read {}", path.display()))?; @@ -104,17 +110,54 @@ fn load_names(authored: &Path) -> Result { .archives) } -/// Every RATC entry of a UI pak that parses as a screen build. +/// Extra pak entries to export that `is_build` does not accept, keyed by +/// archive. AUTHORED, and each carries its own `why`. +type AlsoExport = + std::collections::BTreeMap>; + +fn load_also_export(authored: &Path) -> Result { + let path = authored.join("screen_names.json"); + if !path.exists() { + return Ok(AlsoExport::new()); + } + #[derive(serde::Deserialize)] + struct File { + #[serde(default)] + also_export: AlsoExport, + } + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("read {}", path.display()))?; + Ok(serde_json::from_str::(&raw) + .with_context(|| format!("parse {}", path.display()))? + .also_export) +} + +/// Every RATC entry of a UI pak this exporter treats as a screen. /// -/// The filter is `is_build` — a bundle with a `.rat` layout child. The developer -/// splash declares its sprites directly and has none, so it is invisible here; -/// that is P3's problem and is recorded as a manifest warning rather than -/// silently widened. -fn screen_builds(ar: &PakArchive) -> Vec<(usize, Vec)> { +/// The rule is `is_build` — a bundle with a `.rat` layout child — **plus an +/// authored allow-list of entry indices**. +/// +/// The allow-list exists because the splash screens declare their sprites +/// directly and have no `.rat` child, so `is_build` cannot see them, and **there +/// is no content rule that would**. The RE agent looked: design size fails +/// (every extra composable bundle sampled is 1280x720, 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 the splashes are located **by entry index**, which is a locator +/// and not a claim, and each one says so in its own `why`. +/// +/// This is safe here rather than in general: in `GP_TITLE` the widened set adds +/// exactly four bundles and all four are real screens, with zero fragments. In +/// another archive it would not be, which is why this is an allow-list and not +/// a widened predicate. +fn screen_builds(ar: &PakArchive, also: Option<&std::collections::BTreeMap>) + -> Vec<(usize, Vec)> +{ let mut out = Vec::new(); for (i, e) in ar.entries().iter().enumerate() { let Ok(bytes) = ar.read(e) else { continue }; - if ui_layout::is_build(&bytes) { + let allowed = also.is_some_and(|m| m.contains_key(&i.to_string())); + if ui_layout::is_build(&bytes) || allowed { out.push((i, bytes)); } } @@ -150,18 +193,26 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { let archive = "dat/GP_TITLE.pak"; let pak = disc.join(archive); let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?; - let builds = screen_builds(&ar); + let also = load_also_export(authored_dir)?; + let archive_also = also.get(archive); + let builds = screen_builds(&ar, archive_also); println!("{archive}: {} screen build(s)", builds.len()); let archive_names = names.get(archive); let mut screens = Vec::new(); for (build_idx, (entry, bytes)) in builds.iter().enumerate() { - let authored = archive_names.and_then(|m| m.get(&build_idx.to_string())); - let (name, name_source, why) = match authored { + // Keyed by ENTRY, not by the ordinal: widening the enumeration to reach + // the splash renumbers ordinals, and a name that moves when the rule + // changes is not a name. + let key = entry.to_string(); + let named = archive_names + .and_then(|m| m.get(&key)) + .or_else(|| archive_also.and_then(|m| m.get(&key))); + let (name, name_source, why) = match named { Some(e) => (e.name.clone(), "authored", e.why.clone()), // Nobody has identified this build. Emit a stable synthetic id and // say in the file that the name is not a recovered one. - None => (format!("build_{build_idx:02}"), "index", None), + None => (format!("build_{entry:02}"), "index", None), }; let ex = screen::export_build( &out, @@ -204,8 +255,11 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { warnings: vec![ "P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive." .into(), - "The developer-logo splash is not here: it declares its sprites directly and has \ - no .rat layout child, so `is_build` does not see it. P3." + "The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \ + layout child, so `is_build` cannot see them and no content rule can: element \ + count and design size both overlap with two-element fragments in other archives. \ + They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \ + which is a locator and not a claim -- see each one's name_why." .into(), ], }; From 83f1c750a4261de72870151bb5fdbf19a2cbb3b8 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 08:27:41 +0000 Subject: [PATCH 22/29] port: play the exit ramp, and run the boot sequence unattended P3. The exit is the group playing ITSELF out, not a black rect over a frozen screen -- and that was settled by a test that discriminates rather than by plausibility. Under the black-rect model every region is scaled by the same 1-alpha, so the button/background brightness RATIO would hold constant through the fade; measured, it falls 6.495 -> 5.574 -> 3.105 -> 2.125 -> 1.935. Implemented by giving the final untimed keyframe a SYNTHETIC time, exit_ramp_units after the last timed one, then interpolating it like any other frame. One code path: arriving and leaving differ only in how far `t` is allowed to run, not in kind. `holding` is what the sequencer clears to send a screen away. The sequencer waits on nothing the disc does not carry. A screen holds until its own group has arrived, then plays out; `dwell` in flow.json is deliberately empty because each screen's dwell IS its keyframe group (publisher wordmark 3.92 s, developer logos 3.17 s, both read from the disc). Any extra hold would be a number nobody measured. The last screen keeps holding -- nothing is taking the title's place, and a boot that ends by fading to black looks like a boot that crashed. flow.json reproduces an OBSERVATION and says so in its header: Q6 closed with a negative, the order is on the disc nowhere, a transition is a call with a name argument chosen by code. The intro video's place in the real boot is named as a gap rather than the order being quietly rewritten to hide it. --- port/scripts/boot.gd | 77 ++++++++++++++++++++++++++++++++++++- port/scripts/screen_view.gd | 51 ++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 1ac135fb..61c3b193 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -8,6 +8,8 @@ # godot --path port -- --screen=main_menu --capture=/tmp/godot.png # godot --path port -- --screen=main_menu --time=0.5 --capture=/tmp/at-half.png # godot --path port -- --screen=main_menu --pose=rest --capture=/tmp/rest.png +# godot --path port -- --boot # the whole boot sequence +# godot --path port -- --boot --film=/tmp/boot # ...and a frame every 0.25 s # # `--time` is in SECONDS and freezes the timeline there; without it the screen # animates in real time from t=0. `--pose=rest` draws the export's declared @@ -36,7 +38,17 @@ func _ready() -> void: get_tree().quit(2) return - var name: String = args.get("screen", DEFAULT_SCREEN) + _flow = export_tree.authored("flow.json") + if args.has("boot"): + if _flow == null: + push_error(export_tree.error) + get_tree().quit(2) + return + for step: Dictionary in _flow["boot"]: + _sequence.append(String(step["screen"])) + _film = args.get("film", "") + + var name: String = _sequence[0] if not _sequence.is_empty() else args.get("screen", DEFAULT_SCREEN) var screen: Dictionary = export_tree.screen(name) if screen.is_empty(): push_error(export_tree.error) @@ -74,6 +86,9 @@ func _ready() -> void: get_tree().quit(2) return view.units_per_second = float(timing["keyframe_units_per_second"]) + # The one unknown duration per screen: the ramp into the final untimed + # keyframe. Authored, because the disc has no time slot there. + view.exit_ramp_units = float(timing["exit_ramp_units"]) viewport.add_child(view) if not view.load_screen(export_tree, name): @@ -91,20 +106,66 @@ func _ready() -> void: view.time_units = float(args["time"]) * view.units_per_second view.queue_redraw() + if _film != "": + set_process(true) + _film_capture() + if args.has("capture"): await _capture(args["capture"]) get_tree().quit(0) var _frozen := false +var _flow: Variant = null +var _sequence: Array[String] = [] +var _step := 0 +var _film := "" +var _film_frame := 0 +var _film_next := 0.0 +var _elapsed := 0.0 +var _boot_done := false func _process(delta: float) -> void: if _frozen or view == null: return view.time_units += delta * view.units_per_second + _elapsed += delta view.queue_redraw() + if _sequence.is_empty(): + return + + # A screen holds at `rest` until it has arrived, then plays itself out and + # the next one begins. Nothing waits on a timer the disc does not carry: the + # pacing is each group's own timeline (authored/flow.json, `dwell`). + if view.holding and view.time_units >= view.settle_time(): + # The LAST screen in the sequence keeps holding. A screen plays itself + # out because something is taking its place; nothing is taking the + # title's place here, and 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. + if _step + 1 < _sequence.size(): + view.holding = false + elif not _boot_done: + _boot_done = true + print("boot sequence complete after %.2f s, holding on %s" % [_elapsed, _sequence[_step]]) + if _film == "": + get_tree().quit(0) + elif not view.holding and view.time_units >= view.exit_time(): + _advance() + + +func _advance() -> void: + _step += 1 + var next := _sequence[_step] + print(" -> %s at %.2f s" % [next, _elapsed]) + view.holding = true + view.time_units = 0.0 + if not view.load_screen(view.tree, next): + push_error(view.tree.error) + get_tree().quit(2) + func _capture(path: String) -> void: # Two frames: the first is the one this callback is still inside of. @@ -124,6 +185,18 @@ func _capture(path: String) -> void: print("captured %dx%d -> %s" % [img.get_width(), img.get_height(), path]) +## A frame every 0.25 s for the whole run, so an unattended boot leaves a +## filmstrip behind rather than requiring someone to be watching it. +func _film_capture() -> void: + while true: + await RenderingServer.frame_post_draw + if _elapsed >= _film_next: + var img := viewport.get_texture().get_image() + img.save_png("%s_%03d.png" % [_film, _film_frame]) + _film_frame += 1 + _film_next += 0.25 + + # Godot passes everything after `--` through untouched; take `--key=value`. static func _args() -> Dictionary: var out := {} @@ -131,4 +204,6 @@ static func _args() -> Dictionary: if arg.begins_with("--") and arg.contains("="): var pair := arg.substr(2).split("=", true, 1) out[pair[0]] = pair[1] + elif arg.begins_with("--"): + out[arg.substr(2)] = "1" return out diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd index 9f6837f7..08cb8f6e 100644 --- a/port/scripts/screen_view.gd +++ b/port/scripts/screen_view.gd @@ -37,6 +37,15 @@ var pose_mode: Pose = Pose.TIMELINE var time_units: float = 0.0 var units_per_second: float = 60.0 +## Duration of the ramp into the final, untimed keyframe -- the screen playing +## itself out. Authored (`authored/timing.json`): the disc has no time slot on +## that keyframe, so this is the one unknown duration per screen. +var exit_ramp_units: float = 24.0 + +## While true the screen holds at `rest` and never plays its exit. The +## sequencer clears it to send the screen away. +var holding: bool = true + var tree: ExportTree = null var screen: Dictionary = {} var textures: Dictionary = {} @@ -140,10 +149,26 @@ func pose_at(element: Dictionary, t: float) -> Dictionary: if timed.is_empty(): # No timed frame at all: the group is a single static pose. return frames[0] if not frames.is_empty() else element.get("rest", {}) - # Stop at the hold. Past it the group is ramping out, which is the screen - # transition and belongs to whatever is driving the transition -- not to a - # screen that has arrived and is sitting there. - t = minf(t, settle_units(element)) + # While holding, stop at the hold: past it the group is ramping out, and a + # screen that has arrived and is sitting there is not leaving. + if holding: + t = minf(t, settle_units(element)) + # The exit. The final keyframe carries no `t` -- the disc has no slot for one + # -- so it is given a synthetic time `exit_ramp_units` after the last timed + # frame and then interpolated like any other. That keeps one code path: the + # difference between arriving and leaving is only how far `t` is allowed to + # run, not a second kind of animation. + # + # The whole group plays out, not just the fade quad: on the main menu + # pteff00 ramps to opaque black while the labels ramp to transparent and + # ptframe1/2 hold. Modelling the exit as a black rect over a frozen screen + # was measured and refuted -- see authored/timing.json. + var last_frame: Dictionary = frames[frames.size() - 1] + if not last_frame.has("t"): + var exit_frame := last_frame.duplicate() + exit_frame["t"] = float(timed[timed.size() - 1]["t"]) + exit_ramp_units + timed.append(exit_frame) + if t <= float(timed[0]["t"]): return timed[0] for i in range(timed.size() - 1): @@ -206,6 +231,24 @@ func settle_time() -> float: return last +## The moment the screen has finished playing itself out, in keyframe units -- +## the last element's final timed keyframe plus the authored exit ramp. +func exit_time() -> float: + var last := 0.0 + for element: Dictionary in screen.get("elements", []): + var frames: Array = element.get("keyframes", []) + if frames.is_empty(): + continue + var timed_end := 0.0 + for k: Dictionary in frames: + if k.has("t"): + timed_end = maxf(timed_end, float(k["t"])) + if not frames[frames.size() - 1].has("t"): + timed_end += exit_ramp_units + last = maxf(last, timed_end) + return last + + # An element is a ghost only when another element on the same screen carries the # same id *without* the template bit -- the template it is a repeat of. func _template_instance_ids() -> Dictionary: From c5254943b7cdf195e2e79ad21820525a00a09b2c Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 08:27:41 +0000 Subject: [PATCH 23/29] docs: P3 -- the boot runs unattended, and four answers taken as given Gate: `godot --path port -- --boot --film=/tmp/boot` runs publisher_logo -> developer_logos (4.65 s) -> title (8.57 s), holding on the title after 13.05 s, each screen fading in, holding, and fading through black into the next. verify-screen now covers all 16 screens and passes `--all` to the reference renderer: the exporter addresses by pak entry index, which is the numbering `--all` uses, and without it `--build 10` would land on entry 12. The four new splash bundles come in at max 1-2/255. The three known differences are unchanged. Also recorded, taken from the RE agent rather than re-derived: * Focus stays "replace" -- over-vs-instead is unobservable (the focused sprite covers the base at 100 % of base-visible pixels; the two compositions differ by RMSE 1.1, under the gamma floor). The port guessed right for the wrong reason. The real gap is that ptbtn0Nf.rat declares TWO sprites -- a glowing ring, then the label -- where ptbtn0N.rat declares one. P5's, and the ring's placement is not decoded, so it will be authored from the capture and marked as such. * RMSE against captures has a FLOOR: capture ~ 255*(render/255)^g, g ~ 1.49 menu and EXTRAS, 1.34 title, and it is a ramp the GAME installed, not a capture-path artefact to subtract. Narrow reach (fitted on mostly-dark patches), so the port will not extrapolate it and will not apply it to rendered output. 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 -- about the declared pivot, measured -- and it has zero effect on the five screens at rest. --- docs/DECISIONS.md | 119 ++++++++++++++++++++++++++++++++++++++++++++ tools/verify-screen | 6 ++- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 11ae0af8..16d7a2b3 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -563,3 +563,122 @@ 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. diff --git a/tools/verify-screen b/tools/verify-screen index 6ca45069..fc98aa6a 100755 --- a/tools/verify-screen +++ b/tools/verify-screen @@ -65,8 +65,12 @@ m=json.load(open("export/manifest.json")) f=next(s["file"] for s in m["screens"] if s["name"]==sys.argv[1]) print(json.load(open("export/"+f))["source"]["build"])' "$name") + # `--all` because the exporter now addresses by PAK ENTRY INDEX, which is the + # numbering `--all` uses; without it the CLI enumerates only the 12 bundles + # `is_build` accepts and `--build 10` would land on entry 12. `--all` widens + # the list, it does not change how any one bundle composites. "$CLI" screen render "$DISC/dat/GP_TITLE.pak" "$OUT/$name.ref.png" \ - --build "$build" --black --primitives --animated >/dev/null + --build "$build" --all --black --primitives --animated >/dev/null godot --path port --resolution 1280x720 -- \ "--screen=$name" --pose=rest "--capture=$OUT/$name.godot.png" >"$OUT/$name.log" 2>&1 From f0b050cfe1722e2773f3bb4acc88c7fad2c7d6c0 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 08:44:25 +0000 Subject: [PATCH 24/29] port: P4 -- the intro video plays inside the boot sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exporter transcodes ADV.wmv and S00A.wmv to Ogg Theora and records the exact ffmpeg command in the manifest, per MISSION §6, so a modder who dislikes the quality re-runs one line rather than reverse-engineering what was done. Quality was MEASURED, not judged: SSIM against the decoded source over a 10 s sample is 0.9863 / 0.9896 / 0.9924 at -q:v 6 / 8 / 10, and 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. So MISSION §6's permitted FFmpeg-GDExtension fallback is NOT needed and is NOT being proposed. No new runtime dependency. -ac 2 because the source is 6-channel WMA Pro; that downmix is a decision, so it lives in the recorded command rather than in prose. Encoding is cached on a .cmd sidecar holding the command and the source size -- any change to either re-encodes. export/ is still regenerated wholesale; this is derived state validating derived state, not a hand-edit, and without it every re-export pays ~4 minutes to produce a byte-identical file. The player renders INTO the design SubViewport. Parenting it to the Boot node played the movie to the window instead, and every captured frame came out black -- which is worth more than a capture-bug note: a movie outside the 1280x720 design space is outside the coordinate system every screen is expressed in. (A) skips a movie, because Q9 measured that (title at 57 s vs a 193 s baseline). NOT VERIFIED, and stated as such: audible playback. This container has no audio device and Godot falls back to the dummy driver. The Vorbis stream exists, is 2-channel and decodes; whether Godot emits it is unconfirmed. --- authored/flow.json | 12 +-- crates/sylpheed-export/src/main.rs | 31 ++++++++ crates/sylpheed-export/src/video.rs | 111 ++++++++++++++++++++++++++++ docs/DECISIONS.md | 68 +++++++++++++++++ port/scripts/boot.gd | 78 +++++++++++++++++-- port/scripts/export_tree.gd | 14 ++++ 6 files changed, 302 insertions(+), 12 deletions(-) create mode 100644 crates/sylpheed-export/src/video.rs diff --git a/authored/flow.json b/authored/flow.json index 3575f4ea..5025fa5d 100644 --- a/authored/flow.json +++ b/authored/flow.json @@ -1,6 +1,5 @@ { "format": "sylpheed.flow/1", - "_": [ "The boot sequence. AUTHORED, and it has to be: HANDOFF Q6 closed this with a", "negative -- the order is in none of the four places it could have been. It is", @@ -13,7 +12,6 @@ "agent watched the game do, not what any file on the disc says it does. Nothing", "here may be presented as decoded." ], - "boot": [ { "screen": "publisher_logo", @@ -23,12 +21,17 @@ "screen": "developer_logos", "why": "GAME ARTS / SETA / studio anima, after the publisher wordmark. HANDOFF Q2." }, + { + "video": "ADV", + "why": "HANDOFF Q9, DECODED from the movie manifest: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the attract movie are the SAME asset -- there is no separate boot slot. Its POSITION here (after the developer logos, before the title) is measured, not decoded: it is the order the RE agent watched the game boot in.", + "skippable": true, + "skippable_why": "HANDOFF Q9: one (A) press skips a movie -- measured, title reached at 57 s against a 193 s baseline." + }, { "screen": "title", - "why": "HANDOFF Q2/Q6: the boot reaches the title after the splashes. The intro video (ADVERTISE_MOVIE -> ADV.wmv) plays between the splash and the title in the real boot and is SKIPPED here -- it is P4, and the sequencer names the gap rather than pretending the order is different." + "why": "HANDOFF Q2/Q6: the boot reaches the title after the intro movie. The port holds here -- nothing takes the title's place until P5 gives it somewhere to go." } ], - "dwell": { "_": [ "DELIBERATELY EMPTY. Each screen's dwell is its own keyframe group -- the", @@ -40,7 +43,6 @@ "When a capture times the real boot, the extra hold per screen goes here." ] }, - "screens": { "_": [ "What each button does. NOT FILLED IN -- that is P5. HANDOFF Q4 measured the", diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index 5e5778bd..7817acef 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -13,6 +13,7 @@ //! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope. mod check; +mod video; mod screen; use anyhow::{Context, Result}; @@ -67,6 +68,16 @@ struct ManifestScreen { missing_sprites: Vec, } +#[derive(Serialize)] +struct ManifestVideo { + name: String, + file: String, + /// The exact command that produced this file. MISSION §6: a modder who + /// dislikes the quality re-runs one line rather than reverse-engineering it. + command: String, + why: &'static str, +} + #[derive(Serialize)] struct Manifest { format: &'static str, @@ -75,6 +86,8 @@ struct Manifest { formats_rev: &'static str, disc: String, screens: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + videos: Vec, warnings: Vec, } @@ -246,12 +259,30 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { }); } + // MISSION §6: the boot intro and the one new-game intro only. + let mut videos = Vec::new(); + for m in video::MOVIES { + match video::transcode(disc, out, m)? { + Some(t) => { + println!(" video {} -> {}", m.src, t.file); + videos.push(ManifestVideo { + name: t.name, + file: t.file, + command: t.command, + why: t.why, + }); + } + None => println!(" video {} not on this disc -- skipped", m.src), + } + } + let manifest = Manifest { format: "sylpheed.manifest/1", exporter: EXPORTER, formats_rev: FORMATS_REV, disc: disc.display().to_string(), screens, + videos, warnings: vec![ "P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive." .into(), diff --git a/crates/sylpheed-export/src/video.rs b/crates/sylpheed-export/src/video.rs new file mode 100644 index 00000000..fa580520 --- /dev/null +++ b/crates/sylpheed-export/src/video.rs @@ -0,0 +1,111 @@ +//! Movies: disc WMV → Ogg Theora, because Godot 4 plays Theora natively and +//! will never be taught to read WMV. +//! +//! The transcode command is **recorded in the manifest verbatim**. A modder who +//! dislikes the quality re-runs one line rather than reverse-engineering what +//! was done to their video, which is the whole reason this project converts the +//! disc instead of reading it at runtime. + +use anyhow::{bail, Context, Result}; +use std::path::Path; +use std::process::Command; + +/// A movie in scope for this port. +pub struct Movie { + /// Path under the disc root. + pub src: &'static str, + /// Output stem under `export/video/`. + pub stem: &'static str, + pub why: &'static str, +} + +/// MISSION §6: the boot intro and the one new-game intro. The disc holds 3.3 GB +/// of video and transcoding all of it is not this milestone. +pub const MOVIES: &[Movie] = &[ + Movie { + src: "dat/movie/ADV.wmv", + stem: "ADV", + why: "HANDOFF Q9: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the \ + attract movie are the SAME asset -- there is no separate boot slot.", + }, + Movie { + src: "dat/movie/S00A.wmv", + stem: "S00A", + why: "HANDOFF Q9: MS00A -> S00A.wmv is the new-game intro. P7.", + }, +]; + +/// The encode. +/// +/// `-q:v 8` was chosen by measurement, not taste: against the decoded source, +/// SSIM over a 10 s sample is 0.9863 at q6, **0.9896 at q8** and 0.9924 at q10, +/// and q8 is visually indistinguishable at 200 % zoom on the reel's hardest +/// case — fine serif text and soft gradients over near-black, which is where +/// Theora usually breaks first. MISSION §6 anticipated that 720p Theora might +/// be too poor and asked for the FFmpeg-GDExtension fallback to be *proposed* +/// if so. It is not: **no runtime dependency is needed, and none is requested.** +/// +/// `-ac 2` because the source is 6-channel WMA Pro and Godot's Theora playback +/// is not a surround path. Downmixing is a decision, so it is in the recorded +/// command where a modder can see and change it. +fn args(src: &Path, out: &Path) -> Vec { + [ + "-hide_banner", "-loglevel", "error", "-y", + "-i", &src.display().to_string(), + "-c:v", "libtheora", "-q:v", "8", + "-c:a", "libvorbis", "-q:a", "5", "-ac", "2", + &out.display().to_string(), + ] + .iter() + .map(|s| s.to_string()) + .collect() +} + +pub struct Transcoded { + pub name: String, + pub file: String, + pub command: String, + pub why: &'static str, +} + +/// Transcode one movie, skipping the encode when the output already exists and +/// was produced by exactly this command against exactly this source. +/// +/// `export/` is still regenerated wholesale — this is a cache, not a hand-edit. +/// The sidecar records the command and the source size, so any change to either +/// re-encodes. Without it every re-export pays ~4 minutes to produce a +/// byte-identical file, and an exporter nobody re-runs is worse than a cache. +pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result> { + let src = disc.join(m.src); + if !src.exists() { + return Ok(None); + } + let dir = out.join("video"); + std::fs::create_dir_all(&dir)?; + let ogv = dir.join(format!("{}.ogv", m.stem)); + let stamp = dir.join(format!("{}.cmd", m.stem)); + + let argv = args(&src, &ogv); + let command = format!("ffmpeg {}", argv.join(" ")); + let size = std::fs::metadata(&src)?.len(); + let want = format!("{command}\nsource-bytes: {size}\n"); + + let fresh = ogv.exists() + && std::fs::read_to_string(&stamp).map(|s| s == want).unwrap_or(false); + if !fresh { + let status = Command::new("ffmpeg") + .args(&argv) + .status() + .context("run ffmpeg -- is it on PATH?")?; + if !status.success() { + bail!("ffmpeg failed on {}", m.src); + } + std::fs::write(&stamp, &want)?; + } + Ok(Some(Transcoded { + name: m.stem.to_string(), + file: format!("video/{}.ogv", m.stem), + command, + why: m.why, + })) +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 16d7a2b3..720114c9 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -682,3 +682,71 @@ renderer. The three known differences are unchanged: `title` 6 (paint-order tie) 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. diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 61c3b193..8020b78f 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -45,10 +45,13 @@ func _ready() -> void: get_tree().quit(2) return for step: Dictionary in _flow["boot"]: - _sequence.append(String(step["screen"])) + _sequence.append(step) _film = args.get("film", "") - var name: String = _sequence[0] if not _sequence.is_empty() else args.get("screen", DEFAULT_SCREEN) + var name: String = String(_sequence[0].get("screen", "")) if not _sequence.is_empty() \ + else args.get("screen", DEFAULT_SCREEN) + if name == "": + name = DEFAULT_SCREEN # the sequence opens on a video; load something to size the viewport var screen: Dictionary = export_tree.screen(name) if screen.is_empty(): push_error(export_tree.error) @@ -117,7 +120,8 @@ func _ready() -> void: var _frozen := false var _flow: Variant = null -var _sequence: Array[String] = [] +var _sequence: Array[Dictionary] = [] +var _player: VideoStreamPlayer = null var _step := 0 var _film := "" var _film_frame := 0 @@ -133,7 +137,7 @@ func _process(delta: float) -> void: _elapsed += delta view.queue_redraw() - if _sequence.is_empty(): + if _sequence.is_empty() or _player != null: return # A screen holds at `rest` until it has arrived, then plays itself out and @@ -158,15 +162,75 @@ func _process(delta: float) -> void: func _advance() -> void: _step += 1 - var next := _sequence[_step] - print(" -> %s at %.2f s" % [next, _elapsed]) + var next: Dictionary = _sequence[_step] + if next.has("video"): + _play_video(String(next["video"]), bool(next.get("skippable", false))) + return + var name := String(next["screen"]) + print(" -> %s at %.2f s" % [name, _elapsed]) view.holding = true view.time_units = 0.0 - if not view.load_screen(view.tree, next): + if not view.load_screen(view.tree, name): push_error(view.tree.error) get_tree().quit(2) +## Play one transcoded movie, full-bleed over the screen. +## +## The port never reads WMV: the exporter transcoded this to Ogg Theora and +## recorded the exact ffmpeg command in the manifest (MISSION §6), so a modder +## who dislikes the quality re-runs one line. +func _play_video(name: String, skippable: bool) -> void: + var v := view.tree.video(name) + if v.is_empty(): + push_error(view.tree.error) + get_tree().quit(2) + return + print(" -> video %s at %.2f s (%s)" % [name, _elapsed, v["path"]]) + + var stream := VideoStreamTheora.new() + stream.file = v["path"] + _player = VideoStreamPlayer.new() + _player.stream = stream + _player.expand = true + _player.set_anchors_preset(Control.PRESET_FULL_RECT) + # Into the SubViewport, not beside it. Everything this port draws composes in + # the export's own 1280x720 design space; a player parented to the Boot node + # renders to the window instead and is invisible to `--capture`, which reads + # the SubViewport. That is not only a capture artefact -- it would also put + # the movie outside the space every screen coordinate is expressed in. + viewport.add_child(_player) + _skippable = skippable + # `play()` needs the node in the tree; calling it before that is an error + # the engine reports and then ignores, which looks like a video that simply + # never starts. + await get_tree().process_frame + _player.finished.connect(_video_finished) + _player.play() + + +var _skippable := false + + +func _video_finished() -> void: + print(" video ended at %.2f s" % _elapsed) + _player.queue_free() + _player = null + _advance() + + +func _unhandled_input(event: InputEvent) -> void: + # HANDOFF Q9, measured: one (A) press skips a movie -- the title was reached + # at 57 s against a 193 s baseline. This is the only input the port handles + # so far; menu navigation is P5. + if _player == null or not _skippable: + return + if event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel"): + print(" video skipped at %.2f s" % _elapsed) + _player.stop() + _video_finished() + + func _capture(path: String) -> void: # Two frames: the first is the one this callback is still inside of. await RenderingServer.frame_post_draw diff --git a/port/scripts/export_tree.gd b/port/scripts/export_tree.gd index 1a6c615a..19290537 100644 --- a/port/scripts/export_tree.gd +++ b/port/scripts/export_tree.gd @@ -83,6 +83,20 @@ func screen(name: String) -> Dictionary: return {} +# A transcoded movie, addressed by manifest name. The port never reads WMV -- +# the exporter emits Ogg Theora, which Godot plays natively (MISSION §2, §6). +func video(name: String) -> Dictionary: + for entry: Dictionary in manifest().get("videos", []): + if entry.get("name") == name: + var path := root.path_join(entry["file"]) + if not FileAccess.file_exists(path): + error = "manifest lists %s but %s is not there" % [name, path] + return {} + return {"path": path, "command": entry.get("command", "")} + error = "no video named %s in manifest.json" % name + return {} + + func screen_names() -> PackedStringArray: var names := PackedStringArray() for entry: Dictionary in manifest().get("screens", []): From 2cffac0d31028daca160d4387a78c55090deecad Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 08:45:48 +0000 Subject: [PATCH 25/29] docs: retract "reference renderer" -- sylpheed-cli is not the oracle A framing correction from the human, and it runs through everything I have written, so it is a retraction rather than a silent edit. 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 verify-screen is a CONSISTENCY check between two decoders that share their assumptions, plus a regression detector -- not a correctness check, and agreement in it is not evidence of correctness. Its header now says so, it calls the CLI the COMPARISON renderer, and DIFFERS means "we moved apart, find out which of us moved". The uncomfortable part, recorded because it is the actual failure mode: this file already contained the sentence "two renderers reading one field through one decoder agreeing is not evidence that the field is right", written after the ptframe1 case -- and I then went on quoting 3/255 against sylpheed-cli as though it meant the port was right. Having the principle written down did not stop me leaning on the agreement. Three times both renderers agreed and both were wrong, each caught only by a capture: pteff05 (menu screens had no background), scale-0 (drawn full size instead of collapsed), rest() (the menu bracket missing). Correctness moves to the captures -- nine of them, indexed at docs/re/captures/ORACLE-CAPTURES.md, covering all five screens in scope. Three cautions travel with them: not gamma-neutral (there is a floor, don't chase it), geometry IS sound (a positional disagreement is real), and each is one moment of a still-animating screen. verify-screen keeps running over all 16 screens every iteration. It is still worth having -- total, cheap, and it catches a divergence introduced on the RE side. It is just not a grade. --- README.md | 19 ++++++++++----- docs/DECISIONS.md | 59 +++++++++++++++++++++++++++++++++++++++++++++ tools/verify-screen | 26 +++++++++++++++++--- 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0e27e5e4..9f847044 100644 --- a/README.md +++ b/README.md @@ -47,18 +47,25 @@ assuming a value is on the disc. ## Verifying -`sylpheed-cli screen render` -- built from the same `sylpheed-formats` revision -the exporter is pinned to -- is the reference renderer. `tools/verify-screen` -draws every exported screen both ways and reports the largest per-channel -difference in the frame: +**The oracle is the Xenia Canary capture and the game**, not either renderer. +`sylpheed-cli screen render` is an explorer and extraction CLI for verifying +decodes, and it can be wrong -- three times both it and the port agreed and both +were wrong, each caught only by a capture. + +So `tools/verify-screen` is a **consistency check and a regression detector**, +not a grade. It draws every exported screen both ways -- built from the same +`sylpheed-formats` revision the exporter is pinned to -- and reports the largest +per-channel difference in the frame: ``` tools/verify-screen # every screen in the manifest tools/verify-screen main_menu # one of them ``` -Where the two disagree, one of them is wrong; `docs/DECISIONS.md` says which and -why, rather than tuning the port until the number goes down. +A difference means the two moved apart; `docs/DECISIONS.md` says which one moved +and why, rather than tuning the port until the number goes down. Correctness is +checked against the captures indexed at `docs/re/captures/ORACLE-CAPTURES.md` -- +mind that they are not gamma-neutral, so RMSE against them has a floor. ## Status diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 720114c9..60f0a13c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -750,3 +750,62 @@ 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. diff --git a/tools/verify-screen b/tools/verify-screen index fc98aa6a..f1ef2ada 100755 --- a/tools/verify-screen +++ b/tools/verify-screen @@ -1,6 +1,23 @@ #!/usr/bin/env bash # Diff Godot's drawing of an exported screen against `sylpheed-cli screen -# render` of the same build -- the P1 gate. +# render` of the same build. +# +# WHAT THIS IS, AND WHAT IT IS NOT. +# +# It is a CONSISTENCY check between two decoders that share their assumptions, +# and a REGRESSION detector: "did anything move since last commit". It is NOT a +# correctness check and agreement here is NOT evidence of correctness. +# +# `sylpheed-cli` is not the oracle. The oracle is the Xenia Canary capture and +# the game. Reborn is an explorer and extraction CLI for verifying decodes, and +# it can be wrong -- this corpus has been bitten three times by both renderers +# agreeing and both being wrong: pteff05 (the menu background, missing from +# both), scale-0, and rest(). Each time the capture caught it and neither +# renderer could have. +# +# So: a DIFFERS row means "we moved apart, go find out which of us moved". It +# does not mean the port is wrong. Where a capture and this tool disagree, the +# capture wins. Use `tools/verify-capture` for the correctness question. # # tools/verify-screen # every screen in the manifest # tools/verify-screen main_menu title # named screens @@ -11,7 +28,7 @@ # # The two renderers are held to the same inputs on purpose: # -# * the REFERENCE CLI is the one built by `build-reference-cli`, from the same +# * the COMPARISON CLI is the one built by `build-reference-cli`, from the same # `sylpheed-formats` revision the exporter is pinned to. /reborn's own # target/ is a live mount of the other agent's checkout and moves mid-run; a # pixel disagreement against a moving decoder proves nothing. @@ -30,8 +47,9 @@ # PORT against the reference. It is not the test of whether `rest` is right; # that one is the oracle capture, and the port already departs from it. # -# A difference here is not automatically the port's fault. Say which renderer is -# wrong and why -- do not tune until they match. +# A difference here is not automatically the port's fault, and it is not +# automatically a fault at all. Say which renderer moved and why -- do not tune +# until they match. set -euo pipefail cd "${PROJECT_DIR:-/work}" From 1573fda9a1ba6bc4e50cb65e0381559816074a7a Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 29 Aug 2026 11:00:54 +0200 Subject: [PATCH 26/29] port: pin by tag, and how to verify audio with no sound card **The pin.** 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, silently, at their build. formats-pin-2026-08-29 exists for the current state. Also says plainly why NOT to float to a branch, which was the tempting fix: Cargo resolves a git dependency once and writes the sha into Cargo.lock, so floating gives staleness you cannot see instead of staleness you can read. push-work now pushes --follow-tags so annotated tags travel with the branch. **Audio.** docs/AUDIO-VERIFICATION.md separates three questions that were being asked as one: is the transcode faithful (no engine, no device -- a file-vs-file difference measurement), does Godot route it (AudioEffectRecord on the Master bus writes a WAV from a headless run), and what does the GAME play (a PulseAudio null sink, which needs a rebuild). It leads with the three ways the fidelity measurement lies, because all three were hit on the first attempt and each produces a confident wrong number rather than an error: unaligned subtraction, mismatched channel layouts, and probing a file another process is still writing. The 5.1 disc fact deliberately is NOT copied here -- it lives in the RE corpus at docs/re/structures/movie-audio-channels.md and is linked, so there is one copy to keep true rather than two that drift. Same reason HANDOFF is a summary with links. The downmix itself stays flagged as an unmade decision, not quietly resolved. Co-Authored-By: Claude Opus 5 --- docker/bin/push-work | 4 +- docs/AUDIO-VERIFICATION.md | 110 +++++++++++++++++++++++++++++++++++++ docs/MISSION.md | 19 +++++-- 3 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 docs/AUDIO-VERIFICATION.md diff --git a/docker/bin/push-work b/docker/bin/push-work index 5a1d13b3..d072ff8c 100755 --- a/docker/bin/push-work +++ b/docker/bin/push-work @@ -16,7 +16,7 @@ # `sylph-agent`). They are never printed, never logged, and never passed on a # command line. # -# push-work push the current branch +# push-work push the current branch, and any annotated tags on it # push-work --dry-run say what it would do set -euo pipefail @@ -72,5 +72,5 @@ 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 -c "credential.helper=$CRED_HELPER" push --set-upstream origin "$branch" +git -c "credential.helper=$CRED_HELPER" push --follow-tags --set-upstream origin "$branch" echo "push-work: pushed $branch" diff --git a/docs/AUDIO-VERIFICATION.md b/docs/AUDIO-VERIFICATION.md new file mode 100644 index 00000000..41c80c90 --- /dev/null +++ b/docs/AUDIO-VERIFICATION.md @@ -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 +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. diff --git a/docs/MISSION.md b/docs/MISSION.md index f5ed6198..3333b637 100644 --- a/docs/MISSION.md +++ b/docs/MISSION.md @@ -43,10 +43,21 @@ 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. +* 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 From 753d62a08fa24ba6891ce4352633209970aa9e42 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 09:04:47 +0000 Subject: [PATCH 27/29] FORMAT v3: rotation, and the focus record -- the ring the port could not reach Pin bumped to the TAG formats-pin-2026-08-29 (76653ca), applying the policy the previous commit wrote. What I wanted from it: `UiBuild` gained a public `records` map. Without it a leaf was unreachable through the public API -- parse_build sorted T8aD children into `sprites` and `.rat` children into a PRIVATE map -- so the focus ring, which lives inside ptbtn0Nf.rat, a record the parent bundle declares NO element for, could not be located at all. My exporter was writing 19 of build 5's 21 sprites and I could not see why. v3 carries two new things. ROTATION. `rotation_deg`, decoded at keyframe +12, and the game DRAWS it -- confirmed twice by the RE agent on different screens with different elements: the title's ptloop sweeps declare +30/-45 and a GPU capture submits them at +30.26/-45.28, and the focus ring ramps 0 -> 360 with everything else constant, caught mid-spin in a capture. Rotation is about the DECLARED PIVOT, measured. The comparison renderer does not draw it yet, so a rotation disagreement means sylpheed-cli is behind, not that the port is wrong. Sign is still an assumption. THE FOCUS RECORD. A focused button is not a sprite swap: ptbtn0Nf.rat declares the spinning ring AND the bright label, and since the parent declares no element for the record, the leaf is the only source of placement for both. v2's single focus_sprite could not carry the ring at all and drew the highlight 7 px off-centre by inheriting the base position. That -7,-7 is load-bearing: the f label is 13 px larger per axis and -7 keeps the two concentric. Checked against the game, not against the other renderer: rendering main_menu with OPTIONS focused changes the region x 504..703, y 399..448. The RE agent measured the same difference in the live capture at x 505..703, y 397..446 -- independently, from the other side. Ring, label and underline all land; the only visible residual is the ring's spin PHASE, which is exactly the one thing neither of us has resolved (its second keyframe is untimed, and the screen-level rule for that is not established to apply inside a leaf). Listed in `unresolved` rather than invented. verify-screen is unchanged at 16/16 -- rotation has no effect at rest on these screens, as predicted. --- Cargo.lock | 2 +- crates/sylpheed-export/Cargo.toml | 19 +++- crates/sylpheed-export/src/check.rs | 2 +- crates/sylpheed-export/src/main.rs | 2 +- crates/sylpheed-export/src/screen.rs | 142 +++++++++++++++++++++++++-- docs/FORMAT.md | 78 ++++++++++++++- port/scripts/export_tree.gd | 2 +- port/scripts/screen_view.gd | 90 ++++++++++++++++- 8 files changed, 314 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f919478a..214307f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -959,7 +959,7 @@ dependencies = [ [[package]] name = "sylpheed-formats" version = "0.1.0" -source = "git+https://git.mc02.dev/fabi/Syplheed-Reborn.git?rev=f817dd5#f817dd59393b1437d4ea70b58124c1309f6c076f" +source = "git+https://git.mc02.dev/fabi/Syplheed-Reborn.git?tag=formats-pin-2026-08-29#7eeae3006a7bb92ae31ba8d2b402891f3a31f070" dependencies = [ "anyhow", "binrw", diff --git a/crates/sylpheed-export/Cargo.toml b/crates/sylpheed-export/Cargo.toml index 45b25dd4..6b08a7a8 100644 --- a/crates/sylpheed-export/Cargo.toml +++ b/crates/sylpheed-export/Cargo.toml @@ -14,6 +14,23 @@ license.workspace = true # is not one archive entry (segment-spanning reads, multi-sub-wave banks, and the # continuous cutscene-voice stream). Do not re-derive those here. # +# Pin moved f817dd5 -> 7eeae30 on 2026-08-29. WHAT I WANTED FROM IT: `UiBuild` +# gained a public `records` map (record name -> the nested `.rat` leaf's byte +# range). Without it a consumer could not locate a leaf at all: `parse_build` +# sorted T8aD children into `sprites` and `.rat` children into a PRIVATE map, so +# the focus ring -- which lives inside `ptbtn0Nf.rat`, a record the parent +# bundle declares no element for -- was unreachable through the public API. +# Also brings `Keyframe::rotation_deg`, which the game does render. +# +# PINNED BY TAG, not by sha, and MISSION §2 now requires it. The reachability +# risk this comment used to warn about is closed: a sha reachable only from an +# `auto/*` branch is orphaned when that branch is deleted or -- worse -- +# SQUASH-MERGED, because squash creates new commits, so `main` would look like +# it contained the work while this pin became unreachable. A tag is a permanent +# ref, it says what it is here in the file, and it fails loudly at FETCH rather +# than silently at build. `formats-pin-2026-08-29` is 7eeae30. +# +# Previous pin note, kept because the reasoning still holds: # Pin moved 5414db3 -> f817dd5 on 2026-08-29. WHAT I WANTED FROM IT: `56cc7ac`, # "a RATC child's name is stated, not inferred". A child was named by scanning # backwards for the last printable run before its magic; for `pteff05.t32` the @@ -32,7 +49,7 @@ license.workspace = true # which a capture of the running game plainly shows. 5414db3 is the revision at # which that fix carries its disc-wide check (30 of 13 991 elements move, 4 # become visible, 0 become invisible), not merely the one where it was written. -sylpheed-formats = { git = "https://git.mc02.dev/fabi/Syplheed-Reborn.git", rev = "f817dd5" } +sylpheed-formats = { git = "https://git.mc02.dev/fabi/Syplheed-Reborn.git", tag = "formats-pin-2026-08-29" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/sylpheed-export/src/check.rs b/crates/sylpheed-export/src/check.rs index abbeb528..7a7e9e24 100644 --- a/crates/sylpheed-export/src/check.rs +++ b/crates/sylpheed-export/src/check.rs @@ -21,7 +21,7 @@ use anyhow::{bail, Result}; use serde_json::Value; use std::path::Path; -const SCREEN_FORMAT: &str = "sylpheed.screen/2"; +const SCREEN_FORMAT: &str = "sylpheed.screen/3"; const MANIFEST_FORMAT: &str = "sylpheed.manifest/1"; struct Ctx { diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index 7817acef..9b128f08 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -186,7 +186,7 @@ fn main() -> Result<()> { } => run_export(&disc, &out, &authored), Cmd::Check { out } => { let n = check::run(&out)?; - println!("{} screen(s) in {} validate against sylpheed.screen/2", n, out.display()); + println!("{} screen(s) in {} validate against sylpheed.screen/3", n, out.display()); Ok(()) } } diff --git a/crates/sylpheed-export/src/screen.rs b/crates/sylpheed-export/src/screen.rs index 6f24ac95..92735855 100644 --- a/crates/sylpheed-export/src/screen.rs +++ b/crates/sylpheed-export/src/screen.rs @@ -1,4 +1,4 @@ -//! One UI build → one `sylpheed.screen/2` JSON document plus its sprite PNGs. +//! One UI build → one `sylpheed.screen/3` JSON document plus its sprite PNGs. //! //! Everything here is **derived**: it is what the bundle says, restated in a //! format Godot can read. The two places a value is not read off the disc are @@ -61,6 +61,14 @@ pub struct Keyframe { /// The second modulate colour, **ARGB** byte order — the high byte is the /// alpha that ramps during a fade. Multiplies with `tint_rgba`. pub fade_argb: String, + /// Screen-plane rotation in **degrees**, clockwise-positive, decoded from + /// the keyframe's `+12`. **The game renders this** — confirmed twice, 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 focus ring ramps 0 → 360 with everything else constant, + /// which a capture caught mid-spin. Rotation is about the **declared + /// pivot**, also measured. + pub rotation_deg: i32, } #[derive(Serialize)] @@ -69,10 +77,45 @@ pub struct Rest { pub scale: [u32; 2], pub tint_rgba: String, pub fade_argb: String, + /// Screen-plane rotation in **degrees**, clockwise-positive, decoded from + /// the keyframe's `+12`. **The game renders this** — confirmed twice, 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 focus ring ramps 0 → 360 with everything else constant, + /// which a capture caught mid-spin. Rotation is about the **declared + /// pivot**, also measured. + pub rotation_deg: i32, + #[serde(skip_serializing_if = "Option::is_none")] pub t: Option, } +/// One element of a button's focused-state record. +/// +/// A focus record is **not** a single sprite. `ptbtn0Nf.rat` declares 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 the parent has nothing to inherit from. +/// That is why these carry their own `pos`, and why they are not simply a +/// second sprite path on the base element. +#[derive(Serialize)] +pub struct FocusElement { + pub id: String, + pub declared: String, + pub sprite: Option, + pub pivot: [u32; 2], + pub rest: Rest, + pub keyframes: Vec, +} + +#[derive(Serialize)] +pub struct Focus { + /// The `.rat` leaf this came from, e.g. `ptbtn01f.rat`. + pub record: String, + /// Back-to-front, in the leaf's own declaration order. + pub elements: Vec, +} + #[derive(Serialize)] pub struct Element { /// Declaration index — the key the placement region and `paint_order` use. @@ -93,6 +136,11 @@ pub struct Element { /// between a button and its highlight that has survived checking. #[serde(skip_serializing_if = "Option::is_none")] pub focus_sprite: Option, + /// The focused state, read from the element's `.rat` leaf. Supersedes + /// `focus_sprite`, which is kept because it is the 54-pair naming + /// convention and a consumer may still want the bare highlight texture. + #[serde(skip_serializing_if = "Option::is_none")] + pub focus: Option, /// The raw `opt ` link inside this element's `.rat` record. /// /// ⚠️ **This is not a focus link.** It was read as one, and that was @@ -207,22 +255,32 @@ pub fn export_build( std::fs::create_dir_all(&sprite_dir)?; let mut written: BTreeMap = BTreeMap::new(); let mut missing = Vec::new(); - let mut write_sprite = |sprite: &str| -> Result { + // Decode one T8aD and write it, from whichever bundle slice and sprite map + // owns it. A leaf's sprites may be indexed in the leaf's own map (offsets + // relative to the leaf slice) or in the parent's; the caller says which. + fn write_from( + dir: &Path, + written: &mut BTreeMap, + sprite: &str, + bytes: &[u8], + map: &std::collections::HashMap, + ) -> Result { if written.contains_key(sprite) { return Ok(true); } - let Some(&(off, size)) = b.sprites.get(sprite) else { + let Some(&(off, size)) = map.get(sprite) else { return Ok(false); }; - let Some(img) = t8ad::parse(&bundle[off..off + size]) else { + let Some(img) = t8ad::parse(&bytes[off..off + size]) else { return Ok(false); }; let buf = image::RgbaImage::from_raw(img.width, img.height, img.rgba) .context("T8aD dimensions disagree with its pixel count")?; - buf.save(sprite_dir.join(format!("{}.png", id_of(sprite))))?; + buf.save(dir.join(format!("{}.png", id_of(sprite))))?; written.insert(sprite.to_string(), ()); Ok(true) - }; + } + /// The highlighted twin of a sprite name: `ptbtn01.t32` → `ptbtn01f.t32`. fn highlight_name(sprite: &str) -> Option { @@ -234,7 +292,7 @@ pub fn export_build( for el in &b.elements { let mut sprite_out = None; if let Some(s) = &el.sprite { - if write_sprite(s)? { + if write_from(&sprite_dir, &mut written, s, bundle, &b.sprites)? { sprite_out = Some(sprite_rel(s)); } else { missing.push(s.clone()); @@ -245,11 +303,74 @@ pub fn export_build( // entirely on half these elements. let mut focus_sprite = None; if let Some(h) = el.sprite.as_deref().and_then(highlight_name) { - if b.sprites.contains_key(&h) && write_sprite(&h)? { + if write_from(&sprite_dir, &mut written, &h, bundle, &b.sprites)? { focus_sprite = Some(sprite_rel(&h)); } } + // The focused state is a RECORD, not a sprite. `ptbtn0Nf.rat` declares + // the spinning ring AND the bright label, and the parent bundle + // declares no element for it at all -- so the leaf is the only source + // of placement for both, and there is nothing for it to inherit. + // + // Contrast with a BASE record, where the leaf duplicates the parent's + // placement and the two can differ by a unit (ptbtn04: parent y=401, + // leaf y=402). There the parent wins. Here there is no parent. + let mut focus = None; + if let Some(rec) = highlight_name(&el.name) { + if let Some(&(off, size)) = b.records.get(&rec) { + if let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) { + let mut fes = Vec::new(); + for fe in &leaf.elements { + // A leaf element's own NAME is its sprite -- `el.sprite` + // is only populated for a T8aD child of the same bundle, + // and these are indexed either in the leaf's map (offsets + // into the leaf slice) or in the parent's. + let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name); + let mut fsprite = None; + if write_from(&sprite_dir, &mut written, sp, + &bundle[off..off + size], &leaf.sprites)? + || write_from(&sprite_dir, &mut written, sp, bundle, &b.sprites)? + { + fsprite = Some(sprite_rel(sp)); + } else if sp.ends_with(".t32") { + missing.push(sp.to_string()); + } + let Some(r) = fe.rest() else { continue }; + fes.push(FocusElement { + id: id_of(&fe.name), + declared: fe.name.clone(), + sprite: fsprite, + pivot: [fe.pivot_x, fe.pivot_y], + rest: Rest { + pos: [r.x, r.y], + scale: [r.scale_x, r.scale_y], + tint_rgba: hex32(r.tint), + fade_argb: hex32(r.fade), + rotation_deg: r.rotation_deg, + t: r.time, + }, + keyframes: fe + .keyframes + .iter() + .map(|k| Keyframe { + t: k.time, + pos: [k.x, k.y], + scale: [k.scale_x, k.scale_y], + tint_rgba: hex32(k.tint), + fade_argb: hex32(k.fade), + rotation_deg: k.rotation_deg, + }) + .collect(), + }); + } + if !fes.is_empty() { + focus = Some(Focus { record: rec, elements: fes }); + } + } + } + } + let (layer, layer_source) = match ui_layout::sprite_layer_key(&b, bundle, el) { Some(k) => (Some(hex32(k)), "sprite"), None => match ui_layout::implied_layer_key(&el.name) { @@ -264,6 +385,7 @@ pub fn export_build( scale: [k.scale_x, k.scale_y], tint_rgba: hex32(k.tint), fade_argb: hex32(k.fade), + rotation_deg: k.rotation_deg, }; let role = role_of(el.kind, el.sprite.is_some()); elements.push(Element { @@ -274,6 +396,7 @@ pub fn export_build( kind_raw: format!("{:#x}", el.kind), sprite: sprite_out, focus_sprite, + focus, opt_link: el.focus_link.clone(), pivot: [el.pivot_x, el.pivot_y], size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]), @@ -287,6 +410,7 @@ pub fn export_build( scale: [k.scale_x, k.scale_y], tint_rgba: hex32(k.tint), fade_argb: hex32(k.fade), + rotation_deg: k.rotation_deg, t: k.time, }), keyframes: el.keyframes.iter().map(kf).collect(), @@ -304,7 +428,7 @@ pub fn export_build( buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); let screen = Screen { - format: "sylpheed.screen/2", + format: "sylpheed.screen/3", exporter: exporter.to_string(), formats_rev, source: Source { diff --git a/docs/FORMAT.md b/docs/FORMAT.md index 446ebd9d..7f9184a3 100644 --- a/docs/FORMAT.md +++ b/docs/FORMAT.md @@ -1,9 +1,9 @@ -# The open export format — v2 +# 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 v1](#changes-from-v1) is at the -bottom, with a reason for each. +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: @@ -53,7 +53,7 @@ rest are applied by the runtime over `export/`. ```json { - "format": "sylpheed.screen/2", + "format": "sylpheed.screen/3", "exporter": "sylpheed-export 0.1.0", "formats_rev": "8b6dbcf", "source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 } @@ -69,7 +69,7 @@ the index into that pak's list of screen builds (what `sylpheed-cli screen ```json { - "format": "sylpheed.screen/2", + "format": "sylpheed.screen/3", "exporter": "sylpheed-export 0.1.0", "formats_rev": "8b6dbcf", "source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 }, @@ -154,6 +154,46 @@ pairs **by name** on the sprite — `ptbtn01.t32` ↔ `ptbtn01f.t32` — which i 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 @@ -177,6 +217,23 @@ there — and a file that puts one on it is wrong, not merely odd. The unit of ` 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 @@ -260,6 +317,17 @@ reaches which entry is Q4 and is not). `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 diff --git a/port/scripts/export_tree.gd b/port/scripts/export_tree.gd index 19290537..ea3d141f 100644 --- a/port/scripts/export_tree.gd +++ b/port/scripts/export_tree.gd @@ -7,7 +7,7 @@ class_name ExportTree extends RefCounted -const FORMAT_SCREEN := "sylpheed.screen/2" +const FORMAT_SCREEN := "sylpheed.screen/3" const FORMAT_MANIFEST := "sylpheed.manifest/1" var root: String = "" diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd index 08cb8f6e..5b111f9a 100644 --- a/port/scripts/screen_view.gd +++ b/port/scripts/screen_view.gd @@ -80,8 +80,12 @@ func load_screen(t: ExportTree, name: String) -> bool: func _load_textures() -> void: textures.clear() for element: Dictionary in screen.get("elements", []): - for key in ["sprite", "focus_sprite"]: - var rel: String = element.get(key, "") + var paths: Array = [element.get("sprite", ""), element.get("focus_sprite", "")] + # The focus record's own elements carry their own sprites -- the ring is + # only reachable this way. + for fe: Dictionary in element.get("focus", {}).get("elements", []): + paths.append(fe.get("sprite", "")) + for rel: String in paths: if rel != "" and not textures.has(rel): var tex := tree.texture(rel) if tex == null: @@ -191,6 +195,7 @@ static func _lerp_pose(a: Dictionary, b: Dictionary, f: float) -> Dictionary: "scale": [_ilerp(a["scale"][0], b["scale"][0], f), _ilerp(a["scale"][1], b["scale"][1], f)], "tint_rgba": _hex_lerp(a["tint_rgba"], b["tint_rgba"], f), "fade_argb": _hex_lerp(a["fade_argb"], b["fade_argb"], f), + "rotation_deg": _ilerp(a.get("rotation_deg", 0), b.get("rotation_deg", 0), f), } @@ -264,6 +269,77 @@ func _template_instance_ids() -> Dictionary: return ghosts +## Draw one textured or solid quad, rotated about its pivot. +## +## The rotation anchor in design space is `pos + pivot`: `pos` is the top-left +## at 1:1, so the pivot point sits `pivot` in from it, and scaling about that +## point is exactly the `pos - pivot*(s-1)` rule the placement already uses. +## +## THE GAME DRAWS ROTATION. 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 them at +30.26/-45.28, and the main menu's focus +## ring ramps 0 -> 360 with everything else held constant, caught mid-spin in a +## capture. The comparison renderer does not draw it yet, so expect a title +## divergence that means "sylpheed-cli is behind", not "the port is broken". +## +## 🟡 The SIGN is an assumption: the decoder documents `+12` as +## clockwise-positive and Godot's 2D rotation is clockwise-positive in a y-down +## space, so this passes the value straight through. Not yet checked against a +## capture at a known angle. +func _draw_quad(tex: Texture2D, rect: Rect2, colour: Color, pivot: Vector2, + pos: Vector2, rotation_deg: float) -> void: + if is_zero_approx(rotation_deg): + if tex != null: + draw_texture_rect(tex, rect, false, colour) + else: + draw_rect(rect, colour, true) + return + var anchor := pos + pivot + draw_set_transform(anchor, deg_to_rad(rotation_deg), Vector2.ONE) + var local := Rect2(rect.position - anchor, rect.size) + if tex != null: + draw_texture_rect(tex, local, false, colour) + else: + draw_rect(local, colour, true) + draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) + + +static func _rot_of(pose: Dictionary) -> float: + return float(pose.get("rotation_deg", 0)) + + +## Draw a focus record's own elements -- the spinning ring and the bright label. +## +## The focused state is NOT a sprite swap. `ptbtn0Nf.rat` declares two elements, +## 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 to inherit. +## The label is 13 px larger per axis than the base and sits at (-7,-7), which +## keeps the two concentric; drawing it at the base position pushes it 7 px +## down-right and off-centre. +func _draw_focus(element: Dictionary) -> void: + var focus: Dictionary = element.get("focus", {}) + for fe: Dictionary in focus.get("elements", []): + var rel: String = fe.get("sprite", "") + if rel == "": + continue + var tex: Texture2D = textures.get(rel) + if tex == null: + skipped.append("%s (focus sprite failed to load)" % fe.get("id", "")) + continue + # The ring's rest pose. Its spin is real -- rotation_deg ramps 0 -> 360 + # with position, scale and alpha all constant -- but the PERIOD is not + # established: the ramp's second keyframe is untimed, and what an untimed + # keyframe means inside a leaf (rather than at screen level, where it is + # the exit) is untested. So this holds the resting angle and does not + # invent a spin rate. + var pose: Dictionary = fe.get("rest", {}) + var pivot := _vec(fe.get("pivot", [0, 0])) + var pos := _vec(pose.get("pos", [0, 0])) + _draw_quad(tex, placement(pose, pivot, tex.get_size()), modulate_of(pose), + pivot, pos, _rot_of(pose)) + drawn.append(fe.get("id", "")) + + func _draw() -> void: if screen.is_empty(): return @@ -284,6 +360,12 @@ func _draw() -> void: skipped.append("%s (transparent at rest)" % id) continue var pivot := _vec(element.get("pivot", [0, 0])) + var pos := _vec(pose.get("pos", [0, 0])) + var rot := _rot_of(pose) + # A focused button draws its own record instead of its base sprite. + if focused_id == id and element.has("focus"): + _draw_focus(element) + continue var rel: String = element.get("sprite", "") if focused_id == id and element.get("focus_sprite", "") != "": rel = element["focus_sprite"] @@ -292,12 +374,12 @@ func _draw() -> void: if tex == null: skipped.append("%s (sprite failed to load)" % id) continue - draw_texture_rect(tex, placement(pose, pivot, tex.get_size()), false, colour) + _draw_quad(tex, placement(pose, pivot, tex.get_size()), colour, pivot, pos, rot) drawn.append(id) elif element.get("role", "") == "primitive" and element.has("size"): # A primitive has no texture; the quad is its declared size and its # colour is the pose's own modulate. - draw_rect(placement(pose, pivot, _vec(element["size"])), colour, true) + _draw_quad(null, placement(pose, pivot, _vec(element["size"])), colour, pivot, pos, rot) drawn.append(id) else: # A .t32 element whose sprite the exporter could not produce. Saying From 7d494359d1ecc296922fe55b5a60567704408a82 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Sat, 29 Aug 2026 09:04:47 +0000 Subject: [PATCH 28/29] video: state the 5.1 downmix instead of inheriting it, and write atomically A real defect in the P4 output, found by the human on ADV.wmv and widened by the RE agent to the whole disc: the disc ships 28 movies in 5.1 WMA Pro (every cutscene, INCLUDING both movies this port needs) and 69 already in stereo. A bare `-ac 2` therefore does two different things and records neither -- stereo passes through, and 5.1 is folded by ffmpeg's DEFAULT matrix. How loudly centre-channel dialogue sits against the music is a content decision, and it was being made by accident and could move under an ffmpeg upgrade. Now stated: ITU-R BS.775, LFE dropped, normalised by 1/(1+2*sqrt(1/2)) = 0.4142. It appears in the recorded command, so the manifest determines the output. MEASURED rather than chosen by taste, and the measurement is the interesting part: the explicit matrix and ffmpeg's inherited default differ by a residual of -91 dB -- about one LSB at 16-bit -- with peak and mean agreeing to 0.1 dB. So ffmpeg's default IS this matrix, and the audio does not change; what changes is that the manifest now says which matrix. The UNnormalised textbook form was measured too and clips at 0.0 dBFS, which is why the scaling is there. The filter is applied only to 6-channel sources, probed per file with ffprobe, so a stereo source is never run through a matrix referencing channels it lacks. Also: encode to a temp name and rename on success. ffprobe read a mid-write .ogv as 33 s against a 137 s source -- no error, no warning, the exact shape of catastrophic truncation. The filesystem is shared with the RE agent, so that is a race, not an edge case, and a half-written file must never be visible under its final name. tools/verify-video-audio answers the second of the three questions docs/AUDIO-VERIFICATION.md separates: does GODOT route the audio. An AudioEffectRecord on the Master bus writes Godot's own mixed output to a WAV from a headless run, so "no sound card" was never the obstacle I claimed. It deliberately checks non-silence and level only -- a difference-signal RMS against the source is inconclusive without cross-correlation alignment and an agreed downmix, and would produce a confident wrong number. --- crates/sylpheed-export/src/video.rs | 75 +++++++++++++++++++++---- tools/verify-video-audio | 86 +++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 10 deletions(-) create mode 100755 tools/verify-video-audio diff --git a/crates/sylpheed-export/src/video.rs b/crates/sylpheed-export/src/video.rs index fa580520..963eef81 100644 --- a/crates/sylpheed-export/src/video.rs +++ b/crates/sylpheed-export/src/video.rs @@ -45,20 +45,62 @@ pub const MOVIES: &[Movie] = &[ /// be too poor and asked for the FFmpeg-GDExtension fallback to be *proposed* /// if so. It is not: **no runtime dependency is needed, and none is requested.** /// -/// `-ac 2` because the source is 6-channel WMA Pro and Godot's Theora playback -/// is not a surround path. Downmixing is a decision, so it is in the recorded -/// command where a modder can see and change it. -fn args(src: &Path, out: &Path) -> Vec { - [ +/// The stereo downmix, **stated explicitly rather than inherited**. +/// +/// The disc ships movies in two audio profiles: 28 files are 5.1 WMA Pro (every +/// cutscene, including both movies this port needs) and 69 are already stereo. +/// A bare `-ac 2` therefore does two different things and records neither — the +/// stereo files pass through, and the 5.1 files are folded by **ffmpeg's default +/// matrix**. How loudly centre-channel dialogue sits against the music is a +/// CONTENT decision, and leaving it to a default means it is made by accident +/// and can move under an ffmpeg upgrade. +/// +/// So the matrix is written out: **ITU-R BS.775, LFE dropped**, normalised by +/// `1/(1 + √½ + √½) = 0.4142` so the sum of coefficients cannot clip. +/// +/// This does not change the audio. Measured against the inherited default over a +/// 25 s stretch, the residual is **−91 dB** — roughly one LSB at 16-bit, i.e. +/// coefficient rounding — and peak and mean levels agree to 0.1 dB. ffmpeg's +/// default *is* this matrix; the point is that the manifest now says so. +/// +/// The unnormalised form was measured too and **clips**: peak 0.0 dBFS. That is +/// why the normalisation is here rather than the textbook coefficients. +const DOWNMIX_51: &str = "pan=stereo|FL=0.4142*FL+0.2929*FC+0.2929*BL |FR=0.4142*FR+0.2929*FC+0.2929*BR"; + +/// How many audio channels the source declares. +fn channels(src: &Path) -> Result { + let out = Command::new("ffprobe") + .args([ + "-v", "error", "-select_streams", "a:0", + "-show_entries", "stream=channels", "-of", "csv=p=0", + ]) + .arg(src) + .output() + .context("run ffprobe -- is it on PATH?")?; + Ok(String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(2)) +} + +fn args(src: &Path, out: &Path, channels: u32) -> Vec { + let mut v: Vec = [ "-hide_banner", "-loglevel", "error", "-y", "-i", &src.display().to_string(), "-c:v", "libtheora", "-q:v", "8", - "-c:a", "libvorbis", "-q:a", "5", "-ac", "2", - &out.display().to_string(), + "-c:a", "libvorbis", "-q:a", "5", ] .iter() .map(|s| s.to_string()) - .collect() + .collect(); + // Only 5.1 sources are folded. A source that is already stereo is passed + // through untouched rather than run through a matrix that would silently + // reference channels it does not have. + if channels == 6 { + v.push("-af".into()); + v.push(DOWNMIX_51.into()); + } + v.push("-ac".into()); + v.push("2".into()); + v.push(out.display().to_string()); + v } pub struct Transcoded { @@ -85,21 +127,34 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result/dev/null 2>&1 + +wav="$OUT/$name.godot.wav" +rm -f "$wav" +cat > "$OUT/probe.gd" <<'GD' +extends SceneTree + +func _init() -> void: + var args := {} + for a in OS.get_cmdline_user_args(): + if a.begins_with("--") and a.contains("="): + var p := a.substr(2).split("=", true, 1) + args[p[0]] = p[1] + + var tree_ := ExportTree.locate() + if tree_.root == "": + push_error(tree_.error); quit(2); return + var v: Dictionary = tree_.video(args.get("video", "ADV")) + if v.is_empty(): + push_error(tree_.error); quit(2); return + + # Record the MASTER bus: whatever Godot mixes, including the dummy driver's + # output. This is the real playback path, not the encoded file. + var rec := AudioEffectRecord.new() + AudioServer.add_bus_effect(0, rec) + + var stream := VideoStreamTheora.new() + stream.file = v["path"] + var p := VideoStreamPlayer.new() + p.stream = stream + get_root().add_child(p) + await process_frame + rec.set_recording_active(true) + p.play() + var seconds := float(args.get("seconds", "6")) + var t := 0.0 + while t < seconds and p.is_playing(): + await process_frame + t += get_root().get_process_delta_time() + rec.set_recording_active(false) + var clip := rec.get_recording() + if clip == null: + push_error("no recording came back from the Master bus"); quit(3); return + clip.save_to_wav(args.get("out", "/tmp/godot-audio.wav")) + print("recorded %.2f s, %d Hz, stereo=%s -> %s" % [ + t, clip.mix_rate, clip.stereo, args.get("out", "")]) + quit(0) +GD + +godot --path port --resolution 320x180 --script "$OUT/probe.gd" -- \ + "--video=$name" "--out=$wav" "--seconds=${SECONDS_TO_RECORD:-6}" 2>&1 \ + | grep -viE "ALSA|Vulkan|V-Sync|OpenGL|audio driver|^ *at: |Condition|^$" || true + +[ -s "$wav" ] || { echo "verify-video-audio: Godot wrote no WAV" >&2; exit 1; } +echo "--- what Godot emitted ---" +ffmpeg -hide_banner -i "$wav" -af volumedetect -f null - 2>&1 \ + | grep -oE "(max_volume|mean_volume): [-0-9.]+ dB" | sed 's/^/ /' +mean=$(ffmpeg -hide_banner -i "$wav" -af volumedetect -f null - 2>&1 \ + | grep -oE "mean_volume: [-0-9.]+" | grep -oE -- "-?[0-9.]+") +# Digital silence reports around -91 dB at 16-bit. Anything near that is nothing. +awk -v m="$mean" 'BEGIN{ if (m < -80) { print " VERDICT: silence -- Godot is not emitting this movie\047s audio"; exit 1 } + else { printf " VERDICT: audio present (mean %.1f dB)\n", m } }' From 38693df2ed7211643e9c325c876910b9c1c6d604 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 29 Aug 2026 11:12:38 +0200 Subject: [PATCH 29/29] port: pin the 5.1 downmix matrix explicitly -- human decision The RE agent escalated this rather than picking, correctly: folding centre into L/R changes how dialogue sits against the music, which is an aesthetic judgement about the game and not a container detail. Decided: explicit ITU fold, centre at -3 dB, recorded in the manifest with the rest of the transcode command. Pinned rather than defaulted because a default is a decision nobody made -- invisible in the output and free to change between ffmpeg versions. --- docs/MISSION.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/MISSION.md b/docs/MISSION.md index 3333b637..cc2be83b 100644 --- a/docs/MISSION.md +++ b/docs/MISSION.md @@ -121,6 +121,28 @@ 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