#!/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.")