The port refuted the stronger half of the last claim and was right. I wrote that the units/frame drift explained the publisher splash's 4.1% error against its declared dwell. It explains the sign only. Their test verified exactly here: the publisher/developer dwell ratio is 1.2143 declared, 1.2784 as the corpus's three cold boots measure it, and 1.3678 as this container's drift predicts -- so the drift's direction is right and real evidence, but its magnitude is about 2.4x too strong. One refinement, because the means are being compared more finely than n=3 supports: the corpus's three boots individually give excesses of +0.89%, +8.24% and +6.79%, a spread of 7.3 percentage points -- WIDER than the 5.30 pp gap under test -- and boot 1's ratio (1.2251) is essentially the declared 1.2143. This run sits 2.3 sigma above their mean: suggestive, not established. Not closable without a frame log from the corpus's instrument, which was screenshot timing and has none. An attempt to give this side an n of 3 failed on tooling and is recorded: ARM=early loses its F10 about 40% of the time -- two of five runs logged "ARMED EARLY" and produced no draw log at all, with nothing in the session log distinguishing them. Also fences the 33% drift against a misreading the port flagged: it is PRESENTATION pacing and cannot reach keyframe_units_per_second = 60, which is the game's logical rate, decoded under Q1, and which a renderer converts through at its own frame rate. And records a cross-check neither side went looking for: the batch counts are 1 and 2 on the publisher against 3 and 6 on the developer, and the port reports a count restricted to SPRITE-BEARING elements reproduces that exactly from the export -- so palogo_eff0, the layerless forced backdrop, is not in the batched draw, confirmed from the file. Two instruments that disagreed about that element in every previous iteration now agree on which one it is. New tool splash_boundaries.py carries the corrected counting method. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
247 lines
13 KiB
Markdown
247 lines
13 KiB
Markdown
# 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. **So any Canary change is a
|
||
full reconfigure against `/canary` plus a full compile**, not an incremental
|
||
one. Budget for that before starting: `SYLPH_JOBS=4`, and this box has been
|
||
sitting at **~700 MB free** with a documented history of full-parallel builds
|
||
OOM-killing the host.
|
||
* **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
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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`](../re/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`.
|
||
|
||
⚠️ **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:
|
||
```bash
|
||
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 boundaries** — `end_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 |
|
||
| 4–6 | 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.2–9.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.
|