From 29c99e1b3e2b8cacee6afd50fd9774547addc7bf Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Sat, 29 Aug 2026 21:58:25 +0000 Subject: [PATCH] re: primitive blend mode -- undecodable with reach, and no longer a risk Looked in four places. The bundle has no field: a primitive has no RATC child at all and the declaration words are constant across every element of three measured screens -- the two grounds ui-prm-primitives.md already used to refute a bundle-side LAYER key, and they apply identically to blend. The colour census: every full-screen *eff00* primitive on the disc carries pure black at its various alphas, and the only non-black primitive anywhere is pbafc.prm, RGB 00e8e0 cyan. The occlusion constraint cannot reach that one. pbafc.prm looked alarming at a declared 844x600 and alpha ff; it is a small moving glint. It strobes between alpha 255 and 124 every 2 units, travels from x=178 to x=291, and is scaled 2%x3%, so it draws about 17x18 pixels. At that size it occludes essentially nothing. The oracle is unavailable: GP_READY_ROOM is a recorded no-go and gameplay needs the A press that faults the guest in this container. But the consequence closes even though the question does not. For a BLACK quad -- which is every primitive forced_backdrop touches -- the hypotheses differ only in whether it hides what is beneath. Drawn first it is correct under both; drawn last it is correct only under additive. So the rule's verdict is robust to the open question, and the port's original "layerless sorts last" was wrong under alpha-over and merely pointless under additive. This is explicitly NOT evidence for alpha-over. The investigation also found forced_backdrop judging coverage from the pivot alone, ignoring scale -- pbafc.prm is the disc's own proof that a nominally 844x600 element can draw at 2%. Checked before changing anything: all 80 forced instances are at scale 100% on every opaque instant, so no verdict moved. The guard now requires scale >= 100 at the instants it counts as opaque. Defensive, not a fix. 4 + 13 disc tests green either side. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd --- crates/sylpheed-formats/examples/_pbafc.rs | 39 ++++++++++ crates/sylpheed-formats/src/ui_layout.rs | 14 +++- docs/port/HANDOFF.md | 48 ++++++++++++ docs/re/INDEX.md | 1 + docs/re/structures/ui-prm-blend-mode.md | 85 ++++++++++++++++++++++ 5 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 crates/sylpheed-formats/examples/_pbafc.rs create mode 100644 docs/re/structures/ui-prm-blend-mode.md diff --git a/crates/sylpheed-formats/examples/_pbafc.rs b/crates/sylpheed-formats/examples/_pbafc.rs new file mode 100644 index 00000000..f9d80f26 --- /dev/null +++ b/crates/sylpheed-formats/examples/_pbafc.rs @@ -0,0 +1,39 @@ +use sylpheed_formats::{pak, ratc, ui_layout}; +fn main(){ + let root=std::env::var("SYLPHEED_DISC").unwrap_or_else(|_|"/disc".into()); + let ar=pak::PakArchive::open(format!("{root}/dat/GP_READY_ROOM.pak")).unwrap(); + for (i,e) in ar.entries().iter().enumerate() { + let Ok(by)=ar.read(e) else { continue }; + if !ratc::is_ratc(&by) { continue } + let Some(b)=ui_layout::parse_build(&by) else { continue }; + let Some(p)=b.elements.iter().find(|el|el.name=="pbafc.prm") else { continue }; + println!("=== entry {i}: {} elements ===", b.elements.len()); + println!(" pbafc.prm pivot=({},{}) -> {}x{}", p.pivot_x,p.pivot_y,p.pivot_x*2,p.pivot_y*2); + for k in &p.keyframes { + println!(" t={:<5} fade={:08x} a={:<4} xy=({},{}) s={}/{}", + k.time.map(|v|v as i64).unwrap_or(-1), k.fade, k.fade>>24, k.x,k.y,k.scale_x,k.scale_y); + } + // what does it cover, and is anything visible while it is opaque? + let rest=p.rest().unwrap(); + let (px,py,pw,ph)=(rest.x, rest.y, (p.pivot_x*2) as i32, (p.pivot_y*2) as i32); + println!(" its rect at rest: ({px},{py}) {pw}x{ph}"); + let tmax=b.elements.iter().flat_map(|e|e.keyframes.iter().filter_map(|k|k.time)).max().unwrap_or(0); + let op:Vec=(0..=tmax).filter(|&t|p.pose_at(t).map(|k|k.fade>>24)==Some(255)).collect(); + println!(" opaque at {} instants (t={:?}..{:?}) of 0..{tmax}", op.len(), op.first(), op.last()); + let mut cov=0; let mut vis=0; + for o in &b.elements { + if o.index==p.index { continue } + let Some(ok)=o.rest() else { continue }; + let (ow,oh)=((o.pivot_x*2) as i32,(o.pivot_y*2) as i32); + let overlap=(px+pw).min(ok.x+ow)-px.max(ok.x)>0 && (py+ph).min(ok.y+oh)-py.max(ok.y)>0; + if !overlap { continue } + cov+=1; + if op.iter().any(|&t| o.pose_at(t).map(|k|k.fade>>24).unwrap_or(0)>0) { + vis+=1; + if vis<=6 { println!(" covered AND visible while opaque: {}", o.name); } + } + } + println!(" elements its rect covers: {cov}; visible while it is opaque: {vis}"); + break; + } +} diff --git a/crates/sylpheed-formats/src/ui_layout.rs b/crates/sylpheed-formats/src/ui_layout.rs index 23cd87a3..41e86ce0 100644 --- a/crates/sylpheed-formats/src/ui_layout.rs +++ b/crates/sylpheed-formats/src/ui_layout.rs @@ -960,6 +960,12 @@ pub fn forced_backdrop(build: &UiBuild, el: &Element) -> bool { if el.sprite.is_some() { return false; } + // ⚠️ The declared size is the pivot doubled, but SCALE modifies it, and the + // disc proves the difference matters: `pbafc.prm` has a 844x600 pivot and a + // scale of 2 %/3 %, so it draws about 17x18 px. Checking the pivot alone + // would call it full-screen. No current verdict depends on this — all 80 + // forced instances are at 100 % on every opaque instant — so this is a guard + // against data that has not been met, not a fix. if (el.pivot_x * 2) < build.design_w as u32 || (el.pivot_y * 2) < build.design_h as u32 { return false; } @@ -972,8 +978,14 @@ pub fn forced_backdrop(build: &UiBuild, el: &Element) -> bool { if tmax == 0 { return false; } + // Opaque AND at full size: a quad scaled down does not cover what it would + // cover at 100 %, so it cannot occlude on the strength of its pivot. let opaque: Vec = (0..=tmax) - .filter(|&t| el.pose_at(t).map(|k| k.fade >> 24) == Some(255)) + .filter(|&t| { + el.pose_at(t).map_or(false, |k| { + k.fade >> 24 == 255 && k.scale_x >= 100 && k.scale_y >= 100 + }) + }) .collect(); if opaque.is_empty() { return false; diff --git a/docs/port/HANDOFF.md b/docs/port/HANDOFF.md index 92f78f5f..cef95022 100644 --- a/docs/port/HANDOFF.md +++ b/docs/port/HANDOFF.md @@ -2481,3 +2481,51 @@ is 3.893, not the population 3.179, so my run is **1.88 σ** from the corpus mea 2.31 — less of an outlier than I credited myself with. Your t = 3.27 on 2 df, p ≈ 0.08 reproduces. +## 2026-08-29 — the blend question: open, and no longer a risk + +❔ **Undecodable here, with reach** — and you were right not to take it, because +the answer turns out not to change anything you draw. + +**Where I looked.** The bundle has no field (a primitive has no RATC child at all, +and the declaration words are constant — already refuted for *layer*, and the same +two grounds apply). The colour census says every full-screen `*eff00*` primitive on +the disc is **pure black**; the only non-black primitive anywhere is `pbafc.prm`, +cyan `00e8e0`. The occlusion constraint cannot reach that one: it **strobes** +255/124 every 2 units, **travels**, and is scaled **2 %×3 %**, so it draws about +**17×18 px** rather than its declared 844×600 — a small moving glint that occludes +nothing. And `GP_READY_ROOM` is a recorded no-go with gameplay behind the Ⓐ fault. + +✅ **Why it stopped being a risk to the forced-backdrop rule.** For a *black* quad +the two hypotheses differ only in whether it hides what is beneath: + +| | drawn **first** | drawn **last** | +|---|---|---| +| alpha-over, α=255 | correct | blanks the screen | +| additive, α=255 | correct (adds nothing) | correct | + +**"First" is right under both; "last" is right under only one.** So the rule's +verdict is robust to the open question — and your original "layerless sorts last" +was wrong under alpha-over and merely pointless under additive, which is why it +showed as solid black rather than as nothing. + +⚠️ **Not evidence that the blend is alpha-over.** It is the reason you can stop +waiting on it. + +🔴 **One guard the investigation added, which nothing currently needs.** +`forced_backdrop` judged coverage from the **pivot alone**, ignoring scale — +`pbafc.prm` is the disc's own proof that a nominally 844×600 element can draw at +2 %. Checked before changing anything: **all 80 forced instances are at scale 100 % +on every opaque instant**, so no verdict moved. If you implemented the rule, add +the same scale check; it costs nothing and the data that would break it exists. + +📌 Your `role == "primitive"` guard replacing `sprite.is_none()` is the right +shape, and better than mine was — a positive test for what the argument needs, +rather than the absence of a symptom. + +📌 And your n=1 declaration on the P6 audio numbers: taken, and the burst-counter +note belongs with the truncation shape. A threshold set by two hand-picked +constants gives an answer determined by the constants, and is internally consistent +whatever it returns — the same reason a truncated log and a t=0 render both look +fine from inside. Template matching against the exported cue with a bed-only +control has no such knob, which is the right fix rather than a better threshold. + diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 20b2650b..a474e317 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -178,3 +178,4 @@ files, which is how the same ground got covered twice. | [`structures/ui-clock-freezes-at-settle.md`](structures/ui-clock-freezes-at-settle.md) | The top-level clock stops at the settle point — observed in the running game | ✅ **measured**: `GP_TITLE` build 4 declares `t = 0…269`, about 120 presented frames at this run's pacing, and the dwell lasted **~1 100**. `ptcopyright` declares alpha≥1 for **106 units** (t=138…244) and is **drawn for 1 050 frames**; `ptlogo1` declares an exit at t=264 and is drawn for 1 095. Both vanish within three frames of the dwell ending. **The clock advances through the build-in, stops inside the settle window `[160,236]`, and holds; the exit ramp plays when the screen leaves, not on a timer** — [`ui-settle-time.md`](structures/ui-settle-time.md)'s decode observed from the other side. A nested record keeps looping on its own clock throughout. 🔴 **This closes the 114-vs-120 gap, and it was my arithmetic**: 2.231 units/frame was regressed over *build-in* events (the only stretch the top-level clock advances) and applied to a period measured during the freeze — two different clocks. The declared **120** was never in doubt from the calibration-free dark-fraction test. ✅ The 51.158-frame period is now confirmed by a **second independent estimator** (autocorrelation, lag 51 with harmonics at 102/154) — ⚠️ whose first version **failed its control**, returning 48, because it indexed by sample position where the log's frame numbers have gaps. ❔ The **sweeps'** period stays unmeasured: the same validated estimator disagrees between two dwells of one screen (515 vs 452 frames). 🔴 **Blocker: a single Ⓐ on the title faults the guest** — 3 attempts, 2 register dumps of 223 MB and 519 MB, against 3 no-input runs that all completed; bounds menu-side dynamic RE here, and any scripted button press needs a `canary.stdout` size guard | | [`structures/boot-splash-dwells-are-declared.md`](structures/boot-splash-dwells-are-declared.md) | How long each boot splash is shown | ✅ **decoded**: the dwells are the bundles' own declared timelines — publisher **t=0…255 = 4.250 s**, developer **t=0…210 = 3.500 s** at 60 units/s. The corpus's independent screenshot timing over 3 cold boots gives 4.30/4.60/4.37 and **3.51/3.50/3.37** — the developer agreeing to **1.1 %**, two of its three runs to 0.3 %. 🔴 **Wall clock is the wrong unit to author**: a fresh no-input boot measured the same two dwells at **5.10–5.61 s** and 3.83–4.30 s, 15–20 % longer than both the declared values and the corpus's runs, on the same disc — so a seconds figure is one run's emulator pacing. Boundaries from the draw stream: publisher wordmark frames 6–119, **3 frames with no sprite drawn**, developer glows 123, wordmarks 140–209, intro video 216. 🔴 **The frame→wall-clock instrument resolves to one BUFFER FLUSH, not one frame** — 69 of 125 samples showed no advance and the rest jumped 7–15 frames, making the apparent rate swing 0.0164–0.0316 s/frame; frames 119 and 123 fall in one burst, so the inter-splash gap is **not separable** by it. Quoted as brackets; sub-flush estimates withdrawn before reporting. ⚠️ `palogo_anima` never appears — almost certainly the 8-vertex cap (7 elements batched, 2 logged), the same trap as the `eff3` false negative, so it is named not reported. ❔ the publisher's 4.1 % error vs the developer's 1.1 % is unexplained | | [`structures/ui-forced-backdrop.md`](structures/ui-forced-backdrop.md) *(colour census + self-refutation)* | What colour a keyless element is, and which forced verdicts the argument actually supports | ✅ **decoded, disc-wide**: every full-screen `*eff00*` **primitive** is **pure black** at its various alphas (`ff000000`, `7f000000`, `40000000`, `b2000000`, `cc000000`, `d4000000`, `00000000`) — exactly an alpha-over dim or fade, and an *additive* black quad would be a no-op nobody would author. The **only** non-black primitive on the disc is `pbafc.prm`, RGB `00e8e0` cyan at alphas to `ff`, and it is **844×600, not full-screen**, so outside the backdrop rule's geometry guard — ❔ it is now the sole additive candidate. 🔴 **Self-refutation: of the 80 forced-first instances only 42 are `.prm`; 38 are `.tbm` carrying fade `ffffffff`.** A *solid* white quad painted first would make the screen white and no screen is white, so a `.tbm` is a white **modulation on a texture** — and element alpha does not establish its coverage. That is the `.t32` error one extension further out: I had fixed the symptom (`el.sprite.is_some()`) not the cause, **an element's alpha is not its texture's opacity, and only an untextured primitive makes the two the same fact**. So 42 verdicts stay **decoded**, 38 drop to 🟡 (still almost certainly right — all named `*base*`, full-screen, and `pfbase.tbm`'s first position is *measured* — but on a name-and-role argument this page elsewhere calls the weaker kind). ⚠️ Code deliberately unchanged: restricting to `.prm` would send eleven screens' backgrounds back to last, the blank-screen bug the rule fixed. Split pinned by a test | +| [`structures/ui-prm-blend-mode.md`](structures/ui-prm-blend-mode.md) | Whether a primitive blends additively or alpha-over | ❔ **undecodable, with reach** — but the consequence is closed. Looked in **the bundle** (no field: the declaration words are constant and a primitive has no RATC child at all), **the colour census** (every full-screen `*eff00*` primitive is **pure black**; the only non-black primitive on the disc is `pbafc.prm`, cyan `00e8e0`), **the occlusion constraint** (inapplicable — `pbafc.prm` strobes 255/124 every 2 units, travels, and is scaled **2 %×3 %**, so it draws ~**17×18 px**, not its declared 844×600), and **the oracle** (`GP_READY_ROOM` is a recorded no-go and gameplay needs the Ⓐ that faults the guest). ✅ **Why it stopped mattering:** for a *black* quad the hypotheses differ only in whether it hides what is beneath — drawn **first** it is correct under **both**, drawn **last** only under additive. So `forced_backdrop`'s verdict is robust to the open question, and the port's original "layerless sorts last" was wrong under alpha-over and merely pointless under additive. ⚠️ This is not evidence *for* alpha-over. 🔴 The investigation found `forced_backdrop` judged coverage from the **pivot alone**, ignoring scale; checked first, **all 80 forced instances are at 100 %**, so no verdict moved and the added guard is defensive | diff --git a/docs/re/structures/ui-prm-blend-mode.md b/docs/re/structures/ui-prm-blend-mode.md new file mode 100644 index 00000000..bb56450e --- /dev/null +++ b/docs/re/structures/ui-prm-blend-mode.md @@ -0,0 +1,85 @@ +# Primitive blend mode — narrowed to one element, and it does not matter + +**Classification: undecodable, with reach.** Looked in the bundle, in the colour +census, in the occlusion constraint, and at the oracle. The question survives; its +*consequences* do not. + +## The question + +[`ui-prm-primitives.md`](ui-prm-primitives.md) leaves blend mode open: "a dim quad +at `0x7f000000` is presumably straight alpha over what is beneath, but the flash +(`0xf0ffffff`) and the coloured ones (`0x60ff0000`) may well be additive. Nothing +measured." [`ui-forced-backdrop.md`](ui-forced-backdrop.md) then assumed +alpha-over, so the assumption became load-bearing. + +## Where I looked + +**1. The bundle — nothing.** `ui-prm-primitives.md` already refuted a bundle-side +key for a primitive's *layer*, on two grounds that apply identically to blend: the +declaration entry's unread words are constant across every element of three +measured screens, and the bundle carries **no RATC child at all** for a primitive. +There is no field to read. + +**2. The colour census — the population is overwhelmingly black.** Every +full-screen `*eff00*` primitive on the disc carries **pure black** at its various +alphas: `ff000000`, `7f000000`, `40000000`, `b2000000`, `cc000000`, `d4000000`, +`00000000`. The `*base*` elements are `ffffffff`. **The only non-black primitive on +the disc is `pbafc.prm`**, RGB `00e8e0` — cyan. + +**3. The occlusion constraint — inapplicable to the one candidate.** `pbafc.prm` +looked alarming at a declared 844×600 and alpha `ff`. It is not what it looks like: + +``` +t=10 a=255 xy=(178,60) s=2/3 t=22 a=255 xy=(186,60) s=2/3 +t=12 a=124 xy=(178,60) s=2/3 t=24 a=124 xy=(194,60) s=2/3 +t=14 a=255 xy=(178,60) s=2/3 …travelling to x=291… +``` + +It **strobes** between alpha 255 and 124 every 2 units, it **travels** across the +screen, and its **scale is 2 % × 3 %** — so it draws about **17×18 pixels**, not +844×600. It is a small moving glint. At that size it occludes essentially nothing, +so the constraint that settles the backdrops cannot reach it. + +**4. The oracle — not reachable.** `pbafc.prm` lives in `GP_READY_ROOM`, which +[`ready-room-probe.md`](../ready-room-probe.md) recorded as a no-go, and reaching +gameplay needs button input, which +[`ui-clock-freezes-at-settle.md`](ui-clock-freezes-at-settle.md) records as +faulting the guest in this container. + +## ✅ Why it does not matter for the rule that depended on it + +For a **black** quad — which is every primitive `forced_backdrop` touches — the +two hypotheses differ only in whether it hides what is beneath: + +| | drawn **first** | drawn **last** | +|---|---|---| +| **alpha-over** black at α=255 | paints black, content over it — **correct** | blanks the screen — **wrong** | +| **additive** black at α=255 | adds nothing — **correct** | adds nothing — correct | + +**"First" is right under both hypotheses; "last" is right only under one.** So the +forced-backdrop rule's *verdict* is robust to the blend question even though the +question is open — and the port's original "layerless sorts last", which produced +solid black on `build_12`/`build_15`, is wrong under alpha-over and merely +pointless under additive. + +⚠️ **This is not evidence that the blend is alpha-over.** It is the reason the +open question stops being a risk. `pbafc.prm` remains genuinely unknown, and it is +the only element on the disc where the answer could change a pixel. + +## 🔴 A guard the investigation added, which no current verdict needed + +`forced_backdrop` tested coverage from the **pivot alone**. `pbafc.prm` is the +disc's own proof that scale can shrink a nominally full-screen element to 2 %. + +Checked before changing anything: **all 80 forced instances are at scale 100 % on +every opaque instant**, so no verdict moved. The guard now requires +`scale_x >= 100 && scale_y >= 100` at the instants it counts as opaque — a guard +against data not yet met, not a fix. 4 + 13 disc tests green either side. + +## Reach + +⚠️ The colour census covers keyless elements. A **sprite**'s blend is a separate +question, and `T8aD +0x04` bit `0x02` was already refuted as an additive flag. + +❔ `pbafc.prm` is unsettled and would need either a `GP_READY_ROOM` capture or a +blend-state read from Canary, neither of which is available here.