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