Files
Sylpheed/docs/agents/CONTAINER-NOTES.md
sylph-decoder 6f53c2cc9f notes: refute 'any Canary change is a full reconfigure plus a full compile'
CONTAINER-NOTES said a Canary source change costs a full reconfigure against
/canary and a full compile, and told the reader to budget a session for it. It
was right about the defect -- the warm build tree at /sylph-home/re/canary-build
is configured with CMAKE_HOME_DIRECTORY=/work/xenia-canary, which does not exist
here -- and wrong about the consequence.

Symlinking /work/xenia-canary -> /canary restores the path the tree was
configured with. A one-file edit to command_processor.cc then rebuilt and
relinked xenia_canary in under 10 minutes at -j4, exit 0, no reconfigure and no
OOM. This is how the blend-state logging in the same session got built.

The symlink is untracked inside the repository, so the note also says to remove
it afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
2026-08-31 05:50:02 +00:00

16 KiB
Raw Blame History

Notes for an agent working inside this container

Read this before starting a dynamic-RE run. Everything here is something that already went wrong once.

The container fixes three old traps for you

  • The display outlives the turn. Xvfb and openbox are children of PID 1, not of your shell. The old "Xvfb dies on its own every few minutes" note is gone — you no longer have to wrap a whole session in one blocking foreground call to keep it alive.

  • The toolchain is real. tools/re-capture/rebuild_canary.sh exists because the old box had no cmake/ninja/clang and only runtime sonames, so it hand- relinked object files. Do not use it here. Use build-canary. 🔴 But build-canary does not work in this container as it stands (2026-08-29). It builds ${PROJECT_DIR:-/work}/xenia-canary, which does not exist here — the Canary source is at /canary ($XENIA_SRC). The warm 235 MB tree at /sylph-home/re/canary-build is configured with CMAKE_HOME_DIRECTORY=/work/xenia-canary, also missing, and its build-Release.ninja carries no per-file rules — it wants to re-run CMake first, which would fail on the absent source root.

    The conclusion drawn from all that is REFUTED (2026-08-31). The note went on to say "any Canary change is a full reconfigure against /canary plus a full compile, not an incremental one", and budgeted a session for it. That is wrong, and the fix is one line:

    ln -sfn /canary /work/xenia-canary          # the path the warm tree was configured with
    cmake --build /sylph-home/re/canary-build --config Release --parallel 4 \
          --target xenia_canary
    rm /work/xenia-canary                       # it is UNTRACKED inside the repo — remove it
    

    Measured: a one-file edit to src/xenia/gpu/command_processor.cc rebuilt and relinked bin/Linux/Release/xenia_canary in under 10 minutes at -j4, exit 0, no reconfigure, no OOM. The missing source root was the only defect; the warm 235 MB tree is otherwise intact and the ninja re-run resolves its rules from the symlink.

    ⚠️ Remove the symlink when you are done. /work is the repository, and xenia-canary is not in .gitignore, so it shows up as untracked and can be swept into a git add -A.

  • numpy and Pillow are installed. entities2.py, flight_probe.py and the image oracles work. Their absence used to look like a logic bug.

Method (the part that matters more than the tooling)

  • Measure the oracle; never infer it. A session with zero Canary runs is a red flag.
  • Trace upstream to where data first goes wrong, rather than patching the symptom you can see.
  • Try to refute before believing. Record demotions rather than editing them away — docs/re/README.md has the /🟡/ convention, and a withdrawn result is more useful than a quietly deleted one.
  • A probe that never performs the action will "prove" the action does not exist. The "targeting is automatic" conclusion came from a sweep that only ever tapped once; target select is Ⓐ pressed twice.
  • Do not poll faster than the guest updates — it manufactures a clean curve out of noise. rate-curve-aliased-BAD.csv is committed as the bad example.

Running the emulator

