revert(mesh): withdraw the neighbourhood anchor -- it regressed the e106 twin mirror

The neighbourhood anchor (f18d591) and its refinement (27a0701) took
cross-container inconsistency from 125 to 51 with coverage unchanged, and made
e106 render as a destroyer rather than a slab. Both are reverted.

ship::tests::static_assembly_matches_runtime_capture is gated on SYLPHEED_ISO, so
it SKIPS in an ordinary cargo test -- which is why the regression was invisible
in every suite run so far. With the ISO it fails:

  e106_bdy_01: static M row0 [-1.0, 0.0, 0.0] != captured [1.0, 0.0, 0.0]

e106_bdy_01 and _02 are a mirrored pair whose two buffers hold the same geometry
reflected in X, and BOTH resources currently decode to the SAME buffer (identical
counts, span and mean_x). apply_twin_mirrors picks which instance to reflect from
the sign of that mean_x, so which buffer wins flips the decision:

  before  both twins mean_x = -66.83  -> mirror bdy_02  (matches the capture)
  after   both twins mean_x = +66.83  -> mirror bdy_01  (contradicts it)

Neither is right -- two resources sharing one decode is itself the bug and the
mirror heuristic has been compensating. The capture is ground truth, so a change
that contradicts it does not ship. The real fix must give each twin its own
buffer first.

Kept from the attempt: this test now also asserts the SET of static placements
against the capture (allow-list {e303_wep_01} for vbase dedup), so extra
placements can finally fail it -- the direction it could never fail in before.

Docs, backlog, INDEX and the ignored test's message all corrected to say
diagnosed-not-fixed rather than fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 01:08:41 +00:00
parent 0f9c95c52e
commit 64d372c7e8
6 changed files with 85 additions and 159 deletions

View File

