formats: teach the reference renderer the additive blend, and re-open 8 claims
The blend bit has been decoded against RB_BLENDCONTROL0 since 2026-08-31, but ui_layout::blit could not draw it, and said so in a comment citing a refutation that is <render-vs-capture> -- this renderer disagreeing with itself while it had a stale keyframe association, no leaf geometry and no rotation. The consequence the port raised: verify-screen compares two renderers, so a renderer that structurally cannot express a declared field makes the check incapable on every screen that uses it -- 12 of 16 -- and the tolerance silently excuses all of them. A quiet check is worse than a failing one. Both equations come off the game's own pixel shader, which premultiplies (oC0 = rgb*A, A), so only the blend register differs: 0x07010701 gives rgb*A + dst*(1-A), 0x01010101 gives rgb*A + dst. Additive therefore saturates rather than wrapping, and a transparent or black source is the identity -- neither is a choice. No plumbing needed: t8ad::parse already stores +0x04 as T8adImage::flags. Four controls, pinned against arithmetic per the rotation precedent. The fourth is the only one that can fail for the right reason: the first three pass just as well if blit ignores the flag and draws everything additive, so the discriminator flips only the blend on one sprite and requires two different answers, each equal to its own equation. That is the same failure class as the port's non-inverting latch check and my own backward scan that resolved every guard to "internal". 120 passed, 0 failed on the full lib suite. 67 sprites over 14 screens were being drawn with the wrong blend, including 10 of 18 on the title and ptbtn00f, the PRESS (A) plate's highlight. R1: tools/stale-instrument render-vs-capture lists 8 claims that died to this instrument, including both legs of the rest() pair and "the plate-free title capture may be too early to be settled", which sits on play-test finding 3. None is re-derived here; this only records that the instrument no longer exists in that form. Also corroborates the port's H5: pgloading_loop5 is an ELEMENT resolving to sprite pgloading_ring.t32, which is additive. I could not find loop5 as a sprite in any pak and nearly reported a false contradiction from the element/sprite name split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jc4pciRArGHfxGGhEbwp5t
This commit is contained in:
26
crates/sylpheed-formats/examples/additive_census.rs
Normal file
26
crates/sylpheed-formats/examples/additive_census.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use std::path::PathBuf;
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").unwrap());
|
||||
println!("# Which elements the reference renderer now draws ADDITIVE, per screen.");
|
||||
println!("# Source: T8aD +0x04 bit 0x02, docs/re/structures/ui-blend-mode-decoded.md.");
|
||||
println!("# Generated after ui_layout::blit gained an additive path (2026-09-01).");
|
||||
println!("# Before that change EVERY row below was drawn alpha-over by our renderer,");
|
||||
println!("# which is why `verify-screen` was structurally incapable on these screens.");
|
||||
for pak in ["GP_TITLE", "GP_OPTIONS"] {
|
||||
let Ok(ar) = PakArchive::open(root.join(format!("dat/{pak}.pak"))) else { continue };
|
||||
let n = ar.entries().len();
|
||||
for e in 0..n {
|
||||
let Ok(by) = ar.read(&ar.entries()[e]) else { continue };
|
||||
let Some(b) = ui_layout::parse_build(&by) else { continue };
|
||||
let mut add: Vec<&String> = b.sprites.keys()
|
||||
.filter(|s| ui_layout::blend_additive_by_name(&b, &by, s) == Some(true))
|
||||
.collect();
|
||||
if add.is_empty() { continue }
|
||||
add.sort();
|
||||
println!("\n{pak} entry {e} -- {} of {} sprites additive",
|
||||
add.len(), b.sprites.len());
|
||||
for s in add { println!(" {s}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1424,7 +1424,8 @@ pub fn compose_with_order(
|
||||
if lk.scale_x == 0 || lk.scale_y == 0 {
|
||||
continue;
|
||||
}
|
||||
blit(&mut canvas, w, h, &limg, &lk, le.pivot_x, le.pivot_y);
|
||||
blit(&mut canvas, w, h, &limg, &lk, le.pivot_x, le.pivot_y,
|
||||
limg.flags & 0x02 != 0);
|
||||
any = true;
|
||||
}
|
||||
if any {
|
||||
@@ -1432,7 +1433,8 @@ pub fn compose_with_order(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
blit(&mut canvas, w, h, &img, kf, el.pivot_x, el.pivot_y);
|
||||
blit(&mut canvas, w, h, &img, kf, el.pivot_x, el.pivot_y,
|
||||
img.flags & 0x02 != 0);
|
||||
drawn.push(el.index);
|
||||
}
|
||||
ComposedScreen {
|
||||
@@ -1522,6 +1524,37 @@ fn fill_quad(
|
||||
/// rect, which is what the game draws. At 100 % the pivot cancels, which is why
|
||||
/// every unscaled element — and so every ruler this format was checked against —
|
||||
/// was unaffected. See `docs/re/structures/ui-rat-layout.md`.
|
||||
/// One source sample over one destination sample, in whichever blend the disc
|
||||
/// declares for the element.
|
||||
///
|
||||
/// Both equations come from the game's own pixel shader, dumped from the running
|
||||
/// guest and disassembled in `docs/re/ui-splash-draw-pass.md`:
|
||||
///
|
||||
/// ```text
|
||||
/// mul r1.___w, r2.wwww, r0.wwww ; A = tex.a * vcol.a
|
||||
/// mul r0.xyz_, r2.xyzz, r0.xyzz ; rgb = tex.rgb * vcol.rgb
|
||||
/// mul r1.xyz_, r0.xyzz, r1.wwww ; rgb = rgb * A <-- the shader PREMULTIPLIES
|
||||
/// max oC0, r1, r1 ; oC0 = (rgb*A, A)
|
||||
/// ```
|
||||
///
|
||||
/// So the shader always emits `src = rgb·A`, and only the blend register differs:
|
||||
///
|
||||
/// * alpha-over, `RB_BLENDCONTROL0 = 0x07010701` — `ONE / ONE_MINUS_SRC_ALPHA`,
|
||||
/// giving `dst' = rgb·A + dst·(1 − A)`, i.e. ordinary source-over;
|
||||
/// * additive, `0x01010101` — `ONE / ONE`, giving `dst' = rgb·A + dst`.
|
||||
///
|
||||
/// The additive case therefore **saturates rather than wraps**, and a fully
|
||||
/// transparent or fully black source is the identity in it — both of which the
|
||||
/// control tests pin, because they are arithmetic and not opinions.
|
||||
#[inline]
|
||||
fn combine(additive: bool, sc: u32, sa: u32, dc: u32) -> u8 {
|
||||
if additive {
|
||||
(dc + sc * sa / 255).min(255) as u8
|
||||
} else {
|
||||
((sc * sa + dc * (255 - sa)) / 255) as u8
|
||||
}
|
||||
}
|
||||
|
||||
fn blit(
|
||||
canvas: &mut [u8],
|
||||
cw: u32,
|
||||
@@ -1530,6 +1563,11 @@ fn blit(
|
||||
kf: &Keyframe,
|
||||
pivot_x: u32,
|
||||
pivot_y: u32,
|
||||
// `T8aD +0x04` bit `0x02` — the game draws this element ADDITIVE. Decoded,
|
||||
// `docs/re/structures/ui-blend-mode-decoded.md`: 35 elements over three
|
||||
// screens against `RB_BLENDCONTROL0` read out of the guest command stream,
|
||||
// 0 errors, plus an out-of-sample hit on `GP_OPTIONS`.
|
||||
additive: bool,
|
||||
) {
|
||||
let (sw, sh) = (img.width, img.height);
|
||||
if sw == 0 || sh == 0 {
|
||||
@@ -1632,7 +1670,7 @@ fn blit(
|
||||
let di = ((ty as u32 * cw + tx as u32) * 4) as usize;
|
||||
for (k, sc) in [sr, sg, sb].into_iter().enumerate() {
|
||||
let dc = canvas[di + k] as u32;
|
||||
canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8;
|
||||
canvas[di + k] = combine(additive, sc, sa, dc);
|
||||
}
|
||||
canvas[di + 3] = 255;
|
||||
}
|
||||
@@ -1670,13 +1708,9 @@ fn blit(
|
||||
continue;
|
||||
}
|
||||
let di = ((ty as u32 * cw + tx as u32) * 4) as usize;
|
||||
// Straight alpha-over. `T8aD +0x04` bit 0x02 was tested as an
|
||||
// ADDITIVE selector and REFUTED — it moved every metric against the
|
||||
// title capture the wrong way (see the doc comment on
|
||||
// `T8adImage::flags`), so the bit is carried but not acted on.
|
||||
for (k, sc) in [sr, sg, sb].into_iter().enumerate() {
|
||||
let dc = canvas[di + k] as u32;
|
||||
canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8;
|
||||
canvas[di + k] = combine(additive, sc, sa, dc);
|
||||
}
|
||||
canvas[di + 3] = 255;
|
||||
}
|
||||
@@ -1698,7 +1732,7 @@ mod tests {
|
||||
}
|
||||
fn draw(img: &t8ad::T8adImage, kf: &Keyframe, px: u32, py: u32) -> Vec<u8> {
|
||||
let mut c = vec![0u8; 64 * 64 * 4];
|
||||
blit(&mut c, 64, 64, img, kf, px, py);
|
||||
blit(&mut c, 64, 64, img, kf, px, py, false);
|
||||
c
|
||||
}
|
||||
fn covered(c: &[u8]) -> Vec<(i32, i32)> {
|
||||
@@ -1925,7 +1959,7 @@ mod tests {
|
||||
};
|
||||
let (w, h) = (1280u32, 720u32);
|
||||
let mut canvas = vec![0u8; (w * h * 4) as usize];
|
||||
blit(&mut canvas, w, h, &img, &k, 320, 180);
|
||||
blit(&mut canvas, w, h, &img, &k, 320, 180, false);
|
||||
// Every pixel is covered; the four corners are the cheap witnesses.
|
||||
for (x, y) in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)] {
|
||||
let i = ((y * w + x) * 4) as usize;
|
||||
@@ -1951,7 +1985,7 @@ mod tests {
|
||||
let k = kf(293, 655, 0);
|
||||
let (w, h) = (1280u32, 720u32);
|
||||
let mut canvas = vec![0u8; (w * h * 4) as usize];
|
||||
blit(&mut canvas, w, h, &img, &k, 309, 10);
|
||||
blit(&mut canvas, w, h, &img, &k, 309, 10, false);
|
||||
let at = |x: u32, y: u32| canvas[((y * w + x) * 4) as usize];
|
||||
assert_eq!(at(293, 655), 255, "top-left corner is the keyframe");
|
||||
assert_eq!(at(986, 674), 255, "bottom-right corner is corner + size");
|
||||
@@ -1972,4 +2006,83 @@ mod tests {
|
||||
}
|
||||
assert_eq!(scan_placement_block(&r), Some((0xffff_ffff, 226, 268)));
|
||||
}
|
||||
|
||||
// ---- additive blend: controls ------------------------------------------
|
||||
//
|
||||
// `T8aD +0x04` bit 0x02 is DECODED against the GPU, but this renderer could
|
||||
// not express it, so `verify-screen` was structurally incapable on 12 of 16
|
||||
// screens and its allowance quietly excused all of them. These pin the
|
||||
// equation against answers that are ARITHMETIC, following the precedent set
|
||||
// by `rotation_control_known_angles`.
|
||||
//
|
||||
// ⚠️ The last test is the one that matters. A suite that only checks the
|
||||
// additive path passes just as well if the flag is ignored and everything
|
||||
// draws additive; the discriminator has to show the SAME sprite giving TWO
|
||||
// different answers according to the bit.
|
||||
|
||||
/// Adding zero changes nothing: a black additive source is the identity.
|
||||
#[test]
|
||||
fn additive_control_black_source_is_identity() {
|
||||
let img = t8ad::T8adImage { width: 4, height: 4,
|
||||
rgba: [[0u8, 0, 0, 255]; 16].concat(), flags: 0x02 };
|
||||
let kf = kf_at(10, 10, 0);
|
||||
let mut c = vec![77u8; 64 * 64 * 4];
|
||||
let before = c.clone();
|
||||
blit(&mut c, 64, 64, &img, &kf, 0, 0, true);
|
||||
// alpha is forced to 255 on any touched pixel, so compare colour only
|
||||
for i in (0..c.len()).filter(|i| i % 4 != 3) {
|
||||
assert_eq!(c[i], before[i], "black additive source moved a pixel at {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A fully transparent source is the identity in either blend.
|
||||
#[test]
|
||||
fn additive_control_alpha_zero_is_identity() {
|
||||
let img = t8ad::T8adImage { width: 4, height: 4,
|
||||
rgba: [[200u8, 200, 200, 0]; 16].concat(), flags: 0x02 };
|
||||
let kf = kf_at(10, 10, 0);
|
||||
let mut c = vec![77u8; 64 * 64 * 4];
|
||||
let before = c.clone();
|
||||
blit(&mut c, 64, 64, &img, &kf, 0, 0, true);
|
||||
assert_eq!(c, before, "a zero-alpha source is not the identity");
|
||||
}
|
||||
|
||||
/// The sum is the sum, and it SATURATES rather than wrapping.
|
||||
#[test]
|
||||
fn additive_control_known_sums() {
|
||||
for (dst, src, want) in [(40u8, 100u32, 140u8), (200, 100, 255), (0, 255, 255),
|
||||
(250, 10, 255), (7, 8, 15)] {
|
||||
let img = t8ad::T8adImage {
|
||||
width: 2, height: 2,
|
||||
rgba: [[src as u8, src as u8, src as u8, 255]; 4].concat(), flags: 0x02 };
|
||||
let kf = kf_at(10, 10, 0);
|
||||
let mut c = vec![dst; 64 * 64 * 4];
|
||||
blit(&mut c, 64, 64, &img, &kf, 0, 0, true);
|
||||
let di = ((10 * 64 + 10) * 4) as usize;
|
||||
assert_eq!(c[di], want, "additive {dst} + {src} should saturate to {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 THE DISCRIMINATOR. The same sprite, the same pose, the same canvas —
|
||||
/// only the blend differs — must produce two DIFFERENT answers, each equal
|
||||
/// to its own equation. Without this, a `blit` that ignored the flag and
|
||||
/// always drew additive would pass every test above.
|
||||
#[test]
|
||||
fn additive_control_bit_actually_selects() {
|
||||
let img = t8ad::T8adImage { width: 2, height: 2,
|
||||
rgba: [[100u8, 100, 100, 128]; 4].concat(), flags: 0 };
|
||||
let kf = kf_at(10, 10, 0);
|
||||
let di = ((10 * 64 + 10) * 4) as usize;
|
||||
|
||||
let mut over = vec![80u8; 64 * 64 * 4];
|
||||
blit(&mut over, 64, 64, &img, &kf, 0, 0, false);
|
||||
let mut add = vec![80u8; 64 * 64 * 4];
|
||||
blit(&mut add, 64, 64, &img, &kf, 0, 0, true);
|
||||
|
||||
// alpha-over: (100*128 + 80*127)/255 = (12800 + 10160)/255 = 90
|
||||
assert_eq!(over[di], 90, "alpha-over arithmetic changed");
|
||||
// additive: 80 + 100*128/255 = 80 + 50 = 130
|
||||
assert_eq!(add[di], 130, "additive arithmetic changed");
|
||||
assert_ne!(over[di], add[di], "the blend flag selects nothing -- blit ignores it");
|
||||
}
|
||||
}
|
||||
|
||||
100
docs/re/data/additive-elements-per-screen.txt
Normal file
100
docs/re/data/additive-elements-per-screen.txt
Normal file
@@ -0,0 +1,100 @@
|
||||
# Which elements the reference renderer now draws ADDITIVE, per screen.
|
||||
# Source: T8aD +0x04 bit 0x02, docs/re/structures/ui-blend-mode-decoded.md.
|
||||
# Generated after ui_layout::blit gained an additive path (2026-09-01).
|
||||
# Before that change EVERY row below was drawn alpha-over by our renderer,
|
||||
# which is why `verify-screen` was structurally incapable on these screens.
|
||||
|
||||
GP_TITLE entry 0 -- 2 of 7 sprites additive
|
||||
pgloading_circle1.t32
|
||||
pgloading_delta.t32
|
||||
|
||||
GP_TITLE entry 1 -- 2 of 7 sprites additive
|
||||
pgloading_circle1.t32
|
||||
pgloading_delta.t32
|
||||
|
||||
GP_TITLE entry 2 -- 1 of 2 sprites additive
|
||||
ptbtn00f.t32
|
||||
|
||||
GP_TITLE entry 3 -- 1 of 2 sprites additive
|
||||
ptbtn00f.t32
|
||||
|
||||
GP_TITLE entry 4 -- 10 of 18 sprites additive
|
||||
pteff01.t32
|
||||
pteff03.t32
|
||||
pteff03a.t32
|
||||
ptlogo_back2eff1.t32
|
||||
ptlogo_back2eff2.t32
|
||||
ptlogo_back2eff3.t32
|
||||
ptlogo_back2eff4.t32
|
||||
ptlogo_back2eff5.t32
|
||||
ptlogoall_eff.t32
|
||||
ptlogoall_eff2.t32
|
||||
|
||||
GP_TITLE entry 5 -- 6 of 21 sprites additive
|
||||
pteff03.t32
|
||||
pteff03a.t32
|
||||
pteff10.t32
|
||||
pteff12.t32
|
||||
ptframe1.t32
|
||||
ptframe2.t32
|
||||
|
||||
GP_TITLE entry 6 -- 9 of 20 sprites additive
|
||||
pteff03.t32
|
||||
pteff03a.t32
|
||||
pteff10.t32
|
||||
pteff20.t32
|
||||
pteff21.t32
|
||||
pteff22.t32
|
||||
pteff23.t32
|
||||
ptframe3.t32
|
||||
ptframe4.t32
|
||||
|
||||
GP_TITLE entry 7 -- 9 of 24 sprites additive
|
||||
pteff03.t32
|
||||
pteff03a.t32
|
||||
ptlogo_back2eff1.t32
|
||||
ptlogo_back2eff2.t32
|
||||
ptlogo_back2eff3.t32
|
||||
ptlogo_back2eff4.t32
|
||||
ptlogo_back2eff5.t32
|
||||
ptlogo_eff2.t32
|
||||
ptlogo_eff3.t32
|
||||
|
||||
GP_TITLE entry 8 -- 6 of 21 sprites additive
|
||||
pteff03.t32
|
||||
pteff03a.t32
|
||||
pteff10.t32
|
||||
pteff12.t32
|
||||
ptframe1.t32
|
||||
ptframe2.t32
|
||||
|
||||
GP_TITLE entry 9 -- 9 of 20 sprites additive
|
||||
pteff03.t32
|
||||
pteff03a.t32
|
||||
pteff10.t32
|
||||
pteff20.t32
|
||||
pteff21.t32
|
||||
pteff22.t32
|
||||
pteff23.t32
|
||||
ptframe3.t32
|
||||
ptframe4.t32
|
||||
|
||||
GP_TITLE entry 12 -- 3 of 9 sprites additive
|
||||
pgloading_circle1.t32
|
||||
pgloading_delta.t32
|
||||
pgloading_ring.t32
|
||||
|
||||
GP_TITLE entry 15 -- 3 of 9 sprites additive
|
||||
pgloading_circle1.t32
|
||||
pgloading_delta.t32
|
||||
pgloading_ring.t32
|
||||
|
||||
GP_OPTIONS entry 19 -- 3 of 16 sprites additive
|
||||
po_menu_eff01.t32
|
||||
po_menu_eff02.t32
|
||||
po_menu_eff03.t32
|
||||
|
||||
GP_OPTIONS entry 21 -- 3 of 16 sprites additive
|
||||
po_menu_eff01.t32
|
||||
po_menu_eff02.t32
|
||||
po_menu_eff03.t32
|
||||
146
docs/re/ui-renderer-additive-path.md
Normal file
146
docs/re/ui-renderer-additive-path.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# The reference renderer can now express the blend the disc declares — and 8 claims re-open
|
||||
|
||||
**Status: ✅ done, with controls.** Instrument: ⟨our-renderer⟩, deliberately —
|
||||
this page is about a change to *our* instrument, not a claim about the game.
|
||||
2026-09-01.
|
||||
|
||||
---
|
||||
|
||||
## Why
|
||||
|
||||
`T8aD +0x04` bit `0x02` has been **decoded** since 2026-08-31 — 35 elements over
|
||||
three screens against `RB_BLENDCONTROL0` read out of the guest command stream,
|
||||
zero errors, plus an out-of-sample hit on `GP_OPTIONS`
|
||||
([`structures/ui-blend-mode-decoded.md`](structures/ui-blend-mode-decoded.md)).
|
||||
|
||||
`ui_layout::blit` could not draw it. The code said so in a comment that had gone
|
||||
stale:
|
||||
|
||||
> *"Straight alpha-over. `T8aD +0x04` bit 0x02 was tested as an ADDITIVE selector
|
||||
> and REFUTED — it moved every metric against the title capture the wrong way …
|
||||
> so the bit is carried but not acted on."*
|
||||
|
||||
That refutation is `⟨render-vs-capture⟩`: it was **this renderer disagreeing with
|
||||
itself**, recorded while the same renderer had a stale keyframe association, no
|
||||
leaf geometry and no rotation. The corpus's own rule — *a claim resting on our
|
||||
renderer is a claim about our renderer* — applies to it, and R1 is exactly the
|
||||
machinery for noticing.
|
||||
|
||||
The port raised the consequence: `verify-screen` compares two renderers, and if
|
||||
one of them **structurally cannot** express a field the disc declares, the check
|
||||
is incapable on every screen that uses it — **12 of 16** — and its tolerance
|
||||
silently excuses all of them. A quiet check is worse than a failing one.
|
||||
|
||||
## What changed
|
||||
|
||||
One function. Both the rotated and unrotated paths now route their per-pixel
|
||||
combine through:
|
||||
|
||||
```rust
|
||||
fn combine(additive: bool, sc: u32, sa: u32, dc: u32) -> u8 {
|
||||
if additive { (dc + sc * sa / 255).min(255) as u8 }
|
||||
else { ((sc * sa + dc * (255 - sa)) / 255) as u8 }
|
||||
}
|
||||
```
|
||||
|
||||
**Both equations are read off the game's own pixel shader**, dumped from the
|
||||
running guest and disassembled in
|
||||
[`ui-splash-draw-pass.md`](ui-splash-draw-pass.md). The shader premultiplies —
|
||||
`oC0 = (rgb·A, A)` — so only the blend register differs:
|
||||
|
||||
| `RB_BLENDCONTROL0` | is | gives |
|
||||
|---|---|---|
|
||||
| `0x07010701` | `ONE / ONE_MINUS_SRC_ALPHA` | `dst' = rgb·A + dst·(1 − A)` — source-over |
|
||||
| `0x01010101` | `ONE / ONE` | `dst' = rgb·A + dst` — additive |
|
||||
|
||||
So the additive case **saturates rather than wrapping**, and a transparent or
|
||||
black source is the identity in it. Neither is a choice; both fall out of the
|
||||
equation.
|
||||
|
||||
The flag needs no plumbing: `t8ad::parse` already stores the `+0x04` word as
|
||||
`T8adImage::flags`, so both call sites read `img.flags & 0x02 != 0`.
|
||||
|
||||
## The controls
|
||||
|
||||
Following the precedent of `rotation_control_known_angles` — pin against answers
|
||||
that are **arithmetic**, not opinions.
|
||||
|
||||
| test | pins |
|
||||
|---|---|
|
||||
| `additive_control_black_source_is_identity` | adding zero changes nothing |
|
||||
| `additive_control_alpha_zero_is_identity` | a transparent source is the identity |
|
||||
| `additive_control_known_sums` | `40+100=140`, `200+100=255`, `250+10=255` — it **saturates** |
|
||||
| 🔴 `additive_control_bit_actually_selects` | **the discriminator** |
|
||||
|
||||
⚠️ **The fourth one is the only one that can fail for the right reason.** The
|
||||
first three pass just as well if `blit` ignores the flag and draws everything
|
||||
additive. The discriminator takes one sprite, one pose, one canvas, flips only
|
||||
the blend, and requires **two different answers**, each equal to its own
|
||||
equation: alpha-over `(100·128 + 80·127)/255 = 90`, additive `80 + 100·128/255 =
|
||||
130`.
|
||||
|
||||
This is the failure class the port hit the same day — *"removing the latch does
|
||||
not remove the threshold, so the row could never invert"* — and one I hit in the
|
||||
pad decode, where a backward scan silently resolved every guard to "internal".
|
||||
A control that removes the mechanism but not the observable tests nothing.
|
||||
|
||||
`120 passed; 0 failed` on the full library suite, so no unrotated screen regresses.
|
||||
|
||||
## What it changes, per screen
|
||||
|
||||
[`data/additive-elements-per-screen.txt`](data/additive-elements-per-screen.txt)
|
||||
— **67 sprites over 14 screens** that our renderer was drawing with the wrong
|
||||
blend, including **10 of 18 on the title** (`pteff01`, `pteff03`, `pteff03a`, the
|
||||
five `ptlogo_back2eff*`, `ptlogoall_eff`) and `ptbtn00f`, the `PRESS Ⓐ` plate's
|
||||
own highlight.
|
||||
|
||||
## 🔴 R1: what this re-opens
|
||||
|
||||
`tools/stale-instrument render-vs-capture` — **8 claims**, and they are not
|
||||
peripheral. Three bear on the current focus:
|
||||
|
||||
* *"`rest()` for a plateau-less element should be the last keyframe"* — 🟡, and
|
||||
its sibling, **both legs of the pair the R1 pass re-opened in both directions**;
|
||||
* *"an element with no held pose should be drawn as NOTHING rather than at a
|
||||
guessed endpoint"* — 🟡;
|
||||
* *"the plate-free title capture (t ≈ 4.0 s) may be too early to be settled"* — 🟡,
|
||||
which sits directly on play-test finding 3;
|
||||
* *"the shifted time reading implies `rest` = the last keyframe"* — 🟡.
|
||||
|
||||
Every one of them died to a renderer that drew ten of the title's eighteen
|
||||
sprites with the wrong blend. **None is re-derived here** — this page only
|
||||
records that the instrument that killed them no longer exists in that form.
|
||||
|
||||
## Corroboration of another agent's claim, recorded per the adversarial duty
|
||||
|
||||
**Target:** the port's *"H5 closes on your bit — `pgloading_loop5` is additive."*
|
||||
|
||||
I could not find `loop5` as a sprite **anywhere in any pak**, which looked like a
|
||||
contradiction. It is not: `pgloading_loop5.rat` is an **element**, and it resolves
|
||||
to the sprite `pgloading_ring.t32`, which the bit marks additive.
|
||||
|
||||
```
|
||||
GP_TITLE.pak entry 12 element 'pgloading_loop5.rat' sprite pgloading_ring.t32 additive=true
|
||||
GP_TITLE.pak entry 15 ditto
|
||||
GP_SAVE_LOAD.pak entry 46 ditto
|
||||
GP_SAVE_LOAD.pak entry 69 ditto
|
||||
```
|
||||
|
||||
**Their claim stands.** Recorded because the element/sprite name split is a real
|
||||
trap — a census keyed by sprite name will not find an element by its own name,
|
||||
and I nearly reported a false contradiction from exactly that.
|
||||
|
||||
## Reach, and what this is NOT
|
||||
|
||||
⟨our-renderer⟩. This makes our renderer **able** to express a decoded field; it
|
||||
does not make it right, and it is **not** evidence about the game. The evidence
|
||||
for the field is `ui-blend-mode-decoded.md`'s GPU measurement, which predates this
|
||||
and does not depend on it.
|
||||
|
||||
⚠️ `.prm` primitives carry no `T8aD` header, so the bit cannot speak for them and
|
||||
this path never fires for one.
|
||||
|
||||
⚠️ **Not re-run:** `verify-screen` across the 16 screens. The numbers there will
|
||||
move, and the port's own generalisation applies to reading them — `raw-rmse` is
|
||||
**area-weighted**, so ask "broad or deep?" of any row that moves before calling it
|
||||
a regression.
|
||||
Reference in New Issue
Block a user