re(units): target missions by roster, harvest S09 — 36 units / 3 439 defaulted values

examples/roster_target.rs ranks stages by how many roster units are still
unharvested. The EnumUnit_S<NN> tables are found by hashing candidate TOC paths
(hash::TOC_NAME_SCHEMES) — UnitRoster::stage can only infer a tag when the roster
carries a UN_S<NN>_ prop, which most do not.

It picked S09 (10 missing). Flying it: 26 -> 36 units, 3 345 -> 4 785 rows,
2 351 -> 3 439 defaulted-on-disc values. New: e102_Battleship, e104_Carrier,
e107_AAFrigate, e011_Attacker_B, e008_TurretPlus, be001_TerrafoamingUnit,
e001_Elan_GR{,_Violeta}, f102_LightCarrier_Inv, f106_Destroyer_Inv.

Also settled: the definition objects are mission-independent. Eleven units appear in
more than one snapshot and four are not byte-identical, but compared through the
layout ZERO mapped fields differ — the 12 differing slots are all unmapped (offsets
4/8/16/20 and 0x250/0x268/0x300-0x308/0x330-0x338: object header and sub-object
pointers). So a harvested value is the definition, not a per-mission tweak, and the
earlier UN_f201_TCAF_Tanker flag resolves the same way. Cross-checks over three
snapshots: 1 052 agree, 0 disagree.

Third angle field found the same way (Through_AngleMaximum = 60 degrees in radians),
so the degrees<->radians rule covers any name containing "Angle".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
This commit is contained in:
2026-08-13 13:48:12 +00:00
parent 14d5ed54d1
commit bf6825e278
4 changed files with 1536 additions and 5 deletions

View File

