diff --git a/crates/sylpheed-formats/examples/paint_order_audit.rs b/crates/sylpheed-formats/examples/paint_order_audit.rs index 3b45033..69cd1f3 100644 --- a/crates/sylpheed-formats/examples/paint_order_audit.rs +++ b/crates/sylpheed-formats/examples/paint_order_audit.rs @@ -83,6 +83,20 @@ fn main() { let Some((label, want)) = measured(&names) else { println!("entry {i:2} (no measured order) {} elements, {tie_pairs} tied pairs, \ {tie_overlap} of them OVERLAPPING", build.elements.len()); + // Name them: these are the only pairs whose order can show. + for a in 0..keys_all.len() { + for b in (a + 1)..keys_all.len() { + if keys_all[a] != keys_all[b] || keys_all[a] == u32::MAX { continue; } + let (Some(ra), Some(rb)) = (rect(&build.elements[a]), rect(&build.elements[b])) + else { continue }; + let ox = (ra.0 + ra.2).min(rb.0 + rb.2) - ra.0.max(rb.0); + let oy = (ra.1 + ra.3).min(rb.1 + rb.3) - ra.1.max(rb.1); + if ox > 0 && oy > 0 { + println!(" overlapping tie: [{a}] {} x [{b}] {} key {} rect {:?} / {:?} overlap {}x{}", + build.elements[a].name, build.elements[b].name, keys_all[a], ra, rb, ox, oy); + } + } + } continue; }; checked += 1; diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 9a1bf99..0a529a0 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -34,7 +34,7 @@ authored version can be deleted. | Q5 | navigation semantics | ✅ answered | **measured**: initial focus varies boot to boot (2× `TUTORIAL`, 2× `NEW GAME`); ⬆⬇ one step, **wraps both ends**; ⬅➡ do nothing; Ⓑ returns to the parent **with focus restored**; Ⓑ on the main menu → title; Ⓑ on the title → nothing — [`menu-navigation-semantics.md`](../re/menu-navigation-semantics.md) | | Q6 | boot sequence + what drives it | ✅ answered | sequence **measured** end to end; the driver is **code, not data** — four search spaces closed, so the port **authors** the sequence — [`boot-config-and-gamepart-registry.md`](../re/boot-config-and-gamepart-registry.md) | | Q7 | transitions | ✅ answered | a **fade through black**, drawn by the screen's own last-painting `.prm` quad. Fade-in ramp is **decoded** from its keyframes; the ~0.4 s fade-out is **measured** (not in the file) — [`screen-transitions.md`](../re/screen-transitions.md) | -| Q8 | menu audio bindings | ✅ answered | cue vocabulary + bank **decoded**; event binding is a **name match** (the authors' own event names); SE audio is **undecodable from the disc** — no XACT container exists anywhere — [`menu-audio-cues.md`](../re/menu-audio-cues.md) | +| Q8 | menu audio bindings | ✅ answered | cue vocabulary + bank **decoded**; event binding is a **name match** (the authors' own event names). ✅ **You CAN have the SE audio** — ⚠️ an earlier version of this row said it was "undecodable from the disc"; that was **retracted** and the row was stale. Three cues are located in `Static.slb` and **decode to PCM**: d-pad move `0x1ec0` (4 packets), Ⓑ back `0x0ec0` (2), Ⓐ confirm `0x5d6c0` (6), all mono 48 kHz. The bank is a packed run of XMA waves with no delimiter, so a wave is only (offset, packet count) — and ⚠️ the file order is **not** cue-id order, so the index cannot be counted out — [`menu-audio-cues.md`](../re/menu-audio-cues.md) | | Q9 | video binding + playback rules | ✅ answered | **decoded** from the movie manifest: `ADVERTISE_MOVIE`→`ADV.wmv` (boot intro *and* attract are one asset), `MS00A`→`S00A.wmv` is the new-game intro, `STAFF_ROLL`→the credits reel. ✅ **one Ⓐ skips a movie** (title at 57 s vs a 193 s baseline) — [`movie-binding.md`](../re/movie-binding.md) | | Q10 | music-bank sub-wave roles (intro+loop?) | ✅ answered | **two stems of one performance, played together** — sample-synchronous, equal duration, 32/32 banks. **Concatenating is wrong.** Not a seamless loop either — [`structures/bgm-two-stems.md`](../re/structures/bgm-two-stems.md) | | S1 | Ready Room go/no-go | ✅ **no-go** | it is 2D and enumerates fine (60 builds), but `GP_READY_ROOM.pak` holds **briefing/tactical-map** content, not the six-button Ready Room menu — [`ready-room-probe.md`](../re/ready-room-probe.md) | @@ -267,10 +267,16 @@ authored version can be deleted. in practice because it has a measured order. Per screen: title **measured**, main menu **measured**, developer splash **measured**, publisher splash derived but with **0 ties** (fully determined), - and **`EXTRAS` derived with 15 tied pairs of which only 2 overlap**. That pair - count is the whole risk — a tie-break by declaration index can be wrong by a - whole layer where it is wrong, so if `EXTRAS` ever looks off, those two pairs - are where to look. + and **`EXTRAS` derived with 15 tied pairs of which only 2 overlap**. + ✅ **That narrows again to ONE, and the capture is consistent with it.** Of the + two overlapping pairs, `ptloop01`×`ptloop02` are `loop*` animations that + `compose` skips by default, so their tie is unreachable. The remaining pair is + `ptframe3`×`ptframe4`, overlapping 102×132 px — and against `live-extras.png` + that contested region correlates **+0.9622**, *better* than the whole frame + (+0.9440) and inside the range of regions where order cannot matter (+0.8502 / + +0.9903). 🟡 Consistent with, not proof — correlation cannot see a swap between + locally similar art. **15 → 2 → 1 → consistent** is the whole paint-order risk + on your five screens. [`structures/ui-paint-order-derived-check.md`](../re/structures/ui-paint-order-derived-check.md) * **Menu order is geometric.** Buttons sorted top-to-bottom by resting Y. This is diff --git a/docs/re/METHOD.md b/docs/re/METHOD.md index e4aa213..7848975 100644 --- a/docs/re/METHOD.md +++ b/docs/re/METHOD.md @@ -571,3 +571,17 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the 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. diff --git a/docs/re/REFUTED.md b/docs/re/REFUTED.md index 23a6110..f5d4cd8 100644 --- a/docs/re/REFUTED.md +++ b/docs/re/REFUTED.md @@ -464,3 +464,8 @@ neighbourhood, not just the line. header (exhaustive 0x00–0x7f, u8/u16/u32, both directions — 0 matches against 64 for the declaration-order control) all give `1,2,3,4,5`. [`ui-paint-order-derived-check.md`](structures/ui-paint-order-derived-check.md) +* "SE audio is undecodable from the disc — no XACT container exists anywhere" + (as it stood on the **handoff page**) → **stale**: `menu-audio-cues.md` had + already retracted it and located three cues in `Static.slb` that decode to PCM. + The retraction never reached the row the port agent reads. Handoff row fixed; + `tools/re-capture/handoff_lint.py` now checks for this class. diff --git a/docs/re/structures/ui-paint-order-derived-check.md b/docs/re/structures/ui-paint-order-derived-check.md index 17858c1..ae7ef63 100644 --- a/docs/re/structures/ui-paint-order-derived-check.md +++ b/docs/re/structures/ui-paint-order-derived-check.md @@ -126,3 +126,44 @@ So Q3 resolves as: the layer key is ✅ **decoded** and orders 4 of the 5 measur bundles exactly; the **tie-break within a key is ❔ undecodable**, and a consumer must either use a measured order or accept declaration index as an arbitrary stand-in. + +--- + +## ✅ `EXTRAS`'s exposure narrows to ONE pair — and the capture is consistent with it + +The 15 tied pairs on `EXTRAS` reduce twice. + +**2 of the 15 overlap** (only an overlapping tie can paint differently): + +``` +[0] ptframe3.t32 x [1] ptframe4.t32 key 32848 overlap 102x132 +[14] ptloop01.rat x [15] ptloop02.rat key 32784 overlap 400x180 +``` + +**1 of those 2 can be drawn.** `ptloop01`/`ptloop02` are `loop*` sprite +animations, which `compose` skips unless `--animated` is passed, so their tie is +unreachable in a default render. The real exposure is `ptframe3` × `ptframe4`, +overlapping **102 × 132 px** at x 584–686, y 318–450. + +### The capture is consistent with our order + +Correlating our render against +[`live-extras.png`](../captures/title-builds/live-extras.png): + +| region | correlation | +|---|---| +| **the contested overlap** (x 584–686, y 318–450) | **+0.9622** | +| `ptframe3` alone, outside the overlap | +0.8502 | +| `ptframe4` alone, outside the overlap | +0.9903 | +| whole frame | +0.9440 | + +The contested region matches **better than the frame as a whole** and sits inside +the range of regions where order *cannot* matter. Were our tie-break wrong, that +region should correlate worse than its neighbours. + +🟡 **Consistent with, not proof.** Correlation cannot see a swap if the two +frames happen to be locally similar. But it is a real check, and it removes the +"entirely unverified" character of the one tie the port can actually hit. + +**So the chain is: 15 tied pairs → 2 overlapping → 1 drawable → consistent with +the capture.** That is the whole paint-order risk on the port's five screens. diff --git a/tools/re-capture/handoff_lint.py b/tools/re-capture/handoff_lint.py new file mode 100755 index 0000000..75cc951 --- /dev/null +++ b/tools/re-capture/handoff_lint.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Does HANDOFF.md still assert something its own linked doc has retracted? + +This has now happened four times: a finding lands in docs/re/ and the page the +port agent actually reads keeps the superseded text. Once it was worse than +stale -- the port was told SE audio was "undecodable from the disc" while three +cues were located and decoding to PCM in the linked file. + +For every HANDOFF line that makes a STRONG NEGATIVE claim and links to a doc, +check whether that doc contains retraction language. A hit is not proof the row +is wrong -- docs retract other things too -- it is a row to read. + + handoff_lint.py [docs/port/HANDOFF.md] +""" +import os, re, sys + +HANDOFF = sys.argv[1] if len(sys.argv) > 1 else "docs/port/HANDOFF.md" +# Resolve links the way markdown does: relative to the FILE, not to a guessed +# repo root. The first version joined a guessed root and reported every existing +# doc as missing -- which is the tool failing its own control, and is why the +# "missing doc" branch prints rather than being silently skipped. +BASE = os.path.dirname(os.path.abspath(HANDOFF)) + +NEGATIVE = re.compile(r"undecodable|cannot be|can not be|impossible|no .{0,24}exists|" + r"does not exist|not extractable|unreachable", re.I) +RETRACT = re.compile(r"retract|withdraw|was too strong|that was wrong|superseded|" + r"refuted\b.{0,40}\bmine|no longer", re.I) +LINK = re.compile(r"\]\(\.\./re/([^)]+)\)") + +def main(): + text = open(HANDOFF, encoding="utf-8").read().splitlines() + flagged = checked = 0 + for n, line in enumerate(text, 1): + if not NEGATIVE.search(line): + continue + for rel in LINK.findall(line): + doc = os.path.normpath(os.path.join(BASE, "..", "re", rel.split("#")[0])) + if not os.path.exists(doc): + print(f" L{n}: link to a MISSING doc: {rel}") + flagged += 1 + continue + checked += 1 + body = open(doc, encoding="utf-8").read() + hits = sorted({m.group(0).lower() for m in RETRACT.finditer(body)}) + if hits: + flagged += 1 + claim = NEGATIVE.search(line).group(0) + print(f" L{n}: claims {claim!r}; linked {rel} contains {hits}") + print(f"\n{checked} negative claim(s) with links checked, {flagged} to read") + return 1 if flagged else 0 + +if __name__ == "__main__": + sys.exit(main())