Takes the port branch up to77320d5e-- the state the human play-tested on 2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio` is 366 commits and 938 files, and most of that must not land. WHAT COMES IN (76 files, all human-confirmed working): * the logo splash animation.08ed3dd1found it: `pose_at` ASSIGNED the settle instant instead of clamping to it, so the splash never animated at all -- and the same bug manufactured a passing harness result, because the harness photographed t past the settle. Confirmed by play-test: "cannot notice any obvious difference from the actual game." * gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad binding), stick latched with hysteresis at the game's own 61% digitise threshold. This is what made (A), video-skip and Extras work at all. * menu navigation and flow, menu audio, the exporter, the authored declarations, and 23 verification tools under tools/port/. WHAT IS DELIBERATELY LEFT ON THE BRANCH: * everything afterc0ae460a-- the F5/F6 title-timing investigation, whose own tip commit calls itself a "hand-off for one-minute human checks". Unchecked by definition; it goes through the new review gate like anything else. * the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested. * the F1 repeat mechanism, which its own commit calls "deliberately inert". WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED: 545 MB of extracted game content was committed on that branch -- 850 sprite, audio and transcoded video files under `export-probe/` and `export-probe2/`, plus 246 MB of loose .wav and .tsv at the repo root. This repository's own rule, in this file, is "never game content". The rule was not missing. It was written, and it was tightened on that very branch, with a careful comment explaining why BOTH `export/` and `data/base/` had to be listed -- while the exporter was writing to a third name that nobody had thought to list. Enumerating names is the thing that failed. So the ignore rules now describe the SHAPE: any top-level `export*/`, game media by extension, and loose capture output at the root. Verified both ways -- it catches all four offenders and ignores nothing currently tracked. Verified: `cargo check --workspace` clean; all nine GDScript files parse in project context, with a positive control (an injected syntax error is detected, 3 lines) so the clean result means something. `tools/port/check-all` was NOT run -- it needs the container, the export tree and a display.
181 lines
7.9 KiB
Python
Executable File
181 lines
7.9 KiB
Python
Executable File
#!/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.")
|