port: land the play-tested work, and only that
Takes the port branch up to77320d5e-- the state the human play-tested on 2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio` is 366 commits and 938 files, and most of that must not land. WHAT COMES IN (76 files, all human-confirmed working): * the logo splash animation.08ed3dd1found it: `pose_at` ASSIGNED the settle instant instead of clamping to it, so the splash never animated at all -- and the same bug manufactured a passing harness result, because the harness photographed t past the settle. Confirmed by play-test: "cannot notice any obvious difference from the actual game." * gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad binding), stick latched with hysteresis at the game's own 61% digitise threshold. This is what made (A), video-skip and Extras work at all. * menu navigation and flow, menu audio, the exporter, the authored declarations, and 23 verification tools under tools/port/. WHAT IS DELIBERATELY LEFT ON THE BRANCH: * everything afterc0ae460a-- the F5/F6 title-timing investigation, whose own tip commit calls itself a "hand-off for one-minute human checks". Unchecked by definition; it goes through the new review gate like anything else. * the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested. * the F1 repeat mechanism, which its own commit calls "deliberately inert". WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED: 545 MB of extracted game content was committed on that branch -- 850 sprite, audio and transcoded video files under `export-probe/` and `export-probe2/`, plus 246 MB of loose .wav and .tsv at the repo root. This repository's own rule, in this file, is "never game content". The rule was not missing. It was written, and it was tightened on that very branch, with a careful comment explaining why BOTH `export/` and `data/base/` had to be listed -- while the exporter was writing to a third name that nobody had thought to list. Enumerating names is the thing that failed. So the ignore rules now describe the SHAPE: any top-level `export*/`, game media by extension, and loose capture output at the root. Verified both ways -- it catches all four offenders and ignores nothing currently tracked. Verified: `cargo check --workspace` clean; all nine GDScript files parse in project context, with a positive control (an injected syntax error is detected, 3 lines) so the clean result means something. `tools/port/check-all` was NOT run -- it needs the container, the export tree and a display.
This commit is contained in:
@@ -46,8 +46,69 @@ from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
_BACKEND = "pillow"
|
||||
except ImportError:
|
||||
sys.exit("motion-census: needs Pillow (pip install pillow)")
|
||||
# 🔴 FALLBACK, NOT A SECOND IMPLEMENTATION. The port's container has no
|
||||
# Pillow and no pip, so the tool could not run at all there -- and a tool the
|
||||
# port cannot run is a check the port does not have, which is how this class
|
||||
# of defect survived in the first place.
|
||||
#
|
||||
# This shims only the three Pillow calls used below (open+convert, crop,
|
||||
# resize+getdata, and new+save for the selftest) onto ImageMagick. The census
|
||||
# arithmetic, the MOVED floor and the GRID are untouched, so the numbers are
|
||||
# the tool's and not a re-derivation.
|
||||
#
|
||||
# `-grayscale Rec601Luma` rather than `-colorspace Gray`: Rec601 is what
|
||||
# Pillow's `.convert("L")` uses, and IM7's `-colorspace Gray` linearises
|
||||
# first, which would shift every value. Verified to round-trip a flat
|
||||
# rgb(100,100,100) to exactly 100 on this build.
|
||||
#
|
||||
# ⚠️ The --selftest is what makes this safe to trust: it drives the SAME
|
||||
# fade / switch / frozen discrimination through whichever backend is active,
|
||||
# so a shim that distorted the pixels would fail its own control.
|
||||
import subprocess
|
||||
|
||||
_BACKEND = "imagemagick"
|
||||
|
||||
class _IMImage:
|
||||
def __init__(self, path=None, size=None, value=None):
|
||||
self._path, self._size, self._value = path, size, value
|
||||
self._crop = None
|
||||
|
||||
def convert(self, _mode):
|
||||
return self
|
||||
|
||||
def crop(self, box):
|
||||
x0, y0, x1, y1 = box
|
||||
self._crop = (x1 - x0, y1 - y0, x0, y0)
|
||||
return self
|
||||
|
||||
def resize(self, grid):
|
||||
self._grid = grid
|
||||
return self
|
||||
|
||||
def getdata(self):
|
||||
cmd = ["convert", self._path]
|
||||
if self._crop:
|
||||
cmd += ["-crop", "%dx%d+%d+%d" % self._crop, "+repage"]
|
||||
cmd += ["-grayscale", "Rec601Luma",
|
||||
"-resize", "%dx%d!" % self._grid, "-depth", "8", "gray:-"]
|
||||
out = subprocess.run(cmd, capture_output=True).stdout
|
||||
return list(out)
|
||||
|
||||
def save(self, path):
|
||||
subprocess.run(["convert", "-size", "%dx%d" % self._size,
|
||||
"xc:rgb(%d,%d,%d)" % ((self._value,) * 3),
|
||||
"-grayscale", "Rec601Luma", str(path)], check=True)
|
||||
|
||||
class Image: # noqa: F811 - deliberate stand-in, same call surface
|
||||
@staticmethod
|
||||
def open(path):
|
||||
return _IMImage(path=str(path))
|
||||
|
||||
@staticmethod
|
||||
def new(_mode, size, value):
|
||||
return _IMImage(size=size, value=int(value))
|
||||
|
||||
# Below this, two frames are the same picture. Chosen as a floor, not tuned: PNG
|
||||
# frames of an unchanged scene differ by exactly 0.000, so anything above noise
|
||||
|
||||
319
tools/port/audit-kinds
Executable file
319
tools/port/audit-kinds
Executable file
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""What does each `kind` label in `authored/` actually REST on?
|
||||
|
||||
Every authored entry carries a `kind` -- `measured`, `authored`, `name match,
|
||||
not measured` -- and a `why`. The label is the load-bearing part: `measured`
|
||||
means the port is repeating something observed off the running game, and a
|
||||
reader downstream will treat it as fact.
|
||||
|
||||
Nothing has ever checked them. That is the point: **a discipline that has never
|
||||
visibly failed is the one nothing directs attention at.** The Decoder reached
|
||||
this from the input side -- Ⓐ and Ⓑ were delivery-confirmed because they had
|
||||
once broken, so the d-pad never was -- and on the same day a `measured` label of
|
||||
mine turned out to rest on a single entry that may have been measuring history.
|
||||
|
||||
So this checks what is checkable about a label, and is explicit that the rest is
|
||||
not:
|
||||
|
||||
citations resolvable references in the `why` -- a `docs/` path that exists on
|
||||
some ref, a commit sha that resolves, a capture filename
|
||||
BARE a label whose `why` cites nothing a reader could go and open
|
||||
DANGLING a citation that does not resolve anywhere in the repository
|
||||
|
||||
🔴 What it CANNOT do is read the cited page and confirm it says what the `why`
|
||||
claims. A label with three resolvable citations can still be wrong. This narrows
|
||||
"which labels rest on nothing" from unknown to a list; it does not audit meaning.
|
||||
"""
|
||||
import json, glob, os, re, subprocess, sys
|
||||
|
||||
REFS = None
|
||||
|
||||
|
||||
def known_paths():
|
||||
"""Every path in the repo, across ALL refs -- docs/re/ lives on a branch.
|
||||
|
||||
Checked against the working tree as well: a file added this iteration is not
|
||||
in any ref yet, and reporting a citation to it as unresolvable would make the
|
||||
audit fail every time it is itself referenced.
|
||||
"""
|
||||
global REFS
|
||||
if REFS is None:
|
||||
out = subprocess.run(["git", "rev-list", "--all", "--objects"],
|
||||
capture_output=True, text=True).stdout
|
||||
REFS = {l.split(" ", 1)[1] for l in out.splitlines() if " " in l}
|
||||
return REFS
|
||||
|
||||
|
||||
HANDOFF_TEXT = None
|
||||
|
||||
|
||||
def handoff():
|
||||
"""The live HANDOFF, so a cited Q number is checked against the real table."""
|
||||
global HANDOFF_TEXT
|
||||
if HANDOFF_TEXT is None:
|
||||
sha = subprocess.run(["git", "log", "--all", "--format=%h", "--",
|
||||
"docs/port/HANDOFF.md"], capture_output=True,
|
||||
text=True).stdout.split()[0]
|
||||
HANDOFF_TEXT = subprocess.run(["git", "show", f"{sha}:docs/port/HANDOFF.md"],
|
||||
capture_output=True, text=True).stdout
|
||||
return HANDOFF_TEXT
|
||||
|
||||
|
||||
def sha_ok(s):
|
||||
r = subprocess.run(["git", "cat-file", "-e", s + "^{commit}"], capture_output=True)
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
def text_of(why):
|
||||
if isinstance(why, str):
|
||||
return why
|
||||
if isinstance(why, list):
|
||||
return " ".join(str(x) for x in why)
|
||||
return ""
|
||||
|
||||
|
||||
def citations(t):
|
||||
"""References a reader could actually follow."""
|
||||
out = []
|
||||
for p in re.findall(r"\b(?:docs|crates|port|tools|authored)/[\w./-]+\w", t):
|
||||
out.append(("path", p.rstrip(".,")))
|
||||
for sha in re.findall(r"\b([0-9a-f]{7,40})\b", t):
|
||||
# 🔴 A PURE-DECIMAL RUN IS NOT A SHA. `1118268` and `1171516` are byte
|
||||
# counts in `voice/presentation_why`, and this reported them as
|
||||
# unresolvable commits -- a DANGLING verdict on a why that cites
|
||||
# nothing of the kind. A sha in this corpus always carries at least one
|
||||
# of a-f; requiring that removes the whole class without a length rule.
|
||||
if any(c in "abcdef" for c in sha):
|
||||
out.append(("sha", sha))
|
||||
for p in re.findall(r"\b([\w-]+\.(?:png|txt|tsv|wav))\b", t):
|
||||
out.append(("file", p))
|
||||
# The corpus cites two things that are not paths and are still followable:
|
||||
# a HANDOFF question number, and a MISSION section. Leaving these out made
|
||||
# the first run report four labels as resting on nothing when they rest on
|
||||
# the two documents the mission names -- an audit inventing defects is worse
|
||||
# than no audit, because its false positives are indistinguishable from its
|
||||
# true ones until each is opened.
|
||||
for q in re.findall(r"HANDOFF Q(\d+)", t):
|
||||
out.append(("handoff", "Q" + q))
|
||||
for m in re.findall(r"(PORT-MISSION|MISSION)[ ]section[ ](\d+)", t):
|
||||
out.append(("mission", m[1]))
|
||||
for r in re.findall(r"MODDING rule (\d+)", t):
|
||||
out.append(("modding", r))
|
||||
# 🔴 A CAPTURE FILENAME IS A CITATION and this could not see one. Five of the
|
||||
# sixteen `why` fields I reported as uncited name `live-extras.png` or an
|
||||
# equivalent -- openable, in `docs/re/captures/`, and exactly the evidence a
|
||||
# reader wants. My published "17 uncited" was inflated by a third by my own
|
||||
# extractor, which is the invents-defects failure aimed at my own backlog.
|
||||
for cap in re.findall(r"\b([\w-]+\.(?:png|txt|wav|tsv))\b", t):
|
||||
out.append(("capture", cap))
|
||||
# ⚠️ A bare `HANDOFF` names the document and not the section. Counted, and
|
||||
# counted SEPARATELY, because "the contract says so" is a weaker pointer than
|
||||
# "Q5 says so" -- it sends a reader to 4 000 lines.
|
||||
if re.search(r"\bHANDOFF\b", t) and not re.search(r"HANDOFF Q\d+", t):
|
||||
out.append(("handoff-vague", "HANDOFF"))
|
||||
return out
|
||||
|
||||
|
||||
def walk(o, f, path, out):
|
||||
if isinstance(o, dict):
|
||||
for k, v in o.items():
|
||||
if (k == "kind" or k.endswith("_kind")) and isinstance(v, str):
|
||||
stem = "" if k == "kind" else k[: -len("_kind")]
|
||||
own = o.get((stem + "_why") if stem else "why")
|
||||
# 🔴 An earlier version fell back to the parent's `why` when a
|
||||
# label had none of its own, and reported the result as `ok`.
|
||||
# That credits a label with evidence for a DIFFERENT claim:
|
||||
# every `goto_name_kind` scored on a sibling `why` about the
|
||||
# DESTINATION, while the label is about where the NAME came
|
||||
# from. Borrowed evidence is now its own outcome, because a
|
||||
# label resting on a neighbour's argument is exactly the case
|
||||
# this audit exists to surface.
|
||||
out.append((f, path + "/" + k, v, text_of(own),
|
||||
own is None and bool(text_of(o.get("why")))))
|
||||
walk(v, f, path + "/" + k, out)
|
||||
elif isinstance(o, list):
|
||||
for x in o:
|
||||
walk(x, f, path, out)
|
||||
|
||||
|
||||
def selftest():
|
||||
"""Does this audit notice a label that rests on nothing?
|
||||
|
||||
🔴 THE GAP: `audit-kinds` has always reported what it found and never been
|
||||
asked whether it can find anything. A walk that matched no labels, a citation
|
||||
extractor that accepted everything, or a `main` that returned 0 regardless
|
||||
would all have produced the same clean run -- and clean runs from this tool
|
||||
are cited in `DECISIONS.md` as evidence that fifteen labels are grounded.
|
||||
|
||||
Three synthetic rows are pushed through the REAL classifier, and its verdict
|
||||
is read rather than reasoned about:
|
||||
|
||||
a `why` citing nothing -> must be BARE
|
||||
a `why` citing a path that exists -> must be ok
|
||||
a `why` citing a path that does not -> must be DANGLING
|
||||
|
||||
Exit codes follow the convention the Decoder and I converged on: 0 all good,
|
||||
1 a real audit failure, **2 the harness is broken** and no clean run from it
|
||||
means anything.
|
||||
"""
|
||||
paths = known_paths()
|
||||
# The liveness case belongs in the self-test too, driven as a subprocess so
|
||||
# its real exit code is read rather than reasoned about.
|
||||
empty = os.path.join(os.environ.get("TMPDIR", "/tmp"), "audit-kinds-liveness")
|
||||
os.makedirs(empty, exist_ok=True)
|
||||
got = subprocess.run([sys.executable, os.path.abspath(__file__)], cwd=empty,
|
||||
capture_output=True).returncode
|
||||
print(f" harness: an empty tree -> exit {got} (want 2) "
|
||||
f"{'✅' if got == 2 else '🔴 examined nothing and reported clean'}")
|
||||
live_ok = got == 2
|
||||
cases = [
|
||||
("bare", "no citation of any kind here, just prose", "BARE"),
|
||||
("ok", "see tools/port/audit-kinds for the method", "ok"),
|
||||
("dangling", "see docs/port/NO-SUCH-FILE-XYZ.md", "DANGLING"),
|
||||
]
|
||||
bad = 0
|
||||
for name, why, want in cases:
|
||||
cites = citations(why)
|
||||
if not cites:
|
||||
got = "BARE"
|
||||
else:
|
||||
unresolved = [c for t, c in cites
|
||||
if t == "path" and c not in paths and not os.path.exists(c)]
|
||||
got = "DANGLING" if unresolved else "ok"
|
||||
mark = "✅" if got == want else "🔴"
|
||||
print(f" harness: a why that is {name:<9} -> {got:<8} (want {want:<8}) {mark}")
|
||||
if got != want:
|
||||
bad += 1
|
||||
print()
|
||||
if not live_ok:
|
||||
bad += 1
|
||||
if bad:
|
||||
print("🔴 the classifier cannot tell grounded labels from ungrounded ones,")
|
||||
print(" or it reports clean on an empty tree.")
|
||||
print(" Exit 2: nothing this tool has reported clean is trustworthy.")
|
||||
return 2
|
||||
print("the classifier separates bare, dangling and grounded citations")
|
||||
return 0
|
||||
|
||||
|
||||
def coverage(files):
|
||||
"""How much of the authored corpus this audit can even see.
|
||||
|
||||
🔴 IT SEES 15 OF 70. Every `kind` label is checked for a citation, and a
|
||||
clean run has been quoted in `DECISIONS.md` as evidence that the authored
|
||||
data is grounded -- but a `why` with NO `kind` beside it is invisible to this
|
||||
walk entirely, and there are 55 of those against 15 labels.
|
||||
|
||||
Found by reading the data rather than the tool: `audio.json`'s three SE cues
|
||||
carry measured provenance from HANDOFF Q8 and no `kind` field, so the audit
|
||||
that exists to check provenance never looked at them.
|
||||
|
||||
⚠️ NOT every `why` should have a `kind`. Section prose and `_` blocks explain
|
||||
a group rather than assert one value's provenance, and forcing a label there
|
||||
would invite mislabelling to satisfy a counter. So this REPORTS the ratio
|
||||
rather than demanding it be 1 -- a clean run must not read as full coverage.
|
||||
"""
|
||||
labelled = orphan = 0
|
||||
for f in files:
|
||||
def walk(o):
|
||||
nonlocal labelled, orphan
|
||||
if isinstance(o, dict):
|
||||
for k, v in o.items():
|
||||
if k.endswith("_why") or k == "why":
|
||||
stem = k[:-4] if k.endswith("_why") else ""
|
||||
kk = (stem + "_kind") if stem else "kind"
|
||||
if kk in o:
|
||||
labelled += 1
|
||||
else:
|
||||
orphan += 1
|
||||
walk(v)
|
||||
elif isinstance(o, list):
|
||||
for x in o:
|
||||
walk(x)
|
||||
walk(json.load(open(f, encoding="utf-8")))
|
||||
return labelled, orphan
|
||||
|
||||
|
||||
def main():
|
||||
if "--selftest" in sys.argv:
|
||||
return selftest()
|
||||
rows = []
|
||||
for f in sorted(glob.glob("authored/*.json")):
|
||||
walk(json.load(open(f)), f, "", rows)
|
||||
# 🔴 LIVENESS. Run against a tree with no `authored/*.json` this printed
|
||||
# "0 kind label(s)" and exited 0 -- examined nothing, reported clean. The
|
||||
# Decoder's generalisation of my empty-band case, which is more general than
|
||||
# either instance: **a control that only compares two things cannot tell you
|
||||
# the comparison is happening.** An empty input makes a checker AGREEABLE
|
||||
# rather than wrong, and agreeable is indistinguishable from correct in a
|
||||
# log.
|
||||
if not rows:
|
||||
print("🔴 no `kind` labels found at all -- this audit examined NOTHING.")
|
||||
print(" Exit 2: the harness is broken (wrong directory, renamed files),")
|
||||
print(" not the corpus.")
|
||||
return 2
|
||||
paths = known_paths()
|
||||
bare = dangling = 0
|
||||
kinds = {}
|
||||
print(f" {len(rows)} kind label(s) in authored/\n")
|
||||
for f, where, kind, why, borrowed in rows:
|
||||
kinds.setdefault(kind, 0)
|
||||
kinds[kind] += 1
|
||||
cites = citations(why)
|
||||
bad = []
|
||||
for typ, c in cites:
|
||||
if typ == "capture":
|
||||
if c not in paths and not os.path.exists(c) \
|
||||
and not any(p.endswith("/" + c) for p in paths):
|
||||
bad.append(c)
|
||||
elif typ == "handoff":
|
||||
if not re.search(rf"\|\s*{c}\s*\|", handoff()):
|
||||
bad.append(f"HANDOFF {c} (no such row)")
|
||||
elif typ == "path" and c not in paths and not os.path.exists(c):
|
||||
bad.append(c)
|
||||
elif typ == "sha" and not sha_ok(c):
|
||||
bad.append(c)
|
||||
mark = "ok "
|
||||
if not cites and borrowed:
|
||||
mark, bare = "🔴 BORROW", bare + 1
|
||||
elif not cites:
|
||||
mark, bare = "🔴 BARE", bare + 1
|
||||
elif bad:
|
||||
mark, dangling = "🔴 DANGL", dangling + 1
|
||||
print(f" {mark} {kind:<24} {f.split('/')[-1]}{where}")
|
||||
if not cites and borrowed:
|
||||
print(" no `why` of its own; a sibling `why` argues a"
|
||||
" DIFFERENT claim")
|
||||
elif not cites:
|
||||
print(f" cites nothing openable -- {len(why)} chars of prose")
|
||||
elif bad:
|
||||
print(f" unresolvable: {', '.join(sorted(set(bad))[:4])}")
|
||||
else:
|
||||
print(f" {len(cites)} citation(s), all resolve")
|
||||
print()
|
||||
# Casing is checked because a consumer comparing == "measured" silently
|
||||
# misses "MEASURED", and a label that fails to match reads as absent.
|
||||
variants = [k for k in kinds if k.lower() == "measured"]
|
||||
if len(variants) > 1:
|
||||
print(f" ⚠️ {len(variants)} spellings of the same label: {variants}")
|
||||
print(" A consumer comparing == 'measured' misses the others, and a")
|
||||
print(" label that fails to match reads as ABSENT, not as wrong.\n")
|
||||
lab, orph = coverage(sorted(glob.glob("authored/*.json")))
|
||||
print(f" COVERAGE: {lab} `why` field(s) carry a `kind` and were audited above;")
|
||||
print(f" {orph} carry NO `kind` and are INVISIBLE to this audit. A clean run")
|
||||
print(f" below is a statement about {lab} of {lab + orph} authored justifications.")
|
||||
print(" ⚠️ The denominator is not a target. Of the unlabelled ones, the great")
|
||||
print(" majority are SECTION PROSE -- `_` blocks and group explanations that")
|
||||
print(" assert no single value's provenance, where a label would be")
|
||||
print(" mislabelling to satisfy a counter. What was audited on 2026-09-01 is")
|
||||
print(" the other kind: a `why` sitting beside an actual VALUE. Thirteen of")
|
||||
print(" those existed unlabelled; all thirteen now carry a kind, and two of")
|
||||
print(" them failed the citation check the moment they became visible.")
|
||||
print()
|
||||
print(f" {bare} bare or borrowed, {dangling} dangling, {len(rows) - bare - dangling} with resolving citations")
|
||||
print(" 🔴 A resolving citation is not a verified label. Nothing here reads")
|
||||
print(" the cited page to confirm it says what the `why` claims.")
|
||||
return 1 if (bare or dangling) else 0
|
||||
|
||||
|
||||
sys.exit(main())
|
||||
226
tools/port/blocked-provenance
Executable file
226
tools/port/blocked-provenance
Executable file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Date every open row in BLOCKED.md from history, instead of guessing.
|
||||
|
||||
`BLOCKED.md` is required to record the HANDOFF commit each row derives from, and
|
||||
none of the rows in the two open tables do. The file itself says why: nobody
|
||||
knows when most of them were written, and inventing a sha would be worse than
|
||||
admitting there is none.
|
||||
|
||||
But git does know. A row's derivation is not a memory, it is the commit that
|
||||
introduced the row -- recoverable with a pickaxe over the file's own history.
|
||||
This prints, per row:
|
||||
|
||||
introduced the oldest commit whose diff added the row's key phrase
|
||||
HANDOFF@ `git log -1 -- docs/port/HANDOFF.md` as of that commit
|
||||
unread commits touching docs/re/ ON ANY REF that are not ancestors of
|
||||
that commit -- decoding the row has never been read against
|
||||
|
||||
`--all`, not my own ancestry, and that distinction is the whole finding. Counted
|
||||
against my checkout every row scores ZERO, which is true and useless: the
|
||||
Decoder's live decoding sits on `origin/auto/no-disc-and-menu-captures`, `main`
|
||||
is a hundred-odd commits behind it, and HANDOFF has not moved in four
|
||||
milestones [refuted] -- 🔴 corrected 2026-09-01: **on `main`**. Flat, that
|
||||
sentence is the claim this port WITHDREW in `BLOCKED.md` on 2026-08-30, where the
|
||||
missing qualifier was recorded as carrying the whole meaning: HANDOFF has moved
|
||||
over a hundred times, just not on the branch this checkout reads. The reasoning
|
||||
below needs the qualifier to work at all -- the sha is constant BECAUSE `main`'s
|
||||
copy is frozen, not because the document is. So a row can be derived from the
|
||||
newest HANDOFF `main` has and still
|
||||
be a day behind the decoding -- and the instruction to record the HANDOFF sha
|
||||
CANNOT DETECT THAT, because the sha it asks for is constant.
|
||||
|
||||
That is the rot mechanism the 2026-08-30 audit found three instances of, and it
|
||||
is not the one the header of BLOCKED.md describes.
|
||||
|
||||
Nothing here is authored. Every field is read out of git, and a row whose key
|
||||
phrase has been rewritten since it was introduced reports `?` rather than a
|
||||
plausible-looking sha.
|
||||
"""
|
||||
import re, subprocess, sys
|
||||
|
||||
DOC = "docs/port/BLOCKED.md"
|
||||
|
||||
|
||||
def git(*a):
|
||||
return subprocess.run(["git", *a], capture_output=True, text=True).stdout.strip()
|
||||
|
||||
|
||||
TOP = 3
|
||||
|
||||
|
||||
def idf_of(commits):
|
||||
"""log(N / how many subjects use the word) -- rarity, from the corpus itself."""
|
||||
import collections, math
|
||||
df = collections.Counter()
|
||||
for _, subj in commits:
|
||||
df.update(tokens(subj))
|
||||
n = len(commits)
|
||||
return collections.defaultdict(lambda: math.log(n), {w: math.log(n / c) for w, c in df.items()})
|
||||
|
||||
|
||||
def key_of(cell):
|
||||
"""The longest markdown-free fragment -- what to pickaxe for.
|
||||
|
||||
Cells get struck through and re-emphasised as they are resolved, so the cell
|
||||
as it stands today is not what was committed. The inner text survives that.
|
||||
"""
|
||||
frags = [f.strip(" ?.") for f in re.split(r"[*~`]+", cell)]
|
||||
frags = [f for f in frags if len(f) >= 20]
|
||||
return max(frags, key=len) if frags else None
|
||||
|
||||
|
||||
def rows():
|
||||
"""Every table row in the open sections, in file order."""
|
||||
open_only, out = False, []
|
||||
for line in open(DOC, encoding="utf-8"):
|
||||
if line.startswith("## "):
|
||||
open_only = line.startswith("## Still open")
|
||||
continue
|
||||
if not open_only or not line.startswith("| "):
|
||||
continue
|
||||
cells = [c.strip() for c in line.strip().strip("|").split(" | ")]
|
||||
if len(cells) < 4 or cells[0] in ("Milestone", "---"):
|
||||
continue
|
||||
out.append(cells)
|
||||
return out
|
||||
|
||||
|
||||
STOP = set("""this that with from what which when does than the and are was were
|
||||
have has been will would could should port game screen menu audio does not any
|
||||
each only its it's whether where else same both very more most into onto over
|
||||
under about after before still open blocked answered measured wrong right first
|
||||
second third disc file files commit branch docs main head sha row rows table
|
||||
mission handoff decoder agent claim claims""".split())
|
||||
|
||||
|
||||
def stem(w):
|
||||
"""Crudest possible stemmer, and it earns its place with a control.
|
||||
|
||||
Without it `looping` does not match `loop` and the P6 row whose answer is
|
||||
sitting in an unread commit scores zero -- which is what happened.
|
||||
"""
|
||||
for suf in ("ping", "ing", "ted", "ed", "es", "s"):
|
||||
if w.endswith(suf) and len(w) - len(suf) >= 4:
|
||||
return w[: -len(suf)]
|
||||
return w
|
||||
|
||||
|
||||
def tokens(text):
|
||||
ws = re.findall(r"[a-z0-9_]{4,}", text.lower())
|
||||
return {stem(w) for w in ws if w not in STOP}
|
||||
|
||||
|
||||
def rank(rt, commits, idf):
|
||||
"""Score every unread commit against one row, rarest words first.
|
||||
|
||||
A COUNT of shared words is the wrong instrument: `menu` and `loop` shared
|
||||
scores the same as `plate` and `pulse`, and in this corpus almost everything
|
||||
says `menu`. Weighting each shared stem by log(N / commits containing it)
|
||||
lets one rare word outrank two common ones -- and it removes the threshold,
|
||||
which was the part that could be tuned. The list is RANKED, fixed length,
|
||||
so nothing is decided by a cutoff nobody can justify.
|
||||
"""
|
||||
out = []
|
||||
for sha, subj in commits:
|
||||
shared = rt & tokens(subj)
|
||||
if shared:
|
||||
out.append((sum(idf[w] for w in shared), sha, subj, shared))
|
||||
return sorted(out, reverse=True)
|
||||
|
||||
|
||||
def overlap(rs):
|
||||
"""Which unread commits NAME something an open row is about.
|
||||
|
||||
Crude on purpose, and it says so: word overlap between a row and a commit
|
||||
SUBJECT, ranked by rarity, top few printed with the words that earned the
|
||||
rank so the reader judges rather than trusting the match. It cannot tell
|
||||
relevance from coincidence -- it narrows 196 commits to a short list worth
|
||||
opening, and nothing more.
|
||||
"""
|
||||
log = git("log", "--all", "--not", "HEAD", "--format=%h\t%s", "--", "docs/re/")
|
||||
commits = [l.split("\t", 1) for l in log.splitlines() if "\t" in l]
|
||||
print(f" {len(commits)} unread docs/re/ commit(s) exist on other refs.")
|
||||
print(" Crude word overlap with the open rows -- a reading list, not a verdict:\n")
|
||||
idf = idf_of(commits)
|
||||
hits = struck = dropped = 0
|
||||
for cells in rs:
|
||||
if cells[0].startswith("~~"):
|
||||
struck += 1 # already struck; re-reading it settles nothing
|
||||
continue
|
||||
scored = rank(tokens(cells[0] + " " + cells[1]), commits, idf)
|
||||
dropped += max(0, len(scored) - TOP)
|
||||
for score, sha, subj, shared in scored[:TOP]:
|
||||
hits += 1
|
||||
print(f" {re.sub(r'[*~`]', '', cells[0])[:36]:<36} {sha} {score:5.1f} {subj[:58]}")
|
||||
print(f" {'':<36} {'':<8} via {', '.join(sorted(shared))}")
|
||||
if not hits:
|
||||
print(" (no row shares a word with any unread commit)")
|
||||
# Every discard, counted. A detector that can drop a candidate in silence
|
||||
# has an unfalsifiable clean run -- which is how the P6 looping row stayed
|
||||
# marked open for a day while its answer sat in `712cac8`, and how the same
|
||||
# class of miss went unnoticed in the Decoder's checker on the same day.
|
||||
print(f"\n suppressed: {struck} struck row(s) not scanned; {dropped} scoring")
|
||||
print(f" pair(s) ranked below top-{TOP} and not shown; {len(STOP)} word(s)")
|
||||
print(" stoplisted and unable to match at any rank.")
|
||||
print()
|
||||
|
||||
|
||||
def control():
|
||||
"""Known positive: the row whose answer is demonstrably in an unread commit.
|
||||
|
||||
`P6 looping` asks where the menu loop restarts. `712cac8` measures it at
|
||||
9.44 s and the port has since shipped that value, so the pair MUST match. It
|
||||
did not, until stemming -- the check exists so that regression is loud.
|
||||
"""
|
||||
log = git("log", "--all", "--not", "HEAD", "--format=%h\t%s", "--", "docs/re/")
|
||||
commits = [l.split("\t", 1) for l in log.splitlines() if "\t" in l]
|
||||
scored = rank(tokens("P6 looping where a menu loop restarts"), commits, idf_of(commits))
|
||||
at = next((i for i, r in enumerate(scored) if r[1].startswith("712cac8")), None)
|
||||
ok = at is not None and at < TOP
|
||||
print(f" control: P6-looping vs 712cac8 -> rank {at} of {len(scored)} scoring "
|
||||
f"{'✅' if ok else f'🔴 OUTSIDE TOP-{TOP}, THE KNOWN POSITIVE IS MISSED'}")
|
||||
return ok
|
||||
|
||||
|
||||
def main():
|
||||
if "--control" in sys.argv:
|
||||
sys.exit(0 if control() else 1)
|
||||
rs = rows()
|
||||
if not rs:
|
||||
sys.exit(f"{DOC}: no rows found under a '## Still open' heading")
|
||||
head_handoff = git("log", "-1", "--format=%h", "--", "docs/port/HANDOFF.md")
|
||||
print(f" {DOC}: {len(rs)} rows in the open tables")
|
||||
print(f" HANDOFF is at {head_handoff} today\n")
|
||||
print(f" {'row':<44} {'introduced':<12} {'date':<11} {'HANDOFF@':<9} unread")
|
||||
unknown = 0
|
||||
for cells in rs:
|
||||
milestone, needs = cells[0], cells[1]
|
||||
label = re.sub(r"[*~`]", "", milestone)[:43]
|
||||
key = key_of(needs) or key_of(milestone)
|
||||
sha = date = handoff = "?"
|
||||
since = "-"
|
||||
if key:
|
||||
# oldest commit whose diff changed the number of occurrences
|
||||
log = git("log", "--format=%h %ad", "--date=short", "-S", key, "--", DOC)
|
||||
if log:
|
||||
sha, date = log.splitlines()[-1].split()
|
||||
handoff = git("log", "-1", "--format=%h", sha, "--", "docs/port/HANDOFF.md")
|
||||
unread = git("log", "--all", "--not", sha, "--format=%h", "--", "docs/re/")
|
||||
since = str(len(unread.splitlines())) if unread else "0"
|
||||
if sha == "?":
|
||||
unknown += 1
|
||||
flag = ""
|
||||
if since not in ("-", "0") and not milestone.startswith("~~"):
|
||||
flag = f" <- never read against {since} docs/re/ commit(s)"
|
||||
print(f" {label:<44} {sha:<12} {date:<11} {handoff:<9} {since:>3}{flag}")
|
||||
print()
|
||||
overlap(rs)
|
||||
if unknown:
|
||||
print(f" ⚠️ {unknown} row(s) could not be dated: the key phrase has been")
|
||||
print(" rewritten since it was introduced, so history cannot place it.")
|
||||
print(" Not a staleness verdict. A high `unread` is not a wrong row -- most of")
|
||||
print(" that decoding is irrelevant to most rows. It is the size of the surface")
|
||||
print(" nobody has looked at, and it is what the HANDOFF sha was supposed to be.")
|
||||
|
||||
|
||||
main()
|
||||
334
tools/port/check-all
Executable file
334
tools/port/check-all
Executable file
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run every check this port has, and say which ones assert.
|
||||
#
|
||||
# tools/port/check-all
|
||||
#
|
||||
# There are fourteen tools under `tools/port/` (eleven when this was written --
|
||||
# the count is stated because it dates the sentence) and nothing ran them
|
||||
# together, so
|
||||
# each had to be remembered individually. That is the ninth instance of this
|
||||
# port's recurring shape -- something correct, documented and unexercised -- one
|
||||
# level up: the checks themselves were the thing nobody was running.
|
||||
#
|
||||
# ⚠️ It runs the tools that ASSERT. The exploratory ones -- `screen-strip`,
|
||||
# `which-focus`, `strip-padding`, `verify-dwell`, `check-capture`,
|
||||
# `verify-video-audio` -- produce artifacts for a person to look at and have no
|
||||
# verdict to collect. Listing them here as passes would be inventing six.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
export DISPLAY="${DISPLAY:-:97}"
|
||||
OUT="${OUT:-${TMPDIR:-/tmp}/check-all}"; mkdir -p "$OUT"
|
||||
BIN="${CARGO_TARGET_DIR:-/sylph-home/port/target-container}/debug/sylpheed-export"
|
||||
fail=0
|
||||
|
||||
step() { # name, expectation, command...
|
||||
local name="$1" expect="$2"; shift 2
|
||||
local log="$OUT/${name}.log" rc=0
|
||||
"$@" >"$log" 2>&1 || rc=$?
|
||||
case "$expect" in
|
||||
must-pass)
|
||||
[ $rc -eq 0 ] && printf ' %-24s ok\n' "$name" \
|
||||
|| { printf ' %-24s 🔴 FAILED (rc=%d) -- %s\n' "$name" "$rc" "$log"; fail=1; }
|
||||
;;
|
||||
report-only)
|
||||
printf ' %-24s ran (no verdict -- see below)\n' "$name"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 🔴 THE DISPLAY CAN BE GONE, AND EVERY GODOT STEP THEN FAILS FOR ONE REASON.
|
||||
#
|
||||
# Xvfb does not survive a container restart, and its socket does: /tmp/.X11-unix
|
||||
# keeps `X97` after the server is gone, so Godot reports
|
||||
#
|
||||
# ERROR: X11 Display is not available
|
||||
#
|
||||
# rather than "no such display", falls back to Wayland, fails that too, and
|
||||
# exits non-zero. Every Godot-backed step below would then report red, and all of
|
||||
# it would mean one thing -- there is no display -- which is exactly the wall of
|
||||
# meaningless failures a check suite exists to avoid. Cost one run on 2026-09-01
|
||||
# before it was noticed.
|
||||
#
|
||||
# Checked with `xdpyinfo` rather than by looking for the socket, because the
|
||||
# stale socket is what makes the failure confusing in the first place.
|
||||
if ! DISPLAY="$DISPLAY" timeout 10 xdpyinfo >/dev/null 2>&1; then
|
||||
echo "🔴 no X display on $DISPLAY -- every Godot step below would fail for that one reason."
|
||||
echo " Xvfb does not survive a container restart and leaves its socket behind. Start it with:"
|
||||
echo " rm -f /tmp/.X11-unix/X\${DISPLAY#:} ; Xvfb $DISPLAY -screen 0 1280x720x24 -nolisten tcp &"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# 🔴 GODOT'S SCRIPT CLASS LIST IS A BUILD CACHE, AND IT IS GITIGNORED.
|
||||
#
|
||||
# `port/.godot/global_script_class_cache.cfg` is what resolves a `class_name`,
|
||||
# and `.gitignore` excludes `port/.godot/` -- correctly, it is derived. So a
|
||||
# checkout that MERGES a commit adding a new `class_name` keeps a cache that
|
||||
# does not list it, and every script referencing the new class fails to parse:
|
||||
#
|
||||
# SCRIPT ERROR: Parse Error: Identifier "Gamepad" not declared in the current scope.
|
||||
# ERROR: Failed to load script "res://scripts/boot.gd" with error "Parse error".
|
||||
#
|
||||
# The whole project then refuses to load, from `--screen` to `--boot`, and the
|
||||
# error names the symbol rather than the cache -- so it reads as a missing file
|
||||
# or a bad merge. This is exactly what merging the human's input fix did on
|
||||
# 2026-09-01: `gamepad.gd` arrived with `class_name Gamepad`, the cache in this
|
||||
# container was warm and predated it, and the port did not run at all.
|
||||
#
|
||||
# A fresh clone has no `.godot/` and Godot builds one on first run, so nobody
|
||||
# hits this until they merge into a working tree -- which is every iteration of
|
||||
# this loop. Reimporting is cheap and idempotent, so it runs unconditionally
|
||||
# rather than behind a staleness test that would itself need to be right.
|
||||
echo "godot: reimporting so class_name resolves against a fresh cache"
|
||||
DISPLAY="$DISPLAY" godot --headless --path port --import >"$OUT/godot-import.log" 2>&1 \
|
||||
|| { echo " 🔴 godot --import FAILED -- see $OUT/godot-import.log"; fail=1; }
|
||||
for c in $(grep -ho '^class_name [A-Za-z_][A-Za-z0-9_]*' port/scripts/*.gd | awk '{print $2}'); do
|
||||
grep -q "\"$c\"" port/.godot/global_script_class_cache.cfg 2>/dev/null \
|
||||
|| { printf ' %-24s 🔴 class_name %s is not in the class cache\n' class-cache "$c"; fail=1; }
|
||||
done
|
||||
echo
|
||||
|
||||
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
|
||||
# 🔴 The control harness itself is asserted. Every --control run says "each check
|
||||
# fails on a perturbed contract"; none of them said "a broken control reports
|
||||
# broken". A harness that silently approves a dead check is exactly as useless as
|
||||
# a check that silently approves a dead value.
|
||||
step control-harness must-pass tools/port/contract-check --selftest
|
||||
step modding-rules must-pass tools/port/check-modding
|
||||
# Every `kind` in authored/ is a claim about where a value came from, and until
|
||||
# 2026-08-30 nothing checked what any of them rested on -- seven were resting on
|
||||
# a sibling `why` that argued a different claim.
|
||||
step authored-kinds must-pass tools/port/audit-kinds
|
||||
# The classifier is asked whether it can tell grounded from ungrounded at all,
|
||||
# rather than only what it found. Exit 2 = the harness is broken.
|
||||
step kinds-harness must-pass tools/port/audit-kinds --selftest
|
||||
# Band levels are alignment-free and carry their own known negative on every run;
|
||||
# the difference-signal half of the same tool stays report-only and asserts
|
||||
# nothing. See docs/port/DECISIONS.md -- the waveform question is still open.
|
||||
step transcode-bands must-pass tools/port/verify-transcode-fidelity
|
||||
# Asks whether the band measurement is LIVE, not just what it found. An empty
|
||||
# band list makes every comparison read 0.0 dB and pass; that now exits 2.
|
||||
step bands-harness must-pass tools/port/verify-transcode-fidelity --selftest
|
||||
step capture-controls must-pass tools/port/check-capture-controls
|
||||
step menu-audio must-pass env OUT="$OUT/audio" tools/port/verify-menu-audio
|
||||
# 🔴 ADDED 2026-09-02, because `menu-audio` above SPENT WEEKS UNABLE TO FAIL. It
|
||||
# computed its verdict, printed a red line when a cue was silent, and its python
|
||||
# had no exit path -- so it returned 0 while registered `must-pass` here. Every
|
||||
# other assertion in this file has a control for exactly this reason and audio
|
||||
# was the one that did not. It costs a second set of runs and that is the price.
|
||||
step menu-audio-ctl must-pass env OUT="$OUT/audioctl" tools/port/verify-menu-audio --control
|
||||
# 🔴 ADDED 2026-09-01 after a human found Ⓐ dead on a real controller while the
|
||||
# unattended P5 walk passed. `--script` sends `InputEventAction`, which BYPASSES
|
||||
# the input map, so every check here asserted the code BELOW the map and nothing
|
||||
# about the map -- which was missing a joypad binding for `ui_accept` and
|
||||
# `ui_cancel` entirely. The same blind spot hid a second defect: an
|
||||
# `InputEventAction` is not an analog axis, so nothing could see that a held
|
||||
# stick fired once per jitter.
|
||||
step input-map must-pass tools/port/verify-input
|
||||
step input-control must-pass tools/port/verify-input --control
|
||||
# 🔴 ADDED 2026-09-02 after a human found the splash frozen while THREE checks
|
||||
# here were green. The frozen sweep proved a pose could be drawn, the settled
|
||||
# comparison scored 0.01 % against the oracle (a frozen screen matches a settled
|
||||
# reference perfectly -- that is what frozen means), and the fps counter counted
|
||||
# frames drawn. All three measured throughput or a pose; none measured CHANGE.
|
||||
# Same shape as InputEventAction bypassing the input map, two rows above.
|
||||
step boot-motion must-pass tools/port/verify-motion
|
||||
step motion-control must-pass tools/port/verify-motion --control
|
||||
# A stale index is worse than none: it answers "is this already decided?" with a
|
||||
# confident no. That is not hypothetical -- see the entry it was built after.
|
||||
step decisions-index must-pass tools/port/index-decisions --check
|
||||
# `audit-kinds` checks citations in `authored/`; nothing checked the PROSE, and
|
||||
# prose is where this port explains itself. A first run found 37 of 91
|
||||
# non-resolving -- 7 of them pointing at NOTHING on any ref, left behind by the
|
||||
# monorepo move and the `export/` rename. Only that class fails; a citation that
|
||||
# is merely on a peer's unmerged branch is reported, because the fix is a merge
|
||||
# and nobody in this container can make it.
|
||||
step doc-citations must-pass tools/port/check-citations
|
||||
step citations-control must-pass tools/port/check-citations --selftest
|
||||
# A refuted claim asserted outside its correction is a lie the corpus tells a
|
||||
# reader who greps for it. Registered claims must carry an explicit `[refuted]`.
|
||||
# 🔴 The register check had NO executable control until 2026-08-31 -- every
|
||||
# "planted a revival and it failed" in DECISIONS was done by hand, once. Four
|
||||
# cases now drive it as a subprocess and read its real exit code, including an
|
||||
# EMPTY REGISTER, which used to report clean forever.
|
||||
step claims-control must-pass tools/port/check-claims --control
|
||||
step refuted-claims must-pass tools/port/check-claims
|
||||
echo
|
||||
echo "reported, not asserted:"
|
||||
# Not an assertion: being behind a peer's topic branch is the normal state, and a
|
||||
# red line for it would be scenery within a day. It is here so the affordance is
|
||||
# visible on every run -- reading a peer's head needs no merge and no human.
|
||||
step peer-heads report-only tools/port/peer-head
|
||||
step oracle-captures report-only env OUT="$OUT/oracle" tools/port/verify-capture
|
||||
sed -n '/^screen /,$p' "$OUT/oracle-captures.log" | sed 's/^/ /'
|
||||
# 🔴 `verify-capture` prints and always exits 0. Its own header is right that the
|
||||
# numbers are not a target -- the captures carry the game's tone ramp, so RMSE has
|
||||
# a floor and driving it lower is fitting the ramp. But "not a target" is not the
|
||||
# same as "not a regression detector", and nothing here would notice `title_plate`
|
||||
# moving off 0.00 %. Asserting it needs a stored baseline per row, which is a real
|
||||
# design decision about what a baseline means when the pose is fitted. NAMED, not
|
||||
# quietly skipped.
|
||||
|
||||
echo
|
||||
echo "consistency (expected to differ, for a stated reason):"
|
||||
rc=0; env OUT="$OUT/screens" tools/port/verify-screen >"$OUT/verify-screen.log" 2>&1 || rc=$?
|
||||
differs=$(grep -c DIFFERS "$OUT/verify-screen.log" || true)
|
||||
# 🔴 THE ALLOWANCE IS DERIVED NOW, NOT LISTED, and that is strictly stronger.
|
||||
#
|
||||
# Six screens joined this set on 2026-09-01 and the cause is diagnosed for two of
|
||||
# them: the port draws some elements ADDITIVE -- transcribed from the Decoder's
|
||||
# per-draw RB_BLENDCONTROL0 log off the running game -- and the reference has no
|
||||
# additive path at all (ui_layout.rs has exactly two blend sites, both
|
||||
# alpha-over, and line 1169 records that it tried additive and refuted it from
|
||||
# its own composite metrics). So the two renderers disagree ON PURPOSE, and the
|
||||
# size of the disagreement tracks the size of the additive set: extras has 9
|
||||
# elements and a mean of 6.74, main_menu has 5 and 3.94, and the screens with
|
||||
# none sit an order of magnitude below.
|
||||
#
|
||||
# Computing the allowance from `authored/rendering.json` rather than listing it
|
||||
# means a screen is excused BECAUSE it has additive elements the reference
|
||||
# cannot draw, and a screen that differs WITHOUT them still fails -- which a
|
||||
# literal list could not express, and which keeps this from going stale against
|
||||
# the map it is derived from. main_menu_jp, extras_jp, build_12 and build_15 are
|
||||
# NOT in that map, are NOT diagnosed, and still fail.
|
||||
# docs/port/verify-screen-blend-divergence.md
|
||||
# 🔴 THIS DERIVED FROM authored/rendering.json AND I DELETED THAT KEY MYSELF.
|
||||
# The blend is decoded now and the map is gone, so the lookup silently returned
|
||||
# an EMPTY allowance -- which would have failed main_menu and extras too, six
|
||||
# rows instead of four, for no reason anyone could have read off the output. A
|
||||
# derived allowance is only as durable as the thing it derives from, and I
|
||||
# pointed this one at a file I then emptied one iteration later.
|
||||
#
|
||||
# It now derives from the EXPORT, which is what the port actually draws from: a
|
||||
# screen may differ if any of its elements -- or any nested focus/leaf element --
|
||||
# carries `blend_additive: true`, because `ui_layout.rs` has no additive path at
|
||||
# all and cannot reproduce those draws by construction.
|
||||
#
|
||||
# ⚠️ THIS ALLOWANCE IS LOOSER THAN THE ONE IT REPLACES AND THAT IS A REAL COST.
|
||||
# The old map covered 3 screens because it was a transcription of what somebody
|
||||
# had driven the game to; the bit is disc-wide, so 12 of 16 screens now qualify
|
||||
# and verify-screen goes fully green. Measured after the swap, the two sets line
|
||||
# up exactly -- all 10 screens that DIFFER have a drawn additive element, and all
|
||||
# 6 that agree have none -- so nothing is being excused that does not have the
|
||||
# cause. But a screen that starts differing for some OTHER reason will now be
|
||||
# excused if it happens to carry an additive element anywhere, and this check
|
||||
# will not say so.
|
||||
#
|
||||
# ✅ THE REAL FIX HAS LANDED -- AT A TAG, NOT YET ON `main`, WHICH IS WHY THIS
|
||||
# CLAUSE IS STILL HERE. `ui_layout::blit` draws additive as of
|
||||
# formats-pin-2026-09-01b, so the comparison is capable again and this widening
|
||||
# has lost its justification.
|
||||
#
|
||||
# Measured at that tag, in a detached worktree, with SYLPHEED_CLI pointed at it:
|
||||
# main_menu 7.26 -> 1.21, extras 6.98 -> 1.02, both JP twins likewise, and
|
||||
# build_00/build_01 go DIFFERS -> OK (over3 3422 -> 0). A 6x collapse.
|
||||
#
|
||||
# 🔴 NOT NARROWED YET, AND ON PURPOSE. This script builds the reference from the
|
||||
# WORKSPACE crate, and the additive path is not on `main`. Narrowing now would
|
||||
# turn check-all red against a reference that still cannot draw additive -- a
|
||||
# wall of failures meaning one thing, which is the defect the display guard above
|
||||
# exists to prevent.
|
||||
#
|
||||
# TRIGGER, so this does not rot: when `grep -q additive crates/sylpheed-formats/src/ui_layout.rs`
|
||||
# succeeds, delete the export-derived clause and keep only `-e title -e title_jp`.
|
||||
# The set that should then differ is measured in
|
||||
# docs/port/verify-screen-blend-divergence.md: title, title_jp, main_menu, extras,
|
||||
# main_menu_jp, extras_jp, build_12, build_15 -- and build_00/build_01 pass.
|
||||
additive_screens=$(python3 -c "
|
||||
import json, glob, os
|
||||
out = []
|
||||
for p in sorted(glob.glob('export/screens/*/*.json')):
|
||||
d = json.load(open(p))
|
||||
def any_add(els):
|
||||
for e in els:
|
||||
if e.get('blend_additive'):
|
||||
return True
|
||||
for k in ('focus', 'leaf'):
|
||||
if any_add((e.get(k) or {}).get('elements', [])):
|
||||
return True
|
||||
return False
|
||||
if any_add(d.get('elements', [])):
|
||||
out.append(os.path.basename(p)[:-5])
|
||||
print('\n'.join(out))" 2>/dev/null)
|
||||
allow_args=(-e title -e title_jp)
|
||||
for sc in $additive_screens; do allow_args+=(-e "$sc"); done
|
||||
printf ' %-24s allowing %s (additive set + 2 legacy)\n' verify-screen \
|
||||
"$(echo $additive_screens | tr '\n' ' ')"
|
||||
unexpected=$(grep DIFFERS "$OUT/verify-screen.log" | awk '{print $1}' \
|
||||
| grep -vx "${allow_args[@]}" || true)
|
||||
|
||||
# 🔴 THE OLD ALLOWANCE WAS FALSE, AND MY FIRST REPLACEMENT REASON WAS ALSO
|
||||
# WRONG. Both are recorded because the second error is the more instructive.
|
||||
#
|
||||
# It said: "the pin is not on main, so this compares two decoder eras". I
|
||||
# replaced that with "the eras render identically -- 0 pixels different on three
|
||||
# screens". 🔴 **That measurement was void**: the two binaries I compared had the
|
||||
# same md5. I built one in a worktree at the pinned tag and one from the
|
||||
# workspace, and both commits carry the record-layout fix, so I compared a
|
||||
# binary with itself and reported the zero as evidence.
|
||||
#
|
||||
# Rebuilt properly against `origin/main`, which is the genuinely stale era
|
||||
# (`rest t=70 [12 70 80 -]` against the fixed `rest t=12 [0 12 70 80]`):
|
||||
#
|
||||
# title 0 px main_menu 0 px title_jp 74 507 px
|
||||
#
|
||||
# ✅ The eras DO change pixels, and `title_jp` is one of the seven bundles where
|
||||
# they do -- reproducing the Decoder's figure exactly, under their flags and
|
||||
# mine. My "--animated masks it" hypothesis was wrong too.
|
||||
#
|
||||
# ✅ BUT THE ERA STILL CANNOT EXPLAIN THIS SCRIPT'S ROWS, for a reason I had not
|
||||
# established: BOTH SIDES OF THIS COMPARISON ARE THE FIXED ERA. The exporter is
|
||||
# pinned to `formats-pin-2026-08-30` and this reference is built from the
|
||||
# workspace, and a binary built from each has the SAME md5. There is no era
|
||||
# mismatch here to explain anything. Right answer, wrong evidence, and the wrong
|
||||
# evidence was a broken experiment.
|
||||
#
|
||||
# The real reasons are per-screen and already documented:
|
||||
# title -- the ptloop SWEEP PHASE residual, max 6 / over3 790, unchanged
|
||||
# across every renderer change since P1 (DECISIONS.md).
|
||||
# title_jp -- the `--pose=rest` sparkle handling. Adjudicated against the
|
||||
# oracle: the port's SHIPPED pose scores r +0.9994 against the
|
||||
# game where the reference scores +0.8727, and `--pose=rest` is
|
||||
# what this script compares.
|
||||
# ⚠️ title_jp is ALSO an era-sensitive bundle, so if this reference is ever
|
||||
# built from a different era than the exporter's pin, that row's cause changes
|
||||
# and this note stops applying. Check the md5s before trusting it again.
|
||||
#
|
||||
# So the allowance is now a NAMED SET, not a count with an excuse. A DIFFERS on
|
||||
# any other screen fails the run, which a count never could.
|
||||
# 🔴 SIX MORE SCREENS JOINED THIS SET ON 2026-09-01 AND THE SET WAS NOT WIDENED.
|
||||
# main_menu, extras, main_menu_jp, extras_jp, build_12, build_15. Measured, not
|
||||
# diagnosed: the difference is full-frame, it is EXACTLY ZERO on unblended
|
||||
# pixels (18 081 of them agree to a hundredth of a level) and gamma-shaped on
|
||||
# every blended one, so it is a blend-SPACE divergence rather than moved content.
|
||||
# Scored against the live capture the port is 16 % closer than the reference --
|
||||
# an ordering only, since both sides share this script's --pose=rest
|
||||
# contamination. Left failing on purpose: this allowance has twice been widened
|
||||
# with a reason that turned out false, and "I measured it but cannot say which
|
||||
# renderer is right" is not a reason. docs/port/verify-screen-blend-divergence.md
|
||||
if [ -n "$unexpected" ]; then
|
||||
printf ' %-24s 🔴 DIFFERS on %s -- not in the allowed set\n' verify-screen "$(echo $unexpected | tr '\n' ' ')"
|
||||
printf ' %-24s see docs/port/verify-screen-blend-divergence.md -- measured, cause open\n' ""
|
||||
fail=1
|
||||
else
|
||||
printf ' %-24s %d DIFFERS, both named and explained per screen:\n' verify-screen "$differs"
|
||||
printf ' %-24s title = sweep phase; title_jp = rest-pose sparkles (the port is\n' ""
|
||||
printf ' %-24s closer to the GAME there than the reference is).\n' ""
|
||||
fi
|
||||
|
||||
# Separately, and unrelated to the rows above: revert to the path dependency when
|
||||
# the pin lands. Read from Cargo.toml so it cannot drift out of step again.
|
||||
pin=$(sed -n 's/.*tag = "\([^"]*\)".*/\1/p' crates/sylpheed-export/Cargo.toml | head -1)
|
||||
if [ -n "$pin" ] && git merge-base --is-ancestor "$pin" origin/main 2>/dev/null; then
|
||||
printf ' %-24s ⚠️ %s has landed on main -- revert Cargo.toml to the path dep\n' pin "$pin"
|
||||
fi
|
||||
|
||||
echo
|
||||
[ $fail -eq 0 ] && echo "every asserting check passes" || echo "🔴 a check failed"
|
||||
exit $fail
|
||||
262
tools/port/check-capture
Executable file
262
tools/port/check-capture
Executable file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env bash
|
||||
# Provenance check for a multichannel capture, BEFORE anybody analyses it.
|
||||
#
|
||||
# tools/port/check-capture /path/to/capture.wav
|
||||
#
|
||||
# WHY THIS EXISTS. A 6-channel capture of the game's own output was analysed at
|
||||
# length -- three controls, a drift test, a written-up negative -- and the file
|
||||
# was corrupt. PulseAudio was remapping between two mismatched channel maps, and
|
||||
# a 6-channel remap SILENTLY DROPS AND DUPLICATES: right duration, right channel
|
||||
# count, plausible per-channel levels, no error anywhere. Two of the six channels
|
||||
# were byte-identical copies of two others and two source channels were simply
|
||||
# gone.
|
||||
#
|
||||
# The Decoder proved it with a control that needs no emulator and no disc: six
|
||||
# channels each carrying a different tone through the same sink and the same
|
||||
# `parec` invocation. Channels came back 400 / 3200 / 200 / 800 / 800 / 200 for
|
||||
# an input of 400 / 800 / 200 / 1600 / 3200 / 6400 -- see
|
||||
# `docs/re/audio-capture-channel-map-trap.md`. Setting the sink's `channel_map`
|
||||
# to the guest's own and passing the same map to `parec` returns all six.
|
||||
#
|
||||
# THE DETECTABLE SIGNATURE IS AN EXACT DUPLICATE PAIR. Two channels of a real
|
||||
# surround mix are never byte-identical over 70 s. Levels are not enough to catch
|
||||
# it -- the corrupt file's per-channel peaks looked entirely reasonable, and it
|
||||
# was only equal peak AND equal RMS to six decimals that prompted a hash.
|
||||
#
|
||||
# This is a NECESSARY check, not a sufficient one: passing it means the capture
|
||||
# has no duplicated channels, not that it recorded the right thing.
|
||||
set -euo pipefail
|
||||
f="${1:?usage: check-capture FILE.wav}"
|
||||
|
||||
# Queried one field at a time. A combined `-show_entries` prints two values on
|
||||
# ONE comma-separated line, and `read -r ch rate dur` then puts "48000,6" in
|
||||
# `$ch` -- which every later arithmetic test rejects, in a script whose whole
|
||||
# job is to be trusted about a file.
|
||||
probe() { ffprobe -v error -select_streams a:0 -show_entries "$1" -of csv=p=0:nk=1 "$f" | head -1; }
|
||||
ch=$(probe stream=channels)
|
||||
rate=$(probe stream=sample_rate)
|
||||
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0:nk=1 "$f" | head -1)
|
||||
printf '%s: %sch %sHz %.3fs\n' "$f" "$ch" "$rate" "$dur"
|
||||
|
||||
|
||||
# ⚠️ MONO SKIPS THE DUPLICATE TEST AND STILL GETS THE STARVATION ONE. An earlier
|
||||
# version returned immediately for a single channel, so the mono voice track --
|
||||
# one of this tool's four controls -- was never actually run through the check it
|
||||
# was supposed to control. A control that does not execute is not a control.
|
||||
dupes=0
|
||||
if [ "$ch" -lt 2 ]; then
|
||||
echo " single channel -- no duplicate test, starvation still checked"
|
||||
else
|
||||
layout=5.1; [ "$ch" = 2 ] && layout=stereo
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
map=""; for i in $(seq 0 $((ch-1))); do map="$map -map [c$i] $tmp/c$i.wav"; done
|
||||
split=""; for i in $(seq 0 $((ch-1))); do split="$split[c$i]"; done
|
||||
# shellcheck disable=SC2086
|
||||
ffmpeg -hide_banner -v error -y -i "$f" \
|
||||
-filter_complex "channelsplit=channel_layout=$layout$split" $map
|
||||
|
||||
declare -a sums
|
||||
for i in $(seq 0 $((ch-1))); do
|
||||
s=$(ffmpeg -hide_banner -v error -i "$tmp/c$i.wav" -f md5 - | cut -d= -f2)
|
||||
peak=$(ffmpeg -hide_banner -v info -i "$tmp/c$i.wav" -af astats -f null - 2>&1 \
|
||||
| grep -m1 "Peak level dB" | sed 's/.*: //')
|
||||
sums[i]="$s"
|
||||
printf ' ch%-2d peak %-12s %s\n' "$i" "$peak" "$s"
|
||||
done
|
||||
|
||||
for i in $(seq 0 $((ch-1))); do
|
||||
for j in $(seq $((i+1)) $((ch-1))); do
|
||||
if [ "${sums[i]}" = "${sums[j]}" ]; then
|
||||
echo " 🔴 ch$i and ch$j are BYTE-IDENTICAL"
|
||||
dupes=1
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
# STARVATION: the second way a capture looks perfect and carries nothing.
|
||||
#
|
||||
# A monitor sink advances at WALL-CLOCK rate and substitutes silence whenever the
|
||||
# producer is late. An emulator running below real time therefore yields a file
|
||||
# of exactly the right duration, right channel count, no duplicated channels --
|
||||
# and chopped into fragments with holes punched between them, thousands of times
|
||||
# over. Envelope correlation against such a file is destroyed by construction:
|
||||
# what dominates the envelope is the dropout schedule, not the content.
|
||||
#
|
||||
# Measured on the capture that prompted this: 35.6 % of frames silent on all six
|
||||
# channels, 10 482 alternating runs, median burst 13.5 ms and median gap 3.9 ms
|
||||
# -- a 17.4 ms period, 57 Hz. The Decoder measured the untruncated original at
|
||||
# 39.3 % and 10 595 runs; the two agree.
|
||||
#
|
||||
# THE DISCRIMINATOR IS THE RUN STRUCTURE, NOT THE SILENCE FRACTION. Real audio is
|
||||
# full of silence -- a voice track is more than half gaps -- but those are TENS of
|
||||
# runs of HUNDREDS of milliseconds. Dropout chop is THOUSANDS of runs of a few
|
||||
# milliseconds. So the test is: many short all-channel gaps.
|
||||
set +e
|
||||
python3 - "$f" <<'PYEOF'
|
||||
import array, struct, sys
|
||||
d = open(sys.argv[1], 'rb').read()
|
||||
i, fmt, off = 12, None, None
|
||||
while i + 8 <= len(d):
|
||||
cid = d[i:i+4]; sz = struct.unpack('<I', d[i+4:i+8])[0]
|
||||
if cid == b'fmt ': fmt = d[i+8:i+8+sz]
|
||||
elif cid == b'data':
|
||||
off, declared = i + 8, sz; break
|
||||
i += 8 + sz + (sz & 1)
|
||||
if fmt is None or off is None:
|
||||
print(" (not a plain WAV -- starvation check skipped)"); raise SystemExit(0)
|
||||
tag = struct.unpack('<H', fmt[0:2])[0]
|
||||
ch = struct.unpack('<H', fmt[2:4])[0]; rate = struct.unpack('<I', fmt[4:8])[0]
|
||||
bits = struct.unpack('<H', fmt[14:16])[0] if len(fmt) >= 16 else 16
|
||||
# REFUSE A FORMAT THIS CANNOT READ, rather than mis-reading it confidently.
|
||||
#
|
||||
# Everything below assumes 16-bit signed. An ALSA `type file` tee writes
|
||||
# **float32** (`SND_PCM_FORMAT_FLOAT_LE`), and read as s16 it produces a
|
||||
# plausible-looking file: the Decoder measured one and its only giveaway was
|
||||
# per-channel peaks alternating EXACTLY -0.00 / -4.82, which is the two halves
|
||||
# of each float landing in alternate channels. A checker that mis-reads a format
|
||||
# is worse than one that has no opinion -- it is the shape of every failure this
|
||||
# tool exists to catch.
|
||||
#
|
||||
# tag 1 = PCM, 3 = IEEE float, 0xFFFE = WAVE_FORMAT_EXTENSIBLE.
|
||||
#
|
||||
# ⚠️ EXTENSIBLE IS ACCEPTED AT 16 BITS, and the first version of this guard was
|
||||
# not -- it rejected one of this tool's own controls, a file `ffprobe` correctly
|
||||
# calls `pcm_s16le`. A format guard that refuses a legitimate capture is the same
|
||||
# defect as one that mis-reads an illegitimate one, pointing the other way.
|
||||
# `wBitsPerSample` is what actually decides how the samples are laid out here, so
|
||||
# it is what the check turns on; a float tee is 32-bit and is still caught.
|
||||
if tag not in (1, 0xFFFE) or bits != 16:
|
||||
print(" 🔴 format tag %d, %d-bit -- this tool reads 16-bit PCM only." % (tag, bits))
|
||||
print(" Read as s16 a float32 tee looks plausible and is not: its tell is")
|
||||
print(" per-channel peaks alternating exactly, one float split across two")
|
||||
print(" channels. Convert first: ffmpeg -i in.wav -c:a pcm_s16le out.wav")
|
||||
raise SystemExit(4)
|
||||
avail = len(d) - off
|
||||
if declared == 0 or declared > avail:
|
||||
# A streaming writer that never patched its header. The file may also be a
|
||||
# copy taken while it was still being written -- which happened, and made a
|
||||
# provenance claim wrong.
|
||||
print(" ⚠️ data chunk declares %d bytes, %d present -- header never patched;"
|
||||
% (declared, avail))
|
||||
print(" treat the duration as unverified and check the file is complete.")
|
||||
n = avail // (2 * ch)
|
||||
a = array.array('h'); a.frombytes(d[off:off + n * 2 * ch])
|
||||
sil = bytearray(n)
|
||||
for f_ in range(n):
|
||||
b = f_ * ch
|
||||
if not any(a[b+c] for c in range(ch)): sil[f_] = 1
|
||||
tot = sum(sil)
|
||||
# A GAP IS A RUN, NOT A SAMPLE. The first version of this counted every frame
|
||||
# whose channels were all exactly zero, and real audio crosses zero constantly --
|
||||
# it scored a clean voice track at 5 947 "gaps" of median 0.0 ms and called it
|
||||
# starved. The known-good control caught it. Only runs of at least 1 ms (48
|
||||
# frames at 48 kHz) count: a zero-crossing is one sample, a dropout is hundreds.
|
||||
MINGAP = max(1, rate // 1000)
|
||||
runs_s, runs_n = [], []
|
||||
cur, ln = sil[0], 0
|
||||
for v in sil:
|
||||
if v == cur: ln += 1
|
||||
else:
|
||||
(runs_s if cur else runs_n).append(ln); cur = v; ln = 1
|
||||
(runs_s if cur else runs_n).append(ln)
|
||||
runs_s = [r for r in runs_s if r >= MINGAP]
|
||||
if not runs_s:
|
||||
print(" all-channel silence 0.0% -- no gaps at all"); raise SystemExit(0)
|
||||
rs = sorted(runs_s); med = 1000.0 * rs[len(rs)//2] / rate
|
||||
secs = n / float(rate)
|
||||
rate_per_s = len(runs_s) / secs
|
||||
print(" all-channel silence %.1f%%, %d gap(s) over 1 ms (%.1f/s), median gap %.1f ms"
|
||||
% (100.0*tot/n, len(runs_s), rate_per_s, med))
|
||||
# THE THRESHOLD IS SET FROM CONTROLS, and the first two I invented were both
|
||||
# wrong -- they failed real audio. Measured:
|
||||
#
|
||||
# the starved capture 32.9 gaps/s, median 3.9 ms, 35.6 % silent
|
||||
# a real music+SFX bed 3.3 gaps/s, median 1.4 ms, 1.1 % silent
|
||||
# a voice track, 53 % pauses 0.03 gaps/s
|
||||
#
|
||||
# Real audio does contain short all-zero runs -- a quiet passage in 16-bit is
|
||||
# genuinely zero for milliseconds -- so neither the gap COUNT nor the median
|
||||
# length separates them. The RATE does, by an order of magnitude in both
|
||||
# directions, and 20/s sits between with a 1.6x margin below the bad case and
|
||||
# 6x above the worst good one.
|
||||
# TWO NUMBERS, BECAUSE ONE CANNOT SEE THE FAILURE NEXT DOOR.
|
||||
#
|
||||
# The first version of this tested the gap RATE alone, at 20/s. The Decoder then
|
||||
# measured what a LARGER client buffer does, and the relationship is not
|
||||
# monotonic: raising `PULSE_LATENCY_MSEC` keeps cutting the rate while total
|
||||
# silence bottoms out and then doubles, because an over-large buffer starves in a
|
||||
# few enormous holes instead of many small ones. Its 500 ms capture scores
|
||||
# **1.3 gaps/s -- better than a genuine music bed at 3.3 -- while being 50 %
|
||||
# silence**, and my bar passed it. Reproduced here on a file I hold: `bigholes`,
|
||||
# a real bed with 350 ms holes punched in, is 46.3 % silence at 3.2 gaps/s.
|
||||
#
|
||||
# That is the same shape as the level table that could not see a duplicated
|
||||
# channel. One number, blind to the neighbouring failure.
|
||||
#
|
||||
# Controls, all four measured here. 🔴 THE FIGURES LIVE IN THE DOC, NOT HERE.
|
||||
#
|
||||
# This table used to restate them, and two of the numbers had DRIFTED from
|
||||
# `AUDIO-VERIFICATION.md`: 53.3 % here against 53.2 % there, in two places each,
|
||||
# for the same control. Neither can be re-measured -- that control file was
|
||||
# transient and is gone -- so there is no way to say which copy aged.
|
||||
#
|
||||
# That is the mirror of the trap the Decoder named the same day: they lost a
|
||||
# finding because its only record was a script comment; this lost a digit because
|
||||
# a finding had TWO records and nothing kept them equal. A number copied into a
|
||||
# second place will drift from the first, and the drift is invisible because both
|
||||
# copies look authoritative.
|
||||
#
|
||||
# So the doc is the record and this cites it.
|
||||
#
|
||||
# real music bed 1.1 % silence, 3.3 gaps/s PASS
|
||||
# voice track, mono see AUDIO-VERIFICATION.md PASS (real pauses)
|
||||
# bed with big holes 46.3 % silence, 3.2 gaps/s FAIL
|
||||
# the starved capture 35.6 % silence, 30.9 gaps/s FAIL
|
||||
#
|
||||
# Rate alone cannot separate rows 2 and 3; silence alone cannot separate rows 1
|
||||
# and 3, nor 2 and 3. The pair does.
|
||||
if tot / float(n) >= 0.10 and rate_per_s >= 1.0:
|
||||
print(" 🔴 STARVED: %.1f%% of the file is silent on every channel, in %.1f gaps"
|
||||
% (100.0 * tot / n, rate_per_s))
|
||||
print(" per second (median %.1f ms). Real audio is either mostly not" % med)
|
||||
print(" silent, or silent in a few long stretches -- not both at once.")
|
||||
raise SystemExit(3)
|
||||
|
||||
# ⚠️ THE REGIME THIS TOOL CANNOT JUDGE, said out loud rather than passed
|
||||
# silently. High silence with FEW gaps is what a real voice track looks like
|
||||
# (AUDIO-VERIFICATION.md §7 has the figure) and also what an over-buffered
|
||||
# capture looks like. No
|
||||
# statistic here separates them, and inventing a bar for a regime I have no
|
||||
# control in is how the last two bars in this file came to be wrong.
|
||||
if tot / float(n) >= 0.10:
|
||||
print(" ⚠️ %.1f%% silent in only %.1f gaps/s -- UNJUDGED. That is the shape of"
|
||||
% (100.0 * tot / n, rate_per_s))
|
||||
print(" a real voice track AND of an over-buffered capture, and this tool")
|
||||
print(" cannot tell them apart. Check it against a known source before")
|
||||
print(" concluding anything from it.")
|
||||
PYEOF
|
||||
starved=$?
|
||||
set -e
|
||||
if [ "$starved" = 4 ]; then
|
||||
# The duplicate test ran (bytes are bytes) but starvation did not. Saying
|
||||
# "PASS" here would be the tool claiming a check it skipped.
|
||||
echo "PARTIAL: channels checked, starvation NOT checked -- unreadable sample format."
|
||||
exit 2
|
||||
fi
|
||||
if [ "$starved" = 3 ]; then
|
||||
echo "FAIL: the recording is starved. A monitor sink advances at wall-clock rate"
|
||||
echo " and substitutes silence when the producer is late, so this file has"
|
||||
echo " the right duration and holes punched through the content. Correlation"
|
||||
echo " against it is meaningless. See docs/port/AUDIO-VERIFICATION.md §7."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$dupes" = 1 ]; then
|
||||
echo "FAIL: duplicated channels. A surround remap drops and duplicates silently;"
|
||||
echo " channels are missing from this file. Do not analyse it -- fix the"
|
||||
echo " sink's channel_map and re-record. See docs/port/AUDIO-VERIFICATION.md."
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: no duplicated channels. (Necessary, not sufficient -- this says"
|
||||
echo " nothing about whether the right thing was recorded.)"
|
||||
131
tools/port/check-capture-controls
Executable file
131
tools/port/check-capture-controls
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run `check-capture` against its own documented control sweep.
|
||||
#
|
||||
# tools/port/check-capture-controls
|
||||
#
|
||||
# `AUDIO-VERIFICATION.md` calls that sweep **"the tool's real specification"**
|
||||
# and prints it as a table. Nothing executed it. So the specification was prose:
|
||||
# if `check-capture` regressed, or if a threshold drifted, no run would have said
|
||||
# so -- and this is a tool whose own history is two invented thresholds that were
|
||||
# both wrong and were caught only by controls.
|
||||
#
|
||||
# 🔴 The same document states the principle this violates: **"A control that does
|
||||
# not execute is not a control."** It was written about a mono file that skipped
|
||||
# its own check. The sweep as a whole was in exactly that condition.
|
||||
#
|
||||
# ⚠️ One control CANNOT be rebuilt: the starved capture itself was a transient
|
||||
# artifact and is gone. It is reported as MISSING rather than omitted, because a
|
||||
# sweep that quietly drops a control is the defect it exists to catch.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
W="${TMPDIR:-/tmp}/capture-controls"; mkdir -p "$W"
|
||||
CC=tools/port/check-capture
|
||||
fail=0
|
||||
|
||||
# `check-capture` emits TWO verdicts -- one for channel provenance, one for
|
||||
# starvation -- and `AUDIO-VERIFICATION.md`'s table compresses them into a word.
|
||||
# That is fine for a summary and wrong for an assertion: the voice control is
|
||||
# `PASS` on channels and `UNJUDGED` on starvation *by design*, and a sweep that
|
||||
# collapsed those could not tell "passed" from "declined to judge". So both are
|
||||
# reported, and a control names the pair it expects.
|
||||
verdict() { # file -> "<channels>/<starvation>"
|
||||
local out ch st
|
||||
out=$("$CC" "$1" 2>&1 || true)
|
||||
if grep -q '^PARTIAL' <<<"$out"; then echo "PARTIAL/PARTIAL"; return; fi
|
||||
# A starved file SHORT-CIRCUITS: the tool reports the starvation and never
|
||||
# reaches the channel check, which is right -- channel provenance is moot in a
|
||||
# recording with holes punched through it. Reported as `n/a`, not as a failure:
|
||||
# "the check did not run" and "the check failed" are different facts, and
|
||||
# collapsing them is how a sweep starts asserting things it never observed.
|
||||
if grep -qi 'no duplicated channels' <<<"$out"; then ch=PASS
|
||||
elif grep -qi 'BYTE-IDENTICAL' <<<"$out"; then ch=FAIL
|
||||
else ch=n/a; fi
|
||||
if grep -q 'UNJUDGED' <<<"$out"; then st=UNJUDGED
|
||||
elif grep -qi 'starv\|holes\|FAIL' <<<"$out"; then st=FAIL
|
||||
else st=PASS; fi
|
||||
echo "$ch/$st"
|
||||
}
|
||||
expect() { # name, file, wanted
|
||||
local got; got=$(verdict "$2")
|
||||
if [ "$got" = "$3" ]; then printf ' %-42s %-8s ok\n' "$1" "$got"
|
||||
else printf ' %-42s %-8s 🔴 EXPECTED %s\n' "$1" "$got" "$3"; fail=1; fi
|
||||
}
|
||||
|
||||
# Six distinct tones -- the duplicate-channel control. Frequencies chosen so no
|
||||
# two channels share one, which is what the provenance check looks for.
|
||||
ffmpeg -v error -y -f lavfi -i "sine=frequency=400:duration=6" \
|
||||
-f lavfi -i "sine=frequency=800:duration=6" -f lavfi -i "sine=frequency=200:duration=6" \
|
||||
-f lavfi -i "sine=frequency=1600:duration=6" -f lavfi -i "sine=frequency=3200:duration=6" \
|
||||
-f lavfi -i "sine=frequency=6400:duration=6" \
|
||||
-filter_complex "[0:a][1:a][2:a][3:a][4:a][5:a]join=inputs=6:channel_layout=5.1[a]" \
|
||||
-map "[a]" -c:a pcm_s16le "$W/tones.wav"
|
||||
ffmpeg -v error -y -i "$W/tones.wav" -c:a pcm_f32le "$W/tones_f32.wav"
|
||||
|
||||
# A real music+SFX bed: six channels of REAL material, one per channel.
|
||||
#
|
||||
# 🔴 The first version of this control was `-ac 6` from the stereo bed, and it
|
||||
# FAILED -- correctly. An upmix leaves channels 2-5 silent and byte-identical,
|
||||
# which is exactly what the provenance check exists to catch, so the control was
|
||||
# a broken capture wearing a control's name. The tool was right and the control
|
||||
# was wrong, which is the outcome a sweep must be able to tell from its opposite.
|
||||
#
|
||||
# Six NON-OVERLAPPING spans of real audio, one per channel, all continuous. A
|
||||
# second attempt used `aloop=-1` to stretch the short UI cues into full-length
|
||||
# channels and hung ffmpeg indefinitely; spans of the long assets need no looping.
|
||||
# 🔴 THIS FFMPEG COMPLETES ITS WORK AND THEN NEVER EXITS, AND IT WEDGED THE
|
||||
# WHOLE SUITE FOR AN HOUR.
|
||||
#
|
||||
# `check-all` sat on two lines of output for over an hour; the cause was this
|
||||
# call. Diagnosed rather than guessed at: the output file reaches **4 604 262
|
||||
# bytes = exactly 8.0 s of 5.1ch/16-bit/48 kHz**, the full intended length, and
|
||||
# ffmpeg then hangs with the artifact already correct on disk.
|
||||
#
|
||||
# Three formulations were tried and all three hang, all three producing
|
||||
# BYTE-IDENTICAL output: the original, one with `-t 8` bounding the output, and
|
||||
# one with explicit `asplit` feeding each `atrim` (the textbook fix for
|
||||
# multi-use of a single input). So it is not the split, not the output stage,
|
||||
# and the artifact is not in doubt.
|
||||
#
|
||||
# ⚠️ Worse than the hang: it LEAKS. An orphaned ffmpeg from this script's earlier
|
||||
# `aloop` form was found still running after **9.5 hours**, burning CPU across
|
||||
# runs nobody was watching. `boot.gd`'s own header already names this failure
|
||||
# shape -- "it does not fail, it waits, and a job that waits forever reads as a
|
||||
# job still working".
|
||||
#
|
||||
# So: bounded, and the ARTIFACT is checked rather than the exit code. That is
|
||||
# the better test regardless of the hang -- an exit code says ffmpeg thought it
|
||||
# was done, the file says what it actually wrote.
|
||||
timeout 90 ffmpeg -v error -y -i export/audio/bgm/main_menu.ogg \
|
||||
-i export/audio/voice/ADV.ogg -i export/audio/voice/S00A.ogg \
|
||||
-filter_complex "[0:a]atrim=2:10,asetpts=N/SR/TB,aformat=channel_layouts=mono[a0]; \
|
||||
[0:a]atrim=20:28,asetpts=N/SR/TB,aformat=channel_layouts=mono[a1]; \
|
||||
[1:a]atrim=15:23,asetpts=N/SR/TB,aformat=channel_layouts=mono[a2]; \
|
||||
[1:a]atrim=40:48,asetpts=N/SR/TB,aformat=channel_layouts=mono[a3]; \
|
||||
[2:a]atrim=12:20,asetpts=N/SR/TB,aformat=channel_layouts=mono[a4]; \
|
||||
[2:a]atrim=35:43,asetpts=N/SR/TB,aformat=channel_layouts=mono[a5]; \
|
||||
[a0][a1][a2][a3][a4][a5]join=inputs=6:channel_layout=5.1[a]" \
|
||||
-map "[a]" -c:a pcm_s16le "$W/bed.wav" </dev/null || true
|
||||
bed_dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$W/bed.wav" 2>/dev/null || echo 0)
|
||||
if ! awk "BEGIN{exit !($bed_dur > 7.9 && $bed_dur < 8.1)}"; then
|
||||
echo "🔴 the 5.1 bed is $bed_dur s, not the 8 s this sweep is built on -- refusing to score it" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# The same bed with 350 ms holes punched through it, every second.
|
||||
ffmpeg -v error -y -i "$W/bed.wav" \
|
||||
-af "volume=enable='lt(mod(t,1),0.35)':volume=0" -c:a pcm_s16le "$W/holes.wav"
|
||||
# A voice track: mono, with the real pauses of speech.
|
||||
ffmpeg -v error -y -i export/audio/voice/ADV.ogg -t 60 -ac 1 -c:a pcm_s16le "$W/voice.wav"
|
||||
|
||||
echo "check-capture against its documented control sweep:"
|
||||
expect "six distinct tones (PCM)" "$W/tones.wav" PASS/PASS
|
||||
expect "the same tones as float32" "$W/tones_f32.wav" PARTIAL/PARTIAL
|
||||
expect "real music bed" "$W/bed.wav" PASS/PASS
|
||||
expect "bed with 350 ms holes punched in" "$W/holes.wav" n/a/FAIL
|
||||
expect "voice track, mono, real pauses" "$W/voice.wav" PASS/UNJUDGED
|
||||
printf ' %-42s %-8s the artifact is gone; not synthesised, because\n' "the starved capture" "MISSING"
|
||||
printf ' %-42s %-8s fitting one to its published statistics would be\n' "" ""
|
||||
printf ' %-42s %-8s a control shaped to the answer it must give\n' "" ""
|
||||
echo
|
||||
[ $fail -eq 0 ] && echo "the sweep matches the specification" || echo "🔴 check-capture no longer matches AUDIO-VERIFICATION.md"
|
||||
exit $fail
|
||||
106
tools/port/check-citations
Executable file
106
tools/port/check-citations
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Do the repo paths cited in `docs/port/*.md` actually resolve?
|
||||
|
||||
tools/port/check-citations # assert
|
||||
tools/port/check-citations --selftest # can it fail?
|
||||
|
||||
`audit-kinds` checks citations in `authored/`. Nothing checked the PROSE, and
|
||||
prose is where this port explains itself. A first run found **37 of 91**
|
||||
non-resolving, 41 %, in two very different classes:
|
||||
|
||||
* **19 on the Decoder's topic branch** — real files, not merged here. Not
|
||||
errors. A reader in this checkout still cannot follow them, which is worth
|
||||
reporting and not worth failing on; the fix is a merge, not an edit.
|
||||
* **7 that resolve NOWHERE** — `docs/BLOCKED.md`, `docs/FORMAT.md`,
|
||||
`port/manifest.json`, `port/screens/title/*.json`. Left behind by the
|
||||
monorepo move and the `export/` rename. Those are simply wrong: a reader
|
||||
following one gets nothing, and nothing had ever told anyone.
|
||||
|
||||
So the two classes are separated and only the second fails. A check that failed
|
||||
on the first would be red for a state nobody in this container can fix, which is
|
||||
the shape the display guard exists to prevent.
|
||||
|
||||
⚠️ THE PEER-BRANCH CLASS IS THE OTHER AGENT'S POINT, TURNED ON MYSELF. They
|
||||
observed that everything they hand over links into `docs/re/` files that live
|
||||
only on their branch, so every link they send is dangling from here. The same is
|
||||
true in reverse and neither of us was counting.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import glob
|
||||
|
||||
# A repo path with a file extension, optionally in backticks or a markdown link.
|
||||
CITE = re.compile(
|
||||
r"`?((?:docs|crates|port|tools|authored|export)/[\w./-]+"
|
||||
r"\.(?:md|rs|gd|json|txt|py|tsv|csv))`?"
|
||||
)
|
||||
PEER_REFS = ("origin/auto/frame-blend-draw-path", "origin/main")
|
||||
|
||||
|
||||
def on_a_ref(path: str) -> str | None:
|
||||
"""The first ref that carries `path`, or None."""
|
||||
for ref in PEER_REFS:
|
||||
if subprocess.run(["git", "cat-file", "-e", f"{ref}:{path}"],
|
||||
capture_output=True).returncode == 0:
|
||||
return ref
|
||||
return None
|
||||
|
||||
|
||||
def scan(files):
|
||||
resolves, peer, nowhere = 0, {}, {}
|
||||
for p in files:
|
||||
try:
|
||||
text = open(p, encoding="utf-8").read()
|
||||
except OSError:
|
||||
continue
|
||||
for m in sorted(set(CITE.findall(text))):
|
||||
if os.path.exists(m):
|
||||
resolves += 1
|
||||
elif (ref := on_a_ref(m)):
|
||||
peer.setdefault(m, (p, ref))
|
||||
else:
|
||||
nowhere.setdefault(m, p)
|
||||
return resolves, peer, nowhere
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if "--selftest" in sys.argv:
|
||||
# 🔴 A CHECK THAT CANNOT FAIL IS NOT A CHECK. This plants a citation of a
|
||||
# path that exists on no ref and requires the scanner to catch it, and a
|
||||
# citation of a real file and requires it NOT to. Both directions,
|
||||
# because a scanner that flagged everything would also "pass" the first.
|
||||
tmp = os.path.join(os.environ.get("TMPDIR", "/tmp"), "check-citations-selftest")
|
||||
os.makedirs(tmp, exist_ok=True)
|
||||
bad = os.path.join(tmp, "bad.md")
|
||||
open(bad, "w").write("see `docs/port/this-file-does-not-exist-anywhere.md`\n")
|
||||
good = os.path.join(tmp, "good.md")
|
||||
open(good, "w").write("see `docs/port/PORT-MISSION.md`\n")
|
||||
_, _, nb = scan([bad])
|
||||
r, _, ng = scan([good])
|
||||
ok = len(nb) == 1 and len(ng) == 0 and r == 1
|
||||
print("selftest: planted dangling caught=%s, real citation passed=%s -> %s"
|
||||
% (len(nb) == 1, len(ng) == 0 and r == 1, "ok" if ok else "🔴 BROKEN"))
|
||||
return 0 if ok else 2
|
||||
|
||||
files = sorted(glob.glob("docs/port/*.md"))
|
||||
resolves, peer, nowhere = scan(files)
|
||||
total = resolves + len(peer) + len(nowhere)
|
||||
print("citations of repo paths in docs/port/*.md: %d" % total)
|
||||
print(" resolve here : %d" % resolves)
|
||||
print(" on a peer branch, not merged: %d (reported, not failed)" % len(peer))
|
||||
for m, (src, ref) in sorted(peer.items()):
|
||||
print(" %-52s %s <- %s" % (m, ref.split("/")[-1], os.path.basename(src)))
|
||||
if nowhere:
|
||||
print(" 🔴 resolve NOWHERE : %d" % len(nowhere))
|
||||
for m, src in sorted(nowhere.items()):
|
||||
print(" %-52s <- %s" % (m, os.path.basename(src)))
|
||||
print("\n🔴 a reader following those gets nothing. Fix the path or drop the citation.")
|
||||
return 1
|
||||
print(" 🔴 resolve nowhere : 0")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
413
tools/port/check-claims
Executable file
413
tools/port/check-claims
Executable file
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env bash
|
||||
# Every refuted claim must appear only inside its own correction.
|
||||
#
|
||||
# tools/port/check-claims
|
||||
#
|
||||
# 🔴 WHY THIS IS A CHECK AND NOT AN AUDIT. The Decoder's rule -- *grep the corpus
|
||||
# for the claim, not for the file you were working in* -- found a refuted sentence
|
||||
# still shipping in this port's `manifest.json`, and a withdrawn one still
|
||||
# standing in `DECISIONS.md`. Running that by hand finds the instances present on
|
||||
# the day it is run. It does not stop the next one.
|
||||
#
|
||||
# So: a REGISTER. Each row is a claim this corpus has refuted, plus a marker that
|
||||
# must appear near every occurrence. A hit without its marker fails the run.
|
||||
#
|
||||
# ⚠️ Two things learned building it, both from the other agent:
|
||||
#
|
||||
# * a "kept for the record" block STILL ASSERTS. Marking the heading superseded
|
||||
# does not mark the sentence a reader lands on, so the marker must sit near
|
||||
# the CLAIM, not at the top of the section.
|
||||
# * naming a refuted claim keeps it greppable, so this check returns its own
|
||||
# corrections as hits -- which is the point. The marker is what distinguishes
|
||||
# "quoted while being refuted" from "still asserted".
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
WINDOW=400 # characters either side of a hit in which the marker must appear
|
||||
fail=0; total_marked=0; scanned=0; peer_hits=0; _peer_probe_done=0
|
||||
|
||||
# 🔴 THE MARKER IS AN EXPLICIT SENTINEL, NOT A KEYWORD.
|
||||
#
|
||||
# The first version matched a per-claim keyword -- "refuted", "WITHDRAWN" -- near
|
||||
# the hit. Every one of its four failures was a quotation sitting INSIDE a
|
||||
# correction whose wording happened not to contain the keyword: a table cell
|
||||
# reading "standing, unmarked", a sentence reading "the real count was ten".
|
||||
#
|
||||
# Widening the window or adding synonyms until those passed would have been
|
||||
# tuning a threshold until the answer came out right, which is the failure this
|
||||
# corpus has spent a fortnight cataloguing. So the marker is a TOKEN THE AUTHOR
|
||||
# PLACES: `[refuted]` near any quotation of a registered claim. It cannot be
|
||||
# satisfied by phrasing, and its absence means exactly one thing.
|
||||
#
|
||||
# ⚠️ The cost is honest: every quotation must be marked by hand, and a new
|
||||
# refuted claim means a new row plus marking its existing quotations. That work
|
||||
# is the check.
|
||||
MARKER='[refuted]'
|
||||
REGISTER=$(cat <<'ROWS'
|
||||
TAIL of the kept stream :: the leading chunk of a voice region duplicates the end of the kept stream, so it can be dropped
|
||||
known too fast :: the boot plays both splashes faster than the game does
|
||||
only thing making the plate :: the plate reappears because of one authored cause
|
||||
no loop-point field has been identified :: nothing anywhere on the disc or in the runtime states where a bank loops
|
||||
AUDIBLY WRONG AT THE SEAM :: replaying the menu bed from sample 0 puts audible fade-out and silence at the loop seam
|
||||
1 of 3 streams :: the exporter ships one of a voice region's three streams
|
||||
six expected DIFFERS :: six screens are expected to differ from the reference renderer
|
||||
goes against the port :: the JP title capture adjudicates title_jp against this port's rendering
|
||||
the capture turns out to determine it :: the leaf's phase is fixed by the capture rather than being an arbitrary choice
|
||||
COMPOSITED rather than standalone :: the four screens without an opaque-black primitive are drawn composited over another screen
|
||||
structural limit, not an unrun experiment :: EXTRAS cannot be strengthened past n=1 because this archive holds no second destination
|
||||
HANDOFF Q10 says nothing on the disc :: nothing on the disc names which track the menu plays, so the port must choose one
|
||||
28 % of `S00A`'s frames :: 28 % of S00A's frames and 47 % of ADV's reached the screen, measured
|
||||
by three routes :: DIFFICULTY is identified by three independent routes
|
||||
HANDOFF has not moved in four milestones :: the contract itself is static, rather than static only on the branch this checkout reads
|
||||
ROWS
|
||||
)
|
||||
|
||||
[ -n "${CLAIMS_REGISTER+x}" ] && REGISTER="$CLAIMS_REGISTER"
|
||||
|
||||
# 🔴 A REGISTER THAT PARSES NOTHING REPORTED CLEAN, FOREVER. The scan loop runs
|
||||
# once per row; with no rows it runs zero times, `fail` stays 0, and the script
|
||||
# printed "every refuted claim appears only inside its correction" and exited 0.
|
||||
# That is the stub defect -- prints a result, asserts nothing -- sitting in the
|
||||
# checker whose clean runs both agents lean on. The Decoder found it in their
|
||||
# equivalent the same day; it was here too.
|
||||
_rows=$(printf '%s\n' "$REGISTER" | grep -c '[^[:space:]]' || true)
|
||||
if [ "$_rows" -eq 0 ]; then
|
||||
echo "🔴 the refuted register is EMPTY -- this check would pass everything." >&2
|
||||
echo " Exit 2: the harness is broken, not the corpus." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# `--control`: the known negatives, EXECUTED.
|
||||
#
|
||||
# 🔴 Until now this check had NO control machinery at all. Every "planted a
|
||||
# revival, it failed, removed it, it passed" in `DECISIONS.md` was done BY HAND,
|
||||
# once, and never again -- in a repository where two of my own tools carry the
|
||||
# line *"a control that does not execute is not a control"*. It was written
|
||||
# about somebody else's tool.
|
||||
#
|
||||
# Four cases, each driving THIS script as a subprocess and reading its real exit
|
||||
# code rather than reasoning about what it would do:
|
||||
#
|
||||
# clean tree -> 0
|
||||
# unmarked revival planted -> 1 (the check must catch it)
|
||||
# revival planted MARKED -> 0 (and must not false-positive on it)
|
||||
# register emptied -> 2 (the harness is broken, not the corpus)
|
||||
#
|
||||
# The plant lands in a real scanned directory, because a control that runs
|
||||
# somewhere the tool does not look proves nothing about the tool.
|
||||
if [ "${1:-}" = "--control" ]; then
|
||||
probe="docs/port/.claims-control-probe.md"
|
||||
trap 'rm -f "$probe"' EXIT INT TERM
|
||||
# The PHRASE only: rows now read `phrase :: proposition`, and the case strings
|
||||
# below are colon-delimited, so passing a whole row made the harness parse the
|
||||
# proposition as a field and report its own cases broken. A data-shape change
|
||||
# breaking the harness that guards the data is this iteration's small version
|
||||
# of my rows making the Decoder's parser fail silently.
|
||||
claim=$(printf '%s\n' "$REGISTER" | grep -m1 '[^[:space:]]')
|
||||
claim="${claim%% :: *}"
|
||||
ok=0
|
||||
run() { CLAIMS_CONTROL=1 "$0" >/dev/null 2>&1; echo $?; }
|
||||
rm -f "$probe"
|
||||
for case in "clean::0" "unmarked:$claim:1" "marked:$claim [refuted]:0"; do
|
||||
IFS=: read -r name body want <<<"$case"
|
||||
if [ -n "$body" ]; then printf '%s\n' "$body" > "$probe"; else rm -f "$probe"; fi
|
||||
got=$(run)
|
||||
if [ "$got" = "$want" ]; then
|
||||
printf ' %-26s exit %s ✅\n' "$name" "$got"
|
||||
else
|
||||
printf ' %-26s exit %s, wanted %s 🔴\n' "$name" "$got" "$want"; ok=1
|
||||
fi
|
||||
done
|
||||
rm -f "$probe"
|
||||
# 🔴 FIFTH CASE: the same text OUTSIDE the scanned root must give 0.
|
||||
#
|
||||
# Without it, "the plant is inside a scanned directory" is a property I
|
||||
# verified BY HAND, once -- which is the exact pattern I had just finished
|
||||
# criticising in this tool one iteration earlier. The pair is what asserts the
|
||||
# boundary is real: identical text, exit 1 inside and 0 outside. Either half
|
||||
# alone is consistent with the tool scanning everything, or nothing.
|
||||
#
|
||||
# The Decoder added this to theirs after I raised the boundary; the reason it
|
||||
# was worth adding is that their property held *because they had reasoned it*,
|
||||
# not because anything asserted it. Mine was in the same state.
|
||||
outside="${TMPDIR:-/tmp}/claims-control-outside.md"
|
||||
printf '%s\n' "$claim" > "$outside"
|
||||
got=$(run)
|
||||
rm -f "$outside"
|
||||
if [ "$got" = "0" ]; then printf ' %-26s exit 0 ✅\n' "same text outside root"
|
||||
else printf ' %-26s exit %s, wanted 0 🔴\n' "same text outside root" "$got"; ok=1; fi
|
||||
got=$(CLAIMS_REGISTER="" "$0" >/dev/null 2>&1; echo $?)
|
||||
if [ "$got" = "2" ]; then printf ' %-26s exit 2 ✅\n' "empty register"
|
||||
else printf ' %-26s exit %s, wanted 2 🔴\n' "empty register" "$got"; ok=1; fi
|
||||
# Sixth case: a tree with nothing to scan. It used to die in the withdrawal
|
||||
# hook and exit 1 -- "a refuted claim is still being asserted" -- for a wrong
|
||||
# directory. Liveness and diagnosis are both asserted here.
|
||||
_empty="${TMPDIR:-/tmp}/claims-liveness-root"; mkdir -p "$_empty"
|
||||
got=$(cd "$_empty" && PROJECT_DIR="$_empty" "$OLDPWD/$0" >/dev/null 2>&1; echo $?)
|
||||
if [ "$got" = "2" ]; then printf ' %-26s exit 2 ✅\n' "nothing to scan"
|
||||
else printf ' %-26s exit %s, wanted 2 🔴\n' "nothing to scan" "$got"; ok=1; fi
|
||||
echo
|
||||
[ $ok -eq 0 ] && echo "the register check fails when it must, and says so distinctly" \
|
||||
|| echo "🔴 the control machinery itself is broken"
|
||||
exit $ok
|
||||
fi
|
||||
|
||||
|
||||
|
||||
# ─── THE WITHDRAWAL-TIME HOOK ────────────────────────────────────────────────
|
||||
# The register enforces claims it KNOWS ABOUT; knowing about them was manual, and
|
||||
# that is how ~8 claims were withdrawn this session and 0 registered. A sweep
|
||||
# cannot fix it -- by the time you sweep, the withdrawal is already unpublished.
|
||||
# The hook fires where the withdrawal is WRITTEN.
|
||||
#
|
||||
# A correction in DECISIONS.md has a shape: a heading carrying WITHDRAWN /
|
||||
# CORRECTION / "refuted". A section like that containing no registered phrase is
|
||||
# a death argued and never indexed.
|
||||
#
|
||||
# ⚠️ The register is passed in the ENVIRONMENT, not inlined. The first version
|
||||
# pasted the rows into this file's own heredoc -- which made every phrase an
|
||||
# unmarked quotation, and the checker flagged its own source. A tool that
|
||||
# violates the rule it enforces by being written is worth a comment.
|
||||
#
|
||||
# 🟡 REPORTED, NOT ASSERTED: not every correction retires a CLAIM -- some fix a
|
||||
# number, a scope, a wrong floor -- and forcing a row for those would push rows
|
||||
# in to silence the check, the failure this file exists to prevent.
|
||||
#
|
||||
# ⚠️ AND IT WILL ALWAYS OVER-REPORT ON WELL-WRITTEN CORRECTIONS. The detection is
|
||||
# "does this section contain a registered phrase", which requires the correction
|
||||
# to QUOTE the dead claim. A good correction paraphrases it away: the JP heading
|
||||
# now reads "does NOT go against the port", which does not contain the registered
|
||||
# "goes against the port" [refuted] and is flagged despite being registered.
|
||||
#
|
||||
# The Decoder's resolution is the right one and costs the correction nothing:
|
||||
# **the register entry is the verbatim home of the dead phrase; prose paraphrases
|
||||
# freely.** They are different documents, so the phrase always has one exact
|
||||
# place to live without any correction having to carry it. What follows for this
|
||||
# hook is that its candidate list mixes "never registered" with "registered and
|
||||
# paraphrased", and it cannot separate them -- so the list is a prompt to check,
|
||||
# never a defect count.
|
||||
echo
|
||||
echo "withdrawal-time hook -- correction sections that registered nothing:"
|
||||
# 🔴 PREFLIGHT. Run from the wrong directory this used to die inside the
|
||||
# withdrawal hook with a FileNotFoundError and exit **1** -- which in this
|
||||
# script's own vocabulary means "a refuted claim is still being asserted". A real
|
||||
# failure with a fabricated diagnosis, the same shape as my control anchoring at
|
||||
# the wrong document. The roots it needs are named here and their absence is a
|
||||
# HARNESS fault with its own code.
|
||||
for _root in docs docs/port authored tools/port; do
|
||||
[ -d "$_root" ] || {
|
||||
echo "🔴 \`$_root\` is not here -- this check cannot scan anything." >&2
|
||||
echo " Exit 2: wrong directory or a bad checkout, not a dirty corpus." >&2
|
||||
exit 2
|
||||
}
|
||||
done
|
||||
[ -f docs/port/DECISIONS.md ] || {
|
||||
echo "🔴 docs/port/DECISIONS.md is missing -- the withdrawal hook has nothing" >&2
|
||||
echo " to read. Exit 2: the harness is broken, not the corpus." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
REG="$REGISTER" python3 - <<'HOOK'
|
||||
import os, re
|
||||
reg = [r.strip() for r in os.environ["REG"].split("\n") if r.strip()]
|
||||
doc = open("docs/port/DECISIONS.md").read()
|
||||
heads = [(m.start(), m.group(0)) for m in re.finditer(r"(?m)^##+ .*$", doc)]
|
||||
flagged = 0
|
||||
for i, (pos, head) in enumerate(heads):
|
||||
# 🔴 THE FIRST REGEX MATCHED HEADINGS *ABOUT* CORRECTIONS, NOT HEADINGS
|
||||
# MAKING THEM -- "withdraw" caught "rather than withdrawing", "refuted"
|
||||
# caught a section discussing the register itself. 33 candidates was a
|
||||
# measurement of the regex. Narrowed to headings that RETIRE something:
|
||||
# a leading WITHDRAWN/CORRECTION/Refuted, or an explicit "is withdrawn".
|
||||
if not re.search(r"^#+\s*(?:[^A-Za-z]*\s*)?(WITHDRAWN|CORRECTION|Refuted)\b"
|
||||
r"|\bis withdrawn\b|\bnow refuted\b", head):
|
||||
continue
|
||||
end = heads[i + 1][0] if i + 1 < len(heads) else len(doc)
|
||||
if not any(c in doc[pos:end] for c in reg):
|
||||
flagged += 1
|
||||
print(" candidate: %s" % head[:92].lstrip("# "))
|
||||
print(" none -- every correction section names a registered claim" if not flagged
|
||||
else " %d correction section(s) argue a withdrawal the register does not carry" % flagged)
|
||||
HOOK
|
||||
|
||||
while IFS= read -r claim; do
|
||||
# 🔴 ROWS CARRY A PROPOSITION NOW, `phrase :: what it asserted`.
|
||||
#
|
||||
# They were bare phrases, and that had two costs. A phrase is not a claim:
|
||||
# `1 of 3 streams` [refuted] is dead here and a LIVE warning in the Decoder's
|
||||
# corpus, and the bare row cannot say which proposition it killed -- so a peer
|
||||
# hit was unadjudicable even in principle. And the bareness made THEIR parser
|
||||
# fail silently: a reader looking for a quoted string in each row found none,
|
||||
# built an empty claim list, and reported a clean table. My data shape made
|
||||
# their instrument lie.
|
||||
#
|
||||
# The phrase is still the search key; the proposition is for whoever has to
|
||||
# judge a hit, here or in another corpus.
|
||||
proposition="${claim#* :: }"
|
||||
claim="${claim%% :: *}"
|
||||
[ -z "$claim" ] && continue
|
||||
hits=0; bad=0; marked=0
|
||||
while IFS= read -r loc; do
|
||||
[ -z "$loc" ] && continue
|
||||
f=${loc%%:*}
|
||||
hits=$((hits+1))
|
||||
out=$(python3 - "$f" "$claim" "$MARKER" "$WINDOW" <<'PY'
|
||||
import sys
|
||||
f, claim, marker, w = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
|
||||
s = open(f, encoding="utf-8", errors="ignore").read()
|
||||
# 🔴 THE REGISTER BLOCK IS ITS OWN VERBATIM HOME, and is excised before
|
||||
# scanning rather than relying on marker proximity. The rows used to be bare
|
||||
# phrases that happened to sit within the marker window of the file header;
|
||||
# adding a proposition to each pushed them out of it, and the check began
|
||||
# reporting its own register as twelve unmarked assertions. Widening the window
|
||||
# would have been tuning a constant to make a failure go away. Excising exactly
|
||||
# the heredoc -- and nothing else in this file -- keeps every other occurrence
|
||||
# in `check-claims` under the same rule as any other file, which matters because
|
||||
# the comments here quote dead phrases constantly.
|
||||
if f.endswith("check-claims"):
|
||||
a = s.find("REGISTER=$(cat <<'ROWS'")
|
||||
b = s.find("\nROWS", a) if a >= 0 else -1
|
||||
if a >= 0 and b > a:
|
||||
s = s[:a] + (" " * (b - a)) + s[b:]
|
||||
i = n = 0
|
||||
low, claim_low = s.lower(), claim.lower()
|
||||
while True:
|
||||
i = low.find(claim_low, i)
|
||||
if i < 0:
|
||||
break
|
||||
if marker.lower() not in low[max(0, i-w):i+w+len(claim)]:
|
||||
print(" unmarked in %s at char %d" % (f, i))
|
||||
sys.exit(1)
|
||||
n += 1
|
||||
i += len(claim)
|
||||
# Every suppression, counted. A checker that can discard an occurrence in silence
|
||||
# reports the same clean run whether or not a live assertion is hiding among the
|
||||
# marked ones, and its zero is unfalsifiable. Reached from the loud end here and
|
||||
# from the quiet end by the Decoder on the same day: their marker language was
|
||||
# vouching for 8 of 8 mentions, so their 0 was going to be 0 either way.
|
||||
print(n)
|
||||
sys.exit(0)
|
||||
PY
|
||||
) && marked=$((marked + out)) || { printf '%s\n' "$out"; bad=$((bad+1)); }
|
||||
# 🔴 CASE-INSENSITIVE since 2026-08-30, and the reason is a live miss. The
|
||||
# register held "no loop-point field has been identified" [refuted]; `BLOCKED.md`
|
||||
# it capitalised at the start of a sentence, and the check reported clean while
|
||||
# a refuted claim stood unmarked in the file whose whole job is to say what is
|
||||
# still open. The Decoder found the same class the same day from the other end
|
||||
# -- their register missed a revival that kept the claim and changed the second
|
||||
# clause. A register matching EXACT wording does not protect the documents that
|
||||
# rewrite most, and a capital letter is the cheapest rewrite there is.
|
||||
done < <(grep -ril -- "$claim" docs/port/ crates/ port/ tools/ authored/ 2>/dev/null || true)
|
||||
|
||||
# 🔴 PEER-OWNED ROOTS ARE SCANNED FROM THE REF, NOT THE TREE.
|
||||
#
|
||||
# `docs/re/`, `docs/game/` and `docs/agents/` are written by the Decoder. My
|
||||
# working copies are 246, 9 and 13 commits behind their heads, so any verdict
|
||||
# this check reached about one of their files would be a verdict about MY
|
||||
# STALE COPY -- and the failure direction is the false positive: flagging a
|
||||
# claim they have already corrected. That is exactly what they did to me by
|
||||
# hand, reading my `BLOCKED.md` 234 commits behind.
|
||||
#
|
||||
# Excluding them would hide the exposure; reporting from the stale copy would
|
||||
# keep it. So the scan reads the newest blob on any ref. It is the only
|
||||
# structural fix either agent has found for this class -- READ THE REF, NOT
|
||||
# THE TREE -- and it is why `contract-check` stayed correct while this tree sat
|
||||
# 115 commits behind.
|
||||
#
|
||||
# ⚠️ Measured before building: 33 files match a registered claim today and
|
||||
# ZERO are in a peer-owned root. The exposure is latent, not active. Recorded
|
||||
# because "I checked and it was clean" and "I never looked" must not read the
|
||||
# same, which is this week's whole lesson.
|
||||
_peer_ref=$(git log --all -n 1 --format=%h -- docs/re docs/game docs/agents)
|
||||
# 🔴 THE PEER SCAN GETS A KNOWN POSITIVE, because a zero from a broken reader
|
||||
# looks identical to a real one. The Decoder demonstrated both halves of that
|
||||
# in one iteration: they controlled their cross-scan by probing this port's
|
||||
# live `BLOCKED.md` for a string they knew was in it -- and separately produced
|
||||
# a FALSE ZERO from a reader they had invented minutes earlier, regexing quoted
|
||||
# strings out of `check-claims` into 63 phantom phrases that matched nothing.
|
||||
#
|
||||
# This scan found six hits today, so it is demonstrably live NOW. The control
|
||||
# is for the run where their pages no longer contain any of these phrases and
|
||||
# a zero would otherwise be unfalsifiable: a wrong ref, a wrong pathspec or a
|
||||
# renamed directory all produce the same clean line.
|
||||
if [ -n "$_peer_ref" ] && [ "$_peer_probe_done" != "1" ]; then
|
||||
_peer_probe_done=1
|
||||
_seen=$(git ls-tree -r --name-only "$_peer_ref" -- docs/re docs/game docs/agents 2>/dev/null | wc -l)
|
||||
if [ "$_seen" -lt 10 ]; then
|
||||
echo "🔴 the peer scan can see only $_seen file(s) at $_peer_ref -- a wrong" >&2
|
||||
echo " ref or pathspec reads the same as a clean corpus. Exit 2." >&2
|
||||
exit 2
|
||||
fi
|
||||
printf ' peer scan reads %s file(s) at %s -- the reader is live\n' "$_seen" "$_peer_ref"
|
||||
fi
|
||||
if [ -n "$_peer_ref" ]; then
|
||||
while IFS= read -r loc; do
|
||||
[ -z "$loc" ] && continue
|
||||
# 🔴 REPORTED, NOT COUNTED AS A FAILURE -- corrected before shipping.
|
||||
#
|
||||
# The first version put these in `bad`, which failed the run. That applies
|
||||
# MY marking convention to THEIR corpus: `[refuted]` is a token this port
|
||||
# uses in its own files, and their pages mark corrections their own way.
|
||||
# Of the six hits, three are in their `METHOD.md` and one in an audit log
|
||||
# -- pages whose subject IS the corrections, so the phrase appearing there
|
||||
# is what a correction looks like, not a revival.
|
||||
#
|
||||
# So this is a prompt to look, never a verdict -- the same conclusion the
|
||||
# withdrawal hook reached about its own candidates. A checker that fails
|
||||
# on another agent's file for not using this one's punctuation would be
|
||||
# noise inside a day, and I would have been the one to file it.
|
||||
printf ' ℹ️ a peer-owned file at their head contains it: %s (%s)\n' \
|
||||
"${loc#*:}" "$_peer_ref"
|
||||
peer_hits=$((peer_hits+1))
|
||||
done < <(git grep -ril -- "$claim" "$_peer_ref" -- docs/re docs/game docs/agents 2>/dev/null || true)
|
||||
fi
|
||||
scanned=$((scanned + hits))
|
||||
if [ "$bad" -eq 0 ]; then
|
||||
printf ' %-42s %d file(s), %d occurrence(s) suppressed\n' "$claim" "$hits" "$marked"
|
||||
[ -n "$proposition" ] && [ "$proposition" != "$claim" ] \
|
||||
&& printf ' it asserted: %s\n' "$proposition"
|
||||
total_marked=$((total_marked + marked))
|
||||
else
|
||||
printf ' %-42s 🔴 %d file(s) assert it unmarked\n' "$claim" "$bad"; fail=1
|
||||
fi
|
||||
done <<< "$REGISTER"
|
||||
|
||||
# 🔴 LIVENESS. A register full of claims and a tree with nothing in it reports
|
||||
# clean: the grep matches no files, every row scores 0, and the run passes having
|
||||
# READ NOTHING. Wrong directory, renamed docs, a bad checkout -- all produce a
|
||||
# green line. The Decoder's rule for the family: a control that only compares two
|
||||
# things cannot tell you the comparison is happening.
|
||||
if [ "$scanned" -eq 0 ]; then
|
||||
echo "🔴 no file anywhere contains any registered claim -- this check READ" >&2
|
||||
echo " NOTHING. Exit 2: the harness is broken, not the corpus." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$peer_hits" -gt 0 ]; then
|
||||
printf ' %d occurrence(s) sit in PEER-OWNED files, read at their branch head\n' "$peer_hits"
|
||||
echo " rather than from this stale tree. NOT counted as failures: their pages"
|
||||
echo " mark corrections their own way, and the pages whose subject IS the"
|
||||
echo " corrections are where a dead phrase is supposed to appear."
|
||||
echo
|
||||
echo " 🔴 AND A PEER HIT IS UNADJUDICABLE FROM THE PHRASE ALONE. This register"
|
||||
echo " indexes PHRASES, not PROPOSITIONS. Demonstrated: \`1 of 3 streams\`"
|
||||
echo " [refuted] is"
|
||||
echo " dead here -- the exporter shipped one stream and now ships all"
|
||||
echo " qualifying ones -- and LIVE in the Decoder's corpus, where it is a"
|
||||
echo " standing warning. Same words, different propositions, and the bare"
|
||||
echo " row cannot tell them apart. It is not even unambiguous HERE: this"
|
||||
echo " port's own DECISIONS says the warning stays, in the same file where"
|
||||
echo " the export claim is dead. The marker separates them locally because"
|
||||
echo " the context is mine. Nothing separates them across corpora."
|
||||
echo
|
||||
fi
|
||||
printf ' %d occurrence(s) were SUPPRESSED by a neighbouring `%s`.\n' "$total_marked" "$MARKER"
|
||||
echo " That number is the size of what this check chose not to look at. A"
|
||||
echo " detector that can discard a candidate without saying how many has an"
|
||||
echo " unfalsifiable clean run -- its zero reads the same whether or not a live"
|
||||
echo " assertion is hiding among the marked ones."
|
||||
echo
|
||||
[ $fail -eq 0 ] && echo "every refuted claim appears only inside its correction" \
|
||||
|| echo "🔴 a refuted claim is still being asserted"
|
||||
exit $fail
|
||||
92
tools/port/check-modding
Executable file
92
tools/port/check-modding
Executable file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# Check the export against MODDING.md's five rules.
|
||||
#
|
||||
# tools/port/check-modding
|
||||
#
|
||||
# `MODDING.md` opens by saying modding is a requirement and **a constraint on the
|
||||
# exporter today, not a later feature**. Nothing checked it. That is the shape
|
||||
# this port keeps finding: a rule stated, believed, and unexercised -- the black
|
||||
# hold implemented and never called, `ScreenView.skipped` written and never read,
|
||||
# `stop_bed` provided and never used, `--focus` parsed and overwritten.
|
||||
#
|
||||
# So this is a guard, not a fix: every rule passes as of 2026-08-30. Its value is
|
||||
# that the next thing to break one of them says so.
|
||||
#
|
||||
# ⚠️ What it CANNOT check: rule 3's "modern, editable" is enforced by extension,
|
||||
# which cannot tell a valid PNG from a renamed one, and rule 1's "one logical
|
||||
# asset" is checked as one-file-per-reference -- an exporter that split a sprite
|
||||
# and referenced both halves would pass. These are the rules' checkable shadows.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
EXPORT="${EXPORT:-export}"
|
||||
fail=0
|
||||
note() { printf ' %-6s %s\n' "$1" "$2"; [ "$1" = FAIL ] && fail=1 || true; }
|
||||
|
||||
echo "MODDING rule 1 -- one logical asset, one file"
|
||||
python3 - "$EXPORT" <<'PY'
|
||||
import json, glob, os, sys
|
||||
E = sys.argv[1]
|
||||
ref = set()
|
||||
for p in glob.glob(f"{E}/screens/*/*.json"):
|
||||
d = json.load(open(p))
|
||||
for e in d["elements"]:
|
||||
for k in ("sprite", "focus_sprite"):
|
||||
if e.get(k): ref.add(e[k])
|
||||
for sub in ("focus", "leaf"):
|
||||
for fe in (e.get(sub) or {}).get("elements", []):
|
||||
if fe.get("sprite"): ref.add(fe["sprite"])
|
||||
files = {os.path.relpath(p, E) for p in glob.glob(f"{E}/sprites/**/*.png", recursive=True)}
|
||||
missing, orphan = sorted(ref - files), sorted(files - ref)
|
||||
split = [f for f in files if any(t in os.path.basename(f) for t in ("part", "seg", "chunk"))]
|
||||
print(" %-6s %d sprites referenced, %d present" % ("OK" if not (missing or orphan) else "FAIL", len(ref), len(files)))
|
||||
if missing: print(" FAIL referenced but absent:", missing[:5])
|
||||
if orphan: print(" FAIL present but unreferenced:", orphan[:5])
|
||||
if split: print(" FAIL split-looking names:", split[:5])
|
||||
sys.exit(1 if (missing or orphan or split) else 0)
|
||||
PY
|
||||
[ $? -eq 0 ] || fail=1
|
||||
|
||||
echo "MODDING rule 2 -- names a person recognises"
|
||||
hex=$(find "$EXPORT" -type f | grep -Ec '0x[0-9a-f]{6,}|/[0-9a-f]{8}\.' || true)
|
||||
[ "$hex" -eq 0 ] && note OK "no hex or hash-shaped filenames" || note FAIL "$hex hash-shaped names"
|
||||
|
||||
echo "MODDING rule 3 -- modern, editable formats only"
|
||||
bad=$(find "$EXPORT" -type f | sed 's/.*\.//' | sort -u | grep -vE '^(json|png|ogg|ogv|cmd)$' || true)
|
||||
[ -z "$bad" ] && note OK "only json/png/ogg/ogv (+ .cmd sidecars)" || note FAIL "unexpected: $(echo $bad)"
|
||||
# A `.cmd` is not an asset. It is allowed only because it SAYS SO in its own
|
||||
# first line -- see video.rs. An unlabelled one reads as something to edit.
|
||||
for c in $(find "$EXPORT" -name '*.cmd'); do
|
||||
head -1 "$c" | grep -q '^# Generated by sylpheed-export' \
|
||||
&& note OK "$(basename "$c") is self-describing" \
|
||||
|| note FAIL "$(basename "$c") has no header saying what it is"
|
||||
done
|
||||
|
||||
echo "MODDING rule 4 -- base and overrides, never one merged pile"
|
||||
grep -q 'data/mods' .gitignore && note OK "data/mods contents are gitignored" \
|
||||
|| note FAIL "data/mods is not gitignored -- a mod is usually a game asset"
|
||||
grep -rq 'data/mods' crates/sylpheed-export/src/ && note OK "the exporter resolves overrides" \
|
||||
|| note FAIL "nothing reads data/mods"
|
||||
|
||||
echo "MODDING rule 5 -- provenance in every generated file"
|
||||
python3 - "$EXPORT" <<'PY'
|
||||
import json, glob, os, sys
|
||||
E = sys.argv[1]; miss = []
|
||||
for p in sorted(glob.glob(f"{E}/**/*.json", recursive=True)):
|
||||
d = json.load(open(p))
|
||||
if os.path.basename(p) == "manifest.json":
|
||||
# The manifest is the provenance -- it carries disc, exporter and the
|
||||
# formats revision for the whole tree, so it has no `source` of its own.
|
||||
if not all(k in d for k in ("disc", "exporter", "formats_rev")): miss.append(p)
|
||||
continue
|
||||
src = d.get("source") or {}
|
||||
if not (isinstance(src, dict) and src): miss.append(p)
|
||||
print(" %-6s %d json files carry provenance" % ("OK" if not miss else "FAIL",
|
||||
len(glob.glob(f"{E}/**/*.json", recursive=True)) - len(miss)))
|
||||
for m in miss[:5]: print(" FAIL ", m)
|
||||
sys.exit(1 if miss else 0)
|
||||
PY
|
||||
[ $? -eq 0 ] || fail=1
|
||||
|
||||
echo
|
||||
[ $fail -eq 0 ] && echo "all five rules pass" || echo "🔴 a MODDING rule is broken"
|
||||
exit $fail
|
||||
538
tools/port/contract-check
Executable file
538
tools/port/contract-check
Executable file
@@ -0,0 +1,538 @@
|
||||
#!/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.
|
||||
|
||||
🔴 WHEN A CHECK GOES `ANCHOR LOST`, ADD A SECOND NARROW ANCHOR -- DO NOT LOOSEN
|
||||
THIS ONE. The temptation is to make the pattern general enough to survive any
|
||||
rewording, and a general matcher fails in a way you have not met yet instead of
|
||||
one you can see. The Decoder reached this the expensive way: a narrow calibrated
|
||||
reader failed, they replaced it wholesale with a whole-frame comparison, and the
|
||||
swap felt like rigour until a crash dialog overlaid the frame and killed the
|
||||
general instrument while the narrow one kept working.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def check_splash_times(h):
|
||||
"""The splash's ABSOLUTE keyframe times, not just the gap between two of them.
|
||||
|
||||
🔴 Added 2026-08-30 because the dwell check above is a DIFFERENCE, and a
|
||||
difference is blind to the origin: a reader whose times were all shifted by a
|
||||
constant would produce the same 190 and pass. That is not hypothetical -- the
|
||||
Decoder's own control asserted "two DOWNs move two items", which a constant
|
||||
offset preserves exactly, and it passed for a whole session on a reader that
|
||||
was two items wrong. Ground truth caught it; the control could not.
|
||||
|
||||
The contract prints entry 10's times in full, so the origin is checkable.
|
||||
"""
|
||||
m = re.search(r"entry 10's times are\s*`\[([0-9, ]+)\]`", h)
|
||||
want = [int(x) for x in m.group(1).split(",")] if m else None
|
||||
d = jload("export/screens/title/publisher_logo.json")
|
||||
got = sorted({k["t"] for e in d["elements"] for k in e["keyframes"]}) if d else None
|
||||
report("splash absolute times", want, got, want is not None and want == got)
|
||||
|
||||
|
||||
def check_initial_focus(h):
|
||||
"""What the menu opens on FROM A FRESH BOOT -- measured, and it was authored.
|
||||
|
||||
Anchored on the measurement rather than on the value, so that if the reading
|
||||
is corrected again this fails instead of silently agreeing.
|
||||
"""
|
||||
want = "NEW GAME" if re.search(
|
||||
r"\*\*Initial focus on a fresh boot is `NEW GAME`\*\*", h) else None
|
||||
scr = ((jload("authored/flow.json") or {}).get("screens") or {}).get("main_menu", {})
|
||||
bid = scr.get("initial_focus")
|
||||
got = (scr.get("buttons") or {}).get(bid, {}).get("label")
|
||||
kind = scr.get("initial_focus_kind")
|
||||
report("menu opens on (fresh boot)", want, f"{got} [{kind}]",
|
||||
want is not None and got == want and kind == "measured")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def fn_nav_perturbed(fn, old, new):
|
||||
"""Run a walk-anchored check against a perturbed copy of the walk.
|
||||
|
||||
`nav()` reads from git, so the perturbation is injected by swapping the
|
||||
function out rather than by editing a file -- nothing on disk is touched.
|
||||
"""
|
||||
global nav
|
||||
real = nav
|
||||
nav = lambda: (real()[0].replace(old, new), real()[1])
|
||||
try:
|
||||
fn(None)
|
||||
finally:
|
||||
nav = real
|
||||
|
||||
|
||||
def selftest(h):
|
||||
"""Does the CONTROL MACHINERY notice a check that cannot fail?
|
||||
|
||||
🔴 THE GAP THIS CLOSES, named by me and prioritised by the Decoder: every
|
||||
`--control` run asserts that each check FAILS on a perturbed contract. None
|
||||
of them asserted that a **broken control reports broken**. That is the same
|
||||
shape as printing a verdict without asserting it, one level up — and a
|
||||
control harness that silently approves a dead check is exactly as useless as
|
||||
a check that silently approves a dead value.
|
||||
|
||||
So a stub check that can never fail is fed to the machinery, and the
|
||||
machinery must flag it. If the stub comes back "✅ fails as it must", the
|
||||
harness is broken and says so with its own exit code.
|
||||
|
||||
Exit codes follow the Decoder's convention, which distinguishes the two
|
||||
failures that matter: **0** all good, **1** a real check failed, **2** the
|
||||
HARNESS is broken and nothing it reported can be trusted.
|
||||
"""
|
||||
import io, contextlib
|
||||
|
||||
def always_ok(_h):
|
||||
# Prints a verdict and asserts nothing -- the exact defect shipped in
|
||||
# `verify-transcode-fidelity`'s unconditional `return 0`.
|
||||
print(" stub: everything is fine")
|
||||
|
||||
# 🔴 RUN THE REAL MACHINERY OVER THE STUB. A first version of this checked
|
||||
# that the stub left FAIL at zero and then ARGUED that `control` would
|
||||
# therefore flag it. That is reasoning where a measurement was available --
|
||||
# the error this whole thread has been about -- so the stub goes through the
|
||||
# same `control()` loop the real checks do, and its verdict is read.
|
||||
with contextlib.redirect_stdout(io.StringIO()) as buf:
|
||||
verdict = control(h, extra=[(always_ok, "120", "121")])
|
||||
out = buf.getvalue()
|
||||
stub_line = [l for l in out.splitlines() if "always_ok" in l]
|
||||
if verdict is not False or not stub_line:
|
||||
print(" 🔴 HARNESS BROKEN: the control machinery did not flag a check that")
|
||||
print(" cannot fail. Nothing any `--control` run has reported is trustworthy.")
|
||||
print(f" stub verdict: {verdict!r}; line: {stub_line}")
|
||||
return 2
|
||||
if "PASSES A WRONG CONTRACT" not in stub_line[0]:
|
||||
print(f" 🔴 HARNESS BROKEN: stub flagged, but not as a dead check: {stub_line[0].strip()}")
|
||||
return 2
|
||||
print(" harness self-test: a check that cannot fail is flagged by the machinery ✅")
|
||||
print(f" {stub_line[0].strip()}")
|
||||
print(" Exit codes: 0 all good, 1 a real check failed, 2 the HARNESS is broken.")
|
||||
return 0
|
||||
|
||||
|
||||
def control(h, extra=None):
|
||||
global FAIL
|
||||
import io, contextlib
|
||||
ok = True
|
||||
print(" known negatives -- every check must notice a perturbed contract:\n")
|
||||
for fn, old, new in CONTROLS + [(f, o, n) for f, o, n in NAV_CONTROLS] + (extra or []):
|
||||
# Membership tested against NAV_CONTROLS, not CONTROLS: anything else --
|
||||
# including a self-test stub passed in via `extra` -- is anchored on
|
||||
# HANDOFF. Written the other way round, the stub was routed at the walk
|
||||
# and flagged "the control's own anchor is gone", a real failure for a
|
||||
# fabricated reason.
|
||||
src = nav()[0] if (fn, old, new) in NAV_CONTROLS else h
|
||||
if old not in src:
|
||||
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()):
|
||||
if src is h:
|
||||
# 🔴 EVERY occurrence, not the first. A one-shot replace left the
|
||||
# check reading an untouched duplicate and passing a perturbed
|
||||
# contract -- reported 2026-08-30 the day a delivery's heading
|
||||
# came to appear twice. The control caught its own harness: a
|
||||
# perturbation that does not reach every copy of the anchor makes
|
||||
# the check untestable, silently, because it keeps passing.
|
||||
fn(h.replace(old, new))
|
||||
else:
|
||||
fn_nav_perturbed(fn, old, new)
|
||||
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 nav():
|
||||
"""The player's-eye walk, from the newest ref that carries it.
|
||||
|
||||
A second unreachable document: `docs/game/navigation.md` was filled in from
|
||||
the committed oracle frames and, like HANDOFF, is not on `main`. The port's
|
||||
`authored/flow.json` is the executable form of that walk, so the two must not
|
||||
drift -- and the drift would be invisible, because nothing in the port fails
|
||||
when a label is wrong.
|
||||
"""
|
||||
sha = git("log", "--all", "--format=%h", "--", "docs/game/navigation.md").split()[0]
|
||||
return git("show", f"{sha}:docs/game/navigation.md"), sha
|
||||
|
||||
|
||||
def flow_buttons(screen):
|
||||
d = jload("authored/flow.json") or {}
|
||||
b = ((d.get("screens") or {}).get(screen) or {}).get("buttons") or {}
|
||||
return [v.get("label") for _, v in sorted(b.items())]
|
||||
|
||||
|
||||
def check_focus_persists(h):
|
||||
"""The menu remembers its cursor -- MEASURED, on the main menu, one screen.
|
||||
|
||||
🔴 This checked a PAIR until 2026-08-30: on for `main_menu`, off everywhere
|
||||
else. The second half asserted that `extras` does NOT persist, and **nothing
|
||||
measured that**. What the corpus has is EXTRAS' initial focus from a single
|
||||
entry and Ⓑ restoring the PARENT's focus 4/4 — neither says what a submenu's
|
||||
own cursor does on re-entry. So one measured behaviour and one absence of a
|
||||
measurement were being reported identically, and if the game does persist
|
||||
EXTRAS the check would have held the port to the wrong behaviour AND PASSED.
|
||||
|
||||
The mirror of the trap it was written to avoid: refusing to let a derived
|
||||
rule overwrite a measured value, then letting "not measured here" become a
|
||||
positive assertion of the negative. Now only the measured half is asserted
|
||||
against the contract; the scope is a guard, below.
|
||||
"""
|
||||
heading = bool(re.search(r"the main menu remembers its cursor; re-entry is not a reset", h))
|
||||
# 🔴 SECOND NARROW ANCHOR, added 2026-08-30 on the Decoder's advice, and it
|
||||
# repairs a weakness I had already identified and not acted on. The heading
|
||||
# anchor is on the CONCLUSION; when they corrected the run's item names --
|
||||
# `TUTORIAL → EXTRAS → EXTRAS` was actually `NEW GAME → TUTORIAL → TUTORIAL`
|
||||
# -- this check sailed past it, because the conclusion was above the part
|
||||
# that was wrong. It survived by luck, not by design.
|
||||
#
|
||||
# So the check now also rests on the EVIDENCE: the ring at y 384.0 before the
|
||||
# round trip and 385.5 after. That pair is the geometry-free equality the
|
||||
# conclusion actually stands on, and it is what a future correction to the
|
||||
# measurement would have to touch.
|
||||
#
|
||||
# Two narrow anchors, NOT one loosened one. Their words: after a specific
|
||||
# instrument fails the general one feels safer, and its failure mode is only
|
||||
# one you have not met yet.
|
||||
evidence = bool(re.search(r"ring sits at y 384\.0 before the round trip and 385\.5 after", h))
|
||||
want = heading and evidence
|
||||
got = (((jload("authored/flow.json") or {}).get("screens") or {})
|
||||
.get("main_menu", {}).get("focus_persists"))
|
||||
if heading != evidence:
|
||||
print(f" {'menu remembers its cursor':<30} 🔴 ANCHOR SPLIT -- heading"
|
||||
f" {heading}, evidence {evidence}: one moved without the other")
|
||||
globals()["FAIL"] = FAIL + 1
|
||||
return
|
||||
report("menu remembers its cursor", want or None, got, want and got is True)
|
||||
|
||||
|
||||
def check_extras_resets(h):
|
||||
"""EXTRAS resets -- MEASURED 2026-08-30, and it used to be asserted unmeasured.
|
||||
|
||||
For one iteration the port asserted this with nothing behind it, which the
|
||||
Decoder flagged; it then measured it and the assertion was right. That does
|
||||
not make the assertion evidence, so the check is rewritten to rest on the
|
||||
measurement rather than being left to look vindicated.
|
||||
"""
|
||||
want = False if re.search(r"EXTRAS resets, the main menu persists", h) else None
|
||||
ex = ((jload("authored/flow.json") or {}).get("screens") or {}).get("extras", {})
|
||||
got = ex.get("focus_persists")
|
||||
report("extras resets its cursor", want, f"{got} [{ex.get('focus_persists_kind')}]",
|
||||
want is not None and got is False and ex.get("focus_persists_kind") == "measured")
|
||||
|
||||
|
||||
def check_reset_target(h):
|
||||
"""A submenu resets to its OWN OPENING ITEM, not to the top one.
|
||||
|
||||
Measured 2026-08-31. The port satisfies it by construction -- `opening_focus`
|
||||
falls through to `initial_focus` -- so this asserts that construction has not
|
||||
been quietly replaced by a `buttons[0]` default, which is now known wrong for
|
||||
a real screen (`DIFFICULTY` opens on the second of four).
|
||||
"""
|
||||
want = bool(re.search(r"resets to its own opening item", h))
|
||||
scr = ((jload("authored/flow.json") or {}).get("screens") or {}).get("extras", {})
|
||||
btns = sorted((scr.get("buttons") or {}).keys())
|
||||
target = scr.get("initial_focus")
|
||||
# The check has teeth only because EXTRAS' named item happens to be first
|
||||
# here: what it guards is that the AUTHORED value is the target, not the
|
||||
# index. Stated so a reader does not mistake agreement for evidence.
|
||||
report("submenu reset target", "the authored opening item" if want else None,
|
||||
f"{target} (authored){' == buttons[0]' if btns and target == btns[0] else ''}",
|
||||
want and target is not None and target == scr.get("initial_focus"))
|
||||
|
||||
|
||||
def guard_focus_scope(_h):
|
||||
"""NOT a contract check. A guard over the screens NOBODY HAS LOOKED AT.
|
||||
|
||||
Two screens are now measured and disagree -- `main_menu` persists, `extras`
|
||||
resets -- so there is no menu-wide rule to state. What this guards is the
|
||||
rest: `OPTIONS`, `LOAD GAME` and `TUTORIAL` are untouched, and their absent
|
||||
`focus_persists` is the port defaulting, not a finding.
|
||||
|
||||
📌 The absent key and a measured `false` behave identically and mean opposite
|
||||
things. That is why `extras` now spends a key on saying `false` out loud.
|
||||
"""
|
||||
global FAIL
|
||||
scr = ((jload("authored/flow.json") or {}).get("screens") or {})
|
||||
stated = {n: v.get("focus_persists") for n, v in scr.items()
|
||||
if isinstance(v, dict) and "focus_persists" in v}
|
||||
silent = sorted(n for n, v in scr.items()
|
||||
if isinstance(v, dict) and "focus_persists" not in v)
|
||||
ok = stated == {"main_menu": True, "extras": False}
|
||||
if ok:
|
||||
# ✅ 2026-08-31: all FOUR submenus are now measured to reset -- EXTRAS,
|
||||
# LOAD GAME, TUTORIAL and OPTIONS -- and the main menu remains the only
|
||||
# screen that remembers. Three of those four are not in this export, so
|
||||
# no authored value changes.
|
||||
#
|
||||
# 🔴 NOT PROMOTED TO A RULE, deliberately. "Submenus reset" at 4/4 is
|
||||
# better evidence than the 2/2 that made `wrap` a rule -- and adopting it
|
||||
# would change nothing today, because the only submenu this port ships is
|
||||
# already measured. What it WOULD do is pre-decide the next screen from a
|
||||
# generalisation instead of a measurement, which is the trap that nearly
|
||||
# let a derived rule overwrite EXTRAS' measured opening item.
|
||||
print(f" {'focus_persists scope':<30} guard {stated} measured;"
|
||||
f" {len(silent)} screen(s) silent = UNMEASURED, not 'resets'"
|
||||
f" [4/4 submenus reset disc-wide; not promoted to a rule]")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" {'focus_persists scope':<30} 🔴 GUARD {stated} -- a screen states"
|
||||
f" this without a measurement behind it")
|
||||
|
||||
|
||||
def check_menu_labels(_h):
|
||||
"""The five main-menu labels, in order, off the walk's own table."""
|
||||
n, sha = nav()
|
||||
rows = re.findall(r"^\| [1-5] \| \*\*([A-Z ]+)\*\* \|", n, re.M)
|
||||
want = rows or None
|
||||
report(f"main menu labels ({sha})", want, flow_buttons("main_menu"),
|
||||
want is not None and want == flow_buttons("main_menu"))
|
||||
|
||||
|
||||
def check_extras_labels(_h):
|
||||
"""EXTRAS' three items, written as prose rather than a table."""
|
||||
n, _ = nav()
|
||||
m = re.search(r"Three items: `([A-Z ]+)` · `([A-Z ]+)` · `([A-Z ]+)`", n)
|
||||
want = [m.group(i) for i in (1, 2, 3)] if m else None
|
||||
report("extras labels", want, flow_buttons("extras"),
|
||||
want is not None and want == flow_buttons("extras"))
|
||||
|
||||
|
||||
def check_wrap(_h):
|
||||
"""The cursor wraps, and it is a MENU rule -- the walk says so in two places."""
|
||||
n, _ = nav()
|
||||
want = True if re.search(r"one item, and it \*\*wraps\*\* at both ends", n) else None
|
||||
got = ((jload("authored/flow.json") or {}).get("navigation") or {}).get("wrap")
|
||||
report("cursor wraps", 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"),
|
||||
(check_focus_persists, "the main menu remembers its cursor; re-entry is not a reset",
|
||||
"the main menu forgets its cursor; re-entry is a reset"),
|
||||
# The SECOND anchor gets its own known negative. Perturbing only the evidence
|
||||
# must trip ANCHOR SPLIT -- otherwise the second anchor is decorative and the
|
||||
# check is still resting on the conclusion alone.
|
||||
(check_focus_persists, "ring sits at y 384.0 before the round trip and 385.5 after",
|
||||
"ring sits at y 384.0 before the round trip and 999.9 after"),
|
||||
# The list sits on the line AFTER "times are", so the perturbation has to
|
||||
# carry the newline the check's `\s*` spans. A control whose own anchor is
|
||||
# written from memory of the prose rather than from the prose is the same
|
||||
# class of error the checks exist to catch.
|
||||
(check_splash_times, "times are\n`[0,15,30,45,235,239,251,255]`",
|
||||
"times are\n`[1,16,31,46,236,240,252,256]`"),
|
||||
(check_reset_target, "resets to its own opening item",
|
||||
"resets to whichever item is on top"),
|
||||
(check_extras_resets, "EXTRAS resets, the main menu persists",
|
||||
"EXTRAS persists, the main menu persists"),
|
||||
(check_initial_focus, "**Initial focus on a fresh boot is `NEW GAME`**",
|
||||
"**Initial focus on a fresh boot is `TUTORIAL`**"),
|
||||
]
|
||||
|
||||
# The walk's controls perturb `navigation.md` instead of HANDOFF, so they are
|
||||
# applied to a different document and kept separate rather than folded in.
|
||||
NAV_CONTROLS = [
|
||||
(check_menu_labels, "| 1 | **NEW GAME**", "| 1 | **NEW GAMES**"),
|
||||
(check_extras_labels, "`MISSION SELECT` · `MOVIE THEATER`", "`MISSION SELECTS` · `MOVIE THEATER`"),
|
||||
(check_wrap, "one item, and it **wraps** at both ends", "one item, and it stops at both ends"),
|
||||
]
|
||||
|
||||
|
||||
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 "--selftest" in sys.argv:
|
||||
return selftest(h)
|
||||
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, check_menu_labels, check_extras_labels,
|
||||
check_wrap, check_focus_persists, guard_focus_scope,
|
||||
check_splash_times, check_initial_focus, check_extras_resets,
|
||||
check_reset_target):
|
||||
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())
|
||||
138
tools/port/edge-residual-kind
Executable file
138
tools/port/edge-residual-kind
Executable file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""What KIND of error is left at the edges after tone is accounted for?
|
||||
|
||||
tools/port/edge-residual-kind [screen] # default main_menu
|
||||
|
||||
`verify-capture`'s `diff` column thresholds at 25 % and so only sees GROSS
|
||||
displacement. Fitting a per-level LUT removes everything a tone effect can
|
||||
explain. What is left on the main menu is concentrated 3.2x on edge pixels
|
||||
(DECISIONS.md, 2026-08-31) -- and three things produce that: a misregistration,
|
||||
an antialiasing difference, or a genuinely misplaced element.
|
||||
|
||||
THE DISCRIMINATOR IS THE SIGN, and it is the Decoder's, from their reply on
|
||||
2026-08-31: a shift gives a residual with a CONSISTENT DIRECTION along the edge,
|
||||
an antialiasing difference does not. Made concrete:
|
||||
|
||||
* shifted by (dx,dy): residual ~ dx*d/dx + dy*d/dy -- and the fitted SLOPE
|
||||
IS THE SHIFT IN PIXELS
|
||||
* blurred/sharpened : residual ~ -k * laplacian -- symmetric, no direction
|
||||
|
||||
EXIT CODES. 0 the report is trustworthy, 2 A CONTROL FAILED so the numbers below
|
||||
it mean nothing. There is no 1: this tool classifies, it does not judge. A
|
||||
correlation this tool reports is worthless without the two controls above it,
|
||||
which is why they are not optional and not a flag.
|
||||
"""
|
||||
import math, os, subprocess, sys, tempfile
|
||||
|
||||
CAPS = "docs/re/captures/title-builds"
|
||||
SCREEN = sys.argv[1] if len(sys.argv) > 1 else "main_menu"
|
||||
# The captures are a 1279x675 top-left crop of the 1280x720 guest surface, so the
|
||||
# render is cropped to match and NOTHING IS SCALED -- resampling would manufacture
|
||||
# exactly the edge signal this tool measures. See verify-capture, same reason.
|
||||
W, H = 1279, 675
|
||||
EDGE = 12 # |grad| above which a pixel is an edge
|
||||
PASS_SHIFT, PASS_BLUR = 0.70, -0.70
|
||||
|
||||
|
||||
def gray(png, out):
|
||||
subprocess.run(["convert", png, "-colorspace", "Gray", "-depth", "8",
|
||||
"gray:" + out], check=True)
|
||||
return open(out, "rb").read()
|
||||
|
||||
|
||||
def lutfit(a, b):
|
||||
tot = [0] * 256; cnt = [0] * 256
|
||||
for i in range(len(a)):
|
||||
tot[a[i]] += b[i]; cnt[a[i]] += 1
|
||||
return [(tot[v] // cnt[v]) if cnt[v] else v for v in range(256)]
|
||||
|
||||
|
||||
def analyse(a, b):
|
||||
lut = lutfit(a, b)
|
||||
gx = []; gy = []; lp = []; rs = []
|
||||
for y in range(1, H - 1):
|
||||
o = y * W
|
||||
for x in range(1, W - 1):
|
||||
i = o + x
|
||||
ax = (a[i + 1] - a[i - 1]) * 0.5
|
||||
ay = (a[i + W] - a[i - W]) * 0.5
|
||||
if abs(ax) + abs(ay) < EDGE:
|
||||
continue
|
||||
gx.append(ax); gy.append(ay)
|
||||
lp.append(float(a[i + 1] + a[i - 1] + a[i + W] + a[i - W] - 4 * a[i]))
|
||||
rs.append(float(lut[a[i]] - b[i]))
|
||||
n = len(rs)
|
||||
if n < 1000:
|
||||
print(f" 🔴 only {n} edge pixels -- nothing to classify"); sys.exit(2)
|
||||
mr = sum(rs) / n
|
||||
|
||||
def fit(u):
|
||||
mu = sum(u) / n
|
||||
suu = sum((v - mu) ** 2 for v in u)
|
||||
srr = sum((v - mr) ** 2 for v in rs)
|
||||
sur = sum((u[k] - mu) * (rs[k] - mr) for k in range(n))
|
||||
return (0.0, 0.0) if suu <= 0 or srr <= 0 else (sur / suu, sur / math.sqrt(suu * srr))
|
||||
return n, fit(gx), fit(gy), fit(lp)
|
||||
|
||||
|
||||
def row(label, res):
|
||||
n, (sx, rx), (sy, ry), (sl, rl) = res
|
||||
print(f" {label} (n={n})")
|
||||
print(f" horizontal shift : r={rx:+.3f} slope={sx:+.3f} px")
|
||||
print(f" vertical shift : r={ry:+.3f} slope={sy:+.3f} px")
|
||||
print(f" blur / sharpness : r={rl:+.3f} coef ={sl:+.3f}")
|
||||
return rx, ry, rl
|
||||
|
||||
|
||||
def shifted(a, dx):
|
||||
out = bytearray(a)
|
||||
for y in range(H):
|
||||
for x in range(W):
|
||||
out[y * W + x] = a[y * W + min(W - 1, max(0, x - dx))]
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def blurred(a):
|
||||
out = bytearray(a)
|
||||
for y in range(1, H - 1):
|
||||
o = y * W
|
||||
for x in range(1, W - 1):
|
||||
i = o + x
|
||||
out[i] = (a[i] * 4 + a[i + 1] + a[i - 1] + a[i + W] + a[i - W]) // 8
|
||||
return bytes(out)
|
||||
|
||||
|
||||
tmp = tempfile.mkdtemp()
|
||||
cap_png = f"{CAPS}/live-{SCREEN.replace('_', '-')}.png"
|
||||
if not os.path.exists(cap_png):
|
||||
print(f" 🔴 no capture: {cap_png}"); sys.exit(2)
|
||||
render = os.environ.get("RENDER") or f"{tmp}/render.png"
|
||||
if not os.path.exists(render):
|
||||
print(f" 🔴 no render at {render} -- set RENDER=<png>"); sys.exit(2)
|
||||
subprocess.run(["convert", render, "-crop", f"{W}x{H}+0+0", "+repage",
|
||||
f"{tmp}/crop.png"], check=True)
|
||||
r = gray(f"{tmp}/crop.png", f"{tmp}/r.gray")
|
||||
c = gray(cap_png, f"{tmp}/c.gray")
|
||||
|
||||
print("CONTROLS -- the render against a deliberately damaged copy of itself.")
|
||||
print("A correlation below is meaningless unless these two recover what was done.\n")
|
||||
ra = analyse(r, shifted(r, 1))
|
||||
rxa, _, rla = row("known +1 px HORIZONTAL shift", ra)
|
||||
rb = analyse(r, blurred(r))
|
||||
_, _, rlb = row("known BLUR, no shift", rb)
|
||||
bad = []
|
||||
if rxa < PASS_SHIFT: bad.append(f"shift control r={rxa:+.3f} < {PASS_SHIFT}")
|
||||
if rlb > PASS_BLUR: bad.append(f"blur control r={rlb:+.3f} > {PASS_BLUR}")
|
||||
if bad:
|
||||
print("\n 🔴 CONTROL FAILED: " + "; ".join(bad))
|
||||
print(" The discriminator cannot see what it is for. Report suppressed.")
|
||||
sys.exit(2)
|
||||
print(f"\n ✅ controls pass -- a 1 px shift reads as {ra[1][0]:+.3f} px\n")
|
||||
print(f"THE REAL PAIR -- {SCREEN}\n")
|
||||
rx, ry, rl = row(f"{SCREEN} render vs oracle capture", analyse(r, c))
|
||||
print()
|
||||
if max(abs(rx), abs(ry)) < 0.15 and abs(rl) < 0.3:
|
||||
print(" => NEITHER a global shift NOR a uniform blur.")
|
||||
print(" ⚠️ REACH: this is a WHOLE-FRAME fit. One misplaced element is a small")
|
||||
print(" share of the edge pixels and would not move these numbers. This")
|
||||
print(" excludes a global translation; it does not exclude a local one.")
|
||||
180
tools/port/edge-residual-map
Executable file
180
tools/port/edge-residual-map
Executable file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WHERE does the edge residual sit, and is that region locally shifted?
|
||||
|
||||
RENDER=<png> tools/port/edge-residual-map [screen]
|
||||
|
||||
`edge-residual-kind` fits the whole frame and excludes a GLOBAL translation. Its
|
||||
own reach statement says the thing it cannot do: one misplaced element is a small
|
||||
share of 38 752 edge pixels and would not move a whole-frame number. This tiles
|
||||
the frame and runs the same discriminator INSIDE each tile, so a single displaced
|
||||
element shows up as one hot tile with a local slope -- which is invisible to the
|
||||
global fit by construction, not by accident.
|
||||
|
||||
Division of labour, agreed with the Decoder 2026-08-31: the residual map is the
|
||||
port's (it needs the render beside the capture), the element inventory is theirs
|
||||
(it needs the disc). This tool produces the map and NAMES NOTHING.
|
||||
|
||||
THE CONTROL IS A KNOWN LOCAL SHIFT. A map that cannot localise a displacement it
|
||||
was told about cannot be trusted to have found one it was not. Exit 0 the report
|
||||
is trustworthy, 2 the control failed and the report is suppressed. No 1.
|
||||
"""
|
||||
import math, os, subprocess, sys, tempfile
|
||||
|
||||
CAPS = "docs/re/captures/title-builds"
|
||||
SCREEN = sys.argv[1] if len(sys.argv) > 1 else "main_menu"
|
||||
W, H = 1279, 675 # top-left crop of the guest surface; never scaled
|
||||
TILE = 64
|
||||
EDGE = 12
|
||||
MIN_EDGE_PX = 150 # below this a tile's slope is noise
|
||||
# The controls displace this region and the map must find it there.
|
||||
CTRL_BOX = (448, 320, 640, 448) # x0, y0, x1, y1
|
||||
# TWO controls, because ONE OF THEM FAILED AND TAUGHT ME THE LIMIT. The slope is
|
||||
# a linearisation, residual ~ dx * gradient, which holds only while dx is small
|
||||
# against the width of an edge. A +2 px displacement localises perfectly but reads
|
||||
# back +0.8..+1.25, so the estimator SATURATES. Control A checks magnitude in the
|
||||
# regime where magnitude means something; control B checks that a displacement too
|
||||
# large to measure is still FOUND. Reporting a saturating slope as a distance
|
||||
# would understate a real displacement by more than half.
|
||||
CTRL_A_DX = 1 # linear regime: localisation AND magnitude
|
||||
CTRL_B_DX = 2 # saturating: localisation and SIGN only
|
||||
|
||||
|
||||
def gray(png, out):
|
||||
subprocess.run(["convert", png, "-colorspace", "Gray", "-depth", "8",
|
||||
"gray:" + out], check=True)
|
||||
return open(out, "rb").read()
|
||||
|
||||
|
||||
def lutfit(a, b):
|
||||
tot = [0] * 256; cnt = [0] * 256
|
||||
for i in range(len(a)):
|
||||
tot[a[i]] += b[i]; cnt[a[i]] += 1
|
||||
return [(tot[v] // cnt[v]) if cnt[v] else v for v in range(256)]
|
||||
|
||||
|
||||
def tiles(a, b):
|
||||
"""Per-tile mean |residual| on edge pixels, and the local shift slope."""
|
||||
lut = lutfit(a, b) # ONE global LUT: tone is global, displacement is not
|
||||
out = {}
|
||||
for ty in range(0, H - 1, TILE):
|
||||
for tx in range(0, W - 1, TILE):
|
||||
gx = []; gy = []; rs = []; flat = []
|
||||
for y in range(max(1, ty), min(H - 1, ty + TILE)):
|
||||
o = y * W
|
||||
for x in range(max(1, tx), min(W - 1, tx + TILE)):
|
||||
i = o + x
|
||||
ax = (a[i + 1] - a[i - 1]) * 0.5
|
||||
ay = (a[i + W] - a[i - W]) * 0.5
|
||||
d = float(lut[a[i]] - b[i])
|
||||
if abs(ax) + abs(ay) < EDGE:
|
||||
flat.append(abs(d)); continue
|
||||
gx.append(ax); gy.append(ay); rs.append(d)
|
||||
n = len(rs)
|
||||
if n < MIN_EDGE_PX:
|
||||
continue
|
||||
mabs = sum(abs(v) for v in rs) / n
|
||||
mflat = (sum(flat) / len(flat)) if flat else 0.0
|
||||
mr = sum(rs) / n
|
||||
|
||||
def slope(u):
|
||||
mu = sum(u) / n
|
||||
suu = sum((v - mu) ** 2 for v in u)
|
||||
if suu <= 0:
|
||||
return 0.0
|
||||
return sum((u[k] - mu) * (rs[k] - mr) for k in range(n)) / suu
|
||||
out[(tx, ty)] = (mabs, slope(gx), slope(gy), n, mflat)
|
||||
return out
|
||||
|
||||
|
||||
def top(t, k=8):
|
||||
return sorted(t.items(), key=lambda kv: -kv[1][0])[:k]
|
||||
|
||||
|
||||
def show(t, label, k=8):
|
||||
print(f" {label}")
|
||||
print(f" {'tile':>12} {'edge':>7} {'flat':>7} {'e/f':>6} "
|
||||
f"{'dx':>7} {'dy':>7} {'edge px':>8}")
|
||||
for (tx, ty), (m, sx, sy, n, mf) in top(t, k):
|
||||
ef = (m / mf) if mf > 0.01 else float('inf')
|
||||
print(f" {tx:4d},{ty:4d} {m:7.2f} {mf:7.2f} {ef:6.2f} "
|
||||
f"{sx:+7.3f} {sy:+7.3f} {n:8d}")
|
||||
|
||||
|
||||
def shift_box(a, box, dx):
|
||||
x0, y0, x1, y1 = box
|
||||
out = bytearray(a)
|
||||
for y in range(y0, y1):
|
||||
for x in range(x0, x1):
|
||||
out[y * W + x] = a[y * W + min(W - 1, max(0, x - dx))]
|
||||
return bytes(out)
|
||||
|
||||
|
||||
tmp = tempfile.mkdtemp()
|
||||
cap = f"{CAPS}/live-{SCREEN.replace('_', '-')}.png"
|
||||
render = os.environ.get("RENDER", "")
|
||||
for p in (cap, render):
|
||||
if not p or not os.path.exists(p):
|
||||
print(f" 🔴 missing: {p or 'RENDER=<png>'}"); sys.exit(2)
|
||||
subprocess.run(["convert", render, "-crop", f"{W}x{H}+0+0", "+repage",
|
||||
f"{tmp}/crop.png"], check=True)
|
||||
r = gray(f"{tmp}/crop.png", f"{tmp}/r.gray")
|
||||
c = gray(cap, f"{tmp}/c.gray")
|
||||
|
||||
print("CONTROLS -- the render against itself with ONE REGION displaced.\n"
|
||||
"The map must put that region on top; magnitude only in the linear regime.\n")
|
||||
x0, y0, x1, y1 = CTRL_BOX
|
||||
bad = []
|
||||
|
||||
|
||||
def control(dx, check_magnitude):
|
||||
t = tiles(r, shift_box(r, CTRL_BOX, dx))
|
||||
show(t, f"known +{dx} px shift inside x {x0}-{x1}, y {y0}-{y1}", 4)
|
||||
hits = [(k, v) for k, v in top(t, 4)
|
||||
if x0 - TILE < k[0] < x1 and y0 - TILE < k[1] < y1]
|
||||
if not hits:
|
||||
bad.append(f"+{dx} px: displaced region not in the top 4 tiles")
|
||||
return
|
||||
best = max(hits, key=lambda kv: kv[1][0])[1][1]
|
||||
if best <= 0.3:
|
||||
bad.append(f"+{dx} px: local slope {best:+.3f} has the wrong sign or is flat")
|
||||
elif check_magnitude and abs(best - dx) > 0.4:
|
||||
bad.append(f"+{dx} px: local slope {best:+.3f} does not recover it")
|
||||
print(f" -> localised, local slope {best:+.3f} px"
|
||||
f"{'' if check_magnitude else ' (saturating -- a LOWER BOUND)'}\n")
|
||||
|
||||
|
||||
control(CTRL_A_DX, True)
|
||||
control(CTRL_B_DX, False)
|
||||
if bad:
|
||||
print(" 🔴 CONTROL FAILED: " + "; ".join(bad))
|
||||
print(" A map that cannot find a displacement it was told about cannot be")
|
||||
print(" trusted to have found one it was not. Report suppressed.")
|
||||
sys.exit(2)
|
||||
print(" ✅ controls pass: a 1 px displacement is localised and measured, a 2 px\n"
|
||||
" one is localised with its magnitude understated. So a hot tile with a\n"
|
||||
" real slope is a floor on the displacement, never a ceiling.\n")
|
||||
|
||||
print(f"THE REAL PAIR -- {SCREEN}\n")
|
||||
rt = tiles(r, c)
|
||||
show(rt, f"{SCREEN}: hottest tiles, whole-frame LUT applied", 10)
|
||||
ms = sorted(v[0] for v in rt.values())
|
||||
med = ms[len(ms) // 2]
|
||||
efs = sorted(v[0] / v[4] for v in rt.values() if v[4] > 0.01)
|
||||
med_ef = efs[len(efs) // 2]
|
||||
hot = max(rt.items(), key=lambda kv: kv[1][0])
|
||||
print(f"\n median tile |resid| {med:.2f} hottest {hot[1][0]:.2f} "
|
||||
f"at {hot[0][0]},{hot[0][1]} ({hot[1][0]/med:.2f}x median)")
|
||||
print(f" median tile edge/flat {med_ef:.2f}")
|
||||
efs_hot = [v[0] / v[4] for _, v in top(rt, 10) if v[4] > 0.01]
|
||||
print(f" hot tiles span edge/flat {min(efs_hot):.2f}..{max(efs_hot):.2f}, "
|
||||
f"straddling that median")
|
||||
print(" 📌 SO THE COLUMN DOES NOT SPLIT THEM. I added it expecting two families --")
|
||||
print(" tiles hot only at edges (an edge-rendering difference) against tiles")
|
||||
print(" hot everywhere (a local tone the global LUT mis-serves). The hot tiles")
|
||||
print(" run continuously across the median instead, so the hot region is NOT")
|
||||
print(" one anomalous element with a character of its own. Note the frame-wide")
|
||||
print(" pooled edge/flat is 3.16 while the per-tile median is 1.84: pooling is")
|
||||
print(" dominated by the tiles carrying the most edge pixels, and reading a")
|
||||
print(" per-tile threshold off it would have manufactured the split.")
|
||||
print("\n ⚠️ THIS TOOL NAMES NOTHING. A hot tile is a coordinate, not an element.")
|
||||
print(" What sits under it is the Decoder's to say -- they hold the disc.")
|
||||
144
tools/port/element-residual
Executable file
144
tools/port/element-residual
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Which ELEMENT carries the disagreement with the capture? Rank them by suppression.
|
||||
|
||||
tools/port/element-residual [screen] # default main_menu
|
||||
|
||||
`edge-residual-map` gives hot COORDINATES, and turning those into elements needs
|
||||
the design-space -> capture transform, which is a convention I would have to
|
||||
assume. This needs no transform: the port has a mod tree, so shadow an element's
|
||||
sprite with a transparent PNG, render, and diff the port's OWN two renders. The
|
||||
pixels that change ARE the element, already in the comparison frame.
|
||||
|
||||
Reports, per element, on the pixels it actually paints:
|
||||
* mean |residual| against the capture, after ONE global tone LUT
|
||||
* the SIGN -- is the port drawing this element too dark or too bright
|
||||
* edge versus flat -- an outline problem or a body problem
|
||||
|
||||
⚠️ SUPPRESSION IS BY SPRITE PATH, so elements sharing a sprite are suppressed
|
||||
together and are reported as one row. `ptloop01` draws `pteff03.png`; the id and
|
||||
the file are not the same thing.
|
||||
|
||||
EXIT 0 the report is trustworthy, 2 a control failed. No 1: this ranks, it does
|
||||
not judge. A brightness difference here is NOT licence to brighten the element --
|
||||
blend mode is undecoded (`screen.rs`), and tuning until the two agree is exactly
|
||||
what the mission forbids.
|
||||
"""
|
||||
import json, os, subprocess, sys, tempfile
|
||||
|
||||
CAPS = "docs/re/captures/title-builds"
|
||||
POSE = { # same poses as verify-capture
|
||||
"main_menu": (f"{CAPS}/live-main-menu.png", ["--menu=main_menu"]),
|
||||
"extras": (f"{CAPS}/live-extras.png", ["--menu=extras"]),
|
||||
"main_menu_options": (f"{CAPS}/live-main-menu-options-focused.png",
|
||||
["--menu=main_menu_options", "--focus=ptbtn04"]),
|
||||
}
|
||||
SCREEN = sys.argv[1] if len(sys.argv) > 1 else "main_menu"
|
||||
if SCREEN not in POSE:
|
||||
print(f" 🔴 no pose for {SCREEN}; known: {', '.join(POSE)}"); sys.exit(2)
|
||||
CAP, ARGS = POSE[SCREEN]
|
||||
W, H = 1279, 675
|
||||
BASE = ["--loop-phase=0", "--leaf-time=0", "--script=wait"]
|
||||
tmp = tempfile.mkdtemp()
|
||||
|
||||
|
||||
def render(png, mods=None):
|
||||
env = dict(os.environ)
|
||||
if mods: env["SYLPHEED_MODS"] = mods
|
||||
else: env.pop("SYLPHEED_MODS", None)
|
||||
r = subprocess.run(["xvfb-run", "-a", "timeout", "300", "godot", "--path", "port",
|
||||
"--"] + BASE + ARGS + [f"--capture={png}"],
|
||||
env=env, capture_output=True, text=True)
|
||||
return r.stdout + r.stderr
|
||||
|
||||
|
||||
def gray(png, out):
|
||||
subprocess.run(["convert", png, "-crop", f"{W}x{H}+0+0", "+repage",
|
||||
"-colorspace", "Gray", "-depth", "8", "gray:" + out], check=True)
|
||||
return open(out, "rb").read()
|
||||
|
||||
|
||||
sd = json.load(open(f"export/screens/{'title'}/{SCREEN}.json")) if os.path.exists(
|
||||
f"export/screens/title/{SCREEN}.json") else None
|
||||
if sd is None:
|
||||
for root, _, files in os.walk("export/screens"):
|
||||
if f"{SCREEN}.json" in files:
|
||||
sd = json.load(open(os.path.join(root, f"{SCREEN}.json"))); break
|
||||
sprites = {}
|
||||
for e in sd["elements"]:
|
||||
s = e.get("sprite", "")
|
||||
if s: sprites.setdefault(s, []).append(e["id"])
|
||||
|
||||
render(f"{tmp}/base.png")
|
||||
base = gray(f"{tmp}/base.png", f"{tmp}/base.gray")
|
||||
cap = gray(CAP, f"{tmp}/cap.gray")
|
||||
|
||||
# CONTROL 1 -- the metric's own zero. The render against ITSELF must be exactly 0.
|
||||
tot = [0] * 256; cnt = [0] * 256
|
||||
for i in range(len(base)): tot[base[i]] += base[i]; cnt[base[i]] += 1
|
||||
idlut = [(tot[v] // cnt[v]) if cnt[v] else v for v in range(256)]
|
||||
z = max(abs(idlut[base[i]] - base[i]) for i in range(0, len(base), 97))
|
||||
# CONTROL 2 -- a mod that shadows NOTHING must move no pixels, or a footprint
|
||||
# below is the harness rather than the element.
|
||||
noop = f"{tmp}/noop"; os.makedirs(noop + "/sprites/title", exist_ok=True)
|
||||
subprocess.run(["convert", "-size", "8x8", "xc:red", f"{noop}/sprites/title/zzz-not-an-asset.png"],
|
||||
check=True)
|
||||
render(f"{tmp}/noop.png", noop)
|
||||
nb = gray(f"{tmp}/noop.png", f"{tmp}/noop.gray")
|
||||
moved = sum(1 for i in range(len(base)) if base[i] != nb[i])
|
||||
print(f" control -- metric zero on identity : {z} (must be 0)")
|
||||
print(f" control -- mod shadowing nothing : {moved} px moved (must be 0)")
|
||||
if z != 0 or moved != 0:
|
||||
print("\n 🔴 CONTROL FAILED. Every row below would be unattributable. Suppressed.")
|
||||
sys.exit(2)
|
||||
print(" ✅ controls pass\n")
|
||||
|
||||
tot = [0] * 256; cnt = [0] * 256
|
||||
for i in range(len(base)): tot[base[i]] += cap[i]; cnt[base[i]] += 1
|
||||
lut = [(tot[v] // cnt[v]) if cnt[v] else v for v in range(256)]
|
||||
resid = [abs(lut[base[i]] - cap[i]) for i in range(len(base))]
|
||||
N = len(base); frame_mean = sum(resid) / N
|
||||
|
||||
|
||||
def isedge(i):
|
||||
x, y = i % W, i // W
|
||||
if x < 1 or y < 1 or x >= W - 1 or y >= H - 1: return False
|
||||
return abs(base[i + 1] - base[i - 1]) + abs(base[i + W] - base[i - W]) >= 12
|
||||
|
||||
|
||||
rows = []
|
||||
for rel, ids in sprites.items():
|
||||
d = f"{tmp}/m_{len(rows)}"; os.makedirs(os.path.dirname(f"{d}/{rel}"), exist_ok=True)
|
||||
src = f"export/{rel}"
|
||||
if not os.path.exists(src): continue
|
||||
dim = subprocess.run(["identify", "-format", "%wx%h", src],
|
||||
capture_output=True, text=True).stdout
|
||||
subprocess.run(["convert", "-size", dim, "xc:none", f"PNG32:{d}/{rel}"], check=True)
|
||||
log = render(f"{tmp}/o.png", d)
|
||||
if "mod: " + rel not in log:
|
||||
print(f" ⚠️ {rel}: the override was never read -- skipped rather than "
|
||||
f"reported as an empty footprint"); continue
|
||||
o = gray(f"{tmp}/o.png", f"{tmp}/o.gray")
|
||||
m = [i for i in range(N) if abs(base[i] - o[i]) > 2]
|
||||
if not m:
|
||||
rows.append((",".join(ids), rel, 0, 0.0, 0.0, 0.0, 0.0)); continue
|
||||
mi = sum(resid[i] for i in m) / len(m)
|
||||
sg = sum(lut[base[i]] - cap[i] for i in m) / len(m)
|
||||
ed = [resid[i] for i in m if isedge(i)]; fl = [resid[i] for i in m if not isedge(i)]
|
||||
rows.append((",".join(ids), rel, len(m), mi,
|
||||
sum(ed) / len(ed) if ed else 0.0, sum(fl) / len(fl) if fl else 0.0, sg))
|
||||
|
||||
print(f"{SCREEN}: frame mean |resid| {frame_mean:.2f}\n")
|
||||
print(f" {'element(s)':<22} {'foot %':>7} {'|resid|':>8} {'xmean':>6} "
|
||||
f"{'edge':>7} {'flat':>7} {'signed':>8}")
|
||||
for ids, rel, n, mi, ed, fl, sg in sorted(rows, key=lambda r: -r[3]):
|
||||
if n == 0:
|
||||
print(f" {ids:<22} {'0.00':>7} {'--':>8} {'--':>6} {'--':>7} {'--':>7} "
|
||||
f"{'--':>8} paints nothing at this pose")
|
||||
continue
|
||||
flag = " <- BODY" if fl > ed else ""
|
||||
print(f" {ids:<22} {100*n/N:7.2f} {mi:8.2f} {mi/frame_mean:6.2f} "
|
||||
f"{ed:7.2f} {fl:7.2f} {sg:+8.2f}{flag}")
|
||||
print("\n signed = render - capture after the LUT; NEGATIVE means the port draws it")
|
||||
print(" DARKER than the game. 'BODY' marks flat residual above edge residual --")
|
||||
print(" an intensity difference rather than an outline one.")
|
||||
print(" ⚠️ This is not licence to brighten anything: blend mode is undecoded.")
|
||||
60
tools/port/index-decisions
Executable file
60
tools/port/index-decisions
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate the contents block at the top of `docs/port/DECISIONS.md`.
|
||||
#
|
||||
# tools/port/index-decisions # rewrite the index
|
||||
# tools/port/index-decisions --check # fail if it is out of date
|
||||
#
|
||||
# 🔴 WHY THIS EXISTS. The record reached 6 500 lines and 111 sections with no
|
||||
# index, and on 2026-08-30 I spent an iteration empirically re-deriving a result
|
||||
# it already contained -- under two headings that name the screens in question --
|
||||
# then reported the question as unexplained to the Decoder. An unnavigable record
|
||||
# is not a record that is hard to read; it is one that does not get read.
|
||||
#
|
||||
# ⚠️ `--check` exists because a stale index is worse than none: it would answer
|
||||
# "is this already decided?" with a confident no. `check-all` runs it.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
DOC=docs/port/DECISIONS.md
|
||||
BEG='<!-- INDEX: generated by tools/port/index-decisions -- do not hand-edit -->'
|
||||
END='<!-- /INDEX -->'
|
||||
|
||||
body=$(python3 - "$DOC" <<'PY'
|
||||
import re, sys
|
||||
lines = open(sys.argv[1]).read().split('\n')
|
||||
out = []
|
||||
for l in lines:
|
||||
if l.startswith('## '):
|
||||
title = l[3:].strip()
|
||||
# A GitHub anchor: lowercased, punctuation dropped, spaces to hyphens.
|
||||
anchor = re.sub(r'[^\w\s-]', '', title.lower()).strip().replace(' ', '-')
|
||||
# NO LINE NUMBERS. They would make the index a fixpoint problem -- writing
|
||||
# it shifts every line below it -- and, worse, every appended section
|
||||
# would silently invalidate all of them. An anchor survives both.
|
||||
out.append(f"* [{title}](#{anchor})")
|
||||
print('\n'.join(out))
|
||||
PY
|
||||
)
|
||||
new=$(printf '%s\n\n%d sections. Search this before re-deriving anything.\n\n%s\n\n%s\n' \
|
||||
"$BEG" "$(grep -c '^## ' "$DOC")" "$body" "$END")
|
||||
|
||||
cur=$(awk -v b="$BEG" -v e="$END" 'index($0,b){f=1} f{print} index($0,e){f=0}' "$DOC")
|
||||
|
||||
if [ "${1:-}" = --check ]; then
|
||||
if [ "$cur" = "$new" ]; then echo " index-decisions ok"; exit 0
|
||||
else echo " index-decisions 🔴 the index is out of date -- run tools/port/index-decisions"; exit 1; fi
|
||||
fi
|
||||
|
||||
python3 - "$DOC" "$BEG" "$END" "$new" <<'PY'
|
||||
import sys
|
||||
doc, beg, end, new = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
||||
s = open(doc).read()
|
||||
if beg in s:
|
||||
i, j = s.index(beg), s.index(end) + len(end)
|
||||
s = s[:i] + new.rstrip('\n') + s[j:]
|
||||
else:
|
||||
# First run: place it after the H1 and its opening paragraph.
|
||||
k = s.index('\n## ')
|
||||
s = s[:k] + '\n\n' + new.rstrip('\n') + s[k:]
|
||||
open(doc, 'w').write(s)
|
||||
print("index written")
|
||||
PY
|
||||
85
tools/port/peer-head
Executable file
85
tools/port/peer-head
Executable file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Is the copy of a file I am reading the newest one anywhere in the repository?
|
||||
|
||||
🔴 THE RULE THIS REPLACES IS A MEMORY. Two agents spent days on a shared-state
|
||||
problem that is really two problems:
|
||||
|
||||
what a peer HOLDS readable right now, from any topic branch, by anyone who
|
||||
remembers the ref exists -- `git show <ref>:<path>`
|
||||
what a peer must be TOLD still needs a human to merge to `main`
|
||||
|
||||
Both were being filed as blocked on the merge. Half never was. The Decoder read
|
||||
this port's `BLOCKED.md` at a copy 234 commits behind and reported a row as stale
|
||||
that had been corrected for days -- with the live file one `git show` away, on a
|
||||
ref already fetched in their checkout. This port read `main`'s 926-line HANDOFF
|
||||
for two days while the live one sat on a branch it had already been citing by sha.
|
||||
|
||||
Same gap, opposite directions, and the fix in both cases costs one command. So
|
||||
the command exists rather than the intention.
|
||||
|
||||
Prints, for each path: the newest commit touching it on ANY ref, how far the
|
||||
working tree's copy is behind, and the exact `git show` line to read the live one.
|
||||
"""
|
||||
import subprocess, sys, os
|
||||
|
||||
# The files this port depends on that another agent writes. Named rather than
|
||||
# globbed: the point is to be explicit about whose head is being tracked.
|
||||
DEFAULT = [
|
||||
"docs/port/HANDOFF.md",
|
||||
"docs/game/navigation.md",
|
||||
"docs/agents/PROTOCOL.md",
|
||||
"docs/port/MISSION.md",
|
||||
"docs/port/PORT-MISSION.md",
|
||||
]
|
||||
|
||||
|
||||
def git(*a):
|
||||
return subprocess.run(["git", *a], capture_output=True, text=True).stdout
|
||||
|
||||
|
||||
def main():
|
||||
paths = sys.argv[1:] or DEFAULT
|
||||
stale = 0
|
||||
print(f" {'path':<30} {'mine':<9} {'newest':<9} {'behind':>6} where")
|
||||
for p in paths:
|
||||
newest = git("log", "--all", "--format=%h", "--", p).split()
|
||||
mine = git("log", "-1", "--format=%h", "--", p).split()
|
||||
if not newest:
|
||||
print(f" {p:<30} {'-':<9} {'-':<9} {'-':>6} no commit touches this path")
|
||||
continue
|
||||
n, m = newest[0], (mine[0] if mine else "-")
|
||||
# 🔴 `--all --not HEAD` counts commits touching the path that are not in
|
||||
# my ancestry. That is a TRUE number and it is NOT staleness: two
|
||||
# branches can each carry an unrelated commit to the same file while my
|
||||
# copy is still the newest. The first version printed it as "behind" and
|
||||
# told me to `git show` MY OWN version of PROTOCOL.md -- a real count
|
||||
# with a fabricated label, which is the family this project keeps paying
|
||||
# for. What decides staleness is whether the NEWEST commit is reachable
|
||||
# from HEAD.
|
||||
reachable = subprocess.run(["git", "merge-base", "--is-ancestor", n, "HEAD"],
|
||||
capture_output=True).returncode == 0
|
||||
diverged = len(git("log", "--all", "--not", "HEAD", "--format=%h", "--", p).split())
|
||||
behind = 0 if reachable else diverged
|
||||
refs = git("for-each-ref", "--format=%(refname:short)", "--contains", n,
|
||||
"refs/remotes", "refs/heads").split()
|
||||
where = refs[0] if refs else "?"
|
||||
note = ""
|
||||
if behind == 0 and diverged:
|
||||
note = f" ({diverged} commit(s) elsewhere, none newer)"
|
||||
flag = note if behind == 0 else f" <- {behind} unread; read it with:"
|
||||
print(f" {p:<30} {m:<9} {n:<9} {behind:>6}{flag}")
|
||||
if behind:
|
||||
stale += 1
|
||||
print(f" {'':<30} git show {n}:{p} (on {where})")
|
||||
print()
|
||||
if stale:
|
||||
print(f" 🔴 {stale} file(s) have a newer version than the one in this tree.")
|
||||
print(" Reading it needs no merge and no human. Being TOLD about it does.")
|
||||
else:
|
||||
print(" every tracked file is at its newest version anywhere")
|
||||
# Not an error: being behind is the normal state between two topic branches.
|
||||
# This reports; the caller decides. Exit 0 unless a path is unknown.
|
||||
return 0
|
||||
|
||||
|
||||
sys.exit(main())
|
||||
67
tools/port/strip-padding
Executable file
67
tools/port/strip-padding
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Remove driver-inserted silence from a capture, exactly.
|
||||
#
|
||||
# tools/port/strip-padding in.wav out.wav
|
||||
#
|
||||
# WHEN THIS IS VALID, AND WHEN IT IS VANDALISM. The distinction is the whole
|
||||
# tool and getting it backwards destroys the artefact:
|
||||
#
|
||||
# * PulseAudio's monitor SUBSTITUTES silence. It advances on a wall clock and
|
||||
# replaces audio that existed when the producer was late. Information is
|
||||
# gone; deleting the holes compresses time unevenly and repairs nothing.
|
||||
# DO NOT RUN THIS ON A MONITOR CAPTURE.
|
||||
# * Xenia's ALSA writer PADS. It inserts silence between samples the guest
|
||||
# emitted when its ring is empty (`alsa_audio_driver.cc:359`). Nothing is
|
||||
# lost and nothing is overwritten, so removing the padding is EXACT -- it
|
||||
# hands back the contiguous stream the guest produced.
|
||||
#
|
||||
# CONTROLLED, not argued. A real music+SFX bed (137.37 s, with 454 zero runs of
|
||||
# its own) had 1 149 holes inserted at 8.37/s to +9.9 % length, matching the
|
||||
# observed ALSA profile, then was stripped:
|
||||
#
|
||||
# original vs itself r 1.000 lag 0.0 s margin +0.141 [ceiling]
|
||||
# PADDED vs original r 0.436 lag -12.2 s margin +0.006 [destroyed]
|
||||
# STRIPPED vs original r 1.000 lag 0.0 s margin +0.142 [recovered]
|
||||
#
|
||||
# Frame counts: original 6 593 984, stripped 6 559 880, and the original stripped
|
||||
# of its own genuine zero runs 6 560 044 -- a difference of 164 frames, 3.4 ms in
|
||||
# 137 s, from inserted holes abutting genuine ones and merging.
|
||||
#
|
||||
# ⚠️ It removes GENUINE silence too, and cannot tell the two apart -- that is why
|
||||
# the reference above is the unstripped original: recovery does not depend on
|
||||
# stripping both sides. On this material the genuine runs total 0.71 s in 137 s
|
||||
# and cost nothing measurable. On material that is mostly silence they would.
|
||||
set -euo pipefail
|
||||
in="${1:?usage: strip-padding IN.wav OUT.wav}"; out="${2:?usage: strip-padding IN.wav OUT.wav}"
|
||||
python3 - "$in" "$out" <<'PYEOF'
|
||||
import array, struct, sys, wave
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
w = wave.open(src); ch = w.getnchannels(); rate = w.getframerate()
|
||||
if w.getsampwidth() != 2:
|
||||
print("strip-padding: 16-bit PCM only (got %d-bit)" % (w.getsampwidth()*8)); raise SystemExit(2)
|
||||
n = w.getnframes(); a = array.array('h'); a.frombytes(w.readframes(n)); w.close()
|
||||
MIN = max(1, rate // 1000) # a gap is a run, not a sample
|
||||
sil = bytearray(n)
|
||||
for f in range(n):
|
||||
b = f * ch
|
||||
if not any(a[b+c] for c in range(ch)): sil[f] = 1
|
||||
keep = array.array('h'); f = 0; removed = 0; holes = 0
|
||||
while f < n:
|
||||
s = f
|
||||
if sil[f]:
|
||||
while f < n and sil[f]: f += 1
|
||||
if f - s < MIN: keep.extend(a[s*ch:f*ch])
|
||||
else: removed += f - s; holes += 1
|
||||
else:
|
||||
while f < n and not sil[f]: f += 1
|
||||
keep.extend(a[s*ch:f*ch])
|
||||
k = len(keep) // ch
|
||||
o = wave.open(dst + ".partial", "wb") # temp name, renamed on completion
|
||||
o.setnchannels(ch); o.setsampwidth(2); o.setframerate(rate)
|
||||
o.writeframes(keep.tobytes()); o.close()
|
||||
import os; os.replace(dst + ".partial", dst)
|
||||
print("%s: %d frames (%.3f s) -> %s: %d frames (%.3f s)"
|
||||
% (src, n, n/rate, dst, k, k/rate))
|
||||
print(" removed %d run(s) totalling %.3f s (%.2f %% of the input)"
|
||||
% (holes, removed/rate, 100.0*removed/n))
|
||||
PYEOF
|
||||
375
tools/port/verify-capture
Executable file
375
tools/port/verify-capture
Executable file
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env bash
|
||||
# Diff the port's render against a CAPTURE OF THE REAL GAME.
|
||||
#
|
||||
# tools/port/verify-capture main_menu
|
||||
# tools/port/verify-capture # every screen with a capture
|
||||
#
|
||||
# THIS IS THE CORRECTNESS CHECK. `verify-screen` is the consistency one, and its
|
||||
# own header has pointed at this file since P1 -- `tools/port/verify-capture` -- while
|
||||
# this file did not exist. The port has had a harness comparing two renderers
|
||||
# that share its assumptions, and none comparing it to the game.
|
||||
#
|
||||
# 🔴 WHAT THE `diff` COLUMN DOES NOT SAY. It counts pixels surviving
|
||||
# `-threshold 25%` -- differing by more than ~64 levels. That is deliberate: it
|
||||
# detects a missing or MISPLACED element, which is a large connected blob. It is
|
||||
# blind to sub-threshold spatial error -- a one-pixel offset, a soft edge in a
|
||||
# slightly wrong place, an antialiasing difference -- because none of that moves
|
||||
# a pixel 64 levels.
|
||||
#
|
||||
# So `main_menu 0.06%` means NO GROSS DISPLACEMENT. It does NOT mean the
|
||||
# geometry is right, and it has already been read that way by another agent:
|
||||
# `docs/re/structures/title-residual-tone-vs-geometry.md` uses this screen as a
|
||||
# tone-only positive control, citing this number as "geometry is essentially
|
||||
# right". Measured 2026-08-31 against that capture: after fitting a per-level LUT
|
||||
# -- the most general tone model there is -- the remaining residual is 6.94 on
|
||||
# edge pixels against 2.20 on flat ones, a 3.2x concentration. A purely tonal
|
||||
# residual leaves a per-level LUT exactly 0.00 (checked, by construction). The
|
||||
# menu carries spatial error this column cannot see.
|
||||
#
|
||||
# ⚠️ That gap is not academic. `docs/re/captures/ORACLE-CAPTURES.md`: two
|
||||
# renderers agreeing proves nothing, and this corpus has been bitten three times
|
||||
# -- the dropped `pteff05` background, the scale-0 rect, and `rest()`. Every one
|
||||
# was invisible to a render-vs-render diff and obvious against a capture.
|
||||
#
|
||||
# WHAT IT CAN CONCLUDE, and what it cannot:
|
||||
#
|
||||
# * ✅ STRUCTURE. Something drawn that should not be, or missing that should be,
|
||||
# shows as a large connected region of difference. That is the failure mode
|
||||
# the three above were, and it is what this tool is for.
|
||||
# * 🔴 NOT a pixel score. The captures are NOT gamma-neutral:
|
||||
# `capture ~= 255*(render/255)^g` with g ~ 1.34-1.49, and that ramp is THE
|
||||
# GAME'S, not the capture path's (`docs/re/structures/ui-render-tone-curve.md`).
|
||||
# So RMSE has a floor and driving it lower is fitting the ramp. This reports
|
||||
# the raw difference AND the gamma-compensated one, and neither is a target.
|
||||
# * ⚠️ A capture is ONE MOMENT. Several screens are still animating -- the
|
||||
# title's two `ptloop` sweeps never stop -- and the focused button in a
|
||||
# capture may not be the one the port focuses. Differences confined to a
|
||||
# button or a moving element are expected; say which before calling anything.
|
||||
#
|
||||
# Geometry needs no correction: the corpus cross-correlated a render against
|
||||
# `live-main-menu.png` over +/-6 px and the best alignment is exactly (0,0) at
|
||||
# 0.9466. The captures are a 1279x675 top-left crop of the 1280x720 guest
|
||||
# surface, so the render is cropped to match and nothing is scaled.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
export DISPLAY="${DISPLAY:-:97}"
|
||||
OUT="${OUT:-$(mktemp -d)}"; mkdir -p "$OUT"
|
||||
CAPS=docs/re/captures/title-builds
|
||||
|
||||
# screen : capture : how to pose it
|
||||
# 🔴 A MENU CAPTURE HAS A BUTTON FOCUSED, AND THE FIRST VERSION OF THIS TOOL
|
||||
# RENDERED WITH NONE. `--screen=` draws no focus record at all, so `main_menu`
|
||||
# was being compared to the oracle in a state the oracle was never in: 2 159
|
||||
# differing pixels, of which 74 % sat inside the focus signature. Rendered with
|
||||
# focus it is 531 -- 0.06 % of the frame, a 4x improvement that was entirely my
|
||||
# harness posing the port wrong.
|
||||
#
|
||||
# `--menu=` applies `authored/flow.json`'s initial focus and `--script=wait`
|
||||
# shoots one settled frame and exits.
|
||||
MAP=(
|
||||
"main_menu:$CAPS/live-main-menu.png:menu"
|
||||
"extras:$CAPS/live-extras.png:menu"
|
||||
# The only capture of a MEASURED focus state, and it was unusable until
|
||||
# `--focus=` was made to work on the `--menu` path (it parsed, was stored, and
|
||||
# was overwritten by the authored initial focus on every `_menu_enter`).
|
||||
#
|
||||
# It discriminates: rendering each of the five buttons focused against this
|
||||
# capture gives 0.1355 % for `ptbtn04` and 0.70-0.82 % for the other four. The
|
||||
# port's focus rendering identifies the right button by a factor of five.
|
||||
"main_menu_options:$CAPS/live-main-menu-options-focused.png:focus:ptbtn04"
|
||||
# ⚠️ THE TITLE IS POSED AT t=357.7 UNITS, NOT AT ITS SETTLE, and the time is
|
||||
# MEASURED rather than chosen. The two `ptloop` sweeps are a continuous
|
||||
# animation whose leaf group ends at t=600 with the quads parked off-screen at
|
||||
# x=1521, so posing at the settle compares a still frame against a capture
|
||||
# taken mid-sweep and simply omits them.
|
||||
#
|
||||
# t=357.7 is the Decoder's REFINED fit, and the refinement is worth knowing.
|
||||
# Its first value, 355, came from the two per-draw alpha bytes alone and left
|
||||
# an 11.5 px residual that looked like a pivot problem. Solving the same
|
||||
# instant on the vertex POSITIONS instead gives t=357.88 and 357.58 to
|
||||
# +/-0.12 units, against +/-1.54 and +/-1.89 from the alphas -- alpha moves
|
||||
# only 0.27-0.33 levels per unit, so one byte of quantisation is worth 6-8 px
|
||||
# of sweep. At 357.7 the centres land within 0.70 px and both alphas inside one
|
||||
# level. THE 11.5 px WAS THE FIT'S RESOLUTION, NOT GEOMETRY.
|
||||
#
|
||||
# ⚠️ And there is no pivot correction: the leaf pivot is (200, 90) on a 399x180
|
||||
# sprite, so it is the centre to within half a pixel -- checked here against
|
||||
# the export rather than taken.
|
||||
#
|
||||
# ⚠️ It is NOT the time that minimises the difference: t=390 measures 1.65 %
|
||||
# against 1.82 % here. Picking that one would be fitting the pose to the
|
||||
# score, which is the thing this harness exists not to do.
|
||||
"title:$CAPS/live-title-build4-no-plate.png:t357"
|
||||
# The Japanese title at rest, Decoder 310bf86. Settled pose by omission --
|
||||
# the capture is demonstrated at rest (five frames over 6 s, 0 px change in
|
||||
# the logo block while 5-8 % of the frame moves).
|
||||
#
|
||||
# 🔴 THIS ROW EXISTS BECAUSE SCORING THE WRONG FRAME COST A WRONG CONCLUSION.
|
||||
# `verify-screen` poses at `--pose=rest`, which for this screen lights every
|
||||
# `ptlogo_back2eff*` sparkle at its own peak simultaneously -- `rest` for those
|
||||
# elements IS the peak of a 4-unit flash. That frame is fine for the
|
||||
# consistency check it was built for and must never be scored against a
|
||||
# capture: doing so put the port at r +0.7462 against the reference's +0.8727
|
||||
# and I wrote up that the port had moved away from the game. Posed as it
|
||||
# SHIPS, the same block scores **+0.9994**.
|
||||
"title_jp:$CAPS/live-title-jp-at-rest.png:settled::1279x675+1+45"
|
||||
# A SECOND title comparison, and the most sensitive row this tool has.
|
||||
#
|
||||
# `live-title-press-a.png` is the title WITH the plate. Posed at t=237 -- inside
|
||||
# the plate's own 8-unit opaque window, t=236-238 -- the port matches it at
|
||||
# **0.00093 %**, two orders below every other row. That makes it the best
|
||||
# regression detector here: anything structural that moves will show.
|
||||
#
|
||||
# ⚠️ The instant is FITTED, not measured: 237 is where this capture's content
|
||||
# places it, found by sweeping. That is legitimate for choosing which frame to
|
||||
# compare against -- every row does it -- but it is not a claim about the game,
|
||||
# and the 0.00093 % is therefore a floor for THIS pose, not a general accuracy.
|
||||
#
|
||||
# It also closes the systematic-error question the leaf sweep left open. Two
|
||||
# independent captures fit at two DIFFERENT phases -- this one at 237, the
|
||||
# no-plate one at ~400 -- and both to 0.01 % or better. A geometry error in how
|
||||
# the sweeps are drawn would leave a floor in both. Neither has one.
|
||||
# 🔴 THIS ROW IS POSED AT THE PLATE'S BLIND PHASE, and it cannot see the plate's
|
||||
# highlight at all. `--loop-phase=0` pins the looping-focus clock, and
|
||||
# `ptbtn00f` -- the plate's own highlight, which the GAME draws ADDITIVE
|
||||
# (docs/re/data/blend-bit-vs-oracle.txt, entry 2) -- contributes EXACTLY 0 px at
|
||||
# phase 0 and 22 000-29 000 px at phases 20..100. Measured 2026-08-31 by
|
||||
# shadowing its sprite and diffing.
|
||||
#
|
||||
# So switching that element to its measured additive blend moved 26 319 px at
|
||||
# phase 20 and reported ZERO here. This row's 0.09 % is real and unaffected; it
|
||||
# simply says nothing about the pulse. A capture at a NON-ZERO loop phase is
|
||||
# what would let this row see it, and none exists -- filed in BLOCKED.md.
|
||||
"title_plate:$CAPS/live-title-press-a.png:plate"
|
||||
# A BANDED row -- the capture is 1279x120, not a full frame, and the harness
|
||||
# could not compare one until now. That was the only reason this capture sat
|
||||
# unused; nothing about it was unusable.
|
||||
#
|
||||
# Its y offset is MEASURED, not guessed: sliding it down the render, the
|
||||
# structural difference is 0.354 % at y=520 against 8.9-9.1 % five pixels
|
||||
# either side and 17-52 % further out. A 25x drop over five pixels.
|
||||
#
|
||||
# ⚠️ Its residual is NOT the port's error. The port reproduces the same band of
|
||||
# `live-title-press-a.png` EXACTLY (0.000 %), and the two captures differ from
|
||||
# each other by 0.301 % -- two thin horizontal strips, 248x5 px and 206x1 px,
|
||||
# the shape of a sub-pixel edge difference rather than a state difference. So
|
||||
# 0.354 % is very nearly the oracle-to-oracle gap and this row's job is to stay
|
||||
# near it, not to reach zero.
|
||||
"title_band:$CAPS/live-attract-title-press-a-band.png:band"
|
||||
"publisher_logo:$CAPS/live-splash-publisher.png:screen"
|
||||
"developer_logos:$CAPS/live-splash-developer.png:screen"
|
||||
)
|
||||
CURVE=""
|
||||
if [ "${1:-}" = "--curve" ]; then CURVE=1; shift; fi
|
||||
want=("$@")
|
||||
echo "RMSE is reported and is NOT a target: the capture carries the game's own"
|
||||
echo "tone ramp, so it has a floor. What finds a real defect is the DIFFERING"
|
||||
echo "REGION -- a missing or misplaced element is a large connected blob."
|
||||
echo
|
||||
# 🔴 THE METRIC'S OWN ZERO, asserted before any row is printed.
|
||||
#
|
||||
# Every number below is "small is good", and this file already says the RMSE has
|
||||
# a floor from the game's tone ramp. What was never established is the floor of
|
||||
# the COMPARISON ITSELF. A control that only bounds error from above cannot tell
|
||||
# an exact instrument from a slightly wrong one -- and slightly wrong is the
|
||||
# failure that passes. The Decoder reached that form of it after their coherence
|
||||
# estimator's positive control read 0.94 for two reasons at once.
|
||||
#
|
||||
# Measured here rather than assumed: a capture against itself, and against a PNG
|
||||
# round-trip of itself, must both be EXACTLY 0. If they are not, the metric has a
|
||||
# bias and no row below means what it says.
|
||||
_ctl=""
|
||||
for row in "${MAP[@]}"; do
|
||||
IFS=: read -r _n _c _rest <<<"$row"; [ -f "$_c" ] && { _ctl="$_c"; break; }
|
||||
done
|
||||
if [ -n "$_ctl" ]; then
|
||||
_rt="${TMPDIR:-/tmp}/verify-capture-rt.png"; convert "$_ctl" -quality 100 "$_rt"
|
||||
for _pair in "$_ctl|$_ctl|identity" "$_ctl|$_rt|PNG round-trip"; do
|
||||
IFS='|' read -r _a _b _lab <<<"$_pair"
|
||||
_d=$(convert "$_a" "$_b" -metric RMSE -compare -format "%[distortion]" info: 2>&1 | tail -1)
|
||||
_v=$(python3 -c "print('%.4f' % (float('$_d')*255))" 2>/dev/null || echo "?")
|
||||
if [ "$_v" = "0.0000" ]; then
|
||||
printf ' metric control, %-16s RMSE %s -- exact\n' "$_lab:" "$_v"
|
||||
else
|
||||
printf ' 🔴 metric control, %-13s RMSE %s -- NOT ZERO. The comparison is\n' "$_lab:" "$_v"
|
||||
echo " biased and every row below is unreadable. Refusing."
|
||||
exit 3
|
||||
fi
|
||||
done
|
||||
echo
|
||||
fi
|
||||
printf '%-17s %-9s %-7s %-22s %s\n' screen raw-rmse diff region note
|
||||
for row in "${MAP[@]}"; do
|
||||
IFS=: read -r name cap pose forced capcrop <<<"$row"
|
||||
if [ ${#want[@]} -gt 0 ] && ! printf '%s\n' "${want[@]}" | grep -qx "$name"; then continue; fi
|
||||
[ -f "$cap" ] || { printf '%-17s %s\n' "$name" "no capture"; continue; }
|
||||
if [ "$pose" = band ]; then
|
||||
godot --path port --resolution 1280x720 -- --loop-phase=0 --leaf-time=0 "--screen=title" --overlay=press_start \
|
||||
--time=3.95 "--capture=$OUT/$name.full.png" >"$OUT/$name.log" 2>&1 || true
|
||||
[ -f "$OUT/$name.full.png" ] && convert "$OUT/$name.full.png" \
|
||||
-crop 1279x120+0+520 +repage "$OUT/$name.render.png"
|
||||
elif [ "$pose" = focus ]; then
|
||||
godot --path port --resolution 1280x720 -- --loop-phase=0 --leaf-time=0 "--menu=main_menu" "--focus=$forced" \
|
||||
--script=wait "--shots=$OUT/$name" >"$OUT/$name.log" 2>&1 || true
|
||||
[ -f "$OUT/${name}_00_start.png" ] && cp "$OUT/${name}_00_start.png" "$OUT/$name.render.png"
|
||||
elif [ "$pose" = menu ]; then
|
||||
godot --path port --resolution 1280x720 -- --loop-phase=0 --leaf-time=0 "--menu=$name" --script=wait \
|
||||
"--shots=$OUT/$name" >"$OUT/$name.log" 2>&1 || true
|
||||
[ -f "$OUT/${name}_00_start.png" ] && cp "$OUT/${name}_00_start.png" "$OUT/$name.render.png"
|
||||
elif [ "$pose" = plate ]; then
|
||||
godot --path port --resolution 1280x720 -- --loop-phase=0 --leaf-time=0 "--screen=title" --overlay=press_start \
|
||||
--time=3.95 "--capture=$OUT/$name.render.png" >"$OUT/$name.log" 2>&1 || true
|
||||
elif [ "$pose" = t357 ]; then
|
||||
# 🔴 NO `--time` HERE EITHER, and the row's note used to claim otherwise.
|
||||
#
|
||||
# It passed `--time=5.9617` (t=357.7 units, the Decoder's refined sweep fit)
|
||||
# and that value was NEVER APPLIED: `pose_at` replaced it with the screen's
|
||||
# settle instant, t=198, on every run. Every title figure this tool has ever
|
||||
# printed -- including the 0.26 % the port has quoted repeatedly -- was
|
||||
# measured at the SETTLE, under a note saying t=357.7.
|
||||
#
|
||||
# Honouring it now makes that visible: t=357.7 is PAST the title's own group,
|
||||
# which ends at t=269, so the whole screen poses at its faded-out final
|
||||
# keyframes and the disagreement goes to 30.97 %. The instant was only ever
|
||||
# meant for the `ptloop` LEAF, which runs to t=600 and is looped separately
|
||||
# by `loop_leaf` (authored/rendering.json). Applying it to the whole screen
|
||||
# was always wrong; it was harmless only while it was ignored.
|
||||
#
|
||||
# So: pose at the settle, which is what was actually being measured, and let
|
||||
# the leaf loop carry the sweeps' phase.
|
||||
godot --path port --resolution 1280x720 -- --loop-phase=0 --leaf-time=0 "--screen=$name" \
|
||||
"--capture=$OUT/$name.render.png" >"$OUT/$name.log" 2>&1 || true
|
||||
else
|
||||
# NO `--time`. It used to pass `--time=99` as an idiom for "settled", and
|
||||
# that worked only because `--time` was SILENTLY IGNORED on a screen with a
|
||||
# settle window: `pose_at` overwrote the requested instant with
|
||||
# `settle_instant` whenever `holding` was true. The tool asked for t=5940
|
||||
# units and was handed the settle instant, which is the pose it actually
|
||||
# wants -- and the 0.01 % agreements on both splashes were measured through
|
||||
# that accident. Now that `--time` is honoured, asking for it explicitly
|
||||
# would pose past the end of every group, so the request is simply dropped
|
||||
# and the settled pose asked for by omission.
|
||||
godot --path port --resolution 1280x720 -- --loop-phase=0 --leaf-time=0 "--screen=$name" \
|
||||
"--capture=$OUT/$name.render.png" >"$OUT/$name.log" 2>&1 || true
|
||||
fi
|
||||
[ -f "$OUT/$name.render.png" ] || { printf '%-17s %s\n' "$name" "render failed"; continue; }
|
||||
# Crop the render to the capture's frame. The capture is the crop, not a scale.
|
||||
if [ "$pose" = band ]; then
|
||||
cp "$OUT/$name.render.png" "$OUT/$name.crop.png"
|
||||
else
|
||||
convert "$OUT/$name.render.png" -crop 1279x675+0+0 +repage "$OUT/$name.crop.png"
|
||||
fi
|
||||
# ⚠️ A FIFTH FIELD, because not every capture is pre-cropped to the game
|
||||
# surface. Every capture in `$CAPS` until now was already 1279x675, so the
|
||||
# render was cropped and the capture used as-is. The JP title capture is a
|
||||
# full 1280x720 DISPLAY frame with the surface at +0+45 -- comparing it whole
|
||||
# would score the port against a 45px shift and report a catastrophe.
|
||||
#
|
||||
# The offset is MEASURED, not inherited from the earlier submenu capture:
|
||||
# row/column profile correlation against the port, with the English pair as a
|
||||
# control, gives (0,0) for the control at r 0.994 and dy=-45 for this frame.
|
||||
if [ -n "$capcrop" ]; then
|
||||
convert "$cap" -crop "$capcrop" +repage "$OUT/$name.cap.png"
|
||||
cap="$OUT/$name.cap.png"
|
||||
fi
|
||||
raw=$(convert "$OUT/$name.crop.png" "$cap" -metric RMSE -compare -format "%[distortion]" info: 2>&1 | tail -1)
|
||||
raw=$(python3 -c "print('%.2f' % (float('$raw')*255))" 2>/dev/null || echo "?")
|
||||
note=""
|
||||
# 🔴 EVERY ROW WITH A SWEEPING LEAF CARRIES A CAPTURE-PHASE TERM, AND THIS
|
||||
# TOOL USED TO PRINT THE NUMBER WITHOUT IT.
|
||||
#
|
||||
# `ptloop01`/`ptloop02` free-run on a settled screen -- a settled screen is not
|
||||
# a static screen -- so a capture froze them wherever the shutter fell, and the
|
||||
# render is pinned at `--leaf-time=0` by CONVENTION, not because 0 is the
|
||||
# game's phase. Measured by sweeping the phase against each capture:
|
||||
#
|
||||
# title 5.56 main_menu 3.78 extras 3.73 splashes 0.00
|
||||
#
|
||||
# Those are RMSE, in this tool's own metric, and larger than most margins
|
||||
# anyone has quoted from these rows. So: usable for REGRESSION at a fixed pin,
|
||||
# run to run; NOT usable as an absolute against anything measured differently.
|
||||
#
|
||||
# ✅ The two splash rows carry no free-running element at all. They are the
|
||||
# only absolutes here that mean what they say.
|
||||
case "$name" in
|
||||
title) note="settle t=198; +/-5.56 capture-phase term -- regression only" ;;
|
||||
title_jp) note="+/-5.6 capture-phase term (same leaves as build 4)" ;;
|
||||
# 🔴 The old note said "rendered with AUTHORED initial focus", and it was
|
||||
# stale twice over. The value became MEASURED on 2026-08-31 (NEW GAME, 2/2
|
||||
# fresh boots, first entry) -- and the capture's OWN focus state, which had
|
||||
# never been established, is now identified by exclusion: rendering all five
|
||||
# candidates against this capture gives ptbtn01 13.06 and every alternative
|
||||
# 15.96-16.59, ~22 % worse. So the residual below is NOT a focus mismatch.
|
||||
#
|
||||
# ⚠️ It does not re-establish "the menu opens on NEW GAME". Focus persists on
|
||||
# this screen, so a capture of the running menu could show any item; what is
|
||||
# established is that THIS capture shows NEW GAME and the port renders the
|
||||
# same state.
|
||||
main_menu) note="focus ptbtn01 confirmed by exclusion (next best +22%); +/-3.78 capture-phase term" ;;
|
||||
extras) note="rendered with authored initial focus; +/-3.73 capture-phase term" ;;
|
||||
publisher_logo|developer_logos) note="no free-running element -- absolute, means what it says" ;;
|
||||
esac
|
||||
# Where the difference lives. This comes FIRST because it is what the gamma
|
||||
# sweep has to be protected from.
|
||||
convert "$OUT/$name.crop.png" "$cap" -compose difference -composite \
|
||||
-colorspace Gray -threshold 25% "$OUT/$name.mask.png"
|
||||
|
||||
# THE TONE RELATIONSHIP IS REPORTED AS A CURVE, NOT AS A BEST EXPONENT, and
|
||||
# two earlier versions of this tool reported an exponent and were wrong twice.
|
||||
#
|
||||
# `docs/re/structures/ui-render-tone-curve.md` models it as
|
||||
# `capture = 255*(render/255)^g`, g ~ 1.34-1.49, measured on dark flat patches
|
||||
# and explicitly not constrained above render ~60. Binning every structurally
|
||||
# matched pixel of `main_menu` by render level and averaging the capture gives:
|
||||
#
|
||||
# render capture implied g pixels
|
||||
# 8 4.04 1.20 183 026
|
||||
# 16 7.89 1.26 227 630
|
||||
# 24 15.57 1.18 100 945
|
||||
# 32 26.15 1.10 87 474
|
||||
# 40 38.07 1.03 86 094
|
||||
# 48 53.96 0.93 85 255
|
||||
# 64 78.52 0.85 6 509
|
||||
# 96 130.44 0.69 1 682
|
||||
#
|
||||
# ⚠️ **The implied exponent is not constant. It falls monotonically and crosses
|
||||
# 1.0 near render ~44**, so the capture is DARKER than the render in the darks
|
||||
# and BRIGHTER in the midtones. A single power law cannot express that, which
|
||||
# is exactly why a whole-frame fit returns 1.00: the two halves cancel. The
|
||||
# corpus's reach -- "nothing constrains midtones or highlights" -- was a real
|
||||
# limit and this is what lies past it.
|
||||
#
|
||||
# So: no best-g is printed. The table above is the instrument that can actually
|
||||
# be argued with; `tools/port/verify-capture --curve SCREEN` regenerates it.
|
||||
frac=$(convert "$OUT/$name.mask.png" -format "%[fx:mean*100]" info:)
|
||||
box=$(convert "$OUT/$name.mask.png" -trim -format "%wx%h%X%Y" info: 2>/dev/null || echo "-")
|
||||
printf '%-17s %-9s %6.2f%% %-22s %s\n' "$name" "$raw" "$frac" "$box" "$note"
|
||||
done
|
||||
if [ -n "$CURVE" ]; then
|
||||
for row in "${MAP[@]}"; do
|
||||
IFS=: read -r name cap pose <<<"$row"
|
||||
if [ ${#want[@]} -gt 0 ] && ! printf '%s\n' "${want[@]}" | grep -qx "$name"; then continue; fi
|
||||
[ -f "$OUT/$name.mask.png" ] || continue
|
||||
convert "$OUT/$name.crop.png" -colorspace Gray -depth 8 "gray:$OUT/$name.r.gray"
|
||||
convert "$cap" -colorspace Gray -depth 8 "gray:$OUT/$name.c.gray"
|
||||
convert "$OUT/$name.mask.png" -colorspace Gray -depth 8 "gray:$OUT/$name.m.gray"
|
||||
echo; echo "transfer curve, $name -- structurally matched pixels only"
|
||||
python3 - "$OUT/$name" <<'PYEOF'
|
||||
import sys, math
|
||||
b = sys.argv[1]
|
||||
r = open(b+".r.gray","rb").read(); c = open(b+".c.gray","rb").read(); m = open(b+".m.gray","rb").read()
|
||||
n = min(len(r), len(c), len(m)); bins = {}
|
||||
for i in range(n):
|
||||
if m[i]: continue
|
||||
s = bins.setdefault(r[i]//8*8, [0,0]); s[0] += c[i]; s[1] += 1
|
||||
print(" %-8s %-9s %-9s %s" % ("render","capture","implied g","pixels"))
|
||||
for k in sorted(bins):
|
||||
tot, cnt = bins[k]
|
||||
if cnt < 500 or k < 8: continue
|
||||
cap = tot/cnt
|
||||
g = math.log(max(cap,0.5)/255.0)/math.log(k/255.0)
|
||||
print(" %-8d %-9.2f %-9.2f %d" % (k, cap, g, cnt))
|
||||
PYEOF
|
||||
done
|
||||
fi
|
||||
echo "artifacts in $OUT"
|
||||
143
tools/port/verify-dwell
Executable file
143
tools/port/verify-dwell
Executable file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
# Check the port's boot pacing against captures of the real game.
|
||||
#
|
||||
# tools/port/verify-dwell
|
||||
#
|
||||
# WHY THIS IS A TOOL AND NOT A ONE-OFF. Doing it by hand once already refuted a
|
||||
# 🔴 I had filed myself: `docs/port/BLOCKED.md` said `rest.t` was the wrong settle
|
||||
# landmark AND that "everything the sequencer paces off it is therefore late".
|
||||
# The first half is true; the second was wrong, and I nearly re-paced screens
|
||||
# that already matched the game to 0.05 s.
|
||||
#
|
||||
# ⚠️ A PORT'S TRANSITION INTERVAL IS NOT THE ORACLE'S VISIBLE SPAN. They differ
|
||||
# by the black hold between screens, and confusing the two cost this corpus 0.6 s
|
||||
# once and 0.48 s on the plate delay. So the comparison here is explicit: the
|
||||
# port's interval is checked against the oracle's span PLUS the measured hold.
|
||||
#
|
||||
# 🔴 AND THE VERDICT DOES NOT COME FROM THE FILMSTRIP ANY MORE. It used to
|
||||
# measure ink spans from `--film` frames. The boot's black hold is 0.17-0.23 s
|
||||
# (HANDOFF Q7) -- shorter than the 0.25 s cadence meant to observe it -- so when
|
||||
# the black frame fell between samples two screens merged into one span and this
|
||||
# tool reported `developer logos` as 93 s against an oracle of 3.5 s. Filming at
|
||||
# 0.1 s made it WORSE: 2.5x the screenshots slows the run enough that the capture
|
||||
# catches up in bursts, and the publisher span came back as 7.80 s.
|
||||
#
|
||||
# The sequencer already knows exactly when it changed screens and prints it.
|
||||
# Sampling a picture to rediscover a number the program can state is how this
|
||||
# went wrong. The filmstrip is kept, and marked advisory.
|
||||
#
|
||||
# THE EXPECTED NUMBERS ARE THE ORACLE'S, NOT THE PORT'S: three cold boots from
|
||||
# `docs/re/boot-order-and-splash-dwell.md`, quoted as a test fixture. Nothing in
|
||||
# the port derives them and nothing may.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
export DISPLAY="${DISPLAY:-:97}"
|
||||
OUT="${OUT:-$(mktemp -d)}"
|
||||
mkdir -p "$OUT"
|
||||
INTERVAL="${INTERVAL:-0.25}"
|
||||
|
||||
echo "running the boot (the intro is skipped -- the splashes are what this measures)"
|
||||
timeout "${TIMEOUT:-300}" godot --path port --resolution 1280x720 -- \
|
||||
--boot --skip-at=1 "--film-interval=$INTERVAL" "--film=$OUT/f" \
|
||||
>"$OUT/boot.log" 2>&1 || true
|
||||
|
||||
INTERVAL="$INTERVAL" python3 - "$OUT" <<'PYEOF'
|
||||
import glob, os, re, subprocess, sys
|
||||
out = sys.argv[1]
|
||||
INTERVAL = float(os.environ.get("INTERVAL", "0.25"))
|
||||
# The boot's black gap, measured in the DRAW STREAM (4 presented frames at
|
||||
# 2.284 units/frame = 9.1 units), not from luminance -- luminance cannot separate
|
||||
#
|
||||
# ⚠️ 2.284 IS NOT A GENERAL RATE AND THIS LINE USED TO READ AS IF IT WERE.
|
||||
# It is the disc used as its own clock ON ONE CAPTURE, which ran at 13.1 fps
|
||||
# against ~28 elsewhere: `palogo_sqex` declares alpha >= 1 for 239.8 units and
|
||||
# was drawn in 105 frames of that run. Correct for converting THAT run's frame
|
||||
# count; not a constant, and not HANDOFF Q1's 2 units per rendered frame, which
|
||||
# is a different quantity measured at normal speed. See DECISIONS.md.
|
||||
# the outgoing fade's tail from true black. The +/-1 frame range is 6.9-11.4
|
||||
# units = 0.114-0.190 s. HANDOFF Q7's luminance figure of 0.17-0.23 s overlaps
|
||||
# only at the top, and the draw-stream number is the one to use.
|
||||
HOLD_LO, HOLD_HI = 0.114, 0.190
|
||||
# 🔴 THAT IS THE GAME'S GAP. THE PORT'S IS AUTHORED AND IS CURRENTLY 0.
|
||||
#
|
||||
# This tool built its target as `oracle span + the GAME's black gap` and compared
|
||||
# the port against it -- correct only while the port inserted that gap. It does
|
||||
# not: `black_hold_units` went to 0 (four measured gaps, 0/6/4/6 units, no rule;
|
||||
# see authored/timing.json). So the port is expected to run SHORT by the gap, and
|
||||
# on `publisher_logo` it does -- 0.131 s below the unslacked target, which the
|
||||
# 0.15 s wall-clock slack was quietly absorbing into an "agrees".
|
||||
#
|
||||
# Read from the authored file so it cannot drift again, and REPORT the shortfall
|
||||
# rather than hide it. A verdict that passes because the slack happens to exceed
|
||||
# a known omission is not a verdict.
|
||||
#
|
||||
# 🔴 AND THE RATE WAS HARDCODED WHILE THE VALUE WAS NOT. This line read
|
||||
# `black_hold_units` from the file -- so it "cannot drift again" -- and then
|
||||
# divided by a literal 60.0. The value could not drift; the conversion could,
|
||||
# and would have gone silently wrong the moment `keyframe_units_per_second`
|
||||
# moved. It is under active dispute right now (60 vs 120), so this is a live
|
||||
# hazard rather than a tidy-up. Harmless only because the hold is currently 0.
|
||||
import json as _json
|
||||
_timing = _json.load(open("authored/timing.json"))
|
||||
_UPS = float(_timing.get("keyframe_units_per_second", 60))
|
||||
PORT_HOLD = float(_timing.get("black_hold_units", 0)) / _UPS
|
||||
|
||||
marks = []
|
||||
for line in open(os.path.join(out, "boot.log"), errors="replace"):
|
||||
m = re.match(r"\s+-> (\S+) at ([0-9.]+) s", line)
|
||||
if m:
|
||||
marks.append((m.group(1), float(m.group(2))))
|
||||
if not marks:
|
||||
print("no transitions in the boot log -- see", os.path.join(out, "boot.log"))
|
||||
raise SystemExit(2)
|
||||
|
||||
ORACLE = [
|
||||
("publisher wordmark", [4.297, 4.604, 4.370]),
|
||||
("developer logos", [3.508, 3.503, 3.366]),
|
||||
]
|
||||
starts = [0.0] + [t for _, t in marks]
|
||||
print()
|
||||
print("%-20s %-14s %-26s %s" % ("screen", "port interval", "oracle span (3 boots)", "verdict"))
|
||||
bad = 0
|
||||
for k, (name, runs) in enumerate(ORACLE):
|
||||
if k + 1 >= len(starts):
|
||||
print("%-20s %-14s %s" % (name, "-", "no such transition this run")); continue
|
||||
d = starts[k + 1] - starts[k]
|
||||
lo, hi = min(runs) + PORT_HOLD, max(runs) + PORT_HOLD
|
||||
game_lo, game_hi = min(runs) + HOLD_LO, max(runs) + HOLD_HI
|
||||
ok = lo - 0.15 <= d <= hi + 0.15
|
||||
bad += 0 if ok else 1
|
||||
print("%-20s %-14s %-26s %s"
|
||||
% (name, "%.2f s" % d, "%.3f / %.3f / %.3f" % tuple(runs),
|
||||
"agrees" if ok else "DIFFERS"))
|
||||
print()
|
||||
print(" Target = oracle SPAN + the PORT's authored hold (%.3f s); the GAME's" % PORT_HOLD)
|
||||
print(" measured gap is %.3f-%.3f s, so a port with hold 0 runs short by that." % (HOLD_LO, HOLD_HI))
|
||||
print(" 0.15 s of slack for wall-clock jitter -- which is LARGER than the gap,")
|
||||
print(" so a shortfall of that size passes unless it is reported separately:")
|
||||
print(" transitions:", ", ".join("%s@%.2f" % m for m in marks[:4]))
|
||||
|
||||
frames = sorted(glob.glob(os.path.join(out, "f_*.png")))[:120]
|
||||
if frames:
|
||||
means = [float(subprocess.run(["convert", f, "-colorspace", "Gray", "-format",
|
||||
"%[fx:mean*255]", "info:"], capture_output=True, text=True).stdout or 0)
|
||||
for f in frames]
|
||||
ink = [m > 0.0 for m in means]
|
||||
spans, i = [], 0
|
||||
while i < len(ink):
|
||||
if ink[i]:
|
||||
j = i
|
||||
while j < len(ink) and ink[j]: j += 1
|
||||
spans.append((i * INTERVAL, j * INTERVAL)); i = j
|
||||
else:
|
||||
i += 1
|
||||
print()
|
||||
print(" advisory -- filmstrip ink spans at %.2f s, which CANNOT resolve a" % INTERVAL)
|
||||
print(" %.2f-%.2f s hold and merges screens whenever it misses one:" % (HOLD_LO, HOLD_HI))
|
||||
for a, b in spans[:4]:
|
||||
print(" %6.2f - %6.2f s (%.2f s)" % (a, b, b - a))
|
||||
raise SystemExit(1 if bad else 0)
|
||||
PYEOF
|
||||
rc=$?
|
||||
echo "artifacts in $OUT"
|
||||
exit $rc
|
||||
236
tools/port/verify-input
Executable file
236
tools/port/verify-input
Executable file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env bash
|
||||
# The input map, and the stick latch -- asserted against Godot, not reasoned about.
|
||||
#
|
||||
# tools/port/verify-input
|
||||
# tools/port/verify-input --control # each check fails when its subject is removed
|
||||
#
|
||||
# 🔴 WHY THIS EXISTS. A human played the port on a real controller and Ⓐ did
|
||||
# nothing. Skipping the intro did nothing; opening a submenu did nothing. The
|
||||
# unattended P5 walk had passed on every iteration while this was true, and the
|
||||
# reason is exact:
|
||||
#
|
||||
# `--script` sends `InputEventAction`, which BYPASSES the input map.
|
||||
#
|
||||
# So the harness asserted every line of code *after* the input map and nothing
|
||||
# about the map itself -- and the map was missing half the actions. Godot 4.7.2
|
||||
# binds NO joypad button to `ui_accept` or `ui_cancel`, while it binds the d-pad
|
||||
# AND the left stick to `ui_up`/`ui_down`. Four actions worked on the pad, two
|
||||
# did not, which reads as a broken controller.
|
||||
#
|
||||
# The second defect had the same blind spot: `InputEventAction` is not an analog
|
||||
# axis, so the harness could not have seen that a held stick fires once per
|
||||
# jitter. The human's words were "moves the cursor too fast".
|
||||
#
|
||||
# ⚠️ THE GENERAL LESSON, worth more than either fix: **a synthetic-input test
|
||||
# cannot assert the input map.** Anything injected below the map is evidence
|
||||
# about the code above it only.
|
||||
#
|
||||
# ## The control, and what it can and cannot cover
|
||||
#
|
||||
# 🔴 The first version of `--control` inverted ALL NINE assertions and demanded
|
||||
# every one fail with the fixup skipped. Seven of them do not depend on the
|
||||
# fixup, so it reported them as broken -- a control that fails a correct check
|
||||
# is the same defect as one that passes a dead check, and this file would have
|
||||
# shipped claiming its checks were untrustworthy. Each check now names its
|
||||
# SUBJECT, and the control removes exactly that subject:
|
||||
#
|
||||
# bind -- skip `Gamepad.bind_missing()`; the check must fail
|
||||
# latch -- run the same events through no latch at all; the count must differ
|
||||
# godot -- NOT CONTROLLABLE HERE, and said so rather than faked. These assert
|
||||
# what Godot itself binds. There is nothing of ours to remove; they
|
||||
# exist to make a future Godot dropping the d-pad a failing check
|
||||
# instead of a bug report.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-$(git rev-parse --show-toplevel)}"
|
||||
GODOT="${GODOT:-godot}"
|
||||
mode="assert"
|
||||
[ "${1:-}" = "--control" ] && mode="control"
|
||||
|
||||
probe="port/.verify-input-probe.gd"
|
||||
trap 'rm -f "$probe" "${probe}.uid"' EXIT INT TERM
|
||||
|
||||
cat > "$probe" <<'GD'
|
||||
extends SceneTree
|
||||
|
||||
var mode := OS.get_environment("VERIFY_INPUT_MODE")
|
||||
var fail := 0
|
||||
var ran := 0
|
||||
|
||||
## `subject` is what the check depends on, and decides whether the control
|
||||
## removes it. A check whose subject cannot be removed is skipped there and
|
||||
## counted, not silently dropped -- a control that quietly tests four of nine
|
||||
## things reports the same green line as one that tests all nine.
|
||||
func ok(name: String, subject: String, cond: bool, detail: String = "", control_row: String = "the stick row (6 -> 1)") -> void:
|
||||
if mode == "control" and subject == "godot":
|
||||
print(" %-44s -- not controllable (Godot's own binding)" % name)
|
||||
return
|
||||
if mode == "control" and subject == "negative":
|
||||
# 🔴 R4: a NEGATIVE carries a positive control, it does not carry an
|
||||
# inversion. "The latch must not touch buttons" cannot be controlled by
|
||||
# removing the latch -- with no latch, buttons pass, which is the same
|
||||
# answer. What shows the method has power is that the SAME counter, on
|
||||
# the same code path, reduces 6 stick events to 1. That row is the
|
||||
# positive control for this one, and naming it is the honest move;
|
||||
# inverting it would have been a green line that meant nothing.
|
||||
# 🔴 The control row was HARDCODED here and a second negative arrived.
|
||||
# A negative that names someone else's control is not controlled; it is
|
||||
# borrowing a green line. `control_row` now defaults to the original
|
||||
# text so that row is unchanged, and any new negative must say what
|
||||
# actually backs it.
|
||||
print(" %-44s -- negative; positive control is %s" % [name, control_row])
|
||||
return
|
||||
ran += 1
|
||||
var want: bool = cond if mode != "control" else not cond
|
||||
print(" %-44s %s%s" % [name, "ok" if want else "🔴 FAILED",
|
||||
(" " + detail) if detail != "" else ""])
|
||||
if not want:
|
||||
fail = 1
|
||||
|
||||
func has_button(action: String, button: int) -> bool:
|
||||
for e in InputMap.action_get_events(action):
|
||||
if e is InputEventJoypadButton and e.button_index == button:
|
||||
return true
|
||||
return false
|
||||
|
||||
## Feed a run of axis values through a latch (or through none) and count the
|
||||
## presses it would produce.
|
||||
func steps(values: Array, latched: bool) -> int:
|
||||
var pad := Gamepad.new()
|
||||
var n := 0
|
||||
for v: float in values:
|
||||
var e := InputEventJoypadMotion.new()
|
||||
e.axis = JOY_AXIS_LEFT_Y
|
||||
e.axis_value = v
|
||||
# No latch = what the port did before: every event above the action
|
||||
# deadzone is a press. That is the bug, reproduced, as the control.
|
||||
if pad.accepts(e) if latched else absf(v) >= Gamepad.ENTER:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
## The latch as the port actually uses it -- and REMOVED under `--control`, so
|
||||
## the rows that depend on it invert.
|
||||
func nav(values: Array) -> int:
|
||||
return steps(values, mode != "control")
|
||||
|
||||
func _init() -> void:
|
||||
# The control removes the repair. Everything else runs with it applied.
|
||||
if mode != "control":
|
||||
Gamepad.bind_missing()
|
||||
|
||||
# ── 1. subject `bind` -- the two actions Godot leaves unbound ─────────────
|
||||
ok("Ⓐ reaches ui_accept", "bind", has_button("ui_accept", JOY_BUTTON_A),
|
||||
"JOY_BUTTON_A")
|
||||
ok("Ⓑ reaches ui_cancel", "bind", has_button("ui_cancel", JOY_BUTTON_B),
|
||||
"JOY_BUTTON_B")
|
||||
|
||||
# ── 2. subject `godot` -- what the engine binds, and must keep binding ────
|
||||
#
|
||||
# The keyboard events must SURVIVE the fixup: declaring `ui_accept` in
|
||||
# project.godot would have replaced the built-in wholesale and dropped them
|
||||
# silently. Adding to the action must not.
|
||||
var keys := 0
|
||||
for e in InputMap.action_get_events("ui_accept"):
|
||||
if e is InputEventKey:
|
||||
keys += 1
|
||||
ok("ui_accept keeps its keyboard events", "godot", keys >= 2,
|
||||
"%d key event(s)" % keys)
|
||||
ok("d-pad reaches ui_down", "godot", has_button("ui_down", JOY_BUTTON_DPAD_DOWN))
|
||||
var axis := false
|
||||
for e in InputMap.action_get_events("ui_down"):
|
||||
if e is InputEventJoypadMotion and e.axis == JOY_AXIS_LEFT_Y:
|
||||
axis = true
|
||||
ok("left stick reaches ui_down", "godot", axis, "axis %d" % JOY_AXIS_LEFT_Y)
|
||||
|
||||
# ── 3. subject `latch` -- one step per deflection, not one per jitter ─────
|
||||
#
|
||||
# A push to full deflection followed by jitter that never returns to
|
||||
# neutral: what a real stick emits, and what produced "moves the cursor too
|
||||
# fast". The control runs the identical values with no latch and must count
|
||||
# every one of them, which is what makes this a discriminator rather than a
|
||||
# number that happens to be 1.
|
||||
var held := [0.92, 0.95, 0.91, 0.99, 0.93, 0.97]
|
||||
# `nav()` is the latch under control: in `--control` the latch is REMOVED,
|
||||
# which is what makes these rows invert. Reading `steps(..., true)` in both
|
||||
# modes was the earlier defect -- the control ran the repaired code and then
|
||||
# demanded it fail.
|
||||
ok("a held stick is ONE step, not six", "latch", nav(held) == 1,
|
||||
"latched %d, unlatched %d" % [steps(held, true), steps(held, false)])
|
||||
|
||||
# Release, then push again: that IS a second press, or the stick becomes
|
||||
# single-use.
|
||||
ok("release then push is a second step", "latch",
|
||||
nav([0.92, 0.95, 0.10, 0.88]) == 2,
|
||||
"%d step(s)" % nav([0.92, 0.95, 0.10, 0.88]))
|
||||
|
||||
# Hysteresis: drifting back only as far as the release threshold must not
|
||||
# re-arm, or a stick resting near the boundary chatters -- the original bug
|
||||
# with a smaller number.
|
||||
ok("boundary drift does not re-arm", "latch",
|
||||
nav([0.9, 0.45, 0.9, 0.45, 0.9]) == 1,
|
||||
"%d step(s)" % nav([0.9, 0.45, 0.9, 0.45, 0.9]))
|
||||
|
||||
# ✅ THE GAME'''S OWN THRESHOLD, ASSERTED AT THE DEVICE LEVEL. The game
|
||||
# digitises the stick to four direction bits at 61 % deflection, so a
|
||||
# deflection between Godot'''s 0.50 action deadzone and that 0.61 is a
|
||||
# direction the real game never sees. At the old ENTER = 0.5 this port
|
||||
# stepped there. Negative first, then the positive control on the SAME run
|
||||
# shape -- a negative alone would also pass if the latch were simply broken.
|
||||
# 🔴 THIS ROW WAS "latch" AND THE CONTROL CAUGHT IT IMMEDIATELY. Removing
|
||||
# the latch does not remove the THRESHOLD -- the unlatched path also tests
|
||||
# `>= Gamepad.ENTER`, so 0.55 counts 0 either way and the row could never
|
||||
# invert. The harness said so in one run: "a check did not invert -- it is
|
||||
# not testing what it claims to test". It is a negative, and its positive
|
||||
# control is the row below it: the same shape at 0.70 does step.
|
||||
ok("0.55 is below the game 61 % threshold, must not step", "negative",
|
||||
nav([0.55, 0.55, 0.55]) == 0,
|
||||
"%d step(s)" % nav([0.55, 0.55, 0.55]),
|
||||
"the 0.70 row on the same shape")
|
||||
ok("...and its control: 0.70 on the same shape DOES step", "latch",
|
||||
nav([0.70, 0.70, 0.70]) == 1,
|
||||
"%d step(s)" % nav([0.70, 0.70, 0.70]))
|
||||
|
||||
# A button already IS an edge; latching it would swallow the second of two
|
||||
# quick taps.
|
||||
var pad := Gamepad.new()
|
||||
var passed := 0
|
||||
for i in 3:
|
||||
var b := InputEventJoypadButton.new()
|
||||
b.button_index = JOY_BUTTON_DPAD_DOWN
|
||||
b.pressed = true
|
||||
if pad.accepts(b):
|
||||
passed += 1
|
||||
ok("d-pad presses are not latched", "negative", passed == 3, "%d of 3" % passed)
|
||||
|
||||
if ran == 0:
|
||||
print("🔴 no check ran -- the harness asserted nothing")
|
||||
quit(2)
|
||||
quit(fail)
|
||||
GD
|
||||
|
||||
out=$(VERIFY_INPUT_MODE="$mode" "$GODOT" --headless --path port \
|
||||
--script "res://$(basename "$probe")" 2>&1 \
|
||||
| grep -v "^Godot Engine\|^$" || true)
|
||||
rc=0
|
||||
printf '%s' "$out" | grep -q "🔴" && rc=1
|
||||
|
||||
if [ "$mode" = "control" ]; then
|
||||
echo "control -- each check must fail when ITS OWN subject is removed:"
|
||||
printf '%s\n' "$out"
|
||||
echo
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo "every controllable check fails without its subject -- the control holds"
|
||||
exit 0
|
||||
fi
|
||||
echo "🔴 a check did not invert -- it is not testing what it claims to test"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "input map and stick latch:"
|
||||
printf '%s\n' "$out"
|
||||
echo
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo "Ⓐ and Ⓑ reach the game, and a held stick is one step"
|
||||
exit 0
|
||||
fi
|
||||
echo "🔴 the input map is not what the port needs"
|
||||
exit 1
|
||||
246
tools/port/verify-menu-audio
Executable file
246
tools/port/verify-menu-audio
Executable file
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env bash
|
||||
# Does the port actually MAKE SOUND on the P5 walk, and the RIGHT sound?
|
||||
#
|
||||
# tools/port/verify-menu-audio # assert
|
||||
# tools/port/verify-menu-audio --control # can it fail?
|
||||
#
|
||||
# 🔴 FOR WEEKS THIS COULD NOT FAIL. It computed the verdict, printed a red line
|
||||
# when a cue was silent -- and the python had NO EXIT PATH, so it returned 0
|
||||
# every time while `check-all` registered it `must-pass`. A cue could stop
|
||||
# sounding and the suite would print the failure and stay green.
|
||||
#
|
||||
# That is this project's recurring defect one level up: not an instrument that
|
||||
# sits below the thing under test, but an instrument that SEES the failure and
|
||||
# does not report it. Ask of any check: what would this still report if the
|
||||
# feature were absent -- AND what would it EXIT?
|
||||
#
|
||||
# This is the P6 gate check. P6's gate is "sound on the P5 gate", and until this
|
||||
# existed the only evidence for it was that `audio.play("move")` appears in
|
||||
# boot.gd -- which is evidence that a call is written, not that a sound reaches
|
||||
# the Master bus. Those differ: the black hold was implemented, called, and
|
||||
# emitted nothing for five milestones.
|
||||
#
|
||||
# It needs NO SOUND CARD. Godot records the Master bus to a WAV under the Dummy
|
||||
# driver (docs/port/AUDIO-VERIFICATION.md section 2).
|
||||
#
|
||||
# WHAT IT CONCLUDES, and what it must not be read as:
|
||||
#
|
||||
# * ✅ that a cue REACHES THE BUS when a press does something;
|
||||
# * ✅ that a press bound to NOTHING is silent, byte for byte;
|
||||
# * ✅ that two presses of the same action play the SAME cue;
|
||||
# * 🔴 NOT that the cue is the one the GAME plays. That binding is HANDOFF Q8,
|
||||
# measured by the Decoder, and nothing here re-measures it. This tool cannot
|
||||
# tell a correct cue from a confidently wrong one.
|
||||
#
|
||||
# ⚠️ Cue LENGTH is deliberately not asserted. The audible part of a cue is much
|
||||
# shorter than its wave -- the music bed masks the tail -- so "elevated for
|
||||
# 0.13 s" is a fact about the bed, not about the cue, and an assertion built on
|
||||
# it would fail whenever the bed changes.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
export DISPLAY="${DISPLAY:-:97}"
|
||||
OUT="${OUT:-${TMPDIR:-/tmp}/verify-menu-audio}"
|
||||
CONTROL=0; [ "${1:-}" = "--control" ] && CONTROL=1
|
||||
mkdir -p "$OUT"
|
||||
[ -d port/.godot ] || godot --headless --path port --import >/dev/null 2>&1
|
||||
|
||||
run() { # name, script
|
||||
timeout 300 godot --path port --resolution 1280x720 -- \
|
||||
--menu=main_menu "--script=$2" "--audio=$OUT/$1.wav" >"$OUT/$1.log" 2>&1 || true
|
||||
[ -s "$OUT/$1.wav" ] || { echo "no audio written for $1 -- see $OUT/$1.log" >&2; exit 2; }
|
||||
}
|
||||
|
||||
# THE WALK, and TWO CONTROLS. The controls are the point: a run that makes noise
|
||||
# proves nothing on its own, because the music bed makes noise too.
|
||||
#
|
||||
# `wait` -- the bed alone, nothing pressed.
|
||||
# `left` -- five presses that REACH _unhandled_input and are bound to nothing
|
||||
# (HANDOFF Q5: left/right do nothing). If these differ from `wait`,
|
||||
# the port is making a sound the game does not.
|
||||
run walk down,down,accept,cancel,up
|
||||
run ctrl wait,wait,wait,wait,wait
|
||||
run noop left,left,left,left,left
|
||||
|
||||
# 🔴 AND A PER-CUE KNOWN NEGATIVE, because the bed-only control could not settle
|
||||
# what it was being asked. `move` reported NOT FOUND on three consecutive runs at
|
||||
# margins 0.109/0.120/0.131 against a 0.15 line that a documented earlier run had
|
||||
# cleared at 0.185. Two readings fit that -- the cue stopped playing, or the
|
||||
# threshold sits above the quietest cue's true signal -- and A MARGIN CANNOT
|
||||
# SEPARATE THEM, because both produce a small number.
|
||||
#
|
||||
# So each cue now gets its own negative: the SAME walk, with only that cue's .ogg
|
||||
# replaced by silence through the mod tree. Silencing a cue that is playing must
|
||||
# collapse its correlation and leave the other two alone, which is a 3x3 matrix
|
||||
# with six off-diagonal controls rather than one number to compare against a
|
||||
# threshold.
|
||||
for c in move confirm back; do
|
||||
d="$OUT/sup_$c"; mkdir -p "$d/audio/se"
|
||||
ffmpeg -v error -f lavfi -i anullsrc=r=44100:cl=stereo \
|
||||
-t "$(ffprobe -v error -show_entries format=duration -of csv=p=0 export/audio/se/$c.ogg)" \
|
||||
-c:a libvorbis "$d/audio/se/$c.ogg" -y
|
||||
SYLPHEED_MODS="$d" run "sup_$c" down,down,accept,cancel,up
|
||||
grep -q "^mod: audio/se/$c.ogg" "$OUT/sup_$c.log" || {
|
||||
echo "the $c override was never read -- the matrix below would be meaningless" >&2
|
||||
exit 2; }
|
||||
done
|
||||
|
||||
# THE CONTROL. Replace the walk with the run that already had `move` silenced, so
|
||||
# the cue is genuinely missing from the baseline. Silencing it again can then
|
||||
# remove nothing, the diagonal cannot drop, and the check MUST fail. Built from
|
||||
# the tool's OWN suppression machinery rather than a second mechanism -- a
|
||||
# control built a different way tests the control, not the check.
|
||||
if [ $CONTROL -eq 1 ]; then
|
||||
cp "$OUT/sup_move.wav" "$OUT/walk.wav"
|
||||
echo "control: analysing a walk in which \`move\` never sounded"
|
||||
fi
|
||||
|
||||
rc=0
|
||||
python3 - "$OUT" <<'PYEOF' || rc=$?
|
||||
import array, math, subprocess, sys
|
||||
O = sys.argv[1]; SR = 44100
|
||||
def dec(src, dst):
|
||||
subprocess.run(["ffmpeg","-v","error","-i",src,"-f","s16le","-ac","1",
|
||||
"-ar",str(SR),dst,"-y"], check=True)
|
||||
a = array.array('h'); a.frombytes(open(dst,'rb').read()); return a
|
||||
walk = dec(f"{O}/walk.wav", f"{O}/walk.raw")
|
||||
ctrl = dec(f"{O}/ctrl.wav", f"{O}/ctrl.raw")
|
||||
noop = dec(f"{O}/noop.wav", f"{O}/noop.raw")
|
||||
|
||||
# 1. A press bound to nothing must be SILENT, and silent still means IDENTICAL --
|
||||
# but aligned to a WHOLE AUDIO BUFFER, because the recording is not
|
||||
# sample-deterministic across runs and never was.
|
||||
#
|
||||
# 🔴 This check compared the two byte streams directly and passed for weeks.
|
||||
# It then began failing, and the cause is not the port: three IDENTICAL
|
||||
# invocations produce two distinct outcomes, 1.207438 s and 1.300317 s,
|
||||
# differing by 0.092879 s = **exactly 4096 samples**, one mixing buffer. The
|
||||
# recording quantises to whole buffers and a one-buffer shift moves both the
|
||||
# length and the alignment of everything inside it.
|
||||
#
|
||||
# So the old premise -- cross-run bit-determinism -- was never guaranteed. It
|
||||
# held while the run's timing sat away from a buffer boundary, and a larger
|
||||
# export (three voice streams instead of one) moved it onto one. A test that
|
||||
# passes by luck reports the luck running out as a regression in the code.
|
||||
#
|
||||
# The fix keeps the strength that mattered: still EXACT equality, still no
|
||||
# threshold to tune. It only allows the comparison to slide by whole buffers,
|
||||
# which is the one degree of freedom the recorder actually has.
|
||||
BUF = 4096
|
||||
best = None
|
||||
for k in (0, BUF, -BUF, 2*BUF, -2*BUF):
|
||||
a, b = (ctrl[k:], noop) if k >= 0 else (ctrl, noop[-k:])
|
||||
n = min(len(a), len(b))
|
||||
if n < BUF:
|
||||
continue
|
||||
if a[:n].tobytes() == b[:n].tobytes():
|
||||
best = (k, n)
|
||||
break
|
||||
if best:
|
||||
print("no-op presses vs bed alone : IDENTICAL -- silent (%d samples, %+d buffer shift)"
|
||||
% (best[1], best[0] // BUF))
|
||||
else:
|
||||
n = min(len(ctrl), len(noop))
|
||||
print("no-op presses vs bed alone : DIFFER at every whole-buffer alignment "
|
||||
"-- the port sounds a dead press (%d samples)" % n)
|
||||
|
||||
# 2. Is the RIGHT CUE on the bus? Match each EXPORTED cue wave against the
|
||||
# recording by normalised cross-correlation over the whole file.
|
||||
#
|
||||
# This replaced a burst-counter that thresholded the envelope at a multiple
|
||||
# of the bed level. That counter reported 4 cues on one run and 0 on the next
|
||||
# from the SAME script, because its answer was set by two hand-picked
|
||||
# constants -- the multiple and a minimum run length -- and the bed level is
|
||||
# not constant across a run. It was nearly shipped. A tool whose headline
|
||||
# number moves with its own tuning cannot detect anything.
|
||||
#
|
||||
# This has no such constant. The cue file is its own template, the search is
|
||||
# over the whole recording, and the verdict is a MARGIN over the same
|
||||
# template matched against the bed-only control.
|
||||
def slide(tpl, hay, step=16):
|
||||
t = [float(v) for v in tpl]; bt = math.sqrt(sum(v*v for v in t))
|
||||
if bt == 0: return (0.0, 0.0)
|
||||
best = (-2.0, 0.0)
|
||||
for i in range(0, len(hay)-len(t), step):
|
||||
seg = hay[i:i+len(t)]
|
||||
bs = math.sqrt(sum(float(v)*v for v in seg))
|
||||
if bs:
|
||||
r = sum(a*float(b) for a, b in zip(t, seg))/(bt*bs)
|
||||
if r > best[0]: best = (r, i/SR)
|
||||
return best
|
||||
|
||||
found = []
|
||||
tpls = {}
|
||||
for cue in ("move", "confirm", "back"):
|
||||
tpl = dec("export/audio/se/%s.ogg" % cue, "%s/%s.raw" % (O, cue))[:int(0.15*SR)]
|
||||
tpls[cue] = tpl
|
||||
rw, tw = slide(tpl, walk)
|
||||
rc, _ = slide(tpl, ctrl)
|
||||
# 🔴 NO VERDICT ON THIS LINE ANY MORE. It used to print PRESENT/NOT FOUND on
|
||||
# `margin > 0.15`, and it called `move` NOT FOUND on three consecutive runs at
|
||||
# 0.109/0.120/0.131 while the cue was DEMONSTRABLY SOUNDING -- silencing its
|
||||
# .ogg collapses it to the bed floor. The bed-only control is a DIFFERENT RUN,
|
||||
# so its margin carries every difference between two runs; the threshold that
|
||||
# once cleared 0.185 was never a property of the cue. The number is still worth
|
||||
# printing. The verdict now comes from the suppression matrix below.
|
||||
hit = rw - rc > 0.15
|
||||
found.append((cue, tw, hit))
|
||||
print("%-8s walk r=%.3f at %5.2fs | bed-only r=%.3f | margin %+.3f"
|
||||
% (cue, rw, tw, rc, rw-rc))
|
||||
|
||||
# 3. The ORDER is the strongest evidence here and it is free: the correlator is
|
||||
# never told where to look, so three templates landing in script order --
|
||||
# move (step 1) before confirm (step 3) before back (step 4) -- is three
|
||||
# independent searches agreeing with the log.
|
||||
# 3b. THE SUPPRESSION MATRIX. Row = the cue silenced, column = the template
|
||||
# searched for. The diagonal is the only cell that should move.
|
||||
sup = {c: dec("%s/sup_%s.wav" % (O, c), "%s/sup_%s.raw" % (O, c))
|
||||
for c in ("move", "confirm", "back")}
|
||||
base = {c: slide(tpls[c], walk)[0] for c in tpls}
|
||||
print("\nsuppression matrix -- drop in r when one cue's .ogg is silenced")
|
||||
print(" " + "".join("%9s" % c for c in ("move", "confirm", "back")))
|
||||
ok = True
|
||||
for row in ("move", "confirm", "back"):
|
||||
drops = {col: base[col] - slide(tpls[col], sup[row])[0] for col in ("move", "confirm", "back")}
|
||||
print(" silence %-6s" % row + "".join("%+9.3f" % drops[c] for c in ("move", "confirm", "back")))
|
||||
if drops[row] <= 0.05:
|
||||
ok = False
|
||||
print(" 🔴 silencing %s did not remove %s -- that cue is NOT SOUNDING" % (row, row))
|
||||
print(" => %s" % ("all three cues SOUND: silencing each one collapses its own signal"
|
||||
if ok else "at least one cue is not sounding"))
|
||||
# 🔴 THE VERDICT EXITS. Everything below this line is REPORTED, not asserted, and
|
||||
# deliberately so: the no-op-silence line and the cue-order line both carry
|
||||
# DOCUMENTED cross-run instability (whole-buffer recording shifts; a 0.15 margin
|
||||
# this file's own comments show going to 0.109 on a sounding cue). Making either
|
||||
# binding would produce red on correct audio, which is how a suite gets ignored.
|
||||
# The diagonal has no threshold to drift: silencing a cue either removes its own
|
||||
# signal or it was never there.
|
||||
VERDICT_FAILED = not ok
|
||||
# 🔴 THE VERDICT IS THE DIAGONAL ONLY, and the first version of this asserted the
|
||||
# off-diagonal too -- "silencing a cue must not move the others". That failed, and
|
||||
# the material is why: `confirm` lands at 1.12 s and `back` at 1.21 s, 0.09 s apart
|
||||
# under a 0.15 s template, so the two windows OVERLAP. Silencing `confirm` raises
|
||||
# `back` by 0.468 because confirm was masking it. That is a fact about two cues the
|
||||
# game plays 90 ms apart, not a fault, and an assertion that calls it one would
|
||||
# fail forever on correct audio.
|
||||
print(" (off-diagonal is MASKING between overlapping cues, not an error --")
|
||||
print(" confirm at 1.12 s and back at 1.21 s share a 0.15 s window)")
|
||||
|
||||
times = [t for _, t, hit in found if hit]
|
||||
print("cue order vs script order : %s"
|
||||
% ("CONSISTENT" if times == sorted(times) and len(times) == 3
|
||||
else "check %s" % [(c, round(t, 2)) for c, t, _ in found]))
|
||||
raise SystemExit(1 if VERDICT_FAILED else 0)
|
||||
PYEOF
|
||||
|
||||
if [ $CONTROL -eq 1 ]; then
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo
|
||||
echo " 🔴 CONTROL FAILED -- the check passed a walk with \`move\` silenced, so it"
|
||||
echo " cannot detect a cue that stops sounding."
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
echo "the check rejects a run with a cue missing (rc=$rc)"
|
||||
exit 0
|
||||
fi
|
||||
exit $rc
|
||||
152
tools/port/verify-motion
Executable file
152
tools/port/verify-motion
Executable file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env bash
|
||||
# Does the boot ANIMATE, or does it draw the same picture very fast?
|
||||
#
|
||||
# tools/port/verify-motion # assert
|
||||
# tools/port/verify-motion --control # can it fail?
|
||||
#
|
||||
# 🔴 WHY THIS EXISTS. A human on a 140 fps GPU: *"the port does no blur
|
||||
# animation at all, the logos just switch."* Three checks this port already had
|
||||
# were green at the time, and all three were blind the same way:
|
||||
#
|
||||
# frozen sweep (`--time=`) proves the renderer CAN draw pose N. It drives the
|
||||
# clock by hand and never runs the animation.
|
||||
# settled comparison scored 0.01 % against the oracle. A screen frozen
|
||||
# 84 % of the time matches a settled reference
|
||||
# PERFECTLY -- that is what frozen means.
|
||||
# achieved-fps counter counts frames DRAWN. Drawing identical pixels 25
|
||||
# times a second scores exactly like animating.
|
||||
#
|
||||
# Every one measured throughput or a pose. **None measured CHANGE.** Same shape
|
||||
# as `InputEventAction` bypassing the input map: the instrument sat below the
|
||||
# thing that was broken, so the breakage could not appear in it.
|
||||
#
|
||||
# This films a REAL boot -- no `--time`, no pinning -- and hands it to
|
||||
# `tools/motion-census`, which measures change and nothing else.
|
||||
#
|
||||
# ⚠️ WHAT IT CANNOT DO. It is the liveness half only. A wrong ramp that moves
|
||||
# every frame passes here. Correctness stays with `verify-capture` against the
|
||||
# oracle, and the two are complementary: one screen can pass either alone.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
export DISPLAY="${DISPLAY:-:97}"
|
||||
OUT="${OUT:-${TMPDIR:-/tmp}/verify-motion}"
|
||||
INTERVAL=0.05
|
||||
|
||||
# The publisher splash declares its whole build-in over t=0..45 -- 0.75 s at
|
||||
# 60 units/s -- and then holds. So the FIRST second of the boot is where a
|
||||
# frozen build-in shows up, and it is the only window this asserts on.
|
||||
#
|
||||
# The bar is 60 % of adjacent frame-pairs moving in that window, and BOTH SIDES
|
||||
# WERE MEASURED rather than one measured and one assumed -- the one operator in
|
||||
# `ScreenView.pose_at` was reverted, this check run against the defect, and the
|
||||
# operator restored:
|
||||
#
|
||||
# broken (pose_at ASSIGNED the settle instant) 40 % -- and it FAILED
|
||||
# fixed (clamps to it) 86 % -- and it passed
|
||||
#
|
||||
# 60 sits mid-gap: 20 points above the defect, 26 below the fix. That is why it
|
||||
# is a floor and not a tuned threshold, and it is deliberately NOT set near the
|
||||
# passing value -- a check that only passes at exactly today's number fails on
|
||||
# the next legitimate change and teaches people to edit the bar.
|
||||
#
|
||||
# 🔴 THE FIRST VERSION CLAIMED "~40 POINTS OF CLEARANCE ON BOTH SIDES" AND HAD
|
||||
# NOT MEASURED THE BROKEN CASE. With a 1.0 s window the real clearance was 5
|
||||
# points, because that window includes 0.25 s of legitimate hold and dilutes the
|
||||
# signal. The window is now the DECLARED build-in -- publisher t=0..45, 0.75 s
|
||||
# at 60 units/s -- so it asks about the interval the disc says is animating and
|
||||
# nothing else. A bar justified by an unmeasured number is the same defect this
|
||||
# whole check exists to catch, one level up.
|
||||
WINDOW=0.75
|
||||
BAR=60
|
||||
|
||||
films() { # $1 = dir
|
||||
rm -rf "$1"; mkdir -p "$1"
|
||||
timeout 120 godot --path port -- --boot --skip-at=1 \
|
||||
--film="$1/f" --film-interval="$INTERVAL" >"$1/boot.log" 2>&1 || true
|
||||
}
|
||||
|
||||
moving_pct_in_window() { # $1 = dir -- % of adjacent pairs that MOVED, first $WINDOW seconds
|
||||
python3 - "$1" "$INTERVAL" "$WINDOW" <<'PY'
|
||||
import sys, glob, os, importlib.util, importlib.machinery
|
||||
d, interval, window = sys.argv[1], float(sys.argv[2]), float(sys.argv[3])
|
||||
frames = sorted(glob.glob(os.path.join(d, "f_*.png")))
|
||||
n = int(window / interval) + 1
|
||||
frames = frames[:n]
|
||||
if len(frames) < 3:
|
||||
print("0"); raise SystemExit
|
||||
# Reuse motion-census's own loader and floor rather than re-deriving them: a
|
||||
# second implementation of "did it move" is a second thing to be wrong.
|
||||
sys.path.insert(0, os.path.join(os.environ.get("PROJECT_DIR", "/work"), "tools"))
|
||||
spec = importlib.util.spec_from_loader(
|
||||
"mc", importlib.machinery.SourceFileLoader(
|
||||
"mc", os.path.join(os.environ.get("PROJECT_DIR", "/work"), "tools", "motion-census")))
|
||||
mc = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mc)
|
||||
from pathlib import Path
|
||||
prev, moved, total = None, 0, 0
|
||||
for f in frames:
|
||||
cur = mc.load(Path(f))
|
||||
if prev is not None:
|
||||
delta = sum(abs(a - b) for a, b in zip(cur, prev)) / len(cur)
|
||||
total += 1
|
||||
if delta > mc.MOVED:
|
||||
moved += 1
|
||||
prev = cur
|
||||
print("%d" % (100 * moved / total if total else 0))
|
||||
PY
|
||||
}
|
||||
|
||||
echo "boot liveness: films a real boot and measures CHANGE, not throughput"
|
||||
|
||||
# 🔴 THE CONTROL RUNS FIRST AND IS NOT OPTIONAL. `motion-census --selftest`
|
||||
# drives a synthetic fade, a switch and a frozen film through the same loader
|
||||
# and the same floor this check uses. If it cannot separate those three, every
|
||||
# number below is decoration.
|
||||
if ! tools/motion-census --selftest >"$OUT.selftest.log" 2>&1; then
|
||||
echo " 🔴 motion-census --selftest FAILED -- the detector cannot tell a fade"
|
||||
echo " from a switch, so nothing it reports about the boot means anything."
|
||||
sed 's/^/ /' "$OUT.selftest.log"
|
||||
exit 2
|
||||
fi
|
||||
echo " census selftest ok (fade / switch / frozen separated)"
|
||||
|
||||
if [ "${1:-}" = "--control" ]; then
|
||||
# A frozen film must FAIL this check. Built by repeating one real boot frame,
|
||||
# so it has the port's own pixels and differs from a passing run in exactly
|
||||
# one property: nothing changes.
|
||||
films "$OUT/live"
|
||||
ctl="$OUT/frozen"; rm -rf "$ctl"; mkdir -p "$ctl"
|
||||
# NOT `ls | head`: under `set -o pipefail` head closes the pipe, ls takes
|
||||
# SIGPIPE and the script exits 141 before it ever asserts anything. Cost one
|
||||
# run to notice, and a check that dies before checking looks a lot like a
|
||||
# check that passed.
|
||||
local_frames=("$OUT"/live/f_*.png)
|
||||
src="${local_frames[0]}"
|
||||
for i in $(seq -w 0 24); do cp "$src" "$ctl/f_0$i.png"; done
|
||||
pct=$(moving_pct_in_window "$ctl")
|
||||
if [ "$pct" -lt "$BAR" ]; then
|
||||
echo " frozen film is REJECTED ok ${pct}% moving, bar ${BAR}%"
|
||||
echo
|
||||
echo "the check fails on a film that does not move"
|
||||
exit 0
|
||||
fi
|
||||
echo " frozen film is REJECTED 🔴 FAILED ${pct}% moving -- it passed, so"
|
||||
echo " this check cannot detect the defect it was written for."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
films "$OUT/live"
|
||||
grep -m1 -E "fps achieved" "$OUT/live/boot.log" | sed 's/^ */ /' || true
|
||||
pct=$(moving_pct_in_window "$OUT/live")
|
||||
printf ' %-25s %s %d%% of pairs moved in the first %.1fs, bar %d%%\n' \
|
||||
"build-in moves" "$([ "$pct" -ge "$BAR" ] && echo ok || echo '🔴 FAILED')" \
|
||||
"$pct" "$WINDOW" "$BAR"
|
||||
[ "$pct" -ge "$BAR" ] || {
|
||||
echo
|
||||
echo "🔴 the boot draws its first second without changing. That is the"
|
||||
echo " 2026-09-02 defect: poses not advancing while the clock does."
|
||||
echo " Films are in $OUT/live -- run tools/motion-census on them."
|
||||
exit 1
|
||||
}
|
||||
echo
|
||||
echo "the boot's build-in animates"
|
||||
@@ -15,12 +15,26 @@
|
||||
# both), scale-0, and rest(). Each time the capture caught it and neither
|
||||
# renderer could have.
|
||||
#
|
||||
# 🔴 AND ITS FRAMES MUST NEVER BE SCORED AGAINST A CAPTURE. This script poses
|
||||
# `--pose=rest`, deliberately -- both renderers read `rest` through the same
|
||||
# decoder, which is what makes it a test of the PORT against the REFERENCE. It
|
||||
# is NOT the pose the port ships, and on some screens the two are very far
|
||||
# apart: `rest` for each `ptlogo_back2eff*` sparkle is the peak of its own
|
||||
# 4-unit flash, so `--pose=rest` lights all of them at once, a frame the game
|
||||
# never shows.
|
||||
#
|
||||
# I scored this script's `title_jp` frame against the oracle capture and
|
||||
# concluded the port had drifted away from the game -- r +0.7462 against the
|
||||
# reference's +0.8727. Posed as it SHIPS, the same block scores **+0.9994**.
|
||||
# The conclusion was an artefact of the pose, and it was written up as a finding.
|
||||
# Correctness questions go to `tools/port/verify-capture`, which poses as shipped.
|
||||
#
|
||||
# So: a DIFFERS row means "we moved apart, go find out which of us moved". It
|
||||
# does not mean the port is wrong. Where a capture and this tool disagree, the
|
||||
# capture wins. Use `tools/verify-capture` for the correctness question.
|
||||
# capture wins. Use `tools/port/verify-capture` for the correctness question.
|
||||
#
|
||||
# tools/verify-screen # every screen in the manifest
|
||||
# tools/verify-screen main_menu title # named screens
|
||||
# tools/port/verify-screen # every screen in the manifest
|
||||
# tools/port/verify-screen main_menu title # named screens
|
||||
#
|
||||
# Writes <screen>.godot.png, <screen>.ref.png and <screen>.diff.png into
|
||||
# $OUT (default: a directory under /tmp) and prints, per screen, the largest
|
||||
@@ -35,6 +49,43 @@
|
||||
# * `--black` because Godot clears to black and the screen carries its own
|
||||
# background. The CLI's default dim slate stands in for a 3D scene behind an
|
||||
# in-mission screen, which is not this screen.
|
||||
#
|
||||
# ⚠️ THAT PREMISE IS DECLARED ON 12 OF 16 SCREENS AND ASSUMED ON 4. Audited
|
||||
# 2026-08-30: a screen "carries its own background" when it declares a
|
||||
# full-screen untextured primitive at `t=0` with `fade_argb 0xff000000` --
|
||||
# opaque black. Twelve do (`pteff00`, `palogo_eff0`, `pgloading_eff00`).
|
||||
# Four do NOT. I first called those four "composited rather than standalone" [refuted];
|
||||
# that reading is REFUTED disc-wide (see below) and what they share is only
|
||||
# that they do not begin from black:
|
||||
#
|
||||
# press_start / press_start_jp -- one element, the plate, drawn OVER the
|
||||
# title; its own `name_why` says so. The game never shows it on black.
|
||||
# build_00 / build_01 -- loading variants carrying the `pgloading_*` set
|
||||
# WITHOUT the `pgloading_eff00` backdrop that build_12/15 declare.
|
||||
#
|
||||
# ✅ Harmless HERE, because both renderers are given `--black` and the
|
||||
# assumption cancels in a consistency check. It would NOT be harmless in an
|
||||
# oracle comparison, and `verify-capture` already avoids it: the plate is
|
||||
# scored as `--screen=title --overlay=press_start`, over the title, not on
|
||||
# black.
|
||||
#
|
||||
# 📌 The audit is a rule worth having WITHIN THIS ARCHIVE, and its first
|
||||
# reading was wrong. I called it "standalone versus composited"; the Decoder
|
||||
# ran it disc-wide and it does not carry: **76 of 965 builds, 7.9 %**, with
|
||||
# `GP_HANGAR_ARSENAL` **0 of 390**, `GP_OPTIONS` 0/14, `GP_PAUSE_MENU` 0/6 --
|
||||
# screens a player plainly sees AS screens. Read as "composited", the rule
|
||||
# makes 92 % of the game composited, which the archives do not support.
|
||||
#
|
||||
# ✅ What survives is narrower: it separates **screens that begin from black**
|
||||
# from everything else. The negative class is heterogeneous -- a pause menu
|
||||
# over gameplay, a hangar over a 3D scene and a plate over a title are not the
|
||||
# same kind of thing -- which is exactly what a two-way rule cannot express.
|
||||
#
|
||||
# ⚠️ Within `GP_TITLE` it is exact and independently reproduced from the disc
|
||||
# (12/4, the four being entries 0-3). That is the only archive it is claimed
|
||||
# for. Do NOT carry it into `GP_READY_ROOM`, `GP_HANGAR_ARSENAL`,
|
||||
# `GP_MISSION_SELECT` or `GP_OPTIONS`: in three of them it classifies every
|
||||
# screen alike, so it would look like a clean answer and say nothing.
|
||||
# * `--primitives --animated` because those are what make the CLI draw the same
|
||||
# element set. `--focus` is NOT passed: nothing is focused at rest (HANDOFF
|
||||
# Q5 measured initial focus as unstable boot to boot, so choosing one is
|
||||
@@ -53,16 +104,83 @@
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
|
||||
# `reference-cli/`, not `release/`: the reference binary is built per pinned
|
||||
# revision so a pin change cannot silently reuse the previous revision's build.
|
||||
# See docker/bin/build-reference-cli.
|
||||
CLI="${SYLPHEED_CLI:-${CARGO_TARGET_DIR:-/sylph-home/port/target-container}/reference-cli/sylpheed-cli}"
|
||||
# THE REFERENCE IS THE WORKSPACE'S OWN `sylpheed-cli`, and that is a change.
|
||||
#
|
||||
# It used to be a binary built per PINNED REVISION into `reference-cli/<rev>/`,
|
||||
# because `sylpheed-formats` was a git dependency and /reborn's target/ was a
|
||||
# live mount of the other agent's checkout that moved mid-run. A pixel
|
||||
# disagreement against a moving decoder has a free variable in it.
|
||||
#
|
||||
# The monorepo merge (`65cefa7`) removed that problem by construction:
|
||||
# `crates/sylpheed-export/Cargo.toml` now says
|
||||
# `sylpheed-formats = { path = "../sylpheed-formats" }`, so the exporter, this
|
||||
# reference and the port all read ONE decoder -- the working tree's.
|
||||
#
|
||||
# 🔴 It also silently broke the old machinery, and this script did not notice.
|
||||
# `build-reference-cli` greps Cargo.toml for `Syplheed-Reborn.git", rev = "..."`;
|
||||
# that line no longer exists, so the script exits 1 and the binary at
|
||||
# `reference-cli/sylpheed-cli` is whatever was last built before the merge --
|
||||
# here, three hours older than the sources and from a revision nothing points
|
||||
# at any more. Running the diff against it would have compared the port to a
|
||||
# decoder from another era and called the result a regression check. This
|
||||
# corpus has already been bitten by a stale reference renderer three times.
|
||||
#
|
||||
# So: build it from the workspace. `SYLPHEED_CLI` still overrides, for anyone
|
||||
# who does want to pin one deliberately.
|
||||
CLI="${SYLPHEED_CLI:-}"
|
||||
if [ -z "$CLI" ]; then
|
||||
CLI="${CARGO_TARGET_DIR:-/sylph-home/port/target-container}/release/sylpheed-cli"
|
||||
cargo build --release -p sylpheed-cli >/dev/null 2>&1 || true
|
||||
fi
|
||||
DISC="${SYLPHEED_DISC:-/disc}"
|
||||
OUT="${OUT:-${TMPDIR:-/tmp}/verify-screen}"
|
||||
export DISPLAY="${DISPLAY:-:97}"
|
||||
|
||||
[ -x "$CLI" ] || { echo "no reference CLI at $CLI -- run build-reference-cli" >&2; exit 2; }
|
||||
[ -x "$CLI" ] || { echo "no reference CLI at $CLI -- \`cargo build --release -p sylpheed-cli\` failed?" >&2; exit 2; }
|
||||
[ -f export/manifest.json ] || { echo "no export/manifest.json -- run build-export --run" >&2; exit 2; }
|
||||
|
||||
# 🔴 THE REFERENCE BINARY IS NOT NECESSARILY THE ONE THIS SCRIPT BUILT.
|
||||
#
|
||||
# `CARGO_TARGET_DIR` is a SHARED `/sylph-home/port/target-container`. Two source
|
||||
# trees -- this workspace and any worktree built with the same variable set --
|
||||
# write one `release/sylpheed-cli`, and cargo fingerprints per source path, so
|
||||
# each build reports "Finished" while the binary on disk belongs to whichever
|
||||
# tree wrote last. `cargo build` here returns in 0.15 s and changes nothing.
|
||||
#
|
||||
# That is the hazard the header above says the monorepo removed. It did not; the
|
||||
# shared target dir reintroduced it by another route. Measured 2026-08-30: a CLI
|
||||
# built from this workspace is `rest t=70` (the stale record layout) while the
|
||||
# binary actually sitting in the target dir was `rest t=12` (fixed) -- so this
|
||||
# script was comparing the port against a decoder from a tree nobody had named.
|
||||
#
|
||||
# ⚠️ It happened to be the RIGHT era, which is worse than wrong: it agreed with
|
||||
# the exporter's pin by luck, and one successful rebuild would have flipped it
|
||||
# silently. `title_jp` differs by 74 507 px between the two eras.
|
||||
#
|
||||
# So the era is CHECKED, against the export the port actually reads, rather than
|
||||
# assumed from having run `cargo build`.
|
||||
ref_rest=$("$CLI" screen info "$DISC/dat/GP_TITLE.pak" --build 5 --all 2>/dev/null \
|
||||
| grep -i 'pteff00' | head -1 | sed -n 's/.*rest (0,0) t=\([0-9]*\).*/\1/p')
|
||||
exp_rest=$(python3 -c '
|
||||
import json
|
||||
m=json.load(open("export/manifest.json"))
|
||||
f=next(s["file"] for s in m["screens"] if s["name"]=="main_menu")
|
||||
d=json.load(open("export/"+f))
|
||||
print(int(next(e for e in d["elements"] if e.get("id")=="pteff00")["rest"]["t"]))')
|
||||
if [ -n "$ref_rest" ] && [ "$ref_rest" != "$exp_rest" ]; then
|
||||
echo "🔴 the reference CLI and the export disagree on the decoder era:" >&2
|
||||
echo " reference $CLI says pteff00 rest t=$ref_rest" >&2
|
||||
echo " export/ (built by the pinned exporter) says rest t=$exp_rest" >&2
|
||||
echo " Every row below would compare two decoder eras. Refusing." >&2
|
||||
echo "" >&2
|
||||
echo " REMEDY, verified both directions 2026-08-30: this workspace's" >&2
|
||||
echo " ui_layout.rs is the STALE era and still carries the retired" >&2
|
||||
echo " SYLPHEED_KF_TIME_SHIFT knob, which converts it to the corrected" >&2
|
||||
echo " reading. Re-run with SYLPHEED_KF_TIME_SHIFT=1 and the reference" >&2
|
||||
echo " reports rest t=12, matching the pinned exporter; without it, t=70." >&2
|
||||
echo " The knob is absent from the pinned tag, so it cannot affect export/." >&2
|
||||
exit 2
|
||||
fi
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# Godot needs one scan to register the `class_name` globals; without it every
|
||||
@@ -90,20 +208,127 @@ print(json.load(open("export/"+f))["source"]["build"])' "$name")
|
||||
"$CLI" screen render "$DISC/dat/GP_TITLE.pak" "$OUT/$name.ref.png" \
|
||||
--build "$build" --all --black --primitives --animated >/dev/null
|
||||
|
||||
# 🔴 THE REFERENCE RENDERER SILENTLY OMITS A `.tbm` BACKGROUND.
|
||||
#
|
||||
# The Decoder reached and captured the TUTORIAL screen and found that
|
||||
# `screen render` draws every OTHER element of a `.tbm`-bearing build and
|
||||
# leaves the background out, with no diagnostic: their render of GP_TUTORIAL
|
||||
# build 0 is the correct layout on pure black, 6.0-6.4 % inked against the
|
||||
# game's 99.7 %. `docs/re/structures/tbm-submenu-not-reached.md`, their branch.
|
||||
#
|
||||
# I confirmed the shape of it here with both controls: `screen info` reports
|
||||
# `pubase.tbm` on GP_TUTORIAL build 0 and no `.tbm` on any of the 16 builds in
|
||||
# my manifest. So this trap CANNOT fire today.
|
||||
#
|
||||
# ⚠️ That is a fact about today's manifest, not a property of this script, and
|
||||
# the failure it would cause is the expensive kind: the port draws a
|
||||
# background the reference does not, the row reads DIFFERS, and the header
|
||||
# above tells the reader to go find out which renderer moved. Neither did.
|
||||
# The row would be a real disagreement caused by a KNOWN omission on the
|
||||
# reference side, and nothing on screen would say so.
|
||||
#
|
||||
# So the row says so. This does not change the verdict or the bar -- it
|
||||
# attaches the provenance to the one row that would otherwise mislead.
|
||||
tbm=$("$CLI" screen info "$DISC/dat/GP_TITLE.pak" --build "$build" --all 2>/dev/null \
|
||||
| grep -ioc '\.tbm' || true)
|
||||
|
||||
# 🔴 `--loop-phase=0` PINS THE PULSE, AND WITHOUT IT THIS SCRIPT WAS
|
||||
# NONDETERMINISTIC. `press_start` returned `over3` **5021, 8919, 5021** on
|
||||
# three identical runs: the plate's looping focus record rides `time_units`,
|
||||
# so the captured frame lands wherever the grab fell, while the reference
|
||||
# renderer cannot pulse at all.
|
||||
#
|
||||
# ⚠️ The port is NOT the thing that is wrong. A thing that pulses does not
|
||||
# stop because the screen has arrived, and the pulse is measured. What was
|
||||
# wrong is comparing a moving frame against a static one and calling the
|
||||
# difference a regression -- a detector that answers differently each run
|
||||
# teaches its reader to ignore it, which is worse than one that fails.
|
||||
#
|
||||
# So the phase is pinned HERE, in the harness, and nothing about playback
|
||||
# changes: `loop_phase_units` defaults to free-running everywhere else.
|
||||
# ⚠️ It is usually stable -- 3 of 4 control runs agreed -- which is exactly
|
||||
# why this survived: it looks deterministic most of the time.
|
||||
godot --path port --resolution 1280x720 -- \
|
||||
"--screen=$name" --pose=rest "--capture=$OUT/$name.godot.png" >"$OUT/$name.log" 2>&1
|
||||
"--screen=$name" --pose=rest --loop-phase=0 "--capture=$OUT/$name.godot.png" >"$OUT/$name.log" 2>&1
|
||||
|
||||
convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \
|
||||
-compose difference -composite -colorspace Gray -auto-level "$OUT/$name.diff.png"
|
||||
read -r max mean <<<"$(convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \
|
||||
-compose difference -composite -format "%[fx:maxima*255] %[fx:mean*255]" info:)"
|
||||
|
||||
# 3/255 is what integer-truncating compositing in the CLI and float rounding
|
||||
# in a GPU differ by. Anything above that is a placement, order or colour
|
||||
# disagreement and needs a reason, not a threshold.
|
||||
# HOW MANY pixels are over the bar, not just how far the worst one is. A
|
||||
# single `max` cannot tell 2 pixels from 25 444, and this run produced both:
|
||||
# `main_menu` trips the threshold on TWO pixels out of 921 600 while
|
||||
# `title_jp` trips it on 2.8 % of the frame. Reporting only the max made those
|
||||
# the same verdict, which is how a real disagreement hides behind a rounding
|
||||
# one. The bar itself is NOT raised -- tuning a threshold until things match
|
||||
# is the failure this script's own header warns about.
|
||||
over=$(convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \
|
||||
-compose difference -composite -colorspace Gray -threshold $((3*65535/255)) \
|
||||
-format "%[fx:int(mean*w*h)]" info:)
|
||||
|
||||
# BOTH FRAMES BLANK IS NOT AGREEMENT, AND THIS SCRIPT USED TO SAY IT WAS.
|
||||
#
|
||||
# `build_12` and `build_15` -- the two dressed loading screens -- render as
|
||||
# pure black in BOTH renderers, mean 0 and max 0, so the difference is 0 and
|
||||
# the row read `max 0 over3 0 OK`. Two of the sixteen rows in the committed
|
||||
# baseline were comparing nothing against nothing and reporting the strongest
|
||||
# verdict this script has.
|
||||
#
|
||||
# That is worse than a missing test: it is a test that reports a pass. The
|
||||
# screens are black because `pgloading_eff00` is a full-frame opaque black
|
||||
# quad whose `rest.t` (38) sits inside its own opening black hold, and
|
||||
# `--pose=rest` freezes it there -- see docs/port/DECISIONS.md. Whether that
|
||||
# is the port's bug or the decoders' reading of `rest` is open; what is not
|
||||
# open is that a blank pair may not be scored.
|
||||
#
|
||||
# ✅ RESOLVED 2026-08-30, AND THE PARAGRAPH ABOVE IS NOW HISTORY. It was the
|
||||
# PAINT ORDER, not `rest`. `pgloading_eff00` carries `layer: null`,
|
||||
# `layer_source: none` -- the only elements in the export with neither a read
|
||||
# nor an implied key -- so without the forced-backdrop pass the first element
|
||||
# becomes `pgloading_loop5` and the opaque quad paints over everything. With
|
||||
# the pass, both screens render at max 214.5 in BOTH renderers (mean 1.949
|
||||
# port, 1.918 reference) and the rows read `OK` on a real comparison.
|
||||
#
|
||||
# ⚠️ The guard STAYS. It is not firing today, which is exactly when a guard
|
||||
# quietly rots -- and it was right when it was written: two of sixteen rows
|
||||
# were comparing nothing against nothing and reporting this script's
|
||||
# strongest verdict. Leaving the reasoning above intact is deliberate; a
|
||||
# reader who hits a blank pair tomorrow needs it.
|
||||
#
|
||||
# So blankness is checked FIRST and reported as its own verdict. It is not a
|
||||
# failure -- the port may legitimately have nothing to draw -- but it is not a
|
||||
# pass either, and `status` is left alone so an unrelated screen's DIFFERS is
|
||||
# still what fails the run.
|
||||
ink=$(convert "$OUT/$name.godot.png" "$OUT/$name.ref.png" \
|
||||
-evaluate-sequence max -colorspace Gray -format "%[fx:maxima*255]" info:)
|
||||
verdict=OK
|
||||
awk "BEGIN{exit !($max > 3)}" && { verdict=DIFFERS; status=1; }
|
||||
printf '%-16s build %-3s max %-5s mean %-8s %s\n' "$name" "$build" "$max" "${mean:0:6}" "$verdict"
|
||||
if awk "BEGIN{exit !($ink <= 0)}"; then
|
||||
verdict="BLANK -- both renderers drew nothing; this row proves nothing"
|
||||
else
|
||||
# 🔴 THE VERDICT USES `over3`, NOT `max` ALONE, AND FOR YEARS IT DID NOT.
|
||||
#
|
||||
# This script computed `over3` precisely because "a single `max` cannot tell
|
||||
# 2 pixels from 25 444" -- its own words, a few lines up -- and then decided
|
||||
# the verdict on `max` regardless. So `main_menu` (max 4, over3 **0**) read
|
||||
# DIFFERS while `extras` (max 3, over3 0) read OK: one unit on one pixel,
|
||||
# separating two frames that are pixel-for-pixel equivalent at the bar.
|
||||
#
|
||||
# ⚠️ This is NOT raising the bar, which this file rightly warns against. The
|
||||
# bar is still 3. What changes is that a frame with NO pixel over it gets a
|
||||
# verdict of its own instead of being lumped in with a real disagreement --
|
||||
# the distinction the statistic was added to make and was never given.
|
||||
if awk "BEGIN{exit !($over > 0)}"; then
|
||||
verdict=DIFFERS; status=1
|
||||
elif awk "BEGIN{exit !($max > 3)}"; then
|
||||
verdict="ROUNDING -- max $max but NO pixel over the bar"
|
||||
fi
|
||||
fi
|
||||
if [ "${tbm:-0}" -gt 0 ]; then
|
||||
verdict="$verdict [build carries a .tbm: the REFERENCE omits that background, so a DIFFERS here is likely theirs]"
|
||||
fi
|
||||
printf '%-17s build %-3s max %-5s mean %-8s over3 %-7s %s\n' \
|
||||
"$name" "$build" "$max" "${mean:0:6}" "$over" "$verdict"
|
||||
done
|
||||
echo "artifacts in $OUT"
|
||||
exit $status
|
||||
|
||||
500
tools/port/verify-transcode-fidelity
Executable file
500
tools/port/verify-transcode-fidelity
Executable file
@@ -0,0 +1,500 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Is the transcode faithful to the source? Decode both, align, subtract.
|
||||
|
||||
`AUDIO-VERIFICATION.md` §1 states this as the question P4 actually raised and
|
||||
gives the method, and nothing implemented it. `verify-video-audio` deliberately
|
||||
does not: it proves Godot emits non-silence and says in as many words that a
|
||||
difference RMS without alignment is meaningless. So the gate has rested on level
|
||||
and non-silence, and the fidelity claim has never been made.
|
||||
|
||||
The doc names three ways the measurement lies, and all three are handled here
|
||||
rather than hoped about:
|
||||
|
||||
ALIGNMENT a one-sample offset makes the difference nearly as loud as the
|
||||
source. Cross-correlated coarse-to-fine BEFORE subtracting, and
|
||||
the search REFUSES when its best lag sits on the boundary --
|
||||
printing the range beside the answer, so an edge reads as an
|
||||
edge.
|
||||
CHANNEL LAYOUT the source is 5.1 and the transcode is stereo. The source is
|
||||
folded with `video.rs`'s own `DOWNMIX_51` -- read out of the
|
||||
manifest's recorded command, not restated here -- so both sides
|
||||
are the same fold.
|
||||
A PARTIAL FILE `ffprobe` once reported 33 s for a 137 s transcode because the
|
||||
encode was still running. Duration and mtime are checked, and a
|
||||
file written in the last 60 s is refused.
|
||||
THE SEEK `-ss` before `-i` returned 4.6 s of AUDIO for a 4.0 s request on
|
||||
this WMA Pro source, so the two windows covered different audio.
|
||||
Not in §1. ⚠️ NARROWED after the Decoder checked it: on this
|
||||
disc the VIDEO container-seek is EXACT -- a frame taken at 20 s
|
||||
via container seek is byte-identical to one from a full decode.
|
||||
So it is a property of the AUDIO STREAM, not of `-ss` placement
|
||||
as such, and a check that only looked at video would clear a
|
||||
path still unsafe for audio.
|
||||
|
||||
🔴 AND IT RUNS ITS OWN KNOWN NEGATIVES. A fidelity check that has only ever
|
||||
returned "faithful" is the unfalsifiable clean run this project keeps finding:
|
||||
`--control` compares the source against itself (must be near-perfect) and against
|
||||
the OTHER movie (must be near 0 dB down).
|
||||
|
||||
⚠️ **REPORT ONLY. THIS DOES NOT YET PRODUCE A VERDICT**, and it is committed in
|
||||
that state deliberately. It has reproduced four distinct ways the measurement
|
||||
lies -- three that §1 names and one it does not -- and each was found by a
|
||||
diagnostic rather than by reasoning. It still reports the difference signal
|
||||
LOUDER than the source, which cannot be true of two aligned signals at equal
|
||||
level, so the remaining fault is on this side of the instrument.
|
||||
|
||||
A tool that says "not faithful" while its own alignment is broken would be worse
|
||||
than no tool: it would put a false defect on the exporter. Committed so the next
|
||||
iteration starts from four known traps instead of from four lines of shell.
|
||||
"""
|
||||
import json, os, re, subprocess, sys, time, math, array
|
||||
|
||||
RATE = 48000
|
||||
COARSE = 8000
|
||||
WINDOW_S = 25.0
|
||||
PASS_DB = 40.0
|
||||
# Per-band tolerance. Both shipped transcodes sit at 0.29 and 0.66 dB worst-case
|
||||
# across four bands, and the unrelated-movie control lands an order of magnitude
|
||||
# out, so this is set between two measured populations rather than picked.
|
||||
PASS_BAND_DB = 1.5
|
||||
|
||||
|
||||
def sh(*a):
|
||||
return subprocess.run(a, capture_output=True).stdout
|
||||
|
||||
|
||||
def pcm(path, rate, seconds, af=None, skip=0.0):
|
||||
"""Decode to mono signed-16 at `rate`, optionally through a filter chain."""
|
||||
# 🔴 `-ss` AFTER `-i`, and this is a FOURTH way the measurement lies that
|
||||
# AUDIO-VERIFICATION §1 does not list. Placed before `-i` the seek is a
|
||||
# container-level jump, and on this WMA Pro source it overshot: a 4.0 s
|
||||
# request returned 4.6 s of audio while the Ogg side returned 4.0 s. The two
|
||||
# windows then covered DIFFERENT STRETCHES OF THE MOVIE, no shift could
|
||||
# align them, and the check reported a faithful transcode as garbage --
|
||||
# normalised correlation 0.172 at its best lag.
|
||||
#
|
||||
# Decoder-side seeking is slower and exact. The failure looks identical to
|
||||
# the alignment trap the doc does name, which is why it cost a diagnostic
|
||||
# rather than a guess to tell them apart.
|
||||
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", path,
|
||||
"-ss", str(skip), "-t", str(seconds)]
|
||||
if af:
|
||||
cmd += ["-af", af + ",aformat=channel_layouts=mono"]
|
||||
else:
|
||||
cmd += ["-af", "aformat=channel_layouts=mono"]
|
||||
cmd += ["-ar", str(rate), "-f", "s16le", "-"]
|
||||
raw = sh(*cmd)
|
||||
a = array.array("h")
|
||||
a.frombytes(raw[: len(raw) // 2 * 2])
|
||||
return a
|
||||
|
||||
|
||||
def rms_db(xs):
|
||||
if not xs:
|
||||
return float("-inf")
|
||||
s = sum(float(v) * v for v in xs)
|
||||
r = math.sqrt(s / len(xs))
|
||||
return 20 * math.log10(r / 32768.0) if r > 0 else float("-inf")
|
||||
|
||||
|
||||
def corr(a, b, lag, stride):
|
||||
"""Correlation and the norms needed to normalise it, at one lag."""
|
||||
n = min(len(a), len(b)) - abs(lag)
|
||||
s = ea = eb = 0.0
|
||||
for i in range(0, n, stride):
|
||||
j = i + lag
|
||||
if 0 <= j < len(b):
|
||||
s += a[i] * b[j]
|
||||
ea += float(a[i]) * a[i]
|
||||
eb += float(b[j]) * b[j]
|
||||
return s, ea, eb
|
||||
|
||||
|
||||
def best_lag(a, b, span, stride=3):
|
||||
"""Lag maximising correlation, with the NORMALISED value so the caller can
|
||||
tell "aligned" from "there is no alignment"."""
|
||||
best = (-1e30, 0, 0.0)
|
||||
for lag in range(-span, span + 1):
|
||||
s, ea, eb = corr(a, b, lag, stride)
|
||||
if s > best[0]:
|
||||
best = (s, lag, s / math.sqrt(ea * eb) if ea > 0 and eb > 0 else 0.0)
|
||||
return best[1], best[2]
|
||||
|
||||
|
||||
def align(src, dst, af):
|
||||
"""Sample offset between the two decodes, found coarse-to-fine.
|
||||
|
||||
🔴 A SINGLE-RESOLUTION SEARCH PINNED AT ITS OWN EDGE. `ADV` returned +2413
|
||||
against a window of +/-2400 -- the answer was the boundary, not the peak,
|
||||
and the check then reported a faithful transcode as a failure. Same family
|
||||
as the Decoder's period estimator returning its own search floor: an
|
||||
instrument answering with a property of itself.
|
||||
"""
|
||||
for rate, span, stride in ((2000, 2000, 2), (8000, 60, 2)):
|
||||
a = pcm(src, rate, 8.0, af, skip=2.0)
|
||||
b = pcm(dst, rate, 8.0, None, skip=2.0)
|
||||
if not a or not b:
|
||||
return None, 0.0
|
||||
if rate == 2000:
|
||||
lag, c = best_lag(a, b, span, stride)
|
||||
if abs(lag) >= span:
|
||||
# Refuse AND say what the range was: the Decoder's cheap defence
|
||||
# is printing the search range beside the answer so a boundary
|
||||
# reads as a boundary rather than as a result.
|
||||
print(f" coarse lag {lag:+d} of a +/-{span} search at {rate} Hz"
|
||||
f" -- ON THE BOUNDARY, so this is the window's edge, not a peak")
|
||||
return None, c
|
||||
coarse = lag / rate
|
||||
else:
|
||||
centre = int(round(coarse * rate))
|
||||
sub_a, sub_b = a, b[max(0, centre):] if centre >= 0 else b
|
||||
lag, c = best_lag(sub_a, sub_b, span, stride)
|
||||
coarse += lag / rate
|
||||
return int(round(coarse * RATE)), c
|
||||
|
||||
|
||||
# 🔴 THE TOP BAND IS SPLIT BECAUSE THE NEAR-MISS CONTROL FAILED. With a single
|
||||
# 6-16 kHz band, a 6 kHz-lowpassed source -- a transcode that lost its whole top
|
||||
# end, the failure this check exists to catch -- deviated by only 2.58 dB and
|
||||
# would have PASSED. The band was wide enough to average the loss away against
|
||||
# the filter's transition region.
|
||||
#
|
||||
# ⚠️ This is changing the instrument's RESOLUTION so it can see a failure it must
|
||||
# see, driven by a control it failed. It is NOT loosening the pass threshold for
|
||||
# the real comparison, which is unchanged -- that would be tuning until the
|
||||
# answer came out right, which is the thing this project keeps catching.
|
||||
BANDS = [(0, 500), (500, 2000), (2000, 6000), (6000, 10000), (10000, 16000)]
|
||||
|
||||
# `FID_BANDS=none` empties the band list and `FID_WINDOW` shortens the analysis
|
||||
# window. Both exist ONLY so `--selftest` can drive this script as a subprocess
|
||||
# in a deliberately broken configuration and read its real exit code, rather than
|
||||
# reasoning about what it would do -- the failure I walked into on my first
|
||||
# harness self-test and the Decoder walked into on theirs.
|
||||
if os.environ.get("FID_BANDS") == "none":
|
||||
BANDS = []
|
||||
WINDOW_S = float(os.environ.get("FID_WINDOW", WINDOW_S))
|
||||
|
||||
|
||||
def band_db(path, af, lo, hi, seconds=25.0, skip=2.0):
|
||||
"""RMS in one band, straight out of `astats`.
|
||||
|
||||
🔴 A DIFFERENT KIND OF QUANTITY, and that is the whole reason it exists. The
|
||||
difference-signal method needs the two decodes aligned to the sample, and
|
||||
four attempts at that produced four different failures and no verdict. The
|
||||
Decoder's rule from their own two failed attempts: **two failed attempts at
|
||||
the same measurement are evidence the QUANTITY is wrong, not the parsing.**
|
||||
Band energy needs no alignment at all -- it is a statistic over the window,
|
||||
so a lag of any size cannot corrupt it.
|
||||
|
||||
⚠️ It is a WEAKER claim than a difference signal. Matching band energies
|
||||
cannot distinguish a faithful transcode from one that preserved the spectrum
|
||||
while mangling the waveform. It is what this instrument can honestly support,
|
||||
and it is stated as that rather than dressed up as fidelity.
|
||||
"""
|
||||
chain = [(af + "," if af else ""), "aformat=channel_layouts=mono"]
|
||||
if lo > 0:
|
||||
chain.append(",highpass=f=%d" % lo)
|
||||
if hi < 20000:
|
||||
chain.append(",lowpass=f=%d" % hi)
|
||||
chain.append(",astats=measure_perchannel=none")
|
||||
out = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-i", path, "-ss", str(skip), "-t", str(seconds),
|
||||
"-af", "".join(chain), "-f", "null", "-"],
|
||||
capture_output=True, text=True).stderr
|
||||
m = re.search(r"RMS level dB: (-?[\d.]+|-inf)", out)
|
||||
if not m or m.group(1) == "-inf":
|
||||
return None
|
||||
return float(m.group(1))
|
||||
|
||||
|
||||
def bands(src, dst, af, label, af_dst=None):
|
||||
"""Per-band level, source against transcode. ROBUST to misalignment, not free of it.
|
||||
|
||||
⚠️ CLAIM NARROWED 2026-08-31 after the Decoder tried to refute it. It survives
|
||||
-- **1 s of misalignment costs 0.16 dB**, well inside the 1.5 dB pass band --
|
||||
but it is **not literally alignment-free**: at **10 s the cost reaches 1.00 dB**,
|
||||
because a fixed analysis window covers different material once the shift is
|
||||
large relative to it. "Needs no alignment" was my wording and it was too
|
||||
strong; the honest claim is robustness up to a few seconds.
|
||||
|
||||
🔴 THE FOLD IS PER-SIDE, and the identity control is what made that
|
||||
necessary. `af` applies to the LEFT side only, which is correct for the real
|
||||
comparison -- a 5.1 source needs folding, an already-stereo transcode does
|
||||
not. Applying that same asymmetry to source-against-itself compares a folded
|
||||
signal with a raw six-channel average and reports **7.656 dB on an
|
||||
identity**, larger than the 0.66 dB this check calls a pass.
|
||||
"""
|
||||
print(f" {label}")
|
||||
worst = 0.0
|
||||
for lo, hi in BANDS:
|
||||
a = band_db(src, af, lo, hi)
|
||||
b = band_db(dst, af_dst, lo, hi)
|
||||
if a is None or b is None:
|
||||
print(f" {lo:>5}-{hi:<5} Hz one side silent -- no comparison")
|
||||
continue
|
||||
d = b - a
|
||||
worst = max(worst, abs(d))
|
||||
flag = "" if abs(d) <= 1.0 else (" <- " + ("transcode louder" if d > 0 else "transcode quieter"))
|
||||
print(f" {lo:>5}-{hi:<5} Hz source {a:7.2f} transcode {b:7.2f}"
|
||||
f" {d:+6.2f} dB{flag}")
|
||||
return worst
|
||||
|
||||
|
||||
def downmix_of(manifest, name):
|
||||
"""The fold the EXPORTER used, read back out of the recorded command."""
|
||||
for v in manifest.get("videos", []):
|
||||
if v.get("name") == name:
|
||||
# 🔴 Take everything between `-af` and the next flag. A tighter
|
||||
# pattern truncated the fold to its FL half -- the source was being
|
||||
# folded to a left-only signal while the transcode carried both --
|
||||
# and the run reported the difference 7 dB LOUDER than the source.
|
||||
# That is AUDIO-VERIFICATION §1's channel-layout trap, reached
|
||||
# through a parsing bug rather than a decision. The matrix contains
|
||||
# runs of spaces, so it cannot be tokenised on whitespace.
|
||||
m = re.search(r"-af (.*?) -ac ", v.get("command", ""))
|
||||
return m.group(1) if m else None
|
||||
return None
|
||||
|
||||
|
||||
def fresh_enough(path):
|
||||
"""A file written moments ago may still be being written."""
|
||||
age = time.time() - os.path.getmtime(path)
|
||||
return age > 60, age
|
||||
|
||||
|
||||
def compare(src, dst, af, label):
|
||||
off, c = align(src, dst, af)
|
||||
if off is None:
|
||||
print(f" {label:<28} 🔴 COULD NOT ALIGN (best normalised correlation"
|
||||
f" {c:.3f}) -- this is NOT a fidelity verdict")
|
||||
return None
|
||||
a = pcm(src, RATE, WINDOW_S, af, skip=2.0)
|
||||
b = pcm(dst, RATE, WINDOW_S, None, skip=2.0)
|
||||
# 🔴 THE SIGN MATTERS AND THE FIRST VERSION GOT IT WRONG. Indexing `b[i+off]`
|
||||
# with a negative `off` walks off the front of the array, which in Python
|
||||
# wraps to the end -- so the "difference" was the transcode subtracted from
|
||||
# an unrelated part of the source. It reported the difference 7 dB LOUDER
|
||||
# than the source, which is precisely the catastrophic-looking number
|
||||
# AUDIO-VERIFICATION §1 warns a misaligned run produces. The instrument
|
||||
# reproduced the documented failure before it produced a result.
|
||||
ia, ib = (0, off) if off >= 0 else (-off, 0)
|
||||
_ = c
|
||||
# Refine sample-exact on one second, now that both sides are roughly aligned.
|
||||
fine, _cf = best_lag(a[ia : ia + RATE], b[ib : ib + RATE], 16, 1)
|
||||
if fine >= 0:
|
||||
ib += fine
|
||||
else:
|
||||
ia += -fine
|
||||
n = min(len(a) - ia, len(b) - ib)
|
||||
if n <= 0:
|
||||
print(f" {label:<28} 🔴 no overlap after alignment")
|
||||
return None
|
||||
diff = array.array("i", (a[ia + i] - b[ib + i] for i in range(n)))
|
||||
off = ib - ia
|
||||
s_db, d_db = rms_db(a[ia : ia + n]), rms_db(diff)
|
||||
down = s_db - d_db
|
||||
print(f" {label:<28} source {s_db:7.2f} dB difference {d_db:7.2f} dB"
|
||||
f" {down:6.2f} dB down (lag {off:+d} smp, corr {c:.3f})")
|
||||
return down
|
||||
|
||||
|
||||
def selftest():
|
||||
"""Can this tool tell a working configuration from a broken one?
|
||||
|
||||
🔴 THE LAST GAP ON MY LIST. This script has three controls that run every
|
||||
time -- identity, a 4-pole top-end loss, an unrelated movie -- and none asks
|
||||
whether the MEASUREMENT ITSELF is live. With an empty band list every
|
||||
comparison returns a worst deviation of 0.0: identity passes, the real pair
|
||||
passes, and only the unrelated-movie control fails -- reporting **exit 1, a
|
||||
corpus problem**, for what is actually a broken instrument. Same shape as the
|
||||
empty register in `check-claims`, and the same fix: a distinct answer.
|
||||
|
||||
Drives this script as a subprocess over a short window and reads its real
|
||||
exit code: normal -> 0, band list emptied -> 2.
|
||||
"""
|
||||
env = dict(os.environ, FID_WINDOW="4")
|
||||
ok = True
|
||||
for label, extra, want in (("normal config", {}, 0),
|
||||
("band list emptied", {"FID_BANDS": "none"}, 2)):
|
||||
got = subprocess.run([sys.executable, __file__], env={**env, **extra},
|
||||
capture_output=True).returncode
|
||||
mark = "✅" if got == want else "🔴"
|
||||
print(f" harness: {label:<20} exit {got}, wanted {want} {mark}")
|
||||
ok = ok and got == want
|
||||
print()
|
||||
print("the band measurement can tell a broken configuration from a clean run"
|
||||
if ok else "🔴 the harness cannot distinguish a broken configuration")
|
||||
return 0 if ok else 2
|
||||
|
||||
|
||||
def main():
|
||||
if "--selftest" in sys.argv:
|
||||
return selftest()
|
||||
# 🔴 An empty band list makes every comparison read 0.0 dB and pass. That is
|
||||
# the harness failing, not the transcodes, and it gets its own exit code.
|
||||
if not BANDS:
|
||||
print("🔴 the band list is EMPTY -- every comparison would read 0.0 dB and")
|
||||
print(" pass. Exit 2: the harness is broken, not the transcodes.")
|
||||
return 2
|
||||
man = json.load(open("export/manifest.json"))
|
||||
names = [v["name"] for v in man.get("videos", [])]
|
||||
# 🔴 LIVENESS, the same shape as the empty band list one line up. With no
|
||||
# videos in the manifest the loop never runs, `fail` stays 0 and this reports
|
||||
# every transcode faithful -- having compared none.
|
||||
if not names:
|
||||
print("🔴 the manifest lists NO videos -- nothing was compared.")
|
||||
print(" Exit 2: the harness is broken, not the transcodes.")
|
||||
return 2
|
||||
control = "--control" in sys.argv
|
||||
fail = 0
|
||||
print(f" window {WINDOW_S:.0f} s from t=2 s, mono {RATE} Hz, pass at "
|
||||
f"{PASS_DB:.0f} dB down\n")
|
||||
for name in names:
|
||||
src = re.search(r"-i (\S+\.wmv)", next(v["command"] for v in man["videos"]
|
||||
if v["name"] == name)).group(1)
|
||||
dst = os.path.join("export", next(v["file"] for v in man["videos"]
|
||||
if v["name"] == name))
|
||||
ok_age, age = fresh_enough(dst)
|
||||
if not ok_age:
|
||||
print(f" {name:<28} 🔴 written {age:.0f} s ago -- may still be being"
|
||||
" written; refusing to measure it")
|
||||
fail += 1
|
||||
continue
|
||||
af = downmix_of(man, name)
|
||||
worst = bands(src, dst, af, f"{name} -- band energies (robust to misalignment, not free of it)")
|
||||
verdict = "ok" if worst <= PASS_BAND_DB else "🔴 OUT OF TOLERANCE"
|
||||
print(f" worst band deviation {worst:.2f} dB {verdict}")
|
||||
if worst > PASS_BAND_DB:
|
||||
fail += 1
|
||||
# 🔴 THE KNOWN NEGATIVE RUNS EVERY TIME, not behind a flag. A band check
|
||||
# that has only ever seen a faithful pair cannot be told from one that
|
||||
# compares a file with itself by accident -- and this tool has already
|
||||
# produced four confident wrong numbers on the other quantity.
|
||||
# 🔴 THE IDENTITY CONTROL, added 2026-08-31 after the Decoder generalised
|
||||
# my own rule back at me: **a positive control that is merely "high"
|
||||
# hides the difference between an exact instrument and a lossy one.**
|
||||
# This check's positive side was 0.29 and 0.66 dB -- small, and small is
|
||||
# not zero. A systematic bias (the fold applied to one side only, a
|
||||
# different window, a resampler difference) would sit inside 0.66 dB
|
||||
# while looking like a pass. Source against itself must be EXACTLY 0.00
|
||||
# in every band, and anything else is the instrument, not the transcode.
|
||||
ident = bands(src, src, af, " control: source vs ITSELF, must be exact", af_dst=af)
|
||||
idv = "ok" if ident == 0.0 else f"🔴 {ident:.3f} dB on an identity -- the instrument is biased"
|
||||
print(f" worst band deviation {ident:.3f} dB {idv}")
|
||||
if ident != 0.0:
|
||||
fail += 1
|
||||
# 🔴 A NEAR-MISS NEGATIVE, because an unrelated movie is an EASY one.
|
||||
# The Decoder measured two unrelated music BANKS separating by just
|
||||
# 5.28 dB where an unrelated movie gave me 19-20, so the margin against a
|
||||
# hard negative is 8x, not 30x. The negative that matters is the failure
|
||||
# this check exists to catch: a transcode that lost its top end. A 6 kHz
|
||||
# lowpass of the source is that failure, constructed.
|
||||
# 🔴 FOUR POLES, NOT ONE -- corrected 2026-08-31, and the correction
|
||||
# retracts a finding I published. `lowpass=f=6000` is SINGLE-POLE,
|
||||
# 6 dB/octave: a mild tilt, not a lost top end. I named it "a transcode
|
||||
# that lost its top end", measured 1.28 dB on `S00A`, and reported a
|
||||
# COVERAGE HOLE to the Decoder. **The hole was my filter.** A real brick
|
||||
# wall -- four poles -- is caught on `S00A` at 1.83 dB and on `ADV` at
|
||||
# far more.
|
||||
#
|
||||
# The lesson is the one this project keeps paying for from the other
|
||||
# side: a control has to CONSTRUCT the failure it is named after. Mine
|
||||
# was named for a failure it did not build, and the instrument took the
|
||||
# blame for the control's weakness.
|
||||
brick = "lowpass=f=6000:poles=2,lowpass=f=6000:poles=2"
|
||||
low = bands(src, src, af, " control: top end removed (4-pole @ 6 kHz)",
|
||||
af_dst=(af + "," if af else "") + brick)
|
||||
# Judged against THE CHECK'S OWN pass threshold, not an invented 3x.
|
||||
#
|
||||
# With the top band split this lands at 4.27 dB: it fails the 1.5 dB pass
|
||||
# test, so the check does catch it -- but by 2.8x, against the 6.4x it
|
||||
# has over the worst real transcode (0.67 dB). ⚠️ NOT COMFORTABLE, and
|
||||
# said out loud rather than smoothed: a loss milder than a 6 kHz brick
|
||||
# wall could sit between 0.67 and 1.5 and pass. The honest statement is
|
||||
# that this check catches a SEVERE top-end loss and is not characterised
|
||||
# for a mild one.
|
||||
#
|
||||
# The 3x bar it used to be judged against was mine and stricter than the
|
||||
# check itself; using the check's own threshold is the principled
|
||||
# criterion, and lowering the 3x to make a failing control pass would
|
||||
# have been tuning.
|
||||
# 🔴 REPORTED PER ASSET, NOT ASSERTED, and the reason is a measured gap
|
||||
# rather than convenience. `ADV` catches the lowpass by 2.8x. **`S00A`
|
||||
# does not catch it at all** -- 1.28 dB against a 1.5 dB threshold --
|
||||
# because its own 6-16 kHz content sits at -67 dB, so removing it changes
|
||||
# almost nothing. The check's sensitivity is MATERIAL-DEPENDENT, which is
|
||||
# the Decoder's finding about negative-separation arriving on the
|
||||
# positive side.
|
||||
#
|
||||
# Asserting it would make the suite permanently red on a gap I cannot
|
||||
# close today; hiding it would make a coverage hole into scenery. So it
|
||||
# prints COVERED / NOT COVERED per asset and the gap is tracked in
|
||||
# BLOCKED.md. The identity and unrelated-movie controls still assert.
|
||||
if low > PASS_BAND_DB:
|
||||
margin = low / PASS_BAND_DB
|
||||
note = "" if margin >= 2.0 else " ⚠️ THIN -- little HF in this material"
|
||||
print(f" worst band deviation {low:.2f} dB COVERED, caught by"
|
||||
f" {margin:.1f}x{note}")
|
||||
else:
|
||||
print(f" worst band deviation {low:.2f} dB 🔴 NOT COVERED --"
|
||||
f" a 6 kHz top-end loss on {name} would PASS this check")
|
||||
other = [v for v in man["videos"] if v["name"] != name]
|
||||
if other:
|
||||
osrc = os.path.join("export", other[0]["file"])
|
||||
bad = bands(src, osrc, af, f" control: vs {other[0]['name']}, must be FAR out")
|
||||
ctl = "ok" if bad > 3 * PASS_BAND_DB else "🔴 an unrelated movie passes as faithful"
|
||||
print(f" worst band deviation {bad:.2f} dB {ctl}")
|
||||
if bad <= 3 * PASS_BAND_DB:
|
||||
fail += 1
|
||||
print()
|
||||
if af is None:
|
||||
print(f" {name:<28} ⚠️ no `-af` in the recorded command: the source"
|
||||
" is stereo, comparing without a fold")
|
||||
# Report-only: a disqualified path must not vote on the exit code. It
|
||||
# did, which is why the run went red for the wrong reason the moment the
|
||||
# return was fixed -- two defects hiding each other, and repairing one
|
||||
# exposed the other rather than the run going quietly green.
|
||||
compare(src, dst, af, name)
|
||||
if control:
|
||||
print(f" known negatives for {name}:")
|
||||
same = compare(src, src, af, " source vs itself")
|
||||
if same is None or same < 60:
|
||||
print(" 🔴 the check cannot even match a file with itself")
|
||||
fail += 1
|
||||
other = [n for n in names if n != name]
|
||||
if other:
|
||||
osrc = os.path.join("export", next(v["file"] for v in man["videos"]
|
||||
if v["name"] == other[0]))
|
||||
un = compare(src, osrc, af, f" vs {other[0]} (unrelated)")
|
||||
if un is not None and un > 10:
|
||||
print(" 🔴 an unrelated movie scores as faithful")
|
||||
fail += 1
|
||||
print()
|
||||
print(" ⚠️ WHAT IS ASSERTED: per-band level agreement, which needs no")
|
||||
print(" alignment. It CANNOT tell a faithful transcode from one that kept")
|
||||
print(" the spectrum and mangled the waveform. That is the honest limit of")
|
||||
print(" this quantity, and it is what the difference signal below was for.")
|
||||
print()
|
||||
print(" 🔴 THE DIFFERENCE SIGNAL IS REPORT ONLY -- NO VERDICT, and the numbers")
|
||||
print(" above must not be read as one. Best alignment so far is corr")
|
||||
print(" 0.763 on `S00A` and 0.075 on `ADV`, and both still report the")
|
||||
print(" difference LOUDER than the source, which is impossible for two")
|
||||
print(" aligned signals at equal level. Something remains wrong on this")
|
||||
print(" side of the measurement, not necessarily in the transcodes.")
|
||||
print()
|
||||
print(" What this run DOES establish is the trap list below, each reproduced")
|
||||
print(" here rather than reasoned about. See docs/port/DECISIONS.md.")
|
||||
print(" 🔴 It measures AUDIO only; `-q:v 8` was chosen on SSIM separately.")
|
||||
# 🔴 THIS RETURN WAS UNCONDITIONAL `return 0` FOR A DAY. Making the difference
|
||||
# path report-only swallowed the band verdict with it, so `check-all`'s
|
||||
# `transcode-bands must-pass` step COULD NOT FAIL -- an asserting step that
|
||||
# asserts nothing, which is the exact shape this project keeps finding in
|
||||
# other people's work and had now shipped in mine. The band failures were
|
||||
# being printed and discarded.
|
||||
if fail:
|
||||
print(f"\n🔴 {fail} band control failure(s)")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
sys.exit(main())
|
||||
116
tools/port/which-focus
Executable file
116
tools/port/which-focus
Executable file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# Which button is focused in a screenshot of the real game?
|
||||
#
|
||||
# tools/port/which-focus SHOT.png # main_menu (5 buttons)
|
||||
# tools/port/which-focus SHOT.png extras # extras (3 buttons)
|
||||
#
|
||||
# Renders the port's own screen with each button focused in turn and reports
|
||||
# which one differs least from the shot. Answers a question the Decoder needs to
|
||||
# drive the game -- `newgame_path.sh` assumed NEW GAME is focused at boot, drove
|
||||
# on that, and landed in a tutorial mission, because HANDOFF Q5 measured focus as
|
||||
# UNSTABLE across boots. Counting presses cannot substitute: up from the first
|
||||
# item wraps to the last, so no fixed number of presses lands on a known item
|
||||
# from an unknown start.
|
||||
#
|
||||
# ⚠️ IT RUNS ITS OWN CONTROL FIRST AND REFUSES TO ANSWER IF THE CONTROL FAILS.
|
||||
# `docs/re/captures/title-builds/live-main-menu-options-focused.png` has the
|
||||
# answer in its filename, so the method can be tested on every invocation rather
|
||||
# than once when it was written. A brightness-per-row detector was tried for this
|
||||
# job and picked NEW GAME on that capture; this method picks OPTIONS by 4.7x.
|
||||
# A control that does not execute is not a control.
|
||||
#
|
||||
# 🔴 IT NEEDS GODOT AND THE PORT'S EXPORT TREE, so it does NOT run in the RE
|
||||
# container -- no engine there, and rendering this project is outside that
|
||||
# agent's role. It reads a capture, but it answers by RENDERING the candidates.
|
||||
# `tools/re-capture/focus_from_capture.py` is the capture-only alternative; note
|
||||
# that its offline controls are its own calibration inputs, which is
|
||||
# self-consistency rather than validation, so it is the live transition test
|
||||
# (NEW GAME -> down -> LOAD GAME, expected LOAD GAME) that validates it.
|
||||
#
|
||||
# WHAT IT IS NOT. It identifies the focus in ONE FRAME. It says nothing about
|
||||
# what selects focus -- Q5's four boots gave TUTORIAL, TUTORIAL, NEW GAME, NEW
|
||||
# GAME and that instability stands.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
export DISPLAY="${DISPLAY:-:97}"
|
||||
shot="${1:?usage: which-focus SHOT.png [screen]}"
|
||||
screen="${2:-main_menu}"
|
||||
OUT="${OUT:-$(mktemp -d)}"; mkdir -p "$OUT"
|
||||
CAPS=docs/re/captures/title-builds
|
||||
|
||||
# Buttons, in the order ui_down walks them.
|
||||
case "$screen" in
|
||||
main_menu) BUTTONS=(ptbtn01:NEW_GAME ptbtn02:LOAD_GAME ptbtn03:TUTORIAL ptbtn04:OPTIONS ptbtn05:EXTRAS) ;;
|
||||
extras) BUTTONS=(ptbtn11:MISSION_SELECT ptbtn12:MOVIE_THEATER ptbtn13:THIRD) ;;
|
||||
*) echo "which-focus: no button list for $screen" >&2; exit 2 ;;
|
||||
esac
|
||||
n=${#BUTTONS[@]}
|
||||
downs=$(python3 -c "print(','.join(['down']*($n-1)))")
|
||||
|
||||
render_all() { # render_all <tag>
|
||||
godot --path port --resolution 1280x720 -- "--menu=$screen" "--script=$downs" \
|
||||
"--shots=$OUT/$1" >"$OUT/$1.log" 2>&1 || true
|
||||
}
|
||||
# Normalise any input to the captures' 1279x675 top-left crop. A 1280x720 guest
|
||||
# frame and a 1279x675 screenshot are the same pixels; the difference is the
|
||||
# crop the screenshot tool applies, not a scale.
|
||||
norm() { convert "$1" -crop 1279x675+0+0 +repage "$2"; }
|
||||
|
||||
score() { # score <shot> ; prints "<idx> <label> <pixels>" per candidate
|
||||
local s="$1" i=0 f
|
||||
for f in "$OUT"/r_*.png; do
|
||||
[ -f "$f" ] || continue
|
||||
norm "$f" "$OUT/cand.png"
|
||||
local d
|
||||
d=$(convert "$OUT/cand.png" "$s" -compose difference -composite \
|
||||
-colorspace Gray -threshold 25% -format "%[fx:mean*w*h]" info:)
|
||||
echo "$i ${BUTTONS[$i]#*:} $d"
|
||||
i=$((i+1))
|
||||
done
|
||||
}
|
||||
|
||||
render_all r
|
||||
# Godot names the shots `<tag>_00_start.png`, `<tag>_01_down.png`, ... -- rename
|
||||
# to a sortable form so the candidate order is the ui_down order and not glob luck.
|
||||
i=0
|
||||
for f in "$OUT"/r_0*.png; do mv "$f" "$OUT/r_$(printf '%02d' $i).png"; i=$((i+1)); done
|
||||
[ "$i" = "$n" ] || { echo "which-focus: rendered $i of $n focus states -- see $OUT" >&2; exit 3; }
|
||||
|
||||
verdict() { # verdict <shot> <expected-or-empty>
|
||||
local s="$1" expect="${2:-}"
|
||||
norm "$s" "$OUT/shot.png"
|
||||
mapfile -t rows < <(score "$OUT/shot.png" | sort -k3 -n)
|
||||
local best_lbl best_px second_px
|
||||
best_lbl=$(echo "${rows[0]}" | awk '{print $2}')
|
||||
best_px=$(echo "${rows[0]}" | awk '{print $3}')
|
||||
second_px=$(echo "${rows[1]}" | awk '{print $3}')
|
||||
local margin
|
||||
margin=$(python3 -c "print('%.1f' % ($second_px/max($best_px,1)))")
|
||||
for r in "${rows[@]}"; do printf ' %-16s %8s\n' "$(echo "$r"|awk '{print $2}')" "$(echo "$r"|awk '{print $3}')"; done
|
||||
echo " -> $best_lbl, margin ${margin}x"
|
||||
if [ -n "$expect" ]; then
|
||||
if [ "$best_lbl" = "$expect" ]; then echo " CONTROL PASSED (expected $expect)"; return 0
|
||||
else echo " 🔴 CONTROL FAILED: expected $expect, got $best_lbl"; return 1; fi
|
||||
fi
|
||||
# A thin margin means the frame does not decide it. 2x is below the 4.7x the
|
||||
# control achieves and well above 1.0; a shot that cannot beat it should be
|
||||
# re-taken rather than guessed at.
|
||||
python3 -c "import sys; sys.exit(0 if $margin >= 2.0 else 1)" || {
|
||||
echo " ⚠️ margin under 2x -- this frame does not decide it. Do not act on this."; return 1; }
|
||||
}
|
||||
|
||||
if [ "$screen" = main_menu ]; then
|
||||
echo "control -- $CAPS/live-main-menu-options-focused.png (answer is in the filename):"
|
||||
verdict "$CAPS/live-main-menu-options-focused.png" OPTIONS || {
|
||||
echo "refusing to report a result from a method that just failed its control." >&2; exit 1; }
|
||||
echo
|
||||
fi
|
||||
# The exit code must carry the refusal. An earlier version printed "do not act on
|
||||
# this" and exited 0, so a caller scripting this -- which is the entire point,
|
||||
# the Decoder runs it between drive steps -- would have read a refusal as an
|
||||
# answer. That is the same defect as a checker claiming a check it skipped.
|
||||
echo "$shot:"
|
||||
rc=0
|
||||
verdict "$shot" || rc=$?
|
||||
echo "artifacts in $OUT"
|
||||
exit $rc
|
||||
Reference in New Issue
Block a user