@@ -0,0 +1,60 @@
//! Which mission should the next unit harvest fly?
//!
//! Unit definitions are instantiated per stage, so coverage grows by visiting
//! missions — but only if the mission fields units we have not read yet. This
//! ranks every stage by how many of its roster units are missing from the
//! harvested CSV.
//!
//! Usage: roster_target <disc-root> <unit-runtime-fields.csv>
use sylpheed_formats::hash::name_hash;
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
use std::collections::{BTreeMap, BTreeSet};
fn main() {
let a: Vec<String> = std::env::args().collect();
let pak = PakArchive::open(format!("{}/dat/GP_MAIN_GAME_E.pak", a[1])).expect("pak");
let have: BTreeSet<String> = std::fs::read_to_string(&a[2])
.expect("csv")
.lines()
.skip(1)
.filter_map(|l| l.split(',').next().map(str::to_string))
.collect();
// Label each roster by STAGE: the TOC stores a hash of the original path, and
// the table names are known (`EnumUnit_SNN.tbl`), so hashing the candidates
// maps entries back to stages. Most rosters carry no `UN_S<NN>_` prop id, which
// is all `UnitRoster::stage` can infer from.
let mut by_hash: BTreeMap<u32, String> = BTreeMap::new();
// The TOC hashes a PATH, and these tables live under a directory — try the
// known schemes (see hash::TOC_NAME_SCHEMES) rather than the bare name.
for i in 1..=29 {
let stage = format!("S{i:02}");
for pre in ["", "unit\\", "battle\\", "stage\\", "enemy\\"] {
for suf in [".tbl", ""] {
by_hash.insert(name_hash(&format!("{pre}EnumUnit_{stage}{suf}")), stage.clone());
}
}
}
let mut rows: Vec<(usize, String, Vec<String>)> = Vec::new();
for e in pak.entries() {
let Some(stage) = by_hash.get(&e.name_hash).cloned() else { continue };
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let mut units: Vec<String> = Vec::new();
for id in o.tokens().iter().filter(|s| s.starts_with("UN_")) {
let prop = id.contains("Asteroid") || id.contains("cmesh") || id.contains("_Box");
if !prop && !units.iter().any(|u| u == id) {
units.push(id.clone());
}
}
let missing: Vec<String> = units.iter().filter(|u| !have.contains(*u)).cloned().collect();
rows.push((missing.len(), stage, missing));
}
rows.sort_by_key(|(n, _, _)| std::cmp::Reverse(*n));
println!("units already harvested: {}\n", have.len());
println!("stage missing roster units not yet read");
for (n, stage, missing) in rows.iter().take(12) {
let s: Vec<&str> = missing.iter().take(6).map(|s| s.as_str()).collect();
println!("{stage:<8} {n:>5} {}", s.join(", "));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -450,3 +450,33 @@ Two things the run pinned down, both cheap to re-learn the hard way:
⚠️ One unit's object is **not** byte-identical between the two missions — ⚠️ One unit's object is **not** byte-identical between the two missions —
`UN_f201_TCAF_Tanker`. Either a per-mission override or a field the runtime mutates; `UN_f201_TCAF_Tanker`. Either a per-mission override or a field the runtime mutates;
the merged CSV holds the later reading, and separating them needs a third snapshot. the merged CSV holds the later reading, and separating them needs a third snapshot.
### Targeting the next mission, and the check that these really are definitions (2026-08-13)
[`examples/roster_target.rs`](../../../crates/sylpheed-formats/examples/roster_target.rs)
ranks the stages by how many of their roster units are **not yet harvested**. The
rosters are `EnumUnit_S<NN>` tables whose TOC entries store a *path hash*, so the
stage label comes from hashing the candidate paths
(`hash::TOC_NAME_SCHEMES`) rather than from `UnitRoster::stage`, which can only
infer a tag when the roster happens to carry a `UN_S<NN>_…` prop.
It picked **S09 (10 missing)**; flying it took the file to **36 units / 4 785 rows /
3 439 defaulted-on-disc values**, adding `UN_e102_ADAN_Battleship`,
`UN_e104_ADAN_Carrier`, `UN_e107_ADAN_AAFrigate`, `UN_e011_ADAN_Attacker_B`,
`UN_e008_ADAN_TurretPlus`, `UN_be001_ADAN_TerrafoamingUnit`,
`UN_e001_ADAN_Elan_GR{,_Violeta}`, `UN_f102_TCAF_LightCarrier_Inv` and
`UN_f106_TCAF_Destroyer_Inv`.
**The objects are mission-independent — measured, not assumed.** Eleven units appear
in more than one snapshot, and four of them are *not* byte-identical across missions.
Comparing them **through the layout**: **zero mapped fields differ**. The 12 differing
4-byte slots are all **unmapped** — offsets `4/8/16/20` (the object header and name
pointer) and `0x250/0x268/0x3000x308/0x3300x338` (sub-object pointers) — i.e. guest
addresses, not data. So a value harvested in one mission is the unit's definition, not
a per-mission tweak, and the earlier `UN_f201_TCAF_Tanker` flag resolves the same way.
Cross-checks against the disc over the three snapshots: **1 052 agree, 0 disagree.**
One more angle field turned up the same way as the last: `Through_AngleMaximum`
(object `1.0472` = 60° in radians) carries neither an `AV_`/`AA_` token nor `Bank`, so
the degrees↔radians rule covers **anything with `Angle` in the name** too.

View File

@@ -26,11 +26,12 @@ import sys
sys.path.insert(0, __file__.rsplit("/", 1)[0]) sys.path.insert(0, __file__.rsplit("/", 1)[0])
import unit_runtime as ur # object locator + disc token reader import unit_runtime as ur # object locator + disc token reader
# Angle fields can carry a prefix (`AB_AA_PitchPlus` = afterburner), so match # Angle fields are DEGREES on disc and RADIANS in the object. The set is wider
# the AV_/AA_ token anywhere, not just at the start — anchoring it left two # than the `AV_`/`AA_` families: a prefix may precede them (`AB_AA_PitchPlus` =
# player-craft fields looking like contradictions when they were 15°/16° in # afterburner) and some carry neither (`Through_AngleMaximum`). Both cases showed
# radians. # up as lone "contradictions" whose object value was exactly the disc value in
ANGLE = re.compile(r"(^|_)(AV|AA)_|Bank|Turn_AngularVelocity") # radians — which is the tell.
ANGLE = re.compile(r"(^|_)(AV|AA)_|Bank|Angle|Turn_AngularVelocity")
def read_layout(path): def read_layout(path):