From 4cdbbc2d481557efdf5606354075324a648e5f28 Mon Sep 17 00:00:00 2001 From: "Claude (auto-RE)" Date: Thu, 13 Aug 2026 11:45:20 +0000 Subject: [PATCH] feat(xbg7): read the per-sub-mesh vertex declarations (XBG7_SUBMESH_DECLS, off by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit desc_dump shows each index marker is followed by its OWN element triples: n201_01 declares strides 24, 24, 24 and 28 (the last sub-mesh has a fourth element), which matches the runtime capture's stride=28 on that draw and the four distinct vertex shaders. parse_vertex_decl read the first declaration for the whole pool. all_vertex_decls reads one per marker; anchor_grouped_meshes uses each sub-mesh's own stride for the pool walk, the pivot validation and the read. debug_grouped_report follows the same setting so the diagnostic cannot blame the wrong gate — at n201_01's capture-proven pool start it now reports "pad 0: ACCEPTED" instead of a NaN position. With XBG7_SUBMESH_DECLS=1: resources that never decode 85 -> 47, resources decoding in no container 63 -> 30, capture oracles unchanged (93/93 index runs, 42/42 index counts), consistency unchanged at 96. Off by default because selection has not caught up: the three n201_0x copies then settle on one pool (twin_pairs_do_not_share_a_buffer fails), production still picks a start 4 bytes before sub-mesh #1 rather than the proven one even though the proven start validates and is unclaimed, and four newly decoded ptc_pack .dat composites carry degenerate triangles. Format truth is settled; choosing among candidates is the remaining work. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9 --- crates/sylpheed-formats/examples/desc_dump.rs | 71 ++++++++ crates/sylpheed-formats/examples/params.rs | 4 +- .../sylpheed-formats/examples/starts_probe.rs | 7 + crates/sylpheed-formats/src/mesh.rs | 152 +++++++++++++++--- crates/sylpheed-formats/tests/mesh_disc.rs | 9 +- docs/re/structures/xbg7-mesh.md | 52 ++++++ 6 files changed, 272 insertions(+), 23 deletions(-) create mode 100644 crates/sylpheed-formats/examples/desc_dump.rs create mode 100644 crates/sylpheed-formats/examples/starts_probe.rs diff --git a/crates/sylpheed-formats/examples/desc_dump.rs b/crates/sylpheed-formats/examples/desc_dump.rs new file mode 100644 index 0000000..f77a67e --- /dev/null +++ b/crates/sylpheed-formats/examples/desc_dump.rs @@ -0,0 +1,71 @@ +//! Dump an XBG7 resource's descriptor around each index marker, to see whether a +//! grouped pool carries a declaration PER sub-mesh (the 2026-08-13 mixed-stride +//! finding says it must: n201's four sub-meshes use strides 24/24/24/28 and four +//! different vertex shaders). +//! Usage: desc_dump + +fn be32(b: &[u8], o: usize) -> u32 { + u32::from_be_bytes(b[o..o + 4].try_into().unwrap()) +} + +fn main() { + let a: Vec = std::env::args().collect(); + let bytes = std::fs::read(&a[1]).unwrap(); + let want = &a[2]; + // Walk the XPR2 directory by hand (the crate's reader is private). + // 16-byte directory entries at 0x10: type tag, data offset, descriptor size, + // name offset — all relative to 0x10 (see texture::Xpr2ResourceEntry). + let num = be32(&bytes, 12) as usize; + for i in 0..num { + let e = &bytes[0x10 + i * 16..0x10 + i * 16 + 16]; + let tag = &e[0..4]; + let data_off = be32(e, 4) as usize + 0x10; + let desc_size = be32(e, 8) as usize; + let name_off = be32(e, 12) as usize + 0x10; + if tag != b"XBG7" { + continue; + } + let name = { + let s = name_off.min(bytes.len()); + let end = bytes[s..].iter().position(|&c| c == 0).unwrap_or(0) + s; + String::from_utf8_lossy(&bytes[s..end]).to_string() + }; + if name != *want { + continue; + } + let desc = &bytes[data_off..(data_off + desc_size).min(bytes.len())]; + println!("{name}: descriptor at 0x{data_off:X}, {} bytes", desc.len()); + // markers: a == c*2, c%3==0, vertex count 32 bytes earlier + let mut rel = 0usize; + while rel + 8 <= desc.len() { + let (x, c) = (be32(desc, rel), be32(desc, rel + 4)); + if c >= 3 && c % 3 == 0 && c < 400_000 && x == c * 2 && rel >= 32 { + let vc = be32(desc, rel - 32); + if (3..=200_000).contains(&vc) { + println!("\n marker @0x{rel:X}: {vc} verts / {c} indices — declaration triples after it:"); + let mut r = rel + 8; + let mut stride = 0u32; + for _ in 0..12 { + if r + 12 > desc.len() { + break; + } + let (o, code, usage) = (be32(desc, r), be32(desc, r + 4), be32(desc, r + 8) >> 16); + if o == 0x00FF_0000 || code == 0xFFFF_FFFF || o > 0x1000 { + println!(" end marker @0x{r:X}: off=0x{o:X} code=0x{code:X}"); + break; + } + println!(" off {o:>3} code 0x{:06X} usage {usage}", code & 0xFF_FFFF); + stride = stride.max(o + 4); + r += 12; + } + println!(" (min stride from element offsets: {stride})"); + rel += 8; + continue; + } + } + rel += 4; + } + return; + } + eprintln!("resource not found"); +} diff --git a/crates/sylpheed-formats/examples/params.rs b/crates/sylpheed-formats/examples/params.rs index 6279b43..ce8eaea 100644 --- a/crates/sylpheed-formats/examples/params.rs +++ b/crates/sylpheed-formats/examples/params.rs @@ -5,7 +5,9 @@ fn main(){ for n in sylpheed_formats::mesh::xbg7_resource_names(&bytes) { if !filter.is_empty() && !n.contains(&filter) { continue } if let Some((m,stride))=sylpheed_formats::mesh::debug_resource_params(&bytes,&n) { - println!("{n:<28} stride {stride} markers {:?}", &m[..m.len().min(4)]); + let strides = sylpheed_formats::mesh::debug_decl_strides(&bytes, &n); + println!("{n:<28} decl-stride {stride} markers {:?} per-sub-mesh strides {:?}", + &m[..m.len().min(4)], strides); } } } diff --git a/crates/sylpheed-formats/examples/starts_probe.rs b/crates/sylpheed-formats/examples/starts_probe.rs new file mode 100644 index 0000000..d516eab --- /dev/null +++ b/crates/sylpheed-formats/examples/starts_probe.rs @@ -0,0 +1,7 @@ +fn main(){let a:Vec=std::env::args().collect();let b=std::fs::read(&a[1]).unwrap(); +let stride:usize=a[2].parse().unwrap(); let want:usize=a[3].parse().unwrap(); +let s=sylpheed_formats::mesh::debug_vertex_run_starts(&b,stride); +println!("{} candidate starts at stride {}", s.len(), stride); +println!("contains {:#x}: {}", want, s.contains(&want)); +let near:Vec=s.iter().filter(|&&o| o.abs_diff(want)<0x200).map(|o|format!("{o:#x}")).collect(); +println!("nearby: {}", near.join(" "));} diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index 697d7ea..6d40708 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -513,6 +513,9 @@ impl Xbg7Model { /// → the grouped-pool layout ([`anchor_grouped_meshes`]). markers: Vec<(usize, usize)>, decl: VertexDecl, + /// One declaration per marker (same order). A grouped pool can mix + /// strides, so the grouped path must use these rather than `decl`. + decls: Vec, } let mut resources: Vec = Vec::new(); for _ in 0..header.num_resources { @@ -551,10 +554,17 @@ impl Xbg7Model { // at a different offset when requested alone. The filter is applied // to the OUTPUT instead, so a subset is always a subset of the // container's own answer. + let mut decls = if submesh_decls() { all_vertex_decls(d) } else { Vec::new() }; + if decls.len() != markers.len() { + // Never seen on the disc, but if the two walks disagree fall back + // to the single declaration rather than mis-pair them. + decls = vec![decl.clone(); markers.len()]; + } resources.push(Res { name, markers, decl, + decls, }); } if resources.is_empty() { @@ -614,7 +624,7 @@ impl Xbg7Model { // Several sub-meshes sharing grouped index/vertex pools → the // deterministic grouped-pool decode (hero ships et al.). let grouped = - anchor_grouped_meshes(bytes, data_base, starts, &r.markers, &r.decl, &empty_taken); + anchor_grouped_meshes(bytes, data_base, starts, &r.markers, &r.decls, &empty_taken); if !grouped.is_empty() { grouped } else { @@ -728,7 +738,7 @@ impl Xbg7Model { // Grouped pool: re-place the WHOLE pool past everything // claimed, or keep what we had. let alt = anchor_grouped_meshes( - bytes, data_base, starts, &r.markers, &r.decl, &taken, + bytes, data_base, starts, &r.markers, &r.decls, &taken, ); if !alt.is_empty() { m.meshes = alt; @@ -915,6 +925,40 @@ pub fn debug_best_rejection(bytes: &[u8], name: &str) -> Option<(usize, String)> Some(best) } +/// Diagnostic: the stride of every sub-mesh declaration of a named resource. +/// A grouped pool may mix them (`n201_01` → 24, 24, 24, 28). +pub fn debug_decl_strides(bytes: &[u8], name: &str) -> Vec { + decls_of(bytes, name).map(|v| v.iter().map(|d| d.stride).collect()).unwrap_or_default() +} + +/// Every sub-mesh declaration of a named resource (see [`all_vertex_decls`]). +fn decls_of(bytes: &[u8], name: &str) -> Option> { + if bytes.len() < 16 || &bytes[..4] != b"XPR2" { + return None; + } + let mut cur = Cursor::new(bytes); + let header = Xpr2Header::read(&mut cur).ok()?; + const DIR_BASE: usize = 0x10; + for _ in 0..header.num_resources { + let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { break }; + if &e.type_tag != b"XBG7" { + continue; + } + let desc = e.data_offset as usize + DIR_BASE; + let desc_end = (desc + e.descriptor_size as usize).min(bytes.len()); + if desc >= bytes.len() || desc_end <= desc { + continue; + } + let rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE) + .unwrap_or_else(|| "XBG7".to_string()); + if rname != name { + continue; + } + return Some(all_vertex_decls(&bytes[desc..desc_end])); + } + None +} + /// Diagnostic: why does the decoder refuse a grouped-pool resource at a given /// pool start? Recomputes the pool layout exactly as [`anchor_grouped_meshes`] /// does and reports the pivot sub-mesh's verdict for each index/vertex pad — @@ -927,6 +971,12 @@ pub fn debug_grouped_report(bytes: &[u8], name: &str, vb0: usize) -> Vec if n == 0 { return vec!["no index markers".into()]; } + // Per-sub-mesh declarations, like the production path: a pool can mix strides + // and reporting with one of them blames the wrong gate. + let decls = match decls_of(bytes, name).filter(|v| v.len() == n && submesh_decls()) { + Some(v) => v, + None => vec![decl.clone(); n], + }; let (mut rel_ib, mut acc_i) = (Vec::with_capacity(n), 0usize); for &(_, ic) in &markers { rel_ib.push(acc_i); @@ -936,8 +986,8 @@ pub fn debug_grouped_report(bytes: &[u8], name: &str, vb0: usize) -> Vec let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0); let (vck, ick) = markers[kmax]; let mut off_v = 0usize; - for &(vc, _) in markers.iter().take(kmax) { - off_v += vc * decl.stride; + for (i, &(vc, _)) in markers.iter().take(kmax).enumerate() { + off_v += vc * decls[i].stride; } let mut out = vec![format!( "{name}: {n} sub-meshes, pivot #{kmax} ({vck} verts, {ick} idx), pool span {span}" @@ -957,7 +1007,7 @@ pub fn debug_grouped_report(bytes: &[u8], name: &str, vb0: usize) -> Vec vb0 + off_v, vck, ick, - &decl, + &decls[kmax], 0.85, true, ); @@ -1093,6 +1143,25 @@ fn pad0_consistency() -> f32 { /// [`anchor_pool_mesh`] takes the FIRST pad that validates (the pre-fix /// behaviour) instead of the pad whose index run is cleanest. Kept so the two /// behaviours can be diffed on the disc; see docs/re/structures/xbg7-mesh.md. +/// Use the **per-sub-mesh** vertex declarations in a grouped pool +/// (`XBG7_SUBMESH_DECLS=1`, default off). +/// +/// The format truth is not in question: each index marker is followed by its own +/// element triples and they can differ — `n201_01` (`Stage_S02.xpr`) declares +/// strides 24, 24, 24, **28**, which a runtime capture confirms draw-for-draw. With +/// this on, the disc-wide misses fall **85 → 47** and the resources that decode in +/// no container at all fall **63 → 30**. +/// +/// It is off by default because selection has not caught up: the three `n201_0x` +/// copies then land on ONE pool (`tests/mesh_consistency_disc.rs::twin_pairs_do_not_share_a_buffer` +/// fails), and four newly decoded `ptc_pack` `.dat` composites carry degenerate +/// triangles. The capture-proven pool start now VALIDATES (`debug_grouped_report` +/// reports `pad 0: ACCEPTED` where it used to report a NaN position), so what +/// remains is choosing it — see docs/re/structures/xbg7-mesh.md. +fn submesh_decls() -> bool { + std::env::var("XBG7_SUBMESH_DECLS").map(|v| v == "1").unwrap_or(false) +} + fn pad_first_match() -> bool { std::env::var("XBG7_PAD_FIRST_MATCH").map(|v| v == "1").unwrap_or(false) } @@ -1603,25 +1672,27 @@ fn anchor_grouped_meshes( data_base: usize, starts: &[usize], markers: &[(usize, usize)], // (vtx_count, idx_count) in descriptor/file order - decl: &VertexDecl, + decls: &[VertexDecl], // one per marker — a pool CAN mix strides taken: &std::collections::HashSet, ) -> Vec { let n = markers.len(); - if n == 0 { + if n == 0 || decls.len() < n { return Vec::new(); } - let stride = decl.stride; + let decl = &decls[0]; // Relative index-buffer offsets (ib0 = 0), 4-byte aligned between buffers, // and cumulative vertex offsets (vb0 = 0) — both derived from the marker list. let mut rel_ib = Vec::with_capacity(n); let mut off_v = Vec::with_capacity(n); let (mut acc_i, mut acc_v) = (0usize, 0usize); - for &(vc, ic) in markers { + for (i, &(vc, ic)) in markers.iter().enumerate() { rel_ib.push(acc_i); off_v.push(acc_v); acc_i = align4(acc_i + ic * 2); - acc_v += vc * stride; + // Each sub-mesh advances by ITS OWN stride: `n201_01` ends with a + // stride-28 sub-mesh after three stride-24 ones (capture-confirmed). + acc_v += vc * decls[i].stride; } // The index pool spans ib0 .. end-of-last-buffer. The vertex pool that // follows is **4-byte aligned**, so up to 3 bytes of padding can sit between @@ -1665,10 +1736,10 @@ fn anchor_grouped_meshes( // collapses well below 0.85, so only the true pad/pivot passes. let ib_k = ib0 + rel_ib[kmax]; let vb_k = vb0 + off_v[kmax]; - if !validate_block(bytes, ib_k, vb_k, vck, ick, decl, grouped_consistency(), true) { + if !validate_block(bytes, ib_k, vb_k, vck, ick, &decls[kmax], grouped_consistency(), true) { continue; } - let (degen, wind) = index_run_quality(bytes, ib_k, vb_k, ick, decl); + let (degen, wind) = index_run_quality(bytes, ib_k, vb_k, ick, &decls[kmax]); let cand = (degen, -wind, pad); if best.map_or(true, |b| cand < b) { best = Some(cand); @@ -1691,14 +1762,14 @@ fn anchor_grouped_meshes( let (vc, ic) = markers[i]; let ib = ib0 + rel_ib[i]; if ib + ic * 2 > bytes.len() - || vc.checked_mul(stride).map_or(true, |b| vb + b > bytes.len()) + || vc.checked_mul(decls[i].stride).map_or(true, |b| vb + b > bytes.len()) { break; } // Parts are placed deterministically; in-range + consistency pins // them, so the connectivity heuristic (which mis-rejects small // flat fins) is relaxed here. - let ok = validate_block(bytes, ib, vb, vc, ic, decl, 0.85, false); + let ok = validate_block(bytes, ib, vb, vc, ic, &decls[i], 0.85, false); if !ok && i > kmax { break; // chain diverged — emit the validated prefix, no garbage } @@ -1722,9 +1793,9 @@ fn anchor_grouped_meshes( i < vc }); if in_range && max_idx + cover_slack() >= vc { - meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl)); + meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, &decls[i])); } - vb += vc * stride; + vb += vc * decls[i].stride; } return meshes; } @@ -1814,8 +1885,10 @@ const VERTEX_BUFFER_GAP: usize = 12; // ── Vertex declaration ─────────────────────────────────────────────────────── -/// The vertex layout for one XBG7 resource, parsed from the descriptor's -/// declaration table (shared by all its sub-meshes). +/// The vertex layout of one XBG7 **sub-mesh**, parsed from the element triples +/// that follow its index marker. A grouped pool may declare a different layout +/// per sub-mesh — see [`all_vertex_decls`]. +#[derive(Clone)] struct VertexDecl { /// Bytes per vertex. stride: usize, @@ -1887,8 +1960,51 @@ fn all_index_markers(desc: &[u8]) -> Vec<(usize, usize)> { out } +/// Every sub-mesh's OWN declaration, in file order. +/// +/// A grouped pool is **not** one declaration repeated: each index marker is +/// followed by its own element triples, and they can differ. `n201_01` +/// (`Stage_S02.xpr`) declares strides 24, 24, 24 and **28** — the last sub-mesh +/// has a fourth element — which a 2026-08-13 runtime capture confirms +/// (`stride=28` on that draw, and a different vertex shader for each sub-mesh). +/// Reading the first declaration for the whole pool walks the last buffer out of +/// phase and the resource is declined; see docs/re/structures/xbg7-mesh.md. +fn all_vertex_decls(desc: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut rel = 0usize; + while rel + 8 <= desc.len() { + let a = be32(desc, rel); + let c = be32(desc, rel + 4); + if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 && rel >= 32 { + let vc = be32(desc, rel - 32) as usize; + if (3..=200_000).contains(&vc) { + match parse_decl_at(desc, rel + 8) { + Some(d) => out.push(d), + // Keep the positions aligned with `all_index_markers`: a + // marker whose declaration will not parse still occupies a + // slot, so fall back to the resource's first declaration. + None => match out.first() { + Some(d) => out.push(d.clone()), + None => return Vec::new(), + }, + } + rel += 8; + continue; + } + } + rel += 4; + } + out +} + fn parse_vertex_decl(desc: &[u8]) -> Option { let mk = find_index_marker(desc)?.0; + parse_decl_at(desc, mk + 8) +} + +/// Parse the element triples that start at `at` (just past an index marker). +fn parse_decl_at(desc: &[u8], at: usize) -> Option { + let mk = at.checked_sub(8)?; // Read declaration triples. let mut elems: Vec<(usize, u32, u32)> = Vec::new(); // (offset, code, usage) diff --git a/crates/sylpheed-formats/tests/mesh_disc.rs b/crates/sylpheed-formats/tests/mesh_disc.rs index 6dc5a0f..a7928dc 100644 --- a/crates/sylpheed-formats/tests/mesh_disc.rs +++ b/crates/sylpheed-formats/tests/mesh_disc.rs @@ -468,10 +468,11 @@ fn decoded_index_runs_have_almost_no_degenerate_triangles() { } } } - // The one: `_rou_f402_dead` in `Stage_S09`, whose degenerate-free block is - // claimed by `e_rou_f003_Near` — both 24-vertex bounding boxes, the identity - // class that needs descriptor-level data, not another heuristic. Anything - // else appearing here is a regression. + // The one at default settings: `_rou_f402_dead` in `Stage_S09`, whose + // degenerate-free block is claimed by `e_rou_f003_Near` — both 24-vertex + // bounding boxes, the identity class that needs descriptor-level data. (With + // `XBG7_SUBMESH_DECLS=1` four newly decoded `ptc_pack` `.dat` composites join + // it; that knob is off by default.) Anything else here is a regression. assert!( offenders.len() <= 1, "{} decoded index runs contain degenerate triangles (expected ≤ 1): {:?}", diff --git a/docs/re/structures/xbg7-mesh.md b/docs/re/structures/xbg7-mesh.md index 93a7dcb..ac4bc18 100644 --- a/docs/re/structures/xbg7-mesh.md +++ b/docs/re/structures/xbg7-mesh.md @@ -1602,3 +1602,55 @@ S09/S25 `n205`, S15 `n207_208`). 58 of the 63 live in exactly one container. Flying stage 16 did **not** get the `e901` wings drawn (the boss appears later in the mission), which is the next lesson: choosing the mission puts a container in memory, but the unit still has to be **on screen** for a draw to exist. + +### 🔎 The descriptor carries a declaration PER SUB-MESH — worth 38 misses, held behind a knob (2026-08-13) + +Following the `n201` mixed-stride finding: the descriptor was dumped around every +index marker (`examples/desc_dump.rs`), and the layout is unambiguous — **each +marker is followed by its own element triples**, terminated by +`off=0x00FF0000 / code=0xFFFFFFFF`: + +| `n201_01` marker | verts / indices | elements (offset, code) | stride | +|---|---|---|---| +| `@0x428` | 777 / 4464 | 0 `2A23B9`, 12 `1A2360`, 20 `182886` | 24 | +| `@0x900` | 869 / 4464 | 0 `2A23B9`, 12 `1A2360`, 20 `2C235F` | 24 | +| `@0xDD8` | 192 / 576 | 0 `2A23B9`, 12 `1A2360`, 20 `182886` | 24 | +| `@0x12B0` | 869 / 4464 | 0 `2A23B9`, 12 `1A2360`, 20 `182886`, **24 `2C235F`** | **28** | + +That is exactly the capture's `stride=28` on the fourth draw, and it explains the +four distinct vertex-shader hashes. `parse_vertex_decl` read the **first** +declaration and applied it to the whole pool; `all_vertex_decls` now reads one per +marker, and `anchor_grouped_meshes` uses each sub-mesh's own stride for the vertex +pool walk, the pivot validation and the read. + +**Measured with `XBG7_SUBMESH_DECLS=1`:** + +| | default | per-sub-mesh declarations | +|---|---|---| +| resources that never decode (disc-wide) | 85 | **47** | +| resources that decode in **no** container | 63 | **30** | +| capture oracles (`Stage_S02`) | 93/93 runs, 42/42 index counts | **unchanged** | +| cross-container minority decodes | 96 | 96 | +| decoded index runs with a degenerate triangle | 1 | 5 | + +And `debug_grouped_report` at `n201_01`'s **capture-proven** pool start now reads +`pad 0: ACCEPTED`, where it used to read `position component NaN` — the block the +engine draws from is finally acceptable to the decoder. + +**Why it is off by default.** Selection has not caught up: + +* the three `n201_0x` copies all settle on ONE pool, so + `tests/mesh_consistency_disc.rs::twin_pairs_do_not_share_a_buffer` fails — a twin + collapse is the one thing this project has decided never to ship; +* production still picks a pool start 4 bytes before sub-mesh **#1** (`0x32BEFF0`) + instead of the proven `0x32BA71C`, even though that start **is** in the candidate + list, validates at pad 0, and is claimed by nobody in the final output — so the + remaining defect is in *choosing* among candidates, not in the format reading; +* four newly decoded `ptc_pack` `.dat` composites carry degenerate triangles + (`f202_break`, `e108_break`, `f106_break`, `eff_s901_e04`) — new coverage of + imperfect quality rather than a regression, but not clean either. + +So the format question is **settled** (and capture-confirmed), the coverage win is +real and measured, and what stands between the two is the same +selection/distinct-assignment machinery that the pad work already improved once. +That is the next step, with `n201`'s proven offsets as the acceptance test.