diff --git a/crates/sylpheed-formats/examples/blend_prediction_splash.rs b/crates/sylpheed-formats/examples/blend_prediction_splash.rs new file mode 100644 index 00000000..3afb4b1a --- /dev/null +++ b/crates/sylpheed-formats/examples/blend_prediction_splash.rs @@ -0,0 +1,46 @@ +//! A PREDICTION, written before the capture that tests it. +//! +//! `blend_vs_t8ad_bit` finds that `T8aD +0x04` bit `0x02` separates additive from +//! alpha-over on all 35 elements whose blend has been measured off the GPU, and +//! that no other bit of the 48-byte header does. That is a fit to three screens. +//! +//! The developer splash (`GP_TITLE` entries 10 and 13) has **never been captured** +//! and is one of the five screens the port ships. This prints what the bit says +//! its elements should be, so the capture can falsify it rather than confirm it. +//! +//! Takes an archive and a build list, so the prediction can be written for any +//! screen -- including one in a DIFFERENT pak, which is the sharper test: the +//! splash predicts alpha-over for both its elements and so can only fail, never +//! discriminate, while a screen with a predicted MIX can do both. +//! +//! cargo run -p sylpheed-formats --example blend_prediction_splash +//! cargo run -p sylpheed-formats --example blend_prediction_splash -- GP_OPTIONS 0 1 2 +use sylpheed_formats::{pak::PakArchive, ui_layout}; +use std::path::PathBuf; + +fn main() { + let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC")); + let args: Vec = std::env::args().skip(1).collect(); + let pak = args.iter().find(|a| a.parse::().is_err()) + .cloned().unwrap_or_else(|| "GP_TITLE".to_string()); + let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak"); + let builds: Vec = args.iter().filter_map(|a| a.parse().ok()).collect(); + let builds = if builds.is_empty() { (0..ar.entries().len()).collect() } else { builds }; + for e in builds { + let Ok(by) = ar.read(&ar.entries()[e]) else { continue }; + let Some(b) = ui_layout::parse_build(&by) else { continue }; + println!("=== {pak} entry {e} ==="); + let mut names: Vec<&String> = b.sprites.keys().collect(); + names.sort(); + for n in names { + let (off, size) = b.sprites[n]; + let s = &by[off..(off + size).min(by.len())]; + if s.len() < 8 || &s[0..4] != b"T8aD" { continue } + let w = u32::from_be_bytes([s[4], s[5], s[6], s[7]]); + println!("{n:<26} +0x04 = {w:08X} bit 0x02 {} PREDICT {}", + if w & 2 != 0 { "SET " } else { "clear" }, + if w & 2 != 0 { "ADDITIVE" } else { "alpha-over" }); + } + println!(); + } +} diff --git a/crates/sylpheed-formats/examples/blend_vs_t8ad_bit.rs b/crates/sylpheed-formats/examples/blend_vs_t8ad_bit.rs new file mode 100644 index 00000000..27dde596 --- /dev/null +++ b/crates/sylpheed-formats/examples/blend_vs_t8ad_bit.rs @@ -0,0 +1,152 @@ +//! Does `T8aD +0x04` bit `0x02` predict the blend the GAME uses? +//! +//! ⚠️ **`REFUTED.md` kills this claim**: *"`T8aD +0x04` bit `0x02` selects an +//! additive blend" → mine, and refuted. Blending those sprites additively +//! worsens every measure against the capture.* That refutation rests entirely on +//! **our renderer** — it is a claim about our renderer, and the corpus's own rule +//! says so. Since it was written, the blend has been measured off the GPU per +//! draw on three screens (`structures/ui-blend-mode-measured.md`), so the claim +//! can now be tested against the oracle instead of against a render. +//! +//! The labels below are **not** from a render. Every one is a +//! `RB_BLENDCONTROL0` value read out of the guest command stream and attributed +//! to an element by quad size: +//! `data/ui-blend-mode-measured.txt`, `data/ui-blend-title-and-replication.txt`, +//! `data/ui-blend-extras-complete.txt`. +//! +//! cargo run -p sylpheed-formats --example blend_vs_t8ad_bit +use sylpheed_formats::{pak::PakArchive, ui_layout}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// (build entry, sprite, measured additive?) — the oracle's verdicts, verbatim. +const MEASURED: &[(usize, &str, bool)] = &[ + // --- GP_TITLE entry 4 + 2, the live title ------------------------------- + (4, "ptbase2.t32", false), + (4, "ptlogo1.t32", false), + (4, "ptlogo2.t32", false), + (4, "ptlogo_tm.t32", false), + (4, "ptcopyright.t32", false), + (4, "ptlogo_back2.t32", false), + (4, "ptlogo_back2eff.t32", false), + (2, "ptbtn00.t32", false), + (2, "ptbtn00f.t32", true), + // --- entry 5, the main menu --------------------------------------------- + (5, "ptbase.t32", false), + (5, "ptmsg.t32", false), + (5, "ptbtn01f.t32", false), + (5, "ptbtneff01.t32", false), + (5, "pteff10.t32", true), + (5, "pteff12.t32", true), + (5, "ptframe1.t32", true), + (5, "ptframe2.t32", true), + (5, "pteff03.t32", true), // the rotated sweep strips, via ptloop01/02 + (5, "pteff03a.t32", true), + // --- entry 6, EXTRAS ------------------------------------------------------ + (6, "ptbase.t32", false), + (6, "ptmsg2.t32", false), + (6, "pttitle.t32", false), + (6, "ptbtn11f.t32", false), + (6, "ptbtn12.t32", false), + (6, "ptbtn13.t32", false), + (6, "ptbtneff02.t32", false), + (6, "pteff10.t32", true), + (6, "pteff20.t32", true), + (6, "pteff21.t32", true), + (6, "pteff22.t32", true), + (6, "pteff23.t32", true), + (6, "ptframe3.t32", true), + (6, "ptframe4.t32", true), + (6, "pteff03.t32", true), + (6, "pteff03a.t32", true), +]; + +fn main() { + let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC")); + let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE"); + let mut hdr: BTreeMap<(usize, String), u32> = BTreeMap::new(); + for e in [2usize, 4, 5, 6] { + let by = ar.read(&ar.entries()[e]).expect("entry"); + let b = ui_layout::parse_build(&by).expect("build"); + for (n, &(off, size)) in &b.sprites { + let s = &by[off..(off + size).min(by.len())]; + if s.len() < 8 || &s[0..4] != b"T8aD" { continue } + hdr.insert((e, n.clone()), u32::from_be_bytes([s[4], s[5], s[6], s[7]])); + } + } + println!("{:<10} {:<22} {:<10} {:>10} {}", "entry", "sprite", "+0x04", "bit 0x02", "measured blend"); + let (mut tp, mut tn, mut fp, mut fnn, mut missing) = (0, 0, 0, 0, 0); + for &(e, n, additive) in MEASURED { + let Some(&w) = hdr.get(&(e, n.to_string())) else { + println!("{e:<10} {n:<22} {:<10} {:>10} {}", "MISSING", "-", + if additive { "ADDITIVE" } else { "alpha-over" }); + missing += 1; + continue; + }; + let bit = w & 0x02 != 0; + match (bit, additive) { + (true, true) => tp += 1, + (false, false) => tn += 1, + (true, false) => fp += 1, + (false, true) => fnn += 1, + } + println!("{e:<10} {n:<22} {:08X} {:>10} {}{}", w, bit, + if additive { "ADDITIVE" } else { "alpha-over" }, + if bit == additive { "" } else { " <== DISAGREES" }); + } + println!("\nbit set & additive {tp}"); + println!("bit clear & alpha-over {tn}"); + println!("bit set & alpha-over {fp} <- false positives"); + println!("bit clear & additive {fnn} <- false negatives"); + println!("sprite not found {missing}"); + println!("\n{}", if fp == 0 && fnn == 0 && missing == 0 { + "PERFECT PARTITION on every element whose blend was measured." + } else { + "THE BIT DOES NOT PREDICT THE MEASURED BLEND." + }); + + // ── THE CONTROL THAT MATTERS ──────────────────────────────────────────── + // A perfect partition is worthless if half the header partitions equally + // well: then the sample is too small to single out a field, and picking + // `+0x04` bit 0x02 out of the tie is the same mistake as picking `+0x08` + // 0x8050 was. So: how many OTHER bits of the first 12 header words separate + // the same 35 elements without error? + let mut rivals: Vec = Vec::new(); + let mut words: BTreeMap<(usize, String), Vec> = BTreeMap::new(); + for e in [2usize, 4, 5, 6] { + let by = ar.read(&ar.entries()[e]).expect("entry"); + let b = ui_layout::parse_build(&by).expect("build"); + for (n, &(off, size)) in &b.sprites { + let s = &by[off..(off + size).min(by.len())]; + if s.len() < 48 || &s[0..4] != b"T8aD" { continue } + words.insert((e, n.clone()), (0..12) + .map(|k| u32::from_be_bytes([s[k*4], s[k*4+1], s[k*4+2], s[k*4+3]])) + .collect()); + } + } + for w in 0..12 { + for bit in 0..32 { + let mut ok = true; + let mut set_seen = false; + let mut clear_seen = false; + for &(e, n, additive) in MEASURED { + let Some(v) = words.get(&(e, n.to_string())) else { ok = false; break }; + let on = (v[w] >> bit) & 1 == 1; + if on { set_seen = true } else { clear_seen = true } + if on != additive { ok = false; break } + } + // A constant bit trivially "agrees" with nothing; require both sides. + if ok && set_seen && clear_seen { + rivals.push(format!("+0x{:02X} bit {bit} (0x{:X})", w * 4, 1u32 << bit)); + } + } + } + println!("\nRIVAL FIELDS — other bits of the first 12 header words that separate"); + println!("the same 35 elements with zero errors: {}", rivals.len()); + for r in &rivals { println!(" {r}"); } + if rivals.len() == 1 { + println!(" -> the sample singles out ONE field. Nothing else in the header does it."); + } else { + println!(" -> the sample does NOT single out a field; {} candidates tie.", rivals.len()); + } +} diff --git a/crates/sylpheed-formats/examples/frame_alpha_census.rs b/crates/sylpheed-formats/examples/frame_alpha_census.rs index 3a80f974..d6188147 100644 --- a/crates/sylpheed-formats/examples/frame_alpha_census.rs +++ b/crates/sylpheed-formats/examples/frame_alpha_census.rs @@ -38,15 +38,18 @@ fn census(name: &str, img: &t8ad::T8adImage) { fn main() { let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC")); - let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE"); + let argv: Vec = std::env::args().skip(1).collect(); + let pak = argv.iter().find(|a| a.parse::().is_err()) + .cloned().unwrap_or_else(|| "GP_TITLE".to_string()); + let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak"); // Builds default to the two the port ships and can be overridden, so the // same census serves the title (4) and the `PRESS (A)` plate (2). - let args: Vec = std::env::args().skip(1).filter_map(|a| a.parse().ok()).collect(); + let args: Vec = argv.iter().filter_map(|a| a.parse().ok()).collect(); let builds: Vec = if args.is_empty() { vec![5, 6] } else { args }; for build in builds { - let by = ar.read(&ar.entries()[build]).expect("entry"); - let b = ui_layout::parse_build(&by).expect("build"); - println!("=== GP_TITLE build {build} ==="); + let Ok(by) = ar.read(&ar.entries()[build]) else { continue }; + let Some(b) = ui_layout::parse_build(&by) else { continue }; + println!("=== {pak} build {build} ==="); let mut names: Vec<&String> = b.sprites.keys().collect(); names.sort(); for n in names { diff --git a/crates/sylpheed-formats/examples/rest_scale_of.rs b/crates/sylpheed-formats/examples/rest_scale_of.rs index eb238a81..2f4b7b18 100644 --- a/crates/sylpheed-formats/examples/rest_scale_of.rs +++ b/crates/sylpheed-formats/examples/rest_scale_of.rs @@ -11,12 +11,15 @@ use std::path::PathBuf; fn main() { let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC")); - let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE"); - let builds: Vec = std::env::args().skip(1).filter_map(|a| a.parse().ok()).collect(); + let argv: Vec = std::env::args().skip(1).collect(); + let pak = argv.iter().find(|a| a.parse::().is_err()) + .cloned().unwrap_or_else(|| "GP_TITLE".to_string()); + let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak"); + let builds: Vec = argv.iter().filter_map(|a| a.parse().ok()).collect(); for build in if builds.is_empty() { vec![5usize, 6] } else { builds } { - let by = ar.read(&ar.entries()[build]).expect("entry"); - let b = ui_layout::parse_build(&by).expect("build"); - println!("=== GP_TITLE entry {build} ==="); + let Ok(by) = ar.read(&ar.entries()[build]) else { continue }; + let Some(b) = ui_layout::parse_build(&by) else { continue }; + println!("=== {pak} entry {build} ==="); println!("{:<22} {:>10} {:>7} {:>7} {:>12} {}", "element", "pivot(w,h)", "sx%", "sy%", "drawn px", "at 1x/2x of pivot*2"); for e in &b.elements { let k = match e.rest() { Some(k) => k, None => continue }; diff --git a/docs/re/data/blend-bit-prediction-gp-options.txt b/docs/re/data/blend-bit-prediction-gp-options.txt new file mode 100644 index 00000000..088b3d85 --- /dev/null +++ b/docs/re/data/blend-bit-prediction-gp-options.txt @@ -0,0 +1,36 @@ +# PREDICTION, written and committed BEFORE the capture that tests it. 2026-08-31. +# +# blend_vs_t8ad_bit finds T8aD +0x04 bit 0x02 separates ADDITIVE from alpha-over +# on all 35 elements whose blend has been measured off the GPU, and that NO OTHER +# BIT of the 48-byte header does it. That is a fit to three screens of one +# archive. +# +# GP_OPTIONS has never been captured, is a different archive with an entirely +# different element set, and is a screen the port ships. Its prediction is MIXED, +# which is what makes it a test rather than a formality: 3 elements ADDITIVE, +# 595 alpha-over. +# +# FALSIFIED IF: po_menu_eff01/02/03 draw alpha-over, or any other GP_OPTIONS +# element draws additive. +# +# (The developer splash was considered first and rejected as a test: both its +# elements predict alpha-over, so it can fail but cannot discriminate.) + +=== the ADDITIVE predictions in GP_OPTIONS === +po_menu_eff01.t32 +0x04 = 00008832 bit 0x02 SET PREDICT ADDITIVE +po_menu_eff02.t32 +0x04 = 00008832 bit 0x02 SET PREDICT ADDITIVE +po_menu_eff03.t32 +0x04 = 00008832 bit 0x02 SET PREDICT ADDITIVE + +=== counts === +elements predicted ADDITIVE: 6 +elements predicted alpha-over: 592 + +=== the developer splash, for completeness (both alpha-over) === +=== GP_TITLE entry 10 === +palogo_sqex.t32 +0x04 = 00008830 bit 0x02 clear PREDICT alpha-over +palogo_sqex_eff.t32 +0x04 = 00008830 bit 0x02 clear PREDICT alpha-over + +=== GP_TITLE entry 13 === +palogo_sqex.t32 +0x04 = 00008830 bit 0x02 clear PREDICT alpha-over +palogo_sqex_eff.t32 +0x04 = 00008830 bit 0x02 clear PREDICT alpha-over + diff --git a/docs/re/data/blend-bit-vs-oracle.txt b/docs/re/data/blend-bit-vs-oracle.txt new file mode 100644 index 00000000..2b091801 --- /dev/null +++ b/docs/re/data/blend-bit-vs-oracle.txt @@ -0,0 +1,67 @@ +# T8aD +0x04 bit 0x02 vs the blend the GAME uses -- 35 elements, 3 screens. +# 2026-08-31. cargo run -p sylpheed-formats --example blend_vs_t8ad_bit +# +# ⚠️ REFUTED.md kills this claim: "T8aD +0x04 bit 0x02 selects an additive blend +# -> mine, and refuted. Blending those sprites additively worsens every measure +# against the capture." That refutation is a claim about OUR RENDERER, which the +# corpus's own rule says is a hypothesis under test. The blend has since been +# measured off the GPU, so the claim can be tested against the oracle instead. +# +# Every label below is an RB_BLENDCONTROL0 value read out of the guest command +# stream and attributed to an element by quad size -- see +# data/ui-blend-mode-measured.txt, data/ui-blend-title-and-replication.txt and +# data/ui-blend-extras-complete.txt. +# +# The pair that no confound survives: ptbtn00 = 0x0110 and ptbtn00f = 0x0112, the +# PRESS (A) plate and its own highlight, same screen, same draw order, differing +# in exactly this bit -- and the game draws one alpha-over and the other additive. + +entry sprite +0x04 bit 0x02 measured blend +4 ptbase2.t32 00008830 false alpha-over +4 ptlogo1.t32 00008830 false alpha-over +4 ptlogo2.t32 00008830 false alpha-over +4 ptlogo_tm.t32 00008830 false alpha-over +4 ptcopyright.t32 00008830 false alpha-over +4 ptlogo_back2.t32 00008830 false alpha-over +4 ptlogo_back2eff.t32 00008830 false alpha-over +2 ptbtn00.t32 00000110 false alpha-over +2 ptbtn00f.t32 00000112 true ADDITIVE +5 ptbase.t32 00008830 false alpha-over +5 ptmsg.t32 00008830 false alpha-over +5 ptbtn01f.t32 00008130 false alpha-over +5 ptbtneff01.t32 00008130 false alpha-over +5 pteff10.t32 00008832 true ADDITIVE +5 pteff12.t32 00008832 true ADDITIVE +5 ptframe1.t32 00008832 true ADDITIVE +5 ptframe2.t32 00008832 true ADDITIVE +5 pteff03.t32 00008832 true ADDITIVE +5 pteff03a.t32 00008832 true ADDITIVE +6 ptbase.t32 00008830 false alpha-over +6 ptmsg2.t32 00008830 false alpha-over +6 pttitle.t32 00008830 false alpha-over +6 ptbtn11f.t32 00008130 false alpha-over +6 ptbtn12.t32 00008130 false alpha-over +6 ptbtn13.t32 00008130 false alpha-over +6 ptbtneff02.t32 00008130 false alpha-over +6 pteff10.t32 00008832 true ADDITIVE +6 pteff20.t32 00008832 true ADDITIVE +6 pteff21.t32 00008832 true ADDITIVE +6 pteff22.t32 00008832 true ADDITIVE +6 pteff23.t32 00008832 true ADDITIVE +6 ptframe3.t32 00008832 true ADDITIVE +6 ptframe4.t32 00008832 true ADDITIVE +6 pteff03.t32 00008832 true ADDITIVE +6 pteff03a.t32 00008832 true ADDITIVE + +bit set & additive 16 +bit clear & alpha-over 19 +bit set & alpha-over 0 <- false positives +bit clear & additive 0 <- false negatives +sprite not found 0 + +PERFECT PARTITION on every element whose blend was measured. + +RIVAL FIELDS — other bits of the first 12 header words that separate +the same 35 elements with zero errors: 1 + +0x04 bit 1 (0x2) + -> the sample singles out ONE field. Nothing else in the header does it. diff --git a/tools/re-capture/ui_blend_map.py b/tools/re-capture/ui_blend_map.py index 0c165b9f..d68d682f 100755 --- a/tools/re-capture/ui_blend_map.py +++ b/tools/re-capture/ui_blend_map.py @@ -45,7 +45,7 @@ def blend_name(raw): return "%s+%s" % (FACTOR.get(src, src), FACTOR.get(dst, dst)) -def candidates(builds): +def candidates(builds, pak="GP_TITLE"): """Every size a UI draw could legitimately have, with a label and a basis. Two bases, because neither alone names every element: @@ -67,7 +67,7 @@ def candidates(builds): def run(example): return subprocess.run( ["cargo", "run", "--release", "-q", "-p", "sylpheed-formats", - "--example", example, "--"] + [str(b) for b in builds], + "--example", example, "--", pak] + [str(b) for b in builds], capture_output=True, text=True, cwd="/work", env=env).stdout out = [] for line in run("rest_scale_of").splitlines(): @@ -87,7 +87,8 @@ def main(): path = sys.argv[1] spec = sys.argv[sys.argv.index("--build") + 1] if "--build" in sys.argv else "5" builds = [int(b) for b in spec.split(",")] - cands = candidates(builds) + pak = sys.argv[sys.argv.index("--pak") + 1] if "--pak" in sys.argv else "GP_TITLE" + cands = candidates(builds, pak) lines = open(path).read().splitlines() rows = [] for i, line in enumerate(lines):