run-canary                       # correct audio/pad/display flags baked in
pad.py tap A ; pad.py dpad down  # scripted input (--hid=file, no uinput)
screenshot ~/shots/now.png       # cropped to the GAME surface, not the window
python3 tools/re-capture/gmem.py find hex:820af844 400
  • One emulator at a time. run-canary enforces it with a lockfile.

  • 🔴 run-canary is SILENT TWICE OVER, and that defeats audio-capture. Line 82 is export SDL_AUDIODRIVER="${SDL_AUDIODRIVER:-dummy}", and its header explains why: --apu=nop stalls the guest in the intro movie, so the SDL driver against a dummy device is what lets the title advance. The comment's premise — "there is no PulseAudio here" — stopped being true when tools/audio-capture landed, and it starts a daemon on demand. So a capture through the null sink records pure silence, at the right length, with a perfectly healthy-looking run behind it. To actually record the game:

    ⚠️ And that is only the first of TWO layers. run-canary also passes --mute=true on its own command line (line 98). With the driver fixed and the mute left alone, Canary attaches a healthy 6-channel stream to the sink, holds it at 100 % volume for the whole run — and emits silence. Both have to go:

    audio-capture start                     # or load a null sink yourself
    PULSE_SINK=cap SDL_AUDIODRIVER=pulseaudio \
      run-canary --mute=false# `"$@"` is last, so this wins
    

    Record at the monitor's real format, too — parec defaults to stereo/44.1 kHz and will silently resample a 6-channel monitor: parec -d cap.monitor --channels=6 --rate=48000 --format=s16le.

    🔴 And even with both mutes off, a PulseAudio-monitor capture is not faithful — use the ALSA tee instead. A null sink's monitor is sampled on a wall clock and invents silence whenever the client is late, so a capture through it is 39 % holes that the game never emitted. PULSE_LATENCY_MSEC only trades gap count against gap size and never wins. The working route is --apu=alsa with an ALSA file tee in front of a paced slave — full recipe, controls and three configuration traps in audio-capture-alsa-file-tee.md.

  • 🔴 A BARE ALSA file tee WILL FILL THE DISK — always run a size guard. Xenia's ALSA writer thread pads silence whenever its ring is empty (alsa_audio_driver.cc:359), so against a device that never blocks it free-runs: measured at ~250× real time, 7.34 GB in 50 seconds. The slave must pace — slave.pcm { type pulse } — and the capture loop should abort above ~3× real time. The next person to try a bare tee hits this in the first minute.

  • And use --gpu=null for an audio capture. It is what takes the guest from 0.70× to 0.96× real time, which stops Xenia padding at all: 0.31 % silence and 0.01 gaps/s, against 9.98 % / 8.37 rendered. ⚠️ No video, so screen-based provenance is unavailable (use the XMA probe), and --gpu=null runs here die at ~70 s with PM4_DRAW_INDX: Failed in backend. 🔴 REFUTED 2026-08-30 — that lifetime does not hold. A --gpu=null capture ran 148.02 s and ended on its own probe's timer with the emulator still alive, having decoded the whole ADV movie (intro-audio-output-census.md). More than twice the quoted figure. Whatever produced the ~70 s was fixed or was never general, and this note had been the reason not to use --gpu=null for anything long — which is exactly the configuration a clean audio capture needs.

    ⚠️ Do not read Canary's 6-channel PulseAudio stream as evidence the GAME is 5.1. pactl will show float32le 6ch 48000Hz, channel-mapped to a full 5.1 layout, on any title. That is AudioDriver::kFrameChannelsDefault = 6, a hardcoded constant — the code path actually used (SDLAudioSystem::CreateDriver(index, semaphore, &driver)) constructs SDLAudioDriver(semaphore) and takes every default. The format is Xenia's; only the content of those six channels is the guest's.

    ⚠️ Check pactl list sink-inputs before trusting a recording. If it is empty, Canary never attached and you are recording zeroes; the sink also sits at IDLE. audio-capture run warns on a -inf peak afterwards, which is the backstop — but a live check fails in seconds instead of after the whole run.

  • Boot is slow cold, ~25 s once the shader/code caches are warm — so a launch-and-dump fits in a single call.

  • Screens: classify by whole-image statistics (screen_id.py), not named pixels. Named-pixel oracles are only valid while the game image sits at a known place, and nothing errors when it moves.

Verifying your own work

  • Reborn's disc-gated tests self-skip without SYLPHEED_DISC. A green run with it unset means almost nothing. 🔴 But build-reborn does not work in this container (2026-08-29). Line 15 is SRC="${PROJECT_DIR:-/work}/Syplheed-Reborn" — note the transposed letters — and no such directory exists; the workspace is at /work itself. It fails immediately with cd: /work/Syplheed-Reborn: No such file or directory, so the documented way to run the disc-gated tests is broken. Run them directly instead, setting the variable yourself:
    SYLPHEED_DISC=/disc cargo test -p sylpheed-formats --test <name>
    
    ⚠️ This is the second wrapper in this container pointing at a source root that does not exist — build-canary has the same defect. Check a wrapper's SRC before trusting that a green or a failure came from your code.
  • Prefer a headless self-verify over "it compiles": sylpheed-cli mesh render, screen render, save info all produce checkable artifacts.
  • A Bevy system-parameter conflict is invisible to the type checker and panics at startup. If you touch viewer systems, run the binary, don't just build it.

Reporting

State what you measured, what you assumed, and what you could not settle. If a result is withdrawn, say so and keep the reasoning — that is the corpus's whole convention, and the reason its numbers can be trusted.

The static-analysis corpus — mounted, not reproducible

Two things arrive read-only from the host because nothing in this repository can produce them yet:

path what env
/xenia-rs/sylpheed.db the disassembly database, 586 MB SYLPHEED_DB
/image/sylpheed.pe the decompressed image, flat VA dump SYLPHEED_PE

The .pe is a flat VA dump: file offset = VA - 0x82000000 (SYLPHEED_IMAGE_BASE). So reading 0x820A1630 is seek(0xA1630) — no XEX decrypt, no LZX, and no booted emulator. An earlier belief that this file was stale was tested and refuted; it is current.