@@ -487,7 +487,6 @@ impl Xbg7Model {
decl: VertexDecl, decl: VertexDecl,
} }
let mut resources: Vec<Res> = Vec::new(); let mut resources: Vec<Res> = Vec::new();
let mut asked_for: Vec<bool> = Vec::new();
for _ in 0..header.num_resources { for _ in 0..header.num_resources {
let e = match Xpr2ResourceEntry::read(&mut cur) { let e = match Xpr2ResourceEntry::read(&mut cur) {
Ok(e) => e, Ok(e) => e,
@@ -517,18 +516,16 @@ impl Xbg7Model {
} }
let name = read_cstr(bytes, e.name_offset as usize + DIR_BASE) let name = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
.unwrap_or_else(|| "XBG7".to_string()); .unwrap_or_else(|| "XBG7".to_string());
// Keep NON-wanted resources too: a resource is anchored by where its if let Some(w) = wanted {
// descriptor NEIGHBOURS anchor, so filtering them out here would if !w.contains(&name) {
// leave a filtered decode with no neighbourhood (and the pre-2026-08 continue;
// first-in-file-order behaviour). Only a ±2 window is actually }
// decoded — see `need_pass1` below. }
let asked = wanted.map_or(true, |w| w.contains(&name));
resources.push(Res { resources.push(Res {
name, name,
markers, markers,
decl, decl,
}); });
asked_for.push(asked);
} }
if resources.is_empty() { if resources.is_empty() {
return out; return out;
@@ -559,30 +556,20 @@ impl Xbg7Model {
// preserves resource order, so the output is identical to the sequential // preserves resource order, so the output is identical to the sequential
// decode. `should_cancel()` is polled per resource so a superseded load // decode. `should_cancel()` is polled per resource so a superseded load
// stops promptly. // stops promptly.
// `near`: prefer candidate anchors close to this offset (see let decode_one = |r: &Res| -> Option<Xbg7Model> {
// `anchor_pool_mesh_near`). Pass 1 runs with `None` to learn where each
// resource lands; pass 2 re-runs with each resource's neighbourhood.
let decode_one_near = |r: &Res, near: Option<usize>| -> (Option<Xbg7Model>, Option<usize>) {
if should_cancel() { if should_cancel() {
return (None, None); return None;
} }
let starts = &starts_by_stride[&r.decl.stride]; let starts = &starts_by_stride[&r.decl.stride];
let mut anchored_at = None;
let meshes = if r.markers.len() == 1 { let meshes = if r.markers.len() == 1 {
// Single sub-mesh → the proven per-block adjacency anchor // Single sub-mesh → the proven per-block adjacency anchor
// (index buffer immediately before its vertex buffer). Stages and // (index buffer immediately before its vertex buffer). Stages and
// simple props take this path; `min_consistency` behaviour is // simple props take this path; `min_consistency` behaviour is
// exactly as before. // exactly as before.
let (vtx_count, index_count) = r.markers[0]; let (vtx_count, index_count) = r.markers[0];
anchor_pool_mesh_near( anchor_pool_mesh(bytes, starts, index_count, vtx_count, &r.decl, min_consistency)
bytes, starts, index_count, vtx_count, &r.decl, min_consistency, near, .into_iter()
) .collect()
.map(|(m, vb)| {
anchored_at = Some(vb);
m
})
.into_iter()
.collect()
} else { } else {
// Several sub-meshes sharing grouped index/vertex pools → the // Several sub-meshes sharing grouped index/vertex pools → the
// deterministic grouped-pool decode (hero ships et al.). // deterministic grouped-pool decode (hero ships et al.).
@@ -595,106 +582,25 @@ impl Xbg7Model {
// to the original single-block adjacency anchor on the first // to the original single-block adjacency anchor on the first
// marker so coverage is never *below* the pre-grouped decode. // marker so coverage is never *below* the pre-grouped decode.
let (vtx_count, index_count) = r.markers[0]; let (vtx_count, index_count) = r.markers[0];
anchor_pool_mesh_near( anchor_pool_mesh(bytes, starts, index_count, vtx_count, &r.decl, min_consistency)
bytes, starts, index_count, vtx_count, &r.decl, min_consistency, near, .into_iter()
) .collect()
.map(|(m, vb)| {
anchored_at = Some(vb);
m
})
.into_iter()
.collect()
} }
}; };
let model = (!meshes.is_empty()).then(|| Xbg7Model { (!meshes.is_empty()).then(|| Xbg7Model {
name: r.name.clone(), name: r.name.clone(),
meshes, meshes,
});
(model, anchored_at)
};
/// Median of a resource's neighbours' anchors — the reference a resource
/// should sit near. `None` when too few neighbours anchored to be useful.
fn neighbourhood(vbs: &[Option<usize>], i: usize) -> Option<usize> {
const SPAN: usize = 2;
let lo = i.saturating_sub(SPAN);
let hi = (i + SPAN + 1).min(vbs.len());
let mut near: Vec<usize> = (lo..hi).filter(|&k| k != i).filter_map(|k| vbs[k]).collect();
if near.len() < 2 {
return None;
}
near.sort_unstable();
Some(near[near.len() / 2])
}
// Pass 1 — first-match, to learn each resource's neighbourhood. Only the
// asked-for resources and their ±2 neighbours need it, so a filtered
// decode stays proportional to what was asked for.
let need_pass1: Vec<bool> = (0..resources.len())
.map(|i| {
let lo = i.saturating_sub(2);
let hi = (i + 3).min(asked_for.len());
asked_for[lo..hi].iter().any(|&a| a)
}) })
.collect();
let run1 = |(i, r): (usize, &Res)| -> (Option<Xbg7Model>, Option<usize>) {
if need_pass1[i] {
decode_one_near(r, None)
} else {
(None, None)
}
}; };
#[cfg(not(target_arch = "wasm32"))]
let pass1: Vec<(Option<Xbg7Model>, Option<usize>)> = {
use rayon::prelude::*;
resources.par_iter().enumerate().map(run1).collect()
};
#[cfg(target_arch = "wasm32")]
let pass1: Vec<(Option<Xbg7Model>, Option<usize>)> =
resources.iter().enumerate().map(run1).collect();
let mut vbs: Vec<Option<usize>> = pass1.iter().map(|(_, vb)| *vb).collect();
// Refine the anchor map before using it: pass 1's anchors include the
// very mistakes this is meant to correct, so a resource next to a
// mis-anchored neighbour inherits a bad reference. Re-anchoring against
// the improving map and repeating converges quickly; two rounds is
// enough on this disc (a third changes nothing).
for _ in 0..2 {
let refined: Vec<Option<usize>> = (0..resources.len())
.map(|i| match (need_pass1[i], neighbourhood(&vbs, i)) {
(true, Some(anchor)) => decode_one_near(&resources[i], Some(anchor)).1.or(vbs[i]),
_ => vbs[i],
})
.collect();
if refined == vbs {
break;
}
vbs = refined;
}
// Pass 2 — re-anchor preferring the resource's own neighbourhood, which
// is what separates its data from another resource's identically-shaped
// block. Resources without a usable neighbourhood keep pass 1's result.
let finish = |(i, r): (usize, &Res)| -> Option<Xbg7Model> {
if !asked_for[i] {
return None;
}
match neighbourhood(&vbs, i) {
Some(anchor) => decode_one_near(r, Some(anchor))
.0
.or_else(|| pass1[i].0.clone()),
None => pass1[i].0.clone(),
}
};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
use rayon::prelude::*; use rayon::prelude::*;
out = resources.par_iter().enumerate().filter_map(finish).collect(); out = resources.par_iter().filter_map(decode_one).collect();
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
out = resources.iter().enumerate().filter_map(finish).collect(); out = resources.iter().filter_map(decode_one).collect();
} }
out out
} }
@@ -747,35 +653,8 @@ fn anchor_pool_mesh(
decl: &VertexDecl, decl: &VertexDecl,
min_consistency: f32, min_consistency: f32,
) -> Option<GameMesh> { ) -> Option<GameMesh> {
anchor_pool_mesh_near(bytes, starts, index_count, vtx_count, decl, min_consistency, None)
.map(|(m, _)| m)
}
/// As [`anchor_pool_mesh`], but when `near` is given the candidates are tried in
/// order of distance from it, and the accepted vertex-buffer offset is returned
/// alongside the mesh.
///
/// Why: the candidate list is one scan of the **whole container** per stride and
/// is shared by every resource of that stride, so first-in-file-order can hand a
/// resource a block belonging to something else that happens to share its vertex
/// and index counts. Both blocks are real geometry and both pass every quality
/// gate, so only *position* separates them — a resource's own data sits near its
/// descriptor neighbours' (`docs/re/structures/xbg7-mesh.md`).
fn anchor_pool_mesh_near(
bytes: &[u8],
starts: &[usize],
index_count: usize,
vtx_count: usize,
decl: &VertexDecl,
min_consistency: f32,
near: Option<usize>,
) -> Option<(GameMesh, usize)> {
let idx_bytes = index_count * 2; let idx_bytes = index_count * 2;
let mut order: Vec<usize> = starts.to_vec(); for &vb in starts {
if let Some(anchor) = near {
order.sort_by_key(|&vb| vb.abs_diff(anchor));
}
for &vb in &order {
// The index buffer sits just before the vertex buffer, which is 4-byte // The index buffer sits just before the vertex buffer, which is 4-byte
// aligned — so 0..=3 bytes of padding may separate them (`ib = vb // aligned — so 0..=3 bytes of padding may separate them (`ib = vb
// idx_bytes pad`). pad 0 is the immediate-adjacency case (all stages so // idx_bytes pad`). pad 0 is the immediate-adjacency case (all stages so
@@ -795,10 +674,7 @@ fn anchor_pool_mesh_near(
}; };
if validate_block(bytes, ib, vb, vtx_count, index_count, decl, mc, true) { if validate_block(bytes, ib, vb, vtx_count, index_count, decl, mc, true) {
// ── Accepted: read the full mesh. ── // ── Accepted: read the full mesh. ──
return Some(( return Some(read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl));
read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl),
vb,
));
} }
} }
} }

