diff --git a/authored/rendering.json b/authored/rendering.json index 9ce052a2..1c341e3d 100644 --- a/authored/rendering.json +++ b/authored/rendering.json @@ -102,5 +102,65 @@ "", "What settles the menu: a direct capture of it, which the Decoder has offered." ], - "loop_leaf_kind": "measured" + "loop_leaf_kind": "measured", + "additive_elements": { + "main_menu": [ + "pteff12", + "ptframe1", + "ptframe2" + ], + "extras": [ + "pteff20", + "ptframe3" + ] + }, + "additive_elements_why": [ + "TRANSCRIBED, not authored, and the distinction is the whole entry. The port", + "composited every element with straight alpha-over and four came out too dark", + "against the capture. I measured from my side that the shortfall scales with", + "the BACKGROUND, and that of alpha-over / additive / screen, additive roughly", + "halves the error (DECISIONS.md, two-background composite solve, control 0.0000).", + "That was a PROPOSAL and stayed one: nothing on the disc selects a blend mode", + "(`docs/re/structures/t32-blend-mode-not-on-disc.md`), so choosing one was not", + "mine to do.", + "", + "It is now MEASURED OFF THE RUNNING GAME. The Decoder logged RB_BLENDCONTROL0", + "per draw in Canary and drove the game to both screens:", + "`docs/re/structures/ui-blend-mode-measured.md`, table in", + "`docs/re/data/ui-blend-mode-measured.txt`. 0x01010101 is src=ONE dst=ONE,", + "additive. Elements are identified by the PIXEL SIZE of their quad against", + "sprite dimensions.", + "", + "✅ Their control, and it is what makes this a blend result rather than a shader", + "result: ONE pixel shader, 0xE59B2B3DA4AA9008, runs with BOTH states on the main", + "menu -- 12 draws additive and 18 alpha-over. The frames and `ptbase` share a", + "shader. Only the blend register differs.", + "", + "⚠️ EVERY ID HERE IS A MEASURED DRAW, and the list stops where the measurement", + "does. THREE THINGS ARE NOT IN IT:", + "", + " `ptframe4` -- NOT MEASURED. It never appears in either capture's draw list,", + " and the port measures it at 10.46x the frame mean, dark, exactly like its", + " three siblings. It is the most tempting entry on this page and it is not", + " going in: 'the other three frames are additive' is a RULE, and the Decoder's", + " own warning with the table was to take it as per-element facts because which", + " field selects the mode is still unknown. A fourth frame added on the strength", + " of a pattern would be indistinguishable from a measured one in a month.", + "", + " The two unidentified additive draws per screen (889.6x1134.0 and", + " 1299.2x1303.2) -- these are the rotated sweep strips, whose height the", + " Decoder's OWN control reproduces independently. Additive, but the port draws", + " them through `draw_leaf_for` and I have not checked that path.", + "", + " The 819.2x720.0 additive draw, which matches no sprite on either screen.", + "", + "⚠️ AND NOT PREMULTIPLIED TEXTURES. The measured non-additive state is", + "0x07010701 -- src=ONE, which is the PREMULTIPLIED form of alpha-over. It would", + "be easy to read 'so the textures are premultiplied and the port's straight", + "alpha-over is wrong everywhere'. The Decoder flagged this before I could: src", + "being ONE means the fixed-function stage multiplies the SHADER's output by 1,", + "and the shader has not been read. The port's alpha-over path measures 0.92-1.31x", + "the frame mean on `ptbase`/`pteff05`, so it is not visibly wrong. Nothing here", + "changes it." + ] } diff --git a/docs/port/BLOCKED.md b/docs/port/BLOCKED.md index ff8c2118..e2426389 100644 --- a/docs/port/BLOCKED.md +++ b/docs/port/BLOCKED.md @@ -147,6 +147,38 @@ 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 (today's blend-mode delivery), port `HEAD` `0a054d6` + +**Four elements on EXTRAS are in no draw capture: `ptframe4`, `pteff21`, `pteff22`, +`pteff23`. What blend mode do they use?** + +The measured table (`docs/re/data/ui-blend-mode-measured.txt`) named `pteff20` and +`ptframe3` additive on EXTRAS, and the port now draws those two that way. **The +result is exactly the shape you want and exactly why this ask matters:** + +| EXTRAS element | before | after | signed after | +|---|---|---|---| +| **`ptframe3`** — measured, applied | 34.80 | **7.97** | **−0.61** | +| **`ptframe4`** — *not measured, left alone* | 25.58 | **31.90** | −31.81 | +| `pteff21` / `22` / `23` — *not measured* | 10.25 / 10.63 / 10.09 | 14.34 / 13.15 / 12.04 | all negative | + +📌 **Where the blend is measured the element is now near-exact; where it is not, +it is the worst thing on the screen.** The four unmeasured ones also got *worse* in +absolute terms, which is consistent rather than alarming: their neighbours are now +correctly brighter, so an alpha-over deficit that scales with the background scales +up with it. + +🔴 **I am not inferring them from the pattern.** `ptframe4` is the third frame on a +screen whose other frame is measured additive, and it is dark, and additive would +plainly help. That is precisely the argument I must not act on — the Decoder's own +warning with the table was to read it as **per-element facts**, because which field +selects the mode is still unknown, and a fourth frame added by pattern would be +indistinguishable from a measured one in a month. + +⚠️ They appear in **no** captured draw on either screen, so this may need a +different pose rather than a re-read: they may simply not have been drawing in the +frames that were captured. + ## ✅ 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?** diff --git a/docs/port/DECISIONS.md b/docs/port/DECISIONS.md index 6068ca6c..4f06a1b3 100644 --- a/docs/port/DECISIONS.md +++ b/docs/port/DECISIONS.md @@ -9,7 +9,7 @@ dies, which is what this file is for. -330 sections. Search this before re-deriving anything. +332 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) @@ -341,6 +341,8 @@ dies, which is what this file is for. * [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) * [Which blend? Additive halves the error, on both frames — proposed, not adopted](#which-blend-additive-halves-the-error-on-both-frames--proposed-not-adopted) * [Refutation attempt: the Decoder's kind-0 claim survives, checked from my own data](#refutation-attempt-the-decoders-kind-0-claim-survives-checked-from-my-own-data) +* [The blend is measured, so the port draws it — main_menu 13.21 → 10.67](#the-blend-is-measured-so-the-port-draws-it--main_menu-1321--1067) +* [🔴 Refuted: my "no fully-opaque pixel" sharpener](#refuted-my-no-fully-opaque-pixel-sharpener) ## P0 — the exporter, 2026-08-28 @@ -16085,3 +16087,89 @@ field agree, and the claim is now stronger than when only one side had read it. declared *identically* to `ptbase` (drawn at 1.31× the frame mean) and `pteff05` (0.92×). Same declaration, opposite accuracy — so whatever distinguishes them is not in the field either of us can read. + +## The blend is measured, so the port draws it — main_menu 13.21 → 10.67 + +The Decoder took the draw-path route and logged `RB_BLENDCONTROL0` per draw in +Canary on both screens. `0x01010101` is `src=ONE dst=ONE`: **additive**. That +turns my proposal into a transcription, and they said so explicitly — *"withdraw +the instruction I gave you last time; additive is transcribed now, not authored."* + +📌 **Their control is what licenses the change:** one pixel shader, +`0xE59B2B3DA4AA9008`, runs with **both** blend states on the main menu — 12 draws +additive, 18 alpha-over. The frames and `ptbase` share a shader. **Only the blend +register differs**, so this is a blend result and not a shader result. + +Recorded in `authored/rendering.json` as `additive_elements`, per screen, with +every id being a measured draw and the reach written next to it. + +### The result, and a neutrality control that came free + +| screen | before | after | +|---|---|---| +| **`main_menu`** | 13.21 / 0.06 % | **10.67 / 0.02 %** | +| **`extras`** | 13.38 / 0.20 % | **11.43 / 0.07 %** | +| main menu, focus `ptbtn04` | 13.82 / 0.15 % | 11.36 / 0.11 % | +| title / title_plate / title_band | 14.16 / 13.04 / 12.86 | 14.10 / 13.03 / 12.85 | +| **`publisher_logo`** | **2.17** | **2.17** | +| **`developer_logos`** | **3.05** | **3.05** | + +✅ **The last two rows are the control.** They are the screens `verify-capture` +marks *"no free-running element — absolute, means what it says"*, and they have no +additive element. They did not move **at all**. The rewrite routed every draw in +the project through `RenderingServer` canvas items, so "did the plumbing change +the picture?" was a live question; those two rows answer it. The improvement is +the blend, not the refactor. + +Per element on `main_menu`: `ptframe1` **22.72 → 4.17** (signed −22.72 → −3.51), +`ptframe2` 13.09 → 3.32 (−12.31 → **+1.80**), whole-screen mean 2.40 → 1.55. + +### 🔴 The change ran, produced a number, and the number was wrong by looking right + +First run after wiring it all up: `ptframe1` moved from **22.72 to 22.69**. Bands +were created, ordered and assigned correctly; the screen composited exactly as +before. I had written `CanvasItemMaterial.new()` and **never set `blend_mode`**, so +every band was MIX. + +📌 Nothing errored. A 0.03 move is a *plausible* outcome — I could have written +"additive does not help after all, the two-background solve overstated it" and it +would have read as a careful negative result. **It was caught only because the +measurement predicted a large move and 0.03 is not one.** The comment now sits on +that line. + +### Why it is `RenderingServer` and not child nodes + +Godot sets blend mode per **canvas item**, not per draw call. The obvious +implementation — a child `Node2D` per band with a `CanvasItemMaterial` — **loses a +frame**: `boot.gd` calls `view.queue_redraw()` from nine places and none reaches a +child node, so bands would paint the *previous* pose. Under `--script=wait` that +surfaces as a plausible wrong capture, not an error. The bands are canvas items +filled synchronously inside `_draw()` instead. + +The runs are recomputed every frame rather than cached. The additive elements are +consecutive in paint order on both measured screens — **that is an accident of +those two screens**, and a cache keyed on "the additive block" would be right today +and silently wrong on the first screen that interleaves. + +### ⚠️ What I did not do: `ptframe4` + +`ptframe4` is now the worst element on EXTRAS (31.90, 16.19× the frame mean) and +additive would plainly help it. **It is not in the measured table and it is not in +the file.** Filed in `BLOCKED.md` with `pteff21`/`22`/`23`, which are also absent +from every captured draw. Where the blend is measured the element is near-exact +(`ptframe3` signed **−0.61**); where it is not, it is the worst thing on screen. +That contrast is the argument for asking rather than inferring. + +## 🔴 Refuted: my "no fully-opaque pixel" sharpener + +I offered, as the thing that distinguished the frames, that **neither frame has a +single fully-opaque pixel** against `ptbase`'s 99.1 %. The Decoder refuted it with +a census (`docs/re/data/menu-sprite-alpha-census.txt`): **`pteff10` has max alpha +130, is 100 % partial, has no opaque pixel either — and I measure it as nearly +exact.** `pteff12`, `pteff20` and `pteff21`–`23` likewise. + +✅ **The observation was true and it was not the discriminator.** My *direction* +survived — the draw path was the right place to look, and it answered — but the +reason I gave for looking there was wrong. Recording it because the conclusion +being vindicated is exactly the circumstance in which a bad supporting argument +survives unexamined. diff --git a/port/scripts/boot.gd b/port/scripts/boot.gd index 05425182..13534df4 100644 --- a/port/scripts/boot.gd +++ b/port/scripts/boot.gd @@ -285,6 +285,7 @@ func _ready() -> void: var rendering: Variant = export_tree.authored("rendering.json") _draw_leaf_for = [] if rendering == null else rendering.get("draw_leaf_for", []) _loop_leaf_screens = [] if rendering == null else rendering.get("loop_leaf_on_screens", []) + _additive_elements = {} if rendering == null else rendering.get("additive_elements", {}) # `--no-hold` plays a screen's groups PAST their rest instead of clamping each # element at its own `rest.t`. A diagnostic, not a mode: `rest.t` is the last # HOLD keyframe before the exit, not the settled state, and the only way to @@ -312,6 +313,7 @@ func _ready() -> void: view.looping_focus = _looping_for(name) view.loop_phase_units = _loop_phase view.draw_leaf_for = _draw_leaf_for + view.additive_elements = _additive_elements view.loop_leaf = _loop_leaf_screens.has(name) view.focused_id = _force_focus if not view.load_screen(export_tree, name): @@ -406,6 +408,8 @@ var _pending: Variant = null var _looping: Dictionary = {} ## `authored/rendering.json` `draw_leaf_for`. var _draw_leaf_for: Array = [] +## `authored/rendering.json` `additive_elements` -- measured off the running game. +var _additive_elements: Dictionary = {} ## `authored/rendering.json` `loop_leaf_on_screens`. var _loop_leaf_screens: Array = [] var _script: PackedStringArray = PackedStringArray() @@ -558,6 +562,7 @@ func _advance() -> void: view.looping_focus = _looping_for(name) view.loop_phase_units = _loop_phase view.draw_leaf_for = _draw_leaf_for + view.additive_elements = _additive_elements view.loop_leaf = _loop_leaf_screens.has(name) if not view.load_screen(view.tree, name): push_error(view.tree.error) @@ -917,6 +922,7 @@ func _menu_arrive() -> void: view.looping_focus = _looping_for(name) view.loop_phase_units = _loop_phase view.draw_leaf_for = _draw_leaf_for + view.additive_elements = _additive_elements view.loop_leaf = _loop_leaf_screens.has(name) if not view.load_screen(view.tree, name): push_error(view.tree.error) diff --git a/port/scripts/screen_view.gd b/port/scripts/screen_view.gd index 1cf5324f..c5b95ef2 100644 --- a/port/scripts/screen_view.gd +++ b/port/scripts/screen_view.gd @@ -145,6 +145,32 @@ var loop_phase_units: float = -1.0 ## consistency harness compares against a renderer that draws no leaves at all. var draw_leaf_for: Array = [] +## Elements the game draws ADDITIVELY, by screen -- `authored/rendering.json` +## `additive_elements`. Measured off the running game per draw; see that file for +## what is in the list and, more importantly, what is not. +var additive_elements: Dictionary = {} +## The canvas items backing the paint-order runs. See `_band`. +var _bands: Array[RID] = [] +## The canvas item the next `_draw_quad` paints into. It is a member rather than +## a parameter because every draw funnels through `_draw_quad` from three call +## sites, and threading a RID through `_draw_leaf` and `_draw_focus` would change +## their signatures to carry a value neither of them chooses. +var _target: RID = RID() +## 🔴 THIS USED TO BE `CanvasItemMaterial.new()` AND NOTHING ELSE, so the blend +## mode was its default, MIX. Every band was created, ordered and assigned +## correctly and the screen composited exactly as before: `ptframe1` moved from +## 22.72 to 22.69. That is the failure mode this port keeps meeting -- the change +## ran, produced a number, and the number was WRONG BY BEING RIGHT-LOOKING. It was +## caught only because the measurement predicted a large move and a 0.03 move is +## not one. +var _additive_material := _make_additive() + + +static func _make_additive() -> CanvasItemMaterial: + var m := CanvasItemMaterial.new() + m.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD + return m + ## Whether this screen replays a leaf's group. See `authored/rendering.json`. var loop_leaf := false @@ -524,20 +550,49 @@ func _template_instance_ids() -> Dictionary: ## capture at a known angle. func _draw_quad(tex: Texture2D, rect: Rect2, colour: Color, pivot: Vector2, pos: Vector2, rotation_deg: float) -> void: + var ci := _target if _target.is_valid() else get_canvas_item() if is_zero_approx(rotation_deg): - if tex != null: - draw_texture_rect(tex, rect, false, colour) - else: - draw_rect(rect, colour, true) + _add_quad(ci, tex, rect, colour) return var anchor := pos + pivot - draw_set_transform(anchor, deg_to_rad(rotation_deg), Vector2.ONE) - var local := Rect2(rect.position - anchor, rect.size) + RenderingServer.canvas_item_add_set_transform(ci, + Transform2D(deg_to_rad(rotation_deg), anchor)) + _add_quad(ci, tex, Rect2(rect.position - anchor, rect.size), colour) + RenderingServer.canvas_item_add_set_transform(ci, Transform2D()) + + +func _add_quad(ci: RID, tex: Texture2D, rect: Rect2, colour: Color) -> void: if tex != null: - draw_texture_rect(tex, local, false, colour) + RenderingServer.canvas_item_add_texture_rect(ci, rect, tex.get_rid(), false, colour) else: - draw_rect(local, colour, true) - draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) + RenderingServer.canvas_item_add_rect(ci, rect, colour) + + +## 🔴 WHY THE DRAWING GOES THROUGH `RenderingServer` AND NOT `draw_texture_rect`. +## +## Godot sets the blend mode on a CANVAS ITEM, not on a draw call, so an additive +## element cannot simply be drawn differently inside one `_draw()`. The measured +## fact is per element (`authored/rendering.json` `additive_elements`), so the +## screen is split into RUNS of consecutive paint-order entries sharing a blend +## mode and each run gets its own canvas item, ordered by `canvas_item_set_draw_index`. +## +## ⚠️ The obvious implementation -- child `Node2D`s with a `CanvasItemMaterial` +## each -- LOSES A FRAME. `boot.gd` calls `view.queue_redraw()` from nine places +## and none of them reaches a child node, so the bands would paint the previous +## pose. A capture taken with `--script=wait` would have shown that as a plausible +## wrong answer rather than as an error. These items are filled synchronously +## inside `_draw()` instead, so there is no second node to keep in step. +func _band(i: int, additive: bool) -> RID: + while _bands.size() <= i: + var ci := RenderingServer.canvas_item_create() + RenderingServer.canvas_item_set_parent(ci, get_canvas_item()) + _bands.append(ci) + var item: RID = _bands[i] + RenderingServer.canvas_item_clear(item) + RenderingServer.canvas_item_set_draw_index(item, i) + RenderingServer.canvas_item_set_material(item, + _additive_material.get_rid() if additive else RID()) + return item static func _rot_of(pose: Dictionary) -> float: @@ -706,9 +761,33 @@ func _draw() -> void: var ghosts := _template_instance_ids() skipped.clear() drawn.clear() - for index: int in screen.get("paint_order", []): + # The runs are computed from the paint order every frame rather than cached, + # because the additive elements happen to be CONSECUTIVE on both screens that + # have a measurement and that is an accident of those two screens. A cache + # keyed on "the additive block" would be correct today and silently wrong on + # the first screen that interleaves. + var additive_here: Array = additive_elements.get( + String(screen.get("name", "")), []) + var order: Array = screen.get("paint_order", []) + var band_of := {} + var band_additive: Array[bool] = [] + var prev := -1 + for index: int in order: + var add_it: bool = additive_here.has(String(elements[index].get("id", ""))) + if prev == -1 or add_it != band_additive[prev]: + band_additive.append(add_it) + prev += 1 + band_of[index] = prev + for i in band_additive.size(): + _band(i, band_additive[i]) + # Runs left over from a screen with more of them would still hold last + # frame's commands and paint over this one. + for i in range(band_additive.size(), _bands.size()): + RenderingServer.canvas_item_clear(_bands[i]) + for index: int in order: var element: Dictionary = elements[index] var id: String = element.get("id", "") + _target = _bands[band_of[index]] if ghosts.has(index): skipped.append("%s (template instance)" % id) continue