No emulator-requiring state/approved item was open this iteration (issues
#1, #3, #5 all landed and moved to state/needs-human; #25 still awaits
approval). Used the gap for corpus consistency instead of idling, per the
same "a correction that never reaches the row someone reads" failure this
project keeps naming.
1. menu-navigation-semantics.md's own Q4 table still said NEW GAME was
"not tested" and its own prose said it was "deliberately not pressed"
and hangs the emulator -- both refuted BY THIS SAME PAGE on 2026-08-28,
34 lines further down ("NEW GAME -- measured ... it is not a hang. It
opens DIFFICULTY then SELECT DATA"). The correction never propagated
backward into the table or the status line above it, so a reader
stopping at either would come away with the wrong (and already-refuted)
answer. Fixed in place, struck rather than deleted, with the actual
destination and an honest note that DIFFICULTY has no id-table name
match (already refuted separately) while SELECT DATA plausibly matches
GP_SELECT_STORAGE as a fresh, low-confidence guess.
2. Found the identical failure mode in my own recent work: HANDOFF.md's
original Q1-Q10 summary table (near the top of a 6600+ line file) still
quoted the 2026-08-30 "no auto-repeat" finding as current, three commits
after this same session measured 12 frames delay / 4 frames interval
through a repeat-capable driver and explained why the earlier negative
was a driver limitation, not a game fact. A reader who only sees the
summary table -- which is exactly what a long file trains a reader to
rely on -- would get the withdrawn answer. Fixed with an explicit note
pointing at the current entries rather than silently editing the number
in place, so the correction itself stays visible.
Refutation-shaped either way: two claims ("NEW GAME untested", "no
auto-repeat") checked against this corpus's own newer evidence and found
not to survive, recorded rather than left to be rediscovered.
Continues the static lead from two iterations ago
(f3-title-sting-mechanism-found-not-value.md) with the dynamic half it
named as the next step. Booted with --xma_param_probe=true (the same
census menu-audio-cues.md used for the menu's SE cues), no pad input,
recording continuously from window-open: a glyph time series (not a
threshold trigger) and every newly-seen XMA-PARAM stream, stamped on
arrival since Xenia's own log carries no timestamps.
Positive control, and a real one: the probe caught the title's two BGM
stems starting at t=147.6s, matching f3-title-plays-bgm-102-and-103.md's
already-established finding exactly -- the instrument finds a real stream
before being asked to find nothing.
From the plate's first visible activity through 68 seconds of build-in
plus fully-settled pulsing (killed at t=220.1s), zero new XMA streams
appeared beyond the two BGM stems and three unidentified early ones (boot
splash, not this question). No SE-range stream, no second BGM, nothing --
measured, not the prior static reach limit.
Refutation attempt this iteration, recorded either way: my first read of
the fine-grained glyph series said BGM and the plate's build-in start at
"essentially the same moment". Checking the raw per-sample data instead of
a coarse table refutes that -- first non-zero glyph reading is ~0.67s after
BGM onset, and immediately noisy rather than a clean climb. Corrected in
the doc rather than left as an overclaim for someone chasing frame-accurate
sync later.
Reference data: docs/re/data/f3-sting-{glyph-timeseries,xma-param-arrivals}.tsv
-- derived numeric/log-line data, not a capture of rendered game content.
Direct follow-through on this session's own named next step. The prior
result (f1-held-down-measured-no-repeat-via-file-driver.md) concluded the
file driver cannot show menu repeat because its GetKeystroke() never emits
a REPEAT-flagged event, and that the menu's repeat is very likely driven by
that flag rather than raw polled state. Testable, so tested: patched
/canary/src/xenia/hid/file/file_input_driver.h to add opt-in repeat behind
a new --pad_file_repeat cvar (off by default, every other scripted script
unaffected), using the SDL driver's own constants verbatim
(HID_SDL_REPEAT_DELAY/_RATE = 400/100, guest-time ms via
Clock::QueryGuestUptimeMillis) rather than re-deriving them. Incremental
rebuild, ~1 minute (only xenia_main.cc needed recompiling).
Control: the driver's own log confirms repeated keystroke events fire as
designed, zero crashes.
Result: re-ran the identical held-DOWN capture. The cursor that moved once
and stopped in the null result now cycles continuously through the whole
5-item menu, wrapping, for as long as the button is held -- the null result
was real for that driver path, and giving the driver the one thing it
lacked reverses it completely.
Measured at this run's achieved 29.87 fps guest rate: 12 frames (~402ms)
initial delay from the press-triggered step to the first repeat step; 4
frames (~133ms) steady-state interval for 13 of 15 gaps, 3 frames (~100ms)
for the other 2 -- slower than the raw 100ms constant driving it, which
this page flags but does not trace further (most likely the game batches
drained keystrokes per its own frame tick rather than reacting to each one
instantly). The 4-frame figure is what matters for the port: it's what the
cursor visibly does.
Honestly scoped: this measures what the game does when FED repeat events
shaped like the SDL driver's, not a capture through an actual physical
controller (none exists in this container) -- classified measured, not
decoded, for exactly that reason. One run only; the corpus's two-run
minimum isn't met, flagged rather than overclaimed.
f1_hold_capture.py gains an optional `repeat` argument. The Canary source
patch itself lives in /canary, outside this repo (Canary source, not
sylpheed-formats) -- fully described inline in the finding doc so it can be
reapplied if that tree doesn't persist across a container reset.
Reference data: docs/re/data/f1-repeat-cursor-transitions.tsv -- every
transition's frame, guest tick and Y position, not the raw draw log.
Fifth boot attempt this session, first clean one, using the harness debugged
last iteration (env-safe input, wide capture window, signed-in profile,
blanked X root). Reached the menu, held DOWN 2.5s wall-clock, armed the F10
draw capture, released, killed -- zero crashes.
Achieved vs requested: 433 of 600 requested frames, spanning 14.43s of GUEST
time against ~4.5s wall-clock for the hold+tail window -- this run went at
~3.2x real time (a cheap static menu, nothing pacing it to a display
refresh). The 2.5s wall-clock hold covers roughly 8s of that guest time.
Method: track one quad shape's position per frame via read_draws.py, not a
screen diff -- the approach f1-menu-repeat-harness-built-not-answered.md
already named as correct. Exactly one shape-group moves at all: one jump of
~0.21-0.225 NDC units (matching the known one-menu-item pitch), complete
within 4 frames / 133ms guest time of arming, then flat (a ~0.0125 pulse
wobble only) for the remaining 427 frames / 14.3s of guest time. Broadening
the match threshold finds no other moving element.
This is a STRONGER negative than the 2026-08-30 result it agrees with (that
one sampled a screen diff at ~4-5fps for 2.0s; this reads every quad every
frame for ~8s of guest-time held) -- it survives, rather than confirms, last
iteration's "coarse sampling hid a fast repeat" alternative, which does not
survive a per-frame instrument over a much longer window. Refutation
attempt against my own prior lean, recorded: it does not survive.
Reconciled, not left in conflict: the human's play-test and pad.py's
"auto-repeats" warning almost certainly went through a REAL controller
(Canary's SDL driver), which auto-repeats keystrokes at a documented
upstream 400ms/100ms (guest time) via the REPEAT keystroke flag. The file
driver deliberately never emits that flag, by design -- so it cannot show
repeat regardless of how carefully or how long it's held, which is exactly
the negative measured here. Still no F1 number; now a specific, well-
evidenced reason the file driver alone will never produce one, and a named
next step (add opt-in REPEAT-flag support to the file driver, matching the
SDL driver's constants, then re-run this exact capture) -- not attempted
this iteration, a second unit on an already-complete one.
Reference data: docs/re/data/f1-cursor-quad-y-per-frame.tsv -- the derived
per-frame Y position and guest tick, not the raw draw log itself (a capture
artifact, kept uncommitted per the corpus's game-content rule).
Tried to close out issue #1 with an actual draw-log measurement this
iteration: built f1_hold_capture.py to boot to the settled main menu via the
proven glyph-gated route, arm the F10 UI-draw capture, hold a direction, and
read cursor position per frame -- the instrument f1-menu-repeat-harness-
built-not-answered.md already validated but never got to run against a
reachable menu.
Four bugs found across four boot attempts:
1. tap() shelled out to pad.py without this script's own env, so the press
went to /tmp/xenia_pad.txt while Canary watched OUT/pad.txt -- an
unobserved press indistinguishable from a dead pad. Fixed with an
in-process tap() using the same pad() the hold uses; confirmed working
the next run (title 154.5s, menu 163.0s).
2. ui_draw_capture_frames/max were persisted at 3/20000 from a prior
session in xenia-canary.config.toml -- log_ui_draws is now a documented
no-op (F10 arms unconditionally) and these two cvars didn't visibly
respond to command-line overrides. Bumped to 600/400000 directly in the
config.
3. The real blocker: this container has no signed-in profile (no content/
directory at all -- a fresh container after a restart, which every
container is right after one). Without a profile the title's sign-in
dialog sets IsUIActive() true, which reproduces
structures/title-a-press-fault.md's already-diagnosed unbounded-
keystroke-queue crash -- verified byte-for-byte against that page's own
addresses (PC 0x868 past sub_82457038, 0x828F3xxx registers, identical
host/guest address arithmetic), looping continuously from before F10 was
ever pressed. Fixed by creating a profile (--create_profile_if_none) and
signing in (--logged_profile_slot_0_xuid), matching boot_menu.sh, which
already did this and so never hit it. Confirmed: zero crashes with the
fix, dozens per run without it.
4. Found but not re-verified: no xsetroot blank before launch, so a stale
X-root frame from a killed prior run gave a false "TITLE" read at 2.6s,
before any real window existed -- skip_intro.sh already blanks the root
for exactly this reason. Fixed in the script.
Ran out of budget before a clean end-to-end run landed. Still no number for
issue #1 -- the Port keeps -1.0. Flagged prominently (HANDOFF, REFUTED.md)
because bug 3 will hit any bare run-canary invocation in any fresh
container, not just this script.
Continuing the F1 investigation rather than starting a fresh one. Last
iteration left two competing hypotheses open (Keystroke-queue-driven vs
polled-state-driven repeat) and flagged C_PAD_RINGBUF's producer as the
cheapest thing to trace next -- named but not traced.
Traced it this time: C_PAD_DECODER's own constructor (sub_8220B610)
allocates C_PAD_RINGBUF (52-byte control struct, 1024-byte backing buffer,
confirmed against its own Shift-JIS trace strings -- "C_PAD_RINGBUF
initialization" and its allocation-error message). Its update function
(sub_8220B8C0) takes the input-manager singleton as a parameter and reads
the ring at offsets 12, 36, 40, 44 and 48 -- not just the one button word.
Offsets 36-48 are four consecutive fields read together through the same
int-to-double conversion an analog axis would use. XamInputGetKeystrokeEx
has no field for a stick position, so a structure carrying four axis-shaped
fields cannot be a keystroke queue -- it reads as a periodically-refreshed
polled-state snapshot. My own prior reading of the "ring buffer" name as
implying a queue was the wrong inference; refuted by tracing it, recorded
either way per adversarial duty.
This shifts the balance toward the second, previously-uncertain hypothesis:
the file driver's GetState() was always capable of showing real repeat (no
modification needed), and nav_repeat_and_b.py's null result is more likely
a sampling artifact of its ~4-5 fps screen-diff detector than a structural
driver limit. Revises "what would close it" accordingly -- re-run the
existing draw-log position-tracking instrument, gated on the menu properly,
before reaching for a driver change.
Not found: the actual producer that writes into C_PAD_RINGBUF each frame --
narrowed to "reachable from the input-manager singleton fetch in
sub_821A9DC8," not traced to completion. Still no number for issue #1;
this narrows the path to one, further than last iteration but not there.
Issue #1's premise (the human's play-test: a held direction repeats) directly
contradicted menu-navigation-semantics.md's 2026-08-30 "no auto-repeat"
row. Read the instrument before trusting either: nav_repeat_and_b.py drove
input through Canary's --hid=file driver, and that driver's GetKeystroke()
is explicitly, deliberately built to deliver exactly one event per held
press ("scripted input wants precisely one event per press, and repeat is
what makes menu steps overshoot" -- file_input_driver.h's own comment).
input-pad-read-path.md already established the game reads menu input via
this same Keystroke API. A driver engineered to prevent repeat cannot be
evidence the game lacks it -- the counter's control (a tap gives 1 spike)
proved the counter works, not that the driver could show more than one.
Not a clean reversal, and said so: the same driver's GetState() holds a
button continuously with no edge suppression, and pad.py's own docstring --
written by an earlier session driving this exact tool -- warns that a longer
dpad hold "auto-repeats and overshoots," describing an observed effect
through this same driver. The two pieces of evidence disagree and this page
does not resolve which wins.
Also read from Canary's source: the SDL input driver (what a real controller
goes through) auto-repeats keystrokes at 400 ms initial delay then 100 ms
interval, guest time (HID_SDL_REPEAT_DELAY/RATE, upstream Xenia, not a
project change) -- a concrete, testable prediction for what the real number
could be if the menu treats repeat-flagged keystrokes as nav steps, matching
the human's "medium pace" description. Not yet measured.
Refutation attempt this iteration, recorded per adversarial duty: targeted
the 2026-08-30 "no auto-repeat, ✅ measured" claim. Survives only partially --
demoted to unsettled, not flipped to a confident opposite. New REFUTED.md
section (Menu navigation and input) and the row in
menu-navigation-semantics.md both corrected in place, old text kept per
convention.
What would close it: trace C_PAD_RINGBUF's producer (keystroke ring vs
polled state) statically, or add an opt-in repeat mode to the file driver
and read cursor position off the draw log per frame. Neither run this
iteration -- this is the static half, and reversing a standing claim is
enough for one unit without stacking a build-and-boot run on top of it
unverified.
f3-title-plays-bgm-102-and-103.md's BGM census resolved a literal
`addi r5,r0,<imm>` before every call to the play primitive, which is blind by
construction to a cue id supplied any other way. Two of the primitive's 34
callers ARE supplied another way -- one is a register passthrough inside a
generic 6-caller wrapper `sub_821CCCB0(obj, cueId)` with a -1 "don't play"
sentinel. One of its six callers chains back to slot 1 of GamePart_Title's
OWN dispatch table at 0x820a3dec, identified by its adjacent
RegisterToFactory<0, class silph::GamePart_Title> string -- the same
convention already used disc-wide for the other 28 GameParts, not a
neighbourhood guess this time.
Traced as far as static analysis goes: the field that would carry the cue id
traces to a 19-caller shared helper's return value, too common to be
title-specific -- reads as "allocate a sound-emitter handle", not "here is
the cue". The value most likely gets set at whatever runtime moment the game
wants this emitter to speak, which a disassembly listing does not contain.
Classified undecodable-with-reach, not a fourth thing -- narrower and
better-aimed than the prior reach ("SE goes through a different call").
Every address cited was independently re-decoded from the raw .pe bytes
(file offset = VA - 0x82000000), not taken from the database's own
mnemonic/operands columns, and all agree.
Refutation attempt this iteration: checked whether the "6 callers is
exhaustive" claim for sub_821CCCB0 survives indirect dispatch (a plain
kind='call' xref scan is blind to a vtable/function-pointer-array entry).
Checked function_pointer_array_entries and kind='ind_call' xrefs for all
four functions in the chain -- none found. The claim survives.
What would close it: --xma_param_probe=true during a title boot's build-in,
watching for a newly-decoded stream when the plate reaches full alpha with
no input -- the same technique menu-audio-cues.md used for the menu's SE
census. Not run this iteration; this is the static half only.
f6-out-of-sample-RESULT.md left three failures unexplained beyond "n=2 wasn't
enough". Reviewing what's already decoded: two of the three (the ptcopyright/
parent ramp ratio, the sweep-leads-plate lead) fail in the SAME direction
(0.75x, 0.71x of predicted), and f6-unit10 already established the sweep and
plate families are gated by separately-triggered parents -- a ratio across
two independently-triggered elements has no structural guarantee of being a
constant, unlike a ratio internal to one element family (which is exactly
what the passing three checks are). The gross mislabeling that caused the
ORIGINAL 1.7x conflict was fixed two days before the prereg was written, so
that's ruled out as the cause here; whether the cross-group phase genuinely
varies boot to boot vs. an artifact in the frame-based ratio math is still
open, and needs more captures to tell apart -- filed as a follow-on, not run
here.
check_labels.py conflated two different claims under one "N LABEL(S) DRIFTED"
verdict: identity checks (which element -- clock-free, still 3-for-3 out of
sample) and timing checks (a cross-element ratio and a self-consistency curve
fit -- 0-for-2 out of sample). Split into two reported groups; only identity
gates the exit code now. Tolerances untouched -- widening them to pass f6c
would be tuning the check on the case that failed it, the same error class
already named twice in this corpus. Verified with synthetic data shaped like
the real f6c residue (3/3 identity, 0/2 timing, exit 0) and confirmed the
selftest's injected mislabel still fails an identity check (exit 1) -- no
capture exists in this fresh container to run it against real logs.
The withdrawn sweep->plate lead (0.138-0.141) stays withdrawn. Nothing here
reinstates a number.
`cargo build -p sylpheed-cli` after resolving the merge's one real conflict
(a rustfmt-only clash) was not "the merge is resolved" -- it only checked the
crate whose conflict I'd just looked at. `cargo check --workspace --all-targets`
finds the actual break: sylpheed-viewer's `compose_screen` builds a
`ComposeOptions` struct literal, and this branch added a field
(`at: Option<u32>`, for posing a composite mid-animation) that main's Explorer
code never had to name because every prior caller used `..Default::default()`
or the field didn't exist yet. Neither side's diff touched the other's line,
so nothing in the conflict pointed at it.
`at: None` is the only correct value here -- the doc comment on the field
already says so ("`None` keeps the settled composite, which is what every
existing caller wants"), and the Explorer's screen view has no notion of
mid-animation posing to wire up. This restores its exact prior behavior; nothing
about the Explorer's data source, static-only rule, or design changes.
PR #23 is red on this. Fixes it in place rather than opening a second PR, since
it is the same unit -- a merge I performed that did not actually leave a
building tree.
`decoder-loop.md` has had `## Each iteration` since 2026-09-04, ending in
7. Commit, `push-work`, open the PR, label the issue `state/needs-human`,
and stop.
`port-loop.md` has never had that section at all. It states the rules -- "you
cannot close your own work", "you move an item to state/needs-human", work
reaches main "through pull requests" -- but nowhere gives the STEPS that carry
them out. The Port agent was told the rule and never the procedure.
Two steps were missing entirely, and both are Phase 7 check items:
* READ YOUR NOTIFICATIONS first. They are polled and nothing pushes them, so
an agent that skips this never receives anything addressed to it -- and the
symptom is silence, not an error. That is GITEA-SETUP.md Phase 7 check 1.
* OPEN THE PR AND SET THE LABEL. That is Phase 7 check 2.
The second carries a trap worth naming in the brief rather than in a runbook:
`docker/port/bin/push-work` ONLY PUSHES. Verified by reading it -- no /pulls
call, no `Closes #`, no label; its refusals (no main, no force-push) are
intact. `propose-work`, which would do all three, is still unbuilt --
GITEA-SETUP.md's own words are "the other two thirds being manual is how they
get skipped". The capability is there (`pull_request_write` and `issue_write`
are both in the pinned MCP tool set), so this is an instruction gap, not a
permissions one.
Written to mirror the Decoder's shape while using the Port's own vocabulary --
a gate is an artifact, verify against captures rather than our renderer, state
the expected number first, `authored/` entries carry a `why`, never an invented
number where a measured one is pending.
Found by the Pi agent while answering a Phase 7 readiness question. My own
check had been decoder-only, which is why the asymmetry survived it.
Refs #11
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`420e84c` claimed the report prints
SYLPHEED_DISC PRESENT via the HARDCODED fallback, NOT $SYLPHEED_DISC
and it did not. The string was built by `"...(not ${env})".replace("${env}",
env)` -- a placeholder substituted at runtime, which printed
`(not SYLPHEED_DISC)` and dropped the `$`. I had cleaned this up before
committing, but the edit silently no-op'd (rustfmt had already rewrapped the
line, so the pattern no longer matched) and the commit went ahead with the
message quoting output the code does not produce.
Now a plain `format!`, which is what it should have been: shorter, no
placeholder to keep in sync, and the `$` makes it read as the env var it is.
The quoted output in `420e84c` is accurate as of this commit.
Not squashed into it because force-push is denied on this remote, and a
visible correction is worth more than a rewritten history anyway.
Verified: `cargo fmt --all -- --check` clean, both tests pass, output matches
the text above exactly.
Refs #16
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cargo test --workspace` reports the same tally whether the disc corpus was
exercised or entirely absent. Measured: the disc suites RAN on a developer
desktop (1936 s, mesh_consistency_disc alone 1220 s) and SKIPPED on CI
(2.4 s total) -- and both reported 207 passed / 0 failed / 14 ignored across
30 suites.
Two mechanisms compound:
* a skip is a PASSING test. The gated suites `eprintln!("SKIP: ...")` and
return early from a test that still passes, so a skipped suite and a
fully exercised one both score 1 passed. The totals are invariant.
* the message is invisible. `cargo test` captures a passing test's output,
so NEITHER log contains a `SKIP:` line. The absence of one proves
nothing, which makes the obvious check useless too.
And `14 ignored` cannot help: `#[ignore]` is static, so that column is the
literal count of attributes in the source and cannot move at runtime. Ask
what this check would still report if the corpus were entirely absent, and
the answer is 207/0/14.
This is #16's remedy (2) -- the only one that touches the REPORT, which is
the defect. Remedies (1) and (3) improve the control and are left open.
Adds `tests/corpus_report.rs`: always runs, never fails, resolves all three
corpora exactly as the per-suite helpers do, and records what was available.
It writes to a FILE rather than relying on stdout, because a passing test's
stdout is captured and would be invisible in exactly the CI log that needs
it; the workflow then prints that file. It also appends to
GITHUB_STEP_SUMMARY when set.
Run here, it immediately shows the thing the issue is about -- all three
corpora resolve through the HARDCODED fallback, so SYLPHEED_DISC is not
controlling anything on this machine:
SYLPHEED_DISC PRESENT via the HARDCODED fallback, NOT $SYLPHEED_DISC
SYLPHEED_RES3D PRESENT via the HARDCODED fallback, NOT $SYLPHEED_RES3D
SYLPHEED_ISO PRESENT via the HARDCODED fallback, NOT $SYLPHEED_ISO
The ABSENT branch is the one CI takes and cannot be reached on a machine
that has the corpora, so `resolve_renders_every_branch` exercises it
directly rather than shipping it unrun -- along with "set but does not
resolve", which is what a typo in the env var produces and which is
deliberately reported as a DIFFERENT state from absent, since the two want
different fixes.
Verified: `cargo fmt --all -- --check` clean. Clippy is unchanged by this
(a test target; CI's `cargo clippy --workspace` does not build test cfg) --
it fails identically on unmodified main here with
`only_used_in_recursion` at vfs.rs:85, which is the rustc 1.90.0 vs the
runner's 1.98.1 divergence, i.e. #15, not this.
Refs #16
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cargo fmt --all -- --check` has failed on every run in this repository's
history, identically on `main` and on every branch. This is #12.
Mechanical: `cargo fmt --all`, nothing else. 154 files, all `.rs`, no other
extension touched. `cargo check --workspace` exits 0 afterwards, so nothing
changed semantically.
ON THE ORDERING, WHICH WAS THE REAL QUESTION.
HANDOFF-2026-09-06 section 7 warns this is the expensive fix: a whole-tree
reformat before #7 and #8 return "would put a conflict in every file of 861
commits and make the reviews those items exist to enable unreadable".
That is measurably too pessimistic, and it had been reasoned rather than
tested. Measured here by three-way merging a rustfmt'd `main` against both
unmerged branches, file by file:
file/branch pairs tested 32
merges CLEAN 28
merges CONFLICTING 4 (8 conflict hunks total)
sylpheed-cli/src/main.rs 1 hunk
sylpheed-export/src/check.rs 1
sylpheed-export/src/screen.rs 4
sylpheed-export/src/video.rs 2
All four are against `auto/frame-blend-draw-path` only;
`auto/port-p6-audio` does not conflict anywhere. The earlier framing --
154 dirty files, 133 that cannot collide, 21 that can, the collision set
carrying 147 of 774 hunks (19%) -- reproduces exactly. What it did not say
is that most of the 21 still merge cleanly, because rustfmt's edits and the
branches' edits rarely land on the same lines.
So the cost of sweeping now is 4 files and 8 hunks for one branch, against
a check that is otherwise red forever. Deliberately NOT folded into the
WASM PR: 154 reformatted files would make that one unreviewable.
Closes#12
With the build fixed, `trunk build --release` succeeds and the job then
died on its last step:
::error:: @actions/artifact v2.0.0+, upload-artifact@v4+ and
download-artifact@v4+ are not currently supported on GHES.
Gitea presents as GHES and the action aborts by design -- go-gitea/gitea
#31256 and #36024. Same class as the trunk-action bug this branch already
fixes: an action written for GitHub's hosted fleet, on a runner that is not
it.
Dropped rather than downgraded, because nothing consumes the artifact.
`web-dist` had exactly one reference in the repository -- the line that
produced it. There is no `download-artifact`, no deploy step, and no second
workflow. It was failing the job for no benefit.
The two ways to keep it are recorded in the file rather than here, because
choosing between them needs a fact this run does not provide (the runner's
node version) or a supply-chain decision (a third-party action).
What now runs end to end on aarch64, from scratch:
Install Trunk ok (v0.5.1, the arch fix, on the real runner)
cargo check wasm32 ok
trunk build --release ok 10m 56s, 3 files in dist/
Refs #11
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`trunk build --release` reached the asset pipeline for the first time and
failed there:
found more than one target artifact: ["sylpheed_viewer", "sylpheed-viewer"]
The crate declares both a [[bin]] `sylpheed-viewer` (src/main.rs) and a
[lib] `sylpheed_viewer` cdylib (src/lib.rs), and the index.html link named
neither, so trunk refused to guess.
Trunk's error offers two ways out and THEY ARE NOT EQUIVALENT. Measured,
both exiting 0:
data-target-name="sylpheed_viewer" 1_478 bytes, 1 app symbol
data-bin="sylpheed-viewer" 21_298_268 bytes, 2_998 app symbols
Selecting the lib "succeeds" while linking nothing, because there is no
wasm entry point in it -- no wasm-bindgen dependency, no import, no
`#[wasm_bindgen(start)]`. The linker drops the whole app and trunk emits an
empty module. That would have turned this job GREEN on a bundle that cannot
start, which is worse than the red it replaced.
`main()` is a valid wasm entry: it calls `sylpheed_viewer::run()` and its
only native-specific code is already `#[cfg(not(target_arch = "wasm32"))]`.
With the bin selected, trunk injects a real init -- `import init`, an
integrity-checked module preload, `__wbindgen_start`, and the
`TrunkApplicationStarted` event.
The lib.rs docs claimed this file was the WASM entry point "called from
`wasm_bindgen` init on the web". Nothing ever called it. That comment is
what made the lib look like the right target, so it is corrected here
rather than left to mislead the next reader.
Verified locally with trunk 0.21.7 on x86_64. The exit code does not
distinguish these two cases -- only the artifact does.
Refs #11
`Install Trunk` has never run to completion here, because the two steps
before it always failed first. With those cleared it becomes reachable,
and on this runner v0.5.0 would fetch the wrong binary.
v0.5.0 switches on PLATFORM alone and never consults the architecture:
case 'linux': arch = 'x86_64-unknown-linux-gnu'; break;
v0.5.1 reads it and maps it, failing loudly rather than wrongly:
const arch = process.env['ARCH'] || process.arch;
case 'x64': targetArch = 'x86_64'; break;
case 'arm64': targetArch = 'aarch64'; break;
default: core.setFailed(`Unsupported architecture: ${arch}`); return;
Node reports `arm64` here, so it resolves to `aarch64`, and upstream does
publish trunk-aarch64-unknown-linux-gnu.tar.gz. There is also an `ARCH`
env override if the mapping is ever wrong.
Read out of the two bundled dist/index.js files, not the release notes.
Counts moved as predicted: `x86_64-unknown-linux-gnu` 1 -> 0, `aarch64`
0 -> 1, `os.arch()` 5 -> 5. Confirmed independently on both machines.
Two further changes the bump carries, neither of them about architecture:
* the download host moves thedodd/trunk -> trunk-rs/trunk. Trunk moved
repositories and v0.5.0 still points at the old one -- arguably the
more durable half of the fix.
* `io.mv` 1 -> 0 and `io.cp` 2 -> 3. A cross-filesystem move throws
EXDEV; a copy does not. This is the fix for self-hosted runners whose
/tmp is a separate filesystem, which is ours. NOTE: the string EXDEV
appears zero times in either bundle, so this cannot be found by
grepping for the error it prevents -- it is visible only as the
primitive swap.
STILL UNVERIFIED: whether `trunk build --release` then succeeds. Everything
above concerns selecting and fetching the binary. The step after it has
never run in this repository's history, on any architecture, so there is no
basis to predict it. Expect to read that log fresh.
Refs #11
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the `mio` subtree gone (previous commit), two blockers remain. Each
was invisible until the one before it was cleared, which is why #11 was
written around only the first.
1. getrandom 0.3 refuses wasm32-unknown-unknown without being told which
backend to use. It needs `--cfg getrandom_backend="wasm_js"` AND the
crate's `wasm_js` feature; its own error is explicit that either alone
is insufficient. Nothing here depends on getrandom directly -- it
arrives through `ahash`, in `sylpheed-viewer` only -- so the feature
half is declared there purely to switch it on.
2. error: bevy_egui uses unstable APIs to support clipboard on web.
Needs `--cfg web_sys_unstable_apis`.
Both cfgs live in /.cargo/config.toml scoped to the wasm target, so native
builds are untouched.
Verified on aarch64 / rustc 1.98.1 -- the runner's toolchain -- from
scratch with the cache cleared: exit 0 in 163s. Independently reproduced
on x86_64 / rustc 1.90.0 as a controlled A/B against the parent, both
running the job's exact invocation:
with this branch exit 0, zero errors, 38s
same command at 34f19ae exit 101, the getrandom error
The control also shows `bevy_egui` is never reached when getrandom fails,
and the passing case contains no `mio` and no `tokio` in the wasm graph at
all -- so each blocker is confirmed separately rather than by the
aggregate exit code.
This does NOT make the WASM job green, and the next failure is already
identified rather than left to be discovered. `Install Trunk` uses
`jetli/trunk-action@v0.5.0`, whose bundled `dist/index.js` contains the
string `x86_64-unknown-linux-gnu` exactly once and `aarch64` not at all,
while calling `os.arch()` five times with no mapping for it. On this
aarch64 runner it will fetch an x86_64 binary. Upstream trunk does ship
`trunk-aarch64-unknown-linux-gnu.tar.gz`, so the asset exists and only the
action's selection is wrong -- but replacing the install step means
picking a version to pin and an install method, which is a decision, not a
fix. Left for #11 to decide.
Refs #11
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`sylpheed-formats` declared `tokio` as a normal dependency and never used
it as one. All four references in its `src/` are inside a `mod tests` --
three runtime builders in ship.rs (539, 566, 690; `mod tests` at 498) and
one `#[tokio::test]` in xiso.rs (182; `mod tests` at 179) -- and tokio was
ALREADY present in `[dev-dependencies]`, so the tests keep compiling
unchanged.
The unused normal dependency pulled `tokio/full`, whose `net` feature
drags in `mio`, which does not build for wasm32:
error: This wasm target is unsupported by mio.
Removing it is right on its own terms; the WASM job is merely what exposed
it. Native is unaffected -- `cargo check --workspace` exits 0 on x86_64.
This is separated from the CI configuration it was found through because
it is the one change here that touches another crate, and #11 is
`state/proposed` around the getrandom error alone. It is ordered first so
that every commit builds: the reverse order would leave an intermediate
commit still failing the wasm check on `mio`.
WARNING: this does NOT reach `sylpheed-export`, which builds
`sylpheed-formats` from the git pin `formats-pin-2026-09-01` (`e2630413`,
contained in `auto/frame-blend-draw-path` only) rather than the workspace
path crate. The dependency is not gone tree-wide until that pin resolves,
so anyone later adding `-p sylpheed-export` to the WASM job will hit `mio`
with this fix apparently already applied.
Refs #11
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
§§1-9 were written on fabi-Hyrican-PC. On the other desktop the ~/.sylph-*
credentials do not exist, the Pi does not resolve, and stable is 1.90.0 rather
than the 1.98.1 §8 records -- so §8.2 (fetch the WASM bundle) and §8.4 (Phase 7)
cannot be run from here at all. Says which of the four steps can.
Measured rather than carried over: protection holds (10/10), fmt is 774 hunks
across 154 files, check-citations is 19, and the tests are 207/0/14 across 30
suites. Two of those need reading carefully:
* clippy DIVERGES. The runner is rustc 1.98.1 -- read out of job 794's log,
not assumed -- and is clean; here 1.90.0 exits 101 on only_used_in_recursion
at vfs.rs:85. That is #15 ceasing to be theoretical. It is NOT evidence that
CI's green is fake, which is the §7 lesson-5 inference in the other
direction.
* the test tally matches to the unit while measuring something else. 15
*_disc.rs files resolve disc_root() through a hardcoded absolute path, so
unsetting SYLPHEED_DISC does not skip them: the disc suites RAN here (1936 s,
mesh_consistency_disc alone 1220 s) and skipped on CI (2.4 s total) -- and
both report 207/0/14, because the skip path returns from a test that still
passes. Good news for #14, since this run is the stronger evidence; and worth
an issue, since SYLPHEED_DISC looks like a control and is not one.
I got that last one wrong first -- inferred "the counts cannot match" from "the
fallback resolves", which is §7's shape a sixth time, recorded as such.
Also: a plain `git clone` of this repo fails three ways on the pack that still
carries the 545 MB; --filter=blob:none works. And §6's tokio claim was
challenged and survived -- every use is inside a #[cfg(test)] module.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Written to be read cold by a session with no context: what is established
versus what is someone's claim, which of the four actors the gate actually
binds, and what is in flight but unpushed.
The transferable part is §7 -- one failure shape recurred five times in two
days across two agents and the assistant: a property inferred from something
ADJACENT to it rather than tested directly. Protection from a settings page,
reachability from a DNS record, a label count from a grep that matched the
function definition, Gitea's host from a DNS lookup, and a clippy version from
rustfmt output parity. The last is the sharpest: rustfmt 1.8.0 and 1.9.0, nine
months apart, both emit 774 hunks here, so formatting parity carries no
information about which clippy ran -- and the conclusion drawn from it (that
CI's green was cached or ungated) was wrong in a way that would have sent
someone chasing a phantom.
`ascii_runs` in movie_manifest.rs had a nested `else { if .. }`. Clippy's
`collapsible_else_if` flags it — on some toolchains.
WHY IT WAS NEVER CAUGHT, AND WHY THAT IS THE INTERESTING PART:
clippy::collapsible-else-if @1.92.0 (2025-12-08) -> warn
clippy::collapsible-else-if @1.98.1 (2026-09-01) -> allow
CI installs `dtolnay/rust-toolchain@stable`, which floats. The runner is on
1.98.1, where this lint is allow-by-default, so the Clippy job passed. A local
run on a nine-month-old stable failed on the same bytes. Neither instrument was
broken; "clippy clean" is simply a statement about a toolchain and a date, and
nothing in the pipeline records which. That is #15, and this commit does not fix
it -- it removes one instance of its consequence.
Fixing it rather than pinning, because `else { if }` is worse code on every
version. A tree whose cleanliness is contingent on a release date is the thing
to avoid; agreeing with both toolchains is cheaper than arguing about which one
is right.
⚠️ I FIRST DIAGNOSED THIS WRONG, and the wrong version is worth recording. From
CI's rustfmt reporting 774 hunks and mine reporting 774, I concluded the
toolchains matched, therefore the clippys matched, therefore CI's green must be
a cached or ungated result -- "the frozen splash again". Every step after the
first was false. rustfmt is deliberately output-stable within a style edition;
clippy explicitly moves lints between groups. Measured here afterwards:
rustfmt 1.8.0-stable -> 774 hunks
rustfmt 1.9.0-stable -> 774 hunks (nine months apart, identical)
So formatting parity carries no information about which clippy ran. It is the
same error as reading protection off a settings page or reachability off a DNS
record: a property inferred from something ADJACENT to it. The peer refuted it
by measurement -- `No cache found` in the run log, and `success()` evaluating
'true' in 207 against 'false' in 203 -- rather than by accepting the framing.
Verified on both: 1.92.0 `clippy --workspace -- -D warnings` rc=0 (was 101);
1.98.1 with the lint forced on, 0 sites. rustfmt still 774, so no debt added to
#12. Tests 207 passed / 0 failed / 14 ignored, unchanged.
The lint work added 8 rustfmt hunks. Run 206's Formatting job reports 782
where run 204 reported 774, and the difference is mine — debt added to the
one issue the lint pass argued should not be disturbed. Measured against a
`4ac5c9f` worktree under the same rustfmt, the tree is back to 774: equal
to baseline, not merely close to it.
Two causes, both interactions rather than mistakes of judgement:
`cargo clippy --fix` writes its replacement on one line. Where the call
sat inside a multi-line method chain that produced
.as_chunks::<2>().0.iter()
which rustfmt wants split across three lines. Seven sites, in
`formats/{audio,vfs,game_data}.rs` and `cli/main.rs`. An eighth was a
`for` header pushed past the width limit; it reads better as two bindings
than as a six-line chain, so that is what it became.
The last one is subtler and was the only file left over after the other
seven were fixed. `ship_capture.rs` had
let mut flush = |base: u32,
size: u32,
and clippy correctly removed the `mut`. That shortens the line by four
characters, so the closure's six continuation parameters were then
aligned four columns too far right — a formatting change caused by a
change on a different line, in a file whose own hunk count is what
exposed it.
Worth recording because it generalises: `--fix` output is not rustfmt
output, and on a tree that is not rustfmt-clean the difference is
invisible in the diff and only shows up as a hunk count moving. The check
that catches it is a count against a same-toolchain baseline, not an
inspection of the patch.
Re-verified after the reformat, since these edits changed real lines:
cargo clippy --workspace -- -D warnings exit 0
cargo test --workspace exit 0 207 passed, 0 failed
cargo fmt --all -- --check 774 hunks == baseline
Refs #12, #13
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
`cargo clippy --workspace -- -D warnings` now exits 0. `cargo test
--workspace` still reports 207 passed, 0 failed, 14 ignored across 30
suites — identical to runs 203 and 204, so none of this changed behaviour.
The workspace total was 73, not the 48 run 204 reported. `-D warnings`
turns a lint into a hard compile error, so `sylpheed-formats` failing
stopped its dependents from ever being built: `sylpheed-viewer` (14) and
`sylpheed-cli` (11) had never been linted by anyone. Clearing formats in
bc79817 is what made them visible.
formats 43 -> 0 (bc79817)
viewer 14 -> 0
cli 11 -> 0
export 5 -> 0
Collision surface, measured rather than assumed. Every viewer file
carrying a lint is byte-identical on both `auto/frame-blend-draw-path`
(495 commits) and `auto/port-p6-audio` (366). All eleven cli sites fall
outside every hunk either branch touches. 68 of the 73 sites could not
collide with anything.
The five that can are all in `sylpheed-export`, and three of those are
real:
main.rs:278 `&out` -> `out`, inside frame-blend's hunk -278,12
main.rs:318 `&out` -> `out`, inside port-p6-audio's hunk -303,44
audio.rs:113 an added `#[allow]` in a file frame-blend DELETES
Each is one line. Resolving the first two means taking the branch's
version and re-applying a borrow removal; the third resolves to the
deletion. Flagged here so neither branch owner meets them cold.
Judgement calls, all stated at the site rather than suppressed globally:
* Three `too_many_arguments` in the viewer are false positives.
`draw_viewer_ui`, `poll_loader_channel` and `apply_pak` are Bevy
systems — every parameter is a `Res`/`ResMut`/`EventWriter` the
scheduler injects, so the count is the framework's dependency list and
cannot be reduced without a `SystemParam` struct.
* `cmd_screen_render` (cli, 8/7) is a plain function, so that one is real
if mild; its arguments are the subcommand's flags.
* Two `dead_code` fields in export are serde schema fields. They model
what the on-disc JSON accepts; deleting them would quietly change that.
* `iso_loader.rs` gains a `FrameRx` alias for the ffmpeg frame channel,
which is what "very complex type" was asking for.
A site-local `#[allow]` with a reason is a decision recorded where it
applies: one lint, one function, and any new violation elsewhere still
fails the build. That is not the shape PROTOCOL.md forbids.
Closes#13
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
Run 204 gave this repository its first clippy measurement — 48 errors, 43
of them in `sylpheed-formats`. This clears that 43 to zero under the exact
invocation CI runs, `cargo clippy -p sylpheed-formats -- -D warnings`.
Why this crate first, and why it is safe to touch:
Every one of the 43 sites was checked against the line ranges that
`auto/frame-blend-draw-path` (495 commits) and `auto/port-p6-audio` (366)
actually modify. None of them overlap. Eleven of the fifteen affected
files are byte-identical on both branches, including `mesh.rs` and
`texture.rs`, which carry 28 of the hits between them. The three sites in
`audio.rs`, `ui_layout.rs` and `slb.rs` that live in files those branches
do change fall outside every modified hunk. The collision argument that
defers #12 does not transfer here; it was tested rather than assumed.
It also unblocks a measurement. `-D warnings` turns a lint in this crate
into a hard compile error, so its dependents never build — `sylpheed-cli`
and `sylpheed-viewer` have never been linted at all, and viewer is the
largest crate in the workspace. Both depend only on `sylpheed-formats`
(`sylpheed-export` pins it from a git tag instead), so this commit is what
makes their real counts knowable.
38 applied by `cargo clippy --fix` — chunks_exact_to_as_chunks,
manual_div_ceil / is_multiple_of / range_contains, unnecessary_map_or,
needless_borrow, let_and_return, dead_code, unused_mut/variables.
Purely local expression rewrites: 38 insertions, 39 deletions.
2 by hand: a doc continuation that markdown was parsing as a list, and
`d / frame` behind a `frame > 0` guard becoming `checked_div`.
3 `#[allow(clippy::too_many_arguments)]` with a stated reason.
On those three allows: 8 parameters against a threshold of 7, in the mesh
anchor path. The real fix is a shared params struct across
`anchor_pool_mesh`, `validate_block` and `validate_block_report` — the
latter two take the same eight arguments and one delegates to the other —
which is a change to the decoder's signatures and belongs to whoever owns
that path, not to a CI-lint pass.
This is not the shape PROTOCOL.md forbids. `continue-on-error` suppresses
everything, present and future, at the job level, and cannot tell "not
yet" from "no longer". A site-local `#[allow]` with a reason is a decision
recorded where it applies: one lint, one function, and any new violation
anywhere else still fails the build.
`sylpheed-export`'s remaining 5 are deliberately untouched — three of them
sit inside hunks both long-lived branches modify, and that crate blocks
nothing. Left for #13.
Refs #13
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
The two instances already in "Checks that were kind once" were correct
when written and decayed. The third was wrong on its first commit, and it
arrived by a different route: the check and the tree's failure to pass it
land in the same change, so the softening writes itself.
Concretely — the Clippy step had never run (no component in the
toolchain), and the tree is not clippy-clean, so fixing the step and
turning it red are the same commit. The first draft paired the fix with
`continue-on-error: true` and a comment promising removal once the debt
was paid: an expiry date nobody set, in the shape #12's closing line had
already ruled out for rustfmt. Reverted on reading it.
Adds the distinction, a table separating decay from dirty authorship, and
an earlier tell than the mechanical test:
If you are writing the softening in the same commit as the check,
the thing you want is an issue, not a flag.
The mechanical test is unchanged and still correct; this only catches the
same failure sooner, at the keyboard rather than at review.
Refs #12, #13
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
`dtolnay/rust-toolchain@stable` installs a minimal profile. The `native`
job named no components, so every run that reached the Clippy step died
on
error: 'cargo-clippy' is not installed for the toolchain
'stable-aarch64-unknown-linux-gnu'
before clippy read a line of source. That is not a lint result; the step
had never run. The `fmt` job below always named `components: rustfmt`
correctly — this one never did.
Two lines of behaviour change. The rest is the comment explaining why the
step is left gating on `-D warnings` rather than softened: the workspace
is not clippy-clean (run 203's build alone emits ~13 rustc warnings that
`-D warnings` promotes to errors), and `continue-on-error` cannot tell
"debt not yet paid" from "debt paid". That debt is scoped in #13, the way
the rustfmt debt is in #12.
Run 203 is what made this visible. With the aarch64 fix in 64bb7da the
native job got all the way through:
cargo check --workspace ok 10m01s
cargo build --workspace ok 19m04s
cargo test --workspace ok 16m22s 214 passed, 0 failed
cargo clippy --workspace toolchain error
Refs #13
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
This workflow has never once gone green on this instance: 23 runs cancelled,
2 waiting, zero successes. Not a regression -- it has been decorative since it
was written, because it describes GitHub's hosted fleet and runs on one
self-hosted aarch64 Pi advertising ["ubuntu-latest","ubuntu-24.04",
"ubuntu-22.04"].
Two failures, both configuration rather than code:
`windows-latest` and `macos-latest` match no runner label, so those jobs sit in
WAITING for ever and the RUN never reaches a terminal state. A pull request's
checks therefore never resolve either way -- not red, just never finished, which
is worse than red because a red check tells you something. Removed: a second
architecture here needs a second runner, not a second matrix row.
`--target x86_64-unknown-linux-gnu` on an aarch64 host makes every build a
cross-compile, and `wayland-sys`'s build script dies on it with "pkg-config has
not been configured to support cross-compilation". Dropped; the native job now
builds for its host.
NOT touched, deliberately: the WASM and Formatting jobs still fail, on real code
state rather than on configuration -- `getrandom` needs the `wasm_js` backend
for wasm32-unknown-unknown, and `cargo fmt --check` reports a ~13,000 line diff
across the tree. Editing those two into passing is precisely the leniency with
an expiry date nobody sets that PROTOCOL.md now forbids. They are issues, not
workflow lines.
(One latent defect noted while reading: `jetli/trunk-action` fetches
trunk-x86_64-unknown-linux-gnu onto this aarch64 host. It has never been reached
because the WASM check fails first, and it will bite the moment that is fixed.)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
Two rules that look unrelated and are one failure, plus the change that makes
the second enforceable.
1. A FINDING REACHES `main` BEFORE THE CODE THAT CITES IT. A citation resolving
only on a peer branch is dead the moment it merges. Not hypothetical: 495
decoder and 366 port commits sit off `main`, and `port/scripts/boot.gd`
already cites two docs/re pages present on neither its own branch nor main.
2. A CHECK MAY ONLY SOFTEN AGAINST A CONDITION IT CAN TEST -- the Pi agent's
wording, and better than mine, because it is applicable while writing rather
than a call to be vigilant. The mechanical form:
Can this branch tell the difference between "not yet" and "no longer"?
`gitea-protect --verify` printed ⚪ "not a collaborator (yet)" and continued,
so the only instrument checking Write-not-Admin could not report that gate
being REMOVED. `check-citations` reported peer citations instead of failing
them, because under the old topology that was unfixable from the container.
Both were correct AND kind when written; neither recorded that the kindness
had a scope. Nobody edits these into being wrong -- the world moves and the
allowance stays, which is why they survive review. The smell is leniency with
an expiry date nobody set; the fix is the testable-condition rule.
check-citations gains `--for-merge`, which turns the peer class into a failure.
A flag rather than a new default because BOTH readings are still live: mid-work
on a topic branch the peer class really is unfixable noise. What the old code
could not express is where the code is GOING, and that is a condition the caller
can state. Measured on this tree: 19 citations resolve only on a peer branch --
which is the size of the #7-depends-on-#8 edge, not the 2 I had counted in
boot.gd.
The selftest gains that third class, because a flag whose classification is
unexercised is the shape this rule exists to catch. Controlled: emptying
PEER_REFS makes the peer case collapse into "nowhere" and the selftest reports
🔴 BROKEN, rc=2.
⚠️ Pre-existing and NOT from this change: the default run already exits 1 on 4
citations of `export/...` paths. Those are the generated tree, gitignored by
design, and main's copy of the tool fails identically. The CITE regex treats
`export/` as a repo prefix. Reported, not fixed -- it is the port's file and its
call whether the regex or the citations are wrong.
--verify's collaborator loop printed ⚪ and continued on 404 without touching
`ok`, so the one instrument that checks Phase 1.2 could not report Phase 1.2
being undone. An agent removed from the repository read as "nothing to say"
rather than as a gate that is no longer there.
It has never fired: Gitea answers that endpoint with permission "read" for a
non-collaborator rather than 404, so the case was caught by the role test two
lines down. Correct outcome, wrong reason -- the same shape as the check that
passed on an instance with no rule at all, and not worth keeping because the
luck has held so far.
Found by the port agent reading the file rather than running it, which is the
only way this one was ever going to surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
Phases 1-4 and 6 are done on the instance. This file still opened with "Nothing
exists on the instance: no agent users, no API tokens, no labels, no milestones,
no branch protection" -- every clause of which was false by the time the merge
that carried it landed.
Replaced with a table of measured state, and each row says what was MEASURED
rather than what was run:
* protection is verified behaviourally -- a real push to main refused with
`pre-receive hook declined`, as the repository owner -- not read off a
settings page. That distinction is the whole subject of this file.
* the tokens are probed: right identity, 403 on branch_protections for both
agents, so the Write-not-Admin carve-out is demonstrated and not asserted.
* the labels are 11 because the instance holds 11.
And a standing note that this block is the part most likely to be wrong, with
what to believe instead: `gitea-protect --verify` and the issue list MEASURE,
this block REMEMBERS. A remembered status is a cache with no invalidation, which
is the same failure as a 1,227-line BLOCKED.md and as the two documents this
runbook was split across an hour ago.
Caught by the Pi agent against the live instance after Phase 4 ran. The tool
creates 5 state/*, 2 agent/*, 4 kind/* = 11.
Where the 12 came from is worth a line, because it is a shape that recurs:
$ grep -c '^mklabel' tools/gitea-setup
12
$ grep -n '^mklabel' tools/gitea-setup | grep -v ':mklabel "'
74:mklabel() { # name colour description
I counted the function DEFINITION as a call. A measurement taken one token away
from the thing being measured -- the same shape as reading protection off a
settings page and reachability off a DNS record, which is now three today. The
version that cannot make this mistake is counting what the instance holds, and
that is what found it.
Two things that read as protection while being none.
Phase 2's rule binds everyone who reaches Gitea through the API or the web, and
does not bind anyone with `gitea admin` in the container -- which includes the
supervising agent that created the agent accounts and minted their tokens. From
that shell the rule is editable and an admin token is one command away. That is
the boundary of what the phase buys, not a hole to plug there, and the document
read as though the gate were universal. Phases 1 and 2 gate the two CONTAINERISED
agents, whose design assumption is that policy lives where they cannot reach it;
a supervisor with a host shell is not in that set.
And `gitea-setup` finished by telling the reader to go and build a Gitea project
board by hand, four sections after the doc explains that a board is a second copy
of the state to hand-sync and is precisely the failure that produced a 1,227-line
BLOCKED.md. A tool instructing you to do the thing its own documentation argues
against is the drift this whole surface exists to end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
The runbook existed as two documents -- a published page and this file -- with
no mechanism keeping them equal, only an intention to remember. Two versions was
the predicted outcome of that, not an accident on top of it. This is the fold,
and the rule that follows it: THIS FILE IS THE SOURCE, the page is derived from
it. When something is urgent enough to push to the page first, it lands here in
the same turn, not "shortly after".
Four things the file did not carry:
* YOUR OWN PUSHES TO main STOP. `enable_push: false` compiles to CanUserPush,
which returns false with no bypass for admins or the owner -- quoted from
the source. Three commits went in by direct push the day this was written,
so the first notice would have been mid-task. Now a check step.
* the token files' MACHINES, which the table had lost.
* do NOT add `write:repository` to the `fabi` token. That scope IS a push
credential. Written down because that advice was given, in chat, by the
author of this file.
* Gitea 1.25.5 confirmed from the desktop too, not just the Pi.
And one thing deliberately NOT folded in: the page said the desktop's outbound
HTTP was blocked, and that is false. `python3 -c 'urllib...'` returns
200 {"version":"1.25.5"} from this box. What is refused here is `curl`, by a
local permission prompt -- which I read as a network constraint and then
published as one. The Phase 3 locations stand; the reason given for them did not.
The "not verified" section now separates WRONG from UNCHECKED. Four entries are
wrong -- requiring an approval does not close the gate, the check could not have
caught that, the token scope, the reachability -- and the pattern in all four is
identical: a property inferred from something ADJACENT to it (protection from a
settings page, reachability from a DNS record) instead of tested directly. That
is the frozen-splash failure, committed in the document about avoiding it. The
first two were caught by the other agent, which is the argument for the review
gate this file exists to build.
Phase 2 as a file. Six settings where two are load-bearing and both were missing
from the first draft is the shape of thing that gets mis-clicked at 1am, so it
goes through the API: what was applied is readable in a diff, and `--verify`
can re-check it later instead of it being checked once.
--verify states its expectations INDEPENDENTLY of what the apply path sends.
A check derived from "whatever we posted" cannot fail -- it re-derives the
expectation from the thing under test, which is the same instrument-shaped
failure as a check that passes on an instance with no rule at all.
It also asserts both agents are still Write and not Admin, because an agent
promoted to Admin can edit the rule and then merge, so a green rule proves
nothing on its own. That is the `gitea-verify` card from "Still to build";
what is left of it is only putting it on a timer.
`block_admin_merge_override` stays false on purpose, and the reasoning is in
the file: approvals are whitelisted to `fabi`, and Gitea will not let `fabi`
approve a `fabi` PR -- so with the override blocked, a human-authored PR could
never reach one approval and could never merge at all. The override is not a
hole in the agent gate because the agents are Write, not Admin. Phase 1.2 pays
for that; this is where it is spent.
Reads the repository-scoped credential that already exists on the agent box
(~/.sylph-git-credentials) rather than the issue-only ~/.sylph-gitea-api-token,
which every branch-protection endpoint refuses. That keeps the setup needing no
new credential, and keeps push rights on one machine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
Phase 5 of docs/agents/GITEA-SETUP.md, plus a correction to Phase 2 that the
runbook could not have known it needed.
gitea-mcp v1.7.0 goes into both images, pinned by the sha256 the release
publishes and smoke-tested with `--version` at build time, so a bad pin fails
the build instead of the agent. Each entrypoint registers it at user scope for
that container's own identity, remove-then-add so a restart is idempotent.
The token is passed BY PATH. `-e GITEA_ACCESS_TOKEN=$(cat …)` would write it in
cleartext into ~/.claude.json, which every session in the container reads;
GITEA_ACCESS_TOKEN_FILE is new in the pinned version and leaves the secret in
its read-only mount. Verified against the binary's own --help, not assumed.
The tool filter stops being an experiment. The names are in the release README:
each agent gets issues, notifications, labels, milestones and pull requests, and
NOT `pull_request_review_write`. That one matters because separate identities
open a hole the runbook did not name: Gitea refuses to let an author approve
their own pull request, and does nothing about sylph-decoder approving
sylph-port's. Two agents could satisfy `required_approvals = 1` between
themselves and then merge, since branch protection blocks pushes to main and
never blocked merges.
Withholding the tool is defence in depth; the controls are in branch protection,
and both docs now say so: approvals whitelisted to the human so an agent's
approval does not count, merges whitelisted to the human so an approved PR is
still merged by a person. Phase 2's check gains the step that actually tests it
-- approve the throwaway PR yourself, then confirm the agent STILL has no merge
button. Without that step, the check passes on an instance where the agents can
merge each other's work.
Also settles two entries on the runbook's own "not verified" list: the tool
filter names, and the Gitea version (1.25.5, whose API schema carries
enable_merge_whitelist and enable_approvals_whitelist under those names).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
WORKFLOW-gitea.md said what the working surface is and why. It did not say how,
in what order, or how to know a step worked -- so it was a destination with no
route. This is the route.
Seven phases, each with a check, each marked 👤 human or 🤖 me:
1 identities two agent users, Write NOT Admin
2 protection main behind a PR + 1 approval -- BEFORE tokens exist
3 tokens three principals, three tokens, three files
4 structure labels and bundles, and deliberately NO Kanban board
5 MCP gitea-mcp v1.7.0, per-agent identity, user scope not .mcp.json
6 items migrate the live findings only -- not 1,227 historical lines
7 restart and verify the three things that must be true
Phase 1 leads because it is not hygiene: Gitea does not let a PR's author
approve it, so while an agent IS `fabi` either the human cannot approve its work
or it can approve its own. The review gate does not exist until the agents are
distinct people. (It also fixes 495 commits of agent work attributed to the
human's email.)
Phase 2's check is a real push and a real PR, not a reading of the settings
page. The reason protection lives in the server rather than in a brief is that
it should not depend on good behaviour -- so verifying it should not either.
Phase 5's install facts are checked, not remembered: gitea-mcp v1.7.0,
`gitea-mcp_Linux_x86_64.tar.gz`, `-t stdio -H <host>`, `GITEA_ACCESS_TOKEN`.
The `--tools` filter is flagged as an EXPERIMENT that might exclude the merge
tool as defence in depth -- explicitly not a substitute for phase 2.
Ends with what is still to build (propose-work, an attachment uploader,
gitea-verify, the wiki landing page) and a "what I have not verified" section:
the approve-your-own-PR behaviour, the --tools names, the Projects API, and the
Gitea version -- the API was unreachable from my sandbox three times running.
The two loop files ARE the prompts -- `sylph-port` and `sylph-decoder` read them
off the host at launch -- so the workflow change had to land here or it would
not reach the agents at all.
PROTOCOL.md gains four sections:
* Work items -- issues, milestones as bundles, the state labels, and that
`state/blocked` uses DEPENDENCY EDGES, never prose. A prose blocker is what
let a 1,227-line BLOCKED.md go stale.
* Messages -- an ask is a `kind/ask` issue, not a SendMessage. With the part
that matters: 🔴 NOTHING PUSHES. Notifications are polled, at the top of
every iteration, and therefore an agent must NEVER wait on an ask -- set the
edge, take the next item. The channel this replaces dropped 21 consecutive
messages to a stale session id and reported success each time.
* Pull requests -- one item per branch, `Closes #N`, and you may not merge
your own. Branch protection enforces it; the rule is written down so the
agent knows it, not so it depends on the agent.
* Each iteration, in order -- notifications, sync, one unit, hand over, stop.
Also: evidence a human must look at now attaches to its issue, and a blunt
"never commit game content, under any directory name" with the 545 MB that
prompted it.
The two briefs shrink 697 -> 298 lines. They had accreted five dated focus
blocks between them -- sole-focus orders, F1-F6 queues, one-off "merge this
branch on your first iteration" instructions -- which is a queue, and a queue
belongs in the tracker. What is KEPT is what outlives its bug:
* ask of any check, what would this still report if the feature were absent?
Three instruments passed a splash that never animated.
* the instrument must sit at or above the thing that can break -- the
InputEventAction / input-map miss.
* R1, and grep REFUTED.md before proposing.
* the .pe is primary and the database is somebody's analysis of it.
* the oracle is the real game in Canary, not any renderer of ours.
⚠️ NOT YET TRUE when this lands: the agents have no Gitea users, no API tokens
and no MCP server, so the issue tooling these briefs assume does not exist yet.
The agents are stopped. Setting that up is the prerequisite for restarting them.
Takes the port branch up to 77320d5e -- the state the human play-tested on
2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio`
is 366 commits and 938 files, and most of that must not land.
WHAT COMES IN (76 files, all human-confirmed working):
* the logo splash animation. 08ed3dd1 found it: `pose_at` ASSIGNED the settle
instant instead of clamping to it, so the splash never animated at all --
and the same bug manufactured a passing harness result, because the harness
photographed t past the settle. Confirmed by play-test: "cannot notice any
obvious difference from the actual game."
* gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad
binding), stick latched with hysteresis at the game's own 61% digitise
threshold. This is what made (A), video-skip and Extras work at all.
* menu navigation and flow, menu audio, the exporter, the authored
declarations, and 23 verification tools under tools/port/.
WHAT IS DELIBERATELY LEFT ON THE BRANCH:
* everything after c0ae460a -- the F5/F6 title-timing investigation, whose own
tip commit calls itself a "hand-off for one-minute human checks". Unchecked
by definition; it goes through the new review gate like anything else.
* the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested.
* the F1 repeat mechanism, which its own commit calls "deliberately inert".
WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED:
545 MB of extracted game content was committed on that branch -- 850 sprite,
audio and transcoded video files under `export-probe/` and `export-probe2/`,
plus 246 MB of loose .wav and .tsv at the repo root. This repository's own
rule, in this file, is "never game content".
The rule was not missing. It was written, and it was tightened on that very
branch, with a careful comment explaining why BOTH `export/` and `data/base/`
had to be listed -- while the exporter was writing to a third name that
nobody had thought to list. Enumerating names is the thing that failed. So
the ignore rules now describe the SHAPE: any top-level `export*/`, game media
by extension, and loose capture output at the root. Verified both ways -- it
catches all four offenders and ignores nothing currently tracked.
Verified: `cargo check --workspace` clean; all nine GDScript files parse in
project context, with a positive control (an injected syntax error is detected,
3 lines) so the clean result means something. `tools/port/check-all` was NOT
run -- it needs the container, the export tree and a display.
The human wants to direct this project from a web UI rather than chat or Remote
Control, so Gitea becomes the working surface. No new store: adding a second
copy of the truth is this project's defining failure mode, and Gitea already
holds the code. Its first-party MCP server (gitea/gitea-mcp v1.7.0, checked) has
issues, labels, milestones, PRs, attachments and notifications.
ISSUES replace BLOCKED.md. Milestones are bundles the human defines; issues are
items agents propose and the human approves. The state labels end in
`needs-human`, which is the state the whole model turns on and the one no
off-the-shelf tool models -- the market has converged on removing the human.
`blocked` uses Gitea's DEPENDENCY EDGES rather than prose, so "the Port is
blocked on the Decoder answering X" becomes queryable and closes itself.
PULL REQUESTS, the human's proposal, adopted -- and a bigger improvement than it
looks. Today's long-lived auto/* branches have drifted 280 and 373 commits apart,
which is unreviewable by construction. One PR per item makes the human gate
NATIVE rather than a label convention, binds the change to its item, and enforces
the sizing rule: an item too big to review in one sitting was too big to be an
item.
🔴 Agents must not merge their own PRs, and pull_request_write includes merge --
so this goes in BRANCH PROTECTION on main, not in a document asking them not to.
Same principle that fixed the build-jobs cap: policy where the agent cannot reach
it.
WIKI -- the human suggested it for RE findings, and that half is declined with
reasons. A finding's value is that it sits beside its evidence, versioned with
the code that consumes it; the wiki is a separate git repo, so a decode
correction and the exporter change depending on it could never be one reviewable
PR. And wiki edits bypass review: the REFUTED.md reclassification changed the
file both agents read to decide what not to try, and as a wiki edit it would have
been an unreviewed mutation of shared ground truth. The wiki takes human-facing
orientation instead -- runbook, navigation, container notes, and a landing page,
which closes the real gap that there is no view of what is happening except
container logs.
FILES: three needs, three homes. Agent-to-agent transient stays in /exchange.
Evidence a HUMAN must look at attaches to the issue it belongs to -- it travels
with the item and cannot be orphaned from the claim. Evidence a finding cites
stays in git. Note the MCP exposes attachment_read only; upload needs a direct
REST call.
tools/gitea-setup creates the labels and bundles, idempotently, with --dry-run.
Blocked on a token with write:issue -- the push credential is write:repository
and every issue endpoint refuses it, checked rather than assumed.
Both agents stopped, and the decoder diagnosed it itself:
"I received '2' and '1' but I don't have a pending question those would
answer -- I was in the middle of setting up the /loop cron job."
claude-autonomous matched the BARE SUBSTRINGS 'Choose', 'trust' and 'accept' to
answer Claude Code's one-time first-run gates. The /loop prompt is echoed into
the terminal, and that day's briefs contain 'accepted as-is' and 'least
trustworthy' -- so expect matched the agent's OWN INSTRUCTIONS and typed 2\r and
1\r into a running session, which then sat waiting for a human to explain them.
The old comment argued a multi-word pattern 'never matches' because the gate
text wraps. True of a literal string, false of a whitespace-tolerant regex, which
is what these now are: \s+ spans the wrap, and the terminal is 200 columns wide.
Measured, old against new, against the real brief text and a real gate:
{accept} brief 0 gate 1 (case-sensitive; briefs say 'accepted')
{Yes,\s*I\s+accept} brief 0 gate 1
{trust} brief 1 <- the trigger
{Do\s+you\s+trust\s+the\s+files} brief 0
Two defences, because one is not enough for something that can type: patterns
prose cannot match, and gates skipped ENTIRELY on resume (SYLPH_SKIP_GATES) --
a resumed session cannot show a first-run gate, so there is nothing to answer
and everything to lose. Timeout cut 90s -> 25s for the same reason.
Also: SYLPH_OWN_LOGIN. Remote Control stopped registering under the long-lived
token, and the likely reason is scope -- `claude auth login` requests
user:sessions:claude_code and the token's auth status reports no email, org or
subscription. A per-agent `claude auth login` restores Remote Control AND avoids
the rotation collision, because each agent holds its own grant rather than a copy
of one. The flag stops the entrypoint seeding the host's credentials over it.