Compare commits

...

16 Commits

Author SHA1 Message Date
46ee0d7f78 chore(agents): restore evidence sharing after the asset purge
Some checks failed
CI / Native — linux (pull_request) Failing after 5m14s
CI / WASM — Web (pull_request) Successful in 36m12s
CI / Formatting (pull_request) Successful in 1m40s
Issue #49 removed game assets from git. It also, silently, removed the only
transport agents had for showing each other evidence: each agent works in its
OWN clone, so "commit the screenshot, the other one pulls it" was the mechanism,
and PROTOCOL.md's file table said so in as many words -- "evidence cited by a
finding -> git". That row sat directly above "🔴 Never commit game content",
which is how 76 MB accumulated: the two contradicted each other and the table
won, because it was the one that told you what to do.

WHAT REPLACES IT

  * one host directory, `Sylpheed/docs/re/captures/`, bind-mounted read-write
    into BOTH agents at /work/docs/re/captures. All three -- host, decoder,
    port -- see the same files live, every citation resolves, and nothing can
    reach git history. Read-write on purpose: showing each other a screenshot
    is the point.
  * PROTOCOL.md's table rewritten. Cited evidence -> present but never
    committed; derived measurements (csv/tsv/txt/log/json) -> still git, they
    are our numbers not game content; evidence that must cross MACHINES ->
    attached to the issue or PR, because a bare clone has no captures.

Verified, not assumed: container A wrote a .png there, a SEPARATE container B
read it back, the host saw it, `git status` reported 0 changes, and
`git check-ignore` named the rule.

THE CHECKER WAS RED ON EVERY CLEAN CHECKOUT

A fresh clone/worktree/CI has no captures, so it called all 134 citations
dangling and exited 1. A gate that is red before anyone changes anything is one
people learn to ignore -- the exact failure this file already carries a comment
about. It now distinguishes "no captures here" (expected, explains itself,
exit 0) from "these are missing" (real, exit 1, unchanged when assets ARE
present). Both paths tested.

ALSO
  * `sylph-decoder` no longer mounts `xenia-rs` -- retired repo, gone from disk,
    the mount pointed at nothing.
  * CONSOLIDATION.md closed: it still described captures as committed and the
    history fork as undecided. Both are settled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:12:26 +02:00
