Files
Sylpheed/docs/re/METHOD.md
sylph-decoder 84cf98477f re: the record-layout fix is confirmed against the GAME, and settle-vs-rest is not
The era test left one element responsible for all 74 507 differing pixels on
title_jp -- ptlogo_eff3.t32, the corpus's named plateau-less rest() discriminator
-- with two candidate rest poses, (108,72) stale and (98,42) fixed. There is a
capture of that exact screen, so the oracle can choose.

Scored over the 388x423 box where the two renders differ, so the result is not
diluted by the ~92 % of the frame that is identical:

  stale era  rest (108,72)   RMSE 58.412
  fixed era  rest  (98,42)   RMSE 41.690   <- the game agrees with the fixed era
  fixed era  --settle t=213  RMSE 40.210

Until now the keyframe record-layout fix rested on internal consistency: 0 of
1 042 multi-segment alpha ramps constant-rate under the old reading against 857
of 1 540 under the new. Strong, but not a measurement of the game. It now has
one, on the single screen where the two readings change pixels.

Three controls, all run first. Alignment found by sweeping the vertical offset
rather than assuming it -- 45 gives 32.41 against 56.37 and 53.08 either side, a
sharp minimum at the known game-surface offset. The scoring box discriminates:
the same box against a different screen's capture gives 98-103 against 40-58
here. And --black changes nothing (58.412/41.690 either way) because every pixel
in that box is covered by an element -- recorded because the flag's help says a
framebuffer capture must be compared against a black canvas, and here it happens
not to matter.

Sweeping the screen's own timeline with --at gives the noise scale: the capture
sits on a plateau from t~135 to t~240, flat to 1.2 RMSE across 105 units, rising
sharply outside (78 at t=0 and t=270). So the stale-vs-fixed margin of 16.7 is
~14x that flatness and decisive, while the settle-vs-rest margin of 1.5 is INSIDE
it and is not. This capture separates the eras and cannot separate the policies;
the settle-instant proposal stays unadopted.

Refutation attempted: sylpheed-port's adjudication that their shipped pose is
closer to the game than their reference. It SURVIVES, independently and by a
different metric, in the same direction.

Also concedes that my "your branch is the stale era" reasoning was invalid -- I
inferred era from a line count, which is the error they named -- while recording
that the conclusion holds for the ref I could see: origin/auto/port-p6-audio's
ui_layout.rs is md5-identical to origin/main's.

METHOD: two things that should differ producing identical output is a broken
experiment until proven otherwise, and a zero is its most dangerous form. Four
instances now. Verify the inputs differ before believing the outputs match, and
do not infer that difference from a proxy -- line count is not era.

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

115 KiB
Raw Blame History

Method traps already paid for

Each line cost an iteration at least once. They are general — they are not about Sylpheed, they are about how this kind of measurement goes wrong.

Like REFUTED.md, this list had been living in the autonomous agent's loop prompt, i.e. nowhere durable. See README.md for the /🟡/ confidence convention itself.

Controls

  • Every result needs a control. A control that fails kills the instrument.
  • Run the known-positive through a new filter FIRST. Three filters have been killed by their own control. When one fails, read the known-good's disassembly before assuming a shape.
  • A measured negative is a result — but a negative is only as strong as the route you ran, so state its reach.
  • A null result needs its cause shown to have happened.
  • A result with NO unknowns is suspicious.
  • Census the whole set; always run the other population as the control. Zero partials is stronger than a majority.
  • A 2×2 partition is the sharpest general tool — both off-diagonals empty is a law.
  • Re-derive a doc's own numbers as the control.

Inference

  • Never conclude from ONE sample.

  • ⚠️ The specific observation and the general rule read identically on the page — and the general one is what the next reader uses. This cost five corrections across two agents in two days, and none of them was carelessness about the measurement; every underlying observation was true of the asset actually looked at. The failure is reaching for the general form in the same breath as the specific one:

    • "the two chunks are two stems of one performance" — true of a music bank, written as a fact about voice, where one of the two is digital silence;
    • "the extra bytes are a duplicated channel, not fidelity" — true of ADV, and the size ratio it implies runs 0.0778 to 2.9163 across the disc;
    • "everything the sequencer paces off rest.t is late" — true of the title, and false of the screens actually checked;
    • "a three-stream cue is a movie cue" — mine, and BIRD_224 is neither;
    • "take the highest-rate, highest-gain stream" — mine, and on ADV those two criteria select different streams.

    The counter is cheap and it is always the same one: run the census before writing the rule. A ratio that is tight over 28 assets is a format fact; a ratio that scatters 37× was one asset wearing a rule's clothing. Where the census cannot be run, write the specific sentence and say it is specific.

  • A law proved on one population is a hypothesis on the next.

  • Finding one exception does not imply a family.

  • Consistency is not proof. A suggestive coincidence is a coincidence until measured. An analogy is not a measurement.

  • Same layout ≠ same instance. Same record-name set ≠ same object.

  • A marker is only proven by what it leaves out.

  • A high-confidence SCORE is not a high-confidence MECHANISM.

  • Knowing HOW MANY is not knowing WHICH.

  • Round numbers matching is weak evidence — unless you read the constant.

  • My own last-turn result is a hypothesis too.

  • A global partition can understate a per-owner one.

  • A residual is measured against a population — name it.

  • 🔴 An insensitive observable fails TWICE, and the second way is worse. Two bugs in one exchange, one cause:

    1. A leaf-composition rule was checked against alpha, which moves ~0.3 levels per keyframe unit — so a one-keyframe association error barely shifted it and the rule looked confirmed. The same span moved x by 1 560 px.
    2. Fitting t from that same alpha then manufactured an 11.5 px position residual that did not exist, and sent the consumer hunting a pivot/rotation mechanism to explain it. One byte of alpha quantisation is worth 1.51.9 keyframe units, i.e. 68 px of sweep. Solve on the fastest-moving field; check the slow one. Never the reverse. ⚠️ The second failure is the more expensive: not-falsifying leaves you falsely reassured, but inventing a residual sends you looking for a mechanism.
  • ⚠️ A stated reach is a boundary, not a hedge — do not extrapolate past it. ui-render-tone-curve.md fitted γ ≈ 1.341.49 on dark flat patches and wrote "nothing constrains midtones or highlights". Used above that range the model is simply wrong: binned by level, the exponent falls monotonically and crosses 1.0 near render ≈ 40, so above it the capture is brighter than the render and no single exponent can express the curve. The page had already said where it stopped being true; the error was reading past the sentence. The fix was not a better fit — it was printing the curve instead of a scalar, so it can be argued with. A scalar hides its own domain.

  • ⚠️ Normalising? Divide by how many inputs CARRY SIGNAL, not how many there are. The port hit this three times in one pipeline, each invisible to every check except a level measurement, and each the same mistake: a digitally silent chunk counted in a voice sum; a digitally silent channel counted in a mono fold (5.94 dB); a digitally silent sub-wave — the 10 240-byte bank header, wrapped to 10 300 B — counted as a third stem in a music sum, putting every real stem at 1/3 instead of 1/2 (3.52 dB on all menu music, shipping for two iterations). This corpus's own census said those banks hold two waves; the exporter's divisor said three. A count that disagrees with a census is the count that is wrong, and the symptom is never a crash — it is everything being quietly a few dB down.

Searching and tooling

  • A search that returns thousands has no power; state the reach.
  • A substring match is not a hit. A regex miss looks like a null result — print one raw sample before believing a zero.
  • A derived table can be a cross product — measure its shape first.
  • After refuting an instrument, sweep everything that depended on it.
  • Before measuring how wrong a tool is, read what the tool actually does.
  • The instrument must pass its own control.
  • Classify a bulk before mining it. The residual is the prize.
  • Rank by similarity — the cliff is the finding. But read the values before trusting the rank.
  • Grep the nouns before designing the experiment — and believe it.
  • Grep gives you a file list — read every file on it.
  • The answer is often already in the doc that owns the subject — read it end to end. A 🟡 often names its own route.
  • Before re-trying a blocked idea, check whether the blocker's own doc already tried it.
  • Ship a regenerator with every artefact. An artefact that moves by a pure reorder is a tool bug.
  • Never print per-entry lines from a disc-wide sweep — aggregate.

Reading the data

  • Read what a loader NAMES, not where it stores.
  • A field the disc never values still gets named by the loader.
  • An indexed read beats a deduped-pool adjacency read.
  • Check the whole string set, not the one matching word.
  • A dict keyed by record name across a multi-entry pak is a lie.
  • A set-difference over names hides reuse — join per USER.
  • A self-index names records, not files.
  • Case-insensitive hashing means two spellings can be one entry.
  • An "unresolved" name may be the wrong kind, namespace or prefix — or part of a cut asset.
  • A garbled value may be a real string in another encoding.
  • Two of my own counts disagreeing is a grammar clue.
  • A game's own typo is a join key.
  • A bias constant in the code is a join key.
  • A prefix trap: enumerate maximal [A-Za-z0-9_] runs, not startswith.
  • Re-deriving a format is not a finding — asking whether its values resolve is.

