diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 2676c2af..f579d637 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -1786,3 +1786,56 @@ loop during development: them is wrong and the disagreement is worth reporting back. * `docs/re/captures/` — framebuffer captures of the real screens, for anything that has to be checked against the game rather than against our renderer. + +## 2026-08-29 — the settled screen is one instant (`settle_units` is decodable) + +✅ **decoded.** You author `settle_units` per screen. The disc gives it: +`UiBuild::settle_time()` returns the midpoint of the **longest keyframe-free +interval** in a build, and `settle_window()` returns the interval so you can judge +it. Computed from the keyframe table alone — no capture involved. + +**Why it matters beyond saving an authored constant.** Our renderer posed each +element at `Element::rest()`, its last *hold* keyframe, chosen independently of +every other element. For a transient that is exactly wrong: `ptlogo_back2eff1` is +a two-frame flash (`a=0` until t52, `255` at t54–56, `0` by t58), so its last hold +*is* the peak and it burned forever. `GP_TITLE` build 4 has **five** such flashes +— one light sweep drawn as five staggered frames, all out by t110 — and drawing +them at once saturated the light arc behind the logo. + +Against `live-title-build4-no-plate.png`, posing at the predicted t=198: + +| | mean abs diff | arc band | pixels at clipping | +|---|---|---|---| +| console capture | — | — | **1 459** | +| `rest()` | 14.07 | 33.22 | 8 581 | +| **`--at 198`** | **12.06** | **11.79** | **1 452** | + +The clipped count is unfitted and lands within 0.5 % of the console's. + +⚠️ **Scope it.** Of the 1 758 composable bundles with ≥ 2 keyframe times, only +**30 %** have a window ≥ 30 units; **42 %** have one under 10 units. The latter are +mostly `loop*` fragments — they are *meant* to be in motion and have no settled +pose. Check `settle_window()`'s width before taking the midpoint. + +🔴 **A retraction you should act on.** I previously told you Reborn "does not draw +`ptlogo1` / `ptlogo2` at all", and that our two renderers were therefore not +comparable on the title. **Both are wrong.** Build 4 declares *six* ptlogo +elements: indices 0 and 1 are kind `0x0`, alpha 255, and are drawn; indices 2–5 +are kind `0x4` ghost instances at (−116,−7) and (437,508), alpha 0, skipped +deliberately. Hiding element 0 makes the error *worse* by +5.20 whole-frame and ++7.61 in the band. The renderers are comparable, and the ptlogos were never the +residual. + +🔴 **And `ComposeOptions::at` posed leaves only** — my own fix for a bug I +mis-diagnosed. That is why the earlier rotation pose scan was flat over t = 0…600: +it moved the sweeps and never touched the top-level flashes. `at` now poses +everything; `at = None` is byte-identical (verified with `cmp`) and the +pre-rotation tag renders identically at rest, so nothing regressed. + +❔ **Not settled:** the remaining 12.06, which is broad and level-like rather than +spatial — consistent with the tone term. And the **10.92** baseline quoted in +`ui-rotation-implemented.md` is **not reproducible**: the same command gives 14.07 +at that document's own pre-change tag and 14.07 today. Treat conclusions resting +on it as unverified. + +Detail, controls and census: [`docs/re/structures/ui-settle-time.md`](../re/structures/ui-settle-time.md). diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index b08454bc..076c7466 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -167,3 +167,4 @@ files, which is how the same ground got covered twice. | [`structures/slb-bank-header-not-a-wave.md`](structures/slb-bank-header-not-a-wave.md) | Why a music bank read as THREE sub-waves when the census says two | ✅ **decoded**: the third is the **bank header**, emitted by our own reader. `to_xma_riffs`'s hybrid branch derives a leading packet stream's start as `first_riff % 2048`, which is right only for a header shorter than one packet; a music bank's header is exactly **5 packets (10 240 B)**, so the modulus gave 0 and the whole header came back as sub-wave 0. The header states its own length at `+0x24` in blocks. Disc-wide over 9 519 `sound.pak` entries: **28** match the header signature at offset 0 (ids 1001–1023, 1101–1105), **28/28** end exactly at the first `RIFF`, **0** have a gap, **0** false positives — so a header at offset 0 and a leading packet stream never coexist. Decode control, same chain, same bank: the emitted region gives **0.009 s** against **87.744 s** for the real wave 0. Corroborated by the runtime XMA probe, which saw exactly two streams at the main menu. Fixed + 2 regression tests; the `VOICE_D_453` recovery is untouched (10/10 green) | | [`title-plate-delay-measured.md`](title-plate-delay-measured.md) | How long the boot title shows build 4 before the `PRESS Ⓐ` plate | ✅ **decoded after a refutation**: build 2 and build 4 run on **one clock started together**, and the plate's own `ptbtn00` reaches `a=255` at `t=238`; the last build-in ramp ends at `t=118`, so the interval is a declared **120 units = 2.000 s**. 🔴 The instruction that shipped first — "wait 2.13 s after build 4 settles" — was **refuted by the port** with disc arithmetic and is corrected in place; 🔴 `rest.t` is **not** when a screen settles (it is the last hold keyframe before the exit: `ptlogo1` rests at `t=251` and stops moving at `t=42`). ⚠️ The wall-clock 2.13 s is 6.7 % long because Canary presents at **28.06 / 28.14 fps** against a nominal 30, matching the corpus's independent **28.5 fps**; author the 120 units. ✅ **measured**, two independent boots: **2.138 s** and **2.132 s** from the frame build 4 settles (glyph = its no-plate 154, motion → 0). Agreeing to **6 ms**. So the boot title's end state is **not** plate-free and a compositor must draw **two builds at once**. ⚠️ Measure from *settled*, not from first pixels — "first drawn → plate" is 3.78 s vs 4.26 s across the same two runs, because the build-in animation's own duration varies with emulator frame pacing. Plate pulse re-measured at 2.12/2.19/2.34/2.31 s (mean 2.24), replicating the corpus's ≈2.3 s. ✅ black hold between screens bracketed at **0.14–0.30 s**, consistent with the declared 12 units. 🔴 the Ⓐ→menu latency is still **not** available: both runs freeze one frame for ~1.4 s at surface mean **26.626** — agreeing between runs to 1e-6, and reproduced with stream restarts disabled — which is a guest **load stall**, not the capture path. Probe: 8.7 ms/frame, 7.97/7.98 fps against a requested 8, controls 9/9 + 4/4 | | [`menu-idle-and-b-2026-08-29.md`](menu-idle-and-b-2026-08-29.md) | The main menu does not idle back to the title — and four durations that were a pipeline | ✅ **refuted**: no self-return in **≥ 60 s** untouched; the ~8–10 s idle belongs to the **title**. 🟡 Ⓑ→title ordering measured, latency not. 🔴 `classify_array` at **1503 ms/frame** drained an 8 fps stream at 0.64 fps and manufactured four latencies (24.66 s / 15.58 s / 25.60 s / 20.26 s) — all withdrawn; a backlog preserves ordering and destroys durations | +| [`structures/ui-settle-time.md`](structures/ui-settle-time.md) | Which instant a "settled screen" composite depicts | ✅ **decoded**: a settled screen is **one instant every element is posed at**, and the disc names it — the midpoint of the **longest keyframe-free interval** in the build (`UiBuild::settle_time` / `settle_window`). 🔴 `rest()` is *not* that: it picks each element's last hold **independently**, so a two-frame flash holds at its **peak** and burns forever. `GP_TITLE` build 4 has five staggered flashes (`ptlogo_back2eff1`…`eff5`, all extinguished by t110) that `rest()` draws simultaneously and permanently, saturating the light arc. Predicted t=198 from `[160,236]` **before scoring**: arc band **33.22 → 11.79**, clipped pixels **8 581 → 1 452** against the console's **1 459** (an unfitted statistic), whole frame 14.07 → 12.06; controls at t=100 and t=358 are far worse, and a hand-picked visibility list reaches the identical 12.06/11.79/1 452. Controls: `at=None` byte-identical (`cmp`), pre- and post-rotation tags both 14.07, 13 paint-order tests green. ⚠️ **Reach**: of 1 758 bundles with ≥2 keyframe times only **30 %** have a window ≥ 30 units and **42 %** under 10 — mostly `loop*` fragments that never settle; check the width. 🔴 Withdraws two claims in [`ui-rotation-implemented.md`](structures/ui-rotation-implemented.md) — its "Flat. No minimum." (`at` posed **leaves only**) and its "Reborn does not draw `ptlogo1`/`ptlogo2`" (both **are** drawn; only kind-`0x4` ghosts are skipped, and hiding the real ones makes the error *worse* by +5.20/+7.47). ❔ its **10.92** baseline is unreproducible — 14.07 at both tags | diff --git a/docs/re/METHOD.md b/docs/re/METHOD.md index b7bb9e7c..a3a26397 100644 --- a/docs/re/METHOD.md +++ b/docs/re/METHOD.md @@ -1025,3 +1025,37 @@ agent's loop prompt, i.e. nowhere durable. See [`README.md`](README.md) for the test needs cross-correlation to align first and an agreed downmix, and only then is a pass mark like ">40 dB down" meaningful. Reporting the 9 dB as a result would have been a confident wrong number. + +## A shared `CARGO_TARGET_DIR` makes a worktree build replace the binary you run + +`CARGO_TARGET_DIR=/sylph-home/re/target-container` is set for the whole container, +so **every checkout shares one target directory**. Build anything in a +`git worktree` — the obvious way to render from an old tag as a control — and the +binary at `$CARGO_TARGET_DIR/release/` is now the *other* checkout's. Cargo then +considers your main tree fresh and does not rebuild it. + +It cost three renders here that silently used a CLI with no `--at` flag, and the +only reason it was caught is that the missing flag was a hard error. **A stale +binary that merely produces slightly different numbers would have been believed.** + +After any worktree build, `touch` a source file and rebuild before measuring +anything — and prefer building the control's binary to an explicit +`--target-dir` of its own. + +## `rest()` is one element's last hold, not the settled screen + +`Element::rest()` picks each element's last **hold** keyframe *independently of +every other element*, so a composite built from it is not the screen at any moment +in time — it is a per-element maximum. For a transient this is exactly wrong: a +two-frame flash's last hold is the flash **peak**, so `rest()` leaves it burning +forever. + +Five such flashes stack on the title and saturate the light arc; the band's error +against the console was 33.22, and 8 581 pixels sat at the clipping level where +the console has 1 459. Posing every element at one shared instant instead — the +midpoint of the longest keyframe-free interval — takes those to 11.79 and 1 452. + +The general trap: **an aggregate computed per-element is not a state of the +system.** Ask what instant a composite claims to depict, and check that every +element was asked the same question. See +[`structures/ui-settle-time.md`](structures/ui-settle-time.md). diff --git a/docs/re/captures/title-builds/title-arc-rest-vs-settle.png b/docs/re/captures/title-builds/title-arc-rest-vs-settle.png new file mode 100644 index 00000000..14272281 Binary files /dev/null and b/docs/re/captures/title-builds/title-arc-rest-vs-settle.png differ diff --git a/docs/re/structures/title-residual-tone-vs-geometry.md b/docs/re/structures/title-residual-tone-vs-geometry.md index f7374512..7c54b6b5 100644 --- a/docs/re/structures/title-residual-tone-vs-geometry.md +++ b/docs/re/structures/title-residual-tone-vs-geometry.md @@ -1,5 +1,13 @@ # ✅ At least two thirds of the title's disagreement is GEOMETRY, not tone +> ⚠️ **The 10.92 title baseline here could not be reproduced on 2026-08-29** — +> `screen render --build 4 --black` against the same capture gives 14.07 both at +> `formats-pin-2026-08-29c` and today. The *ratio* this document argues for may +> well survive; the absolute is in doubt. Separately, part of the title's residual +> is now known to be neither tone nor rotation but `rest()` posing five transient +> flashes at their peaks — [`ui-settle-time.md`](ui-settle-time.md) closes the +> light-arc band from 33.22 to 11.79. + **Classification: measured**, on committed captures. Answers a question the pending rotation decision needs: *how much would drawing the rotation actually buy?* diff --git a/docs/re/structures/ui-rotation-implemented.md b/docs/re/structures/ui-rotation-implemented.md index 5c22b547..34d1fe50 100644 --- a/docs/re/structures/ui-rotation-implemented.md +++ b/docs/re/structures/ui-rotation-implemented.md @@ -1,5 +1,20 @@ # ✅ Option A implemented — the reference renderer rotates. ⚠️ It does not close the title. +> 🔴 **Two claims below are withdrawn (2026-08-29), and the baseline is +> unreproducible.** See [`ui-settle-time.md`](ui-settle-time.md). +> +> * **"Flat. No minimum."** is explained, not a property of rotation: +> `ComposeOptions::at` was posing **leaves only**, so the scan moved the light +> sweeps and never touched the top-level flashes. No `t` could have helped. +> `at` now poses every element and the scan has a clear optimum. +> * **"our renderer still does not draw `ptlogo1` / `ptlogo2` at all"** is +> **wrong**. Both are drawn. The four elements the diagnostic reported are kind +> `0x4` ghost instances, skipped deliberately. Hiding the real ones makes the +> error *worse*. +> * ❔ The **10.92** baseline is **not reproducible** — the same command gives +> 14.07 at this document's own pre-change tag and 14.07 today — so the "1.7 % +> better" verdict rests on a recipe that was not recorded. + **Decision:** the human chose **Option A** (2026-08-29) — teach `sylpheed-formats`' own renderer to draw `rotation_deg`, so it and the port stay comparable and `verify-screen` keeps meaning *"someone is wrong"*. diff --git a/docs/re/structures/ui-settle-time.md b/docs/re/structures/ui-settle-time.md new file mode 100644 index 00000000..77aba8b0 --- /dev/null +++ b/docs/re/structures/ui-settle-time.md @@ -0,0 +1,153 @@ +# The settled screen is one instant, not one hold per element + +**Classification: decoded.** The value comes from the keyframe table alone — +no capture is consulted to compute it — and it is checked disc-wide. The +verification against the console capture is a *test* of the decode, not its +source. + +## The claim + +`Element::rest()` returns an element's last **hold** keyframe, chosen for that +element independently of every other element. A composite built from `rest()` is +therefore not a screen at any moment in time; it is a per-element maximum. + +For an element that ends the screen settled, that is the same thing. For a +**transient** it is exactly wrong — a flash's last hold *is* the flash peak, so +`rest()` leaves it burning forever. + +The settled screen is instead **one instant that every element is posed at**, and +the disc says which instant: gather every keyframe time in the build and take the +**longest interval containing none of them**. Inside that gap nothing has an +inflection, so every element is either holding or midway along a single linear +ramp. That is what "the screen has stopped changing" means, expressed in the only +vocabulary the file has. + +`UiBuild::settle_time()` returns the midpoint of that interval; +`UiBuild::settle_window()` returns the interval, whose width is how much +confidence the midpoint deserves. + +## The case that found it + +`GP_TITLE` build 4. Seven elements share the light-arc band behind the logo: + +| element | timeline | at rest | at t=198 | +|---|---|---|---| +| `ptlogo_back2eff1.t32` | a=0 → **255 at t54–56** → 0 by t58 | **255** | 0 | +| `ptlogo_back2eff2.t32` | a=0 → **255 at t58–60** → 0 by t62 | **255** | 0 | +| `ptlogo_back2eff3.t32` | staggered, same shape | **255** | 0 | +| `ptlogo_back2eff4.t32` | staggered, same shape | **255** | 0 | +| `ptlogo_back2eff5.t32` | 255 at t64–66 → 192 at t74 → **0 by t110** | **255** | 0 | +| `ptlogo_back2eff.t32` | 255 at t66, **holds to t238**, exits t244 | 255 | 255 | +| `ptlogo_back2.t32` | 255 at t80, **holds to t243**, exits t249 | 255 | 255 | + +The five numbered ones are a single light sweep travelling left to right across +the logo, drawn as five staggered two-frame flashes. They are all extinguished by +t110. `rest()` draws them **simultaneously and permanently**, and five stacked +white glows (`ptlogo_back2eff5.t32` decodes to a mean opaque RGB of exactly +255,255,255) drive the arc to saturation. + +## What it looks like + +![the light arc: console, rest(), and --settle](../captures/title-builds/title-arc-rest-vs-settle.png) + +The console's arc is a **thin white outline with a pink hooked tail**. `rest()` +renders a fat solid white blob that swallows the tail completely — five white +glows stacked. `--settle` reproduces the console. + +⚠️ A measurement trap worth recording: sampling the *brightest 3 %* of that band +gives (252,245,239) for the console against (255,255,255) for `rest()` — nearly +neutral, and it reads as "no hue difference". That statistic samples the white +outline in both and never touches the pink tail. What it *did* expose was the +count: at a 97th-percentile threshold the console has 1 459 pixels above it and +`rest()` has 8 581, i.e. a saturated plateau. **The pixel count carried the +signal that the mean colour hid.** + +## The measurement + +Against +[`live-title-build4-no-plate.png`](../captures/title-builds/live-title-build4-no-plate.png), +whole frame and over the arc band (y 95–215, x 830–1230). The third column counts +pixels at or above the band's 97th-percentile luminance — a *saturation* statistic, +independent of the error being minimised, and not fitted. + +| | mean abs diff | arc band | clipped px | +|---|---|---|---| +| **console capture** | — | — | **1 459** | +| `rest()` — the default | 14.07 | 33.22 | 8 581 | +| **`--at 198` — predicted from the disc** | **12.06** | **11.79** | **1 452** | +| `--at 100` — control, before the window | 19.13 | 20.46 | 1 468 | +| `--at 358` — control, past every exit | 28.80 | 53.37 | 1 447 | + +**t=198 was computed before the render was scored.** `settle_window()` on build 4 +returns `[160, 236]`; 198 is its midpoint. A separate sweep of t in 40…300 finds a +flat optimum over t ∈ [180, 238] at 12.06 / 11.79 / 1 452, which contains the +prediction. + +⚠️ The clipped-pixel count discriminates `rest()` from *any* single instant — the +two controls also land near 1 459 — so it identifies the **blow-out**, not the +time. The time is identified by the mean, and by the disc. + +## The controls + +* ✅ **`at = None` is byte-identical.** `compose` with no `at` produces the same + bytes before and after this change (`cmp`, exact). The default path is untouched. +* ✅ **The change did not regress the resting composite.** Rendering build 4 at + `formats-pin-2026-08-29c` (pre-rotation, pre-leaf) and at the current tree both + give **14.07 / 33.22**. +* ✅ **A hand-picked visibility list reaches the same answer.** Leave-one-out over + all 24 elements, then hiding exactly `eff1`…`eff5` and keeping the two holders, + gives 12.06 / 11.79 / 1 452 — identical to `--at 198`. The principled rule + reproduces the hand-picked one with nothing hand-picked. +* ✅ **Disc-wide self-consistency.** For every bundle with a window, no element has + a keyframe strictly inside it, and the midpoint lies within it — + `tests/ui_settle_time_disc.rs`. + +## 🔴 Reach: this does not apply to every bundle + +Of the **1 758** composable bundles carrying two or more keyframe times: + +| | count | share | +|---|---|---| +| settle window ≥ 30 units (0.5 s) | 524 | 30 % | +| settle window < 10 units | 731 | 42 % | +| mean window | 49 units | — | + +The 42 % are mostly `loop*` animation fragments, which are **meant** to be in +motion and have no settled pose to find. **Check the window width before trusting +the midpoint.** A narrow window is the data saying "this bundle never settles", +not a settle time with a small error bar. + +## What this corrects elsewhere in the corpus + +* 🔴 [`ui-rotation-implemented.md`](ui-rotation-implemented.md) records the pose + scan as **"Flat. No minimum."** over t = 0…600. The cause is now known: + `ComposeOptions::at` was posing **leaves only**, so the scan moved the light + sweeps and never touched the top-level flashes. No `t` could have helped. `at` + now poses every element. +* 🔴 The same document blames the residual on our renderer **"not drawing + `ptlogo1` / `ptlogo2` at all (four elements)"**. That is **withdrawn**. Build 4 + declares *six* ptlogo elements: indices 0 and 1 are kind `0x0` at (184,193) and + (137,308), alpha 255, and are **drawn**; indices 2–5 are kind `0x4` ghost + instances at (−116,−7) and (437,508), alpha 0, and are skipped deliberately. + Hiding element 0 makes the error **worse** by +5.20 whole-frame and +7.61 in the + band; element 1 by +7.47. They are drawn and correctly placed. +* ❔ The **10.92** baseline in that document's table is **not reproducible**. + `screen render --build 4 --black` against that capture gives 14.07 at the + pre-change tag and 14.07 now. Some element of that recipe was not recorded. + Conclusions resting on 10.92 — including the "1.7 % better" verdict on rotation + — should be treated as unverified until the recipe is recovered. + +## What is not settled + +❔ The remaining **12.06**. It is broad and level-like rather than localised, +which is consistent with the tone term that +[`title-residual-tone-vs-geometry.md`](title-residual-tone-vs-geometry.md) +measures. The arc band is no longer where the error lives. + +## Reproducing + +```bash +cargo run -q -p sylpheed-cli -- screen render --build 4 --black --at 198 \ + "$SYLPHEED_DISC/dat/GP_TITLE.pak" /tmp/title.png +SYLPHEED_DISC=/disc cargo test -p sylpheed-formats --test ui_settle_time_disc +```