re: the "sibling default" rules are a dedup artefact — WITHDRAWN
The corpus recorded that some unit fields the disc leaves defaulted inherit from a sibling: Size_Y from Size_X, FCSRange from RadarRange, DefencePoint from AttackVesselPoint. Size_Y was marked the one to trust, on 9/9 support across 7 independent ships, and it is restated in INDEX.md. The premise is false. These fields are not defaulted -- they are on disc for 113-114 of 114 unit tables -- and Size_Y DIFFERS from Size_X in 90 of them. The mechanism, cross-tabulating "legacy reader missed it" against "equal on disc": pair seen+differ seen+equal miss+differ miss+equal Size_Y / Size_X 90 0 0 24 FCSRange / RadarRange 54 0 1 58 DefencePoint / AttackVesselPoint 51 0 1 61 seen+equal is 0 for all three: a value shared with a sibling is ALWAYS invisible to the string-pool reader, because the pool stores each distinct string once. And the reader almost never misses a value that differs. So "the missing value equals the sibling's" was true BY CONSTRUCTION -- the rule re-derived the very condition that made the field go missing. That is why the support looked perfect: it could not fail on the cases it was fitted to. The two miss+differ cells are its real wrong predictions, both named: UN_e104_ADAN_Carrier DefencePoint is 0.2 (rule says 0.003), and UN_e011_ADAN_Attacker_B_HF_Wayne FCSRange is 3000.0 (rule says 6000.0). Retracted in unit-struct-runtime.md (original reasoning kept below the correction), live-unit-definitions.md and INDEX.md. Pinned by a disc test that asserts the seen+equal cells stay zero, so the mechanism itself is guarded, not just the counts. Artifact: examples/sibling_rule_check.rs. This one was found by my own check after the subagent assigned to it stalled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
This commit is contained in:
61
crates/sylpheed-formats/examples/sibling_rule_check.rs
Normal file
61
crates/sylpheed-formats/examples/sibling_rule_check.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//! Does the legacy reader miss a field precisely when its value is shared?
|
||||||
|
//!
|
||||||
|
//! The corpus records four "sibling default" rules (`Size_Y` inherits `Size_X`,
|
||||||
|
//! `FCSRange` inherits `RadarRange`, …) used to recover values for units never
|
||||||
|
//! visited at runtime. If the rules are really a *deduplication artefact*, then
|
||||||
|
//! the legacy reader should report the field absent exactly when the two values
|
||||||
|
//! are equal on disc — and never otherwise.
|
||||||
|
use sylpheed_formats::{IdxdObject, PakArchive};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let disc = std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC");
|
||||||
|
let arc = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).expect("pak");
|
||||||
|
let pairs = [
|
||||||
|
("Size_Y", "Size_X"),
|
||||||
|
("FCSRange", "RadarRange"),
|
||||||
|
("DefencePoint", "AttackVesselPoint"),
|
||||||
|
];
|
||||||
|
// legacy-absent x equal-on-disc, as a 2x2 table per pair.
|
||||||
|
let mut tally = [[[0usize; 2]; 2]; 3];
|
||||||
|
for e in arc.entries() {
|
||||||
|
let Ok(bytes) = arc.read(e) else { continue };
|
||||||
|
if !IdxdObject::is_idxd(&bytes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
|
||||||
|
let Some(generic) = obj.record("Generic") else { continue };
|
||||||
|
if generic.get("Size_X").is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (i, (field, sibling)) in pairs.iter().enumerate() {
|
||||||
|
let (Some(a), Some(b)) = (generic.get(field), generic.get(sibling)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let legacy_absent = obj.get_f32(field).is_none();
|
||||||
|
tally[i][usize::from(legacy_absent)][usize::from(a == b)] += 1;
|
||||||
|
// The only cases where the rule PREDICTS WRONG: the reader misses
|
||||||
|
// the field and the two values differ, so "inherit the sibling"
|
||||||
|
// substitutes a number the disc contradicts.
|
||||||
|
if legacy_absent && a != b {
|
||||||
|
println!(
|
||||||
|
" rule-wrong: {:08x} {field} = {a} but {sibling} = {b} (ID {:?})",
|
||||||
|
e.name_hash,
|
||||||
|
obj.get_raw("ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{:<14} {:>10} {:>10} {:>10} {:>10}", "pair", "seen+diff", "seen+eq", "MISS+diff", "MISS+eq");
|
||||||
|
for (i, (field, sibling)) in pairs.iter().enumerate() {
|
||||||
|
println!(
|
||||||
|
"{:<14} {:>10} {:>10} {:>10} {:>10}",
|
||||||
|
format!("{field}/{sibling}").chars().take(14).collect::<String>(),
|
||||||
|
tally[i][0][0],
|
||||||
|
tally[i][0][1],
|
||||||
|
tally[i][1][0],
|
||||||
|
tally[i][1][1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("\n'MISS+diff' > 0 refutes 'the reader only misses shared values'.");
|
||||||
|
println!("'seen+eq' > 0 refutes 'a shared value is always invisible'.");
|
||||||
|
}
|
||||||
@@ -289,3 +289,60 @@ fn field_names_are_stored_disc() {
|
|||||||
// fields on the disc whose name still has to be recovered by preimage search.
|
// fields on the disc whose name still has to be recovered by preimage search.
|
||||||
assert_eq!(hash_keyed_unnamed, 504);
|
assert_eq!(hash_keyed_unnamed, 504);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The "sibling default" rules are a string-pool deduplication artefact.
|
||||||
|
///
|
||||||
|
/// The corpus recorded that `Size_Y` inherits `Size_X` (and three similar pairs)
|
||||||
|
/// for units the disc leaves "defaulted". The fields are not defaulted — they are
|
||||||
|
/// on disc — and the old reader missed them precisely when the value was shared
|
||||||
|
/// with the sibling, which is the condition the rule then "predicted".
|
||||||
|
#[test]
|
||||||
|
fn sibling_default_rules_are_a_dedup_artefact() {
|
||||||
|
skip_without_disc!(root);
|
||||||
|
let arc = PakArchive::open(root.join("dat/GP_MAIN_GAME_E.pak")).expect("pak");
|
||||||
|
|
||||||
|
let pairs = [
|
||||||
|
("Size_Y", "Size_X"),
|
||||||
|
("FCSRange", "RadarRange"),
|
||||||
|
("DefencePoint", "AttackVesselPoint"),
|
||||||
|
];
|
||||||
|
// [pair][legacy_absent][equal_on_disc]
|
||||||
|
let mut tally = [[[0usize; 2]; 2]; 3];
|
||||||
|
for e in arc.entries() {
|
||||||
|
let Ok(bytes) = arc.read(e) else { continue };
|
||||||
|
if !IdxdObject::is_idxd(&bytes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
|
||||||
|
let Some(generic) = obj.record("Generic") else { continue };
|
||||||
|
if generic.get("Size_X").is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (i, (field, sibling)) in pairs.iter().enumerate() {
|
||||||
|
let (Some(a), Some(b)) = (generic.get(field), generic.get(sibling)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
tally[i][usize::from(obj.get_f32(field).is_none())][usize::from(a == b)] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The field is present on disc far more often than it "differs", so the
|
||||||
|
// premise "these fields are defaulted" is simply false.
|
||||||
|
assert_eq!(tally[0][0][0] + tally[0][1][0], 90, "Size_Y differs from Size_X");
|
||||||
|
|
||||||
|
// The mechanism: a value shared with the sibling is ALWAYS invisible to the
|
||||||
|
// legacy reader. If this cell were ever non-zero the dedup story would be
|
||||||
|
// incomplete.
|
||||||
|
for (i, (field, sibling)) in pairs.iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
tally[i][0][1], 0,
|
||||||
|
"{field}/{sibling}: legacy reader saw a value it shares with its sibling"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// …and it almost never misses a value that differs. Those few cells are
|
||||||
|
// exactly where the rule predicts the wrong number.
|
||||||
|
assert_eq!(tally[0][1][0], 0, "Size_Y: rule never wrong");
|
||||||
|
assert_eq!(tally[1][1][0], 1, "FCSRange: UN_e011_ADAN_Attacker_B_HF_Wayne");
|
||||||
|
assert_eq!(tally[2][1][0], 1, "DefencePoint: UN_e104_ADAN_Carrier");
|
||||||
|
}
|
||||||
|
|||||||
88
crates/sylpheed-formats/tests/zz_scratch_measure.rs
Normal file
88
crates/sylpheed-formats/tests/zz_scratch_measure.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
//! TEMPORARY measurement scratch — not for commit.
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use sylpheed_formats::idxd::IdxdObject;
|
||||||
|
use sylpheed_formats::game_data::schema;
|
||||||
|
use sylpheed_formats::PakArchive;
|
||||||
|
|
||||||
|
fn pak(name: &str) -> Option<PakArchive> {
|
||||||
|
let disc = std::env::var("SYLPHEED_DISC").ok()?;
|
||||||
|
PakArchive::open(format!("{disc}/dat/{name}")).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_f32(v: &str) -> Option<f32> {
|
||||||
|
let v = v.strip_suffix(['f','F']).unwrap_or(v);
|
||||||
|
v.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// field lists per loader
|
||||||
|
fn keys_for(schema_id: u32) -> Vec<&'static str> {
|
||||||
|
match schema_id {
|
||||||
|
schema::WEAPON => vec!["Power","Velocity","MinimumVelocity","MaximumVelocity","MinimumRange","MaximumRange","LoadingCount","Interval","TriggerShotCount","Mass","Heating","Cooling","LifeTime","ID","Name","TargetType"],
|
||||||
|
schema::UNIT => vec!["HP","IsDestructible","Size_X","Size_Y","Size_Z","Size_Radius","RadarRange","FCSRange","CruisingVelocity","MaximumVelocity","Acceleration","Deceleration","ShieldRatio","TurretCount","ScorePoint","ID","Name"],
|
||||||
|
schema::VESSEL => vec!["HP","Size_X","Size_Y","Size_Z","RadarRange","FCSRange","MaximumVelocity","ShieldRatio","ScorePoint","TurretCount","BridgeCount","HatchCount","ShieldGeneratorCount","ThrusterCount","ID","Name","Model"],
|
||||||
|
schema::PLAYER => vec!["AirDragFactor","GravityFactor","BulletLimit","LaserLimit","HomingLimit","SpaceSize","SupplyRange","MainMissionBonus","RankScore_S","RankScore_A","RankScore_B","RankScore_C","RankScore_D"],
|
||||||
|
schema::CHARACTER => vec!["ID","Name","SideID","Unique"],
|
||||||
|
schema::STAGE => vec!["BackGroundID"],
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn measure() {
|
||||||
|
let Some(pak) = pak("GP_MAIN_GAME_E.pak") else { eprintln!("SKIP"); return };
|
||||||
|
let schemas = [
|
||||||
|
("WEAPON", schema::WEAPON), ("UNIT", schema::UNIT), ("VESSEL", schema::VESSEL),
|
||||||
|
("PLAYER", schema::PLAYER), ("CHARACTER", schema::CHARACTER), ("STAGE", schema::STAGE),
|
||||||
|
];
|
||||||
|
let mut tot = [0usize;5]; // reads, ok, miss, flat, wrong
|
||||||
|
for (label, sid) in schemas {
|
||||||
|
let mut objs = 0usize;
|
||||||
|
let mut c = [0usize;5];
|
||||||
|
let mut recnames: BTreeMap<String, usize> = BTreeMap::new();
|
||||||
|
let mut fieldspread: BTreeMap<&str, BTreeMap<usize,usize>> = BTreeMap::new();
|
||||||
|
let mut examples: Vec<String> = Vec::new();
|
||||||
|
for e in pak.entries() {
|
||||||
|
let Ok(b) = pak.read(e) else { continue };
|
||||||
|
let Ok(o) = IdxdObject::parse(&b) else { continue };
|
||||||
|
if o.schema_hash != sid { continue }
|
||||||
|
objs += 1;
|
||||||
|
let Some(recs) = o.records() else { eprintln!("{label}: NO RECORD TABLE"); continue };
|
||||||
|
for r in recs { *recnames.entry(r.name.clone()).or_default() += 1; }
|
||||||
|
// flat map, as game_data builds it
|
||||||
|
let fmap: BTreeMap<String,String> = o.resolved_fields().into_iter().map(|(k,v)|(k.to_string(),v.to_string())).collect();
|
||||||
|
for key in keys_for(sid) {
|
||||||
|
let hits: Vec<(&str,&str)> = recs.iter().filter_map(|r| r.get(key).map(|v| (r.name.as_str(), v))).collect();
|
||||||
|
let flat: Option<&str> = if ["ID","Name","Model","TargetType","SideID","BackGroundID","Unique","IsDestructible"].contains(&key) {
|
||||||
|
o.get_raw(key)
|
||||||
|
} else {
|
||||||
|
fmap.get(key).map(String::as_str)
|
||||||
|
};
|
||||||
|
if hits.is_empty() { continue }
|
||||||
|
c[0]+=1; tot[0]+=1;
|
||||||
|
let distinct: BTreeSet<&str> = hits.iter().map(|(_,v)| *v).collect();
|
||||||
|
*fieldspread.entry(key).or_default().entry(hits.len()).or_default() += 1;
|
||||||
|
if hits.len() > 1 && distinct.len() > 1 {
|
||||||
|
c[3]+=1; tot[3]+=1;
|
||||||
|
if examples.len() < 6 { examples.push(format!("FLAT {key}: {} records, {} distinct vals, flat={:?}, e.g. {:?}", hits.len(), distinct.len(), flat, &hits[..hits.len().min(4)])); }
|
||||||
|
} else {
|
||||||
|
let truth = hits[0].1;
|
||||||
|
match flat {
|
||||||
|
None => { c[2]+=1; tot[2]+=1;
|
||||||
|
if examples.len()<6 && hits.len()==1 { examples.push(format!("MISS {key} = {truth:?} in record {:?}", hits[0].0)); } }
|
||||||
|
Some(f) if f == truth || as_f32(f).is_some() && as_f32(f)==as_f32(truth) => { c[1]+=1; tot[1]+=1; }
|
||||||
|
Some(f) => { c[4]+=1; tot[4]+=1; examples.push(format!("WRONG {key}: flat={f:?} truth={truth:?} rec={:?}", hits[0].0)); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("\n=== {label} {sid:#010x}: {objs} objects; reads {} ok {} miss {} flat {} wrong {}", c[0],c[1],c[2],c[3],c[4]);
|
||||||
|
let mut rn: Vec<_> = recnames.iter().collect();
|
||||||
|
rn.sort_by_key(|(_,n)| std::cmp::Reverse(**n));
|
||||||
|
println!(" records ({} distinct): {:?}", recnames.len(), rn.iter().take(25).map(|(k,n)|format!("{k}×{n}")).collect::<Vec<_>>());
|
||||||
|
for (k, spread) in &fieldspread {
|
||||||
|
println!(" field {k}: recs-per-object {:?}", spread);
|
||||||
|
}
|
||||||
|
for e in &examples { println!(" {e}"); }
|
||||||
|
}
|
||||||
|
println!("\n=== TOTAL reads {} ok {} miss {} flat {} wrong {}", tot[0],tot[1],tot[2],tot[3],tot[4]);
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
|||||||
| XBG7 mesh | ✅/🟡 | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | **6 294 resources, 6 209 decode (98.7 %), 82 searched-and-missed** (2026-08-12, up from 5 480 / 87.1 %). Five evidence-driven fixes got there: **distinct anchor assignment** (no two resources may claim one buffer — proved by a capture showing the container holds both mirrored `e106` hull halves), the connectivity cap replaced by a **winding-consistency gate at 0.70**, **structural requirements on pre-pivot sub-meshes** (index range, then exact pool coverage), and **filtering after the assignment** so a subset query cannot differ from the full decode. Validated against a runtime capture that names the file offset of every buffer the engine drew: **46/46 drawn buffers claimed, 45 anchored exactly**. **No real mesh now decodes differently in different containers** — all 89 remaining cross-container disagreements are interchangeable 24-vertex bounding boxes, which no anchoring rule can pin (monotone order re-tested and refuted). Remaining misses attribute to the degeneracy/extent gate (42), winding (31) and coverage (9); the first was probed and its "obvious" fix refuted. Every decoded sub-mesh covers its own vertex pool. **The `[index buffer][vertex buffer]` layout is now runtime-verified** (2026-08-13): with the F10 capture extended to log each draw's index buffer, all **42** drawn `Stage_S02` buffers match our decoded index count exactly, all 42 have their index union cover the pool exactly, and the 30 single-block cases all sit at `pad ≤ 3` — so `e106_eng_02_l`'s old rejection was the connectivity gate, not a misplaced index buffer. The `indices=` mystery was the capture keeping only the **first of several index batches** per buffer. **And comparing index VALUES found the biggest silent defect yet**: the anchor took the first `pad` that validated, so a block whose index data sits at pad 2 was read **one element late** — 76/93 captured runs matched, all 17 differences a one-element shift. Scoring pads by degenerate triangles + winding fixes it: **93/93** captured runs now match byte for byte, disc-wide degenerate runs **582 → 1** (the grouped path had the same bug; and two resources were anchored on a degenerate lookalike earlier in file order), **590 of 8 850** sub-meshes re-wired with 10 vertex anchors moved, resources decoded unchanged at 6 209. Cross-container minority decodes 89 → 96 — *because* the decoder improved: `_rou_f402_dead` now has a majority (32×25×8) so its seven wrong copies are named instead of hidden. One dirty run remains, blocked by distinct assignment on a 24-vertex box. **Then the descriptor gave up its last structural secret**: it declares a vertex layout **per sub-mesh** (`n201_01` → strides 24/24/24/**28**, capture-confirmed), and grouped selection must prefer the candidate explaining the **whole** pool rather than the first whose pivot validates — together they take never-decoding resources **85 → 47** (**6 247 / 6 294 = 99.25 %** decode), put `n201_01` on all four capture-proven offsets and raise the stage-05 capture oracle to **128/128**. The residual 47 is 30 pose/proxy composites (0.010-unit marker boxes), 6 `.DAT` particle composites, 8 damage/LOD variants and 3 props — not a threshold away |
|
| XBG7 mesh | ✅/🟡 | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | **6 294 resources, 6 209 decode (98.7 %), 82 searched-and-missed** (2026-08-12, up from 5 480 / 87.1 %). Five evidence-driven fixes got there: **distinct anchor assignment** (no two resources may claim one buffer — proved by a capture showing the container holds both mirrored `e106` hull halves), the connectivity cap replaced by a **winding-consistency gate at 0.70**, **structural requirements on pre-pivot sub-meshes** (index range, then exact pool coverage), and **filtering after the assignment** so a subset query cannot differ from the full decode. Validated against a runtime capture that names the file offset of every buffer the engine drew: **46/46 drawn buffers claimed, 45 anchored exactly**. **No real mesh now decodes differently in different containers** — all 89 remaining cross-container disagreements are interchangeable 24-vertex bounding boxes, which no anchoring rule can pin (monotone order re-tested and refuted). Remaining misses attribute to the degeneracy/extent gate (42), winding (31) and coverage (9); the first was probed and its "obvious" fix refuted. Every decoded sub-mesh covers its own vertex pool. **The `[index buffer][vertex buffer]` layout is now runtime-verified** (2026-08-13): with the F10 capture extended to log each draw's index buffer, all **42** drawn `Stage_S02` buffers match our decoded index count exactly, all 42 have their index union cover the pool exactly, and the 30 single-block cases all sit at `pad ≤ 3` — so `e106_eng_02_l`'s old rejection was the connectivity gate, not a misplaced index buffer. The `indices=` mystery was the capture keeping only the **first of several index batches** per buffer. **And comparing index VALUES found the biggest silent defect yet**: the anchor took the first `pad` that validated, so a block whose index data sits at pad 2 was read **one element late** — 76/93 captured runs matched, all 17 differences a one-element shift. Scoring pads by degenerate triangles + winding fixes it: **93/93** captured runs now match byte for byte, disc-wide degenerate runs **582 → 1** (the grouped path had the same bug; and two resources were anchored on a degenerate lookalike earlier in file order), **590 of 8 850** sub-meshes re-wired with 10 vertex anchors moved, resources decoded unchanged at 6 209. Cross-container minority decodes 89 → 96 — *because* the decoder improved: `_rou_f402_dead` now has a majority (32×25×8) so its seven wrong copies are named instead of hidden. One dirty run remains, blocked by distinct assignment on a 24-vertex box. **Then the descriptor gave up its last structural secret**: it declares a vertex layout **per sub-mesh** (`n201_01` → strides 24/24/24/**28**, capture-confirmed), and grouped selection must prefer the candidate explaining the **whole** pool rather than the first whose pivot validates — together they take never-decoding resources **85 → 47** (**6 247 / 6 294 = 99.25 %** decode), put `n201_01` on all four capture-proven offsets and raise the stage-05 capture oracle to **128/128**. The residual 47 is 30 pose/proxy composites (0.010-unit marker boxes), 6 `.DAT` particle composites, 8 damage/LOD variants and 3 props — not a threshold away |
|
||||||
| Capital-ship part placement | ✅ | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | Placement is **sound** (hull static-exact against the `e106` capture; cross-id mounting genuinely narrow, 2 pairs across 335 ships). The XBG7 mis-decode this row used to blame for "ships assemble wrong" — a shared turret ~100× too large in some containers — is **fixed** (2026-08-12, the exact-coverage requirement): `e303_wep_01` now decodes 49×23×42 everywhere and places at ±179 on the `e106` hull, and no real mesh disagrees across containers. A composite-node audit confirmed the assembler itself never applied a bad scale (all nodes scale 1.0, orthonormal). Still open: `static_assembly_matches_runtime_capture` walks capture parts only, so **extra** static placements cannot fail it |
|
| Capital-ship part placement | ✅ | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | Placement is **sound** (hull static-exact against the `e106` capture; cross-id mounting genuinely narrow, 2 pairs across 335 ships). The XBG7 mis-decode this row used to blame for "ships assemble wrong" — a shared turret ~100× too large in some containers — is **fixed** (2026-08-12, the exact-coverage requirement): `e303_wep_01` now decodes 49×23×42 everywhere and places at ±179 on the `e106` hull, and no real mesh disagrees across containers. A composite-node audit confirmed the assembler itself never applied a bad scale (all nodes scale 1.0, orthonormal). Still open: `static_assembly_matches_runtime_capture` walks capture parts only, so **extra** static placements cannot 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**. **Re-derived independently 2026-08-13 from the loader's own key strings** (`sub_82341A20`; the field name for each store is a string in the image): **159 fields**, agreeing with this solver on **25 of 25 shared offsets**, verified at **406 values matching the disc and 0 disagreeing** over 11 live objects spanning UNIT and VESSEL — landed as `data/unit_definition_layout.txt` + `sylpheed_formats::unit_layout` + a no-emulator test, with **121 defaulted fields** read out ([live-unit-definitions](live-unit-definitions.md)). 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**. **Re-derived independently 2026-08-13 from the loader's own key strings** (`sub_82341A20`; the field name for each store is a string in the image): **159 fields**, agreeing with this solver on **25 of 25 shared offsets**, verified at **406 values matching the disc and 0 disagreeing** over 11 live objects spanning UNIT and VESSEL — landed as `data/unit_definition_layout.txt` + `sylpheed_formats::unit_layout` + a no-emulator test, with **121 defaulted fields** read out ([live-unit-definitions](live-unit-definitions.md)). Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — ❌ **the sibling-default rules are WITHDRAWN (2026-08-25)** — `Size_Y` is on disc for **114/114** unit tables and **differs from `Size_X` in 90**; the old reader missed it exactly when the two were equal (a string-pool dedup artefact, cross-tab `seen+equal = 0` for all three pairs), so the rule was re-deriving the condition that hid the field. It also predicts wrong twice — `UN_e104_ADAN_Carrier.DefencePoint` and `UN_e011_ADAN_Attacker_B_HF_Wayne.FCSRange`. Read the record table instead — [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 |
|
||||||
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` is decoded — it is a **looping sprite animation**, not a composition. ⚠️ **DEMOTED 2026-08-18** — the declaration table is *not* the paint order: a per-draw capture of the running title screen ([ui-title-paint-order-capture](ui-title-paint-order-capture.md)) paints element 13 first and elements 0/1 late, and the visible screen composites two bundles. The rest of the table's reading stands. Previously claimed: the **screen's draw list is the RATC bundle's own declaration table** (elements in back-to-front order, including the `eff*`/`deli*`/`msg` sprites that have no `.rat`, and excluding focused button variants reached via `opt `); its entry also carries a **parent element index** at `+32`. **A screen is fully reconstructible from its bundle**: the placement region right after the declaration table gives every element a keyframe group (header = element index + keyframe count, then 40-byte blocks of scale/tint/X/Y), including the `.rat`-less sprites — verified 11/11 on the tutorial pause bundle, with `pgp_ttrl_btn10`'s inline (546,288) matching its own record exactly |
|
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` is decoded — it is a **looping sprite animation**, not a composition. ⚠️ **DEMOTED 2026-08-18** — the declaration table is *not* the paint order: a per-draw capture of the running title screen ([ui-title-paint-order-capture](ui-title-paint-order-capture.md)) paints element 13 first and elements 0/1 late, and the visible screen composites two bundles. The rest of the table's reading stands. Previously claimed: the **screen's draw list is the RATC bundle's own declaration table** (elements in back-to-front order, including the `eff*`/`deli*`/`msg` sprites that have no `.rat`, and excluding focused button variants reached via `opt `); its entry also carries a **parent element index** at `+32`. **A screen is fully reconstructible from its bundle**: the placement region right after the declaration table gives every element a keyframe group (header = element index + keyframe count, then 40-byte blocks of scale/tint/X/Y), including the `.rat`-less sprites — verified 11/11 on the tutorial pause bundle, with `pgp_ttrl_btn10`'s inline (546,288) matching its own record exactly |
|
||||||
| UI screen paint order | ✅ | [runtime screen object](structures/ui-screen-runtime.md) + [title capture](ui-title-paint-order-capture.md) | **SOLVED**: the game's screen object keeps a second, reordered list of its elements — the child array at `+0x30` — and that is the paint order, not the declaration table. Read live from guest memory (found by the item vtable `0x820b30b4`) and checked against the draw capture: the seven nameable elements sit at child slots 0, 6, 7, 13, 16, 17, 22, strictly ascending, exactly as captured; it also settles the one pair no static field could order. 🟡 deriving that order from the bundle — what the port needs — is still open |
|
| UI screen paint order | ✅ | [runtime screen object](structures/ui-screen-runtime.md) + [title capture](ui-title-paint-order-capture.md) | **SOLVED**: the game's screen object keeps a second, reordered list of its elements — the child array at `+0x30` — and that is the paint order, not the declaration table. Read live from guest memory (found by the item vtable `0x820b30b4`) and checked against the draw capture: the seven nameable elements sit at child slots 0, 6, 7, 13, 16, 17, 22, strictly ascending, exactly as captured; it also settles the one pair no static field could order. 🟡 deriving that order from the bundle — what the port needs — is still open |
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Live unit definitions from a running Stage 02 (2026-08-12)
|
# Live unit definitions from a running Stage 02 (2026-08-12)
|
||||||
|
|
||||||
|
> ❌ **CORRECTION (2026-08-25):** the **`Size_Y` inherits `Size_X`** rule this
|
||||||
|
> file supports is **WITHDRAWN**. `Size_Y` is on disc for **114 / 114** unit
|
||||||
|
> tables and **differs** from `Size_X` in **90** of them. The old reader missed
|
||||||
|
> it precisely when the two values were equal — a string-pool deduplication
|
||||||
|
> artefact, since the pool stores each distinct string once. See
|
||||||
|
> [unit-struct-runtime](structures/unit-struct-runtime.md#-withdrawn--some-defaulted-fields-inherit-from-a-sibling).
|
||||||
|
|
||||||
|
|
||||||
The Route-B question — what value does a field that the disc leaves **defaulted**
|
The Route-B question — what value does a field that the disc leaves **defaulted**
|
||||||
actually take at runtime — needs the game running. This is the first snapshot of
|
actually take at runtime — needs the game running. This is the first snapshot of
|
||||||
the parsed definition objects taken straight out of guest RAM, plus the recipe
|
the parsed definition objects taken straight out of guest RAM, plus the recipe
|
||||||
|
|||||||
@@ -273,7 +273,57 @@ is no need to play it, and no need to survive it.
|
|||||||
|
|
||||||
Stages captured so far: `Ttrl` (BASIC CONTROLS), Stage 02.
|
Stages captured so far: `Ttrl` (BASIC CONTROLS), Stage 02.
|
||||||
|
|
||||||
## A defaulted unit field is not a global constant — some inherit from a sibling
|
## ❌ WITHDRAWN — "some defaulted fields inherit from a sibling"
|
||||||
|
|
||||||
|
**The rules below are a string-pool DEDUPLICATION ARTEFACT. The fields were never
|
||||||
|
defaulted: they are on disc for 113–114 of 114 unit tables.** Measured
|
||||||
|
2026-08-25 against the [record table](idxd-container.md), which the old reader
|
||||||
|
could not see; artifact `examples/sibling_rule_check.rs`.
|
||||||
|
|
||||||
|
The premise was that these fields are absent from the disc. They are not:
|
||||||
|
|
||||||
|
| field | present on disc | **differs** from its claimed parent |
|
||||||
|
|---|---|---|
|
||||||
|
| `Size_Y` vs `Size_X` | **114 / 114** | **90** |
|
||||||
|
| `FCSRange` vs `RadarRange` | 113 / 113 | 55 |
|
||||||
|
| `DefencePoint` vs `AttackVesselPoint` | 113 / 113 | 52 |
|
||||||
|
|
||||||
|
So `Size_Y` does not "inherit" `Size_X` — it differs from it in 90 of 114 units.
|
||||||
|
|
||||||
|
### Why the rules nevertheless *worked*
|
||||||
|
|
||||||
|
The old reader infers a value from string-pool adjacency, and the pool stores
|
||||||
|
each distinct string **once**. So a field whose value equals a sibling's
|
||||||
|
contributes no token of its own and reads as absent. Cross-tabulating "reader
|
||||||
|
missed it" against "equal on disc" makes the mechanism exact:
|
||||||
|
|
||||||
|
| pair | seen + differ | seen + equal | **missed + differ** | missed + equal |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `Size_Y` / `Size_X` | 90 | **0** | **0** | 24 |
|
||||||
|
| `FCSRange` / `RadarRange` | 54 | **0** | 1 | 58 |
|
||||||
|
| `DefencePoint` / `AttackVesselPoint` | 51 | **0** | 1 | 61 |
|
||||||
|
|
||||||
|
`seen + equal = 0` everywhere: a shared value is *always* invisible to the old
|
||||||
|
reader. And it almost never misses a field whose value differs. So "the missing
|
||||||
|
value equals the sibling's" was true **by construction** — the rule was
|
||||||
|
re-deriving the condition under which the field went missing in the first place.
|
||||||
|
|
||||||
|
That is why it looked so strong: 9/9, 4/4, 6/6 support. It could not have
|
||||||
|
failed on the cases it was fitted to.
|
||||||
|
|
||||||
|
### The two places it does fail
|
||||||
|
|
||||||
|
The `missed + differ` cells are the rule's wrong predictions, and both are real:
|
||||||
|
|
||||||
|
* `UN_e104_ADAN_Carrier` — `DefencePoint` is **0.2**; the rule predicts
|
||||||
|
`AttackVesselPoint` = 0.003.
|
||||||
|
* `UN_e011_ADAN_Attacker_B_HF_Wayne` — `FCSRange` is **3000.0**; the rule
|
||||||
|
predicts `RadarRange` = 6000.0.
|
||||||
|
|
||||||
|
**Use the record table.** The rules are unnecessary where they are right and
|
||||||
|
wrong where they are not.
|
||||||
|
|
||||||
|
### The original text, kept for the reasoning
|
||||||
|
|
||||||
**Confidence: 🟡 for `Size_Y`, ❔ for the rest. Analysis 2026-08-10, offline, from
|
**Confidence: 🟡 for `Size_Y`, ❔ for the rest. Analysis 2026-08-10, offline, from
|
||||||
[`captures/unit-runtime-fields.csv`](../captures/unit-runtime-fields.csv).**
|
[`captures/unit-runtime-fields.csv`](../captures/unit-runtime-fields.csv).**
|
||||||
|
|||||||
Reference in New Issue
Block a user