diff --git a/docs/port/BLOCKED.md b/docs/port/BLOCKED.md index c784504b..5ba04aea 100644 --- a/docs/port/BLOCKED.md +++ b/docs/port/BLOCKED.md @@ -147,6 +147,12 @@ HANDOFF. | ~~P1–P7 — the keyframe record layout~~ | ~~adopt the corrected pose/time pairing~~ | — | ✅ **ADOPTED 2026-08-29 by pinning `formats-pin-2026-08-29c`.** This row was wrong twice: it said the change *"cannot be taken yet"* and that it *"reaches the port only when that branch lands on `main`"*. **It arrives when the tag is pinned**, which is what MISSION §2's tagging rule exists for. ⚠️ And the knob I tested first, `SYLPHEED_KF_TIME_SHIFT`, is a **retired partial fix** that left pose 0 untimed — the real correction is the tagged crate's default, with the old reading behind `SYLPHEED_KF_TIME_LEGACY=1`. **The blast radius was far smaller than this row predicted**: under the correction *every pose is timed* (866 keyframes, 0 untimed), so `pose_at`'s synthetic-exit branch became dead code rather than wrong code and nothing needed re-deriving. Oracle: `publisher_logo` 1.00 %→**0.75 %**, `developer_logos` 0.39 %→**0.33 %**, `extras`' differing region collapsing from 736×525 to **398×295 at the sweep position**. 🔴 Open cost: `sylpheed-cli` builds from the workspace crate, so `verify-screen` compares two decoder eras until the tag reaches `main`. Revert to the path dependency then. | | ~~P7 / naming — the four unnamed builds~~ | ~~which locale and variant is each of entries 0, 1, 12, 15?~~ | — | ✅ **answered 2026-08-29** (`docs/re/ui-title-build-map.md`): all four are the loading screen, two variants — plain (7 elements) and dressed (10) — decoded from their own `pgloading_*` element names. ⚠️ **Not adopted as names yet, for two reasons the RE agent gave and one the port found.** Theirs: the executable names exactly two, and *which* bundle takes which name is 🟡 undecided, so `LOADING`/`LOADING2` must not go in an asset path; and locale is 🟡 — the English member of a pair is the one in the first half of `GP_TITLE.p00`, 8/8 structurally but only 3/3 where a capture can check, and the three pairs that matter are the three no capture can check. Mine: **the message gives the bundles as "0/1 and 10/11", which is the `is_build` ordinal, and `authored/screen_names.json` is keyed by PAK ENTRY** — in entry space 10 and 11 are `palogo_sqex` and `palogo_gamearts`, the splashes. See the refutation section in `DECISIONS.md`. | +## Ask for the HUMAN, 2026-08-30 — derived from HANDOFF `27938aa` + +| Milestone | Needs | HANDOFF | State | +|---|---|---|---| +| P0–P7 — the contract itself | **land HANDOFF on `main`, or tell the port to read the branch** | `27938aa` | 🔴 **the document the mission calls the contract is not the document the port opens.** `main`'s copy is **926 lines** frozen at `9ca1eb5`; the live one is **4 111** at `27938aa`, **99 commits** unread, **70 sections** this port has never opened — several titled *"deliver … to the page the port reads"*. Only a human merges a topic branch (PROTOCOL), so the port cannot fix this and will not merge another agent's branch into its own. **Mitigated, not solved:** `tools/port/contract-check` now reads the newest HANDOFF on any ref and reconciles seven of its numbers against `export/` and `authored/` — all seven agree — and `check-all` asserts it plus its known-negative control. That is seven values out of 4 111 lines. ⚠️ The rest is still read by eye, and two consecutive iterations have found instructions addressed to the port sitting unread for a day. | + ## New ask, 2026-08-30 — derived from HANDOFF `27938aa`, at port `HEAD` `f33aeca` | Milestone | Needs | HANDOFF | State | diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 4f2f0930..2cc3239c 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -9,7 +9,7 @@ dies, which is what this file is for. -235 sections. Search this before re-deriving anything. +237 sections. Search this before re-deriving anything. * [P0 — the exporter, 2026-08-28](#p0--the-exporter-2026-08-28) * [P1 — Godot draws the screen, 2026-08-28](#p1--godot-draws-the-screen-2026-08-28) @@ -246,6 +246,8 @@ dies, which is what this file is for. * [Their Q10 correction checked, and the register's cost is per-*mention*, not per-correction](#their-q10-correction-checked-and-the-registers-cost-is-per-mention-not-per-correction) * [The contract I read every iteration is 3 185 lines shorter than the contract](#the-contract-i-read-every-iteration-is-3-185-lines-shorter-than-the-contract) * [A refutation attempt on `+0x08 is the loop length` — it survives, and the port adopts it](#a-refutation-attempt-on-0x08-is-the-loop-length--it-survives-and-the-port-adopts-it) +* [The contract is checked now, not read — `tools/port/contract-check`](#the-contract-is-checked-now-not-read--toolsportcontract-check) +* [A refutation attempt on the fade numbers — it survives, from a third reader](#a-refutation-attempt-on-the-fade-numbers--it-survives-from-a-third-reader) ## P0 — the exporter, 2026-08-28 @@ -12342,3 +12344,74 @@ milestones without anybody finding out. and `ptbtn01f…05f` on the main menu all declaring the same 120-unit cycle, while `looping_focus_records` names only the plate. Whether those records *animate* while focused is behavioural — an ask, not an inference from the header. + +## The contract is checked now, not read — `tools/port/contract-check` + +Last iteration established that `docs/port/HANDOFF.md` on `main` is **926 lines +frozen at `9ca1eb5`** while the live document on the Decoder's branch is **4 111 +lines at `27938aa`** — now **99** unread commits. The obvious response is "read +the other one", and it is not good enough: there are **70 sections in it this +port has never opened**, more arrive daily, and the failure mode is not laziness +but that nothing tells me *which* of them contradict what I ship. + +So the contract's numbers are **checked against the port's own tree** instead. +Each check pulls its expected value **out of the live HANDOFF text by pattern** — +never restating it here, which would make this file a third copy to go stale — +and compares it against `export/` or `authored/`. + +| | contract | port | | +|---|---|---|---| +| fade quad, title / menu / extras | `[0,16,261,269]` `[0,12,70,80]` `[0,12,64,74]`, α 255/0/0/255 | identical | ✅ | +| fade-out ramps | 10, 10, **8** on the title | 10, 10, 8 | ✅ | +| plate glow cycle | 120 | 120 derived, 120 authored | ✅ | +| menu BGM loop window | `-ss 9.44 -t 61.87` | 9.44 / 61.87 | ✅ | +| black hold between screens | 0 | 0 | ✅ | +| menu BGM bank | `BGM_103` | `BGM_103.slb` | ✅ | +| boot splash dwells | 190 and 145 | 190 and 145 | ✅ | + +### Three outcomes, and the third is the point + +`ok`, `MISMATCH`, and **`ANCHOR LOST`** — the pattern no longer matches the +contract. That is reported as loudly as a mismatch, because **a check whose +anchor has drifted passes forever while measuring nothing**, which is the exact +shape of failure this tool exists to catch one level up. + +### The known negative, because a clean first run is not evidence + +`--control` perturbs the contract by one token per check — `120, not 105` becomes +`121, not 105`, `-ss 9.44` becomes `-ss 9.45` — and **requires every check to +fail**. All seven do. Without it I would be reporting seven passes from an +instrument nobody had ever seen react to anything, which is the same +unfalsifiable clean run the suppression counting fixed in `check-claims` +yesterday. Both are in `check-all` now, the control as its own asserting step. + +### 🔴 What a pass does not mean + +Seven values out of a 4 111-line contract. **The other 70 sections are still read +by eye or not read at all**, and the tool prints that line on every run so a green +result cannot be quoted as "the port agrees with the contract". + +## A refutation attempt on the fade numbers — it survives, from a third reader + +The claim to attack, per PROTOCOL's *prefer what the port is about to build on*: +HANDOFF's *"🔴 the transition is OVERLAP, not ramp-then-hold. **And your menu +fade-in is 5× too slow**"*, which corrected `screen-transitions.md` from a 0.97 s +menu fade-in to **12 units, 0.20 s**. Their cause: `fade_quads.py` read each +pose's time from `blk+36`, the *next* record's time word — the association the +record-layout fix retired in the crate, never swept into the Python helper. + +**The port never held that number.** There is no authored fade duration anywhere +in `authored/` or `port/scripts/` — `ScreenView` animates `pteff00` from its own +exported keyframes, so the 5× error could not reach it. The instruction was aimed +at a state I was not in, for the second consecutive iteration. + +✅ **But it makes my export an independent check on their correction**, and it +holds exactly: `[0,16,261,269]`, `[0,12,70,80]`, `[0,12,64,74]` with α +255/0/0/255, and fade-outs 10/10/8. **Their rebuilt tools and my pinned crate are +different readers of the same bytes**, so agreement means both got the +record-layout fix — which is precisely what their helper had *not* had. + +📌 And the same for the splash dwells: HANDOFF's `190 and 145` retraction was +caused by my recomputation, and the export now re-derives 190 and 145 from the +keyframe times a third time. A retraction confirmed by the party that provoked it +is worth less than one confirmed by a third reading; this is the third reading. diff --git a/tools/port/check-all b/tools/port/check-all index dc219e9d..1b0c2830 100755 --- a/tools/port/check-all +++ b/tools/port/check-all @@ -38,6 +38,12 @@ step() { # name, expectation, command... echo "asserting checks:" step format-validator must-pass "$BIN" check +# The contract lives on a branch this checkout does not merge: HANDOFF on `main` +# is frozen at 926 lines while the live one is 4 111. Reading 70 unread sections +# by hand is how two days of deliveries went unread. These are the values that +# have been reduced to a check; the rest are still read by eye, or not at all. +step contract-values must-pass tools/port/contract-check +step contract-control must-pass tools/port/contract-check --control step modding-rules must-pass tools/port/check-modding step capture-controls must-pass tools/port/check-capture-controls step menu-audio must-pass env OUT="$OUT/audio" tools/port/verify-menu-audio diff --git a/tools/port/contract-check b/tools/port/contract-check new file mode 100755 index 00000000..b30b640c --- /dev/null +++ b/tools/port/contract-check @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Reconcile the numbers the CONTRACT states against the numbers the PORT ships. + +`docs/port/HANDOFF.md` is the contract, and this port reads it from `main` -- +where it is frozen at 926 lines while the live document, on the Decoder's branch, +is 4 111. Two days of deliveries addressed to the port landed on a page the port +does not open. Reading 70 unread sections by hand is how that gets missed again. + +So the values are checked instead of read. Each check names a quantity, pulls it +OUT OF THE LIVE HANDOFF TEXT by pattern -- never restating it here, or this file +would be a third copy to go stale -- and compares it against the port's own +`export/` tree or `authored/` mapping. + +Three outcomes, and the third is the point: + + ok the contract and the port agree + MISMATCH they disagree; one of us is wrong and this says which values + ANCHOR the pattern no longer matches the contract -- the check has STOPPED + CHECKING. Reported as loudly as a mismatch, because a check whose + anchor has drifted passes forever while measuring nothing. + +Reads the newest HANDOFF on ANY ref, not the working tree's, and says which. +""" +import json, re, subprocess, sys, os + +FAIL = 0 + + +def git(*a): + return subprocess.run(["git", *a], capture_output=True, text=True).stdout + + +def contract(): + """The newest HANDOFF anywhere, and how far the working tree's copy is behind.""" + sha = git("log", "--all", "--format=%h", "--", "docs/port/HANDOFF.md").split()[0] + mine = git("log", "-1", "--format=%h", "--", "docs/port/HANDOFF.md").strip() + text = git("show", f"{sha}:docs/port/HANDOFF.md") + behind = len(git("log", "--all", "--not", "HEAD", "--format=%h", + "--", "docs/port/HANDOFF.md").split()) + print(f" contract: {sha} ({len(text.splitlines())} lines)") + print(f" my copy : {mine} ({len(git('show', f'{mine}:docs/port/HANDOFF.md').splitlines())} lines)" + f"{'' if behind == 0 else f' <- {behind} HANDOFF commit(s) unread'}") + return text + + +def report(name, want, got, ok): + global FAIL + if want is None: + FAIL += 1 + print(f" {name:<30} 🔴 ANCHOR LOST -- the contract no longer states this") + elif ok: + print(f" {name:<30} ok contract {want} port {got}") + else: + FAIL += 1 + print(f" {name:<30} 🔴 MISMATCH contract {want} port {got}") + + +def jload(p): + return json.load(open(p)) if os.path.exists(p) else None + + +def el(screen, prefix): + d = jload(f"export/screens/title/{screen}.json") + if not d: + return None + return next((e for e in d["elements"] if e["id"].startswith(prefix)), None) + + +# --- the checks ------------------------------------------------------------ + +def check_fade_quads(h): + """The fade-in that a broken helper reported 5x too slow for years. + + The contract prints the three builds' `pteff00` poses in one fence. The port + animates that quad from its OWN export, so agreement here is two readers of + the same bytes -- theirs rebuilt after the record-layout fix, mine the pinned + crate -- and a disagreement would mean one reader never got the fix. + """ + for screen, build in (("title", 4), ("main_menu", 5), ("extras", 6)): + m = re.search(rf"build {build} \([^)]*\)\s+pteff00\.prm\s+(.+)", h) + want = None + if m: + want = [(int(t), int(a)) for t, a in re.findall(r"t=\s*(\d+)\s*α=(\d+)", m.group(1))] + e = el(screen, "pteff00") + got = [(k["t"], int(k["fade_argb"][2:4], 16)) for k in e["keyframes"]] if e else None + report(f"fade quad, {screen}", want, got, want is not None and want == got) + + +def check_plate_period(h): + """`+0x08` is the loop length: 120, and the port must not run the glow at 105.""" + m = re.search(r"the plate's pulse period is (\d+), not (\d+)", h) + want = int(m.group(1)) if m else None + e = el("press_start", "ptbtn00") + got = (e.get("focus") or {}).get("loop_length_units") if e else None + report("plate glow cycle", want, got, want is not None and want == got) + a = jload("authored/timing.json") or {} + auth = a.get("looping_focus_records", {}).get("press_start/ptbtn00", {}).get("period_units") + report(" ... authored 2nd witness", want, auth, want is not None and want == auth) + + +def check_bgm_window(h): + """The menu loop, as an ffmpeg window the contract states literally.""" + m = re.search(r"the window is \*\*`-ss ([\d.]+) -t ([\d.]+)`\*\*", h) + want = (float(m.group(1)), float(m.group(2))) if m else None + a = ((jload("authored/audio.json") or {}).get("bgm") or {}).get("main_menu", {}) + got = (a.get("loop_start_s"), a.get("loop_end_s")) + report("menu BGM loop window", want, got, want is not None and want == got) + + +def check_black_hold(h): + """The gap between screens is not a load: the contract says keep it at 0.""" + m = re.search(r"Keep `black_hold_units` at (\d+)", h) + want = int(m.group(1)) if m else None + got = (jload("authored/timing.json") or {}).get("black_hold_units") + report("black hold between screens", want, got, want is not None and want == got) + + +def check_menu_bank(h): + """Which bank the menu plays -- the row the port once got wrong by authoring.""" + m = re.search(r"`(BGM_\d+)` confirmed from the RUNTIME", h) + want = m.group(1) if m else None + got = (((jload("authored/audio.json") or {}).get("bgm") or {}) + .get("main_menu", {}).get("bank", "")) + report("menu BGM bank", want, got, want is not None and got.startswith(want)) + + +def check_fade_out(h): + """The fade-OUT lengths, derived from the same poses the fade-in check reads. + + Stated as prose rather than in the fence, so this parses the sentence. Split + from the fade-in deliberately: they came from the same broken helper, and a + single check covering both would let one wrong half hide behind a right one. + """ + m = re.search(r"Fade-out = (\d+) units, (\d+) units, and \*\*(\d+)\*\* on the title", h) + want = [int(m.group(i)) for i in (1, 2, 3)] if m else None + got = [] + for screen in ("main_menu", "extras", "title"): + e = el(screen, "pteff00") + ks = [k["t"] for k in e["keyframes"]] if e else [] + got.append(ks[-1] - ks[-2] if len(ks) >= 2 else None) + report("fade-out ramps", want, got, want is not None and want == got) + + +def check_splash_dwell(h): + """The two boot splashes' dwell -- the retraction the port's recomputation caused. + + The contract gives 190 and 145 as the widest gap in each entry's own times. + The port plays the declared timeline, so the same gap must come out of the + export. This is the retracted claim re-derived from a third reading. + """ + m = re.search(r"the splashes are (\d+) and (\d+)", h) + want = [int(m.group(1)), int(m.group(2))] if m else None + got = [] + for screen in ("publisher_logo", "developer_logos"): + d = jload(f"export/screens/title/{screen}.json") + ts = sorted({k["t"] for e in d["elements"] for k in e["keyframes"]}) if d else [] + got.append(max((b - a for a, b in zip(ts, ts[1:])), default=None)) + report("boot splash dwells", want, got, want is not None and want == got) + + +# Each check paired with a one-token edit to the CONTRACT that must break it. +# A check that has never been observed to fail is not evidence -- it may be +# reading nothing, comparing a value to itself, or anchored on a pattern that +# matches anything. `--control` perturbs the contract and requires every check to +# notice. This is the same discipline the checks themselves enforce: an +# instrument goes through a known negative before its clean run is believed. +CONTROLS = [ + (check_fade_quads, "pteff00.prm t= 0 α=255 t= 12", "pteff00.prm t= 0 α=255 t= 13"), + (check_fade_out, "Fade-out = 10 units, 10 units", "Fade-out = 11 units, 10 units"), + (check_plate_period, "pulse period is 120, not 105", "pulse period is 121, not 105"), + (check_bgm_window, "`-ss 9.44 -t 61.87`", "`-ss 9.45 -t 61.87`"), + (check_black_hold, "Keep `black_hold_units` at 0", "Keep `black_hold_units` at 3"), + (check_menu_bank, "`BGM_103` confirmed from the RUNTIME", "`BGM_999` confirmed from the RUNTIME"), + (check_splash_dwell, "the splashes are 190 and 145", "the splashes are 191 and 145"), +] + + +def control(h): + global FAIL + import io, contextlib + ok = True + print(" known negatives -- every check must notice a perturbed contract:\n") + for fn, old, new in CONTROLS: + if old not in h: + print(f" {fn.__name__:<22} 🔴 the control's own anchor is gone") + ok = False + continue + before, FAIL = FAIL, 0 + with contextlib.redirect_stdout(io.StringIO()): + fn(h.replace(old, new, 1)) + noticed, FAIL = FAIL > 0, before + print(f" {fn.__name__:<22} {'✅ fails as it must' if noticed else '🔴 PASSES A WRONG CONTRACT -- it checks nothing'}") + ok = ok and noticed + return ok + + +def main(): + if not os.path.exists("export/manifest.json"): + sys.exit("no export/ -- run the exporter first; this check reads what is shipped") + h = contract() + print() + if "--control" in sys.argv: + return 0 if control(h) else 1 + for fn in (check_fade_quads, check_fade_out, check_plate_period, + check_bgm_window, check_black_hold, check_menu_bank, + check_splash_dwell): + fn(h) + print() + print(" A passing run means the port agrees with the contract ON THESE VALUES.") + print(" It is not a statement about the 70 sections nobody has reduced to a") + print(" check -- those are still read by hand, or not read at all.") + if FAIL: + print(f"\n🔴 {FAIL} disagreement(s) or lost anchor(s) with the contract") + else: + print("\nthe port agrees with the contract on every value checked") + return 1 if FAIL else 0 + + +sys.exit(main())