Files
Sylpheed/tools/port/edge-residual-map
MechaCat02 a23c321831 port: land the play-tested work, and only that
Takes the port branch up to 77320d5e -- 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. 08ed3dd1 found 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 after c0ae460a -- 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.
2026-09-04 16:17:14 +02:00

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.")