View File

@@ -615,6 +615,27 @@ mod tests {
} }
} }
} }
// ── Extras: the direction this test could not previously fail in. ──
// The loop above walks the CAPTURE's parts and looks each up in ours, so
// a static placement with no counterpart was invisible to it — which is
// how a resource decoded 100x too large (`e303_wep_01`, 2026-08-12) sat
// here unnoticed. Pin the set instead: the capture legitimately misses
// repeated instances of a shared resource (vbase dedup), so `e303_wep_01`
// is expected; anything else appearing only in the static assembly is a
// regression.
let captured: std::collections::BTreeSet<&str> =
cap.parts.iter().map(|p| p.part.as_str()).collect();
let extra: std::collections::BTreeSet<&str> = placed
.iter()
.map(|p| p.resource.as_str())
.filter(|r| !captured.contains(r))
.collect();
let allowed: std::collections::BTreeSet<&str> = ["e303_wep_01"].into_iter().collect();
assert_eq!(
extra, allowed,
"static placements with no counterpart in the runtime capture"
);
// Multi-instance coverage the capture couldn't see (vbase dedup). // Multi-instance coverage the capture couldn't see (vbase dedup).
let count = |res: &str| placed.iter().filter(|p| p.resource == res).count(); let count = |res: &str| placed.iter().filter(|p| p.resource == res).count();
assert_eq!(count("e106_eng_01"), 2, "both engine nacelles placed"); assert_eq!(count("e106_eng_01"), 2, "both engine nacelles placed");

