Merge remote-tracking branch 'origin/main' into auto/frame-blend-draw-path
# Conflicts: # crates/sylpheed-cli/src/main.rs
This commit is contained in:
243
tools/gitea-protect
Executable file
243
tools/gitea-protect
Executable file
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply and verify branch protection on `main` -- Phase 2 of GITEA-SETUP.md.
|
||||
|
||||
tools/gitea-protect --dry-run print the exact rule it would send; no token
|
||||
tools/gitea-protect create or update the rule (idempotent)
|
||||
tools/gitea-protect --verify assert the live rule still holds; exit 1 if not
|
||||
|
||||
Six settings, of which two were missing from the first draft of the runbook and
|
||||
both of those are the ones that close the gate. That is the shape of thing that
|
||||
gets mis-clicked in a web form at 1am, so it goes through the API instead: what
|
||||
was applied is reviewable in a diff, and `--verify` re-checks it every day
|
||||
rather than once.
|
||||
|
||||
── Why each field is what it is ─────────────────────────────────────────────
|
||||
|
||||
Read out of Gitea's own models/git/protected_branch.go, not inferred:
|
||||
|
||||
EnableMergeWhitelist=false merging falls back on "whether the user has
|
||||
write permission" -- and both agents have
|
||||
Write. This is THE gate; without it every
|
||||
other row is decoration.
|
||||
EnableApprovalsWhitelist=false "anyone with write access is considered
|
||||
official reviewer". Gitea refuses to let an
|
||||
author approve their OWN pull request and does
|
||||
nothing about sylph-decoder approving
|
||||
sylph-port's, so without this the two agents
|
||||
satisfy the human gate between themselves.
|
||||
enable_push=false blocks PUSHES to main. It has no effect on
|
||||
merging whatsoever, which is the assumption
|
||||
that made the first version of this phase read
|
||||
as protection while being none.
|
||||
|
||||
🔴 block_admin_merge_override stays FALSE, deliberately. Turning it on locks the
|
||||
human out of their own work: approvals are whitelisted to `fabi`, Gitea will not
|
||||
let `fabi` approve a `fabi` PR, so a human-authored PR could never reach one
|
||||
approval and -- with the override blocked -- could never be merged at all. The
|
||||
admin override is what keeps that door open, and it is not a hole in the agent
|
||||
gate because the agents are Write, not Admin. That is what "Write, not Admin" in
|
||||
Phase 1.2 is buying, and this is where it gets spent.
|
||||
|
||||
── Where the token comes from ───────────────────────────────────────────────
|
||||
|
||||
Branch protection is a REPOSITORY-scope endpoint, so `~/.sylph-gitea-api-token`
|
||||
(write:issue, read:repository) cannot do it -- that token exists precisely so the
|
||||
issue work needs no repository rights.
|
||||
|
||||
The credential that CAN is one you already have: `~/.sylph-git-credentials`, on
|
||||
the agent box, scoped write:repository. Reusing it means this needs no new
|
||||
credential and no second machine holding push rights, which is the whole reason
|
||||
to run this here rather than on the Pi.
|
||||
"""
|
||||
|
||||
import argparse, json, os, sys, urllib.error, urllib.parse, urllib.request
|
||||
|
||||
HOST = os.environ.get("SYLPH_GITEA_HOST", "git.mc02.dev")
|
||||
REPO = os.environ.get("SYLPH_GITEA_REPO", "fabi/Sylpheed")
|
||||
HUMAN = os.environ.get("SYLPH_GITEA_HUMAN", "fabi")
|
||||
BRANCH = os.environ.get("SYLPH_GITEA_BRANCH", "main")
|
||||
AGENTS = os.environ.get("SYLPH_GITEA_AGENTS", "sylph-decoder,sylph-port").split(",")
|
||||
|
||||
RULE = {
|
||||
"rule_name": BRANCH,
|
||||
"enable_push": False,
|
||||
"required_approvals": 1,
|
||||
"dismiss_stale_approvals": True,
|
||||
"block_on_rejected_reviews": True,
|
||||
"enable_merge_whitelist": True,
|
||||
"merge_whitelist_usernames": [HUMAN],
|
||||
"enable_approvals_whitelist": True,
|
||||
"approvals_whitelist_username": [HUMAN],
|
||||
"block_admin_merge_override": False, # see the module docstring
|
||||
}
|
||||
|
||||
# What --verify asserts. Kept separate from RULE because a check that is written
|
||||
# as "whatever we sent" cannot fail: it would re-derive the expectation from the
|
||||
# thing under test. These are stated independently, on purpose.
|
||||
EXPECTED = {
|
||||
"enable_push": (lambda v: v is False, "pushes to the branch are blocked"),
|
||||
"required_approvals": (lambda v: v >= 1, "at least one approval required"),
|
||||
"dismiss_stale_approvals": (lambda v: v is True, "stale approvals dismissed"),
|
||||
"block_on_rejected_reviews": (lambda v: v is True, "rejected reviews block the merge"),
|
||||
"enable_merge_whitelist": (lambda v: v is True, "MERGE WHITELIST ON -- the gate"),
|
||||
"merge_whitelist_usernames": (lambda v: v == [HUMAN], f"only {HUMAN} may merge"),
|
||||
"enable_approvals_whitelist": (lambda v: v is True, "APPROVALS WHITELIST ON"),
|
||||
"approvals_whitelist_username": (lambda v: v == [HUMAN], f"only {HUMAN}'s approval counts"),
|
||||
}
|
||||
|
||||
|
||||
def token():
|
||||
"""The first credential that can plausibly do this, and a clear no otherwise."""
|
||||
explicit = os.environ.get("SYLPH_GITEA_ADMIN_TOKEN")
|
||||
if explicit and os.path.exists(explicit):
|
||||
return open(explicit).read().strip(), explicit
|
||||
|
||||
cred = os.path.expanduser(os.environ.get("SYLPH_GIT_CREDENTIALS",
|
||||
"~/.sylph-git-credentials"))
|
||||
if os.path.exists(cred):
|
||||
for line in open(cred):
|
||||
line = line.strip()
|
||||
if HOST in line and "@" in line:
|
||||
parsed = urllib.parse.urlsplit(line)
|
||||
if parsed.password:
|
||||
return urllib.parse.unquote(parsed.password), cred
|
||||
|
||||
sys.exit(
|
||||
f"gitea-protect: no repository-scoped credential found.\n\n"
|
||||
f" Looked in $SYLPH_GITEA_ADMIN_TOKEN and {cred}.\n\n"
|
||||
f" NOT ~/.sylph-gitea-api-token: that one is write:issue + read:repository\n"
|
||||
f" by design, and every branch-protection endpoint refuses it. Run this on\n"
|
||||
f" the machine that already holds the push credential rather than issuing a\n"
|
||||
f" repository-scoped token to a second box.\n"
|
||||
)
|
||||
|
||||
|
||||
def api(method, path, tok, body=None):
|
||||
url = f"https://{HOST}/api/v1/repos/{REPO}{path}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||
"Authorization": f"token {tok}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw else None)
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode(errors="replace")
|
||||
if e.code in (401, 403) and "scope" in raw:
|
||||
sys.exit(f"🔴 that credential lacks repository scope:\n {raw.strip()}")
|
||||
return e.code, raw
|
||||
except urllib.error.URLError as e:
|
||||
sys.exit(f"🔴 no response from {url} -- host or network: {e.reason}")
|
||||
|
||||
|
||||
def apply_rule(tok):
|
||||
status, existing = api("GET", f"/branch_protections/{BRANCH}", tok)
|
||||
if status == 200:
|
||||
status, out = api("PATCH", f"/branch_protections/{BRANCH}", tok,
|
||||
{k: v for k, v in RULE.items() if k != "rule_name"})
|
||||
verb = "updated"
|
||||
elif status == 404:
|
||||
status, out = api("POST", "/branch_protections", tok, RULE)
|
||||
verb = "created"
|
||||
else:
|
||||
sys.exit(f"🔴 unexpected {status} reading the existing rule: {existing}")
|
||||
|
||||
if status not in (200, 201):
|
||||
sys.exit(f"🔴 {verb.rstrip('d')} failed ({status}): {out}")
|
||||
print(f" {verb} the protection rule on {BRANCH}")
|
||||
return out
|
||||
|
||||
|
||||
def verify(tok):
|
||||
"""Assert, one line per property, and say which one failed rather than 'no'."""
|
||||
ok = True
|
||||
status, rule = api("GET", f"/branch_protections/{BRANCH}", tok)
|
||||
if status == 404:
|
||||
print(f"🔴 NO PROTECTION RULE on {BRANCH}. Anyone with Write can push to it.")
|
||||
return False
|
||||
if status != 200:
|
||||
sys.exit(f"🔴 could not read the rule ({status}): {rule}")
|
||||
|
||||
for key, (pred, why) in EXPECTED.items():
|
||||
got = rule.get(key)
|
||||
good = pred(got)
|
||||
ok &= good
|
||||
print(f" {'✅' if good else '🔴'} {why:<42} {key}={got!r}")
|
||||
|
||||
# The other half of what a daily check is for: Phase 1.2's "Write, not
|
||||
# Admin". An agent promoted to Admin could edit the rule above and then
|
||||
# merge, so a green rule proves nothing on its own.
|
||||
for agent in AGENTS:
|
||||
status, perm = api("GET", f"/collaborators/{agent}/permission", tok)
|
||||
# 🔴 A MISSING COLLABORATOR IS A FAILURE, not a blank. This branch used
|
||||
# to print ⚪ and `continue`, leaving `ok` untouched -- so the one
|
||||
# instrument that checks Phase 1.2 could not report Phase 1.2 being
|
||||
# undone. An agent removed from the repository read as "nothing to say"
|
||||
# rather than as a gate that is no longer there.
|
||||
#
|
||||
# It never actually fired: Gitea answers this endpoint with permission
|
||||
# "read" for a non-collaborator rather than 404, so the case was caught
|
||||
# by the role test below -- by luck, not by design. That is the same
|
||||
# shape as a check that passes on an instance with no rule at all, and
|
||||
# it is not worth keeping just because the luck has held.
|
||||
if status == 404:
|
||||
print(f" 🔴 {agent + ' is not a collaborator':<42} Phase 1.2 is undone")
|
||||
ok = False
|
||||
continue
|
||||
if status != 200:
|
||||
print(f" 🔴 {agent:<42} permission unreadable ({status})")
|
||||
ok = False
|
||||
continue
|
||||
role = perm.get("permission")
|
||||
good = role == "write"
|
||||
ok &= good
|
||||
print(f" {'✅' if good else '🔴'} {agent + ' is Write, not Admin':<42} permission={role!r}")
|
||||
|
||||
return ok
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(add_help=True, description=__doc__.split("\n")[0])
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument("--dry-run", action="store_true",
|
||||
help="print the rule that would be sent; needs no credential")
|
||||
g.add_argument("--verify", action="store_true",
|
||||
help="check the live rule against what this file asserts")
|
||||
a = p.parse_args()
|
||||
|
||||
print(f"repo https://{HOST}/{REPO}")
|
||||
print(f"branch {BRANCH}\n")
|
||||
|
||||
if a.dry_run:
|
||||
print(f"would PUT this rule (no credential read, nothing sent):\n")
|
||||
print(json.dumps(RULE, indent=2))
|
||||
print(f"\ndry run -- nothing was changed.")
|
||||
return 0
|
||||
|
||||
tok, where = token()
|
||||
print(f"credential from {where}\n")
|
||||
|
||||
if a.verify:
|
||||
ok = verify(tok)
|
||||
print()
|
||||
print("protection holds." if ok else
|
||||
"🔴 PROTECTION DOES NOT HOLD -- stop the agents until it does.")
|
||||
return 0 if ok else 1
|
||||
|
||||
apply_rule(tok)
|
||||
print()
|
||||
ok = verify(tok)
|
||||
print()
|
||||
if ok:
|
||||
print("Now run the check that a settings page cannot give you, from")
|
||||
print("GITEA-SETUP.md Phase 2 -- especially step 4: approve the throwaway")
|
||||
print("PR yourself, then confirm sylph-port STILL has no merge button.")
|
||||
print("Steps 1-3 pass on an instance with no rule at all.")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
124
tools/gitea-setup
Executable file
124
tools/gitea-setup
Executable file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create the work-item structure in Gitea: labels, milestones, and the board.
|
||||
#
|
||||
# tools/gitea-setup create anything missing (idempotent)
|
||||
# tools/gitea-setup --dry-run say what it would create, change nothing
|
||||
#
|
||||
# Needs a token with `write:issue`. The existing git credential is scoped
|
||||
# `write:repository`, which pushes fine and is REFUSED by every issue endpoint --
|
||||
# checked, not assumed:
|
||||
#
|
||||
# {"message":"token does not have at least one of required scope(s),
|
||||
# required=[read:issue], token scope=write:repository"}
|
||||
#
|
||||
# So this reads a SECOND token from ~/.sylph-gitea-api-token, deliberately
|
||||
# separate from the push credential: different blast radius, and rotating one
|
||||
# does not break the other.
|
||||
#
|
||||
# ── Why Gitea rather than a new tracker ─────────────────────────────────────
|
||||
#
|
||||
# The failure this replaces is a 1,227-line hand-maintained `BLOCKED.md` whose
|
||||
# anti-staleness convention turned out constant by construction, plus 21
|
||||
# inter-agent messages sent into a void with no delivery feedback. Both are
|
||||
# solved by items that live in a database with state, an owner and dependency
|
||||
# edges -- and Gitea is already deployed here, so it adds no second store to
|
||||
# drift out of sync with the first. That drift is this project's defining
|
||||
# failure mode; adding a tool with its own copy of the truth would be choosing
|
||||
# more of it.
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${SYLPH_GITEA_HOST:-git.mc02.dev}"
|
||||
REPO="${SYLPH_GITEA_REPO:-fabi/Sylpheed}"
|
||||
TOKFILE="${SYLPH_GITEA_API_TOKEN:-$HOME/.sylph-gitea-api-token}"
|
||||
DRY=0; [ "${1:-}" = "--dry-run" ] && DRY=1
|
||||
|
||||
[ -f "$TOKFILE" ] || {
|
||||
cat >&2 <<EOF
|
||||
gitea-setup: no API token at $TOKFILE
|
||||
|
||||
Create one in Gitea: Settings -> Applications -> Generate New Token
|
||||
Scopes needed: write:issue (and read:repository, to see the repo)
|
||||
Then: echo '<token>' > $TOKFILE && chmod 600 $TOKFILE
|
||||
|
||||
This is NOT the push credential. That one is scoped write:repository and is
|
||||
refused by every issue endpoint.
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
TOK=$(tr -d '[:space:]' < "$TOKFILE")
|
||||
API="https://$HOST/api/v1/repos/$REPO"
|
||||
AUTH="Authorization: token $TOK"
|
||||
|
||||
api() { curl -sS --max-time 30 -H "$AUTH" -H 'Content-Type: application/json' "$@"; }
|
||||
|
||||
# Fail loudly and specifically on the one error everyone hits.
|
||||
probe=$(api "$API/labels" || true)
|
||||
case "$probe" in
|
||||
*'required scope'*)
|
||||
echo "🔴 the token at $TOKFILE lacks issue scope:" >&2
|
||||
echo " $probe" >&2
|
||||
echo " Regenerate it with write:issue." >&2
|
||||
exit 2 ;;
|
||||
'') echo "🔴 no response from $API -- host or network" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
say() { [ "$DRY" = 1 ] && echo " would create $*" || echo " created $*"; }
|
||||
|
||||
# ── Labels ──────────────────────────────────────────────────────────────────
|
||||
# The state set encodes the working model the human set on 2026-09-02: a human
|
||||
# defines a bundle, agents decompose it, and each item ends in a HUMAN check.
|
||||
# `needs-human` is the important one -- it is the state the whole model turns on
|
||||
# and the one no off-the-shelf agent tool models, because the market has
|
||||
# converged on removing the human rather than gating on them.
|
||||
existing=$(printf '%s' "$probe" | python3 -c "import json,sys;print('\n'.join(l['name'] for l in json.load(sys.stdin)))" 2>/dev/null || true)
|
||||
mklabel() { # name colour description
|
||||
printf '%s\n' "$existing" | grep -qxF "$1" && return 0
|
||||
if [ "$DRY" = 0 ]; then
|
||||
api -X POST "$API/labels" -d "$(python3 -c "
|
||||
import json,sys; print(json.dumps({'name':sys.argv[1],'color':sys.argv[2],'description':sys.argv[3]}))" "$1" "$2" "$3")" >/dev/null
|
||||
fi
|
||||
say "label $1"
|
||||
}
|
||||
|
||||
mklabel "state/proposed" "d4c5f9" "Agent proposed this item; awaiting the human's approval to start"
|
||||
mklabel "state/approved" "0e8a16" "Human approved the shape; an agent may start"
|
||||
mklabel "state/in-progress" "1d76db" "An agent is working it now"
|
||||
mklabel "state/needs-human" "fbca04" "Done as far as an agent can tell -- a person must look. The body says what to look at"
|
||||
mklabel "state/blocked" "b60205" "Waiting on another item; use the Depends-On field, not prose"
|
||||
mklabel "agent/decoder" "5319e7" "Owned by the Decoder (disc to meaning; runs the emulator)"
|
||||
mklabel "agent/port" "006b75" "Owned by the Port (disc to playable; no RE)"
|
||||
mklabel "kind/bundle" "c2e0c6" "A bundle the human defined; agents decompose it into items"
|
||||
mklabel "kind/item" "bfd4f2" "One unit of work, small enough to finish in a single session"
|
||||
mklabel "kind/ask" "e99695" "One agent asking the other for something it cannot answer in role"
|
||||
mklabel "kind/defect" "d93f0b" "Found by a play-test or a check"
|
||||
|
||||
# ── Milestones = bundles ────────────────────────────────────────────────────
|
||||
ms=$(api "$API/milestones?state=all" | python3 -c "import json,sys;print('\n'.join(m['title'] for m in json.load(sys.stdin)))" 2>/dev/null || true)
|
||||
mkms() {
|
||||
printf '%s\n' "$ms" | grep -qxF "$1" && return 0
|
||||
if [ "$DRY" = 0 ]; then
|
||||
api -X POST "$API/milestones" -d "$(python3 -c "
|
||||
import json,sys; print(json.dumps({'title':sys.argv[1],'description':sys.argv[2]}))" "$1" "$2")" >/dev/null
|
||||
fi
|
||||
say "milestone (bundle) $1"
|
||||
}
|
||||
mkms "Menus" "The menu shell: title, main menu, submenus, navigation, audio."
|
||||
mkms "Title screen" "Title timing and animation: the sweep onset, the plate, what (A) does."
|
||||
mkms "Graphics pipeline" "Decoder: disc -> decode -> per-frame update -> submitted draws -> Canary -> screen."
|
||||
mkms "Infrastructure" "Containers, supervision, auth, work tracking. Not game work."
|
||||
|
||||
echo
|
||||
if [ "$DRY" = 1 ]; then
|
||||
echo "dry run -- nothing was created."
|
||||
else
|
||||
echo "labels and bundles are in place at https://$HOST/$REPO/issues"
|
||||
echo
|
||||
# No board, and this used to say the opposite. Gitea's project board does not
|
||||
# follow labels, so it would be a SECOND copy of the state to hand-sync -- the
|
||||
# exact failure that produced a 1,227-line BLOCKED.md. Labels are the truth and
|
||||
# a saved issue filter gives the same view for nothing. Leaving the old
|
||||
# "remaining, by hand: Projects -> New Project" line here would have had the
|
||||
# tool instructing the reader to build the thing the doc argues against.
|
||||
echo "No project board, deliberately -- labels are the truth. See"
|
||||
echo "docs/agents/GITEA-SETUP.md Phase 4. Use a saved issue filter instead."
|
||||
fi
|
||||
@@ -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
|
||||
144
tools/port/check-citations
Executable file
144
tools/port/check-citations
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/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")
|
||||
# The THIRD class, which `--for-merge` turns into a failure. It has to be
|
||||
# told apart from both others: a peer citation is not dangling (the file
|
||||
# exists) and does not resolve here (the reader still gets nothing), and
|
||||
# a scanner that collapsed it into either would make the flag meaningless
|
||||
# while still passing the two checks above.
|
||||
peerfile = os.path.join(tmp, "peer.md")
|
||||
open(peerfile, "w").write("see `docs/re/f5-a-press-snaps-the-plate.md`\n")
|
||||
rp, pp, np_ = scan([peerfile])
|
||||
|
||||
_, _, nb = scan([bad])
|
||||
r, _, ng = scan([good])
|
||||
caught = len(nb) == 1
|
||||
passed = len(ng) == 0 and r == 1
|
||||
peer_ok = len(pp) == 1 and rp == 0 and len(np_) == 0
|
||||
ok = caught and passed and peer_ok
|
||||
print("selftest: planted dangling caught=%s, real citation passed=%s, "
|
||||
"peer-branch classed separately=%s -> %s"
|
||||
% (caught, passed, peer_ok, "ok" if ok else "🔴 BROKEN"))
|
||||
if not peer_ok:
|
||||
print(" 🔴 --for-merge cannot mean anything if the peer class is "
|
||||
"not distinguished; got resolves=%d peer=%d nowhere=%d"
|
||||
% (rp, len(pp), len(np_)))
|
||||
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)
|
||||
# 🔴 --for-merge TURNS THE PEER CLASS INTO A FAILURE.
|
||||
#
|
||||
# Reporting-not-failing was right when it was written: a peer-branch
|
||||
# citation was "a state nobody in this container can fix", so failing on it
|
||||
# would have been red for something unactionable. Under the pull-request
|
||||
# workflow that stopped being true -- a PR into `main` is EXACTLY where it
|
||||
# becomes fixable, by opening the finding's PR first and depending on it.
|
||||
# The citation is dead the moment this merges, so the merge is the last
|
||||
# place the leniency can still be withdrawn.
|
||||
#
|
||||
# Left as a flag rather than made unconditional, because both readings are
|
||||
# still live: mid-work on a topic branch the peer class really is unfixable
|
||||
# noise. The difference the old code could not express is WHERE the code is
|
||||
# going, and that is a condition the caller can state.
|
||||
merging = "--for-merge" in sys.argv
|
||||
label = "🔴 FAILS (--for-merge)" if merging else "reported, not failed"
|
||||
print(" on a peer branch, not merged: %d (%s)" % (len(peer), label))
|
||||
for m, (src, ref) in sorted(peer.items()):
|
||||
print(" %-52s %s <- %s" % (m, ref.split("/")[-1], os.path.basename(src)))
|
||||
if peer and merging:
|
||||
print("\n🔴 %d citation(s) resolve only on a peer branch." % len(peer))
|
||||
print(" After this merges they resolve NOWHERE -- the reader gets a dead")
|
||||
print(" path. Land the finding first and make it a dependency of this PR.")
|
||||
return 1
|
||||
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