c0a5840ce8 Merge pull request 'fix: re-lock the self-referencing git dep after the history rewrite' (#60) from fix/relock-after-history-rewrite into main
Some checks failed
CI / Native — linux (push) Failing after 3h1m17s
CI / WASM — Web (push) Successful in 35m42s
CI / Formatting (push) Successful in 2m7s
Reviewed-on: #60
2026-09-20 11:01:57 +00:00
4156cb6bd1 fix: re-lock the self-referencing git dep after the history rewrite
Some checks failed
CI / Native — linux (pull_request) Failing after 2h42m36s
CI / WASM — Web (pull_request) Successful in 33m28s
CI / Formatting (pull_request) Successful in 2m29s
`crates/sylpheed-export` depends on THIS repository by tag:

    sylpheed-formats = { git = "…/Sylpheed.git", tag = "formats-pin-2026-09-01" }

and `Cargo.lock` pinned it to commit `#1cd5b8b1`. The issue-#49 history rewrite
replaced every commit, so `1cd5b8b1` no longer exists on the remote and the tag
now dereferences to `e2630413`.

⚠️ The failure is invisible here: `~/.cargo/git` still has the old object, so
builds on the machine that did the rewrite keep working. A clean checkout --
CI, or the other desktop -- cannot resolve the locked rev at all. That is the
worst shape for a breakage, which is why this was measured from an empty
CARGO_HOME rather than trusted to a local build.

`CONSOLIDATION.md` called this dependency out as the hard blocker for the
rewrite. It was not re-checked before the push; this is the fix.

The dependency's SOURCE is unchanged -- `crates/sylpheed-formats` has tree
`55461e46` at both the old and the new commit, so the rewrite never touched it
and the build result is identical.

Verified:
  * remote tag peels to e2630413, matching the new lock
  * `cargo fetch --locked` from an EMPTY CARGO_HOME -> exit 0
  * `cargo check -p sylpheed-export --locked` -> exit 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:11:06 +02:00
e16556dc29 Merge pull request 'chore(re): stop committing game assets; captures stay local' (#59) from chore/captures-local-only into main
Some checks failed
CI / Native — linux (push) Failing after 6m55s
CI / WASM — Web (push) Failing after 6m24s
CI / Formatting (push) Successful in 1m8s
Reviewed-on: #59
2026-09-19 19:16:47 +00:00
d96b225e47 chore(re): stop committing game assets; captures stay local
Issue #49: the repos carry code, tooling and docs only.

Untracks 143 screenshots, 3 savegame blobs and `tools/re-capture/ob_digits.png`
(a digit-template sheet cut from game frames) -- 146 files, 76.4 MB. They stay
in the working tree and are gitignored, so the pages' relative links still
resolve where the captures exist and nothing ships.

Derived measurements (csv/tsv/txt/log/json/jsonl/npy) are our own numbers, not
game content, and stay tracked -- they are what most claims rest on.

`check-capture-citations` had its contract inverted, and it is the half worth
reading:

  * presence now comes from the WORKING TREE, not `git ls-files`. The assets are
    deliberately untracked, so asking the index would report every screenshot as
    missing and fail all 203 citations.
  * a NEW failure: a game asset that IS tracked. A screenshot that sneaks back
    in is invisible in review -- a binary shows as "Bin 0 -> 1234567 bytes" --
    and is permanent once merged, since removing it later needs a history
    rewrite. So that half has to be loud.

Verified:
  * selftest 8/8, including the new rule
  * scan: 212 present, 212 cited, 0 dangling, 0 tracked  -> exit 0
  * force-add one PNG -> "game assets TRACKED: 1", exit 1, selftest red

⚠️ This does NOT remove the blobs from history; a clone still fetches them.
That needs a filter-repo rewrite and a force-push, which is a separate,
human-run step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 21:03:46 +02:00
4c1e15b818 Merge pull request 'fix(test): open sound.pak once, not once per call' (#58) from fix/slb-suite-one-archive into main
Reviewed-on: #58
2026-09-19 18:48:28 +00:00
9a8746d380 Merge pull request 'fix(ci): run the container as the invoking user, not root' (#57) from fix/ci-run-file-ownership into main
Reviewed-on: #57
2026-09-19 18:48:21 +00:00
1cbbfd90cb Merge pull request 'docs(re): adopt the homeless disc-contents page, re-measured' (#56) from docs/adopt-disc-contents into main
Reviewed-on: #56
2026-09-19 18:48:14 +00:00
e82d7bff6f Merge pull request 'chore: hand the workspace over — the gated launchers, and the state of play' (#55) from chore/handoff-2026-09-18 into main
Reviewed-on: #55
2026-09-19 18:48:08 +00:00
00585952a9 Merge pull request 'ci: pin the toolchain to 1.98.1 in all three jobs' (#54) from fix/ci-pin-toolchain into main
Reviewed-on: #54
2026-09-19 18:48:00 +00:00
cd3c1a2f73 Merge pull request 'test: one disc resolver, no machine-specific defaults, and all three corpora in the CI container' (#53) from fix/corpus-mounts-and-paths into main
Reviewed-on: #53
2026-09-19 18:47:53 +00:00
2f39c8826e fix(test): open sound.pak once, not once per call
`slb_leading_segment_disc` was SIGKILLed by the OOM killer in `docker/ci/run`
at its 7 GB cap, so the documented 45/377 baseline did not reproduce on a 15 GB
box.

`PakArchive` holds the whole concatenated payload in memory and `sound.pak` is
1.01 GB (sound.p00-.p04). `bank()` and `bank_named()` opened it on every call --
inside loops -- and five tests opened their own besides, ~26 opens in all. With
cargo's default thread count that is ~6.1 GB of archive in flight against a
7 GB cap with `--memory-swap` equal to `--memory`, so there is no swap to
absorb it.

The archive is immutable once open and every accessor takes `&self`, so one
`OnceLock` instance is equivalent to N private ones at 1/N the memory.

⚠️ The failure mode is worth knowing: a SIGKILLed suite prints no
`test result:` line at all, so it disappears from a scraped tally instead of
failing visibly. The run still reported "0 failed" -- true, and useless. Check
cargo's exit code (101), not the tally.

Measured in the capped container, 7 GB, default threads:
  * before: SIGKILL (signal 9), 0 of 10 tests reported
  * after : 10 passed in 3.17s
  * (single-threaded before the fix: 10 passed in 22.64s -- the fix is also
     ~7x faster, because it no longer re-reads 1 GB from disc 26 times)
  * cargo fmt --check clean; cargo clippy --tests -D warnings clean

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 16:59:10 +02:00
4a8f1221b2 fix(ci): run the container as the invoking user, not root
Docker on the dev boxes is rootful, so without `--user` every byte the build
writes into the bind-mounted repo is owned by root and the user needs `sudo` to
delete their own artifacts. This is not hypothetical: `export/` in a working
tree held 227 root-owned paths (149 MB) from earlier runs, and the `sylpheed.db`
regen in the workspace CLAUDE.md writes straight into /work, so it lands
root-owned every time.

The catch is that the daemon creates a named volume root-owned, so a `--user`
container cannot write /cargo or /target at all. So take ownership of both
volumes first -- once, and only when it is actually wrong, since a recursive
chown across a ~36 GB target volume is not something to repeat per invocation.
Both are sampled, not just one, because an older run can leave them drifted.

Volume names become overridable (SYLPH_CI_CARGO_VOL / SYLPH_CI_TARGET_VOL),
which is what let the chown path be tested without touching the real caches.

Placed above the corpus-mount block so it does not collide with #53.

Measured, not assumed:
  * fresh root-owned volumes  -> chowns once, then writes as uid 1000
  * second run                -> no chown, correctly cached
  * `cargo check -p sylpheed-ppc` through the runner -> passes, exit 0
  * a file touched in /work   -> owned fabi:fabi, removable without sudo

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 21:30:29 +02:00
sim
e45a56ad83 chore: hand the workspace over — the gated launchers, and the state of play
Resuming happens on the other machine, so anything that lived only in this
workspace either comes into the repo or gets named as something to carry.

**The launchers come in.** `run-canary-safe.sh` (Wine) and
`run-canary-native-safe.sh` (native) are the ONLY sanctioned way to start Canary
from the editor — they force software Vulkan and refuse to launch while a
hardware Vulkan device is visible, because Canary on the AMD GPU takes VS Code
with it. They sat in the workspace root, outside git, hardcoding one machine's
absolute paths, which is exactly why they could not be checked in. They now
derive the workspace from their own location and honour `$SYLPHEED_ISO` and
`$CANARY_BIN`, so the layout is a default rather than a requirement. Same for
`run-canary-native.sh` (interactive, hardware Vulkan — not from the IDE),
`diagnose-freeze.sh`, `live-guest-state.sh`, `heaptrack-wrap.sh` and the
`asound-null.conf` the native launcher needs beside it.

Both safe launchers were run from `tools/` before this commit: the native one
reached content in 20 s (`VERDICT: HEALTHY`, title + 25,398 XMA lines, no
faults), the Wine one ran 25 s and logged 4 `ADV.wmv` hits.

**`docs/agents/HANDOFF-2026-09-18.md`** records what changed since 2026-09-16,
what is on the server, what cannot travel through git, and the traps this machine
paid for — chiefly that the guest-PC branch probe exists only on the fork's
snapshot branch, so a rebuild from `sylpheed-re` silently loses it.

**`HANDOFF-2026-09-06.md`** gets a correction note rather than an edit: a plain
clone works again now that the 545 MB branch is gone, and its baselines are two
baselines old.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 07:17:36 +02:00
sim
c88391b68c ci: pin the toolchain to 1.98.1 in all three jobs
All three jobs used `dtolnay/rust-toolchain@stable`, so the gate resolved to
whatever stable was on the day it ran. A floating lint gate is not a gate: the
same tree goes green or red by date, and that already produced a disagreement
between two people reading the same commit — `collapsible_else_if` is `warn` on
1.92.0 and `allow`-by-default pedantic on 1.98.1, so both readings were correct.

1.98.1 is what run 206 resolved and what `docker/ci/Dockerfile` already pins, so
`docker/ci/run cargo clippy …` on a desktop becomes a true stand-in for this
workflow instead of an approximation. The header says how to bump: the three
refs here and the Dockerfile's `FROM` in one commit, in a PR of its own, where
the lints the new version turns on are the diff.

Also: the corpus-report step's comment quoted `207/0/14`, a tally two baselines
old. It now describes the shape of the problem without pinning a number that
decays.

Closes #15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:21:27 +02:00
sim
cc9392bde4 test: one disc resolver, no machine-specific defaults, all three corpora in the container
Finishes #16 in the three places its earlier remedies missed.

`tests/`: the last four local `disc_root()` copies now use `tests/common`, and
with them goes the one real hardcoded fallback — `ui_keyframe_record_disc.rs`
fell back to an absolute path on one machine, which made `unset SYLPHEED_DISC`
a no-op there. Control: with the corpus absent that suite now finishes in 0.00s
instead of 57.55s, so it skips rather than finding a disc of its own.

`examples/`: seventeen examples defaulted to `/disc`, the mount point inside the
CI container. Redundant there — `docker/ci/run` sets `SYLPHEED_DISC=/disc` — and
wrong everywhere else, where a missing corpus turned into a file-not-found
against a path that has never existed on the host. They now name the variable to
set, like the other hundred examples already did.

`docker/ci/run`: mount `$SYLPHEED_RES3D` and `$SYLPHEED_ISO` alongside the disc.
Only the disc was mounted, so an in-container run sat out the res3d and iso
suites while looking like a full one — the defect this issue is about, in the
runner itself.

Measured in the container on this desktop with all three corpora present:
45 suites / 377 passed / 0 failed / 14 ignored, and `sylpheed-corpus-report.txt`
now reports PRESENT for all three rather than for the disc alone.

Refs #16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:18:59 +02:00
44 changed files with 933 additions and 89 deletions

View File

@@ -31,6 +31,24 @@ env:
# So: one job, on the machine that exists, building for the machine that exists.
# If a second architecture is ever wanted here it needs a second RUNNER, not a
# second matrix row.
#
# ── The toolchain is PINNED, in three places, deliberately ───────────────────
#
# All three jobs used `dtolnay/rust-toolchain@stable`, which resolves to whatever
# stable is on the day the job runs. A lint gate that floats is not a gate: the
# same tree goes green or red depending on the date, and this repo has already
# produced a disagreement between two people reading the same commit (#15).
# `collapsible_else_if` is the example — `warn` on 1.92.0, `allow`-by-default
# pedantic on 1.98.1, so a clean local run and a red CI run were both correct.
#
# `1.98.1` is the version run 206 resolved, and `docker/ci/Dockerfile` pins the
# same one, so `docker/ci/run cargo clippy …` on a desktop is a true stand-in for
# this workflow rather than an approximation of it.
#
# To bump: change all three `dtolnay/rust-toolchain@` refs here AND the `FROM
# rust:<version>-bookworm` in `docker/ci/Dockerfile` in one commit, so the two
# can never drift apart silently. A bump is a change to the gate and belongs in
# its own PR, where the new lints it turns on are the diff.
jobs:
# ── Native build, on the one runner there is ────────────────────────────────
@@ -42,13 +60,15 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust toolchain
# `stable` installs a MINIMAL profile: rustc, cargo, rust-std and no
# Pinned — see the toolchain note at the top of this file.
#
# This action installs a MINIMAL profile: rustc, cargo, rust-std and no
# more. Components have to be named. Without this line the Clippy step
# below dies on "'cargo-clippy' is not installed for the toolchain
# 'stable-aarch64-unknown-linux-gnu'" — which is not a lint result, it
# is the step never having run. The `fmt` job below always got this
# right; this one never did.
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@1.98.1
with:
components: clippy
@@ -78,7 +98,7 @@ jobs:
run: cargo test --workspace
# The tally above cannot tell you what it verified. `cargo test` reports
# the same 207/0/14 whether the disc corpus was exercised or entirely
# the same count whether the disc corpus was exercised or entirely
# absent -- a gated suite that skips still counts as passed, and the
# `ignored` column is a static count of `#[ignore]` attributes that cannot
# move at runtime. Issue #16. This prints what the run ACTUALLY had, from
@@ -114,7 +134,7 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust toolchain + WASM target
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@1.98.1
with:
targets: wasm32-unknown-unknown
@@ -161,7 +181,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.1
with:
components: rustfmt
- run: cargo fmt --all -- --check

20
Cargo.lock generated
View File

@@ -221,7 +221,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -232,7 +232,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -2507,7 +2507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3824,7 +3824,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4818,7 +4818,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -5116,7 +5116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -5237,7 +5237,7 @@ dependencies = [
[[package]]
name = "sylpheed-formats"
version = "0.1.0"
source = "git+https://git.mc02.dev/fabi/Sylpheed.git?tag=formats-pin-2026-09-01#1cd5b8b1cb1f02eefc0865e1a1fe280e44831c9d"
source = "git+https://git.mc02.dev/fabi/Sylpheed.git?tag=formats-pin-2026-09-01#e26304133732792cb1df5563c21df59471f5bf7d"
dependencies = [
"anyhow",
"binrw",
@@ -5417,7 +5417,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -5776,7 +5776,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6294,7 +6294,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]

View File

@@ -7,7 +7,8 @@ use std::process::Command;
use sylpheed_formats::media;
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let disc =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let src = media::DirectorySource::new(&disc);
for bank in ["BGM_103.slb", "BGM_102.slb", "BGM_001.slb"] {
match media::sound_bank_riffs(&src, bank) {

View File

@@ -18,7 +18,8 @@
use sylpheed_formats::media;
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let src = media::DirectorySource::new(&root);
const WANT: [usize; 2] = [3_876_864, 3_930_112];
let (mut found, mut matches) = (0usize, Vec::new());

View File

@@ -20,7 +20,8 @@ use std::collections::BTreeSet;
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let ar = pak::PakArchive::open(format!("{root}/dat/GP_DIALOG.pak")).expect("GP_DIALOG.pak");
let sets: Vec<Option<BTreeSet<String>>> = ar
.entries()

View File

@@ -17,7 +17,8 @@
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
// 🔴 WIDENED 2026-08-31 to every pak, to check the Decoder's rival search
// independently. They report zero four-button builds within 6 px of
// 259/329/399/469 anywhere on the disc, which turns "another dialog with

View File

@@ -8,7 +8,8 @@
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let disc =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let ar = PakArchive::open(format!("{disc}/dat/GP_TITLE.pak")).expect("open");
let e = &ar.entries()[4]; // entry 4 = the English title
let bundle = ar.read(e).expect("read");

View File

@@ -48,7 +48,8 @@ fn main() {
.unwrap_or(8);
unsafe { OFFSET = off };
println!(" reading the loop length at header +0x{off:02x}");
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat"))
.expect("dat/")
.flatten()

View File

@@ -11,7 +11,8 @@
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat"))
.expect("dat/")
.flatten()

View File

@@ -11,7 +11,8 @@
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let ar = pak::PakArchive::open(format!("{root}/dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let (mut total, mut hits, mut multipose) = (0usize, 0usize, 0usize);
for (i, e) in ar.entries().iter().enumerate() {

View File

@@ -7,7 +7,8 @@ use std::process::Command;
use sylpheed_formats::{media, slb::VoiceLang};
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let disc =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let src = media::DirectorySource::new(&disc);
for movie in ["ADV", "S00A", "RT01A"] {
let Some((s, e)) = media::resolve_movie_voice_region(&src, movie, VoiceLang::English)

View File

@@ -1,6 +1,7 @@
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let ar = pak::PakArchive::open(format!("{root}/dat/GP_READY_ROOM.pak")).unwrap();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };

View File

@@ -16,8 +16,7 @@
//! Usage:
//! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]
//! e.g. SYLPHEED_ISO="/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of
//! Deception (USA, Europe) (En,Ja).iso" \
//! e.g. SYLPHEED_ISO="/path/to/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso" \
//! cargo run --release --example correlate_capture -- \
//! xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit

View File

@@ -11,7 +11,8 @@
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat"))
.expect("dat/")
.flatten()

View File

@@ -15,7 +15,8 @@ use std::collections::BTreeMap;
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat"))
.expect("dat/")
.flatten()

View File

@@ -6,7 +6,8 @@
use std::collections::BTreeMap;
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat"))
.expect("dat/")
.flatten()

View File

@@ -14,7 +14,8 @@
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat"))
.expect("dat/")
.flatten()

View File

@@ -33,7 +33,8 @@ fn opaque_span(el: &ui_layout::Element, thr: u32, tmax: u32) -> Vec<(f64, f64)>
}
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat"))
.expect("dat/")
.flatten()

View File

@@ -58,7 +58,8 @@ fn forced(b: &ui_layout::UiBuild, el: &ui_layout::Element, tmax: u32, hold: bool
}
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat"))
.expect("dat/")
.flatten()

View File

@@ -20,7 +20,8 @@ use std::collections::BTreeMap;
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat"))
.expect("dat/")
.flatten()

View File

@@ -6,14 +6,34 @@
//! silence. One rule, two outcomes.
use std::path::Path;
use std::sync::OnceLock;
use sylpheed_formats::{slb, PakArchive};
mod common;
use common::skip_without_disc;
/// One archive for the whole binary.
///
/// `PakArchive` holds the entire concatenated payload in memory, and
/// `sound.pak` is **1.01 GB** (`sound.p00`-`.p04`). Opening it per call — which
/// the helpers below did, inside loops — put one copy per test thread in flight,
/// so at the default thread count the suite needed ~6 GB and was SIGKILLed by
/// the CI container's 7 GB cap (`--memory-swap` equals `--memory`, so there is
/// no swap to absorb it). A killed suite prints no `test result:` line at all,
/// so it vanishes from the tally rather than failing visibly.
///
/// The archive is immutable once open and every accessor takes `&self`, so one
/// shared instance is equivalent to N private ones — at 1/N the memory.
fn sound(root: &Path) -> &'static PakArchive {
static SOUND: OnceLock<PakArchive> = OnceLock::new();
// Every caller passes the same `disc_root()`, so first-writer-wins is the
// same archive whichever test initialises it.
SOUND.get_or_init(|| PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak"))
}
fn bank(root: &Path, n: u32) -> Vec<u8> {
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
let snd = sound(root);
let path = format!("eng\\etc\\VOICE_D_{n}.slb");
let entry = snd.find_by_name(&path).expect("bank present");
snd.read(entry).expect("read")
@@ -71,7 +91,7 @@ fn all_zero_leading_region_is_skipped() {
}
fn bank_named(root: &Path, path: &str) -> Vec<u8> {
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
let snd = sound(root);
let entry = snd
.find_by_name(path)
.unwrap_or_else(|| panic!("{path} present"));
@@ -154,7 +174,7 @@ fn derived_offset_recovers_voice_banks_without_regressing_etc() {
#[test]
fn scan_data_offset_agrees_with_the_riff_derived_answer() {
skip_without_disc!(root);
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
let snd = sound(&root);
let mut checked = 0usize;
let mut agreed = 0usize;
for lang in ["eng", "jpn"] {
@@ -198,7 +218,7 @@ fn scan_data_offset_agrees_with_the_riff_derived_answer() {
#[test]
fn scan_only_returns_known_offsets() {
skip_without_disc!(root);
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
let snd = sound(&root);
let mut seen = 0usize;
for n in 1u32..200 {
for path in [
@@ -232,7 +252,7 @@ fn scan_only_returns_known_offsets() {
#[test]
fn a_waves_declared_size_is_confirmed_by_the_next_seek() {
skip_without_disc!(root);
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
let snd = sound(&root);
let mut checked = 0usize;
for n in 1u32..400 {
for path in [
@@ -289,7 +309,7 @@ fn a_waves_declared_size_is_confirmed_by_the_next_seek() {
#[test]
fn a_bank_that_states_its_own_header_has_no_leading_segment() {
skip_without_disc!(root);
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
let snd = sound(&root);
let mut with_header = 0usize;
let mut mid_bank = 0usize;
// Peek at the 56-byte header through the archive's flat data rather than
@@ -327,7 +347,7 @@ fn a_bank_that_states_its_own_header_has_no_leading_segment() {
#[test]
fn the_menu_music_bank_is_exactly_two_sub_waves() {
skip_without_disc!(root);
let snd = PakArchive::open(root.join("dat/sound.pak")).expect("sound.pak");
let snd = sound(&root);
for (name, sizes) in [
("BGM_103.slb", [3_876_864usize, 3_930_112]),
("BGM_001.slb", [4_466_688, 4_673_536]),

View File

@@ -16,10 +16,8 @@ use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn disc_root() -> Option<PathBuf> {
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
p.join("dat").is_dir().then_some(p)
}
mod common;
use common::disc_root;
fn build(ar: &PakArchive, i: usize) -> (Vec<u8>, ui_layout::UiBuild) {
let by = ar.read(&ar.entries()[i]).expect("entry");

View File

@@ -26,21 +26,8 @@ use std::path::{Path, PathBuf};
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn disc_root() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
let p = PathBuf::from(p);
if p.join("dat").is_dir() {
return Some(p);
}
}
let default = Path::new(
"/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja)",
);
if default.join("dat").is_dir() {
return Some(default.to_path_buf());
}
None
}
mod common;
use common::disc_root;
fn for_each_build(root: &Path, mut f: impl FnMut(&str, &[u8])) {
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))

View File

@@ -22,10 +22,8 @@ use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn disc_root() -> Option<PathBuf> {
let p = PathBuf::from(std::env::var("SYLPHEED_DISC").ok()?);
p.join("dat").is_dir().then_some(p)
}
mod common;
use common::disc_root;
/// Read a nested record's declared length and its largest keyframe time.
fn record_len_and_maxt(bundle: &[u8], off: usize, size: usize) -> Option<(i64, i64)> {

View File

@@ -25,15 +25,8 @@ use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn disc_root() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_DISC") {
let p = PathBuf::from(p);
if p.join("dat").is_dir() {
return Some(p);
}
}
None
}
mod common;
use common::disc_root;
/// The case that found the bug, asserted end to end.
#[test]

View File

@@ -21,23 +21,68 @@ IMAGE="${SYLPH_CI_IMAGE:-sylph-ci:local}"
CPUS="${SYLPH_CI_CPUS:-6}"
MEM_GB="${SYLPH_CI_MEM_GB:-7}"
CARGO_VOL="${SYLPH_CI_CARGO_VOL:-sylph-ci-cargo}"
TARGET_VOL="${SYLPH_CI_TARGET_VOL:-sylph-ci-target}"
# 🔴 Docker on the dev boxes is ROOTFUL, so without `--user` every byte the build
# writes into the bind-mounted repo is owned by root — and the user then needs
# `sudo` to delete their own artifacts. The regen command in the workspace
# `CLAUDE.md` writes `sylpheed.db` straight into /work, so it lands root-owned,
# and a stray root-owned file is exactly what survived the last cleanup and had
# to be sudo'd away.
#
# The catch is that the daemon creates a named volume root-owned, so a `--user`
# container cannot write /cargo or /target at all. Take ownership once — and
# only when it is actually wrong, because a recursive chown across a ~36 GB
# target volume is not something to repeat on every invocation.
RUN_UID="$(id -u)"
RUN_GID="$(id -g)"
docker volume create "$CARGO_VOL" >/dev/null
docker volume create "$TARGET_VOL" >/dev/null
# Both volumes, not just one: they are chowned together but can drift apart if an
# older root-owned run created only one of them.
vol_owner="$(docker run --rm -v "$CARGO_VOL:/cargo" -v "$TARGET_VOL:/target" "$IMAGE" \
stat -c %u /cargo /target 2>/dev/null | sort -u | tr '\n' ' ' || echo unknown)"
if [ "$vol_owner" != "$RUN_UID " ]; then
echo "docker/ci/run: chowning the cargo/target volumes to $RUN_UID:$RUN_GID (one-off)" >&2
docker run --rm \
-v "$CARGO_VOL:/cargo" -v "$TARGET_VOL:/target" \
"$IMAGE" chown -R "$RUN_UID:$RUN_GID" /cargo /target
fi
args=(
--rm
--cpus "$CPUS"
--memory "${MEM_GB}g"
--memory-swap "${MEM_GB}g"
--pids-limit 2048
# Run as the invoking user so build output in /work is owned by them, not root.
--user "$RUN_UID:$RUN_GID"
-v "$REPO:/work"
# Named volumes, not bind mounts: the host tree keeps a 32 GB `target/` from
# earlier host-side builds, and mixing the two produces rebuilds that look
# like cache misses and are actually two toolchains fighting over one directory.
-v sylph-ci-cargo:/cargo -e CARGO_HOME=/cargo
-v sylph-ci-target:/target -e CARGO_TARGET_DIR=/target
-v "$CARGO_VOL:/cargo" -e CARGO_HOME=/cargo
-v "$TARGET_VOL:/target" -e CARGO_TARGET_DIR=/target
-w /work
)
# The disc, read-only, when a disc-backed test or the exporter needs it.
# The corpora, read-only, when a disc-backed test or the exporter needs them.
#
# All three, not just the disc: a suite whose corpus is absent self-skips and
# still counts as passed, so mounting one of three made an in-container run look
# like a full one while `res3d` and `iso` suites silently sat out (#16). Each is
# mounted only when it exists, and `target/sylpheed-corpus-report.txt` says which
# ones the run actually had.
DISC="${SYLPHEED_DISC:-$REPO/../sylph_extract}"
[ -d "$DISC" ] && args+=(-v "$DISC:/disc:ro" -e SYLPHEED_DISC=/disc)
RES3D="${SYLPHEED_RES3D:-}"
[ -n "$RES3D" ] && [ -d "$RES3D" ] && args+=(-v "$RES3D:/res3d:ro" -e SYLPHEED_RES3D=/res3d)
ISO="${SYLPHEED_ISO:-}"
[ -n "$ISO" ] && [ -f "$ISO" ] && args+=(-v "$ISO:/disc.iso:ro" -e SYLPHEED_ISO=/disc.iso)
exec docker run "${args[@]}" "$IMAGE" "$@"

View File

@@ -122,11 +122,19 @@ docker_args() {
# (file offset = VA - 0x82000000), which removes the need to boot the
# emulator and scrape /dev/shm to get at it. An earlier belief that this
# file was STALE was tested and refuted -- it is current.
-v "${SYLPH_XENIA_RS:-$PROJECT/xenia-rs}:/xenia-rs:ro"
-v "${SYLPH_PE:-$PROJECT/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).pe}:/image/sylpheed.pe:ro"
-e "SYLPHEED_DB=/xenia-rs/sylpheed.db"
-e "SYLPHEED_PE=/image/sylpheed.pe"
-e "SYLPHEED_IMAGE_BASE=0x82000000"
# 🔴 EVIDENCE IS SHARED, NOT COMMITTED (issue #49). Each agent works in its
# OWN clone, so a gitignored capture written in one container is invisible to
# the other and to the human -- git used to be the transport and no longer is.
# One host directory, bind-mounted into both agents, is: all three see the
# same files live, every `docs/re/captures/...` citation resolves everywhere,
# and nothing can reach git history. Read-write on purpose -- showing each
# other a screenshot is the point.
-v "${SYLPH_CAPTURES:-$PROJECT/Sylpheed/docs/re/captures}:/work/docs/re/captures"
# The shared exchange: transient files with provenance, outside git history.
-v "sylpheed-exchange:/exchange"
-e "PROJECT_DIR=/work"

View File

@@ -67,6 +67,14 @@ docker_args() {
-v "sylpheed-port-claude:/sylph-home/port/.claude"
-v "${SYLPH_CLAUDE_HOME:-$HOME/.claude}:/sylph-home/port/.claude.seed:ro"
-v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/port/.claude.host.json:ro"
# 🔴 EVIDENCE IS SHARED, NOT COMMITTED (issue #49). Each agent works in its
# OWN clone, so a gitignored capture written in one container is invisible to
# the other and to the human -- git used to be the transport and no longer is.
# One host directory, bind-mounted into both agents, is: all three see the
# same files live, every `docs/re/captures/...` citation resolves everywhere,
# and nothing can reach git history. Read-write on purpose -- showing each
# other a screenshot is the point.
-v "${SYLPH_CAPTURES:-$WORKSPACE/Sylpheed/docs/re/captures}:/work/docs/re/captures"
-v "sylpheed-exchange:/exchange"
-e "PROJECT_DIR=/work"
# Same guardrail as the decoder, added the same day and for its reason: the

View File

@@ -1,3 +1,27 @@
# ✅ CLOSED 2026-09-20 — every phase done, and the two human decisions taken
Nothing on this page is outstanding. It is kept as the record of how the two-repo
layout was reached; **where it describes the repository's present state it is
now out of date on purpose**, and the notes below say how.
| the page says | what is true now |
|---|---|
| "**Still open, for the human**: the history fork" | **Taken: full purge.** `git filter-repo` stripped 182 assets from every commit; force-pushed 2026-09-19. `main` `2a93be99``e16556dc`, `.git` **217 MB → 114 MB**, **0** assets anywhere in history, verified from an independent fresh clone. Backups + runbook: `~/sylph-repo-backup-2026-09-19/` |
| "`docs/re/captures/` is 118 MB of game screenshots inside a public repository" | **Decided (#49): no game assets in the repos.** Captures live on disk, gitignored, shared between the host and both agents by bind mount; derived measurements (csv/tsv/txt/log/json/npy) stay tracked. PR #59 |
| "all six repos are `private=False`" | Still true, and now deliberate — there is nothing game-derived in them |
| "`ai-agent-*.md`, `scratch/`, `_voice_span.rs` — keep or drop" | `scratch/` and `_voice_span.rs` dropped in the 2026-09-18 cleanup; the `ai-agent-*.md` files are the human's and stay |
| the four archived repos | unchanged: `Sylpheed-Godot`, `xenia-rs`, `xex2tractor` archived read-only, `Syplheed-Reborn` deleted. ⚠️ Their local clones are **gone from this machine** too, and `sylph-decoder` no longer mounts `xenia-rs` |
🔴 **The one thing this page got right and nobody acted on.** It named
`crates/sylpheed-export`'s dependency on **this repository by tag** as the hard
blocker for a rewrite. It was not re-checked before the purge, the locked rev
vanished, and the build broke for every clean checkout while still working on the
machine that did the rewrite — its `~/.cargo/git` still held the old object.
Fixed in PR #60 (re-lock) and PR #61 (path dependency, which removes the class).
**Before any future history rewrite, grep for a dependency on the repo's own URL.**
---
# Consolidating six repositories into two
**Set by the human, 2026-09-13.** The end state is **two** repositories:

View File

@@ -88,6 +88,42 @@ sylph-agent-canary-build the warm 235 MB Canary build tr
sylpheed-exchange agent -> agent files, read-only in
```
**Plus one bind mount, which is not a volume on purpose** — the host's
`Sylpheed/docs/re/captures/`, mounted read-write into both agents at
`/work/docs/re/captures`:
```
-v "${SYLPH_CAPTURES:-<workspace>/Sylpheed/docs/re/captures}:/work/docs/re/captures"
```
🔴 **Why it has to exist.** Each agent works in its OWN clone, so before issue
#49 the transport for evidence was *git*: commit the screenshot, the other agent
pulls it. #49 removed that — captures are gitignored now — and without a
replacement a capture written in one container is invisible to the other agent,
to the human, and to `check-capture-citations`, which would call every citation
dangling. One host directory shared by all three restores it: the same file is
live everywhere, every `docs/re/captures/...` citation resolves, and nothing can
reach git history. Read-write on both, because showing each other a screenshot
is the point.
Verified rather than assumed: container A wrote a `.png` there, a **separate**
container B read it back, the host saw it, and `git status` reported **0**
changes.
⚠️ **Evidence that must cross MACHINES still cannot go this way** — a bare clone
on the other desktop has no captures at all. Attach it to the issue or PR; that
is the only channel that travels.
⚠️ **The agent volumes were all deleted in the 2026-09-18 cleanup** and Docker
recreates them empty on next launch. Nothing was lost — both agent clones were
verified clean with nothing unpushed, and the exchange held only spent artefacts.
One side effect is welcome: the empty `*-claude` volumes mean the next launch
starts a **fresh** session, which is the documented workaround for the
"resumes the old brief" defect below.
⚠️ **`sylph-decoder` used to mount `xenia-rs` read-only. That repo is retired and
the directory is gone**, so the mount pointed at nothing; removed.
**Credentials** — three files on the host, `chmod 600`, mounted read-only:
```

View File

@@ -395,3 +395,15 @@ non-test code does not use tokio, so moving it to `dev-dependencies` is sound an
the `examples/` targets keep compiling. **Claim survives** — recorded because a
survived challenge is stronger than an unchallenged one, not because it changed
anything.
---
## Superseded in part — see `HANDOFF-2026-09-18.md`
Two things in this document have since stopped being true:
* **A plain `git clone` works again** (measured 2026-09-17: 56 s, 114 MB, `main` plus tags). The
`--filter=blob:none` advice above was about the branch that carried 545 MB of game-content history,
and that branch was deleted in the consolidation cleanup.
* The baseline numbers quoted here are two baselines old. The current one, with all three corpora
present, is in `CLAUDE.md` and in `HANDOFF-2026-09-18.md`.

View File

@@ -0,0 +1,108 @@
# Hand-off — 2026-09-18, from the second desktop back to the original machine
Written for whoever resumes on `fabi-Hyrican-PC`. It covers what changed between 2026-09-16 and
2026-09-18 on `fabi-MS-7C37`, what is on the server, and what cannot travel through git.
Method and rules are unchanged: `docs/agents/PROTOCOL.md`. The cold-start doc is still
`docs/agents/HANDOFF-2026-09-06.md`, with the corrections at the end of this file.
## Where the work stands
| | |
|---|---|
| `main` | `e732557` |
| baseline, all three corpora present, measured 2026-09-17 in `docker/ci/run` | **45 suites / 377 passed / 0 failed / 14 ignored** |
| open PRs | **#53** (closes #16), **#54** (closes #15) — both `state/needs-human`, neither merged |
| open issues | #3, #4 (F2 audio gains) · #6, #7 (re-propose the port work) · #9, #25 (F6 residue) · #28 (input) · #49, #50 (decisions) · #51 (loop briefs) · #52 (kanji-only strings) |
| fork | issue **#1** — the branch probe exists only on `auto/canary-instrumentation-snapshot-2026-07-28` |
Branches on `fabi/Sylpheed`: `main`, `fix/corpus-mounts-and-paths` (#53), `fix/ci-pin-toolchain` (#54),
`recover/options-menu`, `recover/port-f5-f6`.
## What changed since 2026-09-16
**The consolidation closed.** PRs #46#48 merged; 34 server branches deleted; about 55 GB freed
locally. `Sylpheed` and the `Xenia-Canary` fork are the only live repos — see
`docs/agents/CONSOLIDATION.md`.
**Work from a deleted branch was recovered.** `auto/port-p6-audio` (tip `0148cb8`, 366 commits, never
in a PR) held what issues #6 and #7 ask to re-propose. It is back as two snapshot commits parented on
the branch's fork point `e53d687`:
* `recover/port-f5-f6` — all 84 files the branch changed, i.e. `0148cb8`'s tree minus the 854 exported
game assets it carried. Verified to differ from the original tip by exactly those assets.
* `recover/options-menu` — the nine files of the 2026-09-03 OPTIONS commits. A review slice, not a
buildable tree: the two efforts interleaved commit by commit and do not separate by file.
The 366 original commits were **not** kept — reachable history must not carry game assets. They still
exist unreferenced in the server's object store, so `0148cb8` can be fetched by SHA for as long as the
server keeps it. Both issues carry the details.
**The tracker was brought in line with reality.** #8 closed (its corpus is on `main`), #26 closed as
obsolete (the container tooling is parked for a from-scratch redo and still names the archived
`Syplheed-Reborn`), stale `state/*` labels dropped from closed issues, and four decisions that had been
living in session notes were filed: #49 (screenshots in public repos), #50 (require CI before merge),
#51 (loop briefs, from sylph-pi's note on #38), #52 (kanji-only Shift_JIS strings).
**The corpus control got honest** (#53): one `disc_root()`, no machine-specific fallbacks, and
`docker/ci/run` mounts `$SYLPHEED_RES3D` and `$SYLPHEED_ISO` as well as the disc. Before that an
in-container run silently sat out two corpora while looking like a full one.
**The CI toolchain is pinned** (#54): `dtolnay/rust-toolchain@1.98.1` in all three jobs, matching the
version `docker/ci` already pins, so a local clippy run is a true stand-in for CI.
**The gated launchers are in the repo now**`tools/run-canary-safe.sh` (Wine),
`tools/run-canary-native-safe.sh` (native), `tools/run-canary-native.sh` (interactive, hardware Vulkan),
plus `asound-null.conf`, `diagnose-freeze.sh`, `live-guest-state.sh`, `heaptrack-wrap.sh`. They used to
live in the workspace root, outside git, hardcoding one machine's absolute paths. They now derive the
workspace from their own location and honour `$SYLPHEED_ISO` and `$CANARY_BIN`. Both safe launchers were
run from their new location before this was committed: the native one reached content in 20 s
(`VERDICT: HEALTHY`), the Wine one ran 25 s and logged 4 `ADV.wmv` hits.
## What cannot travel through git
Ask the outgoing machine for these; none of them belong in a repository.
| what | where it was | note |
|---|---|---|
| agent memory | `~/.claude/projects/-home-fabi-RE---Project-Sylpheed/memory/` | 11 files + `MEMORY.md`, ~48 KB. The project's accumulated feedback and reference notes. |
| workspace instructions | `CLAUDE.md`, `README.md` at the workspace root | `CLAUDE.md` is what every session reads first. |
| Claude settings, skills, agent | `.claude/` at the workspace root | `settings.json` (the Stop hooks), `settings.local.json`, `skills/sylph-dis`, `skills/sylph-canary`, `agents/sylph-static.md`. |
| corpus paths | `Sylpheed/.env` | Three variables: `SYLPHEED_DISC`, `SYLPHEED_RES3D`, `SYLPHEED_ISO`. Rewrite for the machine rather than copy. |
| game data | the ISO, the extract, the flat `.pe`, the `.xex.json`, `sylph_extract` → the extract | Never in git. |
| the Gitea token | `~/.sylph-gitea-token`, chmod 600 | Prefer minting a fresh one on the other machine to moving it. |
| Canary builds | `xenia-canary/build-cross/…/Debug` (Wine, 190 MB) and `xenia-canary-native/build` | **The Wine build is the only binary that accepts `--audit_61_branch_probe_pcs`** — see fork #1. Rebuilding from `sylpheed-re` loses the probe. |
| official Canary oracles | `~/xenia_canary_windows/xenia_canary.exe` (`b86414957`), `~/Downloads/xenia_canary_linux/xenia_canary` (`de10b9ef9`) | The builds the game was played on; use them to bisect a regression in the fork. |
Rebuildable, do not copy: `sylpheed.db` (~2.5 min, command in `CLAUDE.md`), `target/`, the
`sylph-ci:local` image and its volumes, `.trunk-bin`.
## Traps this machine paid for
* **The branch probe is not on the fork's default branch.** `run-canary-safe.sh` and the `/sylph-canary`
skill pass `--audit_61_branch_probe_pcs`, defined only on `auto/canary-instrumentation-snapshot-2026-07-28`
(`30d05ee97`). `sylpheed-re` is 232 commits ahead of that branch, so porting it is a cherry-pick with
a build check, not a merge. Fork issue #1.
* **One suite dominates the test wall time.** `twin_pairs_do_not_share_a_buffer` takes ~19 of the ~36
minutes. Budget for it; it is not a hang.
* **Nothing requires CI to pass before a merge** (#50), and CI has no disc, so its green is parser-only.
* **The tally cannot tell you what it verified.** Read `target/sylpheed-corpus-report.txt` (#16).
* `git gc` on this machine will eventually drop the unreferenced `0148cb8`; the server keeps its own
copy independently.
## First moves on the other machine
1. Clone, set `.env`, then `docker build -t sylph-ci:local docker/ci` (~6 min) and re-run the baseline
through `docker/ci/run` with all three corpora. Compare against 45 / 377 / 0 / 14.
2. Review #53 and #54 — both were measured here, neither was merged, because merging is the human's.
3. Answer #49 and #50; both block nothing technically and both shape what comes next.
4. If dynamic RE is next, resolve fork #1 before rebuilding Canary, or carry the Wine build across.
## Corrections to `HANDOFF-2026-09-06.md`
* **§ "clone" — a plain `git clone` works again.** Measured 2026-09-17: 56 s, 114 MB, `main` plus tags.
The `--filter=blob:none` advice was for the branch carrying 545 MB of game-content history, which was
deleted in the consolidation cleanup.
* The `~/.sylph-*` agent credentials and the agent images it describes exist on the original machine
only; the second desktop never had them, and the agent and container tooling is parked for a
from-scratch redo.

View File

@@ -129,10 +129,18 @@ exchange volume carries the working artefacts.
| kind | where | why |
|---|---|---|
| code, decoded knowledge | **git** | history, review, permanence |
| evidence cited by a finding | **git** | it is the proof |
| evidence cited by a finding — a screenshot, a frame, a savegame | **`docs/re/captures/`, present but NEVER committed** | it is the proof, but it is game content. The directory is gitignored and shared between the host and both agents, so all three see the same file; `tools/re/check-capture-citations` fails if a citation has no file, and fails again if an asset is tracked |
| a measurement you derived — csv, tsv, txt, log, json | **git** | our own numbers, not game content, and most findings rest on them |
| evidence that must cross machines | **attached to the issue or PR** | captures are local-only now, so a bare clone has none of them. Attaching is the only channel that reaches the other desktop or a reviewer |
| **evidence a human must look at** — the screenshot or film behind a `state/needs-human` item | **attached to that issue** | it travels *with* the item, a person sees it in a browser, and it cannot be orphaned from the claim it supports |
| exploratory captures, work in progress, "look at this" | **`share`** → `/exchange` | no history; would bloat the repo forever |
⚠️ **This table's first row used to say "git", and that is how 76 MB of
screenshots accumulated** — directly above the rule forbidding exactly that. The
two contradicted each other for months and the table won, because it was the one
that told you what to *do*. Issue #49 settled it and the history was rewritten;
if you find yourself reaching for `git add -f` on a capture, the answer is no.
🔴 **Never commit game content.** Not sprites, not audio, not transcoded video,
not a capture of the running game — under *any* directory name. On 2026-09-04
this rule was live, and freshly tightened, while **545 MB of extracted disc

View File

@@ -18,6 +18,22 @@ A wrong-but-confident note is worse than no note: someone builds on it and the b
for weeks. Every entry therefore carries an explicit **confidence** and its **evidence**.
This mirrors the project method — *measure the oracle, never infer; refute before believing.*
### Captures are local-only (issue #49)
The repository carries **code, tooling and docs**. Screenshots and savegame
blobs are game-derived, so since 2026-09-19 they live in `docs/re/captures/`
on disk and are **gitignored** — the pages' relative links still resolve on a
machine that has them, and nothing ships.
Derived measurements (`csv`, `tsv`, `txt`, `log`, `json`, `jsonl`, `npy`) are
our own numbers rather than game content, and stay tracked — they are what most
claims here actually rest on.
`tools/re/check-capture-citations` enforces both halves: a cited capture must be
**present**, and a game asset must **not be tracked**. ⚠️ A fresh clone has no
captures, so its citations will not resolve until the captures are copied in;
that is expected, and the checker is a local gate rather than a CI one.
### Clean-room firewall
- ✅ Allowed: behaviour descriptions, field offsets/types, formulas, state machines,

13
docs/re/captures/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
# Game-derived assets stay on disk and ship nowhere (issue #49).
#
# The pages' relative links still resolve on a machine that has the captures,
# so the evidence stays followable where it exists — it is simply not committed.
# `tools/re/check-capture-citations` enforces both halves: a cited capture must
# be PRESENT here, and an asset must NOT be tracked.
#
# Derived measurements (csv, tsv, txt, log, json, jsonl, npy) are our own
# numbers rather than game content, and remain tracked.
*.png
*.jpg
*.jpeg
*.bin

16
tools/asound-null.conf Normal file
View File

@@ -0,0 +1,16 @@
# ALSA config for HEADLESS Xenia-Canary boot-check runs (run-canary-native-safe.sh).
#
# Why: the ALSA apu driver opens PCM "default". Passing --apu=nop instead (to get
# silence) leaves the guest's XAudio render client NULL, and Project Sylpheed then
# dereferences null+0x3C at guest PC 0x824D7C40 -> a FALSE boot crash that does NOT
# happen in interactive runs with real audio. So we must keep the ALSA apu ALIVE
# (guest gets a valid client) but route its output to the null device: fully
# silent, snd_pcm_open("default") still succeeds, no false crash.
#
# Point ALSA at this file via env: ALSA_CONFIG_PATH=<path>/asound-null.conf
pcm.!default {
type null
}
ctl.!default {
type null
}

58
tools/diagnose-freeze.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Freeze autopsy for a LIVE (hung) Xenia-Canary process.
#
# When the game or the emulator freezes, DO NOT kill it. Run this instead: it
# attaches gdb to the running process and dumps a backtrace of every thread,
# which names exactly who is stuck and on what (the guest audio callback, a
# kernel lock, the GPU present, an XMA context lock, ...).
#
# The Release binary is NOT stripped, so we get real function names.
#
# Usage: ./diagnose-freeze.sh [output_file]
# Safe: read-only. gdb detaches afterwards and the process keeps running, so
# you can dump twice and diff -- if two dumps 10s apart are identical, it is a
# true deadlock, not slow progress.
set -u
OUT="${1:-/tmp/canary_freeze_$(date +%H%M%S).txt}"
PID=$(pgrep -x xenia_canary | head -1)
if [ -z "$PID" ]; then
echo "No running xenia_canary process found (is it still up? don't kill it!)."
exit 1
fi
command -v gdb >/dev/null || { echo "ABORT: gdb not installed."; exit 4; }
echo "Attaching to xenia_canary (pid $PID) -- read-only, it keeps running."
{
echo "=== xenia_canary freeze autopsy pid=$PID $(date) ==="
echo
echo "--- /proc/$PID/status ---"
grep -E "^(State|Threads)" "/proc/$PID/status" 2>/dev/null
echo
echo "--- per-thread kernel wait channel (who is blocked, cheap) ---"
for t in /proc/"$PID"/task/*; do
tid=$(basename "$t")
printf " tid %-7s state=%-2s wchan=%-24s %s\n" \
"$tid" \
"$(awk '{print $3}' "$t/stat" 2>/dev/null)" \
"$(cat "$t/wchan" 2>/dev/null || echo '-')" \
"$(cat "$t/comm" 2>/dev/null)"
done
echo
echo "--- all thread backtraces (gdb) ---"
} > "$OUT"
gdb -p "$PID" -batch \
-ex "set pagination off" \
-ex "set confirm off" \
-ex "thread apply all bt" \
-ex "detach" 2>&1 | tee -a "$OUT" > /dev/null
echo "Wrote: $OUT"
echo
echo "--- threads that look blocked ---"
grep -E "^Thread |pthread_cond_wait|futex|__lll_lock|Wait|Acquire" "$OUT" | head -40
echo
echo "Full dump: $OUT (run again in ~10s and diff to confirm a true deadlock)"

14
tools/heaptrack-wrap.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Wrapper so run-canary-native.sh runs xenia under heaptrack (host-malloc leak
# profiler). Usage:
# sudo apt install heaptrack # one-time
# CANARY_BIN="$PWD/tools/heaptrack-wrap.sh" tools/run-canary-native.sh
# Then: boot -> into a menu/READY ROOM (or a short mission) so RSS climbs a few
# hundred MB, then QUIT xenia normally (window close / menu quit) so heaptrack
# flushes its dump. Output: /tmp/xenia-heaptrack.*.zst (or .gz). Tell Claude and
# it will run heaptrack_print/analyze to name the leaking call stacks.
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE="$(cd "$HERE/../.." && pwd)"
REAL_BIN="${CANARY_REAL_BIN:-$WORKSPACE/xenia-canary-native/build/bin/Linux/Release/xenia_canary}"
exec heaptrack -o /tmp/xenia-heaptrack "$REAL_BIN" "$@"

72
tools/live-guest-state.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Read the GUEST state of a live (hung) xenia_canary — who is the guest spinning
# on, and what is it waiting for.
#
# In JIT code the x64 backend keeps:
# rsi = PPCContext* rdi = guest membase
# (x64_emitter.cc: GetContextReg()=rsi, GetMembaseReg()=rdi)
# PPCContext offsets (computed against this build's header):
# r[0..31] @ +40 (8B each) ctr @ +296 lr @ +304
# thread_state @ +2704 virtual_membase @ +2712
#
# Needs ptrace: sudo sysctl -w kernel.yama.ptrace_scope=0 (restore with =1)
# Read-only: gdb detaches, the process keeps running.
#
# Usage: ./live-guest-state.sh ["Thread Name"] (default: Main XThread)
set -u
WANT="${1:-Main XThread}"
PID=$(pgrep -x xenia_canary | head -1)
[ -n "$PID" ] || { echo "no xenia_canary running"; exit 1; }
if [ "$(cat /proc/sys/kernel/yama/ptrace_scope 2>/dev/null)" != "0" ]; then
echo "ABORT: ptrace is locked (yama ptrace_scope != 0). Run once:"
echo " sudo sysctl -w kernel.yama.ptrace_scope=0"
exit 3
fi
RAW=$(mktemp /tmp/guest_state_XXXX.txt)
gdb -p "$PID" -batch \
-ex "set pagination off" -ex "set confirm off" \
-ex "thread find $WANT" \
-ex "thread apply all -ascending printf \"@@TH %d %s\\n\", \$_thread, \$_gthread" \
2>/dev/null | grep -E "Thread .* has name|@@TH" > "$RAW"
# gdb "thread find" prints e.g.: Thread 34 has target name 'Main XThread (F...'
GTH=$(grep -m1 "has .*name" "$RAW" | sed -E 's/.*Thread ([0-9]+) has.*/\1/')
[ -n "$GTH" ] || { echo "could not locate a thread named '$WANT'"; cat "$RAW"; exit 4; }
echo "gdb thread #$GTH == '$WANT' (pid $PID)"
gdb -p "$PID" -batch \
-ex "set pagination off" -ex "set confirm off" \
-ex "thread $GTH" \
-ex "echo \n=== host frame ===\n" \
-ex "printf \"host rip = %#lx\\n\", \$rip" \
-ex "bt 8" \
-ex "echo \n=== guest registers (PPCContext @ rsi) ===\n" \
-ex "set \$ctx = (unsigned long)\$rsi" \
-ex "printf \"ctx = %#lx\\n\", \$ctx" \
-ex "printf \"lr = %#lx\\n\", *(unsigned long*)(\$ctx+304)" \
-ex "printf \"ctr = %#lx\\n\", *(unsigned long*)(\$ctx+296)" \
-ex "printf \"r1(sp)= %#lx\\n\", *(unsigned long*)(\$ctx+40+8*1)" \
-ex "printf \"r3 = %#lx\\n\", *(unsigned long*)(\$ctx+40+8*3)" \
-ex "printf \"r4 = %#lx\\n\", *(unsigned long*)(\$ctx+40+8*4)" \
-ex "printf \"r5 = %#lx\\n\", *(unsigned long*)(\$ctx+40+8*5)" \
-ex "printf \"r6 = %#lx\\n\", *(unsigned long*)(\$ctx+40+8*6)" \
-ex "printf \"r7 = %#lx\\n\", *(unsigned long*)(\$ctx+40+8*7)" \
-ex "printf \"r8 = %#lx\\n\", *(unsigned long*)(\$ctx+40+8*8)" \
-ex "printf \"r9 = %#lx\\n\", *(unsigned long*)(\$ctx+40+8*9)" \
-ex "printf \"r10 = %#lx\\n\", *(unsigned long*)(\$ctx+40+8*10)" \
-ex "printf \"r11 = %#lx\\n\", *(unsigned long*)(\$ctx+40+8*11)" \
-ex "printf \"r12 = %#lx\\n\", *(unsigned long*)(\$ctx+40+8*12)" \
-ex "printf \"membase = %#lx\\n\", *(unsigned long*)(\$ctx+2712)" \
-ex "echo \n=== guest stack bytes @ r1 (BE; look for 82xxxxxx = code) ===\n" \
-ex "set \$mb = *(unsigned long*)(\$ctx+2712)" \
-ex "set \$sp = *(unsigned long*)(\$ctx+40+8*1)" \
-ex "x/128xb \$mb + \$sp" \
-ex "detach" 2>&1 | grep -vE "^\[|Reading symbols|no debugging symbols|Detaching"
rm -f "$RAW"
echo
echo "Guest code addresses look like 0x82xxxxxx — feed lr / stack hits to zq.py fn <pc>."

3
tools/re-capture/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Game-derived digit templates for ob_read.py — pixels from the game (issue #49).
ob_digits.png

View File

@@ -10,10 +10,17 @@
largest thing in the repository, and the one place a file can be added, never
cited, and never noticed.
Two failures, which are opposites and must not be conflated:
⚠️ 2026-09-19, issue #49: **game assets are no longer committed.** Screenshots
and savegame blobs live in the working tree and are gitignored, so the pages'
relative links still resolve on a machine that has them while nothing ships.
That inverts half of this check — "is it committed?" became "is it PRESENT?",
and a NEW failure appeared: an asset that IS tracked. Both are below.
* a page cites a capture that **is not committed** — a reader following it
Three failures, which are opposites and must not be conflated:
* a page cites a capture that **is not present** — a reader following it
gets nothing. That is an error, exactly as in `check-citations`.
* a game asset that **is tracked by git** — issue #49 says it must not be.
* a capture that **no page cites** — not an error. It may be evidence a page
should have cited, and deleting on that basis would silently ratify the
omission. Reported, counted, never failed on.
@@ -65,7 +72,16 @@ def committed() -> tuple[set[str], set[str]]:
proposed. `tools/port/check-citations` uses the working tree for exactly
this reason; so does this.
"""
files = {l for l in git("ls-files", ROOT).splitlines() if l}
# 🔴 NOT `git ls-files`: since #49 the assets are deliberately untracked, so
# the index no longer knows they exist. Presence is a question about the
# working tree, and asking git would report every screenshot as missing and
# fail on all 203 citations.
files = set()
for dirpath, _dirnames, filenames in os.walk(ROOT):
for fn in filenames:
if fn == ".gitignore":
continue
files.add(os.path.join(dirpath, fn).replace(os.sep, "/"))
dirs = set()
for f in files:
parts = f.split("/")
@@ -74,6 +90,24 @@ def committed() -> tuple[set[str], set[str]]:
return files, dirs
ASSET_SUFFIXES = (".png", ".jpg", ".jpeg", ".bin")
def tracked_assets() -> list[str]:
"""Game assets that are committed — forbidden since issue #49.
The repo carries code, tooling and docs. A screenshot that sneaks back in
is invisible in review (a binary shows as "Bin 0 -> 1234567 bytes") and is
permanent once merged, because removing it later needs a history rewrite.
So this is the half of the check that has to be loud.
"""
out = []
for l in git("ls-files").splitlines():
if l.lower().endswith(ASSET_SUFFIXES):
out.append(l)
return out
def cited() -> set[str]:
raw = git("grep", "-rhoE", CITE_ERE, "--", *SEARCH, SELF).splitlines()
out = set()
@@ -141,7 +175,7 @@ def selftest() -> int:
real_dir = next(iter(dirs), None)
real_file = next(iter(files), None)
if not real_dir or not real_file:
print("selftest: 🔴 no captures committed — nothing to test against")
print("selftest: 🔴 no captures present — nothing to test against")
return 2
cases = [
("planted dangling caught", "docs/re/captures/no-such-file-anywhere.png", False),
@@ -168,7 +202,15 @@ def selftest() -> int:
print(" %-34s %s"
% ("own fixtures not counted", "🔴 FAILED" if fixture_counted else "ok"))
ok = gathering_ok and not fixture_counted
# 🔴 THE #49 RULE NEEDS ITS OWN TICK. Presence now comes from the working
# tree, so a checkout with the captures present looks identical whether or
# not they are tracked — only this assertion can tell the difference.
stowaways = tracked_assets()
print(" %-34s %s%s"
% ("no game asset tracked", "ok" if not stowaways else "🔴 FAILED",
"" if not stowaways else " (%d tracked)" % len(stowaways)))
ok = gathering_ok and not fixture_counted and not stowaways
for name, path, want in cases:
got = resolves(path.rstrip(".,;:)`"), files, dirs)
mark = "ok" if got == want else "🔴 FAILED"
@@ -188,19 +230,48 @@ def main() -> int:
print(f)
return 0
print("captures committed : %d" % len(files))
stowaways = tracked_assets()
print("captures present on disk : %d" % len(files))
print(" cited by a page or a tool : %d" % (len(files) - len(orphans)))
print(" cited by nothing : %d (reported, not failed —" % len(orphans))
print(" an orphan may be evidence a page owes)")
# 🔴 A BARE CLONE HAS NO CAPTURES AT ALL, and that is not a defect.
# Since #49 the assets are never committed, so a fresh clone, a worktree or
# a CI checkout legitimately has none — and the naive check calls all 134
# citations dangling and exits 1. A gate that is red on every clean checkout
# is one people learn to ignore, which is how the last wrong-by-default
# check in this file cost a session. Distinguish "none here" from "this one
# is missing": with some assets present, a gap is real and still fails.
assets_here = sum(1 for f in files if f.lower().endswith(ASSET_SUFFIXES))
if assets_here == 0 and dangling:
print(" ⓘ no captures on this checkout: %d citations unresolved" % len(dangling))
print(" Expected — captures are local-only (#49) and a fresh clone has none.")
print(" Copy them in, or read the evidence on the issue it is attached to.")
print(" 🔴 game assets TRACKED : %d" % len(stowaways))
return 1 if stowaways else 0
rc = 0
if dangling:
print(" 🔴 cited but NOT committed: %d" % len(dangling))
print(" 🔴 cited but NOT present : %d" % len(dangling))
for d in dangling:
print(" %s" % d)
print("\n🔴 a reader following those gets nothing. Commit the capture, fix the")
print("\n🔴 a reader following those gets nothing. Restore the capture, fix the")
print(" path, or drop the citation.")
return 1
print(" 🔴 cited but NOT committed: 0")
return 0
rc = 1
else:
print(" 🔴 cited but NOT present : 0")
if stowaways:
print(" 🔴 game assets TRACKED : %d (issue #49 — code, tooling, docs only)" % len(stowaways))
for a in stowaways[:10]:
print(" %s" % a)
if len(stowaways) > 10:
print(" … and %d more" % (len(stowaways) - 10))
print("\n🔴 run `git rm --cached` on those; they stay on disk and stay ignored.")
rc = 1
else:
print(" 🔴 game assets TRACKED : 0")
return rc
if __name__ == "__main__":

98
tools/run-canary-native-safe.sh Executable file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# SAFE launcher for the NATIVE Linux Xenia-Canary build on THIS box.
#
# Same safety contract as run-canary-safe.sh (the Wine/Windows variant): the AMD
# GPU + GPU-accelerated VS Code means a hardware-Vulkan render CRASHES VS Code.
# This wrapper forces software Vulkan (lavapipe/llvmpipe) IN THE SAME process and
# HARD-REFUSES to launch if any hardware Vulkan device is still visible.
#
# Difference vs run-canary-safe.sh: runs the native ELF directly (NO wine).
#
# Usage: tools/run-canary-native-safe.sh [seconds]
# env: CANARY_BIN override binary (default = worktree Release build)
# CANARY_EXTRA_ARGS space-separated extra cvars (values w/o spaces)
# Output: /tmp/canary_native.stdout (+ xenia.log in the binary dir). Prints rc + ADV.wmv hits.
set -u
# --- force software Vulkan, belt-and-suspenders across loader/driver variants ---
export VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json
export VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json
export MESA_VK_DEVICE_SELECT=llvmpipe
export LIBGL_ALWAYS_SOFTWARE=1
# --- keep background runs SILENT *without* killing the audio system ---
# DO NOT use --apu=nop: with no audio system the guest's XAudio render client is
# NULL and Project Sylpheed derefs null+0x3C at PC 0x824D7C40 -> a FALSE boot
# crash that never happens in interactive runs. Instead keep the ALSA apu ALIVE
# and route PCM "default" to the null device (see asound-null.conf): silent, but
# snd_pcm_open succeeds so the guest is happy. --mute does NOT silence ALSA.
export SDL_AUDIODRIVER=dummy
export ALSA_CONFIG_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/asound-null.conf"
[ -f "$ALSA_CONFIG_PATH" ] || { echo "ABORT: asound-null.conf missing next to script"; exit 4; }
# Paths derive from where this script sits — see the note in run-canary-safe.sh.
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE="$(cd "$HERE/../.." && pwd)"
BIN_DEFAULT="$WORKSPACE/xenia-canary-native/build/bin/Linux/Release/xenia_canary"
BIN_EXE="${CANARY_BIN:-$BIN_DEFAULT}"
ISO="${SYLPHEED_ISO:-$WORKSPACE/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso}"
[ -f "$ISO" ] || { echo "ABORT: no ISO at '$ISO' — set \$SYLPHEED_ISO"; exit 4; }
SECS="${1:-95}"
[ -x "$BIN_EXE" ] || { echo "ABORT: native binary not found/executable: $BIN_EXE"; exit 4; }
# --- HARD PRE-FLIGHT GATE: only proceed if Vulkan exposes software devices ONLY ---
devs="$(vulkaninfo --summary 2>/dev/null | grep -i deviceName || true)"
if echo "$devs" | grep -qiE 'radv|amd|radeon|nvidia|geforce|intel\b'; then
echo "ABORT(pre-flight): a HARDWARE Vulkan device is still visible -> refusing (would crash VS Code):"
echo "$devs"
exit 3
fi
if ! echo "$devs" | grep -qiE 'llvmpipe'; then
echo "ABORT(pre-flight): llvmpipe not visible; lavapipe ICD missing? devs=[$devs]"
exit 3
fi
echo "pre-flight OK: software-only Vulkan -> $devs"
# --- clean slate ---
pkill -x xenia_canary 2>/dev/null; pkill -x Xvfb 2>/dev/null; sleep 1
Xvfb :99 -screen 0 1280x720x24 -nolisten tcp >/tmp/xvfb.log 2>&1 &
XVFB=$!
sleep 2
export DISPLAY=:99
cd "$(dirname "$BIN_EXE")" || { echo "ABORT: bin dir missing"; kill "$XVFB" 2>/dev/null; exit 4; }
rm -f xenia.log
# Real ALSA apu (routed to null sink via ALSA_CONFIG_PATH) so the guest gets a
# valid render client; --mute belt-and-suspenders. NEVER add --apu=nop here.
args=(--log_level=3 --mute=true)
if [ -n "${CANARY_EXTRA_ARGS:-}" ]; then
read -ra EXTRA <<< "$CANARY_EXTRA_ARGS"
args+=("${EXTRA[@]}")
fi
echo "launch (native, sw-vulkan, SILENT null-ALSA, ${SECS}s): $(basename "$BIN_EXE") ${args[*]}"
# -k 5: xenia catches SIGTERM and can hang on shutdown; force-KILL 5s later.
timeout -k 5 "$SECS" "$BIN_EXE" "$ISO" "${args[@]}" >/tmp/canary_native.stdout 2>&1
rc=$?
pkill -x xenia_canary 2>/dev/null; kill "$XVFB" 2>/dev/null; pkill -x Xvfb 2>/dev/null
# --- trustworthy boot-health verdict (NOT log-line-count, NOT rc) ---
# A healthy boot reaches actual content: title loaded + XMA audio decoding + NO
# host-exception fault loop. rc=137 is EXPECTED (timeout -k kill), not a failure.
faults=$(grep -ac 'Access Violation' /tmp/canary_native.stdout 2>/dev/null)
gthrow=$(grep -ac 'GUEST-THROW' xenia.log 2>/dev/null)
title=$(grep -ac 'Title name: PROJECT SYLPHEED' xenia.log 2>/dev/null)
xma=$(grep -acE 'XmaContext|Processing context' xenia.log 2>/dev/null)
echo "rc=$rc logsize=$(wc -c < xenia.log 2>/dev/null)"
echo "boot-health: title=$title xma=$xma faults=$faults guest_throw=$gthrow"
if [ "$title" -ge 1 ] && [ "$xma" -ge 100 ] && [ "$faults" -eq 0 ] && [ "$gthrow" -eq 0 ]; then
echo "VERDICT: HEALTHY (reached content, no crash)"
elif [ "$gthrow" -ge 1 ]; then
echo "VERDICT: GUEST-THROW CRASH (the real bug) -- see GUEST-THROW lines in xenia.log"
elif [ "$faults" -ge 1 ]; then
echo "VERDICT: FAULT (host access-violation loop) -- crash PC:"
grep -aoE 'PC: 0x[0-9A-Fa-f]+' /tmp/canary_native.stdout | sort | uniq -c | head -3
else
echo "VERDICT: INCOMPLETE (did not reach content in ${SECS}s -- raise timeout?)"
fi

130
tools/run-canary-native.sh Executable file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# INTERACTIVE launcher for the NATIVE Linux Xenia-Canary build.
#
# Opens a REAL window on your desktop (display :0) and — unlike
# run-canary-native-safe.sh — uses HARDWARE Vulkan (the AMD GPU) and leaves
# audio ON. This is for hands-on play/testing, not headless tracer runs.
#
# ⚠ WARNING: this renders on the same AMD GPU that drives your VS Code / desktop.
# The historical "crashes VS Code" issue was vkd3d-proton (D3D12->Vulkan under
# Wine); the native build talks to Vulkan directly, so it MAY be fine — but if
# the desktop glitches or VS Code dies, fall back to run-canary-native-safe.sh
# (software Vulkan) or set CANARY_SOFTWARE=1 below.
#
# Usage: tools/run-canary-native.sh [seconds] (no arg => runs until you close it)
# env: CANARY_BIN override binary (default = worktree Release build)
# CANARY_SOFTWARE=1 force software Vulkan (lavapipe) even here
# CANARY_MUTE=1 start muted / no audio device (--apu=nop)
# CANARY_GPU=<substr> pick a specific Vulkan device by name (e.g. radv, 6800)
# CANARY_EXTRA_ARGS space-separated extra cvars
# DISPLAY target X display (default :0 = your screen)
# Output: /tmp/canary_native_interactive.stdout (+ xenia.log in the binary dir).
set -u
# Paths derive from where this script sits — see the note in run-canary-safe.sh.
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE="$(cd "$HERE/../.." && pwd)"
BIN_DEFAULT="$WORKSPACE/xenia-canary-native/build/bin/Linux/Release/xenia_canary"
BIN_EXE="${CANARY_BIN:-$BIN_DEFAULT}"
ISO="${SYLPHEED_ISO:-$WORKSPACE/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso}"
[ -f "$ISO" ] || { echo "ABORT: no ISO at '$ISO' — set \$SYLPHEED_ISO"; exit 4; }
SECS="${1:-0}" # 0 => no timeout (interactive)
export DISPLAY="${DISPLAY:-:0}"
[ -x "$BIN_EXE" ] || { echo "ABORT: native binary not found/executable: $BIN_EXE"; exit 4; }
# --- Vulkan device selection ---
if [ "${CANARY_SOFTWARE:-0}" = "1" ]; then
export VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json
export VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json
export MESA_VK_DEVICE_SELECT=llvmpipe
echo "GPU: forced software Vulkan (lavapipe)."
else
# Hardware Vulkan. Optionally pin a device by substring (RADV AMD is default [0]).
[ -n "${CANARY_GPU:-}" ] && export MESA_VK_DEVICE_SELECT="$CANARY_GPU"
echo "GPU: HARDWARE Vulkan${CANARY_GPU:+ (pinned: $CANARY_GPU)}."
echo " Visible devices:"; vulkaninfo --summary 2>/dev/null | grep -i deviceName | sed 's/^/ /'
fi
# --- logging: keep it QUIET so kernel/APU debug spam doesn't starve the audio
# worker thread. At --log_level=3 (Debug) the d>/A> firehose (piped through
# tee) stalls the ALSA pipeline and mission audio dies permanently — the
# exact "mission-audio silence" that commit f10484834 fixes in code; the
# log flood defeats the keepalive. log_mask=13 = suppress Kernel|Cpu|Gpu.
# (guest_audio_flags defaults to 0 = Digital Stereo, the Linux-safe path.)
LOGLEVEL="${CANARY_LOGLEVEL:-1}"
LOGMASK="${CANARY_LOGMASK:-13}"
# --- audio ---
args=(--log_level="$LOGLEVEL" --log_mask="$LOGMASK")
if [ "${CANARY_MUTE:-0}" = "1" ]; then
export SDL_AUDIODRIVER=dummy
args+=(--mute=true --apu=nop)
echo "AUDIO: muted (apu=nop)."
else
echo "AUDIO: on (ALSA)."
fi
# --- audio RE: capture true XMA per-stream params (channels/rate/head bytes) to
# xenia.log as you play. Deduped (one line per unique sound); logs at Warning
# so it shows at the audio-safe log level without spam. Off unless requested.
if [ "${CANARY_XMA_PROBE:-0}" = "1" ]; then
args+=(--xma_param_probe=true)
echo "XMA-PARAM probe: ON (params captured to xenia.log)."
fi
# --- audio watchdog: when audio dies mid-mission and never returns, report
# WHICH pipeline stage stopped (guest callback blocked / no XMA decode /
# no frames submitted / host driver not writing). Near-silent while healthy.
if [ "${CANARY_AUDIO_WD:-0}" = "1" ]; then
args+=(--audio_watchdog=true)
echo "AUDIO watchdog: ON (logs 'AUDIO-WD ...' when audio dies)."
fi
# --- hang watchdog: if the guest stops presenting frames for N seconds, the
# emulator dumps every guest thread's registers + guest call stack to
# xenia.log by itself. No debugger, no ptrace, no need to keep the window
# open -- the freeze autopsy is already in the log.
if [ -n "${CANARY_HANG_WD:-}" ]; then
args+=(--hang_watchdog_secs="$CANARY_HANG_WD")
echo "HANG watchdog: ON (${CANARY_HANG_WD}s without a frame => guest dump to xenia.log)."
fi
if [ -n "${CANARY_EXTRA_ARGS:-}" ]; then
read -ra EXTRA <<< "$CANARY_EXTRA_ARGS"
args+=("${EXTRA[@]}")
fi
# --- CPU stress: the mid-mission audio death only shows up under host CPU load
# (the guest's audio callback misses its 5.33ms deadlines and the game tears
# its own audio client down). CANARY_STRESS=<n> spins n busy loops for the
# duration of the run so the bug reproduces on demand instead of by luck.
# They are killed when the run ends. Silent (no audio involvement).
STRESS_PIDS=()
stress_cleanup() {
if [ ${#STRESS_PIDS[@]} -gt 0 ]; then
kill "${STRESS_PIDS[@]}" 2>/dev/null
echo "stress: stopped ${#STRESS_PIDS[@]} load generators."
fi
}
trap stress_cleanup EXIT INT TERM
if [ -n "${CANARY_STRESS:-}" ] && [ "${CANARY_STRESS}" -gt 0 ] 2>/dev/null; then
for _i in $(seq 1 "$CANARY_STRESS"); do
# Pure userspace spin; no I/O, no audio, no privileges.
( while :; do :; done ) &
STRESS_PIDS+=($!)
done
echo "STRESS: ON -- ${CANARY_STRESS} busy loops competing for CPU (of $(nproc) cores)."
fi
pkill -x xenia_canary 2>/dev/null
cd "$(dirname "$BIN_EXE")" || { echo "ABORT: bin dir missing"; exit 4; }
rm -f xenia.log
echo "launch (interactive, DISPLAY=$DISPLAY): $(basename "$BIN_EXE") ${args[*]}"
if [ "$SECS" -gt 0 ] 2>/dev/null; then
timeout -k 5 "$SECS" "$BIN_EXE" "$ISO" "${args[@]}" 2>&1 | tee /tmp/canary_native_interactive.stdout
else
"$BIN_EXE" "$ISO" "${args[@]}" 2>&1 | tee /tmp/canary_native_interactive.stdout
fi
rc=${PIPESTATUS[0]}
echo "rc=$rc ADV.wmv=$(grep -ac 'ADV.wmv' xenia.log 2>/dev/null)"

72
tools/run-canary-safe.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# SAFE Xenia-Canary launcher for THIS box (shared AMD GPU + GPU-accelerated VS Code).
#
# Canary's vkd3d-proton renders D3D12->Vulkan; if it picks the AMD GPU it CRASHES
# VS Code. This wrapper forces software Vulkan (llvmpipe/lavapipe) IN THE SAME
# process as wine (env vars do NOT survive across separate shells!), and REFUSES
# to launch if any hardware Vulkan device is still visible (hard pre-flight gate).
#
# Usage: tools/run-canary-safe.sh [audit61_pcs_csv] [seconds]
# e.g. tools/run-canary-safe.sh 0x82507458,0x8250747c 95
# Output: /tmp/canary_video.stdout (+ xenia.log in the binary dir). Prints rc + ADV.wmv hit count.
set -u
# --- force software Vulkan, belt-and-suspenders across loader/driver variants ---
export VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json
export VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.json
export MESA_VK_DEVICE_SELECT=llvmpipe
export DXVK_FILTER_DEVICE_NAME=llvmpipe
export VKD3D_FILTER_DEVICE_NAME=llvmpipe
export LIBGL_ALWAYS_SOFTWARE=1
# Paths come from where this script sits — tools/ inside the repo, whose parent
# directory is the workspace holding the fork checkouts and the game data. Both
# are overridable, so a machine that lays things out differently sets the env
# var instead of editing the script. (This file used to hardcode one machine's
# absolute paths, which is why it could not be checked in.)
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE="$(cd "$HERE/../.." && pwd)"
BIN="${CANARY_WINE_BIN_DIR:-$WORKSPACE/xenia-canary/build-cross/bin/Windows/Debug}"
ISO="${SYLPHEED_ISO:-$WORKSPACE/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso}"
[ -f "$ISO" ] || { echo "ABORT: no ISO at '$ISO' — set \$SYLPHEED_ISO"; exit 4; }
PROBES="${1:-}"
SECS="${2:-95}"
# --- HARD PRE-FLIGHT GATE: only proceed if Vulkan exposes software devices ONLY ---
devs="$(vulkaninfo --summary 2>/dev/null | grep -i deviceName || true)"
if echo "$devs" | grep -qiE 'radv|amd|radeon|nvidia|geforce|intel\b'; then
echo "ABORT(pre-flight): a HARDWARE Vulkan device is still visible -> refusing (would crash VS Code):"
echo "$devs"
exit 3
fi
if ! echo "$devs" | grep -qiE 'llvmpipe'; then
echo "ABORT(pre-flight): llvmpipe not visible; lavapipe ICD missing? devs=[$devs]"
exit 3
fi
echo "pre-flight OK: software-only Vulkan -> $devs"
# --- clean slate ---
pkill -x xenia_canary_i2d.exe 2>/dev/null; wineserver -k 2>/dev/null; pkill -x Xvfb 2>/dev/null; sleep 1
Xvfb :99 -screen 0 1280x720x24 -nolisten tcp >/tmp/xvfb.log 2>&1 &
XVFB=$!
sleep 2
export DISPLAY=:99
cd "$BIN" || { echo "ABORT: bin dir missing"; kill "$XVFB" 2>/dev/null; exit 4; }
rm -f xenia.log
# Binary is overridable (default keeps the historical _i2d snapshot); extra
# cvars pass through via CANARY_EXTRA_ARGS (space-separated, values w/o spaces).
BIN_EXE="${CANARY_BIN:-xenia_canary_i2d.exe}"
args=(--log_level=3 --mute=true)
[ -n "$PROBES" ] && args+=("--audit_61_branch_probe_pcs=$PROBES")
if [ -n "${CANARY_EXTRA_ARGS:-}" ]; then
read -ra EXTRA <<< "$CANARY_EXTRA_ARGS"
args+=("${EXTRA[@]}")
fi
echo "launch (sw-vulkan, muted, ${SECS}s): wine $BIN_EXE ${args[*]}"
timeout "$SECS" wine "./$BIN_EXE" "$ISO" "${args[@]}" >/tmp/canary_video.stdout 2>&1
rc=$?
pkill -x xenia_canary_i2d.exe 2>/dev/null; wineserver -k 2>/dev/null; kill "$XVFB" 2>/dev/null; pkill -x Xvfb 2>/dev/null
echo "rc=$rc ADV.wmv=$(grep -ac 'ADV.wmv' xenia.log 2>/dev/null) logsize=$(wc -c < xenia.log 2>/dev/null)"