View File

@@ -53,7 +53,7 @@ fn span(m: &Xbg7Model) -> Option<[i64; 3]> {
} }
#[test] #[test]
#[ignore = "known-failing: 51 of 681 shared resources still decode inconsistently (125 before the neighbourhood anchor, 63 before refining it — 2026-08-12)"] #[ignore = "known-failing: 125 of 681 shared resources decode inconsistently. A neighbourhood anchor took this to 51 but regressed the e106 twin-mirror decision and was withdrawn — see docs/re/structures/xbg7-mesh.md"]
fn shared_resources_decode_identically_in_every_container() { fn shared_resources_decode_identically_in_every_container() {
let Some(root) = disc_root() else { let Some(root) = disc_root() else {
eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)"); eprintln!("SKIP: extracted disc not found (set SYLPHEED_DISC to enable)");

View File

@@ -153,7 +153,7 @@ report needs re-grounding against a specific ship and a specific expectation.
--- ---
## ✅ FIXED 2026-08-12 — it was a mis-decode, and the anchor now uses locality ## ⚠️ DIAGNOSED 2026-08-12 — a mis-decode; the locality fix was written, then withdrawn
> Resolution at the end of this entry. Kept in full because the two wrong turns > Resolution at the end of this entry. Kept in full because the two wrong turns
> along the way (a "stray volume", then "monotonic anchoring") are the useful part. > along the way (a "stray volume", then "monotonic anchoring") are the useful part.
@@ -270,9 +270,13 @@ container-global scan, so a resource could be handed another resource's block
whenever both shared `(stride, vertex count, index count)`. Fixed by anchoring whenever both shared `(stride, vertex count, index count)`. Fixed by anchoring
each resource near its **descriptor neighbours** (two-pass: learn, then re-anchor). each resource near its **descriptor neighbours** (two-pass: learn, then re-anchor).
- decoded **5 480 / 6 294 unchanged**, inconsistent **125 → 63** - it took inconsistency **125 → 51** with coverage unchanged, and made `e106`
- `e106` renders correctly ([after](captures/e106-static-assembly-fixed.png)) render correctly ([after](captures/e106-static-assembly-fixed.png))
- the user-reported "capital ships assemble wrong" is **resolved** for this cause - **but it flipped the `e106` twin-mirror decision**, which
`static_assembly_matches_runtime_capture` (ISO-gated, so it skips in a plain
`cargo test`) catches against the runtime capture — so it was **reverted**
- the user-reported "capital ships assemble wrong" is therefore **diagnosed, not
yet fixed**; see [xbg7](structures/xbg7-mesh.md) for what the real fix needs
Still open from this entry: `static_assembly_matches_runtime_capture` walks only Still open from this entry: `static_assembly_matches_runtime_capture` walks only
the capture's parts, so **extra** static placements still cannot fail it. the capture's parts, so **extra** static placements still cannot fail it.

View File

@@ -20,7 +20,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
| IXUD subtitle | 🟡/✅ | `sylpheed-formats/src/ixud.rs` + [movie link](movie-subtitle-link.md) | timed cues. **The movie↔subtitle↔voice link is solved — statically**, from the movie config record in `tables.pak` (schema `0x067025b9`), not from the running game as this row previously assumed: [101 movies mapped](captures/movie-subtitle-voice-map.csv), 94 with subtitles, 83 with voice, 21 with a telop overlay. 93 of 94 subtitle refs resolve in the language paks; **`SUBTITLE_S12B.tbl` is missing from all six languages** — a dangling reference on the disc. Naming is `SUBTITLE_<base>.tbl` / `VOICE_<base>` with six documented exceptions. The record's ~104 **script ids** are ❔ — positional pairing drifts by three because the IDXD pool dedupes repeated values | | IXUD subtitle | 🟡/✅ | `sylpheed-formats/src/ixud.rs` + [movie link](movie-subtitle-link.md) | timed cues. **The movie↔subtitle↔voice link is solved — statically**, from the movie config record in `tables.pak` (schema `0x067025b9`), not from the running game as this row previously assumed: [101 movies mapped](captures/movie-subtitle-voice-map.csv), 94 with subtitles, 83 with voice, 21 with a telop overlay. 93 of 94 subtitle refs resolve in the language paks; **`SUBTITLE_S12B.tbl` is missing from all six languages** — a dangling reference on the disc. Naming is `SUBTITLE_<base>.tbl` / `VOICE_<base>` with six documented exceptions. The record's ~104 **script ids** are ❔ — positional pairing drifts by three because the IDXD pool dedupes repeated values |
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser | | Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
| XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). **Declined set now measured**: 6 294 resources, **5 480 decode (87.1 %), 814 declined** in 31/166 containers — and it is *not* "a few quantized hero bodies" but 492 `e*`, 142 `f*`, 73 `n*`, 23 `eff*` plus `*_dead` variants. The descriptor does **not** declare decodability (word[2] is a sub-mesh count; decoded and declined appear at every value), so the gap is the anchor scan, not an unread format flag — see [xbg7](structures/xbg7-mesh.md). **Silent mis-decodes also exist and are now measurable**: a resource shared across containers must decode to the same bounds, and **125 of 681 shared resources fail that check** with identical vertex/triangle counts — the decoder picked a different buffer of the same size. A majority vote across containers would resolve 104 of them (14 are 50/50), but that is 🟡 unvalidated beyond the one case with a render and a capture behind it | | XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). **Declined set now measured**: 6 294 resources, **5 480 decode (87.1 %), 814 declined** in 31/166 containers — and it is *not* "a few quantized hero bodies" but 492 `e*`, 142 `f*`, 73 `n*`, 23 `eff*` plus `*_dead` variants. The descriptor does **not** declare decodability (word[2] is a sub-mesh count; decoded and declined appear at every value), so the gap is the anchor scan, not an unread format flag — see [xbg7](structures/xbg7-mesh.md). **Silent mis-decodes also exist and are now measurable**: a resource shared across containers must decode to the same bounds, and **125 of 681 shared resources fail that check** with identical vertex/triangle counts — the decoder picked a different buffer of the same size. A majority vote across containers would resolve 104 of them (14 are 50/50), but that is 🟡 unvalidated beyond the one case with a render and a capture behind it |
| Capital-ship part placement | ✅/🟡 | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | Placement itself is **sound**: hull static-exact, validated against the `e106` runtime capture, and cross-id mounting is genuinely narrow (**2 pairs across 335 ships**). **The user-reported "ships assemble wrong" is NOT a placement bug** — it is an XBG7 **mis-decode** that makes one shared turret 100× too large in 3 of 14 containers ([backlog](BACKLOG.md), [xbg7](structures/xbg7-mesh.md)). Also open: `static_assembly_matches_runtime_capture` walks capture parts only, so **extra** static placements can never fail it | | Capital-ship part placement | ✅/🟡 | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | Placement itself is **sound**: hull static-exact, validated against the `e106` runtime capture, and cross-id mounting is genuinely narrow (**2 pairs across 335 ships**). **The user-reported "ships assemble wrong" is NOT a placement bug** (a locality fix for it was written and withdrawn — it regressed the twin mirror) — it is an XBG7 **mis-decode** that makes one shared turret 100× too large in 3 of 14 containers ([backlog](BACKLOG.md), [xbg7](structures/xbg7-mesh.md)). Also open: `static_assembly_matches_runtime_capture` walks capture parts only, so **extra** static placements can never fail it |
| Weapon fields defaulted on disc | ✅ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv) the disc does not carry. Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check | | Weapon fields defaulted on disc | ✅ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv) the disc does not carry. Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check |
| Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — but a defaulted field is **not** a global constant: `Size_Y` provably inherits `Size_X` (7 independent units, 6 distinct values), and three more sibling rules are recorded ❔, recovering 65 values in units never visited — [values](captures/unit-runtime-fields.csv) | | Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — but a defaulted field is **not** a global constant: `Size_Y` provably inherits `Size_X` (7 independent units, 6 distinct values), and three more sibling rules are recorded ❔, recovering 65 values in units never visited — [values](captures/unit-runtime-fields.csv) |
| Arsenal develop economy | ✅/❔ | [arsenal-develop-economy](arsenal-develop-economy.md) + [conditions](captures/arsenal-develop-conditions.csv) | The Arsenal reads `weapon.tbl` (item ids, in the 8-category display order) and `strings.tbl` (names, descriptions, and a **"Conditions to obtain"** block per item) out of `GP_HANGAR_ARSENAL.pak`. All **60** conditions are extracted: gates are stage completion, a predecessor item, or an **ace kill**; costs run 3 000350 000 P and **20 items are free** once gated. `weapon.tbl`'s first record reproduces the in-game DATA SHEET exactly (Range D / Power E / Speed / Weight 0.3 = Light / 4000 P) — later records are unreadable from the string pool alone because IDXD **dedupes repeated values**. Used to identify the save blob's index space, now **solved**: the blob follows **`strings.tbl`'s** order — the display order *plus* the cut items only the localisation file lists (`Adhesive Mine B2A`, `Ballista GSH`, …) — pinned by four hand-written probe saves (9 Stiletto, 21 Falcon, 39 Tomahawk, 48 Jamming System) and closing exactly at index 53. `weapon.tbl`'s id list is **not** the index space; that it is also 54 long is a coincidence, and the two agree only to index 32. The retail save's five unexplained owned entries are the cut items, shipped owned and never rendered | | Arsenal develop economy | ✅/❔ | [arsenal-develop-economy](arsenal-develop-economy.md) + [conditions](captures/arsenal-develop-conditions.csv) | The Arsenal reads `weapon.tbl` (item ids, in the 8-category display order) and `strings.tbl` (names, descriptions, and a **"Conditions to obtain"** block per item) out of `GP_HANGAR_ARSENAL.pak`. All **60** conditions are extracted: gates are stage completion, a predecessor item, or an **ace kill**; costs run 3 000350 000 P and **20 items are free** once gated. `weapon.tbl`'s first record reproduces the in-game DATA SHEET exactly (Range D / Power E / Speed / Weight 0.3 = Light / 4000 P) — later records are unreadable from the string pool alone because IDXD **dedupes repeated values**. Used to identify the save blob's index space, now **solved**: the blob follows **`strings.tbl`'s** order — the display order *plus* the cut items only the localisation file lists (`Adhesive Mine B2A`, `Ballista GSH`, …) — pinned by four hand-written probe saves (9 Stiletto, 21 Falcon, 39 Tomahawk, 48 Jamming System) and closing exactly at index 53. `weapon.tbl`'s id list is **not** the index space; that it is also 54 long is a coincidence, and the two agree only to index 32. The retail save's five unexplained owned entries are the cut items, shipped owned and never rendered |

