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:
Sylpheed RE agent
2026-08-25 23:38:55 +00:00
parent 6fa6564be8
commit 49a09a9496
6 changed files with 266 additions and 2 deletions

View 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'.");
}

View File

@@ -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");
}

View 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]);
}

View File

@@ -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 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 |
| 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 |

View File

@@ -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

View File

@@ -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 113114 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).**