re(route-b): the field map comes out of the code, and with it the Ratio family

The filler builds each key as addi r4, r30, -N with r30 = 0x82088f94, so every
store's field NAME is a string in the image. Pairing keys with the following stfs
gives the layout outright: Size_X/Y/Z at +48/52/56, HQRatio +88, ShieldRatio +92,
ThrusterRatio +96, resistances +116..132, Radar/FCS/FiringRange +672/676/680,
Attack/Defence points +692/696/700. verify_fieldmap.rs checks it against the live
dump: 62 fields agree with the disc, 0 disagree.

That yields the runtime value of each field for units whose record omits it --
HQRatio 0.2/1, ThrusterRatio 0.2/1, ShieldRatio 1, ResistanceToPlayer 1,
ResistanceToShell 0.1, ResistanceToExplosion 0.5, AttackVesselPoint 0.1,
AttackCraftPoint 0.1/0.5, DefencePoint 0.1/0.25, FiringRange 0, Size_Y = Size_X.
Recorded with the caveat that several show two values across units, so these are
per-unit runtime values rather than one global default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 23:17:05 +00:00
parent 2ed475868b
commit 0434afa6a6
2 changed files with 121 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
//! Check the code-derived offset→field map against the live dump, then read out
//! the runtime value of every field the disc record leaves defaulted.
//! Run: verify_fieldmap <disc-root> <live-dump> <ID=0xVA>...
use std::collections::BTreeMap;
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
// Offsets read out of sub_82341A20's own key strings (stfs stores only).
const MAP: &[(usize, &str)] = &[
(48, "Size_X"), (52, "Size_Y"), (56, "Size_Z"), (80, "Size_Radius"),
(64, "Color_R"), (68, "Color_G"), (72, "Color_B"),
(88, "HQRatio"), (92, "ShieldRatio"), (96, "ThrusterRatio"),
(116, "ResistanceToOptics"), (120, "ResistanceToShell"),
(124, "ResistanceToExplosion"), (128, "ResistanceToPlayer"),
(132, "ResistanceParalyze"),
(672, "RadarRange"), (676, "FCSRange"), (680, "FiringRange"),
(692, "AttackVesselPoint"), (696, "AttackCraftPoint"), (700, "DefencePoint"),
];
fn main() {
let mut a = std::env::args().skip(1);
let disc = a.next().unwrap();
let dump = a.next().unwrap();
let pairs: Vec<(String, String)> =
a.filter_map(|s| s.split_once('=').map(|(i, v)| (i.into(), v.into()))).collect();
let mut live: BTreeMap<String, BTreeMap<usize, f32>> = BTreeMap::new();
let mut cur = String::new();
for line in std::fs::read_to_string(&dump).unwrap().lines() {
if let Some(r) = line.strip_prefix("=== ") { cur = r.trim().into(); continue; }
let f: Vec<&str> = line.split_whitespace().collect();
// dump columns: addr +off hex u32 f32 -- the float is f[4], not f[3]
if f.len() >= 5 && f[1].starts_with('+') {
if let (Ok(o), Ok(v)) = (usize::from_str_radix(&f[1][1..], 16), f[4].parse::<f32>()) {
live.entry(cur.clone()).or_default().insert(o, v);
}
}
}
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let (mut agree, mut disagree) = (0, 0);
let mut defaults: BTreeMap<String, Vec<(String, f32)>> = BTreeMap::new();
for e in pak.entries() {
let Ok(b) = pak.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { continue };
let Some(id) = o.get_raw("ID") else { continue };
let Some((_, va)) = pairs.iter().find(|(i, _)| i == id) else { continue };
let Some(w) = live.get(va) else { continue };
for (off, key) in MAP {
let Some(got) = w.get(off) else { continue };
match o.get_f32(key) {
Some(want) => {
if (want - got).abs() <= want.abs() * 1e-4 { agree += 1 } else {
disagree += 1;
println!(" MISMATCH {id} {key}: disc {want}, live +{off} = {got}");
}
}
None => defaults.entry((*key).into()).or_default().push((id.into(), *got)),
}
}
}
println!("\nmap check: {agree} fields agree with the disc, {disagree} disagree\n");
println!("runtime value where the disc record DEFAULTS the field:");
for (key, hits) in &defaults {
let vals: Vec<String> = hits.iter().map(|(_, v)| format!("{v}")).collect();
let uniq: std::collections::BTreeSet<&String> = vals.iter().collect();
println!(" {:<22} {:<28} ({} units)", key,
uniq.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "), hits.len());
}
}

View File

@@ -336,3 +336,54 @@ Two readings corrected on the way:
So the runtime `Size_Y = Size_X` must be written **after** the definition is
loaded — by whatever instantiates a unit from it, or a post-load pass over the
table. `+108` itself is too generic to chase (1 129 loads image-wide).
## ✅ The field map, read out of the code — and the Ratio family with it
The filler builds each key as `addi r4, r30, -N` with `r30 = 0x82088f94`, so the
**field name for every store is a string in the image**. Pairing each key with the
`stfs` that follows gives the object layout directly, with no guessing:
| offset | field | offset | field |
|---|---|---|---|
| `+48` | `Size_X` | `+116` | `ResistanceToOptics` |
| `+52` | `Size_Y` | `+120` | `ResistanceToShell` |
| `+56` | `Size_Z` | `+124` | `ResistanceToExplosion` |
| `+64/68/72` | `Color_R/G/B` | `+128` | `ResistanceToPlayer` |
| `+80` | `Size_Radius` | `+132` | `ResistanceParalyze` |
| **`+88`** | **`HQRatio`** | `+672` | `RadarRange` |
| **`+92`** | **`ShieldRatio`** | `+676` | `FCSRange` |
| **`+96`** | **`ThrusterRatio`** | `+680` | `FiringRange` |
| `+692` | `AttackVesselPoint` | `+696` | `AttackCraftPoint` |
| `+700` | `DefencePoint` | | |
`examples/verify_fieldmap.rs` checks it against the live dump: **62 fields agree
with the disc record, 0 disagree** across five capital ships.
That turns the same dump into the answer for the family the brief asked about —
the runtime value of each field **for the units whose record omits it**:
| field | runtime value(s) seen | units |
|---|---|---|
| `Size_Y` | 200, 300, 400, 600, 700 (each `= Size_X`) | 5 |
| `HQRatio` | **0.2**, **1** | 4 |
| `ThrusterRatio` | **0.2**, **1** | 4 |
| `ShieldRatio` | **1** | 1 |
| `ResistanceToPlayer` | **1** | 5 |
| `ResistanceToShell` | **0.1** | 3 |
| `ResistanceToExplosion` | **0.5** | 3 |
| `AttackVesselPoint` | **0.1** | 2 |
| `AttackCraftPoint` | **0.1**, **0.5** | 5 |
| `DefencePoint` | **0.1**, **0.25** | 4 |
| `FiringRange` | **0** | 5 |
⚠️ Read that table as *"what the field holds at runtime for a unit that does not
set it"*, **not** as "the default constant". Several fields show **two distinct
values** across units (`HQRatio` 0.2 or 1, `AttackCraftPoint` 0.1 or 0.5), so the
value is derived per unit rather than being one global default — consistent with
`Size_Y`, which takes each unit's own `Size_X`. Only `FiringRange` (0 everywhere)
matches the loader's own miss value.
*Method note:* the first run of this check reported 32 "mismatches". They were an
off-by-one column in the dump reader — `f[3]` is the u32, `f[4]` the float — and
decoding one by hand (`1128792000` = `0x43480000` = 200.0) showed the map had been
right all along.