Recovering the image by dumping /dev/shm/xenia_memory_* also works and self-validates, but it needs a running emulator — a poor dependency for something the whole static corpus rests on. Use the file.

The database is far richer than the four scripts that read it use:

functions                     25 481   address, name, end_address, frame_size,
                                       saved_gprs, is_leaf, pdata_validated, has_eh
classes                          851   name, vtable_address, rtti_present, base_classes_json
imports                          398   library, ordinal, name, address
eh_funcinfo / eh_try_blocks    2 588 / 315
function_pointer_arrays        1 526   + 8 568 entries
indirect_dispatch_candidates 1 827 297  dispatch_pc, vtable_address, method_address

instructions.raw is an INT, not hex — a trap this corpus has already paid for. Query with python3 -c 'import duckdb'; it is not SQLite.

⚠️ It is derived data, and it is wrong in places

The image is primary — those are the bytes the console executed. The database is an analysis of them, produced by a disassembler that had to guess, and it fails the way disassemblers fail:

  • misdecoded mnemonics — data read as code, or a decoder-table gap, produces a plausible instruction that was never executed as one;
  • wrong function boundariesend_address short or long, neighbours merged, one function split in two;
  • incomplete coverage — code reached only through indirect dispatch may be absent entirely; the 1.8 M indirect_dispatch_candidates are candidates;
  • invented names — largely derived rather than symbols, so a name is a hypothesis wearing a label.

A finding resting on a database row is not established until the bytes agree. Read the same address out of the .pe and check. Where they disagree the image wins, and the disagreement is worth recording — it tells the next reader which parts of the database to distrust.

It is a fast index into 9.2 MB of machine code. It is not a source of truth.

These mounts are reference material, not a deliverable

They are read-only and they come from outside the repository, which means a fresh checkout on another machine has neither. Reimplementing the producer — XEX decrypt + LZX decompress, and the disassembly-to-database step — belongs in crates/sylpheed-formats. Until then, every static finding rests on an artefact this project cannot rebuild, and that is a real gap in the corpus rather than a convenience.

🔴 Pressing Ⓐ on the title faults the guest — and the fault fills the disk

Three attempts to capture the main menu on 2026-08-29 ended the same way. Every run that tapped Ⓐ on the title faulted; every run that tapped nothing there completed and produced its capture.

run input on the title outcome
1 Ⓐ, then Ⓐ again on the transition guest fault, 519 MB of register dump
2 one Ⓐ drifted to a flight classification, 97 MB
3 one Ⓐ guest fault, 223 MB of register dump
46 none (NOTAP=1) all completed normally

This is the crash ui_draw_capture.sh's own header records from 2026-08-18 — "a stray A there sends the guest into the save-data probe". ⚠️ The corpus's existing menu measurements (Q4, Q5, the focus ring) were taken by some route that survived this; what differs has not been found. Menu-side dynamic RE is blocked until it is.

⚠️ A guest fault writes an UNBOUNDED register dump to stdout. Xenia runs with break_on_unimplemented_instructions = true, and the dump is vN = [...] / rN = ... lines at roughly 100 MB per 30 s. The filesystem here sits at 91 %. Any scripted run that presses a button must watch canary.stdout and kill on growth — ls -la on it before trusting a long run.

📌 Two knobs added to ui_draw_capture.sh for boot-side work: GRACE=1 (the fixed 8 s wait before arming means an ARM=early capture otherwise misses both splashes, which run at ~1.29.5 s of guest time) and NOTAP=1 (no input at all — the movie tap fires on "the screen changed a lot", which is also true of a fading splash).

⚠️ ARM=early loses its F10 about 40 % of the time

Five ui_draw_capture.sh ARM=early runs on 2026-08-29: two logged ARMED EARLY and produced no xenia_re_ui_draws_NN.log at all. The keypress goes to the window and is silently lost — nothing in the session log distinguishes a run that armed from one that did not, so check the log file exists before spending the run, and treat a repeat measurement as needing more attempts than samples.

  • 🔴 sylpheed-cli in $CARGO_TARGET_DIR can be STALE, and screen info lies quietly when it is. The copy here was built 2026-08-29 12:38, before the keyframe-record-layout fix. The old parser shifted every keyframe time by one slot and could not time a group's final pose, printing a trailing -:

    stale  pteff00.prm  4 kf  rest t=70   [12:0,0  70:0,0  80:0,0  -:0,0]
    fresh  pteff00.prm  4 kf  rest t=12   [ 0:0,0  12:0,0  70:0,0  80:0,0]
    

    Both outputs are well-formed and neither announces its age. A whole page of this corpus (screen-transitions.md) argued from "there is exactly one untimed keyframe", which was the stale parser's artefact.

    ⚠️ cargo build -p sylpheed-cli before trusting screen info — it takes 8 s against a warm cache. Renders are byte-identical across the two binaries (checked on GP_TUTORIAL build 0, max per-channel difference 0), so screen render output and anything derived from element identity, pivots or keyframe counts is unaffected. It is the times that move.