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:
@@ -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,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 000–350 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 000–350 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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user