re: read the defaulted weapon stats out of live guest memory
Canary backs the whole guest address space with one shared-memory file, so the game's RAM is readable from the host while it runs -- no debugger, no emulator patch. That turns the parked Route-B question (fields the IDXD omits because they sit at their default) from "unreachable" into a table lookup. gmem.py maps guest VAs into /dev/shm/xenia_memory_* via Xenia's fixed table and searches only the allocated extents, so a full-RAM scan is ~0.2 s. weapon_runtime.py finds the parsed objects by scanning for their vtable, then *solves* the struct layout instead of guessing it: every (field, offset, encoding) triple is scored against the disc records, and a binding is accepted only with zero contradictions -- and marked confirmed only when >=10 records agree on >=3 distinct values, because a field whose samples are all one number matches any offset holding that constant. Result: Weapon (0xc0) and Shell (0x200) mapped, 4393 values the disc does not carry, for all 126 weapons at 5 % save progress. Cross-checks hold -- the Arsenal DATA SHEET read 4 for wep_05/wep_60 Max. Lock Ons and the memory read agrees; the in-flight HUD's 06000/00300 match LoadingCount; all 252 objects are byte-identical across a screen change, so this is definition data, not state. Two findings fell out: `IsCharging = Yes` switches LoadingCount and TriggerShotCount to float32 (it partitions the 8 float-encoded records exactly), and two .tbl entries declare Shell_TCAF_Ship_AAGun with conflicting Power -- the runtime keeps the one that omits it. Where the defaulted values *come from* (the IDXD's undecoded binary node region vs code defaults) is not settled here; they vary per weapon, so they are not one constructor constant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ Thumbs.db
|
|||||||
|
|
||||||
# Trunk build output
|
# Trunk build output
|
||||||
dist/
|
dist/
|
||||||
|
__pycache__/
|
||||||
|
|||||||
102
crates/sylpheed-formats/examples/idxd_tokens.rs
Normal file
102
crates/sylpheed-formats/examples/idxd_tokens.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
//! RE diagnostic: emit every `weapon\Weapon_*.tbl` in a pak as machine-readable
|
||||||
|
//! records, split at the sub-object boundaries the schema declares.
|
||||||
|
//!
|
||||||
|
//! An IDXD `.tbl` for a weapon holds `count` sub-records — a `Weapon` and its
|
||||||
|
//! `Shell` — flattened into one string pool. `sylpheed-cli pak dump` shows them
|
||||||
|
//! merged, which is ambiguous (both declare `ID`/`Name`). The title's schema
|
||||||
|
//! name pool gives the split point: the pool contains the literal type names
|
||||||
|
//! (`Weapon`, `Shell`), and each sub-record's fields follow its type name.
|
||||||
|
//!
|
||||||
|
//! Here we split on a type-name token appearing as a *key* position, so each
|
||||||
|
//! record is emitted with its own ID and field set:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! REC <tbl-hash> <index> <TypeName> <ID>
|
||||||
|
//! F <key> <value>
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Run: cargo run --release -p sylpheed-formats --example idxd_tokens -- <pak> [types...]
|
||||||
|
|
||||||
|
use sylpheed_formats::idxd::IdxdObject;
|
||||||
|
use sylpheed_formats::pak::PakArchive;
|
||||||
|
|
||||||
|
fn is_number(s: &str) -> bool {
|
||||||
|
let s = s.strip_suffix(['f', 'F']).unwrap_or(s);
|
||||||
|
let t = s.strip_prefix(['-', '+']).unwrap_or(s);
|
||||||
|
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Yes`/`No`-style scalar literals are values, not field names, even though
|
||||||
|
/// they are identifier-shaped.
|
||||||
|
fn is_enum_value(s: &str) -> bool {
|
||||||
|
matches!(s, "Yes" | "No" | "YES" | "NO" | "On" | "Off" | "ON" | "OFF")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_key_like(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
|
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||||
|
&& !is_number(s)
|
||||||
|
&& !is_enum_value(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
let pak_path = args.next().expect("usage: idxd_tokens <pak> [TypeName...]");
|
||||||
|
let types: Vec<String> = {
|
||||||
|
let v: Vec<String> = args.collect();
|
||||||
|
if v.is_empty() {
|
||||||
|
vec!["Weapon".into(), "Shell".into()]
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pak = PakArchive::open(&pak_path).expect("open pak");
|
||||||
|
for entry in pak.entries() {
|
||||||
|
let Ok(bytes) = pak.read(entry) else { continue };
|
||||||
|
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
|
||||||
|
let toks = obj.tokens();
|
||||||
|
if !toks.iter().any(|t| t.starts_with("Weapon_")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if std::env::var("SYLPH_RAW").is_ok() {
|
||||||
|
println!("RAW {:08x}", entry.name_hash);
|
||||||
|
for t in toks {
|
||||||
|
println!("T {t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-record boundaries: a bare type-name token. The first one is the
|
||||||
|
// schema's own declaration (`EnumWeapon`-style header lives in title
|
||||||
|
// code, not here), so we take every occurrence in order.
|
||||||
|
let mut bounds: Vec<(usize, &str)> = Vec::new();
|
||||||
|
for (i, t) in toks.iter().enumerate() {
|
||||||
|
// The pool-start heuristic can glue one stray binary byte onto the
|
||||||
|
// first token ("YWeapon"), so match a short suffix, not equality.
|
||||||
|
if let Some(ty) = types
|
||||||
|
.iter()
|
||||||
|
.find(|ty| t == *ty || (t.ends_with(ty.as_str()) && t.len() <= ty.len() + 2))
|
||||||
|
{
|
||||||
|
bounds.push((i, ty.as_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (n, &(start, ty)) in bounds.iter().enumerate() {
|
||||||
|
let end = bounds.get(n + 1).map(|b| b.0).unwrap_or(toks.len());
|
||||||
|
let slice = &toks[start..end];
|
||||||
|
// The record's own ID value is the token straight after its type
|
||||||
|
// name (`Shell` -> `Shell_DSaber_P_wep_01_Beam`). The `ID`/`Name`
|
||||||
|
// *keys* are interned once in the pool, so only the first record
|
||||||
|
// shows them -- position-of-key lookup would miss the rest.
|
||||||
|
let id = slice.get(1).map(|s| s.as_str()).unwrap_or("?");
|
||||||
|
println!("REC {:08x} {n} {ty} {id}", entry.name_hash);
|
||||||
|
for i in 1..slice.len() {
|
||||||
|
let (k, v) = (slice[i].as_str(), slice[i - 1].as_str());
|
||||||
|
if is_key_like(k) && (!is_key_like(v) || is_number(v)) {
|
||||||
|
println!("F {k} {v}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,9 +21,16 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
|||||||
| 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`). Quantized hero bodies (DeltaSaber `f004`) still declined |
|
| 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`). Quantized hero bodies (DeltaSaber `f004`) still declined |
|
||||||
| Capital-ship part placement | 🟡 | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | hull placement static-exact; external parts approximate statically. **Runtime capture** (Canary F10 → VS-constant WorldView) gives ground truth — validated on `e106` destroyer; not yet baked into the viewer |
|
| Capital-ship part placement | 🟡 | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | hull placement static-exact; external parts approximate statically. **Runtime capture** (Canary F10 → VS-constant WorldView) gives ground truth — validated on `e106` destroyer; not yet baked into the viewer |
|
||||||
| Weapon fields defaulted on disc | ✅/🟡 | [runtime DATA SHEET](weapon-datasheet-runtime.md) | The Arsenal Gallery-Mode panel reads the live record: `Ammo Capacity` = `LoadingCount` ✅, `Max. Lock Ons` = `TriggerShotCount` ✅. Recovers `TriggerShotCount` for `wep_05`/`wep_60` (both **4**); brackets defaulted `Power`/`MaximumRange` via the letter classes. 9 of ~61 weapons reachable at 5 % save progress |
|
| 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 |
|
||||||
| 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` (screen-level draw order) not yet decoded |
|
| 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` (screen-level draw order) not yet decoded |
|
||||||
|
|
||||||
|
## Runtime / dynamic-capture technique
|
||||||
|
|
||||||
|
| Technique | Conf. | Spec | Notes |
|
||||||
|
|-----------|-------|------|-------|
|
||||||
|
| Live guest-memory read | ✅ | [`tools/re-capture/gmem.py`](../../tools/re-capture/gmem.py) | Canary backs the guest address space with `/dev/shm/xenia_memory_*`; guest VAs map in through Xenia's fixed table. Full-RAM search ~0.2 s (sparse, `SEEK_DATA`). No debugger, no emulator patch, game keeps running |
|
||||||
|
| IDXD object layout solver | ✅ | [`tools/re-capture/weapon_runtime.py`](../../tools/re-capture/weapon_runtime.py) | Scan RAM for a class's vtable → enumerate its objects → brute-force `(field, offset, encoding)` against the disc records. Accepts a binding only on **zero** contradictions. Generalizes to any IDXD-backed definition |
|
||||||
|
|
||||||
## Functions / code paths
|
## Functions / code paths
|
||||||
|
|
||||||
_None documented yet — populated during the dynamic-RE phase._
|
_None documented yet — populated during the dynamic-RE phase._
|
||||||
|
|||||||
7183
docs/re/captures/weapon-runtime-fields.csv
Normal file
7183
docs/re/captures/weapon-runtime-fields.csv
Normal file
File diff suppressed because it is too large
Load Diff
294
docs/re/structures/weapon-struct-runtime.md
Normal file
294
docs/re/structures/weapon-struct-runtime.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# Runtime `Weapon` / `Shell` structs — read from live guest memory
|
||||||
|
|
||||||
|
**Confidence: ✅ CONFIRMED** for the fields marked ✅ below (each binding is
|
||||||
|
reproduced by 10–125 independent disc records with **zero** contradictions);
|
||||||
|
🟡 for the thin ones. Captured 2026-07-29 from Xenia Canary running the retail
|
||||||
|
disc, save slot 01 (Stage 02, 5 % progress), READY ROOM and ARSENAL.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
`weapon\Weapon_*.tbl` is an [IDXD](../INDEX.md) record whose string pool **omits
|
||||||
|
every field left at its default**. That parked a long list of stats as
|
||||||
|
unreadable — the `…Ratio` / `…Count` family, `Power`, `MaximumRange`. The
|
||||||
|
[Arsenal DATA SHEET route](../weapon-datasheet-runtime.md) recovered a few of
|
||||||
|
them but only as letter buckets (Range/Damage as `A`…`E`), and only for the 9
|
||||||
|
weapons unlocked at 5 % progress.
|
||||||
|
|
||||||
|
This reads the values **directly out of the running game's memory** instead.
|
||||||
|
All 126 weapons, all fields, exact numbers, in one pass — and it needs no story
|
||||||
|
progress, because the definitions are parsed at load time whether or not the
|
||||||
|
player has unlocked the weapon.
|
||||||
|
|
||||||
|
## The lever: Canary maps guest RAM into `/dev/shm`
|
||||||
|
|
||||||
|
Xenia Canary backs the entire guest address space with one shared-memory file,
|
||||||
|
`/dev/shm/xenia_memory_<id>`. It is a plain file: **the guest's RAM is readable
|
||||||
|
from the host with `open`/`seek`/`read`, live, no debugger and no emulator
|
||||||
|
patch.** Guest VAs map into it through Xenia's fixed table (`memory.cc`), which
|
||||||
|
[`tools/re-capture/gmem.py`](../../../tools/re-capture/gmem.py) implements:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/re-capture/gmem.py find "Weapon_DSaber_P_wep_01" # search all of RAM
|
||||||
|
python3 tools/re-capture/gmem.py words 0xbccce500 48 # dump as BE u32/f32
|
||||||
|
```
|
||||||
|
|
||||||
|
The file is sparse (~212 MB resident of 4.5 GB), and the scan uses
|
||||||
|
`SEEK_DATA`/`SEEK_HOLE`, so a full-RAM search costs ~0.2 s.
|
||||||
|
|
||||||
|
## Finding the objects
|
||||||
|
|
||||||
|
1. The title's **schema field-name pool** is in the XEX at `0x82086a30`, in
|
||||||
|
declaration order: `EnumWeapon`, type `Weapon` (`ID`, `Name`, `TargetType`,
|
||||||
|
… `CartridgeModelName`), then type `Shell` (`ID`, `Name`, `MovementType`, …
|
||||||
|
`Explosion_MaxDamageRadius`). This is exactly the on-disc field order. No
|
||||||
|
pointer to these strings exists anywhere in RAM — PPC builds the addresses
|
||||||
|
with `lis`/`ori` immediate pairs — so the schema descriptor cannot be found
|
||||||
|
by pointer-chasing; the layout has to be solved instead (below).
|
||||||
|
2. Each parsed record becomes **two C++ objects**, a `Weapon` and its `Shell`,
|
||||||
|
each in its own contiguous array, each identified by its **vtable pointer**:
|
||||||
|
|
||||||
|
| class | vtable VA | stride | count |
|
||||||
|
|-------|-----------|--------|-------|
|
||||||
|
| `Weapon` | `0x820af548` | `0xc0` | 126 |
|
||||||
|
| `Shell` | `0x820af58c` | `0x200` | 126 |
|
||||||
|
|
||||||
|
The vtable VAs are static (XEX `.data`); the array base addresses are heap
|
||||||
|
and are **not** assumed — the tool locates every object by scanning RAM for
|
||||||
|
the vtable word.
|
||||||
|
3. Object `+0x04` points at a 0x40-byte **name record**; the ID string sits at
|
||||||
|
`+0x10` inside it. That is what keys each object back to its disc record.
|
||||||
|
|
||||||
|
`Weapon` ↔ `Shell` pairing is by ID (`Weapon_X` ↔ `Shell_X`) — the two arrays
|
||||||
|
are in **different orders**, so index-pairing would be wrong.
|
||||||
|
|
||||||
|
## Solving the layout (the part that makes this evidence, not guesswork)
|
||||||
|
|
||||||
|
[`tools/re-capture/weapon_runtime.py`](../../../tools/re-capture/weapon_runtime.py)
|
||||||
|
brute-forces every `(field, byte offset, encoding)` triple and scores it against
|
||||||
|
the disc: how many records does this offset *reproduce*, and how many does it
|
||||||
|
*contradict*? A binding is accepted only with **zero contradictions**, and is
|
||||||
|
marked ✅ only when ≥10 records agree on ≥3 distinct values.
|
||||||
|
|
||||||
|
That threshold matters: a field whose disc samples are all the same number
|
||||||
|
matches any offset holding that constant, so agreement count alone is not
|
||||||
|
evidence — the number of **distinct** values pinned down is. Two fields landing
|
||||||
|
on one offset is impossible in a real struct, so collisions are resolved to the
|
||||||
|
better-evidenced field and the loser is reported as unsolved. Bindings that
|
||||||
|
contradict more than 20 % of their samples are discarded outright rather than
|
||||||
|
reported on their agreeing subset.
|
||||||
|
|
||||||
|
Encodings found: `f32`, `u32`, `deg` (**angles are stored in radians**;
|
||||||
|
`SprayAngle`, `AngularVelocity` and `SplitCone` are degrees on disc), and `cnt`
|
||||||
|
— see the next section.
|
||||||
|
|
||||||
|
### `IsCharging` switches the counter encoding ✅
|
||||||
|
|
||||||
|
`LoadingCount` and `TriggerShotCount` at `+0x28` / `+0x44` are **int32 on 118
|
||||||
|
records and float32 on 8**. The 8 are exactly the records with
|
||||||
|
`IsCharging = Yes` — an exact partition, found by testing every disc field/value
|
||||||
|
pair against the float-encoded set. A charging weapon drains its magazine
|
||||||
|
continuously, so its counter needs a fraction.
|
||||||
|
|
||||||
|
Any reimplementation must branch on `IsCharging` when reading these two fields;
|
||||||
|
reading them as int unconditionally yields `1125515264` instead of `150`.
|
||||||
|
|
||||||
|
## Struct maps
|
||||||
|
|
||||||
|
See [`docs/re/captures/weapon-runtime-fields.csv`](../captures/weapon-runtime-fields.csv)
|
||||||
|
for the full machine-readable table — 7 182 rows, every confirmed field × every
|
||||||
|
one of the 126 records, tagged `disc` or `defaulted-on-disc`. **4 393 of those
|
||||||
|
values were not readable from the disc at all.**
|
||||||
|
|
||||||
|
### `Weapon` (`0xc0` bytes)
|
||||||
|
|
||||||
|
| offset | enc | field | agree | distinct | conf |
|
||||||
|
|--------|-----|-------|------:|---------:|------|
|
||||||
|
| `+0x010` | u32 | `ReticleType` | 62 | 13 | ✅ |
|
||||||
|
| `+0x01c` | u32 | `SpecialWeaponType` | 73 | 6 | ✅ |
|
||||||
|
| `+0x028` | cnt | `LoadingCount` | 121 | 33 | ✅ |
|
||||||
|
| `+0x02c` | f32 | `Interval` | 125 | 28 | ✅ |
|
||||||
|
| `+0x030` | f32 | `ReadyInterval` | 120 | 10 | ✅ |
|
||||||
|
| `+0x034` | f32 | `Heating` | 114 | 30 | ✅ |
|
||||||
|
| `+0x038` | f32 | `Cooling` | 106 | 21 | ✅ |
|
||||||
|
| `+0x03c` | f32 | `Mass` | 122 | 42 | ✅ |
|
||||||
|
| `+0x040` | f32 | `HitRatio` | 102 | 5 | ✅ |
|
||||||
|
| `+0x044` | cnt | `TriggerShotCount` | 114 | 17 | ✅ |
|
||||||
|
| `+0x048` | f32 | `TriggerShotInterval` | 99 | 11 | ✅ |
|
||||||
|
| `+0x050` | deg | `SprayAngle` | 96 | 17 | ✅ |
|
||||||
|
| `+0x06c` | f32 | `LockIntervalSingle` | 61 | 9 | ✅ |
|
||||||
|
| `+0x070` | f32 | `LockIntervalMulti` | 28 | 9 | ✅ |
|
||||||
|
| `+0x09c` | f32 | `MaximumCharging` | 3 | 3 | 🟡 |
|
||||||
|
|
||||||
|
Also identified structurally, not by the solver: `+0x00` vtable, `+0x04` name
|
||||||
|
record, `+0x0c` `TargetType` as a bitmask (`Vessel|Craft|Structure` = 7),
|
||||||
|
`+0x14`/`+0x18` name hashes, `+0x7c`/`+0x80` muzzle-flash FX name record + hash.
|
||||||
|
|
||||||
|
Unsolved (no consistent offset): `Cracker_ShotInterval`, `Cracker_SubShotCount`,
|
||||||
|
`MinimumCharging`, `MultiTargetCount`.
|
||||||
|
|
||||||
|
### `Shell` (`0x200` bytes)
|
||||||
|
|
||||||
|
| offset | enc | field | agree | distinct | conf |
|
||||||
|
|--------|-----|-------|------:|---------:|------|
|
||||||
|
| `+0x00c` | cnt | `DeleteFadeSpeed` | 39 | 2 | 🟡 |
|
||||||
|
| `+0x010` | cnt | `GuidanceType` | 9 | 4 | 🟡 |
|
||||||
|
| `+0x014` | cnt | `SpiralType` | 3 | 1 | 🟡 |
|
||||||
|
| `+0x020` | f32 | `Length` | 107 | 3 | ✅ |
|
||||||
|
| `+0x024` | f32 | `Volume` | 19 | 3 | ✅ |
|
||||||
|
| `+0x028` | f32 | `ShellMass` | 110 | 34 | ✅ |
|
||||||
|
| `+0x03c` | f32 | `HP` | 28 | 2 | 🟡 |
|
||||||
|
| `+0x040` | f32 | `LifeTime` | 94 | 43 | ✅ |
|
||||||
|
| `+0x044` | f32 | `FadeInTime` | 13 | 2 | 🟡 |
|
||||||
|
| `+0x048` | f32 | `FadeOutTime` | 7 | 1 | 🟡 |
|
||||||
|
| `+0x04c` | f32 | `Velocity` | 106 | 15 | ✅ |
|
||||||
|
| `+0x050` | f32 | `MinimumVelocity` | 11 | 2 | 🟡 |
|
||||||
|
| `+0x054` | f32 | `MaximumVelocity` | 29 | 7 | ✅ |
|
||||||
|
| `+0x058` | deg | `AngularVelocity` | 49 | 19 | ✅ |
|
||||||
|
| `+0x05c` | f32 | `Acceleration` | 9 | 4 | 🟡 |
|
||||||
|
| `+0x064` | f32 | `BeginGuidanceTimeAdjust` | 24 | 2 | 🟡 |
|
||||||
|
| `+0x068` | f32 | `EndGuidanceTime` | 19 | 4 | ✅ |
|
||||||
|
| `+0x070` | f32 | `Spiral_BeginTime` | 2 | 2 | 🟡 |
|
||||||
|
| `+0x074` | f32 | `Spiral_BeginTimeAdjust` | 25 | 2 | 🟡 |
|
||||||
|
| `+0x08c` | f32 | `MinimumRange` | 43 | 7 | ✅ |
|
||||||
|
| `+0x090` | f32 | `MaximumRange` | 117 | 21 | ✅ |
|
||||||
|
| `+0x0a0` | f32 | `Color_R` | 18 | 4 | ✅ |
|
||||||
|
| `+0x0a4` | f32 | `Color_G` | 121 | 3 | ✅ |
|
||||||
|
| `+0x0a8` | f32 | `Color_B` | 100 | 2 | 🟡 |
|
||||||
|
| `+0x0b4` | f32 | `Radius` | 89 | 10 | ✅ |
|
||||||
|
| `+0x0b8` | f32 | `Power` | 101 | 37 | ✅ |
|
||||||
|
| `+0x0bc` | f32 | `FailedDamageRatio` | 2 | 2 | 🟡 |
|
||||||
|
| `+0x0c0` | f32 | `PlayerLaserPower` | 11 | 6 | ✅ |
|
||||||
|
| `+0x0fc` | f32 | `ChaffResistRatio` | 24 | 1 | 🟡 |
|
||||||
|
| `+0x104` | f32 | `ChargingSizeRatio` | 3 | 1 | 🟡 |
|
||||||
|
| `+0x10c` | f32 | `SphereRadiusBegin` | 9 | 6 | 🟡 |
|
||||||
|
| `+0x110` | f32 | `SphereRadiusTurn` | 9 | 8 | 🟡 |
|
||||||
|
| `+0x114` | f32 | `SphereRadiusEnd` | 10 | 8 | ✅ |
|
||||||
|
| `+0x118` | f32 | `ExplosionTurnTime` | 3 | 2 | 🟡 |
|
||||||
|
| `+0x11c` | f32 | `ExplosionLifeTime` | 7 | 6 | 🟡 |
|
||||||
|
| `+0x120` | f32 | `ExplosionDamage_Maximum` | 4 | 4 | 🟡 |
|
||||||
|
| `+0x124` | f32 | `ExplosionDamage_OuterEdge` | 8 | 6 | 🟡 |
|
||||||
|
| `+0x128` | f32 | `Explosion_MaxDamageRadius` | 8 | 3 | 🟡 |
|
||||||
|
| `+0x130` | f32 | `SplitTime_Maximum` | 2 | 2 | 🟡 |
|
||||||
|
| `+0x134` | deg | `SplitCone` | 2 | 2 | 🟡 |
|
||||||
|
| `+0x148` | cnt | `NodeCount` | 39 | 4 | ✅ |
|
||||||
|
| `+0x164` | f32 | `Deceleration` | 9 | 2 | 🟡 |
|
||||||
|
|
||||||
|
Unsolved: `BeginGuidanceTime`, `EndGuidanceTimeAdjust`, `Spiral_EndTime`,
|
||||||
|
`SplitTime_Minimum`, `StartingVelocity`, `Straight1Type`, `st1_df_pitch`,
|
||||||
|
`pitch0`, and the `sp_qu_*` / `sp_sp_*` / `sp_zg_*` spiral-motion family. Those
|
||||||
|
are declared by too few records (or by none that the solver could separate) —
|
||||||
|
they need a state where the shells are actually in flight.
|
||||||
|
|
||||||
|
## The answer to the parked Route-B question
|
||||||
|
|
||||||
|
Player-weapon fields that are **defaulted on disc**, now read exactly (`·` = the
|
||||||
|
disc carries the value already):
|
||||||
|
|
||||||
|
| wep | LoadingCount | TriggerShotCount | MaximumRange | Power | Heating | Cooling | ReadyInterval | HitRatio | SprayAngle |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 01 | · | **1** | · | · | · | · | · | · | · |
|
||||||
|
| 02 | · | · | · | **100** | · | · | · | **1** | · |
|
||||||
|
| 03 | · | · | · | · | · | · | · | · | **1** |
|
||||||
|
| 05 | · | **4** | · | · | · | · | · | · | · |
|
||||||
|
| 08 | · | · | · | · | · | · | · | **1** | · |
|
||||||
|
| 09 | · | · | · | · | **0.02** | · | · | · | **1** |
|
||||||
|
| 11 | **6** | · | · | · | · | · | · | · | · |
|
||||||
|
| 12 | · | · | · | · | **0** | · | · | **1** | · |
|
||||||
|
| 13 | · | · | · | **300** | · | · | · | · | · |
|
||||||
|
| 14 | · | · | · | · | · | · | · | · | **1** |
|
||||||
|
| 19 | · | · | · | · | · | **0.2** | · | · | **1** |
|
||||||
|
| 24 | · | **1** | · | · | · | · | · | · | **1** |
|
||||||
|
| 25 | · | · | **4000** | · | · | · | · | · | · |
|
||||||
|
| 26 | · | · | · | **500** | · | · | · | · | · |
|
||||||
|
| 27 | · | **1** | · | · | · | · | · | · | · |
|
||||||
|
| 28 | **5** | · | · | · | · | · | · | · | · |
|
||||||
|
| 29 | · | · | · | **100** | · | · | · | · | · |
|
||||||
|
| 30 | · | **4** | · | · | · | · | · | · | · |
|
||||||
|
| 36 | **5** | · | · | · | · | · | · | · | · |
|
||||||
|
| 37 | · | **1** | · | · | · | · | · | · | **1** |
|
||||||
|
| 38 | · | · | · | · | · | · | · | **1** | · |
|
||||||
|
| 39 | · | **1** | · | · | · | · | · | · | **1** |
|
||||||
|
| 40 | · | · | · | · | · | · | **0.1** | · | · |
|
||||||
|
| 48 | · | · | · | · | · | · | · | · | **1** |
|
||||||
|
| 50 | · | · | · | · | · | · | · | · | **1** |
|
||||||
|
| 52 | · | · | · | · | · | · | · | **1** | · |
|
||||||
|
| 53 | · | **1** | · | · | · | · | · | · | · |
|
||||||
|
| 54 | · | · | · | · | · | · | · | · | **1** |
|
||||||
|
| 55 | · | · | · | · | **0** | · | · | **1** | · |
|
||||||
|
| 56 | · | · | · | · | · | · | · | **1** | · |
|
||||||
|
| 57 | · | · | · | · | **0** | · | · | **1** | · |
|
||||||
|
| 58 | · | · | **10000** | · | · | · | · | · | **1** |
|
||||||
|
| 59 | · | · | · | · | · | · | · | **1** | **1** |
|
||||||
|
| 60 | · | **4** | · | **1000** | · | · | · | · | · |
|
||||||
|
| 62 | · | **1** | · | · | **0** | · | · | · | · |
|
||||||
|
| 66 | · | · | · | · | · | · | · | **1** | · |
|
||||||
|
| 67 | · | · | · | · | · | · | · | **1** | · |
|
||||||
|
| 68 | · | · | · | · | · | · | · | **1** | · |
|
||||||
|
| 69 | · | · | · | · | · | · | · | **1** | · |
|
||||||
|
| 70 | **0** | · | · | · | · | · | · | **1** | · |
|
||||||
|
| 71 | · | · | · | **10** | · | · | · | **1** | · |
|
||||||
|
| 81 | · | · | · | **300** | · | · | · | · | · |
|
||||||
|
| 82 | · | · | **10000** | · | · | · | · | · | **1** |
|
||||||
|
| 84 | · | · | · | · | · | · | · | · | **0.1** |
|
||||||
|
| 85 | · | **1** | · | · | **0** | · | · | · | · |
|
||||||
|
|
||||||
|
`HitRatio` is **1.0 for every one of the 24 records that default it** — the
|
||||||
|
whole `…Ratio` family that blocked Route B is simply "no penalty".
|
||||||
|
`wep_82`'s defaulted field is `PlayerLaserPower` = **12000** (its `Power`,
|
||||||
|
12000, is on disc).
|
||||||
|
|
||||||
|
## Cross-checks
|
||||||
|
|
||||||
|
- **Against the independent screenshot route.** The Arsenal DATA SHEET
|
||||||
|
([weapon-datasheet-runtime.md](../weapon-datasheet-runtime.md)) established
|
||||||
|
`Max. Lock Ons == TriggerShotCount`, and read **4** for `wep_05` and `wep_60`
|
||||||
|
off the panel. The memory read gives **4** for both — two unrelated methods,
|
||||||
|
same numbers.
|
||||||
|
- **Against the in-flight HUD.** `NOSE BM 06000` / `MAIN MPM 00300` matches
|
||||||
|
`wep_01` `LoadingCount = 6000` and `wep_02` `= 300` at `+0x28`.
|
||||||
|
- **Stability.** All 252 objects are **byte-identical** between the READY ROOM
|
||||||
|
and the ARSENAL, so these are load-time definition data, not transient state.
|
||||||
|
- **Self-consistency.** 121 records agree on `LoadingCount`'s offset across 33
|
||||||
|
distinct values with zero contradictions; `Power`, 101 records / 37 distinct
|
||||||
|
values. A wrong offset cannot do that.
|
||||||
|
|
||||||
|
## Incidental findings
|
||||||
|
|
||||||
|
- **A duplicate, conflicting disc record.** Two `.tbl` entries declare
|
||||||
|
`Shell_TCAF_Ship_AAGun`; one sets `Power = 60.0`, the other omits it. The
|
||||||
|
runtime object holds **5**, i.e. the *omitting* declaration won. Any
|
||||||
|
reimplementation loading both will silently pick one — this says which.
|
||||||
|
- **Not every disc record is instantiated.** 131 disc records produced 126
|
||||||
|
objects; the 5 with no runtime object are context variants that this save
|
||||||
|
never loads — `Weapon_TCAF_DeltaSaber_NoseGun_Ttrl` / `_Laser_Ttrl`
|
||||||
|
(tutorial), `_NoseGun_None`, `Weapon_TCAF_Ship_AAGun_EX5`,
|
||||||
|
`Weapon_ADAN_Attacker_S_GunTurret`. Running the tutorial should instantiate
|
||||||
|
the `_Ttrl` pair. No runtime object lacked a disc record.
|
||||||
|
- Defaulted `Power` values vary per weapon (1, 10, 100, 300, 500, 1000), so they
|
||||||
|
are **not** one constructor constant. Where they come from — the IDXD's
|
||||||
|
undecoded binary node/index region, or per-type code defaults — is **not
|
||||||
|
determined here** (`NEEDS-HUMAN` / follow-up). Either way the runtime values
|
||||||
|
above are ground truth, and they are now a decoding oracle for that region.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy
|
||||||
|
run-canary --audio --apu=sdl --log_mask=13 --logged_profile_slot_0_xuid=E0300000EFBEA3D4 &
|
||||||
|
tools/re-capture/skip_intro.sh # -> title
|
||||||
|
# LOAD GAME -> slot 01 -> YES -> READY ROOM (weapon tables load with the save)
|
||||||
|
|
||||||
|
cargo run --release -p sylpheed-formats --example idxd_tokens -- \
|
||||||
|
<disc>/dat/GP_MAIN_GAME_E.pak > /tmp/wep_tokens.txt
|
||||||
|
python3 tools/re-capture/weapon_runtime.py /tmp/wep_tokens.txt # report
|
||||||
|
python3 tools/re-capture/weapon_runtime.py /tmp/wep_tokens.txt --csv # full table
|
||||||
|
```
|
||||||
|
|
||||||
|
## What this unlocks
|
||||||
|
|
||||||
|
`gmem.py` + "scan for the vtable, solve the layout against disc ground truth" is
|
||||||
|
**not weapon-specific**. The same three steps apply to any IDXD-backed
|
||||||
|
definition whose fields the disc defaults — craft/`UNIT` stats, which the Hangar
|
||||||
|
UI exposes only as a `Gross Weight` class and which
|
||||||
|
[the menu route could not reach at all](../weapon-datasheet-runtime.md), are the
|
||||||
|
obvious next target.
|
||||||
162
tools/re-capture/gmem.py
Normal file
162
tools/re-capture/gmem.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read the *live* guest memory of a running Xenia Canary.
|
||||||
|
|
||||||
|
Canary backs the whole guest address space with a single shared-memory file,
|
||||||
|
`/dev/shm/xenia_memory_<pid-ish>`, so the guest's RAM is readable from the host
|
||||||
|
with no debugger, no emulator patch and no pause: open the file, seek, read.
|
||||||
|
|
||||||
|
The file is one flat image of Xenia's *physical* backing store; guest virtual
|
||||||
|
addresses map into it through Xenia's fixed table (memory.cc `map_info`).
|
||||||
|
`va_to_off()` implements that table, so callers work in guest VAs.
|
||||||
|
|
||||||
|
Sub-commands
|
||||||
|
find <pattern> search every allocated extent, print guest VAs
|
||||||
|
read <va> [len] hexdump guest memory
|
||||||
|
words <va> [n] dump n big-endian u32 / f32 pairs
|
||||||
|
|
||||||
|
`<pattern>` is a python literal-ish string: plain text, or `hex:0011aabb`.
|
||||||
|
Numbers accept 0x form. The scan uses SEEK_DATA so the ~4.6 GB of sparse holes
|
||||||
|
cost nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import struct
|
||||||
|
|
||||||
|
# (guest_va_lo, guest_va_hi_inclusive, file_offset_of_lo) — Xenia memory.cc.
|
||||||
|
MAP = [
|
||||||
|
(0x00000000, 0x3FFFFFFF, 0x00000000),
|
||||||
|
(0x40000000, 0x7EFFFFFF, 0x40000000),
|
||||||
|
(0x7F000000, 0x7F0FFFFF, 0x00000000),
|
||||||
|
(0x7F100000, 0x7FFFFFFF, 0x00100000),
|
||||||
|
(0x80000000, 0x8FFFFFFF, 0x80000000),
|
||||||
|
(0x90000000, 0x9FFFFFFF, 0x80000000),
|
||||||
|
(0xA0000000, 0xBFFFFFFF, 0x100000000),
|
||||||
|
(0xC0000000, 0xDFFFFFFF, 0x100000000),
|
||||||
|
(0xE0000000, 0xFFFFFFFF, 0x100000000),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def va_to_off(va):
|
||||||
|
for lo, hi, base in MAP:
|
||||||
|
if lo <= va <= hi:
|
||||||
|
return base + (va - lo)
|
||||||
|
raise ValueError(f"va {va:#x} outside the guest map")
|
||||||
|
|
||||||
|
|
||||||
|
def off_to_vas(off):
|
||||||
|
"""All guest VAs that alias this file offset (the map is many-to-one)."""
|
||||||
|
out = []
|
||||||
|
for lo, hi, base in MAP:
|
||||||
|
if base <= off <= base + (hi - lo):
|
||||||
|
out.append(lo + (off - base))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def primary_va(off):
|
||||||
|
"""The most useful VA for an offset: physical 0xA0000000+ / xex 0x80000000+."""
|
||||||
|
vas = off_to_vas(off)
|
||||||
|
return vas[0] if vas else None
|
||||||
|
|
||||||
|
|
||||||
|
def mem_path():
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1].startswith("/dev/shm/"):
|
||||||
|
return sys.argv.pop(1)
|
||||||
|
cands = [f"/dev/shm/{n}" for n in os.listdir("/dev/shm") if n.startswith("xenia_memory_")]
|
||||||
|
if not cands:
|
||||||
|
sys.exit("no /dev/shm/xenia_memory_* — is Canary running?")
|
||||||
|
if len(cands) > 1:
|
||||||
|
sys.exit(f"several memory files, pass one explicitly: {cands}")
|
||||||
|
return cands[0]
|
||||||
|
|
||||||
|
|
||||||
|
def extents(fd, size):
|
||||||
|
"""Yield (start, end) of the file's allocated (non-hole) ranges."""
|
||||||
|
pos = 0
|
||||||
|
while pos < size:
|
||||||
|
try:
|
||||||
|
data = os.lseek(fd, pos, os.SEEK_DATA)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
hole = os.lseek(fd, data, os.SEEK_HOLE)
|
||||||
|
except OSError:
|
||||||
|
hole = size
|
||||||
|
yield (data, hole)
|
||||||
|
pos = hole
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pattern(s):
|
||||||
|
if s.startswith("hex:"):
|
||||||
|
return bytes.fromhex(s[4:])
|
||||||
|
return s.encode("latin-1")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_find(f, size, args):
|
||||||
|
pat = parse_pattern(args[0])
|
||||||
|
limit = int(args[1]) if len(args) > 1 else 64
|
||||||
|
hits = 0
|
||||||
|
CHUNK = 1 << 24
|
||||||
|
for start, end in extents(f.fileno(), size):
|
||||||
|
pos = start
|
||||||
|
carry = b""
|
||||||
|
carry_at = start
|
||||||
|
while pos < end:
|
||||||
|
f.seek(pos)
|
||||||
|
buf = f.read(min(CHUNK, end - pos))
|
||||||
|
if not buf:
|
||||||
|
break
|
||||||
|
blob = carry + buf
|
||||||
|
base = carry_at
|
||||||
|
for m in re.finditer(re.escape(pat), blob):
|
||||||
|
off = base + m.start()
|
||||||
|
va = primary_va(off)
|
||||||
|
print(f"{off:#013x} va {va:#010x}" if va is not None else f"{off:#013x} va ?")
|
||||||
|
hits += 1
|
||||||
|
if hits >= limit:
|
||||||
|
return
|
||||||
|
keep = len(pat) - 1
|
||||||
|
carry = blob[-keep:] if keep else b""
|
||||||
|
carry_at = base + len(blob) - len(carry)
|
||||||
|
pos += len(buf)
|
||||||
|
if hits == 0:
|
||||||
|
print("(no hits)", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_read(f, size, args):
|
||||||
|
va = int(args[0], 0)
|
||||||
|
n = int(args[1], 0) if len(args) > 1 else 256
|
||||||
|
f.seek(va_to_off(va))
|
||||||
|
data = f.read(n)
|
||||||
|
for i in range(0, len(data), 16):
|
||||||
|
row = data[i : i + 16]
|
||||||
|
hexs = " ".join(f"{b:02x}" for b in row)
|
||||||
|
txt = "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in row)
|
||||||
|
print(f"{va + i:08x} {hexs:<47} |{txt}|")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_words(f, size, args):
|
||||||
|
va = int(args[0], 0)
|
||||||
|
n = int(args[1], 0) if len(args) > 1 else 32
|
||||||
|
f.seek(va_to_off(va))
|
||||||
|
data = f.read(n * 4)
|
||||||
|
for i in range(0, len(data) - 3, 4):
|
||||||
|
(u,) = struct.unpack_from(">I", data, i)
|
||||||
|
(fl,) = struct.unpack_from(">f", data, i)
|
||||||
|
fs = f"{fl:.6g}" if -1e30 < fl < 1e30 else ""
|
||||||
|
print(f"{va + i:08x} +{i:04x} {u:#010x} {u:>12} {fs}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
path = mem_path()
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
cmd, args = sys.argv[1], sys.argv[2:]
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
{"find": cmd_find, "read": cmd_read, "words": cmd_words}[cmd](f, size, args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
303
tools/re-capture/weapon_runtime.py
Normal file
303
tools/re-capture/weapon_runtime.py
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Solve the runtime `Weapon`/`Shell` struct layouts against disc ground truth,
|
||||||
|
then read the fields the disc leaves defaulted.
|
||||||
|
|
||||||
|
The game parses every `weapon\\Weapon_*.tbl` IDXD record into two C++ objects, a
|
||||||
|
`Weapon` and its `Shell`. Each class lives in its own contiguous array in guest
|
||||||
|
RAM and is identifiable by its vtable pointer. Fields the IDXD omits (the
|
||||||
|
Route-B "defaulted" list) still hold their real values in those objects — put
|
||||||
|
there by the constructor before the parser overwrites what the disc supplies.
|
||||||
|
|
||||||
|
Method (no guessing):
|
||||||
|
1. read every disc record's explicitly-valued fields, split per sub-record
|
||||||
|
(`sylpheed-formats --example idxd_tokens`);
|
||||||
|
2. read every runtime object out of the live emulator (`gmem`), keyed by the
|
||||||
|
object's own ID string;
|
||||||
|
3. for each (field, byte-offset, encoding) triple, count how many weapons the
|
||||||
|
offset reproduces and how many it contradicts. An offset that agrees on
|
||||||
|
many and contradicts none is a confirmed binding;
|
||||||
|
4. print the map, then the values at those offsets for the weapons whose disc
|
||||||
|
record omits the field.
|
||||||
|
|
||||||
|
Step 3 is the whole safeguard: a wrong offset cannot silently agree with ~100
|
||||||
|
independent records.
|
||||||
|
|
||||||
|
Usage: weapon_runtime.py <tokens.txt> [--md]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import gmem # noqa: E402
|
||||||
|
|
||||||
|
# vtable VA -> (class name, object stride)
|
||||||
|
CLASSES = {
|
||||||
|
0x820AF548: ("Weapon", 0xC0),
|
||||||
|
0x820AF58C: ("Shell", 0x200),
|
||||||
|
}
|
||||||
|
NAME_STR_OFF = 0x10 # the name string sits +0x10 into a name record
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- disc side
|
||||||
|
|
||||||
|
|
||||||
|
def read_tokens(path):
|
||||||
|
"""({class: {ID: {field: value}}}, {(class, ID): [field...]}) from the dump.
|
||||||
|
|
||||||
|
A handful of IDs are declared by more than one `.tbl`. Where two such
|
||||||
|
declarations disagree on a field, that field is *ambiguous on disc* — the
|
||||||
|
runtime holds whichever definition won, so it must not be scored as a
|
||||||
|
contradiction. Those are collected separately, not silently merged.
|
||||||
|
"""
|
||||||
|
seen, ambiguous = {}, {}
|
||||||
|
cur = None
|
||||||
|
for line in open(path):
|
||||||
|
if line.startswith("REC "):
|
||||||
|
_, _, _, cls, rid = line.split()
|
||||||
|
cur = (cls, rid)
|
||||||
|
seen.setdefault(cur, [])
|
||||||
|
seen[cur].append({})
|
||||||
|
elif line.startswith("F ") and cur is not None:
|
||||||
|
_, k, v = line.rstrip("\n").split(" ", 2)
|
||||||
|
seen[cur][-1][k] = v
|
||||||
|
|
||||||
|
out = {}
|
||||||
|
for (cls, rid), defs in seen.items():
|
||||||
|
merged = {}
|
||||||
|
for d in defs:
|
||||||
|
merged.update(d)
|
||||||
|
if len(defs) > 1:
|
||||||
|
bad = {
|
||||||
|
k
|
||||||
|
for k in {k for d in defs for k in d}
|
||||||
|
if len({d.get(k) for d in defs}) > 1
|
||||||
|
}
|
||||||
|
if bad:
|
||||||
|
ambiguous[(cls, rid)] = sorted(bad)
|
||||||
|
for k in bad:
|
||||||
|
merged.pop(k, None)
|
||||||
|
out.setdefault(cls, {})[rid] = merged
|
||||||
|
return out, ambiguous
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- runtime side
|
||||||
|
|
||||||
|
|
||||||
|
def scan_vtable(f, size, vt):
|
||||||
|
pat = struct.pack(">I", vt)
|
||||||
|
hits = []
|
||||||
|
for a, b in gmem.extents(f.fileno(), size):
|
||||||
|
f.seek(a)
|
||||||
|
blob = f.read(b - a)
|
||||||
|
for m in re.finditer(re.escape(pat), blob):
|
||||||
|
off = a + m.start()
|
||||||
|
if off % 4 == 0:
|
||||||
|
hits.append(off)
|
||||||
|
return sorted(set(hits))
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_objects(f, size):
|
||||||
|
"""{class: {ID: raw_bytes}} plus {class: [(va, ID)]} in array order."""
|
||||||
|
objs, order = {}, {}
|
||||||
|
for vt, (cls, stride) in CLASSES.items():
|
||||||
|
objs[cls], order[cls] = {}, []
|
||||||
|
for off in scan_vtable(f, size, vt):
|
||||||
|
f.seek(off)
|
||||||
|
raw = f.read(stride)
|
||||||
|
(nameptr,) = struct.unpack_from(">I", raw, 4)
|
||||||
|
try:
|
||||||
|
f.seek(gmem.va_to_off(nameptr + NAME_STR_OFF))
|
||||||
|
name = f.read(64).split(b"\0")[0].decode("latin-1")
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
continue
|
||||||
|
objs[cls][name] = raw
|
||||||
|
order[cls].append((gmem.primary_va(off), name))
|
||||||
|
return objs, order
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- solver
|
||||||
|
|
||||||
|
ENC = {
|
||||||
|
"f32": lambda raw, o, ch: struct.unpack_from(">f", raw, o)[0],
|
||||||
|
"u32": lambda raw, o, ch: float(struct.unpack_from(">I", raw, o)[0]),
|
||||||
|
"deg": lambda raw, o, ch: math.degrees(struct.unpack_from(">f", raw, o)[0]),
|
||||||
|
# A charging weapon drains its magazine continuously, so the two counter
|
||||||
|
# fields are float32 on `IsCharging = Yes` records and int32 on the rest.
|
||||||
|
# Discovered from the runtime: `IsCharging` partitions the 8 float-encoded
|
||||||
|
# records exactly (see docs/re/weapon-struct-runtime.md).
|
||||||
|
"cnt": lambda raw, o, ch: (
|
||||||
|
struct.unpack_from(">f", raw, o)[0] if ch else float(struct.unpack_from(">I", raw, o)[0])
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def near(a, b):
|
||||||
|
if a == b:
|
||||||
|
return True
|
||||||
|
return abs(a - b) <= 2e-4 * max(abs(a), abs(b), 1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def solve(disc_cls, run_cls, stride, charging):
|
||||||
|
"""{field: (off, enc, n_agree, [mismatch...])}"""
|
||||||
|
numeric = {}
|
||||||
|
for rid, fields in disc_cls.items():
|
||||||
|
if rid not in run_cls:
|
||||||
|
continue
|
||||||
|
for k, v in fields.items():
|
||||||
|
try:
|
||||||
|
numeric.setdefault(k, {})[rid] = float(v.rstrip("fF"))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
solved = {}
|
||||||
|
for field, samples in numeric.items():
|
||||||
|
if len(samples) < 2:
|
||||||
|
continue
|
||||||
|
cands = []
|
||||||
|
for off in range(0, stride - 3, 4):
|
||||||
|
for enc, fn in ENC.items():
|
||||||
|
agree, bad = 0, []
|
||||||
|
for rid, want in samples.items():
|
||||||
|
got = fn(run_cls[rid], off, rid in charging)
|
||||||
|
if math.isfinite(got) and near(got, want):
|
||||||
|
agree += 1
|
||||||
|
else:
|
||||||
|
bad.append((rid, want, got))
|
||||||
|
if agree >= 2:
|
||||||
|
cands.append((len(bad), -agree, off, enc, agree, bad))
|
||||||
|
if not cands:
|
||||||
|
continue
|
||||||
|
cands.sort()
|
||||||
|
nbad, _, off, enc, agree, bad = cands[0]
|
||||||
|
# A binding that contradicts a large slice of the evidence is not a
|
||||||
|
# binding at all -- it is a coincidence on the agreeing subset.
|
||||||
|
if len(bad) > 0.2 * len(samples):
|
||||||
|
continue
|
||||||
|
# Discriminating power: a field whose on-disc samples are all the same
|
||||||
|
# value matches any offset holding that constant, so such a binding is
|
||||||
|
# a coincidence waiting to happen. Count the distinct values that the
|
||||||
|
# *agreeing* records pin down.
|
||||||
|
distinct = len({round(v, 6) for rid, v in samples.items() if rid not in {b[0] for b in bad}})
|
||||||
|
solved[field] = (off, enc, agree, bad, distinct)
|
||||||
|
|
||||||
|
# Two fields cannot share one byte offset. Where the solver lands two on the
|
||||||
|
# same (offset, enc), keep the better-evidenced one and drop the other.
|
||||||
|
best_at = {}
|
||||||
|
for field, (off, enc, agree, bad, distinct) in solved.items():
|
||||||
|
key = (off, enc)
|
||||||
|
score = (distinct, agree, -len(bad))
|
||||||
|
if key not in best_at or score > best_at[key][1]:
|
||||||
|
best_at[key] = (field, score)
|
||||||
|
winners = {f for f, _ in best_at.values()}
|
||||||
|
return {f: v for f, v in solved.items() if f in winners}, numeric
|
||||||
|
|
||||||
|
|
||||||
|
def report(cls, disc_cls, run_cls, stride, out, charging):
|
||||||
|
solved, numeric = solve(disc_cls, run_cls, stride, charging)
|
||||||
|
p = out.append
|
||||||
|
p(f"\n### `{cls}` — runtime struct ({stride:#x} bytes/object)\n")
|
||||||
|
p("Confidence: ✅ = ≥10 disc records agree on ≥3 distinct values, none contradict.")
|
||||||
|
p("🟡 = consistent but thin evidence. ⚠️ = the runtime contradicts the disc "
|
||||||
|
"(listed below the table).\n")
|
||||||
|
p("| offset | enc | field | agree | distinct values | contradict | conf |")
|
||||||
|
p("|--------|-----|-------|------:|----------------:|-----------:|------|")
|
||||||
|
contradictions = []
|
||||||
|
for field, (off, enc, agree, bad, distinct) in sorted(solved.items(), key=lambda kv: kv[1][0]):
|
||||||
|
if bad:
|
||||||
|
conf = "⚠️"
|
||||||
|
contradictions.append((field, off, enc, bad))
|
||||||
|
elif agree >= 10 and distinct >= 3:
|
||||||
|
conf = "✅"
|
||||||
|
else:
|
||||||
|
conf = "🟡"
|
||||||
|
p(f"| `+{off:#05x}` | {enc} | `{field}` | {agree} | {distinct} | {len(bad)} | {conf} |")
|
||||||
|
|
||||||
|
unsolved = sorted(set(numeric) - set(solved))
|
||||||
|
if unsolved:
|
||||||
|
p(f"\nNumeric fields with no consistent offset (unsolved): "
|
||||||
|
f"{', '.join('`'+u+'`' for u in unsolved)}")
|
||||||
|
|
||||||
|
for field, off, enc, bad in contradictions:
|
||||||
|
p(f"\n**`{field}` (`+{off:#05x}`) — runtime disagrees with disc on "
|
||||||
|
f"{len(bad)} record(s):**\n")
|
||||||
|
for rid, want, got in sorted(bad)[:24]:
|
||||||
|
p(f"- `{rid}`: disc `{want:g}` → runtime `{got:g}`")
|
||||||
|
|
||||||
|
p(f"\n#### `{cls}` values the disc defaults, read from the live objects\n")
|
||||||
|
for field, (off, enc, agree, bad, distinct) in sorted(solved.items(), key=lambda kv: kv[1][0]):
|
||||||
|
if bad or agree < 10 or distinct < 3:
|
||||||
|
continue # only report from ✅ bindings
|
||||||
|
missing = [
|
||||||
|
(rid, ENC[enc](run_cls[rid], off, rid in charging))
|
||||||
|
for rid in sorted(disc_cls)
|
||||||
|
if rid in run_cls and field not in disc_cls[rid]
|
||||||
|
]
|
||||||
|
if not missing:
|
||||||
|
continue
|
||||||
|
vals = sorted({round(v, 6) for _, v in missing})
|
||||||
|
p(f"\n**`{field}`** (`+{off:#05x}`, {enc}) — defaulted by {len(missing)} of "
|
||||||
|
f"{len(run_cls)} records")
|
||||||
|
if len(vals) == 1:
|
||||||
|
p(f"\n> all = **{vals[0]:g}**")
|
||||||
|
else:
|
||||||
|
p("")
|
||||||
|
for rid, v in missing:
|
||||||
|
p(f"- `{rid}` = **{v:g}**")
|
||||||
|
return solved
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
tokens = sys.argv[1] if len(sys.argv) > 1 else "/tmp/wep_tokens.txt"
|
||||||
|
disc, ambiguous = read_tokens(tokens)
|
||||||
|
path = gmem.mem_path()
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
objs, order = runtime_objects(f, size)
|
||||||
|
|
||||||
|
# `IsCharging = Yes` switches the two counter fields to float32; the Shell
|
||||||
|
# inherits the flag from its Weapon (same ID suffix).
|
||||||
|
charging = {
|
||||||
|
rid for rid, f in disc.get("Weapon", {}).items() if f.get("IsCharging") == "Yes"
|
||||||
|
}
|
||||||
|
charging |= {"Shell" + rid[len("Weapon"):] for rid in set(charging)}
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for cls, stride in [(c, s) for c, s in CLASSES.values()]:
|
||||||
|
d, r = disc.get(cls, {}), objs.get(cls, {})
|
||||||
|
matched = set(d) & set(r)
|
||||||
|
out.append(f"\n<!-- {cls}: {len(r)} runtime objects, {len(d)} disc records, "
|
||||||
|
f"{len(matched)} matched by ID -->")
|
||||||
|
report(cls, d, r, stride, out, charging)
|
||||||
|
if "--csv" in sys.argv:
|
||||||
|
import csv
|
||||||
|
|
||||||
|
w = csv.writer(sys.stdout)
|
||||||
|
w.writerow(["class", "id", "field", "offset", "enc", "value", "source", "conf"])
|
||||||
|
for cls, stride in [(c, st) for c, st in CLASSES.values()]:
|
||||||
|
d, r = disc.get(cls, {}), objs.get(cls, {})
|
||||||
|
solved, _ = solve(d, r, stride, charging)
|
||||||
|
for field, (off, enc, agree, bad, distinct) in sorted(
|
||||||
|
solved.items(), key=lambda kv: kv[1][0]
|
||||||
|
):
|
||||||
|
conf = "confirmed" if (not bad and agree >= 10 and distinct >= 3) else "tentative"
|
||||||
|
for rid in sorted(r):
|
||||||
|
val = ENC[enc](r[rid], off, rid in charging)
|
||||||
|
src = "disc" if field in d.get(rid, {}) else "defaulted-on-disc"
|
||||||
|
w.writerow([cls, rid, field, f"{off:#05x}", enc, f"{val:g}", src, conf])
|
||||||
|
return
|
||||||
|
|
||||||
|
if ambiguous:
|
||||||
|
out.append("\n### Disc records declared twice, with conflicting values\n")
|
||||||
|
out.append("These fields are ambiguous *on disc*; the runtime shows which "
|
||||||
|
"declaration won. They are excluded from the scoring above.\n")
|
||||||
|
for (cls, rid), fields in sorted(ambiguous.items()):
|
||||||
|
out.append(f"- `{cls}` `{rid}`: {', '.join('`'+f+'`' for f in fields)}")
|
||||||
|
print("\n".join(out))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user