From b6a4a079f9642d3c7dea08c15ddb317480e493b8 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Mon, 31 Aug 2026 05:18:37 +0000 Subject: [PATCH] port: generalise the frame finding, refute my own premultiplied-alpha hypothesis tools/port/element-residual turns last iteration's one-off suppression into a tool: pose a screen as verify-capture does, shadow each sprite with a transparent PNG, rank elements by residual on the pixels they actually paint. Two controls, both mandatory -- the metric's zero on identity, and a mod that shadows nothing must move zero pixels, or a footprint is the harness rather than the element. It generalises. Frames rank 1 and 2 on both screens with captures: ptframe1 9.47x and ptframe2 5.46x on main_menu, ptframe3 14.23x and ptframe4 10.46x on extras, all four body-dominated and all four negative. The sign splits, so it is not global darkness: ptmsg +5.02, ptmsg2 +8.83, pttitle +7.88 and every button positive. Refuted my own next hypothesis. Premultiplied alpha decoded as straight alpha predicts error proportional to partial-alpha fraction; the frames are 6.7-7.3 % partial and the most darkened, while pteff10 is 100 % partial and nearly exact. What the frames are: neither has a single fully-opaque pixel, against ptbase's 99.1 %. For a wholly semi-transparent overlay the blend equation decides the result, and alpha-over's shortfall against a background-scaling blend is a*bg. So the shortfall should track the BACKGROUND, not the frame's own contribution. Measured on render, frame-suppressed render and capture, needing no placement: ptframe1 +0.772 vs +0.244, ptframe3 +0.797 vs +0.237. Not adopting anything. The Decoder has established no blend mode is on the disc for .t32 and both frames are kind 0, declared identically to elements drawn almost exactly right, so any blend I pick is authored and that is not mine to take alone. Recorded as an extension of their negative, not a challenge: the behaviour is real and replicated, so if nothing in the data selects it, something in the code does -- the draw path they named as untaken. BLOCKED.md updated: the ask is answered, as a negative. --- docs/port/BLOCKED.md | 21 +++++- docs/port/DECISIONS.md | 80 +++++++++++++++++++- tools/port/element-residual | 144 ++++++++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 2 deletions(-) create mode 100755 tools/port/element-residual diff --git a/docs/port/BLOCKED.md b/docs/port/BLOCKED.md index d8cc2f01..ff8c2118 100644 --- a/docs/port/BLOCKED.md +++ b/docs/port/BLOCKED.md @@ -147,7 +147,26 @@ HANDOFF. | ~~P1–P7 — the keyframe record layout~~ | ~~adopt the corrected pose/time pairing~~ | — | ✅ **ADOPTED 2026-08-29 by pinning `formats-pin-2026-08-29c`.** This row was wrong twice: it said the change *"cannot be taken yet"* and that it *"reaches the port only when that branch lands on `main`"*. **It arrives when the tag is pinned**, which is what MISSION §2's tagging rule exists for. ⚠️ And the knob I tested first, `SYLPHEED_KF_TIME_SHIFT`, is a **retired partial fix** that left pose 0 untimed — the real correction is the tagged crate's default, with the old reading behind `SYLPHEED_KF_TIME_LEGACY=1`. **The blast radius was far smaller than this row predicted**: under the correction *every pose is timed* (866 keyframes, 0 untimed), so `pose_at`'s synthetic-exit branch became dead code rather than wrong code and nothing needed re-deriving. Oracle: `publisher_logo` 1.00 %→**0.75 %**, `developer_logos` 0.39 %→**0.33 %**, `extras`' differing region collapsing from 736×525 to **398×295 at the sweep position**. 🔴 Open cost: `sylpheed-cli` builds from the workspace crate, so `verify-screen` compares two decoder eras until the tag reaches `main`. Revert to the path dependency then. | | ~~P7 / naming — the four unnamed builds~~ | ~~which locale and variant is each of entries 0, 1, 12, 15?~~ | — | ✅ **answered 2026-08-29** (`docs/re/ui-title-build-map.md`): all four are the loading screen, two variants — plain (7 elements) and dressed (10) — decoded from their own `pgloading_*` element names. ⚠️ **Not adopted as names yet, for two reasons the RE agent gave and one the port found.** Theirs: the executable names exactly two, and *which* bundle takes which name is 🟡 undecided, so `LOADING`/`LOADING2` must not go in an asset path; and locale is 🟡 — the English member of a pair is the one in the first half of `GP_TITLE.p00`, 8/8 structurally but only 3/3 where a capture can check, and the three pairs that matter are the three no capture can check. Mine: **the message gives the bundles as "0/1 and 10/11", which is the `is_build` ordinal, and `authored/screen_names.json` is keyed by PAK ENTRY** — in entry space 10 and 11 are `palogo_sqex` and `palogo_gamearts`, the splashes. See the refutation section in `DECISIONS.md`. | -## New ask, 2026-08-31 — derived from HANDOFF `5a7f34d`, at port `HEAD` `6af06bd` +## ✅ ANSWERED SAME DAY, and the answer is a negative — HANDOFF `5a7f34d`, port `HEAD` `6af06bd` + +**Asked: what blend mode do `ptframe1` and `ptframe2` use on the main menu?** + +🔴 **There is none on the disc.** The Decoder read all 15 words of the 60-byte +`.t32` declaration: three are the name, eight are constant across every element, +the rest are kind, focus index, position and pivot. **Both frames are kind 0 — +identical to `ptbase`, `pteff05`, `pteff10`, `pteff12` and `ptmsg`.** Nothing +distinguishes them. They refuted their own single candidate (T8aD `+0x08` = 0x8050) +before sending: 38 sprites carry it disc-wide, only 8 named frame, and its high +byte tracks the archive — an atlas word. +[`docs/re/structures/t32-blend-mode-not-on-disc.md`] + +📌 **So any blend the port picks is AUTHORED**, and my refusal to brighten these is +now evidenced rather than principled. ⚠️ Their stated reach: the negative covers +the *data*. **The executable's draw path is the route they have not taken**, and a +mode selected in code rather than data would live there — see the measurement in +`DECISIONS.md` arguing something must be there. + +**(original ask below, kept because the measurement it carries is still current)** **What blend mode do `ptframe1` and `ptframe2` use on the main menu?** diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index e7e83ffe..2732e585 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -9,7 +9,7 @@ dies, which is what this file is for. -327 sections. Search this before re-deriving anything. +328 sections. Search this before re-deriving anything. * [P0 — the exporter, 2026-08-28](#p0--the-exporter-2026-08-28) * [P1 — Godot draws the screen, 2026-08-28](#p1--godot-draws-the-screen-2026-08-28) @@ -338,6 +338,7 @@ dies, which is what this file is for. * [`GP_DIALOG` 2/3 restored to `authored/flow.json` — on a measurement this time](#gp_dialog-23-restored-to-authoredflowjson--on-a-measurement-this-time) * [The residual map: no local displacement either, and the split I expected is not there](#the-residual-map-no-local-displacement-either-and-the-split-i-expected-is-not-there) * [Suppression beats coordinates: the menu residual is two frame elements, drawn too dark](#suppression-beats-coordinates-the-menu-residual-is-two-frame-elements-drawn-too-dark) +* [The frames generalise, premultiplied alpha is refuted, and the shortfall tracks the background](#the-frames-generalise-premultiplied-alpha-is-refuted-and-the-shortfall-tracks-the-background) ## P0 — the exporter, 2026-08-28 @@ -15935,3 +15936,80 @@ at 100.0 % of base-visible pixels."* confirms: not "100 % of sampled base-visible pixels are covered" but "replacing the base with opaque magenta changes the frame in zero pixels, exactly". I went looking for a bug and independently re-derived a documented measurement. + +## The frames generalise, premultiplied alpha is refuted, and the shortfall tracks the background + +`tools/port/element-residual` generalises last iteration's suppression method: pose +a screen as `verify-capture` does, shadow each sprite with a transparent PNG, and +rank elements by residual on the pixels they actually paint. Two controls, both +mandatory — the metric's zero on identity, and **a mod that shadows nothing must +move zero pixels**, or a footprint is the harness rather than the element. + +### It generalises: four frames, two screens, ranks 1 and 2 on both + +| screen | element | foot % | \|resid\| | ×frame | edge | flat | signed | +|---|---|---|---|---|---|---|---| +| `main_menu` | **`ptframe1`** | 0.45 | 22.72 | **9.47×** | 19.85 | 25.41 | **−22.72** | +| `main_menu` | **`ptframe2`** | 0.50 | 13.09 | 5.46× | 9.82 | 16.54 | −12.31 | +| `extras` | **`ptframe3`** | 0.40 | 34.80 | **14.23×** | 32.41 | 41.93 | **−34.80** | +| `extras` | **`ptframe4`** | 0.40 | 25.58 | 10.46× | 23.29 | 33.55 | −25.36 | + +📌 **And the sign splits, so this is not the port being globally dark.** Frames are +negative; `ptmsg` **+5.02**, `ptmsg2` **+8.83**, `pttitle` **+7.88** and every +button **+1.45…+4.07** are *too bright*. A global tone error cannot do that. + +### 🔴 Premultiplied alpha: my hypothesis, refuted by its own prediction + +The obvious exporter-side cause is a premultiplied-alpha texture decoded as +straight alpha, which darkens exactly where alpha is partial. **It predicts error +∝ partial-alpha fraction. The opposite holds:** + +| element | % partial alpha | signed | +|---|---|---| +| `ptframe1` | **7.3 %** | **−22.72** | +| `ptframe3` | **6.7 %** | **−34.80** | +| `pteff10` | **100 %** | −1.33 | +| `ptbase` | 0.9 % | +1.04 | + +✅ **Dead.** The most-darkened elements have the *least* partial alpha, and the one +element that is entirely partial is almost exactly right. + +### What the frames actually are, and where the shortfall goes + +**Neither frame has a single fully-opaque pixel** — 0 % at alpha ≥ 99 %, against +`ptbase`'s 99.1 %. They are wholly semi-transparent overlays, the one class where +the compositing equation decides the result. + +That gives a falsifiable prediction. Under alpha-over, the shortfall against a +background-scaling blend is `a · background`, so it scales with **what is behind +the frame** — whereas a too-dark texture would scale with **the frame's own +contribution**. Using only the render, the frame-suppressed render and the capture, +so no placement or coordinate convention is assumed: + +| | r(shortfall, **background**) | r(shortfall, frame's contribution) | +|---|---|---| +| `ptframe1` (`main_menu`) | **+0.772** | +0.244 | +| `ptframe3` (`extras`) | **+0.797** | +0.237 | + +✅ **Replicated on two elements on two screens.** The missing light scales with the +background, which is what an additive or screen blend predicts and what a bad +texture does not. Implied `a` medians 0.316 and 0.532. + +### ⚠️ What I am NOT doing about it + +The Decoder has established there is **no blend mode on the disc** for `.t32` — both +frames are kind 0, declared identically to elements the port draws almost exactly +right (`ptbase` ×1.31, `pteff05` ×0.92). **So any blend I choose is authored**, and +adopting one on my own authority is precisely what the mission forbids. I am +proposing it, not taking it. + +📌 **The measurement does say something their negative does not cover, and I want +it on the record as an extension rather than a challenge:** the behaviour exists +and is large and replicated, and if nothing in the *data* selects it, then it is +selected in **code** — the executable's draw path, which they named as the route +they have not taken. Their negative and this measurement are consistent; together +they locate the remaining question rather than closing it. + +⚠️ Not settled: whether it is additive, screen, or something else — `+0.77` and +`+0.80` say "scales with the background", not which curve. Two screens; I have not +checked the title. diff --git a/tools/port/element-residual b/tools/port/element-residual new file mode 100755 index 00000000..c2b6c676 --- /dev/null +++ b/tools/port/element-residual @@ -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.")