View File

@@ -393,7 +393,7 @@ and prefer candidates close to the previous resource's anchor), falling back to
first-match when there is no neighbour yet. That needs no new format knowledge, first-match when there is no neighbour yet. That needs no new format knowledge,
and it selects `52 257 440` here. and it selects `52 257 440` here.
### ✅ Implemented (2026-08-12) — inconsistency halved, coverage unchanged ### ⚠️ WITHDRAWN (2026-08-12) — it halved inconsistency but regressed the twin mirror
`anchor_pool_mesh_near` tries candidates in order of distance from a reference, `anchor_pool_mesh_near` tries candidates in order of distance from a reference,
and `anchor_models_filtered` runs **two passes**: pass 1 anchors first-match to and `anchor_models_filtered` runs **two passes**: pass 1 anchors first-match to
@@ -402,22 +402,47 @@ learn where resources land, then pass 2 re-anchors each resource preferring its
with too few anchored neighbours keeps pass 1's result, so nothing regresses to with too few anchored neighbours keeps pass 1's result, so nothing regresses to
guesswork. guesswork.
| | decoded | shared | inconsistent | | | decoded | shared | inconsistent | e106 mirror |
|---|---|---|---| |---|---|---|---|---|
| before | 5 480 / 6 294 | 681 | **125** | | shipped (today) | 5 480 / 6 294 | 681 | **125** | ✅ matches capture |
| neighbourhood anchor | 5 480 / 6 294 | 681 | **63** | | neighbourhood anchor | 5 480 / 6 294 | 681 | 63 | ❌ flipped |
| + refining the map | 5 480 / 6 294 | 681 | **51** | | + refining the map | 5 480 / 6 294 | 681 | 51 | ❌ flipped |
**Refining matters** because pass 1's anchor map contains the very mistakes the **Refining matters** because pass 1's anchor map contains the very mistakes the
neighbourhood is meant to correct, so a resource beside a mis-anchored neighbour neighbourhood is meant to correct, so a resource beside a mis-anchored neighbour
inherits a bad reference. Re-anchoring against the improving map and repeating inherits a bad reference. Re-anchoring against the improving map and repeating
converges quickly — two rounds, with a third changing nothing. converges quickly — two rounds, with a third changing nothing.
**Coverage is unchanged and inconsistency halves.** `e303_wep_01` now decodes to On its own metric this looked complete: coverage unchanged, inconsistency
49 × 23 × 42 in *all* containers, and `e106` renders as a destroyer instead of a halved, `e303_wep_01` decoding to 49 × 23 × 42 in *all* containers, and `e106`
slab ([before](../captures/e106-static-assembly-volume-bug.png) · rendering as a destroyer instead of a slab
[after](../captures/e106-static-assembly-fixed.png)) — its two shared turrets sit ([before](../captures/e106-static-assembly-volume-bug.png) ·
symmetrically at X[203,154] and X[154,203]. [after](../captures/e106-static-assembly-fixed.png)).
**It was reverted anyway.** `ship::tests::static_assembly_matches_runtime_capture`
is gated on `SYLPHEED_ISO` and therefore skips in an ordinary `cargo test`; run
with the ISO it fails:
```
e106_bdy_01: static M row0 [-1.0, 0.0, 0.0] != captured [1.0, 0.0, 0.0]
```
**Why:** `e106_bdy_01` and `e106_bdy_02` are a mirrored pair whose two vertex
buffers hold the same geometry reflected in X, and **both resources currently
decode to the *same* buffer** — identical vertex count, identical span, identical
`mean_x`. `apply_twin_mirrors` decides which instance to reflect from the sign of
that `mean_x`, so which of the two buffers gets picked flips the decision:
```
before the change both twins decode with mean_x = 66.83 → mirror bdy_02 (matches the capture)
after the change both twins decode with mean_x = +66.83 → mirror bdy_01 (contradicts it)
```
Neither is right: two distinct resources sharing one decode is itself the bug,
and the mirror heuristic has been compensating for it. The runtime capture is
ground truth, so a change that contradicts it does not ship — **the real fix must
give each twin its own buffer**, after which the mirror rule can use each
resource's own geometry.
**The filtered path needed care.** `models_named` (what the viewer's ship **The filtered path needed care.** `models_named` (what the viewer's ship
rendering uses) drops non-wanted resources, which would leave a filtered decode rendering uses) drops non-wanted resources, which would leave a filtered decode