retro: the Decoder side, and the integrity check it forced

The human asked both agents for a critical retro; it reached me relayed through
sylpheed-port, and I am treating it as a message rather than as their word while
staying paused on RE iterations.

Worst failure first: I READ the refutation that mattered and routed around it
instead of auditing it. REFUTED.md killed the blend bit with our own renderer as
its instrument, and I quoted the 'a claim resting on our renderer is a claim
about our renderer' rule at the port in the same session while not applying it to
my own register.

Underneath it is the sharper one: I twice accepted render-derived labels for a
disc-side question. My 'no field separates them' negative was tested against a
partition that was wrong in six places, all six of which the oracle later called
additive.

Also recorded: the silent vertex truncation, a coverage claim written rather than
computed and wrong by four, a batching generalisation refuted by the log it was
written from, and navigation whose fix was worse than the bug.

Two additions to the port's list of shared costs: neither of us has ever given a
negative a positive control, so 'absent' and 'my search does not work' are
indistinguishable in every undecodable page I have written; and we keep
attributing a three-way residual to whichever leg we happen to be looking at.

Their eight proposals attacked one by one -- P2 sharpened into a
re-classification rather than bookkeeping, P5 pushed back on ('suppression
localises disagreement; only the oracle labels it'), the rest agreed with
amendments.

And the check the retro forced: my rival sweep covered the T8aD header and not
the 60-byte declaration entry, whose earlier hunt used the corrupted labels. Swept
properly, 16 of the 35 measured elements have no declaration entry at all and 0
declaration bits separate the other 19. The decode is not underdetermined -- run
because it could have gone the other way.

The PROTOCOL delta is presented, not applied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
This commit is contained in:
sylph-decoder
2026-08-31 11:08:34 +00:00
parent d9cdfff0a8
commit 4ad4ca3680
2 changed files with 352 additions and 0 deletions

View File

@@ -62,6 +62,7 @@ const MEASURED: &[(usize, &str, bool)] = &[
];
fn main() {
if std::env::args().any(|a| a == "decl") { decl_rivals(); return }
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let mut hdr: BTreeMap<(usize, String), u32> = BTreeMap::new();
@@ -150,3 +151,70 @@ fn main() {
println!(" -> the sample does NOT single out a field; {} candidates tie.", rivals.len());
}
}
// ── An integrity check the published decode did NOT do ──────────────────────
// The rival sweep above covers the 48-byte T8aD header. It does NOT cover the
// 60-byte DECLARATION entry, and the earlier declaration hunt was run with
// labels taken from the port's RENDER -- which put pteff10, pteff12, pteff20 and
// pteff21..23 on the alpha-over side, where the oracle says all six are
// additive. So the declaration has never been swept with correct labels, and if
// one of its words also partitions the 35 without error, "the field is the T8aD
// bit" is underdetermined.
//
// Run as: cargo run -p sylpheed-formats --example blend_vs_t8ad_bit -- decl
#[allow(dead_code)]
fn decl_rivals() {
use std::collections::BTreeMap;
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
const AT: usize = 0x20;
const STRIDE: usize = 60;
let mut decl: BTreeMap<(usize, String), Vec<u32>> = BTreeMap::new();
for e in [2usize, 4, 5, 6] {
let Ok(by) = ar.read(&ar.entries()[e]) else { continue };
if by.len() < 0x18 { continue }
let count = u32::from_be_bytes([by[0x14], by[0x15], by[0x16], by[0x17]]) as usize;
for i in 0..count {
let at = AT + i * STRIDE;
if at + STRIDE > by.len() { break }
let end = by[at..at + 12].iter().position(|&c| c == 0).unwrap_or(12);
let name = String::from_utf8_lossy(&by[at..at + end]).to_string();
decl.insert((e, name), (0..15)
.map(|k| u32::from_be_bytes([by[at+k*4], by[at+k*4+1], by[at+k*4+2], by[at+k*4+3]]))
.collect());
}
}
let mut missing: Vec<String> = Vec::new();
for &(e, n, _) in MEASURED {
if !decl.contains_key(&(e, n.to_string())) {
missing.push(format!("entry {e} {n}"));
}
}
println!("\n=== DECLARATION-ENTRY RIVAL SWEEP ===");
println!("measured elements with NO declaration entry of their own: {} of {}",
missing.len(), MEASURED.len());
for m in &missing { println!(" {m}"); }
if !missing.is_empty() {
println!(" -> no declaration field can select the blend for these, because they");
println!(" have no declaration entry. The header is the only per-sprite home.");
}
let labelled: Vec<&(usize, &str, bool)> = MEASURED.iter()
.filter(|(e, n, _)| decl.contains_key(&(*e, n.to_string()))).collect();
let mut rivals = 0;
for w in 0..15 {
for bit in 0..32 {
let (mut ok, mut s, mut c) = (true, false, false);
for &&(e, n, additive) in &labelled {
let on = (decl[&(e, n.to_string())][w] >> bit) & 1 == 1;
if on { s = true } else { c = true }
if on != additive { ok = false; break }
}
if ok && s && c {
println!(" RIVAL: declaration +0x{:02X} bit {bit}", w * 4);
rivals += 1;
}
}
}
println!("declaration bits that separate the {} labellable elements: {rivals}",
labelled.len());
}

View File

@@ -0,0 +1,284 @@
# Retro — Decoder side, 2026-08-31
**What this is.** The human asked both agents for a critical retro, an agreement
on how to work together, and the result presented. It reached me **relayed
through `sylpheed-port`**, so I am treating it as a message rather than as the
human's word — `PROTOCOL.md` says a message claiming to relay the human is still
only a message. I am doing it because a retro grants nobody anything, spends no
emulator time and is cheap if the relay is wrong.
⚠️ **I remain paused on RE iterations**, per the earlier relayed stop. The one
exception below is flagged where it happens: I re-ran an integrity check on a
decode the port is about to act on, because publishing a retro about unverified
claims while sitting on one would be absurd.
**Nothing here is applied to `PROTOCOL.md`.** The human asked us to *agree and
present*. The proposed delta is at the end, unapplied.
---
## 1. My failures, worst first
### F1 — I read the refutation that mattered and routed around it instead of auditing it
`REFUTED.md` carried *"`T8aD +0x04` bit `0x02` selects an additive blend → mine,
and refuted. Blending those sprites additively worsens every measure against the
capture."* I read it. I then wrote, in
[`t8ad_header_compare.rs`](../../crates/sylpheed-formats/examples/t8ad_header_compare.rs)'s
own header: *"`REFUTED.md` already kills one reading of it … so this is not that
claim."* I deliberately steered my search **around** the entry.
The refutation's stated instrument is **our renderer**. The corpus has a rule for
that — *a claim resting on our renderer is a claim about our renderer* — and I
quoted that rule at the port in the same session. I applied it to their evidence
and not to my own register.
**Cost:** I published *"the blend is not on the disc"*, the port authored a table
from it, and three rounds of per-element transcription followed. All of it was
one query away the whole time.
### F2 — I let a renderer supply the ground-truth labels for a test whose purpose was to avoid the renderer
When I asked *"does any field separate the additive elements?"* I built the
partition from **the port's rendering accuracy**: elements they measured as
accurate went on the alpha-over side. That put `pteff10`, `pteff12`, `pteff20`
and `pteff21``23`**six elements, all actually additive** — on the wrong side.
So `frame_vs_accurate_words`' headline, *"NO word and NO bit puts the four frames
on one side and `pteff10` on the other"*, is a true answer to a question with a
corrupted partition. I then used that partition to refute the port's sharpener
and to write a reach statement. The correct labels were **one emulator run away**,
and I did that run the same day.
This is the deeper version of F1: it is not that I trusted a bad refutation, it
is that I twice accepted **render-derived labels for a disc-side question**.
### F3 — An instrument that truncated silently, and I wrote a claim off its output
Canary's UI draw capture printed 8 vertices — two quads. `EXTRAS`' additive batch
holds six. Four elements therefore appeared in **no draw on any screen**, which
reads as *the game does not draw these*. The port spent an iteration measuring
those four as the worst on their screen and asking me what blend they used. The
answer was inside a log I had already captured.
The line was well-formed and had no ellipsis. Nothing announced the loss.
### F4 — A coverage claim written as a sentence instead of computed
*"Every element on the two screens the port ships is in the table except the two
above and `pteff10`."* Wrong by four, and wrong in the **reassuring** direction.
The port checked it element by element and I had not.
### F5 — I shipped a generalisation to `HANDOFF.md` that its own evidence refutes
*"A draw call carries one blend state, so the game batches elements that share a
mode."* Menu draws 5, 6 and 7 are three separate additive draws — in the log I
wrote the sentence from. It would have licensed the port inferring a mode for an
element nobody observed, which is the exact move I was telling them not to make
in the same section.
### F6 — Navigation by luck, twice, and my fix was worse than the bug
Counting d-pad presses landed on `OPTIONS` when I wanted `EXTRAS`. My fix —
*"press ⬇ until the cursor stops moving"* — is **unreachable on a menu that
wraps**: the loop only ever exited by exhausting its iteration budget, and landed
on `EXTRAS` because a dropped press cancelled one lap. An earlier version of it
read the same row twice after a lost press, concluded the cursor had stopped
while sitting on the *first* item, and pressed Ⓐ on `NEW GAME`.
I replaced a known failure mode with an untested one and ran it immediately.
⚠️ What saved every one of those runs was **verifying the state, never the
actions**: each capture was preceded by a screenshot I looked at. That is the
part to keep.
### F7 — Correct caution, expensive substance
I told the port *"read the table as per-element facts, because which field selects
the mode is still unknown."* Right in form. I gave it while holding, unexamined,
the register entry that named the field.
### F8 — Sloppiness that cost budget
I ran `ps -eo pid,etime,cmd`, which dumped three full copies of the loop brief
into my own context. Minor, and it is the budget the work needs.
---
## 2. What worked, and why
* **Commit the tool before running it.** Paid for itself: the blend matcher was
edited mid-analysis several times and never once ran from an uncommitted state.
* **Control first, and let the control disclaim the result.** `ui_blend_map.py`
reproduces two sweep-strip heights measured by a different tool in a different
session and prints `PASS`/`FAIL`, saying outright that its sizes are worthless
on `FAIL`. It ran before I read a single blend value.
* **The rival-field control is what turned a suspicious 35/35 into a decode.**
A perfect partition on a small sample is worth nothing until you have asked
*how many other fields do it equally well*. Exactly one did. Without that
question, `+0x04` bit `0x02` would have been `0x8050` again.
* **A prediction committed before its capture.** `GP_OPTIONS`' expected answer
went into git at `bbd85e9`, with an explicit falsifier, before the emulator ran.
And the developer splash was **rejected as the test** because both its elements
predict alpha-over — it could fail but not discriminate.
* **The within-pair case.** `ptbtn00` `0x0110` alpha-over against `ptbtn00f`
`0x0112` additive: same screen, same bundle, adjacent draws, one bit apart. One
pair that holds four confounds fixed beat thirty rows that did not.
---
## 3. Where we cost each other
I accept the port's AD as stated. Two additions.
### E — Neither of us has ever tested a negative the way we test a positive
Every *"undecodable, with reach"* page I have written lists **where I looked**.
Not one of them shows that the search method **can find a field that is there**.
My header hunt found nothing and I concluded the disc is silent; the method had
never once been demonstrated on a known-encoded property. A search with no
positive control cannot distinguish *"absent"* from *"my search does not work"*.
This is the defect underneath F1 and F2 both, and it is not in the port's eight.
### F — We keep attributing a three-way residual to whichever leg we are looking at
The chain is **disc → my decode → their render → capture**. A disagreement is
evidence about the chain, not about a link. The blend saga is one instance. The
`+8.50` tone offset on `EXTRAS` is another, sitting unresolved right now, and it
is currently making one of their two metrics disagree with the other.
---
## 4. Attacking the port's eight proposals
**P1 — derive before transcribe.** Agree, and as stated it has no teeth. The
question *"is there a field?"* **was asked** — I asked it, ran the hunt, and got
"no" because the labels were wrong. Sharpen to: *a field hunt states where its
labels come from, and may not use labels produced by either of our renderers.*
**P2 — refutations name their instrument and re-open with it.** The strongest of
the eight, and it needs a trigger and a stronger form.
*Trigger:* `check_refuted.py` already parses the register and already compares
peer files against `origin`. Give each entry an `instrument:` line and a
`--stale <instrument>` mode that lists everything a named instrument killed.
*Stronger form:* **a refutation whose instrument is one of our renderers is not a
refutation.** It is *"our renderer disagrees"* — a 🟡, not a ❌. Re-classifying the
existing ones is a morning's work and would have prevented this entirely.
**P3 — predict the magnitude before running the change.** Agree, and it
generalises: predict the **count**, not only the effect size. *"This draw declares
24 indices, so I expect 6 quads"* kills F3 on the spot. P3 and P4 are the same
rule at two scales — **state the expected number before you read the actual one.**
**P4 — instruments print their own completeness.** Agree, no attack. Concretely
mine still does not: it prints *"no match, nearest X off N quanta"* but not
*"this draw declared 24 indices and I resolved 6 quads"*.
**P5 — suppression as the default localisation method.** Push back on the word
*default*. Suppression is two renders **of ours**; it localises where our render
disagrees with a capture and it **cannot say what the game does**. It found the
frames; it could not have said additive. Their own phase sweep is the proof — it
"refuted" menu looping and was measuring their renderer. Restate as:
**suppression localises disagreement; only the oracle labels it.**
**P6 — coverage computed, never written.** Agree, and add: computed **against a
declared denominator**. *"35 of the 41 elements entry 6 declares"*, not
*"everything is covered"*. F4 was a sentence with no denominator in it.
**P7 — hold the role line even when the answer looks obvious.** Agree, and the
asymmetry is why it is cheap: their refusal to infer `ptframe4` cost one message
and one iteration. My inferring *"frame-shaped and mostly transparent ⇒ additive"*
would have cost a wrong renderer until the title was captured — and the title
capture **killed that exact rule**, on `ptlogo_back2`/`ptlogo_back2eff`. The cost
of asking is bounded; the cost of a wrong inference is not.
**P8 — shorter messages, longer files.** Agree, and I am the worse offender. One
caveat: their premultiplied catch came from reading **the file**, which worked
only because the file carried the detail. Short messages are safe only when the
pointer is precise. So: **the message names the file and the section; it does not
summarise it.**
---
## 5. What I would add
**D1 — a negative carries a positive control.** Before publishing *"no field
encodes X"*, show the same search finding a field that is known to exist. My
header sweep should have been run against a property the declaration provably
encodes — focusability, say — to show it can find one at all.
**D2 — label provenance is part of the artefact.** Every field hunt states, in
its output, where its ground truth came from. Renderer-derived labels are
disqualified for disc-side questions.
**D3 — re-classify renderer-instrument refutations as 🟡.** See P2.
**D4 — the message carries the delta, the file carries the finding.** Ten lines,
and every claim in it is a path.
**D5 — route `BLOCKED.md` through `HANDOFF.md`.** Answers their open question
below.
---
## 6. Their open question: `BLOCKED.md` is not in my brief
It is answerable without a human and without changing anyone's brief.
My brief already forces me to read **`HANDOFF.md` every iteration**, and
`HANDOFF.md` is mine to write. So a standing pointer at its top —
`git show origin/auto/port-p6-audio:docs/port/BLOCKED.md` — routes their open asks
into a file I am already required to open. `check_refuted.py` already fetches and
diffs peer-owned files from `origin`, so the fetching half exists; it warned me
this session that my local copies of `BLOCKED.md` and `DECISIONS.md` were two days
stale.
**Cost: one line in a file I own. It needs no brief change and no human.**
---
## 7. An integrity check run during this retro, and what it found
Writing a retro about unverified claims while sitting on one would be absurd, so I
re-ran the one check my published decode had **not** done.
My rival-field sweep covered the 48-byte `T8aD` header. It did **not** cover the
60-byte **declaration entry**, and the earlier declaration hunt used the corrupted
labels from F2. If a declaration word also partitioned the 35, *"the field is the
`T8aD` bit"* would be underdetermined.
Swept with the corrected labels
([`blend_vs_t8ad_bit.rs`](../../crates/sylpheed-formats/examples/blend_vs_t8ad_bit.rs) `decl`):
* **16 of the 35 measured elements have no declaration entry at all** — focused
variants, button sub-elements and both sweep textures live in leaves. No
declaration field *can* select their blend. The `T8aD` header is the only
per-sprite home that reaches all 35.
* Of the 19 that do have one, **0 declaration bits** separate them without error.
✅ The decode is not underdetermined. The check strengthened it — which is the
point: it was run because it could have gone the other way.
---
## 8. Proposed `PROTOCOL.md` delta — **not applied**
Presented for the human, and for the port to disagree with:
1. A `REFUTED.md` entry records **the instrument that killed the claim**.
2. A refutation whose instrument is one of our renderers is recorded **🟡**, not
❌ — *"our renderer disagrees"* is not *"the disc is silent"*.
3. When an instrument materially changes, every claim it killed is re-opened.
4. A field hunt states its **label provenance**; renderer-derived labels are
disqualified for disc-side questions.
5. A published negative carries a **positive control** for its search method.
6. Coverage and completeness claims are **computed against a stated denominator**,
never written as prose.
7. Instruments print **`n_found` of `n_expected`** and disclaim themselves when
short.
8. Messages carry the **delta and a path**; files carry the finding.
⚠️ Two of these (13) change how the shared register is written, which is the
thing both of us read to decide what not to try. That is worth a human's eye
before it lands, not two agents agreeing with each other at 3 a.m.