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.
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.
extracted", and the tool was in the build all along
Two iterations ago I closed Q8 by declaring the SE audio undecodable:
Static.slb has no wave boundaries, there is no XACT container anywhere,
and I said the index "exists only at runtime" as though that put it out
of reach. The disc half of that stands. The conclusion did not.
This build of Canary carries a cvar called xma_param_probe, added by this
project, whose own comment says it logs each XMA stream's parameters and
head bytes so raw sound.pak entries can be matched to real decode params.
It has been sitting in the startup CONFIG DUMP of every log I have read
this session.
Run with it, driving the main menu: a d-pad move spawns a new mono 48 kHz
stream of 4 packets / 8192 bytes, and B spawns a different one of 2
packets / 4096 bytes. Searching their logged head bytes in Static.slb
finds each at exactly one offset -- 0x1ec0 and 0x0ec0 -- and the two are
contiguous, 0x0ec0 + 4096 = 0x1ec0. So the bank is a packed run of whole
2048-byte packets with no delimiters, which is precisely why the seek
scan found nothing: there is nothing to find. A wave is (offset, packet
count) and nothing else.
That splits Q8's binding cleanly. Event -> WAVE is now measured: the port
can have the audio. Event -> cue NAME is still a name match on the
authors' identifiers, and the page says so.
The same run settled something for Q10 too. Sitting on the main menu, TWO
stereo 48 kHz streams were decoding simultaneously. bgm-two-stems.md said
that observation was what it needed and that this container could not
make it; it can, and a music bank's two waves are now measured as
simultaneous rather than only inferred.
METHOD gets the general lesson, because it cost two iterations: check
what instrumentation the local build already has before declaring a
question blocked on tooling.
ambiguous, the sampling was
Continuing last iteration's amber rather than starting something new. The
fix was already named there: stop using 5-second screenshots and record
the display.
Recorded with ffmpeg x11grab at 10 fps and matched every 0.5 s. Across
the 25 consecutive samples from capture 5.0 s to 17.5 s the S00A playhead
is strictly non-decreasing -- 1.0 through 11.0 s, advancing at
essentially real time -- with scores at or above 0.96 and four of them at
0.999 or 1.000 against a runner-up in the 0.78-0.83 range. S00A is the
top match on 23 of the 27 frames carrying signal. So MS00A -> S00A.wmv is
decoded from the manifest AND measured off the game, and the intro begins
about 4.5 s after A on the save slot.
The previous attempt's failure is worth its own METHOD line, because it
did not look like a sampling problem: it looked like weak evidence for
the wrong film. Sparse sampling produced contrast-23 frames, a playhead
that would not join up, and one frame preferring ADV. Sampling does not
weaken a signal gracefully; it turns it into noise shaped like a
different answer.
One aside recorded and not chased: in the S00A 5.5-10 s window, ADV also
scores 0.97-0.99 at its OWN monotone playhead of 33.5-37.5 s. Two films
matching strongly with two consistent playheads is not noise -- it reads
as the boot movie being a trailer cut from the story cutscenes, which
also explains why the sparse run kept flipping between them.
this container kills long runs
Three results, and the middle one is deliberately not upgraded.
First: NEW GAME -> DIFFICULTY -> NORMAL -> SELECT DATA -> pick a slot ->
a movie plays, with no crash. The previous run's throw at 0x82307128 is
therefore not inherent to the menu path -- the same six presses got
through it -- which fits the incomplete-cache trigger the corpus already
names. n=1 either way, and the note says so. Worth recording because one
observation had already hardened once into "A on NEW GAME hangs", and it
would have hardened again into "the new-game path crashes".
Second: with the path open, the manifest's MS00A -> S00A.wmv finally got
a runtime test, and it did NOT reproduce the quality of the attract-movie
identification. The first four samples give a clean monotonic S00A
playhead of 2, 6, 9, 13 s and the strongest frame matches at 0.916, but
the run as a whole is not one advancing timeline, most frames sit at
contrast 23-37 where this page's own rule says discrimination is poor,
and one high-contrast frame prefers ADV. So it stays amber: consistent
with S00A, corroborating the manifest decode, not confirming it. A clean
answer needs the 30fps x11grab route rather than 5-second screenshots.
Third, and it bounds the rest: the emulator vanished at ~145 s with no
crash line in its own log, last entries routine MEM-WATCH at 1.15 GB RSS.
Second time this session -- the other was ~50 s into a boot. That is an
external kill on a box with ~1 GB free and swap exhausted, not a guest
fault. METHOD and the handoff's residue table both now say dynamic
experiments here have to fit in about two minutes of guest time, which is
why the DELTASABER plates went unseen again: they need a mission load and
the run never got there.
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 <noreply@anthropic.com>
1/60 s, and the 60 Hz reading is excluded
ui-keyframe-time-unit.md named its own re-test and nobody had run it: time
300 submitted frames on the IDLE title, where nothing is streaming from
the ISO. Reaching the title is cheap now that one A skips the intro, so
this cost one boot.
Two clean trials: 300 frames in 10.40 s and 10.60 s, i.e. 28.8 and 28.3
fps. That is the same rate as the 27.6 fps measured during the loading
splash, which is exactly the page's own criterion for "the game is 30 Hz
and the unit is 1/60 s". The 60 Hz alternative is now excluded rather
than disfavoured: it needs the emulator at 47% of real time while sitting
idle on a screen the capture says costs 1526 draws over 300 frames, about
five per frame. Nothing there halves an emulator.
So the durations stand as written -- title 4.2 s, main menu 1.1 s, EXTRAS
0.87 s -- and Q1 leaves the handoff's open-residue table. It stays
MEASURED, not decoded: no field on the disc says sixtieths of a second.
One trap, and it nearly cost the conclusion. My first attempt reported
39.5 fps, which sits between the two hypotheses and would have left the
question open. It was a harness bug: I polled the log for a "done" line
rather than for the COUNT of them increasing, so it matched a capture
that had already completed and timed 0.1 s of nothing. METHOD gets the
general form -- a log-polling probe must compare a count, not test for
presence.
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 <noreply@anthropic.com>
The port agent's first published arc. Merged from auto/p0-exporter so the image
build below carries its docker/bin fixes -- build-export follows the exporter's
new subcommand shape, and build-reference-cli makes the pinned reference CLI
reproducible rather than a hand-built artifact in a volume.
residue in one place
Every MISSION row is answered, so this iteration checked the deliverable
itself rather than opening new scope: an answer that is not reachable
from HANDOFF.md has not been delivered, and I have been editing that page
by surgical replacement for a dozen iterations.
Four things were wrong or stale:
- the Q5 row still said initial focus was 2x TUTORIAL, 1x NEW GAME while
its own bullet said four boots -- I updated the body last time and not
the row;
- the Q2 bullet still described the other four main-menu destinations as
"likely by name -- an inference, not a measurement", which Q4 has since
MEASURED, and it listed GP_MISSION_SELECT as a main-menu destination
when MISSION SELECT lives under EXTRAS;
- the Q1 bullet claimed that if the game presents at 60 Hz "nothing else
on this page changes". That is now false: Q7's declared fade-in lengths
are keyframe-derived and would halve. The same bullet also missed that
the transition capture is a SECOND independent measurement supporting
1 unit = 1/60 s -- the quad is declared black for 12 units, 0.20 s
under the conversion, and the capture measured the plateau at
0.17-0.23 s. Recorded as corroboration, not proof, because the plateau
spans two screens' fades and cannot separate them;
- bgm-two-stems.md linked ../port/MISSION.md from inside structures/,
which resolves nowhere.
Also named the two videos actually in scope, since "3.3 GB of video, two
files are in scope" is not much use without the filenames.
And added a What Is Still Open section. With every row green the residue
was scattered across eight pages; it is now one table with why each item
is stuck. Two entries share a single unblocker -- an emulator whose audio
path can be observed -- and they are the only ones that would change a
port decision.
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.
closing Q8
Last iteration I named Pj_Silph.xgs as the route to the SE cue index, on
the reasoning that XACT keeps cue->wave there. That route is dead, and
the negative is properly bounded: its 533-byte entry is high entropy with
no XGSF magic, +/-8 KB around it has none either, all 1.08 GB of
sound.pak contains zero XGSF, SDBK and WBND, and the executable has no
XACT or .xgs string anywhere. Control run first -- the same scan finds
RIFF in BGM_001 exactly where the bank structure says it should be, so
the scan works and the magic genuinely is not there.
So the .xgs and .slb extensions came from the authoring tool and survived
into shipped data that is not XACT at all. That goes in METHOD as a
general trap: check for the magic before committing to a parser for the
format an extension advertises. Writing an XACT parser would have been
wasted work.
What that leaves for the SE audio is undecodable-with-reach rather than
pending: Static.slb has no wave boundaries, there is no project file, and
none of the sound table's five records carries an offset. The bank is
certainly the right one -- 8 353 472 bytes over 322 cues is 0.84 to 1.01
seconds each at the bitrates the disc uses elsewhere, exactly the shape
of a bank of short effects. Only the index into it is missing, and it
exists only at runtime.
Q8 closes: vocabulary and bank decoded, the event binding a name match on
the authors' own event names, and the audio explicitly something the port
must source or author rather than extract. With this every MISSION row is
answered.
rather than leaving it amber
Q6's second half asked what the game READS to decide the boot order. The
answer is nothing, and the value here is the reach rather than a find.
Four places checked, the order in none of them: config.ini's [SYSTEM] is
empty and it is the disc's only config; the movie manifest carries the
boot-side assets but no transitions; the requested GamePart id was
already shown to exist only as a stack argument in flight, with no
persistent field and no literal store; and the string GP_ADVERTISE_DEMO
has zero xrefs of any kind, so nothing in the code reads the attract
entry of the id table. A transition is a call with an id argument.
Traced as far as it goes cheaply: the RegisterToFactory<0,
GamePart_Title> string is referenced from exactly one site,
sub_8280E148, which also takes the address of sub_821C7D98 -- where a
factory template puts its creator. Marked amber, because that is position
and convention rather than proof, and sub_821C7D98 has zero .rdata
references, which fits a new+ctor thunk and not a state machine. The
substantial function in that neighbourhood is sub_821C6458 and I did not
read it.
So Q6 closes as answered with the driver classified as code rather than
data, which means the port AUTHORS the sequence -- and that is fine,
because the sequence itself is measured end to end and the handoff now
carries it in one line.
GameParts exist at all
Q6's second half, advanced but not closed, and the negative is the point.
config.ini is the disc's ONLY config file -- one find over the whole
extract -- and its own Shift-JIS header calls it the "Application /
GamePart initial settings table". Its [SYSTEM] section, which that same
comment says holds what the game and every game part share, is EMPTY. So
the boot order is not in disc-side configuration at all, and that search
space is now closed rather than merely unexplored.
What the file DOES carry is the language: XC_LANGUAGE_* -> eng/jpn/deu/
fra/esp/ita, defaulting to eng. That is the mechanism behind the EN/JP
build pairs in GP_TITLE and the <lang>.pak families -- a question the
corpus had described but never traced to its input.
Then the registry. Pulling every RegisterToFactory diagnostic string
binds 24 of the 29 GamePart ids to a C++ class, and five ids have no
registration site: 1, 2, 16, 18, 28. Id 1 is GP_ADVERTISE_DEMO, which
agrees with what I measured two iterations ago -- the attract loop is the
TITLE replaying ADV.wmv, not a transition into an advertise part. Marked
amber, not green: it is an argument from an error message, not from code.
Two things fall out for Q4: ids 3 and 4 are the same class
(GamePart_SaveLoad, one part with two ids), and the menu buttons' ids now
match the executable's own class names rather than a list of table names.
Still a name match, one level closer to the code.
What is still missing is the transitions themselves, and I say so: the
manifest gives the boot-side assets, config.ini the language, the
registry which parts exist. What decides to advance is in
GamePart_Title's code and that dig has not been started.
Two corpus claims died in one boot, and both were costing us.
Q9's last row: a movie IS skippable with a single A. One tap ~45s into
the boot put the title up at ~57s, against a ~193s no-input baseline
measured over three boots, and Canary's own keystroke counter went 3->4
so exactly one press was delivered. The skipped-to title is fully
functional -- it draws the PRESS A plate and a second A opens the main
menu. What actually breaks the boot is hammering: the 88-press run in the
traps doc. The scripts' "tapping breaks the title" comment is too broad
and costs every scripted boot two and a half minutes.
Q4's last row: A on NEW GAME does not hang. It opens DIFFICULTY
(EASY/NORMAL/HARD/BACK, focus on NORMAL), then SELECT DATA, and only then
does the guest throw -- at PC 0x82307128, which is inside sub_823070B0,
the cache-manager STL erase this corpus already documents and which has
nothing to do with the menu path. The screen sat unchanged for 90s
because it was a menu waiting for input from a loop that never pressed
anything. That is now a METHOD line: a screen that never changes is not
necessarily hung, and the fix is to look at it and press something.
Also METHOD: never run ps -ef in this container -- all three long-lived
processes carry the entire loop prompt as argv.
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.
`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.
`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>
not
Q8. The cheapest thing nobody had tried was to look at the SE cue names,
and they are semantic: SE_UI_CURSOR (2), SE_UI_DECIDE (3), SE_UI_CANSEL
(4), SE_UI_IMPOSI (5, the error buzz), SE_UI_SUB_WIN_OPN/CLS, and
SE_UI_SPLASH_IN/OUT. That is exactly the move/confirm/back/error
vocabulary the question asks for, named by the authors after the EVENT
rather than the sound. 322 SE cues committed as reference data.
They all live in one bank: BANK_SE is a single field reading Static.slb,
and the disc-wide check agrees -- 0 of the 322 has an entry in FILES, the
5135-path list that names every voice, briefing and BGM bank.
I am calling the binding a NAME MATCH, not a measurement, and the page
says so in the same breath as it gives the table. It is a stronger name
match than Q4's GamePart ids -- these are the authors' own event names --
but nobody has watched the game emit cue 2 on a d-pad press, and this
container is muted against a dummy device so there is no audio path to
watch either.
One new negative, with its reach. Static.slb's 8 353 472 readable bytes
contain zero RIFF, zero seek and zero WAVE, scanned over the whole
buffer. The boundary marker that works for all 7 620 other banks is
absent, so the cue is named, the bank is named, and the wave inside it is
not locatable. The UI sound effects cannot be exported yet, and that is a
gap in the assets rather than in the naming. The named next step is
Pj_Silph.xgs -- the XACT project SETTINGS points at, which IS in
sound.pak at TOC 9454, though its 533 bytes carry no XGSF magic and its
region's phase says they are probably the previous bank's tail.
`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>
new-game intro has a name
Q9, answered twice over -- once from the disc and once from frames I had
already captured and not used.
The movie manifest's first eight slots are the whole boot-side flow, and
the slot key is the role: LOGO1-4 -> logo1-4.wmv (not on the disc, which
is why the splash is a screen), ADVERTISE_MOVIE -> ADV.wmv, STAFF_ROLL ->
the credits reel, MS00A -> S00A.wmv, MS01A -> S01A.wmv. So the new-game
intro is S00A.wmv, decoded -- which is the half I could not test at
runtime, because A on NEW GAME hangs the emulator.
And there is no separate boot-intro slot: ADV.wmv IS the advertise movie,
the boot just plays it first. Confirmed independently by matching 19
attract frames against five candidates -- 15 hit ADV with a playhead that
advances monotonically at the sampling rate and ends at 137 s, its full
length, with the title back on the next sample. So the attract movie
plays to its end; nothing cuts it short.
That corrects me. Two iterations ago I recorded the attract movie as
"~85 s, so probably not ADV.wmv" -- arithmetic on a start I never
observed, since sampling began 39 s in. REFUTED and METHOD both take it,
along with the matcher's real failure mode: a near-black frame has no
signature, and its runner-up is not evidence.
Skippability I did NOT settle, and the corpus contradicts itself: one
page says A skips a movie every time, while the boot harness deliberately
never taps during one because it breaks the title. Named the one-boot
test rather than picking a side.
question expected
One iteration, as the mission gates it, and no Ready Room work started.
The two criteria it named both pass. It is NOT 3D: the corpus already
holds a capture of the running screen and it is a flat six-item menu in
the same visual language as the title. And unrecoverable paths do not
mean unrenderable screens -- content enumeration finds 60 builds in the
1106 entries.
What kills it is something the question did not ask. GP_READY_ROOM.pak
does not appear to hold the Ready Room's own menu at all. Two signals
agree: the four largest builds composite to briefing panels -- a planet
readout, unit markers, direction arrows -- and, much harder, the 902
bundles declare 317 distinct element names of which NONE matches any of
the six labels on the screen (takeoff, brief, hangar, pilot, option,
mission, ready all return nothing). The pak is the briefing /
tactical-map content behind the BRIEFINGS item. If this is ever picked
up, the first job is finding where the menu lives -- GP_BUNK.pak is the
obvious place -- not naming this pak's keys.
The probe also reaches back into something already delivered: kind ==
0x3002 finds ZERO buttons across all 902 bundles here, while GP_TITLE has
16. 0x3002 is one member of a 0x3000 family with sub-bits, and this pak
uses 0x3000/0x3004/0x300c/0x3008. Nothing in the milestone changes --
every screen in scope is GP_TITLE -- but the handoff now says not to ship
that equality as a general button test.
not something to concatenate
Q10, and it starts by withdrawing the question's own premise. BGM_001 is
not three sub-waves of 10 KB / 4.47 MB / 4.67 MB: the 10 KB is the bank
header. A bank is exactly TWO waves, and across all 32 BGM banks on the
disc the two always have the SAME duration -- equal to 0.01 s over
lengths from 37 s to 277 s. That alone kills intro+loop and kills two
halves of one piece, both of which require unequal lengths.
Four banks appear to break the rule and do not: BGM_106-109 are the known
leading-region straddle, and the giveaway is that the entry named
BGM_107.slb contains BANK id=1108. The seek packet counts pin each join
exactly, so they realign to the same two-equal-waves shape rather than
being dropped as noise. That trap goes in METHOD.
Then the roles, by decoding both waves to PCM. They are
sample-synchronous: transient-envelope correlation searched over +/-5 s
peaks at lag +0.00 s, and both waves stop at the same millisecond,
167.663 s. Two stems of one performance, meant to sound at once. Wave 1
is quieter, has almost no bass and is far more L/R-decorrelated, which
reads as a surround-rear pair or a second intensity layer -- I cannot
separate those two from the file, and say so: ChannelMask is 0x0002 on
both, and this game's channel metadata is already documented as
meaningless.
Two things the port needs that are NOT on the disc, both marked as
authored: the track is not a seamless loop (BGM_001 fades out and is
followed by 6.15 s of silence, no loop-point field found), and nothing
names which bank the menu plays -- all 32 BGM cues are numeric.
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>
its timing is on the disc
Q7. Every title-side screen carries a full-screen black .prm quad that
paints last, and its keyframe group IS the transition: black at T0, clear
by T1, clear until T2, then back to black on exit. Read with the corpus's
start-of-a-ramp rule and Q1's time unit that gives 0.87s for EXTRAS,
0.97s for the main menu, 4.08s for the title -- from the file, not from a
stopwatch.
The disc-wide check is per-pak all-or-nothing rather than the 41% the
headline count suggests, and GP_TITLE's 6 of 12 is the useful row: the
six builds carrying a fade quad are exactly the six SCREENS, and the six
without are exactly the six overlays. GP_DIALOG is 0 of 133. That is
independent corroboration of the overlay finding from two iterations ago.
One piece is NOT on the disc and says so: the fade-OUT length. The fourth
keyframe has no time slot, because a group's last block stops four bytes
short. Measured instead, at 30fps, ~0.4s and the same both directions.
And a warning I earned: the luminance rise after a transition is NOT the
quad's ramp. The incoming screen's own elements animate in after the quad
has cleared -- 1.47s observed against a declared 0.97s. Time the fade
from where the frame is pure black.
Rig: screenshot samples at 0.5 Hz and cannot see a 0.4s fade at all,
which is why an earlier burst called this an instant cut. ffmpeg x11grab
at 30fps instead; both go in METHOD.
method lines withdrawn
Q5, measured off two boots: up/down move one item and WRAP at both ends
on the 5-item main menu and the 3-item EXTRAS alike; left/right do
nothing; B goes up one level and restores focus to the item you came
from (4/4); B on the main menu returns to the title; B on the title does
nothing. The menu opens on TUTORIAL -- the middle item -- 2/2, though a
third recorded run implies NEW GAME, so that one is reproducible rather
than invariant and says so.
Q4 by driving: LOAD GAME opens the save-slot list, TUTORIAL the lesson
list, OPTIONS the settings menu, EXTRAS build 6, MISSION SELECT the stage
list. NEW GAME is not tested -- A on it hangs the emulator and this
iteration needed the session. The GamePart ID behind each is NOT
measured: it is the entry of the decoded id table whose name matches the
screen I saw, and the page says so rather than wearing the badge.
And the withdrawal. Last iteration I wrote that these menus drop d-pad
presses shorter than ~0.3s. They do not. Once wrap is measured, every
press count I had is exactly right -- four presses moved four steps
THROUGH the bottom, which lands one above where a non-wrapping menu
would. I invented hardware flakiness instead of testing the ends of the
list. METHOD keeps the withdrawal rather than deleting the line.
Also: label brightness is not a cursor oracle here -- the background art
outshines the highlight on some rows. menu_focus.py reads the focus ring
instead, 254 vs <82, no tuning.
The handoff had Q3 as "runtime-solved only". It is not: the layer key at
+0x0A of the T8aD sprite header, stable-sorted, is a file-derivable paint
order, already checked against five measured orders and already driving
the compositor for every build on the disc. That answer had simply never
reached the page the port agent reads.
The new evidence is EXTRAS -- the first screen composited from the rule
alone and scored against a framebuffer capture the rule had never seen.
0.9620 at zero shift, against a same-tooling control of 0.9657 on the
screen the rule was fitted to.
Written down with its limit rather than its headline: align_to_capture
correlates edges, and a paint-order change moves blends, not edges, by
=<45/255 on a few per cent of pixels. So the score is evidence for
placement and only CONSISTENT with the order. The sharp A/B needs a
rebuild and is named in the doc instead of being claimed.
Q2. The archive is eight screens shipped twice, English and Japanese --
not the "build 4 title, 5 main menu, 6/8/9 submenus" the handoff claimed.
Build 8 is the JAPANESE main menu; 6 and 9 are the EN and JP EXTRAS, and
EXTRAS is the only submenu GP_TITLE holds. The PRESS (A) BUTTON plate is
its own build (2/3), composited over the title art and faded in a beat
later, not a state of build 4.
Confirmed by booting to the main menu and walking it: title, PRESS (A),
main menu and EXTRAS each match their render element for element. Builds
0/1 and 10/11 -- a DELTASABER / SYLPHEED A.I. plate -- were looked for in
the whole boot filmstrip, every title-side screen and the attract loop,
and appear in none of them; the reach of that negative is written down
rather than filled in with a guess.
Two rig traps went into METHOD: the menus drop d-pad presses shorter than
~0.3 s, and a grab 2.5 s after a transition can catch a screen mid-fade
-- which nearly wrote "the returned title has no plate" into the corpus.
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>
Q1 of the menu port, measured against the running game rather than reasoned
about. The developer-logo splash is the cheap target: it is the first thing the
guest draws and its bundle declares short, unambiguous ramps.
Two results, both frame-exact and both emulator-speed-independent (frame numbers
are VdSwap counts, the guest's own frames):
* the ramp is LINEAR. A declared 15-unit fade lands on round(255*k/15) for all
seven of its samples with zero error, k stepping 2,4,6,8,10,12,14. No ease
can reproduce a constant step of 34 at both ends.
* the animation clock advances 2.000 time units per submitted frame, over six
consecutive intervals with no residual, with 1 unit as the quantum
underneath (one frame in the fade-out advances by 1).
The conversion to seconds is one step further and is flagged as such: 300 frames
took 10.87 s = 27.6 present-frames/second, which reads as a 30 Hz title at 92 %
under the emulator and gives 1 unit = 1/60 s -- the title build 4.2 s, the main
menu build 1.1 s. That reading is not proven, because the rate was measured
while the guest was still streaming from the ISO; the page names the one test
that would settle it and says what changes if it goes the other way.
Committed beside it: the raw draw capture and the per-frame quad CSV, so the
numbers can be re-derived without a disc or an emulator.
The trickiest reading on the disc lived in the Bevy viewer: resolving a
cutscene's voice to a continuous byte REGION of the sound stream, because the
movie voices are one XMA stream chunked into VOICE_*.slb entries whose
boundaries do not match the cues -- a cue routinely spans two chunks, so a .slb
need not hold the track its name claims.
That put the logic most likely to be re-derived incorrectly in the crate least
likely to be reused. The Godot port's exporter needs the same answers, and there
must be one implementation of them.
New `sylpheed_formats::media` owns every case where the bytes of one playable
thing are not one archive entry: segment-spanning reads, multi-sub-wave banks,
and the voice-region resolution. Callers supply bytes through a `DiscSource`
trait, so the viewer keeps its ISO/directory abstraction and a headless consumer
gets `DirectorySource` for free.
The seam is deliberate: this module returns XMA RIFFs, not PCM. Decoding means
shelling out to FFmpeg, which is native-only and a policy decision for the
consumer -- everything up to "here are the bytes that belong together" is disc
knowledge, everything after it is a codec choice.
The four moved functions were previously untested; `tests/media_disc.rs` now
pins them, including the negative the corpus paid for -- an unbound movie must
stay unvoiced rather than borrow a neighbour's clip, which was tried and played
the WRONG recording.
The algorithm is unchanged, moved verbatim (same window sizes, same fallbacks).
The new disc tests pass; the broader audio suite was not re-run in this pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BGM_001.slb is three sub-waves (10 KB, 4.47 MB, 4.67 MB) and the decoder
concatenates them into one 347 s track. That is a default nobody chose, not a
decision: two near-equal halves could be intro + loop, two variations, or two
halves of one piece, and a menu that loops its music needs to know which.
Found while wiring the Audio Library up to the shared banks. Recorded in
HANDOFF.md as a trap too, so the port does not build looping on top of the
concatenated track before the question is answered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `loose` footer announced "cannot push -- no git credentials are mounted"
unconditionally. It was written before the credential mount existed and then
went stale, so with credentials correctly mounted it told the operator their
work would be lost -- the exact failure the mount was added to prevent.
It now reports what is actually true, and keeps the real warning for the case
where the file genuinely is missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Corrects the split I got wrong in 965c3f7. The container is for reverse
engineering, now focused on the menu port; a SEPARATE agent builds the port from
its investigation results. My previous version had the container agent writing
the exporter and the Godot project, which is not the intent.
This lands on the research/engineering line that was already in the estimate:
the container agent takes the research half, the port agent the engineering half.
docs/port/MISSION.md is now a list of open QUESTIONS (Q1-Q9) rather than build
milestones, ordered by what blocks the port earliest -- the keyframe time unit,
which build is which screen state, paint order for the six screens, button ->
GamePart, navigation semantics, the boot sequence driver, transitions, menu
audio bindings, and video binding. Each is done when a written result with
evidence exists, not when something compiles. S1, the Ready Room probe, stays
gated at one iteration and a go/no-go.
Most of these are BEHAVIOUR questions -- timing, transitions, what a d-pad press
does at the end of a list -- so the mission and the loop prompt both push hard
on measuring the oracle rather than reasoning from the file.
docs/port/FORMAT.md is deleted. The export schema is the port agent's design and
was not mine to specify. It is replaced by docs/port/HANDOFF.md, the single page
the port agent reads: a status table, what is already settled and can be relied
on today, and the facts that will trip the port up (the WMV3/WMA Pro intro, the
Static.slb size over-declaration, the voice-vs-music downmix, JNGL_001).
The derived/authored idea survives as the thing it always was -- a finding, not
a design. Every answer must be classified DECODED, MEASURED or UNDECODABLE-with-
reach, and never a fourth thing, because measured and undecodable both mean the
port agent is authoring that value and has to know it. Labelling a guess as a
decode would put it into the port wearing the badge of a measurement.
Reverts the Godot install from the RE container, its AGENT.md section, and the
export/ gitignore entry -- none of that belongs on this side of the wall.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sets a new mission: boot the real disc through developer splash -> intro video
-> title -> main menu -> submenus in Godot 4, interactively, with no gameplay,
no 3D and no emulator.
docs/port/MISSION.md defines it -- eight gated milestones, each finished by an
ARTIFACT rather than by compiling, plus the Ready Room as an explicitly gated
stretch goal with a one-iteration probe that decides go/no-go. GP_READY_ROOM is
1106 entries with 6 recoverable names and is ISL-scripted, so it is either a
week or a quarter, and the agent must not start it on its own authority.
Architecture, per the user's decision: the Godot project is INDEPENDENT of the
Rust viewer and never reads a disc format. An offline Rust exporter converts the
disc into open formats; Godot reads only those. No GDExtension, no Rust in the
Godot project, and sylpheed-viewer is off limits -- it stays the human's
verification tool with its static-data rule intact.
docs/port/FORMAT.md specifies the open format, versioned, because modding is the
port's second goal and that makes the layout a deliverable rather than a temp
directory: JSON over XML (Godot parses JSON natively; its XMLParser is SAX),
names never hashes, provenance in every generated file, and unknowns listed
rather than guessed.
The discipline the whole thing rests on is the derived/authored split. `export/`
is regenerated wholesale and never hand-edited; `authored/` is hand-written and
survives a re-export. Three things this milestone needs are NOT on the disc in
any decoded form -- which button does what, paint order, and menu sound cues --
so they live in `authored/` with a stated `why`. Deleting an authored entry
because the exporter can now emit it IS the measure of progress.
`export/` is gitignored: it is generated from the user's own disc and this stays
a clean-room repo.
Container: adds a pinned Godot 4 (windowed under Xvfb for screenshots, plus a
headless wrapper). ffmpeg already carries libtheora, which is the video target --
Godot 4 plays only Ogg Theora natively and the disc's ADV.wmv is WMV3/WMA Pro.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A reference page covering the four layers (disc media, IPFB archives, container
formats, domain chains) and the three hashes that join them: name_hash for
archive TOC keys (case-insensitive), tag_hash for IDXD records and fields
(case-sensitive), ixud_hash for localised text.
Counts were measured on the retail extract for this page rather than copied
forward from the corpus -- 41 archives, 26443 entries, 166 xpr, 97 wmv -- and
where they overlap with docs/re they reproduce it independently (104 cutscene
slots / 101 movies / 99 / 99 / 22).
It gives the dangling references their own section, because they are properties
of the shipped disc and a port has to survive them: pmbase.t32, SUBTITLE_S12B,
the four boot logos, GP_TEST, the six 2D paks at exactly 0% named, and the 42
provably unrecoverable ISL symbol keys.
Also published as an artifact for reading.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`movie_manifest` has been parsed since the movie-voice work and rendered
nowhere: it resolved a voice bank and that was all. So the only way to find a
cutscene was to hunt `.wmv` files in the ISO tree, where nothing tells you which
mission a file belongs to, whether it has subtitles, or what is said in it.
View ▸ Cutscenes lists all 104 manifest slots with mission/phase, kind, movie,
subtitle track, voice token and telop, and -- the part that needed no new
parsing, only a route -- resolves the captions to a readable TRANSCRIPT with a
language selector. Subtitles were previously burned into the video during
playback and reachable no other way.
Three negatives are shown rather than smoothed over:
* 5 manifest-bound movies have no `.wmv` (logo1-4 and an encoder test clip).
They are marked and get no Play button instead of one that would fail.
* 9 of 101 movies resolve no English transcript.
* the `.prt` telop overlay is named by the manifest and we have no parser, so
the reference is shown labelled "not decoded" rather than omitted.
`cutscene_catalog_binds_movies_and_transcripts` pins all of it against the disc
-- 104/101/99/99/22, the exact absent-movie list, 92 transcripts -- because a
browser that quietly dropped these would look complete and be wrong. The counts
independently reproduce docs/re/movie-subtitle-link.md.
Play routes through the normal FileSelected path, so the existing video player
handles it exactly as it would from the tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The library enumerator kept only names containing VOICE or \Briefing\, and read
eng\sounds.tbl unconditionally. So the Explorer could reach 4382 of the 9519
banks in sound.pak: no music, no jingles, no sound effects, and no Japanese
voice at all -- roughly half the disc's audio had no route to the UI.
`slb::list_audio_entries` now returns every named bank with the category its
path implies (Music / Jingles / Sound effects / Radio / Dialogue / Movie voice /
Briefing). `list_voice_clips` is that, restricted to the spoken categories, so
its existing test still guards the old behaviour. The 36 root banks carry no
language component and appear whichever table is read; the window gets an
English/Japanese switch that re-reads the other sounds.tbl, since the table name
IS the selector.
Two defects the decode found, both recorded in
docs/re/structures/sound-pak-contents.md:
* `Static.slb` -- the SFX bank -- declares 616768 bytes more than sound.p04
holds. Not our extraction: p04 matches the ISO's own directory record, and a
sweep of every pak on the disc finds this one entry over-running and no other.
It is the highest-offset entry, so its comp_size is an allocation size. A
short read is now allowed for the tail entry ONLY; any other overrun stays an
error, because clamping it would hide real damage behind a half-decoded asset.
The bank went from unreadable to 514 s of audio.
* the left-channel downmix was applied to everything. Right for voice (mono
content however stored), wrong for music (a real stereo mix, half of it
discarded). The caller now decides from the category.
35 of the 36 shared banks decode; JNGL_001 does not, and says so in the player
instead of the panel silently closing. Its payload is not a whole number of XMA1
packets from any known data offset, so it is likely not a plain headerless
stream -- written up rather than papered over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The UI Screens window enumerated with `is_build` while the PAK browser's
inline preview composes anything `parse_build` accepts. So the browser drew
screens this window flatly refused to list -- most visibly `palogo`, the
publisher splash, which declares its sprites directly and has no `.rat`
layout child.
`compose_screen` now gates on `is_composable`, the documented superset (every
`is_build` bundle passes it), so anything the list offers is drawable and the
two presenters share one rule. A "Fragments" toggle widens the enumeration to
it as well, off by default: the extra ~1786 bundles are mostly two-element
fragments (a button beside its glow) that would bury the real screens. It
re-scans, which renumbers the display ordinal -- harmless, because the pak
ENTRY index is the locator and the stale catalog is cleared.
The toggle sets a `rescan` flag rather than self-sending RequestScreenCatalog:
a system that both reads and writes one event type is a B0002 panic at
startup, which is how the Save browser broke.
Also plumbs the last two ComposeOptions the CLI had and the UI did not --
`black backdrop` (what a framebuffer capture must be compared against) and
`primitives` (decoded, but paint order unsolved; hover text says so).
Verified: `screen render --all --build 13 GP_TITLE.pak` draws the SQUARE ENIX
splash, 2/3 elements, the third being the .prm quad. Workspace builds, viewer
reaches steady state, disc-gated suite 20/20 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three compounding causes, found by tracing every place a `loading` flag is set
against every place it is cleared.
THE COST. `compose_screen` inflated EVERY entry in the pak and held every build
in memory at once, purely to index into the result -- then did it again on every
checkbox click, re-opening the ISO from scratch each time. But the catalog
already records each build's pak ENTRY INDEX, so the locator was there all
along: compose now reads exactly one entry. `RequestScreenCompose` carries
`entry` (the locator) beside `build` (the display ordinal).
`build_screen_catalog` had no budget at all, while the pak browser has capped
exactly this work since it was written. It now skips oversized entries and stops
at a ceiling.
That ceiling is 384 MB, not the pak browser's 64 MB, and the difference is the
point: GP_HANGAR_ARSENAL inflates past 160 MB and holds ~390 builds, so a 64 MB
cap would have quietly hidden most of them -- trading a hang for a wrong answer.
When the ceiling IS hit the pak is marked `truncated` and the UI says so.
THE LATCH. `poll_loader_channel` treated a disconnected channel exactly like an
empty one, so if a worker died every in-flight spinner stayed up for the life of
the process -- and the `if loading { return }` guard at the top of each handler
then refused every retry. Disconnect now clears the flags and reports it.
Verified: the workspace builds, the viewer reaches steady state, and one
composite still draws 11/11 elements of the tutorial pause menu.
Commit 56deb56 ("Merge branch 'auto/idxd-unnamed-keys'") shipped INDEX.md with
live conflict markers. My check was worthless in a way worth naming: I ran
git add docs/re/INDEX.md ; git status --short | grep -E '^(UU|AA)'
in that order. Staging a conflicted file is exactly what marks it resolved, so
the grep found nothing and reported "no unmerged" -- the control passed BECAUSE
of the action it was meant to gate. Both halves of the hunk then went into the
tree verbatim, leaving two competing IDXD rows.
Resolved as a union, which is what it always was:
* HEAD's "IDXD object/table" row -- the 2026-08-25 record/index-region decode
-- supersedes the branch's older one-line version of the same row;
* the branch's "IDXD nameless field keys" row is new and is kept.
The autonomous agent's loop prompt had grown to ~9 000 words of accumulated
findings, refutations and traps. That is a symptom, not a style: it was doing
the job the corpus should do, in the one place that dies with the container.
Three concrete failures followed from it, and each gets a structural fix rather
than a louder instruction.
1. IT REPEATED WORK IT HAD ALREADY DONE.
The "do not revive" list and the method traps existed ONLY in the prompt, so
nothing pointed a fresh iteration at them. Extracted verbatim into two
tracked files:
docs/re/REFUTED.md 105 claims tested and dead, grouped by subject so a
grep for your noun finds the neighbourhood
docs/re/METHOD.md the traps already paid for -- controls, inference,
searching, reading data, runtime
Both are linked from INDEX.md, and the loop prompt now opens by requiring
them to be read. This is the fix for "re-derived something already known":
the knowledge is now where the next iteration looks, not in a context window.
2. IT FORGOT TO ARM THE NEXT WAKEUP.
The prompt tried to solve this by shouting at itself in the first line. The
real fix is to stop asking: `loose` now defaults to a FIXED interval (45m),
so the harness owns the cadence and a forgotten ScheduleWakeup cannot end the
run. SYLPH_LOOP_INTERVAL= (empty) restores self-pacing.
3. IT COULD NOT PUBLISH, SO THE WORK ONLY EXISTED IN THE CONTAINER.
New `push-work`, plus a read-only credentials mount
(SYLPH_GIT_CREDENTIALS, default ~/.sylph-git-credentials). It pushes the
CURRENT branch only, refuses anything that is not auto/*, and never
force-pushes -- so the consolidated line stays a human's decision and a
confused iteration cannot rewrite history. The loop prompt now requires a
push on every iteration that commits, rather than at the end of some longer
arc, which is exactly when a container dies.
The prompt itself drops from ~9 000 words to 85 lines and, more importantly,
stops accumulating: findings go in the corpus, and the prompt points at it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Read 0x821A0AD0, the debriefing, end to end, resolving every call and string in
code order. The word-B bit-setter sub_8219F2E8 is its FIRST call, before anything
else; the function makes 44 calls and references exactly two strings, DEBRIEFING
and BASE_INFO.
No Time and no Points anywhere on this path - not in the debriefing, not in the
bit-setter. Section 5.3's expectation that clearing a challenge stage records its
metric alongside the bit is not supported by the code that clears it. The reach of
that negative: the debriefing function and its directly-called bit-setter, both
read whole; it does not rule out a store deeper inside one of the other 43
callees.
0x8219F670 turns out to be an online-state predicate: 88 bytes, no strings, reads
the singleton, tests bit 1 of [0]+60, and if set passes [0]+64 to 0x824A9C90 and
returns whether the result is 2. A connection or sign-in check, not a record
store.
The useful find is 0x8219F460, 368 bytes and self-recursive, which drains a ring
based at r29+320 with element base +4, capacity +8, head +12 and count +16. Those
are the corpus's documented message-bus offsets exactly - BACKLOG records
sub_82175C20(bus+4, &msg) as a ring-buffer append with capacity +8, head +12,
count +16 - and sub_82175C20 is the very notifier section 5.6 found the word-B
writer calling with bit + 64. So the open question "ring-buffer PUSH, no handler"
now has a candidate drainer with a byte-for-byte matching field layout, reached
from the debriefing.
What is NOT shown is that these are the same ring instance. The push targets
bus+4, this drain targets r29+320. Matching offsets prove a shared container
shape, not a shared object, and the identity needs the two base pointers tied
together.
All seventeen artefacts byte-identical.
Section 5.5 narrowed the search to 21 functions but could not name one, because
the scan assumed the copy destination was an r1-relative stack local. It is not.
Reading the known word-A writer instead of guessing its shape shows a frame
register: bl singleton, addi r3,r31,96 (dest), addi r4,r11,80 (src = obj+80), bl
copier, stw r10,96(r31). That one wrong assumption made the first scan return 0
for all 21, the known-good included - the third filter this week killed by its own
control.
With the real shape - addi r3,rF,K before the copier, then stores at K+d on rF -
word B sits at d = 1956-80 = 1876, and exactly one of the 21 hits: sub_8219F2E8,
copy dest r31+560, storing 2436(r31), and 560+1876 = 2436. Control: the word-A
writer 0x821C1630 stores at relative +0, i.e. word A.
The proof is the bias. Word B's writer computes addi r11,r11,-24 then 1 <<
(index-24) before or-ing into the copy; word A's writer shifts by the raw stage
number with no bias. That reproduces section 5.1's read-side rule - n >= 24 means
bit (n-24) of word B - on the write side, so word B's bits 0-5 are challenge
stages 24-29, the same six that 5.4's MISSION_ID names. The two writers are exact
complements.
It has exactly one real caller, 0x821A0B1C inside 0x821A0AD0, whose strings are
BASE_INFO and DEBRIEFING - so clearing a challenge mission is recorded from the
debriefing screen.
A second bitfield falls out of the same function: it reads the copy at +1880
(2440(r31) = obj+1960, the word after B), re-reads obj+1960 after the setter, xors
old against new, and loops the 32 result bits calling 0x82175C20 with bit + 64. So
obj+1960 is a second 32-bit progress bitfield with ids 64-95, whose newly-set bits
are announced one at a time - an unlock-notification loop.
Against 5.3's own prediction: neither sub_8219F2E8 nor its caller references Time
or Points, 0 for both. 5.3 expected word B's writer to also store the stage's
Time/Points record. It does not - this is the bit-setter only, and the record
store is still unlocated.
All seventeen artefacts byte-identical.
Section 5.3 left word B (+1956) with no known writer. Three routes ran this turn:
one produced a population, two were refuted, and the field is still unwritten.
The offset route has power here, unlike +184. +1956 is a large unusual offset with
9 stores and 28 loads image-wide, against +80's 5403 and 6923. Control: the known
reader 0x821898C4 sits in the gate's list-builder 0x82189870, exactly where 5.1
puts it. But none of the 9 stores turns out to be a progress write - filtering by
whether the storing function reaches the progress object gives 0 of 8 distinct
functions, and the 9th stores through r1, the stack pointer, so it is a local.
+1956 is simply an offset several unrelated structures also use.
The obvious filter is dead, and it fails its own control. Testing "does the
function reference the singleton global 0x828F48B0 or call 0x821707C0" against the
KNOWN word-A writer returns no contact, because that path reaches the progress
object through the copier and never through the global. A filter that rejects the
known-good answer proves nothing about the others.
What did work: progress is only ever changed copy-modify-store, so every writer
must call both the copier 0x82175110 and the setter 0x8216FF70. Measured, 21
functions call the setter, 21 call the copier, and 21 call both - the two caller
sets are the same set. That is the complete progress-write population and it is
small enough to read exhaustively. Control: the word-A writer is among them as
0x821C1630 (BASE_INFO + DIFFICULTY), and 5.2's store 0x821C1820 lies inside it -
the doc's 0x821C09D8-0x821C29F0 is the enclosing method, 0x821C1630 the function.
No member of the 21 stores to +1956 directly. Their only large store offsets are
into this - 1004, 1980, 2040, 2100, 2199, 2436 - with 1004 being 5.2's own
this+1004 & 0x20000 guard. So word B is written through the stack copy, at
localbase + 1876, which no offset scan can pick out.
One false friend worth recording: two of the 21 reference Points, which looks
exactly like the challenge record-storing path 5.3 predicts. It is not - their full
string sets are Dependency, MissionObjective, Points, WEAPON, WEAPONS, i.e. the
arsenal development economy, and neither references Time. There are two Points
vocabularies, development points and the leaderboard metric.
Next candidate, not yet checked: 0x8218EFE0 is the one progress writer whose
strings are BASE_EXTRA and DIFFICULTY - the only member of the 21 carrying the
EXTRA vocabulary.
All seventeen artefacts byte-identical.
challenge-mission-gate.md section 5.3 left a marked 🟡 on the exact pairing and named
the right route: read the record's binary index section rather than the string pool.
Running that with the corpus's own reader settles all six missions at once.
TimeAttack MISSION_ID 24 REQUIREMENT 16 RECORD_TYPE Time
ScoreAttack MISSION_ID 25 REQUIREMENT 24 RECORD_TYPE Points
Extra01 MISSION_ID 26 REQUIREMENT 25 RECORD_TYPE Points
Extra02 MISSION_ID 27 REQUIREMENT 26 RECORD_TYPE Time
Extra03 MISSION_ID 28 REQUIREMENT 27 RECORD_TYPE Time
Extra04 MISSION_ID 29 REQUIREMENT 28 RECORD_TYPE Points
Control: the table appears in 6 pak entries, all in tables.pak, and all six are the
same table byte for byte - one per language, i.e. one user under the per-pak copy
rule. Zero variants.
Three things fall out, none of them assumed. MISSION_ID is the stage number: the six
IDs are 24-29 in slot order, exactly the six challenge stages S24-S29, bijectively -
so the "four Extra0n modes against five _EXn stages" worry dissolves, because _EXn is
a unit-variant suffix that indexes nothing here. REQUIREMENT is a strict chain, 16,
24, 25, 26, 27, 28, each mission requiring the previous one's stage with TimeAttack
anchored on stage 16, the final story mission; 5.3 inferred a chain from the shape of
the string pool, and it is now exact and branchless. RECORD_TYPE is the leaderboard
metric and it is per-stage, not per-name: 3 Time (S24, S27, S28) and 3 Points (S25,
S26, S29), so the tempting reading that the Extra0n family shares one metric is
wrong - Extra01 and Extra04 are Points while Extra02 and Extra03 are Time.
Withdrawn from 5.3: the string-pool reading listed the numeric tokens as 16, 25, 26,
27, 29 with 24 and 28 deduped away. The real REQUIREMENT set is 16, 24, 25, 26, 27,
28 - 29 is a MISSION_ID, never a requirement. A value pulled from a deduped pool by
adjacency got one member wrong; the indexed read has no such failure mode.
Left as an association rather than a proof: RECORD_TYPE takes exactly the two values
Points and Time, and result-screens names two ranking screens POINT_RANKING and
CLEAR_TIME_RANKING, but no code path has been shown selecting one screen from that
field.
All seventeen artefacts byte-identical.