From c8d3a6a15d5feb0416b2b589cb8e0c442f7422d3 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Sun, 30 Aug 2026 08:55:47 +0000 Subject: [PATCH] formats: drop the 1.5 MB cap that truncated 17 voice regions' first stream The cause, and the fix, with a disc-wide check. resolve_movie_voice_region picks start = the predecessor cue's trailer, then filtered it with 'end - s < 1_500_000' -- 'only within one bank'. ADV's predecessor sits 3 618 816 B before end, so the filter rejected it and start fell back to anchor, which is a TOC offset and not a stream boundary. That explains the shape of the defect exactly: it strikes regions larger than 1.5 MB, which is why the three-stream multichannel regions are hit and single-stream ones never are. 17 of 95 resolving movies took the fallback. ADV's predecessor trailer at 433 425 776 plus 17 040 B of descriptor and padding is 433 442 816 -- the -238-packet start measured against the decoder, to the byte. Dropping the cap: unchanged 78, fixed cleanly 17, changed in any other way ZERO. In all 17 the only difference is a larger first chunk with every later chunk byte-identical, which is what a corrected start looks like and what pulling in a neighbouring asset does not. Regression test pinned to the RUNNING DECODER's byte_sizes rather than to this crate's own output. That is the point of it: every internal check passed happily while a third of a stream was missing, so only an external number could have caught this class of bug. sylpheed-formats: 136 tests pass, 0 fail (the one still running at commit time is an unrelated long mesh test). Exact clips for the other 16 are not independently verified -- the sweep is strong but ADV is the only one with a decoder measurement behind it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v --- .../examples/voice_region_cap_sweep.rs | 71 +++++++++++++++++++ .../examples/voice_region_fix_test.rs | 40 +++++++++++ .../examples/voice_region_start_why.rs | 70 ++++++++++++++++++ crates/sylpheed-formats/src/media.rs | 23 +++++- crates/sylpheed-formats/tests/media_disc.rs | 36 ++++++++++ docs/re/data/voice-region-cap-sweep.txt | 36 ++++++++++ docs/re/data/voice-region-start-clip.txt | 28 ++++++++ .../re/structures/voice-region-starts-late.md | 57 +++++++++++++-- 8 files changed, 351 insertions(+), 10 deletions(-) create mode 100644 crates/sylpheed-formats/examples/voice_region_cap_sweep.rs create mode 100644 crates/sylpheed-formats/examples/voice_region_fix_test.rs create mode 100644 crates/sylpheed-formats/examples/voice_region_start_why.rs create mode 100644 docs/re/data/voice-region-cap-sweep.txt diff --git a/crates/sylpheed-formats/examples/voice_region_cap_sweep.rs b/crates/sylpheed-formats/examples/voice_region_cap_sweep.rs new file mode 100644 index 00000000..930096fc --- /dev/null +++ b/crates/sylpheed-formats/examples/voice_region_cap_sweep.rs @@ -0,0 +1,71 @@ +//! Is raising the start filter's 1.5 MB cap safe, disc-wide? +//! +//! `voice_region_fix_test.rs` shows that for `ADV` the predecessor start recovers +//! the decoder's own three byte_sizes exactly. But the cap exists to protect a +//! case: the code says *"only within one bank (~1.5 MB), else this is the first cue +//! in its block and the audio starts at the anchor itself"*. Raising it blindly +//! could pull a **previous asset's** streams into the region. +//! +//! So compare, per movie: the chunk list the resolver gives today against the one +//! the predecessor start gives. A safe change makes the FIRST chunk bigger and +//! leaves the rest identical. An unsafe one adds leading chunks. +//! +//! cargo run -p sylpheed-formats --example voice_region_cap_sweep + +use sylpheed_formats::hash::name_hash; +use sylpheed_formats::media::{DirectorySource, DiscSource}; +use sylpheed_formats::pak::PakArchive; +use sylpheed_formats::slb::{self, VoiceLang}; +use sylpheed_formats::{movie_manifest, movie_voice}; + +fn main() { + let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"); + let src = DirectorySource::new(std::path::PathBuf::from(&disc)); + let code = VoiceLang::English.code_pub(); + let tpak = src.open_pak("dat/tables.pak").expect("tables.pak"); + let manifest = tpak.entries().iter() + .find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b))) + .expect("manifest"); + let marker = format!("{code}\\Movie\\VOICE_ADV.slb"); + let registry = tpak.entries().iter().find_map(|e| { + tpak.read(e).ok().filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes())) + }).expect("registry"); + let ids = movie_voice::registry_voice_ids(®istry); + let stoc = src.read_file("dat/sound.pak").expect("sound.pak"); + let entries = PakArchive::parse_toc(&stoc).expect("toc"); + + let (mut same, mut grew, mut extra, mut skip) = (0, 0, 0, 0); + for m in movie_manifest::parse(&manifest) { + let movie = m.movie; + let Some(token) = movie_manifest::voice_token(&manifest, &movie) else { skip += 1; continue }; + let Some(&id) = ids.get(&token) else { skip += 1; continue }; + let Some(anchor) = ["Movie","etc","Voice"].iter().find_map(|dir| { + let h = name_hash(&format!("{code}\\{dir}\\{token}.slb")); + entries.binary_search_by_key(&h, |e| e.name_hash).ok().map(|i| entries[i].offset as u64) + }) else { skip += 1; continue }; + let win_start = anchor.saturating_sub(2*1024*1024) & !3; + let Ok(window) = src.read_segment_range("dat/sound", win_start, 8*1024*1024) else { skip += 1; continue }; + let Some(end_local) = movie_voice::find_descriptor(&window, id) else { skip += 1; continue }; + let end = win_start + end_local as u64; + let cand = movie_voice::find_descriptor(&window, id.wrapping_sub(1)) + .or_else(|| movie_voice::find_descriptor_before(&window, end_local)) + .map(|o| win_start + o as u64) + .filter(|&s| s < end); + let today = cand.filter(|&s| end - s < 1_500_000).unwrap_or(anchor); + let Some(proposed) = cand else { skip += 1; continue }; + if today == proposed { same += 1; continue } + let sizes = |s: u64| -> Vec { + src.read_segment_range("dat/sound", s, (end - s) as usize) + .map(|b| slb::to_xma_riffs(&b).iter().map(|r| r.len() - 60).collect()) + .unwrap_or_default() + }; + let (a, b) = (sizes(today), sizes(proposed)); + let tail_same = a.len() == b.len() && a.iter().skip(1).eq(b.iter().skip(1)); + let verdict = if a.len() == b.len() && tail_same && b[0] > a[0] { + grew += 1; "first chunk GREW, tail identical" + } else if b.len() > a.len() { extra += 1; "EXTRA leading chunks" } + else { extra += 1; "changed otherwise" }; + println!("{movie:10} today {a:?}\n{:10} prop {b:?} {verdict}", ""); + } + println!("\nunchanged {same} fixed-cleanly {grew} would-break {extra} skipped {skip}"); +} diff --git a/crates/sylpheed-formats/examples/voice_region_fix_test.rs b/crates/sylpheed-formats/examples/voice_region_fix_test.rs new file mode 100644 index 00000000..7427e4e0 --- /dev/null +++ b/crates/sylpheed-formats/examples/voice_region_fix_test.rs @@ -0,0 +1,40 @@ +//! Would keeping the predecessor (instead of falling back to `anchor`) recover +//! the streams the running decoder actually decodes? +//! +//! `voice_region_start_why.rs` shows the failing branch: the start filter +//! `end - s < 1_500_000` rejects `ADV`'s predecessor because its span is 3.6 MB, +//! so `start` falls back to `anchor` — a TOC offset, not a stream boundary. +//! +//! This does NOT patch the resolver. It asks the one question that decides whether +//! raising that cap is the fix: **from the predecessor, does `to_xma_riffs` return +//! the decoder's own byte_sizes?** For `ADV` those are known, so this is a test and +//! not a fit. +//! +//! cargo run -p sylpheed-formats --example voice_region_fix_test + +use sylpheed_formats::media::{DirectorySource, DiscSource}; +use sylpheed_formats::slb; + +const ADV_PRED: u64 = 433_425_776; +const ADV_ANCHOR: u64 = 433_930_240; +const ADV_END: u64 = 437_044_592; +/// What the running decoder reported. +const WANT: [usize; 3] = [1_294_336, 1_118_208, 1_171_456]; + +fn main() { + let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"); + let src = DirectorySource::new(std::path::PathBuf::from(&disc)); + for (label, start) in [("anchor (today)", ADV_ANCHOR), ("predecessor (proposed)", ADV_PRED)] { + let bytes = src + .read_segment_range("dat/sound", start, (ADV_END - start) as usize) + .expect("region"); + let sizes: Vec = slb::to_xma_riffs(&bytes).iter().map(|r| r.len() - 60).collect(); + let hit = sizes.len() == 3 && sizes.iter().zip(WANT.iter()).all(|(a, b)| a == b); + println!( + "{label:24} start {start} span {:>9} -> {:?}{}", + ADV_END - start, + sizes, + if hit { " <== MATCHES THE DECODER" } else { "" } + ); + } +} diff --git a/crates/sylpheed-formats/examples/voice_region_start_why.rs b/crates/sylpheed-formats/examples/voice_region_start_why.rs new file mode 100644 index 00000000..7195c2af --- /dev/null +++ b/crates/sylpheed-formats/examples/voice_region_start_why.rs @@ -0,0 +1,70 @@ +//! WHY does `resolve_movie_voice_region` start inside the first stream? +//! +//! [`voice-region-starts-late.md`] establishes that it does — 238 packets late for +//! `ADV`, 8 of 10 multichannel regions disc-wide — but not why, and a fix guessed +//! from one movie would be worse than a documented defect. This reproduces the +//! resolver's own steps and prints each candidate, so the failing branch is visible +//! rather than inferred. +//! +//! The suspicion the code itself raises: the start is filtered by +//! `end - s < 1_500_000` — "only within one bank" — and `ADV`'s region has to span +//! **3.6 MB**. If that filter rejects the real predecessor, `start` silently falls +//! back to `anchor`, which is a TOC offset and not a stream boundary at all. +//! +//! cargo run -p sylpheed-formats --example voice_region_start_why + +use sylpheed_formats::hash::name_hash; +use sylpheed_formats::media::{DirectorySource, DiscSource}; +use sylpheed_formats::pak::PakArchive; +use sylpheed_formats::slb::VoiceLang; +use sylpheed_formats::{movie_manifest, movie_voice}; + +fn main() { + let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"); + let src = DirectorySource::new(std::path::PathBuf::from(&disc)); + let code = VoiceLang::English.code_pub(); + let tpak = src.open_pak("dat/tables.pak").expect("tables.pak"); + let manifest = tpak + .entries().iter() + .find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b))) + .expect("manifest"); + let marker = format!("{code}\\Movie\\VOICE_ADV.slb"); + let registry = tpak.entries().iter().find_map(|e| { + tpak.read(e).ok().filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes())) + }).expect("registry"); + let ids = movie_voice::registry_voice_ids(®istry); + let stoc = src.read_file("dat/sound.pak").expect("sound.pak"); + let entries = PakArchive::parse_toc(&stoc).expect("toc"); + + println!("{:10} {:>6} {:>12} {:>12} {:>12} {:>10} {:>9} {}", + "movie","id","anchor","pred(id-1)","pred(before)","span","chosen","note"); + for m in movie_manifest::parse(&manifest) { + let movie = m.movie; + let Some(token) = movie_manifest::voice_token(&manifest, &movie) else { continue }; + let Some(&id) = ids.get(&token) else { continue }; + let Some(anchor) = ["Movie","etc","Voice"].iter().find_map(|dir| { + let h = name_hash(&format!("{code}\\{dir}\\{token}.slb")); + entries.binary_search_by_key(&h, |e| e.name_hash).ok().map(|i| entries[i].offset as u64) + }) else { continue }; + let win_start = anchor.saturating_sub(2*1024*1024) & !3; + let Ok(window) = src.read_segment_range("dat/sound", win_start, 8*1024*1024) else { continue }; + let Some(end_local) = movie_voice::find_descriptor(&window, id) else { continue }; + let end = win_start + end_local as u64; + let p1 = movie_voice::find_descriptor(&window, id.wrapping_sub(1)).map(|o| win_start + o as u64); + let pb = movie_voice::find_descriptor_before(&window, end_local).map(|o| win_start + o as u64); + let cand = p1.or(pb); + // the resolver's own filter + let kept = cand.filter(|&s| s < end && end - s < 1_500_000); + let chosen = kept.unwrap_or(anchor); + let span = cand.map(|s| end.saturating_sub(s)).unwrap_or(0); + let note = match (cand, kept) { + (Some(_), None) => "REJECTED by the 1.5 MB filter -> fell back to anchor", + (Some(_), Some(_)) => "predecessor kept", + (None, _) => "no predecessor found -> anchor", + }; + println!("{movie:10} {id:>6} {anchor:>12} {:>12} {:>12} {span:>10} {:>9} {note}", + p1.map(|v| v.to_string()).unwrap_or("-".into()), + pb.map(|v| v.to_string()).unwrap_or("-".into()), + if chosen == anchor { "anchor" } else { "pred" }); + } +} diff --git a/crates/sylpheed-formats/src/media.rs b/crates/sylpheed-formats/src/media.rs index 14cccb43..3f92980d 100644 --- a/crates/sylpheed-formats/src/media.rs +++ b/crates/sylpheed-formats/src/media.rs @@ -288,12 +288,29 @@ pub fn resolve_movie_voice_region( // Start = the predecessor trailer. Prefer the exact `id-1`; where the id // sequence has a gap (VOICE_D_453 → 454) fall back to the nearest trailer - // below — but only within one bank (~1.5 MB), else this is the first cue in - // its block and the audio starts at the anchor itself. + // below. + // + // 🔴 There used to be a second condition here — `end - s < 1_500_000`, "only + // within one bank, else this is the first cue in its block and the audio + // starts at the anchor itself". **It was wrong, and it silently truncated the + // first stream of every region larger than 1.5 MB.** `anchor` is a TOC offset, + // not a stream boundary, so the fallback started mid-packet-run: `ADV` began + // **238 packets (487 424 B) into its own first stream**, and a consumer then + // saw a leading chunk that "matched nothing" and dropped 62 % of a real stream. + // + // Ground truth is the running decoder, which reports `ADV`'s three contexts as + // 1 294 336 / 1 118 208 / 1 171 456 (`--xma_param_probe`). With the cap gone the + // region reproduces all three exactly; with it, the first is 806 912. + // + // Disc-wide over the 95 manifest movies that resolve: **17 regions fixed, 78 + // unchanged, 0 changed in any other way** — in every one of the 17 the first + // chunk grows and the remaining chunks are byte-identical, which is what a + // corrected start looks like and what pulling in a neighbouring asset does not. + // `docs/re/structures/voice-region-starts-late.md`. let start = movie_voice::find_descriptor(&window, id.wrapping_sub(1)) .or_else(|| movie_voice::find_descriptor_before(&window, end_local)) .map(|o| win_start + o as u64) - .filter(|&s| s < end && end - s < 1_500_000) + .filter(|&s| s < end) .unwrap_or(anchor); Some((start, end)) } diff --git a/crates/sylpheed-formats/tests/media_disc.rs b/crates/sylpheed-formats/tests/media_disc.rs index a687df39..8c80eafc 100644 --- a/crates/sylpheed-formats/tests/media_disc.rs +++ b/crates/sylpheed-formats/tests/media_disc.rs @@ -86,3 +86,39 @@ fn manifest_binding_is_the_only_route() { None ); } + +/// The resolved `ADV` voice region must contain **all three** streams the running +/// decoder decodes — not a truncated first one. +/// +/// Ground truth is the emulator, not this crate: booting with `--xma_param_probe` +/// reports three XMA contexts with `byte_size` 1 294 336 / 1 118 208 / 1 171 456 +/// (`docs/re/structures/voice-three-streams-are-concurrent.md`). Until 2026-08-30 +/// the resolver's start filter capped a region at 1.5 MB, `ADV`'s span is 3.6 MB, +/// so the start fell back to `anchor` — a TOC offset, 238 packets into the first +/// stream — and this returned 806 912 for the first chunk. +/// +/// This is a regression test against an EXTERNAL measurement, which is the only +/// kind that can catch the class of bug it was written for: every internal check +/// passed happily while a third of a stream was missing. +#[test] +fn adv_voice_region_holds_all_three_decoded_streams() { + let Some(src) = disc() else { + eprintln!("SKIP: set SYLPHEED_DISC"); + return; + }; + let (start, end) = media::resolve_movie_voice_region(&src, "ADV", VoiceLang::English) + .expect("ADV voice region"); + let bytes = src + .read_segment_range("dat/sound", start, (end - start) as usize) + .expect("region bytes"); + let sizes: Vec = sylpheed_formats::slb::to_xma_riffs(&bytes) + .iter() + .map(|r| r.len() - 60) + .collect(); + assert_eq!( + sizes, + vec![1_294_336, 1_118_208, 1_171_456], + "the region must reproduce the RUNNING DECODER's byte_sizes; \ + a first chunk of 806912 means the start filter has come back" + ); +} diff --git a/docs/re/data/voice-region-cap-sweep.txt b/docs/re/data/voice-region-cap-sweep.txt new file mode 100644 index 00000000..23ad0a5d --- /dev/null +++ b/docs/re/data/voice-region-cap-sweep.txt @@ -0,0 +1,36 @@ +ADV today [806912, 1118208, 1171456] + prop [1294336, 1118208, 1171456] first chunk GREW, tail identical +S00A today [1323008, 1263616, 98304] + prop [1810432, 1263616, 98304] first chunk GREW, tail identical +S01A today [1153024, 1390592, 1552384] + prop [1640448, 1390592, 1552384] first chunk GREW, tail identical +S02B today [430080, 505856, 866304] + prop [919552, 505856, 866304] first chunk GREW, tail identical +S02C today [1867776, 1349632, 2390016] + prop [2353152, 1349632, 2390016] first chunk GREW, tail identical +S03A today [251904, 540672, 739328] + prop [741376, 540672, 739328] first chunk GREW, tail identical +S04B today [555008, 870400, 991232] + prop [1044480, 870400, 991232] first chunk GREW, tail identical +S06A today [350208, 294912, 860160] + prop [839680, 294912, 860160] first chunk GREW, tail identical +S06B today [606208, 874496, 1449984] + prop [1093632, 874496, 1449984] first chunk GREW, tail identical +S07A today [503808, 473088, 1040384] + prop [993280, 473088, 1040384] first chunk GREW, tail identical +S09B today [176128, 571392, 843776] + prop [665600, 571392, 843776] first chunk GREW, tail identical +S11C today [741376, 1075200, 1107968] + prop [1228800, 1075200, 1107968] first chunk GREW, tail identical +S12C today [1843200, 1273856, 2502656] + prop [2328576, 1273856, 2502656] first chunk GREW, tail identical +S13A today [401408, 585728, 962560] + prop [890880, 585728, 962560] first chunk GREW, tail identical +S14A today [1816576, 1634304, 2541568] + prop [2301952, 1634304, 2541568] first chunk GREW, tail identical +S15A today [253952, 618496, 1122304] + prop [743424, 618496, 1122304] first chunk GREW, tail identical +S15C today [929792, 1101824, 1384448] + prop [1417216, 1101824, 1384448] first chunk GREW, tail identical + +unchanged 78 fixed-cleanly 17 would-break 0 skipped 9 diff --git a/docs/re/data/voice-region-start-clip.txt b/docs/re/data/voice-region-start-clip.txt index a41f83d2..5d683685 100644 --- a/docs/re/data/voice-region-start-clip.txt +++ b/docs/re/data/voice-region-start-clip.txt @@ -45,3 +45,31 @@ decoder wants [1294336, 1118208, 1171456] = 3584000 B payload # will happily absorb a few packets of the PREVIOUS asset into the first chunk # before that happens -- for ADV it reports 243 where the decoder-verified # answer is 238. Only ADV has external ground truth. + +# --------------------------------------------------------------------------- +# WHY (examples/voice_region_start_why.rs), and the FIX (cap_sweep) +# +# The resolver picks start = the predecessor cue's trailer, then filters it with +# .filter(|&s| s < end && end - s < 1_500_000) +# "only within one bank (~1.5 MB), else this is the first cue in its block and +# the audio starts at the anchor itself". +# +# ADV's predecessor sits 3 618 816 B before `end`. The filter REJECTS it, and the +# start falls back to `anchor` -- a TOC offset, not a stream boundary: +# +# movie id anchor pred(before) span chosen +# ADV 1600 433930240 433425776 3618816 anchor <- REJECTED +# +# predecessor 433425776 + 17 040 B of descriptor/padding = 433442816, +# which is exactly the -238 packet start measured against the decoder. +# +# 17 of 95 resolving movies hit this. Reading from the predecessor instead: +# +# anchor (today) start 433930240 -> [806912, 1118208, 1171456] +# predecessor (proposed) start 433425776 -> [1294336, 1118208, 1171456] MATCHES +# +# DISC-WIDE CONSEQUENCE of dropping the cap (voice-region-cap-sweep.txt): +# unchanged 78 fixed-cleanly 17 would-break 0 skipped 9 +# In all 17 the first chunk GROWS and every later chunk is byte-identical -- +# which is what a corrected start looks like, and what pulling in a neighbouring +# asset does not. diff --git a/docs/re/structures/voice-region-starts-late.md b/docs/re/structures/voice-region-starts-late.md index 8c868b6d..7c8afe3a 100644 --- a/docs/re/structures/voice-region-starts-late.md +++ b/docs/re/structures/voice-region-starts-late.md @@ -77,11 +77,54 @@ clipped chunk 0; the quantitative argument there survives because it quotes a **ratio** test explicitly chosen to be immune to the clipping, but the absolute level for chunk 0 was measured over 62 % of the stream. -## ⚠️ What is NOT fixed here +## ✅ WHY — and it is fixed (2026-08-30, later) -**The resolver is unchanged.** This page establishes that its start is wrong for -these regions and by how much for one of them; it does not derive the correct rule. -`voice_region_chunks.rs`'s own note says the region *"starts at the PREDECESSOR -cue's trailer"* — so the question is why that trailer lands 238 packets into the -next asset, and that is not answered. Recorded rather than patched, because a fix -guessed from one movie would be worse than a documented defect. +The predecessor trailer does **not** land 238 packets into the next asset. It is +never consulted: a second condition on the start filter threw it away. + +```rust +.filter(|&s| s < end && end - s < 1_500_000) // "only within one bank" +``` + +`ADV`'s predecessor sits **3 618 816 B** before `end`, so the filter rejects it and +`start` falls back to `anchor` — **a TOC offset, which is not a stream boundary at +all**. That is the whole mechanism, and it explains the shape of the defect +exactly: it strikes regions *larger than 1.5 MB*, which is why the multichannel +three-stream regions are hit and the single-stream ones never are. + +| | | +|---|---| +| `ADV` predecessor trailer | 433 425 776 | +| + descriptor and padding | 17 040 B | +| = stream start | **433 442 816** — the −238-packet start, to the byte | + +**17 of the 95 resolving movies** took the fallback. + +### ✅ The fix, and its disc-wide check + +Dropping the cap (keeping `s < end`): + +``` +anchor (today) start 433930240 -> [806912, 1118208, 1171456] +predecessor (proposed) start 433425776 -> [1294336, 1118208, 1171456] MATCHES +``` + +| | movies | +|---|---| +| unchanged | **78** | +| fixed cleanly — first chunk grows, every later chunk byte-identical | **17** | +| **changed in any other way** | **0** | + +Zero. In all 17 the *only* difference is a larger first chunk, which is what a +corrected start looks like and what pulling in a neighbouring asset does not. +[`../data/voice-region-cap-sweep.txt`](../data/voice-region-cap-sweep.txt). + +**Landed** in `media.rs`, with a regression test pinned to the **running decoder's** +byte_sizes rather than to this crate's own output — +`adv_voice_region_holds_all_three_decoded_streams`. That distinction is the point: +every internal check passed happily while a third of a stream was missing, so only +an external number could have caught it. + +⚠️ **Exact clips for the other 16 are still not independently verified.** The sweep +shows their first chunk grows and their tails are untouched, which is strong; but +`ADV` is the only one with a decoder measurement behind it.