Commit Graph

14 Commits

Author SHA1 Message Date
Sylpheed port agent
9f740d8ced 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.
2026-08-28 21:44:08 +00:00
Sylpheed port agent
959b43cb53 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.
2026-08-28 21:44:08 +00:00
Sylpheed port agent
dbbcae1df2 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.
2026-08-28 19:51:57 +00:00
Sylpheed port agent
980b717fb0 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=<seconds>`.

`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.
2026-08-28 19:51:57 +00:00
Sylpheed port agent
6e46b6a136 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.
2026-08-28 19:31:21 +00:00
Sylpheed port agent
65fe6e04a2 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.
2026-08-28 19:31:21 +00:00
Sylpheed port agent
19ad10e49c 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.
2026-08-28 19:31:03 +00:00
Sylpheed port agent
39ad4d7e60 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.
2026-08-28 19:00:26 +00:00
Sylpheed port agent
9847adf383 docs: FORMAT v2, what P0 decided, and BLOCKED reconciled
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.
2026-08-28 18:43:52 +00:00
Sylpheed port agent
f6fc269478 export: P0 — GP_TITLE's screens and sprites into the open tree
`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.
2026-08-28 18:43:52 +00:00
MechaCat02
be5ead4dfa 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 <noreply@anthropic.com>
2026-08-28 20:35:22 +02:00
MechaCat02
9e8d5cbe39 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 <noreply@anthropic.com>
2026-08-28 20:31:32 +02:00
MechaCat02
0b18cd0484 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 <noreply@anthropic.com>
2026-08-28 20:07:39 +02:00
MechaCat02
38541c153b 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 <noreply@anthropic.com>
2026-08-28 17:56:44 +02:00