Mechanics that have bitten

  • Never hand-convert a decimal VA — print hex().

  • grep -c counts LINES — use grep -o | wc -l.

  • Counter.most_common() tie-breaks by insertion order — use sorted().

  • Raw grep cannot see inside compressed pak entries.

  • Commit messages go in a file (git commit -F); a literal | in a table cell needs escaping; git log --all -- <path> can hang.

  • 🔴 Never clamp a value before something compares it. A focus detector printed a degenerate margin=12359888888.89, so it was capped at 999 to keep the output readable. That cap ran before the vote-sorting step, so two different votes compared equal, the stable sort kept the wrong one, and a correct NEW GAME became an out-of-range index and a refusal — which aborted a seven-minute driven boot. The measurement was right the whole time; a cosmetic fix changed a decision. Clamp at the point of display, never upstream of a comparison that depends on the value.

  • pkill -f PATTERN / pgrep -f PATTERN match YOUR OWN command line. Hit twice in one session: pkill -9 -f adv_audio_cap.sh killed the shell that ran it, and an until ! pgrep -f "probe.py --run" loop never exited because the loop's own command line contained the pattern. Kill by process name (ps -o pid= -C xenia_canary) or exclude self; a wait-loop that greps for its own text waits forever and looks like the job hanging.

  • "Build 10" of a pak is ambiguous — always say which index space. sylpheed-cli screen list GP_TITLE.pak reports 12 builds and numbers them 011; screen list --all reports 16 and numbers them 015. Only under --all does the ordinal equal the pak entry. Without it, ordinal 10 is pak entry 12 and ordinal 11 is entry 15 — so "builds 10/11 are the loading screen" and "entries 12/15 are the loading screen" are the same true statement, while "entries 10/11 are the loading screen" is false: those are the publisher (palogo_sqex) and developer (palogo_gamearts/seta/anima) splashes. This cost a wrong line in HANDOFF that the port caught, and it would have validated silently because the port's screen_names.json is keyed by entry. Write entry N, not build N, whenever the number leaves this repository.

    🔴 This entry was already here when I broke the rule. So was ui-splash-addressing.md, which says in as many words that the splashes are entries 10/11/13/14, that is_build rejects them, and that they are reachable only through --all. Two documents in my own corpus, and I still ran --build 10 bare and wrote three claims on the output. The failure was not missing knowledge — it was addressing a bundle by index without grepping for the index first. A rule written down is not a rule applied. Before any --build N, run screen list on that pak and read the entry column; it costs one command and it is the only step that would have caught this.

    📌 And the sweep says GP_TITLE was the mildest case on the disc (structures/build-ordinal-vs-entry.md, data/ordinal-entry-map.txt): 21 of 24 archives diverge, 18 of them at ordinal 0 — in the six GP_MAIN_GAME_*2D paks --build 0 is entry 108. GP_TITLE is the only archive whose first ten ordinals are the identity, which is why the corpus survived: almost everything written about builds is about GP_TITLE, at ordinals 09. That is luck in one archive, not a property of the format, and it does not extend to the screens the port has left to do.

  • A layout fix has to be swept across every READER of that layout, not just the crate. The keyframe record-layout fix (a pose's time precedes it) landed in ui_layout.rs, and sylpheed-cli was found stale and rebuilt. Two more readers survived it: tools/re-capture/fade_quads.py, which read each pose's time from blk+36 — the next record's time word — and therefore printed a trailing untimed keyframe; and, through it, screen-transitions.md, which labelled the quad's clear-hold as its fade-in and published 0.874.08 s for a ramp that is 0.200.27 s. Both looked right: a shifted time series is still monotone, plausible, and internally consistent. The tell is structural, not numeric — the stale reader cannot time the last pose, so any output with a trailing t=— or - is that bug's signature. Grep the corpus for readers of a structure before calling its fix done.

  • An absence of output is not a status. sylpheed-port reported "still running, two lines, both ok" for three consecutive iterations of a check script. The first attempt had already died silently under its own timeout with block-buffered output — so there had been zero information from it, and "still two lines" was being read as patience rather than as the alarm it was. The underlying process was wedged on an ffmpeg that finishes its work and never exits (an orphan from an older form of the same script was found still running after 9.5 hours). Two rules fall out, and this corpus has now paid for both: a long-running job needs line-buffered output and a bound, and "no new output" must be treated as no information, never as progress. Check the artifact, not the exit code — the artifact reached its correct 8.0 s duration while the process it came from never returned.

  • An allowance is a claim, and it decays like any other. sylpheed-port's check-all printed "2 DIFFERS, allowed: the pin is not on main, so this compares two decoder eras" — in green, for weeks, and both of us quoted it back without testing it. Tested: sylpheed-cli built at formats-pin-2026-08-30 and at workspace HEAD render title, title_jp and main_menu byte-identically, despite 508 lines of difference in ui_layout.rs. The eras explain nothing; the allowance was making a real disagreement look accounted for, and the real reasons were already written down elsewhere in their own notes. Two rules: an excuse in a passing check is an untested assertion that never gets read as one, because green output is not scrutinised; and allow by NAME, never by COUNT — a count cannot notice that a different row started failing while the total held steady, which is exactly the hole the allowance opens. ⚠️ Same signature as the .tbm and stale-sylpheed-cli findings: the times moved, the pixels did not. When two versions of a renderer are supposed to differ, check whether they actually do before explaining a mismatch with the difference.

  • Two things that should differ producing IDENTICAL output is a broken experiment until proven otherwise — and a zero is its most dangerous form, because it reads as a clean result rather than a suspicious one. This corpus has now hit the signature four times: a --time=50 seconds-vs-units bug gave two poses the same RMSE to two decimals; a .tbm correlation scored 0.004…0.010 across nineteen builds; a two-era render comparison reported 0 differing pixels because both binaries had the same md5; and a build-ordinal error rendered the wrong screen while every downstream number validated. The check is mechanical and costs seconds: verify the two inputs actually differ before believing they produce the same outputmd5sum the binaries, diff the parameter you varied, assert the control moved. ⚠️ And do not infer that difference from a proxy: origin/auto/port-p6-audio's ui_layout.rs differs from the pin by 508 lines and is nonetheless md5-identical to origin/main's. Line count is not era. I reached the right conclusion about that branch through exactly that invalid step.

Runtime / emulator

  • Look at the PNG — and check its dimensions.

  • "Animating" is not "still in a mission".

  • Dedup entity enumerations by position value.

  • Do not diagnose timing or liveness under gdb. ps %cpu is cumulative.

  • The PRESS Ⓐ glyph counter false-positives on the attract movie by 13×. title_timing_probe.py's plate detector thresholds a green-glyph pixel count at 400, and its control checks two committed movie frames that both score 0. A real boot disagrees: in one 100 s attract window, 17 frames scored ≥ 400 and the peak was 5 393 — the movie has green content in the plate region. The probe is safe because its state machine will not look at the glyph until the content classifier has already said title_*, not because the threshold discriminates. ⚠️ glyph() alone is not a plate detector; a two-frame control over a 3½-minute movie is not a control over that movie.

  • 🔴 screen_id.py cannot see a plate-less title, and calls DIFFICULTY a menu. Both reproduce on committed reference frames:

    frame screen_id.py says should be
    live-title-build4-no-plate.png other title
    live-title-press-a.png title title
    difficulty-screen.png menu not the main menu

    It thresholds on green (0.0009 with the plate vs 0.0002 without), so it recognises a title only once PRESS Ⓐ has faded in — and this corpus's own finding is that the boot title shows build 4 FIRST, plate-less, for ~2.25 s. ⚠️ Any harness that waits for title from it can sit through a visible title and report nothing — that is what happened on an S00A drive here, 396 s of other with two spurious menu hits, on a run whose audio proved the guest was healthy throughout. newgame_path.sh, nav_probe.sh and boot_menu.sh all gate on this. The zncc-against-committed-frames classifier used for the settle-time screen log does not have either defect: 6/6 including both movie frames and difficulty-screen as negatives, at a 0.85 threshold. ⚠️ At 0.60 it also called difficulty-screen a menu (0.632) — the threshold is doing real work and must be controlled, not chosen.

  • Classify screens by whole-image statistics, not named pixels — a named pixel is only valid while the image sits at a known place, and nothing errors when it moves.

  • Do not poll faster than the guest updates — it manufactures a clean curve out of noise.

  • A probe that never performs the action will "prove" the action does not exist.

  • The container's Canary binary can be older than the Canary source tree, and the failure mode is a hang, not an error. After a merge into sylpheed-re the prebuilt xenia_canary had no log_ui_draws, no mem_watch, no create_profile_if_none — and an unknown cvar makes xenia open an SDL message box before logging is up, which headless is an unexplained freeze. Check before trusting a harness flag: nm -C <binary> | grep cvars::<flag>, and build-canary Release if it is missing.

  • A capture armed at a screen only ever sees the steady state. Anything about how a screen is built or animated has to be armed before it exists. Re-arming every few seconds and keeping every log tiles the approach: each F10 opens a new numbered file and closes the previous one complete.

  • A capture stream that opens N seconds after launch will report the boot in the wrong order, and nothing errors. data/boot-timeline-2026-08-29.tsv opens on the developer splash and labels the publisher one 6 s later, which reads as dev → pub and is the opposite of the boot. The stream had attached ~7.7 s in and missed the publisher entirely. The tell was in the file: its first twelve rows are byte-identical to four decimals — one held frame sampled twelve times, i.e. the probe joined a screen already in progress rather than watching it arrive. If t = 0 is not the launch, say so in the file; if the first rows do not change, you did not see the beginning. (boot-order-and-splash-dwell.md)

  • ADV.wmv opens with its own SQUARE ENIX card, and it scores 0.75 against the publisher splash. A correlation classifier keyed on live-splash-publisher.png therefore fires twice per boot, ~10 s apart, and the second one is a movie frame. Discriminators that work: the real splash is perfectly still (identical frame statistics for seconds) and scores 0.930.94; the movie card drifts continuously and never passes 0.76 — and its wordmark is bloomed and below centre where the splash's is sharp and centred. A threshold that both a screen and a movie frame clear is not a classifier; look at the frame.

  • Measure animation in submitted frames, not in seconds. VdSwap counts are the guest's own frames, so an emulator at 80 % of real time does not move them; a stopwatch reading does, silently and by an unknown factor.

  • ~This game's menus drop d-pad presses shorter than 0.3 s. WITHDRAWN 2026-08-28 — the menu WRAPS, and I had not measured that. The claim came from reading a cursor that ended up "one item short"; once wrap-around at both ends was measured (menu-navigation-semantics.md), every one of those press counts is exactly right — four presses at 0.12 s moved four steps through the bottom, which lands one above where a non-wrapping menu would put it. No press was ever dropped. The real lesson is the general one: a step count is only readable once you know the topology, and I invented a hardware-flakiness story rather than testing the ends of the list. Still true and worth keeping: screenshot after every step and read the cursor, rather than trusting arithmetic over the press count.

  • A screenshot taken right after a transition can catch a screen mid-fade. A grab 2.5 s after Ⓑ returned the game to the title showed the title art with no PRESS Ⓐ BUTTON plate; one second later the plate was there. That very nearly went into the corpus as "the returned title has no plate". Sample a changing screen several times before writing down what it does not contain.

  • Do not identify a menu cursor by label brightness. The obvious oracle — "the focused label is the brightest row" — fails on this game's menus, because the background art is brighter behind some rows than the highlight is. It confidently named the wrong item on a frame whose ring was plainly elsewhere. Detect the focus ring in the gutter left of the labels instead (tools/re-capture/menu_focus.py, 254 vs <82 — no threshold tuning needed), and look at the PNG before believing either.

  • screenshot samples at 0.5 Hz — it cannot time an animation. Measured: ~2 s per grab (an import of the root plus an ImageMagick crop). A 0.4 s fade falls entirely between two samples, which is why a 40-frame burst across a screen change looked like an instant cut. For anything timed, record the display instead: ffmpeg -f x11grab -framerate 30 -video_size <W>x<H> -i :98+<X>,<Y> -t <secs>, then read per-frame statistics off the file. Take the geometry from xwininfo -root -tree, the same way bin/screenshot does.

  • A screen's brightness curve is not its fade quad. The incoming screen's own elements animate in after the transition quad has cleared, so mean luminance keeps rising long after the fade is over — 1.47 s against a declared 0.97 s on one screen. Time the fade from where the frame is pure black, and take the ramp itself from the keyframes.

  • A sound.pak entry is not a bank. Entry windows straddle bank boundaries (the leading-region effect), so a naive "walk the RIFFs in this entry" gives the tail of the previous bank, then one of your own, then the next bank's header. It looks like a bank with the wrong number of waves. The tell is a BANK id= that is not the entry's own number, and the fix is the seek packet counts — they pin each wave's join exactly, so a straddle can be realigned rather than thrown away. 4 of 32 BGM banks look like counterexamples until this is applied.

  • Do not time a video whose start you did not see. An attract cycle sampled from partway through measured "≈85 s", which was used to argue it was not the 137 s ADV.wmv. It was ADV.wmv; the sampling began 39 s in. Identify a video by content — match frames against the candidates and check the matched playhead advances monotonically — before reasoning about its duration.

  • A near-black frame has no signature. In frame-matching, low-contrast frames drive every correlation toward zero and the ranking becomes noise; one control frame scored 0.000 and another tied the wrong movie at 0.98. Read a dark frame's runner-up as no evidence, not as evidence for the runner-up.

  • A screen that never changes is not necessarily hung — it may be a menu. A 90-second screenshot loop that presses nothing will report a perfectly healthy DIFFICULTY menu as a standing hang, and that reading survived in this corpus for months. Before calling a static screen a hang, look at it, and press something.

  • Never run ps -ef in this container. Each of the three long-lived processes carries the whole loop prompt as its argv, so the listing is tens of kilobytes of the prompt you already have. Use pgrep -x <name> or ps -o pid=,stat= -C <name>.

  • A file extension is not a format. sound.pak is full of .slb banks and names a .xgs project, and neither is an XACT container: the whole 1.08 GB archive contains zero XGSF, SDBK or WBND magic, and the executable has no XACT string at all. The names came from the authoring tool and survived into the shipped data. Check for the magic before committing to a parser for the format the extension advertises.

  • A log-polling probe must compare a COUNT, not test for presence. Timing an operation by waiting for its "done" line in a log gives an instant false positive if any earlier run left one there. This produced a confident "39.5 fps" that was really 0.1 s of matching a stale line — and it disagreed with the true 28.5 fps by enough to have flipped a conclusion. Snapshot log.count(marker) before starting and wait for it to increase.

  • This container kills long emulator runs. Twice in one session the xenia_canary process vanished with no crash line in its own log — once ~50 s into a boot, once ~145 s in at 1.15 GB RSS — while the box sat at ~1 GB free with swap exhausted. Budget dynamic experiments to fit in ~2 minutes of guest time, take the measurement early, and never read a disappearance as a guest crash without checking the log tail for one.

  • When a frame-match will not resolve, sample denser before doubting the hypothesis. Identifying a movie from 5-second screenshots gave contrast-23 frames, a playhead that would not join up, and a runner-up winning one frame — it looked like weak evidence for the wrong film. The same question sampled every 0.5 s (ffmpeg x11grab at 10 fps) answered at 0.999 with a strictly monotone playhead. Sparse sampling does not weaken a signal gracefully; it turns it into noise that looks like a different answer.

  • Check what instrumentation the local build already has before declaring a question blocked on tooling. Two Q8 residuals were written off as needing "an emulator whose audio path can be observed". The emulator in this container already had xma_param_probe — visible in the startup CONFIG DUMP the whole time — added by this project for exactly that purpose. Read the cvar dump, or nm -C <binary> | grep cvars::, before concluding you cannot measure something.

  • "Strings a function references" is not "arguments a function passes". A loose filter over the strings in a caller produced a tidy-looking screen vocabulary; recovering the actual register argument at each call site removed half of it, including two names that appeared to be corroborated by independent measurement. If the claim is about an argument, simulate the registers — the cheap proxy manufactures agreement.

  • A backward scan for addi rN, … misses arguments staged through another register. PPC code often builds a pointer in a scratch register and moves it into place later (addi r27, r11, Xmr r5, r27). A backward search for the destination register alone found 7 of 48 sites and missed the two known-good ones. Simulate forward over a window instead, tracking lis/addi/mr.

  • Check a measurement against the WHOLE reference set, not the part you were reading. Two observed BGM stream sizes were recorded as matching "no bank's declared waves", which spawned a wrong theory that the decoder gets a window rather than a whole wave. They match BGM_103 exactly — the check had covered only the BGM_0xx rows of the census, because that is the block that had been on screen.

  • instructions.function is unpopulated for most rows in sylpheed.db. A query keyed on it returns nothing rather than erroring — a bctr search scoped that way reported "no jump tables" for a function with two known ones. Scope by address between <start> and <end_address> instead, and gate any such query on a function whose answer you already know.

  • Before testing "state X behaves like this", check that your route actually reaches state X. A test of "does Ⓐ work in phase 4 state 0" was run by pressing Ⓑ, on the strength of a decoded 4 → state 0 edge — but the same basic block also writes the phase field, sending it to phase 2. The test probed the wrong state and produced a confident refutation of a live hypothesis. When a transition sets more than one field, read the whole block, not the one store you were looking for.

  • A default filter can hide a whole screen, and the corpus will record it as absent. screen list/render filter on is_build, which needs a .rat child; the developer splash has none, so it never appeared — and a page ended up stating it "cannot be rendered at all". It renders fine under --all. When two pages disagree about whether something exists, run the tool both ways before believing either.

  • A logo that appears twice may be two different things. The boot shows SQUARE ENIX at ~2 s and again at ~10 s; the first is the splash screen, the second is the intro movie's own opening frame. Reading the capture alone gives "three logo screens". Cross-check a repeat against what is supposed to be playing at that moment before counting it as its own screen.

  • Do not hard-code an expected element count into a structure scan. Looking for a .rat record's keyframes, I required 29 strictly-increasing times because a header word read 30. The records hold three keyframes in exactly the layout I was testing for, and the over-constrained filter reported the layout as refuted. Scan for the shape (here: 0x??ffffff fade words at a constant stride) and let the count fall out.

  • Read the target text before writing a patch pattern for it. Two iterations running, a scripted edit to HANDOFF.md failed its assertion because the paragraph had been rewrapped since the pattern was written — and both times the finding landed in docs/re/ while the page the port agent reads kept the stale version. The commit's file count is the tell: if it is one short, an edit was silently dropped.

  • Two renderers agreeing is not evidence the field is right. The port's composite and sylpheed-cli screen render matched on main_menu to 3/255 — and both omitted two elements the game draws, because both read one field through one decoder. Agreement between consumers of the same decode measures the decode's determinism, not its truth. Only a framebuffer capture is an independent check. (Reported by the port agent, 2026-08-28, after a capture crop found the missing bracket.)

  • ./target/debug/<bin> is STALE in this container — CARGO_TARGET_DIR is redirected. The Dockerfile sets CARGO_TARGET_DIR=/sylph-home/re/target-container so a fresh cargo build writes there, while ./target/debug/sylpheed-cli remains whatever was built on the host, hours old. A decoder fix was verified against the stale binary and produced byte-identical "before" and "after" renders — which reads exactly like "the change had no effect" rather than "you ran the old code". Run the binary cargo actually wrote, or cargo run.

  • A pivot-equality test conflates rounding with real breakage. Checking pivot == texture/2 over GP_TITLE flagged 74 % as mismatched, which reads as a systemic decode failure. Split by magnitude it is 17 exact, 54 off by ≤ 1 px (odd dimensions rounding), and only 24 off by > 8 px — and those 24 are the real finding, concentrated on one element family. Bucket a mismatch by size before reporting a rate.

  • I have now broken the "read the target text first" rule three times. Each time a scripted HANDOFF.md edit asserted on a paragraph that had been rewrapped by an earlier edit in the same session, and each time the finding landed in docs/re/ while the port's page kept the stale text. Writing the rule down was not enough; the working fix is mechanical — grep/sed -n the target paragraph in the same command that patches it, and check the commit's file count.

  • "The nearest header after the name" is not a name→header mapping. Looking for each sprite's T8aD header by searching for its name and taking the next T8aD returned the same header for every sprite — all the names live together in the declaration table near the top of the bundle, so "next" is always the first one in the file. It failed its own control instantly: different sprites reported identical dimensions. Match on something the header itself carries (here width × height against the decoded PNGs) rather than on proximity.

  • cargo build passing does not mean cargo test compiles. Adding a field to T8adImage built the library fine in 1.48 s — and broke two test-only struct literals, so cargo test failed to compile with exit 101. A green build on the binary is not a gate for a struct change; run the tests before believing a data-structure edit is safe.

  • Detach long background work with setsid, or a tool-call timeout kills it. Three attempts to run the disc-gated test suite in the background died at exit 143 with an empty log — the timeout's SIGTERM reached the whole process group. setsid nohup cargo test … & survives, and the run then completes across later tool calls. An empty log from a "background" job usually means it was killed, not that it produced nothing.

  • Validate a recommendation before leaving it as advice. Having concluded a question was undecodable from the disc, I named a per-draw GPU capture as the next route "because it reads the blend state". It does not — checking the capture's own source showed it records textures and vertex attributes and no blend registers. A named next step is a claim like any other.

  • Name the element from the draw's own coordinates before building on it. A skewed draw in a title capture was called "the swoosh" because the swoosh is the screen's only diagonal element — an identification by elimination, never checked against the draw's NDC extent. It was wrong: the quads span the full screen and beyond, the swoosh is a 234-pixel band. Five iterations of analysis pointed at the wrong element on the strength of it. Converting the coordinates takes one line and should come first.

  • A census over a bundle's top-level table is not a census over the bundle. The keyframe rotation field read "always zero on this screen" for several iterations because every scan walked GP_TITLE build 4's declaration table, and the rotated elements are nested leaf records reached through an opt link. The bytes were right; the reach was wrong, and a negative stated without its reach reads like a fact about the disc. Say which region a negative covers, and check whether the thing you are looking for lives outside it.

  • Do not assume 4-byte alignment when scanning raw bundle bytes. A nested RATC blob starts wherever the parent's chunk stream leaves it — ptloop01.rat sits at 0xbb5966 — so its 40-byte keyframe blocks are odd-aligned. A scanner that filtered candidates on %4 == 0 found 0/3 of its own control blocks and under-counted the corpus by 16 341 blocks, all of them nested. It cost nothing to catch, because the control was in the script.

  • pkill -f <pattern> matches the shell running it. pkill -f kfscan.py from a bash -c whose command line contains kfscan.py kills its own shell: the tool call returns exit 143/144 and the edit that was queued behind it never runs. The same trap makes pgrep -f self-report — a "still running? yes" that was the shell seeing itself, on a job that had already finished. Use pgrep -x, or match on a path the current command line does not contain. (This is the second ps/pgrep entry in this file; the first is about ps -ef dumping the loop prompt.)

  • Grep a legend and you count the legend. A sweep for elements with a rotation reported "1 element" in every build on the disc — the header line a=alpha r=rot° matched the r= pattern. A uniform count across heterogeneous inputs is the tell. Make the pattern require the value ( r=-?[0-9]+), and sanity-check that a known-negative build reports zero.

  • A pattern over the archives you happened to check is not a negative. I wrote "rotation appears to live only in nested leaf records", correctly scoped to the three paks I had swept — and the sweep refuted it on the fourth, within the hour, because the alphabet had not reached GP_DIALOG yet. Scoping the claim was what made it cheap to withdraw, but the better move is to let the sweep finish before writing the sentence at all: a negative that is still running is not a finding, it is a prediction.

  • A field that is implemented is not a field that is measured. The pivot-anchored scale term had been in blit for months and reasoned about in the handoff, and no capture had ever exercised it, because every element anyone had looked at was at 100 % scale where the term is identically zero. Look for the case that makes a term large — here 600 % and 800 % scale, worth 450 px — and check it there. A term you cannot distinguish from zero has not been verified by any amount of agreement.

  • Printing a series' minimum and reading it as its range. I summarised a captured alpha series as "constant α ≈ 33" and built a contradiction on it — the summary printed min_alpha and no maximum, and the series actually ramps 34 → 255 → 33. The tell was there in the same table: the column beside it said 14 distinct colours, which a constant series cannot have. When a summary statistic and a distinct-value count disagree, the summary is wrong.

  • Calibrate on one element, test on another. Fitting a declared ramp to a capture has two free parameters (rate and offset) and will "succeed" against almost anything — my first attempt scored RMS 128/255 and I nearly read the numbers rather than noticing the search could not reach the ramp at all. The version that means something: fix the clock from element A's ramp, check that fix against A's own next landmark, then apply it to element B in the same frames with nothing left to tune. That is what turned "the shapes look different" into "still at 255 nine frames after it should read 32".

  • Two measurements can both be right and still disagree, when a heuristic sits between them. A change to the keyframe time association is favoured 26× by an animation measurement and rejected by a static-render comparison. Both are sound: rest() picks a pose by longest dwell, which is a heuristic layered on top of the times, so moving the times moves its tie-breaks. The mistake would be to treat one as refuting the other and drop the loser. Name what each measurement actually constrains — here timing versus pose selection — before deciding they conflict.

  • Before believing a render diff refutes a decode, find out which element moved. I reported a 13 % render change as evidence against a keyframe-time hypothesis, on a brightness argument about language twins. One element accounted for all of it, and that element is a transient flash with no resting pose — so the difference was our own rest() heuristic guessing, and it would have guessed under either reading. A whole-image statistic (pixels changed, mean luminance, correlation) tells you that something moved, never what, and an argument built on it can be entirely about your own code. Localise the diff to an element and read its keyframes first.

  • A fallback that only runs when its precondition fails is not a fallback. rest() picks the longest-dwell keyframe when no plateau exists — but a dwell gap is time spent moving between two poses, and the only case where an endpoint is genuinely held is when the two poses are equal, which is exactly the plateau the first path already caught. So the fallback is guaranteed to be guessing every time it is reached. When a heuristic's justification is the same condition that routes around it, it has no justification.

  • Siblings adjudicate a rule that a single element cannot. Deciding what rest() should return for an element with no held pose looked like it needed a capture we do not have. It did not: the developer splash carries three glows with identical structure and identical times, differing in one alpha byte, and any rule that makes one of them behave completely differently from the other two is producing an artefact. Parallel constructions in the same bundle are a free control — look for them before concluding a question needs new measurement.

  • Backgrounding with & in a compound command loses the working directory. cd X && cmd & followed by more work in the same call left the shell reset, and a heredoc two lines later wrote nowhere — while the echo after it still printed "written". A success message after a failed redirect is not a success. Use (setsid nohup … &) and keep the file-writing in its own call.

  • Read the whole function before describing what it does. I wrote "a zero-scale pose paints a 1-pixel sliver" into a tool's docstring, from the .max(1) in the size arithmetic. Two lines above it, a guard coerced scale == 0 to 100 %, so the .max(1) never saw a zero and the element rendered at full size — a much larger bug than the one I described. I had read the lines I went looking for and stopped.

  • Never diff a file a background job might still be writing. A comparison loop over an output directory reported GP_TITLE build 6 as changed; the pixel diff was then all zeros, because cmp had read a half-written PNG. Compare only after the job signals done, or skip the newest file. A "difference" that vanishes on re-examination is usually a race, not a flake.

  • A screen list BUILD index is not a pak ENTRY index. GP_TITLE happens to map 1:1 (16 builds, entries 015), which is how the assumption survived; GP_DIALOG does not — its build 0 is entry 2. Indexing a pak directly with a build number silently reads a different bundle and reports a confident wrong answer: a census control asked for the two rotated elements of GP_DIALOG build 0 and got zero, from a bundle that genuinely has none. screen list prints the mapping; use it rather than assuming identity.

  • "There is no flag" is not "there is no way". I declared a Japanese-locale capture impossible in this container because user_language is DECLAREd and never DEFINEd, so no cvar exists to pass. That was true and not the question. The language is persisted: canary builds XConfig over <storage_root>/xconfig.settings, SetDefaults() only supplies the value when the file has none, and the file is writable. One grep further — for where the setting is stored rather than where it is configured — turned a "blocked, needs a human decision" into a two-line edit. When you conclude a capability is absent, check the persistence layer before writing it up.

  • Locate a binary field from struct landmarks, not from a hard-coded offset. user.language was found by scanning for music_volume's 0.7f (a known default at a known relative offset), then cross-checking language == 1 and country == 103 against the emulator's own SetDefaults(). Three agreeing landmarks, no offset assumed, and the check re-runs every invocation — so the tool fails loudly if the layout moves instead of silently patching a neighbouring field.

  • A stale oracle reports the world, not itself. A 787 s run concluded "the game never reached the title". It had, repeatedly: wait_title.sh was still sampling the single pixel (625,618) that is_title.py had already been written to replace — a 1280×720 coordinate against a 1279×675 game surface, always reading the copyright line. The replacement existed in the same directory, with a docstring naming the bug. Before believing a negative from a probe, check whether something in the toolkit already supersedes it, and run the probe against a known-positive capture first — is_title.py scores 753 on a real title frame and 327 on the main menu, which takes one command to confirm.

  • Run the control before theorising about the difference. A Japanese-locale run failed to reach the interactive title, and I wrote up the open question as "if English reaches it, the difference is the locale". English does not reach it either — 75 samples over 734 s, every one a miss. The locale had nothing to do with it, and two iterations framed the problem around a variable that was never implicated. The control cost one run and would have cost the same one iteration earlier.

  • Log every sample, not just the hit. The first probe printed nothing until it succeeded, so a 787 s failure was indistinguishable from a probe that never ran. Printing the oracle's value each tick turns "no title" into "75 samples, max glyph 0", which is a measurement — and it is what showed the black-screen and attract-movie phases were being sampled correctly all along.

  • Know when to stop paying for a non-blocking answer. A capture that would settle two 🟡 residuals — neither of which blocks the deliverable — absorbed five iterations. Each one produced a real finding (a broken oracle, a wrong "impossible", a locale red herring), so none was wasted, but the question never moved. The signal to stop is not "this is hard", it is "the thing this unblocks is not blocking anything". Write down the recipe you built and leave it for a session with a reason to spend the time.

  • Audit what a renderer silently SKIPS, not only what it draws. compose has half a dozen continue paths, and a sprite-name miss is one of them — an element vanishes with no error, which is exactly how the port agent's missing bracket happened. screen render already prints its omissions; reading that list for the five screens that matter took one command and turned up a full-screen opaque element dropped on three of them. Any composer with skip conditions should be asked to name what it skipped before it is trusted.

  • An element's declared name is not necessarily its texture's name. The opt link on pteff05.t32 leads to a T8aD registered as 8AX, so a lookup by declared name misses. Before concluding a texture is absent, look at the bytes the link actually points at — the name in the declaration table and the name the sprite table keys on can differ.

  • A fit whose residual is large for every model is a broken method, not a close call. Comparing our composite to a capture pixel-wise gave mean abs errors of 1014 for gamma, for a linear scale, for everything — and a non-monotonic transfer curve (render 96127 mapping brighter than render 128159). The cause was edge misalignment: at correlation 0.947 a bright pixel in one image routinely lands on a dark one in the other. Restricting to patches that are flat in both images dropped the residual to 0.21.1. When every candidate model fits badly, stop choosing between them and look at what the comparison is actually measuring.

  • Say when a control failed to discriminate, rather than reporting it as a pass. The held-out screen for a tone-curve fit was the developer splash, whose flat regions are pure black — every model scored ≈ 0.00 error there. That is not corroboration; it is a test with no power, and reporting the 0.00 as agreement would have dressed an untested claim as a verified one.

  • Time your probe against the thing you are probing for. Four runs concluded "the game never reaches the title". screenshot costs 10.8 s while xenia is running and 0.117 s once it is killed — 92× — so a two-grab polling loop samples every ~41 s, against a title screen this corpus documents as lasting a few seconds. The harness was blinking slower than the event. Before believing a negative from a polling loop, measure its interval and compare it to the duration of what you are waiting for; and measure the probe's cost under the same load as the run, because idle timing here was off by two orders of magnitude.

  • Do not change a display setting and a capture behaviour in the same run. kernel_display_gamma_type = 0 brightens the frame, and skip_intro.sh classifies movie-vs-static on an absolute rmse threshold — so the gamma change biased the very classifier the run depended on. Harness thresholds tuned on one output configuration are not portable to another.

  • Fixing the instrument is how you test the explanation that blamed it. I diagnosed four failed runs as "the probe samples slower than the event", wrote it up, and withdrew three earlier conclusions on that basis. Building the faster probe refuted it: at 100× the sampling density, over 420 unbroken seconds, the event still never occurred. The diagnosis was a real defect that happened not to be the cause — the most seductive kind, because fixing it feels like confirmation. A plausible mechanism that explains a failure is a hypothesis, and the fix is its experiment, not its proof.

  • Ask whether the screen is drawing anything before explaining why a feature of it is missing. Four iterations produced four explanations for "the title is not detected" — a stale oracle, the locale, the sampling rate, a one-shot boot window — and three of them were refuted in turn. Splitting a raw root grab into bands then showed the game surface at 0.08 % non-black, mean 0.07: it was rendering black. The check costs one command and belongs at the front of that sequence, not after the fourth hypothesis. A detector reporting absence is ambiguous between "the feature is not there" and "nothing is there".

  • A newly found fault does not retroactively explain older failures. The black surface is real and reproducible in the run where it was measured. Earlier runs in the same session had measurable content (mean 33.1, mean 122.8, non-zero frame-to-frame rmse), so the failure mode changed over the session. Reaching back to re-explain earlier results with the newest cause would have been the fourth over-reach in a row.

  • Validating a probe on static test images tests its ANALYSIS, not its ACQUISITION. The fast probe's counter was controlled against committed captures and matched exactly (753 / 327), which proved the numpy expression right and the frame source untested. The source was the broken half: a long-lived x11grab stream degrades from 3.98 to 1.60 fps and then freezes, repeating one stale frame. The check that catches it is to read the same screen with an independent grabber at the same momentimport said 125.65 where the stream said 5.21. Do that during a run, not only at design time.

  • A dense negative from one instrument is worth less than one cross-check. "2 391 frames, zero hits" reads as overwhelming and collapses to nothing once the frames may all be the same frame. Sample count is not evidence of coverage unless the samples are known to be independent.

  • A probe that cross-checks itself turns "no result" into a result. Three successive "the title never appears" claims were withdrawn because the instrument was broken each time. The version that finally held prints its own reading beside an independent grabber's every 60 s — 9 checks, 8 agreeing to within 2 % — so the negative arrives with its own evidence that the frames were live. Building the cross-check into the tool costs a few lines and is what separates a measurement from a silence.

  • A hedge in a code comment is an unmeasured claim. compose said the derived paint order "reproduces both measured orders up to ties" — a sentence that sounds like a result and was neither measured nor kept current: there were three measured orders by then, not two. Measuring it took one example program and turned a hedge into a bounded number (exact on 4 of 5 bundles; the 5th off by 8 pairs, all ties). Grep your own comments for "up to", "roughly", "essentially" — each one is a claim nobody has checked.

  • Count the cases that can actually bite, not the cases that match the pattern. EXTRAS has 15 tied pairs in its derived paint order, which reads alarming. Only 2 of them overlap, and a tie between non-overlapping elements paints identically either way. Reporting 15 would have overstated the risk by 7×; the useful number is the one filtered by whether the difference can reach a pixel.

  • An exhaustive field search needs a positive control, or "found nothing" is worthless. Scanning a header for a field that reproduces a measured ordering returned zero hits — which could equally mean the field is absent or the scan is broken. Running the same scan against a known ordering (declaration order) returned 64 hits, proving the scan finds ordering fields when they exist. Only then is the zero a finding. The control costs four extra lines and turns a silence into a negative with reach.

  • A stale row on the handoff page is worse than a missing one. The port was told SE audio was "undecodable from the disc" while the linked doc had already retracted that and located three cues decoding to PCM. The finding landed in docs/re/ and the page the port reads kept the superseded text — the fourth time in this corpus. Writing the rule down has not worked, so there is now a tool: tools/re-capture/handoff_lint.py flags every HANDOFF line that makes a strong negative claim and links a doc containing retraction language. It found this row, and one benign false positive, on its first run.

  • Narrow a risk before reporting it, then narrow it again. "15 tied pairs on EXTRAS" became 2 (only overlapping ties can paint differently), then 1 (one of those two is a loop* animation the compositor skips), then "consistent with the capture". Each step is a filter that costs one query and changes the number by more than an order of magnitude in total. A raw count is almost never the number a consumer needs.

  • Nothing was checking that the docs' cited evidence exists. A sweep of every relative link under docs/ found 16 broken, and two of them were the figures backing the UI layout decode's headline claim — the port's foundation, unreachable from its own page, because a path in structures/ was written one directory too shallow. Eleven were wrong relative depth with the target present; five name files that do not exist. Evidence that cannot be opened is not evidence, and a link is exactly the kind of thing no one re-reads. tools/re-capture/doc_link_check.py now does it, and also flags targets that resolve to a zero-byte file — which looks correct in every listing.

  • Repair in bulk only when the counts pair. The fixer rewrote 11 links; the checker went from 1 038 resolving / 16 missing to 1 049 / 5. +11 and 11 against 11 edits is the confirmation that the pass did what it said and touched nothing else. A bulk edit without that arithmetic is a hope.

  • When two candidates carry the same content, compare their DIFFERENCE against the oracle, not themselves. 8AX (1280×720) and ptbase (640×360 at 200 %) are the same artwork, so no comparison of either against a capture can separate them — every such test had been read as "inconclusive, needs a per-draw capture". What separates them is 8AX upscale(ptbase): the detail only one of them has. Correlating the capture's residual against that answered it statically, on two screens, with matched controls.

  • A ratio that saturates at 1 under noise is not a discriminator. A pixel-pair test cleanly separated upscales (0.000.72) from native (0.98) and put the capture at 1.01 — apparently decisive. Additive noise raises both terms of the ratio equally and drives any value toward 1, and fitting a noise term showed both hypotheses reproduce the observed numbers. Before believing a ratio, ask what it does as noise grows.

  • Read what a cvar DOES before building an experiment around it. I planned a run to decide whether a measured gamma was "canary's BT.709 output stage", by setting kernel_display_gamma_type = 0 and re-fitting. The cvar is not an output stage: it is the value a kStub getter returns to the guest, which the game uses to build its own ramp, and canary applies that ramp in the swap path. The experiment could never have isolated a stage that does not exist — and it would have perturbed the capture harness as a side effect. One grep for the cvar's definition replaced a planned emulator run with a better one.

  • An absence in a log is only evidence if the log would have shown it. No gamma lines appear in any run log here — but kernel exports log at Debug and this harness masks Kernel logging entirely (log_mask = 13), so their absence is guaranteed regardless of what the game did. Check the logging configuration before reading silence as a result.

  • Check what an experiment actually needs before filing it behind a blocker. The gamma question sat parked behind "needs the emulator to reach a menu" for several iterations. It needed the emulator only to boot — video init, and the VdGetCurrentDisplayGamma call, happen in the first seconds, long before any title screen. A blocker that stops one experiment does not stop every experiment in the same area, and it is worth re-reading the parked list against what each item really requires rather than against the area it belongs to.

  • A default value is evidence. Whether the game writes a gamma ramp looked like it needed a GPU trace. It mostly did not: canary initialises the ramp table to identity and applies it through a shader that is a pure LUT lookup with no other transfer. An unwritten ramp is therefore a no-op, and any non-identity transfer in the output implies a write. Reading what a field holds when nobody has touched it turns "I cannot observe the write" into "the write must have happened" — cheaper than instrumenting, though it stays an inference and should be labelled one.

  • Name the weak joint of an inference in the same breath as the conclusion. The chain above assumes our composite reproduces the pre-ramp framebuffer, which is the one step that could be wrong. Writing that down beside the conclusion — rather than only the supporting facts — is what lets a later reader attack it instead of inheriting it.

  • The startup config dump is the config FILE, not the effective flags. A run passed --log_mask=12 --log_level=3 and its dump printed log_mask = 0, log_level = 2 — while Kernel Debug logging was demonstrably on, which is how that run's finding was obtained. So a dump can neither confirm nor refute a command-line override, and reading one as "my flag was ignored" is a mistake I nearly made with trace_gpu_stream. Verify a flag by its effect, not by the dump. (This does not undo the earlier user_language conclusion: absence of a name from the dump still shows the cvar is unregistered.)

  • kill -9 destroys anything that finalises on shutdown. Canary starts a GPU trace at init but only closes it in GraphicsSystem::Shutdown(), so the hard kills this session used routinely could never have produced a trace. Before concluding a feature is broken, check whether the way you stop the program is what discards its output.

  • Bound a risky experiment from inside, not by watching it. A boot-time GPU trace on a disk at 95 % could have filled it between two tool calls. The runner carried its own watchdog that killed the emulator the moment the output passed a 2 GiB cap, so the experiment was safe regardless of how coarsely I polled. The watchdog never fired, which is the point — it cost nothing and removed the need to gamble on timing.

  • A cvar existing does not mean the feature is compiled in. trace_gpu_stream parses, is documented, sets state, and does nothing: the code that opens the trace sits behind #if XE_ENABLE_TRACE_WRITER_INSTRUMENTATION == 1, which trace_writer.h ties to #ifdef NDEBUG — off in release. Two runs and two wrong hypotheses before reading the #if. When a switch produces no effect, follow the code from the switch to the output and look for a compile-time gate before theorising about runtime causes.

  • Test a compile-time gate against the binary, with a control. A string that exists only inside the guarded block (_stream.xtr) settles it in one command: 0 occurrences in the release binary, 1 in the debug binary. The debug build is the control that proves the test can find the string when it is there — without it, "0 occurrences" is just as consistent with a bad grep.

  • T8aD headers sit in RATC child order — use that, not the size, to name one. Attributing a sprite header to a sprite name by matching decoded dimensions fails whenever two sprites share a size, and GP_TITLE build 4 has such a pair (ptlogo_back2eff / ptlogo_back2eff5, both 1133×280) that differ in exactly the flag being studied. Header order matches child order — verified 18/18 on that build against the dimensions as an independent check — so ordering resolves what size cannot.

  • Identical summary statistics are not identical data. Two same-sized sprites reported the same %opaque, %clear and mean alpha to one decimal, which reads as "the bundle stores this texture twice". Pixel-comparing them gives max abs diff 21: two different renditions of one image. Three matching summaries over 300 000 pixels is weak evidence of identity; the comparison costs one line.

  • A pattern that is perfect on one screen can be near-chance on the disc. The 0x02 bit matched eff names 10/10 on GP_TITLE build 4, which is exactly the sample size that makes a rule feel found. Disc-wide it holds 46.8 % of the time — the build's artists simply named their effect sprites consistently. An 18-element bundle cannot distinguish a format rule from a local naming habit; before reporting an implication, run it over the corpus, not the example that suggested it.

  • When an association survives a refuted implication, the counterexamples are the finding. The bit is 3.3× enriched for eff names and the sprites that break the rule are pv_loading_ring0, pv_loading_light03, px_bunk_line — rings, glows, lights, thin lines. That the exceptions are all effect-like artwork without the naming convention says more about the field than the rule it broke did.

  • Report a classifier's lift over its base rate, not its accuracy. The 0x02 bit's association with bright-RGB/low-alpha art gives a best single threshold of 76.5 % accuracy — which sounds like a finding until you notice that always guessing the majority class scores 64.1 %. The lift is 12 points, the distributions overlap, and it is a tendency rather than a rule. An accuracy quoted without its base rate is not interpretable.

  • Park a field after N failed hypotheses, and say what was eliminated. Four candidate meanings for one flag bit were each refuted at a cost of roughly an iteration apiece, and the bit blocks nothing measurable. Stopping is the right call, but only if the negative space is written down — additive blend, name, lifetime, premultiplied alpha — so the next attempt starts where this one ended rather than at the beginning.

  • Nothing was checking that a doc's figures match its committed data. A number is written once from a run and then lives in prose that gets edited around it; the data file beside it is regenerated independently. All 19 headline figures across four censuses do currently agree (tools/re-capture/doc_figure_check.py), which is worth knowing rather than assuming — but the checker had to be written numerically, because the first version grepped for the doc's formatting (14 709 with a thin space, 33.7 rounded from 33.66) and reported almost every figure as a mismatch. A consistency check that fails on formatting will train you to ignore it.

  • A detached job you never check can outlive many iterations. Two setsid nohup cargo test … & runs from earlier iterations were still alive four hours later, one child at 89 % CPU for 3 h 26 m, holding the load average at 14 on 12 cores. setsid was added precisely so a tool-call timeout could not kill them — which also means nothing kills them. Check that a backgrounded run actually exited before launching the next one, and prefer ps -o etime= over assuming.

  • Know whether your verification gate can terminate. build-reborn test includes a disc test that decodes 166 .xpr files (1.4 GB) and had not finished after 3 h 26 m of CPU. Every "green run" from it is partial unless the suite is stated to have terminated — so report the suite count and elapsed state rather than the word "green", which is what I should have been doing all session.

  • "Cannot finish" and "takes an hour" are different claims — measure before choosing. I wrote that the verification gate cannot terminate, from having watched it run 3 h 26 m. Timing the work directly: 19 of 166 containers exceed 25 s, Stage_S02 completes in 144 s, and one full pass is ~4560 minutes. Nothing hangs. The 3 h 26 m was that hour of work at a load average of 914, inflated by my own duplicate runs. A slow thing observed under contention looks like a stuck thing, and the correction matters: an hour-scale gate can be run deliberately, a hung one cannot be run at all.

  • Separate the question with evidence from the question tangled with it. The keyframe-time reading was held back for several iterations by one objection: adopting it changes rest() on a single element. But rest() reaches the times only through a fallback that is unsound whenever it runs, and the shift does not fix that fallback either — so the objection was never about the times. Timing had three discriminating measurements; pose-selection had a heuristic guessing. When a conclusion is blocked, check whether the blocker is actually evidence about the same thing.

  • A predicate over adjacent PAIRS silently misclassifies a one-element list. "Has a plateau" was implemented as any two adjacent keyframes share a pose — which is false for a single-keyframe element, so 1 502 static elements were counted as having a guessed rest pose and the published defect rate was 65 % too high. The error only surfaced when acting on it: suppressing those elements dropped the title's correlation by 0.27, because they include the backgrounds. Whenever a rule quantifies over pairs, ask what it says about a list of one.

  • Acting on a claim is a better test of it than re-reading it. The single-keyframe flaw survived a disc-wide census, a write-up and a handoff row. It died the moment the rule was used to change a rendering, because the result was visibly worse. If a measurement implies an action, take the action on something you can score.

  • Score a rule where it can actually differ, or you will measure nothing. Three rest-pose rules rendered builds 4, 5 and 6 to identical correlations — not because they agree, but because the code they change is unreachable on those screens. The signal was on the two splashes, the only builds whose elements reach the fallback at all. Identical results across variants is a clue that the variant is not being exercised, not evidence that the choice does not matter.

  • An argument from symmetry is a prediction, not a refutation. I killed the last-keyframe rule because it treats one of three sibling glows differently, which felt like an artefact. Measured, it is the better rule on both screens where it applies. Aesthetic expectations about how authored data "should" look are worth stating as hypotheses and worth nothing as verdicts.

  • A blank render is a correlation of NaN, not a low score — notice which. Applying "rest = last keyframe" to every element made both splashes render with zero variance, so the correlation was undefined rather than poor. Reading the NaN as "the tool failed" would have hidden the result; it was the result, and the strongest form of it. Check for degenerate output before treating a missing number as a broken measurement.

  • When a model predicts something and the measurement refuses, the model is usually incomplete rather than wrong. The shifted time reading says when each pose is reached, which looked like it implied the final pose is the resting one. It does not: it says nothing about whether the group is played to completion while the screen is still up. The exit is the dismissal, so a displayed screen never reaches its last keyframe. Both claims survive; what was wrong was the step between them that I supplied.

  • The control is what turns a helpful edit into a finding. Suppressing the _eff glows raised both splashes from ≈0.96 to ≈0.998 — on its own that reads as "we over-draw glows". Running the same edit on the three screens that settle made them worse (0.002, 0.092, 0.107), which is what makes the result mean something specific: the over-draw is confined to transient screens, where a plateau mid-animation is not evidence the element is on screen at rest. An edit that improves one set of cases is only interesting once you have shown it damages the cases where it should.

  • When a model reproduces durations but not positions, the missing piece is an origin, not a rate. Playing the splash timeline matched every element's on-screen length to within 2 % while placing the elements in the wrong part of the run. That pattern names the gap precisely — the rate and the shape are right, so what is absent is a per-group start — and it pointed straight at the one undecoded word in the group header. (Which was zero, but the diagnosis was still what made the next step obvious rather than a search.)

  • Two elements with identical data and different outcomes is the strongest possible evidence that the decision is elsewhere. palogo_anima and palogo_gamearts carry byte-identical keyframe times; one is drawn for 95 frames and the other not at all. No amount of re-reading the placement data can explain that, and the pair is worth more than a survey — when a hypothesis says "the data decides", look for two records that agree and behave differently.

  • A shared resource address does not identify the resource's owner. The draw log's tex[base=…] looked like it would say whether two animation phases came from the same bundle. All three splash phases report 0x11C30000 — including the publisher splash, which is certainly a different bundle. The address is a reused upload slot. The control was free and sitting in the same table; without it the shared base would have read as proof of a shared bundle.

  • State the mechanism as a separate claim from the observation. "Declared elements are not what gets drawn" is measured and holds. "Because one bundle activates its elements selectively" was an explanation I attached to it, and it is not established. Bundling the two let the weaker claim inherit the stronger one's evidence.

  • Ask what the competing hypothesis would REQUIRE on the disc. Two explanations for the splash's disjoint phases looked separable only by a capture I could not take, and the capture-side test failed its control. The alternative needed a bundle declaring the glows without the logos — a four-entry enumeration showed none exists, and the question closed statically. A hypothesis that predicts an artefact can be killed by looking for the artefact, which is often far cheaper than measuring the behaviour.

  • Check what a "measured" value was measured from before reasoning about its limits. Two things that never co-occur have no observable relative order — true, and a real limit on any order read from a draw capture. I applied it to the splash's paint order and withdrew it hours later: that vector is a read of the runtime child array, which has a definite order whether or not its children are ever drawn together, and the two halves in question also carry distinct static layer keys. Both the provenance and an independent static field said the order was real. The general trap is not the co-occurrence rule, it is applying a source-specific limit to a value from a different source: the word "measured" covers several kinds of evidence, and the page that recorded it usually says which — ui-screen-runtime.md said "child slots" in as many words. Read that line before building an argument on top of it.

  • A heuristic that is right 99.9 % of the time still has a shape to its failures — find it before trusting the field. RATC child names were read by scanning backwards for the last printable run of bytes. That agrees with the format's own opt declaration on 17 918 of 17 942 children, which is the kind of agreement that stops people looking. The 24 exceptions were not random: all 24 are the same case, a 3-byte binary tail that happens to be printable ASCII (8AX), and one of them was the full-resolution background of every menu screen we care about. Ask what the format states before settling for what a scan infers, especially when the stated version is already decoded elsewhere in the same file — opt was being read for button focus links the whole time.

  • A continue that silently skips is a defect even when the skip is correct. compose drops an element whose sprite does not resolve. Two arms above it record the name into missing first; the el.sprite.is_none() arm does not. So a screen lost its background and screen render still reported "sprites that did not resolve: none" — the diagnostic was structurally unable to see it. When adding an early-out to a loop that already reports what it discards, make it report through the same channel, or it becomes a place findings go to die.

  • "It has no name" can mean "it is not a thing that gets named." Sixty RATC children had no opt name block and the open question was whether the block was absent or merely outside our search window. It was neither: the sixty are frames, ten each of six copies of one .tan animation, and one opt block names the whole run. The give-away was in the data before any hypothesis was — the distances back to the nearest tag were an exact arithmetic progression (213 + n·60600), i.e. ten different records finding the same tag. When a negative result's measurements come out evenly spaced, the thing you are counting is probably not the thing the format counts.

  • This container OOM-kills slb_leading_segment_disc under default test parallelism. It dies with signal: 9, SIGKILL and no assertion — eight threads each holding a slice of a ~1.1 GB bank. It is not a regression and not a flake, and it reproduces when run alone. -- --test-threads=1 passes 8/8 in 20 s. Before believing a SIGKILL in this repo, re-run the suite serially; before believing a pass, check nothing else heavy was sharing the box. ⚠️ And budget for mesh_consistency_disc: it takes 22 minutes (1 318 s measured, serial) and produces no output while it runs, so build-reborn test looks hung for a third of an hour. It is not. Two runs were killed for looking stuck before it was timed. If the change under test is not in the mesh path, build-reborn t -p <crate> scopes around it — note that build-reborn test itself passes --workspace and will ignore a -p.

  • Never measure a file another process is still writing. ffprobe on a half-written transcode reported 33 s against a 137 s source — the shape of catastrophic truncation, with no error and no warning, and it nearly became a filed bug. The tell was the mtime and a climbing packet count, not anything in the tool's output. Before comparing an artifact you did not produce, check that whoever produces it has finished; across two agents sharing a repo this is a race, not an edge case.

  • A difference-signal RMS means nothing until the two are aligned. Comparing a transcode against its source gave source RMS 25.3 dB and difference RMS 34.2 dB — only ~9 dB down, which looks like a failure. It is inconclusive: a one-sample offset makes the difference nearly as loud as the source. Any such test needs cross-correlation to align first and an agreed downmix, and only then is a pass mark like ">40 dB down" meaningful. Reporting the 9 dB as a result would have been a confident wrong number.

A shared CARGO_TARGET_DIR makes a worktree build replace the binary you run

CARGO_TARGET_DIR=/sylph-home/re/target-container is set for the whole container, so every checkout shares one target directory. Build anything in a git worktree — the obvious way to render from an old tag as a control — and the binary at $CARGO_TARGET_DIR/release/ is now the other checkout's. Cargo then considers your main tree fresh and does not rebuild it.

It cost three renders here that silently used a CLI with no --at flag, and the only reason it was caught is that the missing flag was a hard error. A stale binary that merely produces slightly different numbers would have been believed.

After any worktree build, touch a source file and rebuild before measuring anything — and prefer building the control's binary to an explicit --target-dir of its own.

rest() is one element's last hold, not the settled screen

Element::rest() picks each element's last hold keyframe independently of every other element, so a composite built from it is not the screen at any moment in time — it is a per-element maximum. For a transient this is exactly wrong: a two-frame flash's last hold is the flash peak, so rest() leaves it burning forever.

Five such flashes stack on the title and saturate the light arc; the band's error against the console was 33.22, and 8 581 pixels sat at the clipping level where the console has 1 459. Posing every element at one shared instant instead — the midpoint of the longest keyframe-free interval — takes those to 11.79 and 1 452.

The general trap: an aggregate computed per-element is not a state of the system. Ask what instant a composite claims to depict, and check that every element was asked the same question. See structures/ui-settle-time.md.

A 2D draw's identity is its geometry, not its bound texture

The title's sprites sample large shared texture pages, so the texture bound to a draw identifies a page and not an element. Matching a bound texture's dimensions against a decoded sprite's fails silently in both directions, and one pass here did both at once:

  • false negative — "none of the five flash sprites is ever drawn". They are drawn; they simply never appear as their own texture.
  • false positive — "ptbase2 (640×360) and pteff04 (1280×720) are drawn in frames 75105". Those frames are the intro movie, whose YUV planes and target happen to be 640×360 and 1280×720.

Re-run against the quad's vertex rect in design space and every element appears where the disc says it should. Canary's own capture code already carries this warning in a comment, and the corpus had already recorded that the settled title binds only 1280×768 pages — both were there to be read first.

The general shape: a coincidence of size is not an identification. Before matching on one attribute, ask what else in the frame shares it.

A batched draw merges quads, and the merge can be invisible

A GPU draw can carry several quads — indices=4 is one, indices=8 two, indices=24 six — and the UI draw log dumps only the first 8 vertices. Taking min/max over a log line's whole vertex list therefore silently merges quads into one bounding box.

This produced two wrong findings in one session, one of them reported to another agent with three alternative explanations "ruled out":

  • ptlogo_back2eff3 "is never drawn by the game". It is batched with ptlogo_back2eff4, and because the wipe family is right-aligned, eff3 (788…1196) lies entirely inside eff4 (447…1196). The union is exactly eff4's extent — so the merged box matched eff4 to 1 px, eff3 vanished, and nothing looked wrong.
  • "the developer splash is one composited quad." gamearts_eff and seta_eff merged into a box that was read as the bounding box of three logos — which it could not have been, since it was 259 px tall and they span 421.

Why the checks failed. Three hypotheses were tested and refuted — sampling phase, a draw with no geometry logged, a bad position guess. All three were aimed at the wrong failure. In particular the "invisible draw" check counted draws with no geometry line; the hiding place was draws with partial geometry, which was never looked for.

🔴 Refuting three wrong hypotheses is not evidence for a fourth. The confidence gained from "I ruled out everything I could think of" is worth exactly as much as the list was complete, and a list of failure modes assembled by the person who built the instrument is the least likely to contain that instrument's own blind spot.

Parse vertices in groups of four, one per quad, and compare the logged quad count against indices / 4tools/re-capture/quads_per_frame.py does both and warns on the shortfall.

⚠️ A related tell that was present and ignored: a merged box carries the first quad's vertex colour, which made one element's alpha read 255 / 127 / 254 on consecutive frames. That non-monotonicity was noticed, written down as "the vertex-alpha identity does not generalise", and not chased. An anomaly you explain away is cheaper to chase than to re-derive later.

Count the batch, not the quads the log happened to print

The UI draw log caps its vertex dump at 8 vertices — two quads — while a draw may batch many more (indices=24 is six). Two consequences, and the second is the one that bites:

  • a bounding box taken across a line's vertices merges quads (already recorded above, the eff3 false negative);
  • which elements appear in the log is the first two in the batch, and that set changes as elements fade. On the boot's developer splash the three glows occupy the prefix until t=45; the three wordmarks are invisible to the log until the glows stop being submitted. Read naively this says "the wordmarks are first drawn at frame 140", which is the logging prefix shifting and not the game.

That produced two splash spans 7.9 % apart on one boot of one guest — a quantity that must be one number. The fix costs nothing: indices / 4 is how many quads the draw actually holds, and the cap cannot touch it. Its transitions land exactly where the declared count of elements with alpha > 0 changes, which makes them free calibration points.

The general form: when an instrument truncates, the surviving sample is not random — it is the first N, and what falls in the first N is itself a moving function of the thing you are measuring. A truncated view looks like a complete view of a smaller set.

Before calling a failure unexplained, grep the corpus for its symptom

title-a-press-fault.md spent a session recording that a single Ⓐ faults the guest 4/4, and closed with "it does not explain how Q4/Q5 pressed Ⓐ successfully; what differs is unfound."

It was found, and written down twice, before that page existed.

  • canary-scripted-input-traps.md §3: "With no profile, Ⓐ is handled: the guest calls XamShowSigninUI and Xenia pops its Sign In dialog" — with a committed capture.
  • tools/re-capture/boot_menu.sh's header, which explains the swallow and quotes the 8.4 million figure, and is why that launcher passes --logged_profile_slot_0_xuid.

The fault page searched for the cause it had hypothesised — an unimplemented instruction, then a wild pointer — and never searched for its own symptom, which would have hit both immediately.

⚠️ Two lessons, and the second is the expensive one:

  1. Grep for the symptom, not the theory. "Ⓐ", "signin", "IsUIActive" were all in the tree.
  2. 🔴 Knowledge in a script header is invisible to the document that needs it. boot_menu.sh had the mechanism and the magnitude, and no docs/re/ page linked to it. A tool comment is a fine place to explain a flag and a bad place to be the only record of a finding. If a script comment is carrying a measurement, that measurement belongs in docs/re/ with the script pointing at it.

What the later session did add was the join — that this known input blackout is what drives the guest's unbounded keystroke queue into a failed 128 MB allocation — plus the guest code path and a host-vs-guest address retraction. A join between two recorded facts is a real finding; but it is much cheaper when neither fact has to be rediscovered.

…and its mirror: a finding with TWO records and nothing keeping them equal

The section above is about a measurement whose only record was a script comment, so the document that needed it could not see it. The port agent ran the same audit against its own tree and found the opposite failure, which is worth pairing here because the fix for one is the cause of the other.

Its voice-verification control was recorded in two places — a tool's control table and a prose document — and they had drifted: 53.3 % in the tool, 53.2 % in the doc, twice each. The control file was transient and is gone, so neither copy can be re-measured and there is no way to tell which is right.

⚠️ Both copies look authoritative. That is the whole problem: a single record that is hard to find announces itself as missing the moment you look; two records that disagree announce nothing at all, and a reader takes whichever they opened.

So the rule is not "write it down twice". It is:

  • one record, in docs/re/, for anything that is a measurement;
  • everything else cites it — a tool comment says why the flag is there and links to the page, and never restates the number;
  • if a number must appear in two places, one of them has to be generated from the other, not typed.

The port fixed its case by deleting the duplicate rather than picking a winner, which is right: with the evidence gone, choosing between 53.2 and 53.3 would have been authoring a measurement.

A pixel figure without its region and its threshold is not checkable

plate-pulse-measured.md published 159 / 714 / 1520 as the plate-absent floor and the pulse's two levels. The port agent holds the same capture, tried to reproduce the floor, and got 35× at every threshold it tried — because the page named neither the region (whole 1280×720 frame, not a plate crop) nor the predicate ((g>130) & (gr>45) & (gb>45), a three-channel test, not green > N).

⚠️ This is worse than an obviously incomplete number. A figure with no stated method reads as checkable, so a reader spends real effort failing to reproduce it and then has to decide whether the disagreement is theirs or yours.

And writing the method down immediately exposed a defect the prose had hidden: the floor came from a 1279×675 capture while the pulse came from 1280×720 frames — different crops, silently compared. The fix was a same-run, same-geometry floor that was in the series all along.

So: every pixel count states its region and its predicate, and a comparison between two counts states that they share a geometry. If they do not, that is a finding about the comparison, not a detail.

A fix that overshoots leaves no symptom until something else needs the part it disabled

From the port agent, and it generalises past its own case. Its static-overlay path was frozen at the overlay's arrival — a fix for a different bug that reached too far and stopped the overlay's clock entirely. Nothing noticed for a week, because nothing needed that clock to advance. The plate pulse is what finally gave it something to be wrong about.

⚠️ An over-broad fix does not fail; it goes quiet. The class of bug to look for is not "this is broken" but "this has been correct-by-inactivity since the day somebody disabled it". When a fix works by stopping something rather than correcting it, that is the moment to write down what has been stopped.

A detector that can fire on a single frame will fire on the wrong one

The Ⓐ A/B's first pair was void, and the reason is worth more than the result. The "wait for the title" step tested one frame against a glyph threshold. The intro movie throws green flashes of 1 298…5 433 lasting under a second, which clears any threshold the title also clears — so both legs pressed Ⓐ into the movie, about 6 s before the title appeared.

🔴 What makes this dangerous is that it looked like it ran. The presses were real and had a real effect: each skipped the rest of the movie, which is exactly what the corpus documents Ⓐ doing to a movie. Both legs then reported zero swallow and zero crashes — a clean, symmetric, entirely meaningless result. A void test that appears to have run is worse than one that errors, because nothing prompts you to look.

It is the same shape is_title.py already records for screen_id.py, which called the SQUARE ENIX logo "title" 151 s into a boot and spent skip_intro's one press there. The corpus has now paid for this twice.

The rule: a screen detector matches a signature over time, never a single frame. The fixed version requires 12 consecutive samples inside a band the movie overshoots — and, crucially, it was replayed against the void runs' own recorded series as its control, where it declines the flash at 84.8 / 85.5 s and fires at 93.9 / 94.7 s. A broken run's data is the cheapest possible control for its replacement; keep the series.

A demand for reproducibility can surface a defect that is not the one demanded

The port agent challenged this corpus's pulse figures as unverifiable — it had the capture and could not reproduce the numbers. The literal answer was small: name the predicate, and its counts then matched exactly.

But writing the method down is what exposed the actual defect: the floor came from a 1279×675 capture and the pulse from 1280×720 frames, silently compared across geometries. Nobody was looking for that.

⚠️ And both sides were wrong at once. The challenger's counts were the wrong measurement (single-channel, plate-crop) and the published figure had a real flaw. "One of us must be right" was never the shape of it — which is worth remembering before spending a round arguing about which.

A rule learned from a burn generalises to cases that LOOK like the burn, not to cases that share its mechanism

Contributed by the port agent, and it is the sharpest thing either of us has put in this file.

This file already carries two divisor bugs, both the same shape: a silent input sitting in a divisor and attenuating real signal. The lesson taken from them was roughly "be suspicious of dividing by N". So when the intro's three streams had to be combined, the port summed at unity — and its own checker rejected the tree at +2.62 dBFS.

🔴 The precedent did not transfer, and the surface shape is why it looked like it would. Both cases are "several streams, one output". But:

  • a BGM bank's two waves are stems of one signal — parts that were split apart and must be added back;
  • the intro's three streams are positions in a field — a stereo downmix weights them 0.4142 / 0.2929 / 0.2929, which sum to one whatever the assignment, so the total is fixed even when the placement is unknown.

Divide-by-N is neither right nor wrong in itself. It depends on whether the inputs are parts of one signal or parts of one field, and nothing in the phrase "several streams, one output" distinguishes those.

⚠️ The general failure: a rule extracted from a specific burn tends to be indexed by what the burn looked like rather than by why it happened. It then fires on the next thing with the same silhouette — and, worse, feels well-earned while doing it. When reaching for a past lesson, state the mechanism it turned on and check that mechanism is present, not the resemblance.

An internal check cannot catch a bug that makes the input smaller

resolve_movie_voice_region truncated the first stream of 17 voice regions for as long as it existed, and every test passed the whole time. There was nothing for them to catch: the region parsed, to_xma_riffs returned chunks, the chunks decoded, the durations were self-consistent. A missing third of a stream produces smaller valid output, and no check written against our own output distinguishes that from correct output.

What caught it was a number from outside: the running decoder reports its XMA contexts' byte_size, and 3 584 000 did not fit in a 3 114 352-byte region. The port agent did that arithmetic and refused to use my result until it resolved.

⚠️ The general shape: a defect that removes data is invisible to consistency checks and visible only to an external quantity. Prefer at least one test per decoder pinned to something we did not produce — an emulator probe, a header field the format declares, a total the container states. adv_voice_region_holds_all_three_decoded_streams is written that way deliberately, and its comment says so, because the obvious "maintenance" of such a test is to re-baseline it against current output, which would delete the only thing it was for.

📌 And the corollary the port stated better than I did: it was checkable only because the identifier happened to be a byte count. Had the assignment been indexed by something the other side could not measure, it would have been adopted intact. When handing over a result, prefer to index it by a quantity the recipient can test.

Erring cautious is not free: over-warning is what makes the next real warning unreadable

From the port agent, after two consecutive rounds where its "known incomplete" banner fired wrongly — first carrying a message true only for ADV, then claiming a gap over a chunk that is 93.694 s of exact zeroes. A dropped silent stream is not missing content.

Both errors were in the cautious direction, and that is the point. A false warning feels like the safe mistake, so it goes unexamined for far longer than a false all-clear would — and the cost is not zero, it is deferred and it lands on the one occasion the warning is true. Here that warning is the only thing standing between a listener and audio they cannot tell is missing.

⚠️ So a warning needs the same scrutiny as an assertion, and specifically: check what it keys on. The fix was kept < content_waves rather than kept < present, which is the distinction between "we dropped something" and "we dropped something that had audio in it".

A second asset moving the way the mechanism predicts — and what that is worth

When the voice-region fix landed, S00A's kept-stream count went 1 → 2: a chunk that had been a different duration now matched the others at 93.694 s. That is exactly what restoring a truncated first stream predicts, on an asset the fix was not derived from, and the prediction preceded the observation.

⚠️ It is not independent ground truth — it is the port's exporter reading this crate's fixed output, so a fault in the fix would reproduce. Recorded at that strength and no higher. The distinction worth keeping: a different asset is real evidence about generality; a different implementation is what makes it independent, and only one of those two was present.

The instrument can be sound, the number correct, and the sentence around it wrong

Almost everything else in this file is about a broken or misspecified instrument. This one is different, and the port agent named it after hitting it twice in a fortnight:

  • it measured a 3.4 s near-silent seam in its menu bed — correctly — and wrote it up as "the cost of the missing loop point". The seam was real; it was produced by our own loop, and the game has none. It then reached a BLOCKED.md entry and a message to me, both carrying the wrong cause.
  • it measured a leading chunk correlating at r=0.998 with another stream's tail — correctly — and called it a duplicate. It was a start-truncated simultaneous stream, and the flush-against-the-end alignment is exactly what truncation predicts.

Both times the number survived scrutiny and the attribution did not.

⚠️ No amount of instrument control catches this. Controls test whether the measurement is faithful; they say nothing about the clause that follows it. The defences that do work are different in kind:

  • name the alternative explanation explicitly and say why it is excluded — "a seam because we loop wrongly" was never written down as a candidate, so it was never rejected;
  • notice when a measurement is being used to explain something it did not measure. The seam was measured in our output; the sentence made a claim about the game. That step is where the error lives, and it is invisible while the number stays in view.

📌 A useful smell: a measurement that arrives already attached to a cause, and whose cause happens to be the thing you were hoping to justify — a missing field, a duplicate worth dropping. Both of these did.

A control easier than the measurement does not bound the measurement's error

menu-bgm-loop-measured.md located a capture inside a decoded wave and validated the locator on slices cut from that same wave — which it found at 10.00 / 45.00 / 70.00 s, exact. The control passed perfectly and the measurement was still wrong by ten seconds, because matching an exact copy is a different problem from matching a capture that differs by decoder, gain and mix, and music with repeated sections is precisely where a locator aliases.

⚠️ The clean +5.00 s stepping the locator produced showed it was self-consistent. Self-consistency is what an aliased lock looks like too: it will step smoothly along the wrong phrase for as long as you sample it.

So a control has to be at least as hard as the measurement. Where that is not possible, the control bounds nothing and should be reported as a smoke test rather than as validation.

📌 The port agent hit the mirror image the same day: an awk '{print $NF+0}' that read 0 for every span produced a perfectly structured result that agreed with the hypothesis under test. Its tell was that the confirming rows were impossible too — a span of zero cannot occur. Both cases point the same way:

The tell is in the rows that agree with you. Scrutinising the disconfirming half harder is the intuitive discipline and it is the wrong half — a broken instrument and an easy control both fail silently on the side you were hoping for.

A correction that does not reach the artifact a consumer reads has not been made

The port agent found that after correcting loop_end_why and loop_start_why, the field its exporter actually concatenates into manifest.jsonloop_why — was still shipping "AUDIBLY WRONG AT THE SEAM" and "no loop-point field has been identified anywhere". Both refuted days earlier. Its corrections existed, were accurate, and were in the wrong fields.

And the same audit against this corpus found the same failure here. The claim "no loop-point field has been identified in the XMA header, so a menu loop is authored" was refuted on 2026-08-30 — in a new page. The sentence stayed untouched in bgm-two-stems.md, which is where anyone looking up BGM behaviour arrives, and in HANDOFF.md, which is the one page the port is told to read. A reader following either would have got the dead answer with nothing to warn them.

⚠️ Writing the correction down is not the same as landing it. A new page records that you learned something; it does not stop the old sentence being read. When something is refuted, grep the corpus for the claim, not for the file you were working in, and fix it where it is stated — especially in HANDOFF.md, whose whole purpose is to be the page somebody reads instead of the rest.

📌 And a trap in doing that audit, also the port's: its first verification reported the stale text still present, because the replacement quotes the refuted sentences in order to name them — so a substring search matches them inside the paragraph saying they are false. The match was real and meant the opposite of what the search implied. This is "the tell is in the rows that agree" arriving as a grep.

"It did nothing" and "it never happened" look identical from the outside

Driving the menu, an Ⓐ press produced no screen change and the obvious reading was that the button did nothing. The log said otherwise: two [file-pad] vk=5800 lines across the whole run — one press, not two — and one [RE-INPUT] delivery. The second press was never delivered. It was issued 0.8 s after a screen appeared, while the guest was still loading and not polling, and a 120 ms press vanishes in that window.

The pad driver reports what it emitted, so a script that checks its own output sees success either way. Confirm the receiving side, not the sending side: [RE-INPUT] … -> user=0 vk=5800 is the guest saying it got one.

⚠️ Generalised: whenever an action produces no observable effect, the first hypothesis is "the action did not occur", not "the action had no effect" — and those need different evidence. This corpus has now paid for it twice: here, and in the sign-in swallow, where thousands of delivered keystrokes were being discarded inside the emulator and looked exactly like a dead pad.

📌 Companion to the entry above about controls: a control proves your instrument reads correctly; a delivery check proves the experiment happened at all. They are different, and only the second one catches a null result that never ran.

The audit, run on this corpus — and I had already re-offended

Applying grep the corpus for the claim to this repository, one iteration after writing it, found four refuted statements still standing unmarked at the point they are made:

claim where refuted by
envelope correlation "has no resolving power" 3 places, incl. HANDOFF.md the port's control: r = 1.0000 at zero offset on a single track — the saturation needs concurrent streams
"8 of 10 three-chunk regions start mid-stream" HANDOFF.md, in a different section from its correction the completed census: 25 three-chunk, 17 affected
"r9 is a wild pointer… never a guest address" HANDOFF.md, in the kept-for-the-record section it is a host address; subtract 0x100000000
the ALSA channel permutation, stated without scope audio-capture-alsa-file-tee.md a later capture measured the identity

🔴 The first is the one worth admitting. The port corrected that claim, I agreed in a message, wrote the METHOD entry about corrections that never land — and did not land my own, for a full iteration. Acknowledging a correction in conversation feels like making it and is not.

📌 Two things the audit itself teaches:

  • A "kept for the record" section still asserts. Labelling a section superseded at its heading does not mark the sentence a reader lands on. Strike the sentence and point forward from it.
  • Naming a refuted claim keeps it greppable, so the audit returns its own corrections as hits. Every hit needs reading, not just counting — a grep verdict alone sends you to re-fix what is already fixed. That is the price of not deleting the old sentence, and it is worth paying.

Look at the picture

Three iterations went into reaching a submenu, and then two statistical identifiers into deciding which submenu it was. The first failed its control (masked correlation picked EXTRAS over the known main menu by 0.004 — the shared background dominates). The second passed by 1.28×, which is not a margin that licenses identifying an unknown.

The screen says TUTORIAL across the top. Reading it took one look, and it is the same method the corpus already used for Q4 — "pressing each one and reading the screen's own title off the framebuffer".

⚠️ The lesson is not "statistics are bad". It is that a capture of a user interface carries an explicit self-identification, and a correlation coefficient is a worse instrument for reading a word than reading the word. Reaching for a numerical identifier felt more rigorous and was strictly less reliable — and the control is what said so, which is the only reason the weaker number never got used.

📌 Ask, before building a matcher: does the artefact already state the answer? Screens have titles, files have magic, logs have names. A derived statistic is for when nothing states it.

A bundled label is exactly as strong as its weakest cell

HANDOFF.md's Q5 row opened with a single measured and then listed six clauses of very different strength — two of which had an evidence cell reading none in the source table. The port agent's authored/flow.json then stamped title/on_cancel_why = "MEASURED, HANDOFF Q5" for one of those two.

It did not invent that. It read the label the summary offered, and the summary was the document it is told to author against.

⚠️ The failure is in summarising, not in either endpoint. The source table was honest — it had the empty cells. The consumer was honest — it cited its source. Flattening six claims into one adjective is what created a provenance that nothing supports, and provenance is the part that gets believed later, long after anyone would re-derive the value.

So a summary row carries a label per clause, or it carries the weakest one. Never the strongest, and never one label over a list. If that makes the row ugly, the row is telling you it is doing too much.

📌 The general shape: a strength label is not distributive. "These six things are measured" is a claim about the conjunction, and a reader takes it about each element.

Say what the number means physically, and see whether the story survives

Contributed by the port agent, and it is a better generalisation than the one I had.

I had been filing my own failures — a stale binary, a control easier than the measurement, a confounded second press, a null that read as a result — under "an external quantity caught it": the decoder's own byte sizes, the screen's own title, a wrap I could time. True in each case, but it prescribes finding an anchor, and anchors are not always available.

The port's title_jp error had no external anchor. Every control it ran passed, because the metric was fine — the error was which frame it fed the metric. What caught it was asking why rest produced that light, which exposed a 4-unit sparkle whose rest.t is its own peak, which invalidated the frame.

⚠️ So the sharper check is: state what the number means physically, and see whether that story survives contact with the data. "The port puts 25.6 % more light here" has no coherent story once you ask which frame that is — the game never shows all six sparkles at once. A wrong frame yields a number with no physical story behind it, and that is detectable from the inside.

📌 It subsumes the null-as-result cases too: "no element ends on an alpha ramp, on screens that visibly fade" and "every region spans 0 bytes" are both numbers whose stories collapse the moment they are told out loud.

And a control does not test this. A control proves the instrument; it says nothing about the sample. Neither of us has a habit that catches a well-measured number taken from the wrong thing — this is the closest either has got.

The physical-story test catches confident FALSE claims, not just nulls

Sharpened by the port agent after the rule's first prospective catch. Its census returned "28 elements across 12 screens", arithmetic correct, no control it would have failed — and the list contained ptmsg, the main menu's own ⊙ Select Ⓐ OK footer, as "visible 2 of 64 units", plus ptbtn00, the plate. Both sit on screen the whole time the game does.

Nothing else pointed at it. The story collapsed the moment it was said aloud. The cause was that a screen's exit ramp drives every element to a = 0, so counting the exit as the end of visibility made every normal element look like a flash.

⚠️ So the net is wider than null-as-result: a wrong number usually still has a story, just an absurd one. "The footer is a 2-unit flash" is not a null — it is a confident false claim, and the same test catches it as catches "no element ends on an alpha ramp".

📌 And the fix has the tell of a right definition: re-keyed on the screen's span rather than the element's, ptmsg and ptbtn00 fell out on their own. A definition that stops needing hand-maintained exceptions is usually the correct one.

It caught one of mine within the hour. I split a census by whether an element's last keyframe is visible; 87.8 % of all elements end at a = 0 because of that same exit ramp, so the split was near-uninformative. I ran that control only because the port had just been bitten by it.

A candidate cannot be adjudicated against the incumbent by the comparison alone

🔴 This entry was written too strongly and is corrected below.

Proposing that the settled pose come from the screen's settle instant rather than each element's rest(), the obvious validation is: where rest() is already sound, does the candidate agree? It does not — 46.6 %, then 78.1 % after the control was made fair.

⚠️ Neither number could ever have settled it. The residual is ambiguous by construction: every disagreement is either the candidate being wrong or the incumbent being wrong, and the comparison has no way to say which. Tightening the control moved the number and did not change that.

The shape of the experiment was wrong, not its tuning. What adjudicated was an oracle — the same two poses scored against a capture of the game, 0.01 % against 0.75 % differing. That is a third party neither rule authored.

🔴 …and that conclusion was too strong. What was missing was a DISCRIMINATOR.

Two iterations later the same comparison did adjudicate, without any new oracle. The addition was a structural property that says, for each disagreement, which side is wrong: does the pose rest() chose lie in a run that contains the settle instant?

control — one plateau, covering the settle instant 3 072 / 3 072 agree
disagreements attributable to the incumbent 1 036 / 1 036

Every disagreement had rest() on a run the screen has already left. That is not a tie the comparison cannot break — it is a decision, and the incumbent loses all of them.

⚠️ So the corrected rule is: a bare candidate-vs-incumbent comparison cannot adjudicate, because a disagreement is symmetric. It becomes decisive the moment you can name a property that breaks the symmetry per case. Reach for an oracle when no such property exists — not as the first move.

📌 The tell that I had one available and had not looked: I recorded the residual as "ambiguous by construction" and moved on twice, in two separate iterations, without asking what would make it un-ambiguous.

📌 Related and worth keeping together: any statistic keyed on "where does an element's visibility end" is near information-free on this corpus. A screen's exit ramp drives every element to a = 0, so 12 278 of 13 991 (87.8 %) end there. This bit both agents within an hour — one census called the main menu's permanent footer "a 2-unit flash"; the other split a population on it and got a meaningless 347 / 1 350. It is a property of the data, not two coincidences.

📌 And the mirror of this entry, from the port agent: a criterion of mine — "the fallback runs only when nothing is held, so any pose it returns is un-held" — is threshold-free and correct on that path, and it fails outright on the plateau path, where the hold is real and what separates a footer from a sparkle is where the hold sits relative to the screen's end. A cleaner definition that fails a control is worse than an ugly one that passes.

A threshold borrowed from a rule of thumb still has to be checked against the cases it decides

ui-settle-time.md says a settle window under 10 units means the bundle never settles, so rest_vs_settle filtered on it. Reasonable, documented, and wrong in both directions at once:

  • it admitted the 1019-unit bucket — which the later census showed is the worst, at 45.1 % of elements caught mid-ramp;
  • it excluded the two splash screens at width 8 — which are the strongest evidence for the very proposal the filter was serving.

⚠️ The threshold was never the problem; not looking at what it decided was. One --settle invocation per screen would have printed every window in seconds, and I ran it only after the port agent produced a counter-example.

📌 And the near-miss on top: the census made "narrow window ⇒ bad settle pose" look obvious — 45.1 % against 15.0 % — and I thought it refuted by two screens that appeared to sit in the filtered-out band while winning 75×.

🔴 That refutation was itself wrong, and the correction is the sharper entry. Those two screens were addressed by build ordinal where I believed I was giving a pak entry[10] → entry 12 — so I had rendered the loading screens. Their real windows are the widest of the five. A counter-example is a measurement too, and mine was taken with the wrong index.

⚠️ The general form: an index that silently means something else produces well-formed output for the wrong object. This project has now been bitten twice from opposite directions, and both times "everything still validates". When a counter-example arrives that overturns a gradient, check what it is a measurement of before you believe it — the same scrutiny the gradient got.