Files
firestorm/MW4COMPARE/DECOMPILING.md
T
83478b7666 Add MW4COMPARE: .mw4 decompiler toolchain and V4H comparison harness
Tooling built to recover editable source for six 'Mech chassis that exist
in the parallel FS_Build_V4H build but not in this repo. Reverse-engineers
every compiled record type in the .mw4 package format back to the .data /
.instance / .subsystems / .damage / .contents / .torso / .engine /
.armature sources the content pipeline consumes.

Nothing here is wired into the game build. It is a standalone analysis
harness run from Linux.

Package format
--------------
"#VBD" container. Directory records are [len][name][FILETIME][origSize]
[storedSize][offset], payload base at dword 0x0C. A record is stored raw
when storedSize == origSize, otherwise LZW (9->12-bit LSB-first codes,
256=clear, 257=EOF, dict from 258), per Database.cpp:451.

GameModel records are flat /Zp4 structs following the C++ inheritance
chain Entity(0) -> Mover(28) -> MWObject(80) -> Vehicle(664) -> Mech(756),
1636 bytes total. CreateMessage records follow Replicator -> Entity ->
Mover -> MWMover -> MWObject -> Vehicle -> Mech from start=16 (the
undeclared Connection__Message header), ending at 341 and padded to 344.

tools/decompile/
----------------
  datamap.py        header-driven layout engine; CHAIN + ANCHORS
                    {Vehicle:664, Mech:756} assert the struct offsets
  mw4msg.py         CreateMessage reader/walker
  data.py           .data      constants.py  define/table symbol resolution
  damage.py         .damage    contents.py   .contents
  smallmodel.py     .torso + .engine         instance.py  .instance
  armature.py / armature_parts.py  .armature + armaturedata/armaturevideo
  assembly.py       joint hierarchy renderer
  make_generic_doll.py  builds generic MFD/Radar damage dolls
  verify_*.py       per-type round-trip verifiers

Verified round-trip across all 64 shared chassis:
  .armature      2938/2976 pages     .subsystems  7579/7585 keys
  .data map      6071/6071 values    .data trip   8291/8306 keys
  .damage        6605/6605 keys      .contents    7480/7480 keys
  .torso+.engine 1280/1280 keys      .instance     896/896 keys, 64/64 pages
  armature_parts 1202/1202 .data, 1149/1202 .video

Layout-discovery lessons (documented in DECOMPILING.md)
-------------------------------------------------------
- Never let a field map be discovered by the values that verify it. A
  value-matching pass reported 4288/4288 while mis-assigning 34 keys. The
  map was rebuilt from header declaration order, anchored on uniquely
  resolved fields.
- Read the factory, not the data. 12 .data fields and 5 Torso fields are
  declared plain Stuff::Scalar but multiplied by Radians_Per_Degree in
  Mech_Tool.cpp:889 / Torso_Tool.cpp.
- Strip typedefs before walking a header. A stray `typedef int AttributeID;`
  masked a missing ClassID - two 4-byte errors cancelling out, caught only
  by the ANCHORS assertion.
- A verifier that silently narrows its own input reports success. Braced
  blocks must be hidden before splitting pages, replacing both CR and LF,
  because a `Shadow={...}` block contains a line reading `[shadow]` and
  splitlines() also splits on bare CR.
- NSWIZZLE is undefined, so the #else branch is live and orders members
  differently. bool is 1 byte; char x[MaxStringLength] is 256.
- V4H carries stale Mech IDs (their Atlas is 5, ours 6), so 64 of 65 shared
  chassis are off by one; --retarget-ids emits $(M_<Chassis>)/$(IDS_<Chassis>).

reports/ holds generated diffs. The two ~5 MB manifest-*.tsv intermediates
are gitignored; regenerate everything with run-comparison.sh.

Co-authored-by: Claude Opus 5 (Anthropic) <noreply@anthropic.com>
Co-authored-by: GitHub Copilot <copilot@github.com>
2026-08-08 16:47:57 -05:00

1171 lines
58 KiB
Markdown

# Decompiling MW4 packed records back to source
How to turn the compiled records inside a `.mw4` package back into the text source files the
content pipeline consumes -- what has been solved, how it was proved, and exactly where to
pick up.
Written after a long investigation; the point of this document is that **none of it should
need rediscovering**. Everything below was measured, not inferred.
> **Status -- all eight mech record types decompiled and verified**
> `.armature` 2,938 / 2,976 pages (65 chassis; the 38 residuals are data the packer discarded).
> `.subsystems` 7,579 / 7,585 keys (63 chassis; 6 residuals are a later source edit).
> `.data` 8,291 / 8,306 keys, `.damage` 6,605 / 6,605, `.contents` 7,480 / 7,480,
> `.instance` 896 / 896, `.torso` + `.engine` 1,280 / 1,280 -- all over our own 64 chassis.
> Every residual is classified in the section for its type; none is an unexplained decoder bug.
---
## 1. Why this is needed
A `.mw4` is **not** an archive of `Content/`. Measured across our own 102 packages
(51,382 records):
| | count |
|---|---:|
| records byte-identical to a source file | 22,138 |
| records the packer generated or rewrote | 28,728 |
The decisive example: our source `Content/Mechs/Atlas/atlas.data` is **4,891 bytes of text**.
The record packed under that name is a **12-byte binary stub** -- the real payload went into
`atlas.data{GameModel}` and `atlas.data{Element}`.
So to port V4H's six new chassis (Champion, Dasher, Griffin, Jenner IIC, Marauder,
Thunderbolt) we cannot simply copy records into `Content/` and repack. The definition files
have to be reconstructed from the compiled form.
### Which record types are which
| verdict | extensions |
|---|---|
| **verbatim source** -- copy straight into `Content/` | `.tga` `.erf` `.mw4anim` `.bid` `.wav` `.bsp` `.material` `.abl` `.obb` `.ebf` `.bounds` `.animscript` `.fgd` `.tcf` `.mlr` `.d3f` `.gaf` `.script` `.h` `.abi` |
| **compiled** -- must be decompiled | `.data` `.instance` `.subsystems` `.damage` `.torso` `.engine` `.audio` `.video` `.lights` `.mw4`, and every `{hint}` `{handle}` `{gamemodel}` `{element}` `{footsteps}` `{zones}` `{nametable}` `[shadow]` `[joint_*]{armature}` `[joint_*]{sites}` record |
Two qualified records are an exception -- they are real `.obb` files (`#BBO` magic, headers
byte-identical to ours) and only need renaming:
```
X.data{solidobb} -> X_skeleton_SOLID.obb (under Content/Mechs)
X.data{hierarchicalobb} -> X_skeleton.obb
-> X_SOLID.obb / X.obb elsewhere
```
`tools/split-source.py` already performs that classification and rename.
---
## 2. The pipeline, and where the engine source lives
```
Content/<x>.subsystems (text NotationFile, [Page] + Key=Value)
|
| MWTool::BuildResource mw4/Code/MW4/MWTool.cpp:142
v
MWObject::CreateSubsystemStream mw4/Code/MW4/MWObject_Tool.cpp:1196
|
| per [Page]: Tool::ConstructCreateMessage mw4/Libraries/Adept/Tool.cpp:680
| -> script.classData->messageFactory
v
one of 51 per-class factories mw4/Code/MW4/*_Tool.cpp
| e.g. Armor_Tool.cpp, MWMover_Tool.cpp
v
a plain C struct (/Zp4) appended to the stream
```
The struct definitions are **in our own headers** -- `Armor__CreateMessage` in `Armor.hpp`,
`MWMover__CreateMessage` in `MWMover.hpp`, `Mover__CreateMessage` in `Adept/Mover.hpp`,
`Entity__CreateMessage` and `Replicator__CreateMessage` in `Adept/Entity.hpp` /
`Adept/Replicator.hpp`. There is no reverse-engineering to do; we have both the writer and
the reader.
Useful entry points when adding a new type:
| record type | writer |
|---|---|
| `.subsystems` | `MWObject::CreateSubsystemStream`, `MWObject_Tool.cpp:1196` |
| `.armature` | `MWObject::CreateArmatureStream`, `MWObject_Tool.cpp` (just above) |
| `.damage` | `MWObject::CreateDamageStream` -> `MWInternalDamageObject::ConstructMWInternalDamageObjectStream` |
| `[joint_*]{armature}` / `{sites}` | `MWMover__CreateMessage::ConstructCreateMessage`, `MWMover_Tool.cpp:78-84` |
| registration of packable types | `MWTool::IsRegisterable`, `MWTool.cpp:38` |
---
## 3. Stream format
Both `CreateSubsystemStream` and `CreateArmatureStream` write:
```
WORD span number of replicator IDs consumed
N x CreateMessage one per serialised [Page]
```
`span` equals the message count and, for `.subsystems`, the source `[Page]` count.
**Verified: all 65 chassis walk from offset 2 to exactly EOF with no slack.**
### Verified message offsets
```
off 0 u32 messageLength <- walk the chain with this
off 4 u32 messageID
off 8 u32 priority
off 12 u32 messageFlags
off 16 u32 classID <- see the table in section 9
off 20 u32 replicatorFlags
off 24 u32 replicatorID
off 28 12f localToParent rotation = cols 0..2, translation = col 3 of each row
off 76 u32 executionState
off 80 f32 initialAge
off 84 u32 dataListID ResourceID; the package record id is the HIGH word
off 88 u32 alignment
off 92 u32 nameID
--- Mover__CreateMessage ---
off 96 24B worldSpaceVelocity (Motion3D)
off 120 24B worldSpaceAcceleration (Motion3D)
--- MWMover__CreateMessage ---
off 144 u32 siteStreamResourceID
off 148 u32 armatureStreamResourceID
off 152 char jointName[128]
```
**How the layout was confirmed:** a joint message is exactly **280 bytes**, and
`152 + 128 = 280` accounts for every byte. Independently, `dataListID` in Atlas's `[Armor]`
message resolves to record **884**, which is exactly the `core.mw4` record id of
`subsystems\armor.data`.
> `ResourceID` is packed -- the record id is the **high word**, so use `value >> 16`. An early
> byte-granular search found 884 at byte offset 86; that was a coincidental overlap with the
> real field at 84.
### `{sites}` records
A flat array, no message header (`MWMover_Tool.cpp` ~145:
`site_stream << rotation; << translation; << site_name;`):
```
repeat:
3 x f32 YawPitchRoll, in RADIANS
3 x f32 Point3D translation
u32 name length
bytes name
u8 NUL <- names are length-prefixed AND NUL-terminated
```
Forgetting the trailing NUL desynchronises everything after the first entry. That was the
one-byte bug that made the second site decode as garbage.
---
## 4. The test harness -- why any of this is trustworthy
We hold **both the source text and the compiled record for 65 chassis**. That gives a
closed-loop check:
1. Decompile our own packed record.
2. Diff against the known source text. *(this is what is automated today)*
3. Recompile the regenerated text with the real VC6 packer and compare bytes. *(not yet
automated -- needs the Windows build machine)*
Never add a record type without wiring it into step 2 first.
```bash
python3 MW4COMPARE/tools/decompile/armature.py --verify
```
---
## 5. `.armature` -- solved
### How it is stored
The packer merges `<mech>.armature` into `<mech>.contents` via `!include=`, then for every
contents page that has `Child=` entries emits two records:
| record | holds |
|---|---|
| `<mech>.contents[<joint>]{sites}` | children named `site_*` but **not** `site_eye*` |
| `<mech>.contents[<joint>]{armature}` | every other child (joints, and `site_eye*`), as CreateMessages carrying `jointName` + `localToParent` |
Between them they hold every page of the original `.armature`.
### Algorithm
1. Read all `{sites}` records **first**, then all `{armature}` records.
2. `{sites}`: each entry is a page -- name, rotation (radians -> degrees), translation. Append
the name to its parent's child list.
3. `{armature}`: each message is a page -- `jointName`, translation from the matrix, rotation
`(0,0,0)` when the matrix is the identity, otherwise via `ypr_from_matrix`.
4. Emit depth-first, children before the joint that owns them.
### Results
| | |
|---|---|
| chassis rebuilt | **65** |
| pages compared | **2,976** |
| fully exact | **2,938** |
| child lists exact | **1,849 / 1,849** |
| residual | 38 |
Sample, Atlas `[site_cageright]`:
| | rotation | translation |
|---|---|---|
| decompiled | `0.000000 -90.000003 0.000000` | `-0.650000 -0.499999 1.150000` |
| source | `0.000000 -90.000000 0.000000` | `-0.650000 -0.499999 1.150000` |
The 3e-6 is float32 rounding of a value the source stored as text.
### The 38 residuals are packer-side losses, not decoder bugs
The game reads the package, not the source, so **it never sees this data either**. Our
regenerated source is runtime-faithful.
* **36** -- `site_lfoot` / `site_rfoot` rotation on 18 chassis. `MWMover_Tool.cpp` deliberately
writes those two sites to *both* streams (the comment reads *"Jerry this will get deleted
when you fix your foot problem"*); on those chassis both copies came out zeroed. The value
is ~90 degrees on every chassis where it did survive, so it can be restored by convention if ever
needed.
* **2** -- Victor's `.armature` declares `[site_lshellport]` and `[site_rshellport]` **twice**,
under different joints. `GetPage()` resolved both `{sites}` records to the first page.
Confirmed by reading both records directly: they hold identical transforms, so the second
page's values are genuinely absent from the package.
### Three traps, all now handled
1. **Order matters.** Read `{sites}` before `{armature}`. Alphabetically `{armature}` sorts
first, and if it wins, the foot sites get the zeroed matrix instead of the real angle.
2. **Do not double-count.** `site_lfoot` / `site_rfoot` appear in both streams; counting both
doubles them in the parent's child list.
3. **Key pages per occurrence, not by name.** Victor legitimately has two
`[site_lshellport]` pages and the Annihilator lists `site_lfoot` twice under one parent. A
`dict[name]` collapses them.
### Output
`.armature` files generated for all six new chassis into
`FS_Build_V4H_extracted/Content/Mechs/*/`:
| chassis | pages |
|---|---:|
| champion | 64 |
| dasher | 58 |
| griffin | 67 |
| jenner2c | 53 |
| marauder | 64 |
| thunderbolt | 59 |
---
## 6. Values are invertible -- worked example
Atlas `[Armor]`, class 1201, message length 152. The last 44 bytes are nine `Scalar`s then
`m_armorType` and `m_internalType` (`Armor.hpp`):
| | compiled | source | ratio |
|---|---:|---:|---:|
| LeftLeg | 68.80 | 2.15 t | 32.0000 |
| RightLeg | 68.80 | 2.15 t | 32.0000 |
| LeftArm | 62.40 | 1.95 t | 32.0000 |
| RightArm | 62.40 | 1.95 t | 32.0000 |
| LeftFrontTorso | 78.40 | 2.45 t | 32.0000 |
| RightFrontTorso | 78.40 | 2.45 t | 32.0000 |
| CenterFrontTorso | 70.40 | 2.20 t | 32.0000 |
| CenterRearTorso | 35.20 | 1.10 t | 32.0000 |
| Head | 9.60 | 0.30 t | 32.0000 |
`Armor_Tool.cpp` multiplies tons by `m_pointsPerStandardTon` from `Subsystems\Armor.data`,
selected by `m_armorType`. So `tons = points / multiplier`, exactly -- but the multiplier
depends on armour type (Standard / FerroFiberus / Reactive / Reflective / Solarian), so read
it from the referenced `.data` rather than hardcoding 32.
`.data` values survive too: Atlas `VehicleTonnage=28.0` sits at offset 108 of
`atlas.data{GameModel}` and `MaxVehicleTonnage=100.0` at 112, adjacent and in declaration
order.
---
## 7. Recipe for adding the next record type
1. Find the writer (see the table in section 2) and read what it emits, in order.
2. Read the message struct in the corresponding `*.hpp`. Fields are laid out in declaration
order after the base classes, `/Zp4`, no padding surprises so far.
3. Check the arithmetic: `sizeof(base) + own fields` must equal the observed `messageLength`.
This is the fastest way to confirm a layout -- it caught the 280-byte joint message and the
108-byte Subsystem message immediately.
4. Write the decoder against `mw4msg.walk()`.
5. **Wire it into a verifier over all 65 chassis before trusting a single value.**
6. Expect residuals; classify each one as *decoder bug*, *packer-side loss*, or *source newer
than the package* before dismissing it. Every residual so far has been one of the last two,
but only because each was chased down.
Two habits that paid off repeatedly:
* **Derive enums empirically, then check them against the engine's text tables.** Walking the
aligned page/message pairs and tabulating `int -> source string` produced the
ExecutionState, locationID, ArmorType and InternalType maps in one pass, and simultaneously
proved the field offsets were right.
* **Read the factory, do not infer from the data.** `GroupIndex` looked like a plain integer
and decoded plausibly for most chassis; only `Weapon_Tool.cpp` reveals it is a bitmask that
can carry several groups. The same rule cracked the `.data` angle fields: sixteen of them are
declared plain `Stuff::Scalar`, and *only* `Mech_Tool.cpp` shows the writer multiplying them
by `Radians_Per_Degree`.
* **Never let a field map be discovered by the same values that verify it.** Searching for the
offset whose float equals the source value looks rigorous and reports 100%, but it is
circular: any field holding an identical value in every chassis has several valid-looking
candidates, and a verifier built on the same principle cannot tell them apart. Locate fields
by declaration order, anchored on the few that *are* unique, and keep value matching for
confirmation only. See section 8b -- this mistake silently mis-assigned a third of the
`.data` fields while reporting a perfect score.
---
## 8. `.subsystems` -- solved
### Results
| | |
|---|---|
| chassis rebuilt | **63** |
| pages compared | **1,729** -- fully exact **1,723** |
| keys compared | **7,585** -- exact **7,579** |
| residual | 6 |
The 6 residuals are Behemoth and Behemoth II `GroupIndex`, 3 each: commit `e45a67a8`
(`mfdsplit`) moved their Gauss rifles from weapon group 3 to group 1, and `core.mw4` has not
been repacked since. The decompiler reads 3, which is what the package genuinely holds.
battlemaster and battlemaster2c are skipped entirely for the same reason (41 source pages vs
32 packed messages).
### Field map
Beyond the Entity header, from `Subsystem.hpp`, `Weapon.hpp`, `Armor.hpp`, `AMS.hpp`:
```
off 96 i32 subsystemIndex
off 100 u8 locationID -> InternalLocation
off 104 i32 criticalHitsTaken -> CriticalHitsTaken
Armor (len 152)
off 108 9xf32 armour POINTS -> tons = points / pointsPerTon
off 144 i32 m_armorType -> ArmorType
off 148 i32 m_internalType -> InternalType
Engine (len 112, class 1073)
off 108 i32 m_engineUpgrades -> EngineUpgrades
LAMS / AMS (len 112, class 1183)
off 108 i32 ammoCount -> AmmoCount (-1 means absent)
SearchLight (len 236)
off 108 char[128] siteName -> Site
Weapon (len 380, or 376 without the last field)
off 108 char[128] siteName -> Site
off 236 char[128] ejectSiteName -> EjectSite
off 364 i32 groupIndex -> GroupIndex (BITMASK - see below)
off 368 i32 ammoCount -> AmmoCount (-1 means absent)
off 372 i32 initialAmmoCount
off 376 i32 m_weaponFacing -> WeaponFacing (absent when len == 376)
```
Enum tables, derived empirically from the aligned pairs and cross-checked against the engine's
own text functions:
| field | values |
|---|---|
| ExecutionState | 1 NeverExecuteState, 2 AlwaysExecuteState, 6 ActiveState |
| locationID | `InternalDamageObject` enum, `DamageObject.hpp:205`: 0 LeftLeg .. 7 Head, 8 Special1, 9 Special2 |
| ArmorType | 0 Standard, 1 FerroFiberus, 2 Reactive, 3 Reflective, 4 Solarian |
| InternalType | **separate 2-value enum** (`Armor.hpp:275`): 0 Standard, 1 EndoSteel |
Armour is stored in **points**, not tons. Divide by the multiplier for the armour type, read
from `Content/Subsystems/Armor.data`: Standard 32, Ferro 38, Reactive 30, Reflective 30,
Solarian 60. Confirmed empirically -- every chassis inverts to the exact source tonnage.
### Four traps
1. **`GroupIndex` is a bitmask, not an index.** `Weapon_Tool.cpp` ~76 starts from
`DefaultWeaponGroupFlags` and ORs `1 << (n-1)` for *each* `GroupIndex=` line in the page. So
a stored 4 means group 3, and a page may legitimately list several groups. Emit one line per
set bit.
2. **`m_weaponFacing` is optional.** V4H packed champion/griffin/marauder at 376 bytes without
it -- matching their release note *"Any new mech will not have rear facing weapons"* --
while dasher, jenner2c and thunderbolt have it at 380. Read both; our packer always emits
380.
3. **`Model=` is relative to the mech's own directory** in the source but absolute in the
package. Strip the prefix using the **parent folder name**, not the file name: Black Hawk's
chassis files are `nova.*` inside `mechs/blackhawk/`.
4. **Page names are not stored.** The packer serialises order only, so names are regenerated
from `Model=` with a per-kind counter. They are labels; the runtime keys on order.
### Was the index space disturbed by V4H's extra mechs?
No, and this was checked rather than assumed:
* V4H's `core.mw4` adds **no** entries under `subsystems/`, `weaponsubsystems/`, `weapons/`,
`tables/` or `effects/` -- all five sets are identical to ours. Every V4H-only entry is under
`mechs/<one of the six>`.
* For the 47 chassis both builds contain, the **`(classID, messageLength)` sequence is
byte-for-byte identical**. Class IDs did not shift.
* Only 19 distinct class IDs appear across both builds, and the only length variation is the
optional `m_weaponFacing` tail described above.
---
## 8b. `.data{GameModel}` -- numeric field map solved
The mech `<chassis>.data{GameModel}` record is a flat **1636-byte** struct, not a CreateMessage
stream: `Mech__GameModel` inheriting `Vehicle__GameModel` <- `MWMover__GameModel` <-
`Mover__GameModel` <- `Entity__GameModel`. Every scalar member is a 4-byte float, `/Zp4`, laid
out in declaration order.
### Results
chassis : 64
mapped keys : 74
values compared : 4736 exact: 4736 wrong: 0
That covers every numeric `[GameData]` key. The 52 string, boolean, enum and resource-reference
keys are **not yet decoded** -- see section 11.
### How the layout was established, and the wrong turn that came first
The first attempt searched, for each source key, the set of offsets whose float equalled the
source value in all 64 chassis. It reported **4288/4288 exact** and was **wrong**.
The flaw: 34 keys hold the same value in every single chassis -- `dampenWorldJoint` and
`fallAdjustmentSeconds` are both `0.5` everywhere -- so each had 2 to 6 equally valid
candidates, and a verifier built on value matching cannot distinguish them. Breaking the ties by
the order keys appear in the source file gave a plausible, self-consistent, perfect-scoring map
that put `TiltSpeed` at 672 (really `slopeDecel2`) and swapped `PercentageOfTurnToStartTilt`
with `PercentageOfSpeedToStartTilt`. On our 64 chassis it is undetectable. On a **new** mech
whose values differ it silently corrupts the field.
The map is therefore built from **declaration order in the headers**, anchored on the fields
that value matching resolved to exactly one offset:
| Block | Header | Base | Anchors that confirm it |
|---|---|---|---|
| `Vehicle__GameModel` | `mw4/Code/MW4/Vehicle.hpp` | 664 | `minSpeed` 692, `maxSpeed` 696, `acceleration` 720, `decceleration` 724, `reverseAccelerationMultiplier` 728 |
| `Mech__GameModel` | `mw4/Code/MW4/Mech.hpp` | 756 | `footReturnSeconds` 764, `dampenTorsoJoint` 800, `undampenRootJoint` 816, `undampenHipJoint` 824, `scaleInternalTiltDegree` 832 |
Field *i* of a block sits at `base + 4*i`. Every anchor lands exactly, in both blocks, which is
what makes the arithmetic trustworthy. This verification can genuinely fail -- a mis-parsed
header shows up instantly as a whole column of wrong values.
### Degrees vs radians
Sixteen keys decoded to exactly `1/57.2958` of their source value. `Stuff::Radian` members
(`tiltSpeed`, `tiltDegree`, `topSpeedTurnRate`, `fullStopTurnRate`) explain four of them. The
other twelve -- the `torso`/`hip`/`rootHitSpring*` family -- are declared **plain
`Stuff::Scalar`**; the conversion is applied by the writer, e.g. `Mech_Tool.cpp:889`:
model->torsoHitSpringMotionLimit = model->torsoHitSpringMotionLimit * Radians_Per_Degree;
So the degree set is scraped out of `Mech_Tool.cpp` / `Vehicle_Tool.cpp` rather than inferred
from the declared type. Decoding divides by `Radians_Per_Degree` to return authored units.
These twelve were among the "13 numeric keys with no consistent offset" under the value-matching
approach -- they had no matching offset precisely *because* they were scaled.
### Four tail fields, and what they proved
`MaxHeat`, `JumpJetTonnage`, `DamageNeededForCageEffect` and `AdvancedGyroTonnage` initially had
to be pinned by hand, because indexing scalars alone stops tracking the layout once non-scalar
members appear. They are now **derived**, and reproducing them was the test that validated the
whole layout engine. Two things had to be right:
* `char leftJumpJetSiteName[MaxStringLength], rightJumpJetSiteName[MaxStringLength];`
(`MaxStringLength = 256`, `Entity.hpp:199`) -- 512 bytes that a member parser misses if it only
accepts literal array sizes. Their absence put `maxHeat` at 1060 instead of 1572.
* **`bool` is one byte, not four.** The six `m_canLoad*` flags occupy 6 bytes padded to 8, not 24.
Get this wrong and everything after them is off by exactly 16.
With both fixed, the computed offsets are `maxHeat` 1572, `m_jumpJetTonnage` 1604,
`damageNeededForCageEffect` 1612, `m_advancedGyroTonnage` 1628 -- matching the four measured
values exactly. `datamap.layout()` now walks every member with `/Zp4` alignment
(`align = min(4, size)`) and no offset is hand-entered anywhere.
### The engine contains its own decompiler
`Entity__GameModel::SaveGameModel` (`Adept/Entity_Tool.cpp:864`) writes a `[GameData]` page back
out from a model. It does **not** hardcode key names -- it walks the class's
`gameModelAttributeTable` and calls `class_data->modelWriteToText(model, entry, &data)` per
attribute. So the authoritative key/member/type binding is the attribute table, populated by
DIRECT_GAME_MODEL_ATTRIBUTE(class, AttributeName, memberField, type) // Entity.hpp:1315
343 registrations exist overall; **120 are in the mech chain** (Entity 5, Mover 7, MWMover 0,
Vehicle 21, Mech 87). The whole type vocabulary is: `Scalar` (201), `ResourceID` (54), `bool`
(21), `int` (20), `Point3D` (10), `const char *` (9), `Radian` (9), `Vector3D` (7), `RGBAColor`
(5), `UnitQuaternion` (2). Parse those macros and the name-to-member mapping stops being
guesswork -- which is what makes the remaining 52 keys tractable rather than a fishing exercise.
### The struct chain, computed rather than assumed
The real chain is `Entity -> Mover -> MWObject -> Vehicle -> Mech`. Two things had to be right
before it would compute:
* `MWMover__GameModel` has no declaration -- it is `typedef Adept::Mover__GameModel`
(`MWMover.hpp:173`), so it contributes nothing.
* `Entity__GameModel` declares **no base class**, and wraps its members in `#if NSWIZZLE`.
`NSWIZZLE` is defined nowhere in the tree, so the `#else` branch is live -- and the two
branches **order their members differently**, so taking the wrong one silently shifts
everything.
`chain_layout()` starts each block where the previous ended and checks itself against the two
independently measured anchors:
| block | computed base | size |
|---|---|---|
| `Entity__GameModel` | 0 | 28 |
| `Mover__GameModel` | 28 | 52 |
| `MWObject__GameModel` | 80 | 584 |
| `Vehicle__GameModel` | **664** (measured 664) | 92 |
| `Mech__GameModel` | **756** (measured 756) | 878 |
Mech ends at 1634, padding to the record's 1636. Both anchors are hit without being supplied,
which is the check that the member sizes and alignment rules are right; `chain_layout()` raises
if they ever disagree.
### Results
chassis : 64
mapped keys : 114
values compared : 5879 exact: 5879 wrong: 0
not comparable : 1339 (enum / resource text)
Reading is type-aware: floats, `int`, `bool` (one byte), `char[256]`, and `Point3D` /
`Vector3D` / `RGBAColor` / `UnitQuaternion` vectors. `VehicleBattleValue` was the field that
forced this -- as a float it decoded to `7.00649e-45`, the float reinterpretation of `int 5`.
### Dependencies remaining to finish `.data`
All resolved -- see section 8c.
---
## 8c. `.data` -- solved
### Results
chassis : 64
keys compared : 8306 exact: 8291 wrong: 14 missing: 1
deliberately omitted: 256 (unreadable by the engine)
`python3 verify_roundtrip.py` regenerates a whole `.data` from the compiled records for every
chassis and diffs it against the authored source. The comparison is semantic, not byte-exact:
NotationFile is order-independent, and **Windows path lookup is case-insensitive** -- our own
tree writes both `mechs\atlas_destroyed\...` and `Mechs\Atlas_Destroyed\...` for the same key,
which settles that no canonical-case recovery is needed.
The 15 residuals are understood and none is a decoder defect:
* **`SolidOBB` / `HierarchicalOBB`, 7 chassis (14 values).** These name a *source-side* `.obb`
file, and the packer stores only the qualified resource (`x.data{SolidOBB}`), not the filename.
Seven chassis used abbreviations (`Gla_`, `Hau_`, `Lon_`, `Owe_`, `pum_`, `MadCat2_`) that are
not derivable. Irrelevant in practice: we ship the `.obb` for a new chassis, so we choose the
name, and the key only has to match the file next to it.
* **`VehicleBattleValue`, 1 chassis.** Authored by 1 of 64. Emitting it everywhere would add a
key to 63 files that never had one, so rare keys are suppressed instead.
### Four keys are unrecoverable, and it does not matter
`BattleDamageRatio`, `BattleKillBonus`, `DragoonValue` and `VehicleTradeValue` appear in all 64
sources but are read by **nothing** in the engine -- no attribute registration, no factory, no
runtime reference. They never enter the package. They are authoring metadata; the decompiler
omits them deliberately rather than inventing values.
### Value sources
| kind | how | count |
|---|---|---|
| struct member | typed read via `chain_layout()` | 117 keys, 6071/6071 exact |
| `ResourceID` | record id is the HIGH word, `>> 16`, then the manifest | 1083/1083 exact |
| symbolic constant | int reversed via `constants.py` | 256/256 exact |
| factory-written | handled explicitly in `data.py` | the rest |
Symbolic tables: `M_*` and `Tech_*` from `Content/ShellScripts/MechLabHeaders.h`, `IDS_*` from
`Content/Defines/MissionLang.defines`, `MoveTypeFlag` from the anonymous enum at
`MWObject.hpp:136` (declaration order, *not* the order of the `stricmp` chain that reads it).
Values are not unique -- `IDS_FIRSTSKIN` and `IDS_WOLFHOUND` are both 501 -- so candidates are
kept as a list and disambiguated by chassis name.
### Keys the attribute table does not cover
`SaveGameModel` writes these outside the attribute table, so each needed its own factory read:
* `AnimationScript` -> `animScriptName`, a `char[256]` member; reproduces 64/64 exactly.
* `HeatManager` -> `heatManagerResource`, `FootEffectsFile` -> `footFallEffectsTable` (aliases:
the source key does not match the member name).
* `Shadow` -> byte-identical in all 89 mech `.data` files, emitted as a constant block.
* `CraterName` -> `m_craterID` holds `MString::GetHashValue` (`DeathEntity_Tool.cpp:34`), which
is **one-way**. All 64 chassis store the same hash, so the single authored value `crater01` is
recovered by constancy, not by inversion.
* `Class` and the four lighting flags -> constant across all 64.
* `DefaultFootStepTexture` / `FootStepTexture` -> the `{FootSteps}` record: `int material`
(-1 = default), `int length` **not counting the terminator**, the characters, a NUL, then a
one-byte isDefault flag. Material indices are the enum at `Adept.hpp:212`, where `NoMaterial`
is 0 (so 5 = BrownDirt, 12 = Snow).
Booleans are spelled inconsistently by key -- `Collider` and `CanBeShot` use `true`/`false`, the
`CanLoad*` flags use `Yes`/`No` -- so the spelling is learned per key from the corpus.
### A parsing bug that silently truncated every source
`Shadow={...}` contains a line reading `[shadow]`. A page scan that stops at the next `[` therefore
stopped **inside** the Shadow block, discarding every key after it -- `SplashDamageAmount`,
`SplashDamageRadius`, `SplashHeatAmount` and more -- from the comparison corpus. Nothing failed;
the harness simply never saw them, and reported a clean pass on a subset. Braced blocks are now
hidden before the page split, and both CR **and** LF must be replaced while doing so, because
`splitlines()` also splits on a bare CR.
Worth generalising: **a verifier that silently narrows its own input reports success.** The keys
appeared only when an unrelated fix made the parser see the rest of the file.
### V4H stores mech ids from an older roster
V4H's `MechLabHeaders.h` appends the six new mechs at 65-70 and agrees with ours on 0-64
(`M_Atlas = 6` in both). But the ids **stored in their compiled records** disagree: their Atlas
holds 5, and 64 of 65 shared chassis are off by one, with outliers (`Behemoth` and `Behemoth2`
both hold 1). Their packages were built against an earlier roster, so the stored integer is
stale.
This does not affect anything else -- V4H's Atlas decompiles with the correct `AnimationScript`,
`DeathEntityResource`, `VehicleTonnage` and `MaxSpeed`, which is how the pipeline was cleared of
suspicion. It affects only `MechID` and `NameIndex`.
Since both keys are authored as **symbols** rather than integers, `--retarget-ids` emits
`$(M_<Chassis>)` / `$(IDS_<Chassis>)` and lets the build resolve them, instead of reversing a
stale int into some other mech's name. It is opt-in: our own tree has legitimate aliases
(`blackhawk` is `nova`) that the rule would otherwise rewrite.
---
## 8d. `.damage` -- solved
### Results
chassis : 64
pages compared : 1188
keys compared : 6605 exact: 6605 wrong: 0
`python3 verify_damage.py` regenerates each `.damage` and compares page names, page order, key
sets and values against the authored source.
### Format
Unlike everything above this is **not** a CreateMessage stream and has no index: it is a bare
concatenation of variable-length objects. Parsing means walking forward, reading a `classID`, and
letting it decide what follows. `MWObject::CreateDamageStream` (`MWObject_Tool.cpp:1149`) iterates
the source pages in order and dispatches on whether a page carries a `DamageZone` entry.
Armour page, classID **468** (`DamageObject::ConstructDamageObjectStream`, `DamageObject.cpp:157`):
classID, baseArmorValue, currentArmorValue, scaleSplashDamage,
damageObjectName (MString), internalDamageZoneID, armorZone, damageLevel,
armorType, maxArmorValue, attachedToZone
Internal page, classID **1162** (`DamageObject.cpp:931` plus the MW4 subclass at
`MWDamageObject.cpp:88`):
classID, baseInternalDamage, currentInternalDamage, parentEntityName (MString),
damageMode, damageZone, damagePropagationZone, internalType, attachedTo,
damageEffects[count][resourceID, armorPercent],
missileSlots, projectileSlots, beamSlots, omniSlots
`MString` is an int length **not counting the terminator**, the characters, then a NUL -- the same
encoding as the `{FootSteps}` stream in section 8c.
### Three traps
* **The armour page stores its own name; the internal page does not.** Internal names are rebuilt
as `<Zone>Internal` from the `damageZone` that *is* stored. All 89 mech `.damage` files follow
that convention exactly (`LeftArmInternal`, `CenterTorsoInternal`, `Special1Internal`).
* **`ArmorZone` and `InternalZone` are different enums.** `ArmorZone` (`DamageObject.hpp:363`) has
`CenterRearTorso` at 7 and `Head` at 8; `InternalZone` (`DamageObject.hpp:204`) has `Head` at 7
and no rear-torso entry. Conflating them silently mislabels head and torso zones -- the values
differ by one exactly where it is least obvious.
* **Defaults are indistinguishable from explicit values.** The writer defaults
`max_armor_value = base_armor_value` and `damage_mode = GeneralDamageMode`. No source writes
`GeneralDamageMode` explicitly, so a zero there is safely emitted as "omitted". `MaxArmorValue`
is *not* safe: 32 pages omit it while **51 pages state it explicitly equal to
`BaseArmorValue`**, and both produce identical bytes. The distinction is unrecoverable, so the
decompiler always writes it and the verifier accepts either form.
---
## 8e. `.contents` -- solved
### Results
chassis : 64
pages compared : 3740
keys compared : 7480 exact: 7480 wrong: 0
`.contents` is the thin half of the pair section 5 already handles. It `!include`s
`<chassis>.armature` and gives every joint and site exactly two entries:
[joint_torso]
Model=basic.data
ExecutionState=AlwaysExecuteState
Nothing new had to be located. Both values ride in the **same CreateMessages** `armature.py`
walks -- the `.armature` source contributes a page's geometry, the `.contents` source
contributes its Model and ExecutionState, and the packer merges them into one message per page.
`Model` is `dataListID` (record id in the high word), `ExecutionState` the enum at offset 76.
### Three things to know
* **`Model=` is written relative to the mech folder.** The manifest returns
`mechs\annihilator\armaturedata\ann_rfoot.data`; the source says
`armaturedata\ann_rfoot.data`. Strip the `mechs\<chassis>\` prefix. This alone accounted for
all 1124 initial mismatches.
* **Site pages carry no Model in the package.** `{sites}` records store only name, rotation and
translation. They do not need to: all **2926** site pages across the 89 mech `.contents` files
carry the identical pair `basic.data` / `AlwaysExecuteState`, so they are emitted as constants
rather than guessed.
* **Which sites had a `.contents` page is not recoverable.** `.contents` declares a *subset* of
the sites in `.armature` -- `site_eject2` appears in 89 `.armature` files but only 8
`.contents`. Emitting every site over-emits ~2.4 pages per chassis (152 total); emitting only
the `{armature}` messages would lose ~21 per chassis. The former is far closer and harmless,
since the added pair is the same default every other site carries.
12 site pages across all 64 chassis appear in the source but in no record at all, and are lost.
### Name consistency is on us
The package stores neither the `.obb` filenames nor the `.armature` filename, so the decompiler
chooses them -- and they must agree with the files actually shipped. V4H's Jenner IIC exposed
this: its records use the stem `jenner_2c` while its folder is `jenner2c`, so a chassis-derived
name produced `SolidOBB=jenner2c_Skeleton_SOLID.obb` next to a file called
`jenner_2c_skeleton_SOLID.obb`, and a `!include=jenner_2c.armature` next to `jenner2c.armature`.
`data.py` now reads the `.obb` names from the output directory rather than inventing them, and
the generated set is checked so that every referenced file exists.
---
## 8f. `.torso` and `.engine` -- solved
### Results
torso files : 64
engine files : 64
keys compared : 1280 exact: 1279 wrong: 1
Both are single-page `[GameData]` subsystem models stored as flat structs, so they reuse
`datamap.chain_layout()` with their own chains. Both compute to **exactly** the record size with
no anchoring, which is the check that the member list and alignment are right:
| model | chain | computed | record |
|---|---|---|---|
| Torso | `Entity -> Subsystem -> Torso` | 1608 | 1608 |
| Engine | `Entity -> Subsystem -> Engine` | 64 | 64 |
Sources author the numbers as `$(SYMBOL)` macros from an `!include`d defines file -- note these
use **`!NAME=value`** syntax, not `#define` like `MissionLang.defines` -- and the record keeps
only the resolved float, so the symbol is restored by reverse lookup where one matches.
* The five Torso angles are declared plain `Stuff::Scalar` but `Torso_Tool.cpp` multiplies them
by `Radians_Per_Degree`, exactly like the Mech spring fields in section 8b.
* `TotalCritLocations` is read by **nothing**: only the 3DS Max exporter writes it, and the
factory reads `TotalSlotsTaken`, which no source sets. The record holds the default 1 while
every source says 2. It is emitted as the constant it always is.
* The engine's `Class` is `Mechwarrior4::Engine` -- lowercase 'w', in all 89 sources. Preserve
the typo.
### A latent typo, and why the fix was to write the value down
`cauldronborn.torso` said `TwistRadius=$(OBSTUSE_TRADIUS)` -- a typo for `OBTUSE`, defined
nowhere, so `Torso_Tool.cpp:137` fell back to its default `100.0f`. The source therefore said
one thing and the game did another.
**100 is the intended value**, confirmed by the project owner, so the fix was *not* to correct
the symbol to `$(OBTUSE_TRADIUS)` (which would have silently changed the Cauldron Born's twist
radius from 100 to 140). It is now the literal `TwistRadius=100` -- behaviour-preserving, and
matching the 25 other `.torso` files that write a literal rather than a symbol. There is no
symbol for 100 in `MechTorso.defines`; inventing one for a single user was not worth it.
`verify_smallmodel.py` reports **1280/1280** with the source and package now agreeing, which is
itself the evidence that 100 was the operative value all along.
A sweep of every `$(SYMBOL)` in all mech sources against all defines found no other undefined
macro. The general lesson: an undefined macro does not fail loudly here -- the factory quietly
substitutes a default, so the source can drift from the shipped behaviour indefinitely. Check
what the package actually holds before "fixing" a symbol.
---
## 8g. `.instance` -- solved
### Results
chassis : 64
page names : 64/64 match
keys compared : 896 exact: 896 wrong: 0
One page named after the chassis, holding the model/armature/subsystem/damage references and the
mechlab bar ratings. The record is a single `Mech__CreateMessage`, so the layout comes from the
**CreateMessage** chain rather than the GameModel one:
Replicator -> Entity -> Mover -> MWMover -> MWObject -> Vehicle -> Mech
`chain_layout(..., start=16)` computes it; the 16 is the `Connection__Message` header
(messageLength, priority, flags) which sits in front and is declared in none of these classes.
The result ends at 341 and pads to exactly the 344-byte record, and **every offset established
independently back in section 3 lands on the nose** -- classID 16, replicatorID 24,
localToParent 28, dataListID 84, alignment 88, jointName 152. That is six independent
confirmations from a layout that was never told about any of them.
Two parser gaps had to be closed first, both fields typed with names the member regex did not
know: `Stuff::RegisteredClass::ClassID` and `ReplicatorID` in the Replicator base, and
`Entity__ExecutionStateEngine::FactoryRequest` and `ObjectID` in Entity. Missing them shifted
everything after offset 76 by 8 bytes **while still producing a plausible-looking table** -- the
known offsets were what caught it.
References are written relative to the mech folder, same as `Model=` in section 8e.
`CollideeType` and `CollisionMask` are not emitted: they appear in 5 of 64 sources, are
uniformly `Zone` / `-1`, 11 of their occurrences are commented out, and no factory reads them --
only `TCTb`, `InterestBSP` and the old `MW4GameEd` ever write them.
---
## 9. Reference tables and package baseline
### classID map
The message chain aligns 1:1 with source pages, so `classID` can be mapped by walking both in
lockstep. Derived from the 61 chassis that aligned before the malformed-header repair below
(63 align now):
| classID | msgLen | count | class (from the page's `Model=`) |
|---:|---:|---:|---|
| 1181 | 108 | 842 | `subsystems\heatsinksubsystem.data` |
| 1106 | 380 | 210 | laser / pulse-laser weapon subsystems |
| 1134 | 380 | 98 | machine gun, ultra AC |
| 1073 | 112 | 61 | `<mech>.engine` |
| 1201 | 152 | 61 | `subsystems\armor.data` |
| 1223 | 108 | 61 | `subsystems\advancedgyrosubsystem.data` |
| 1157 | 108 | 61 | `subsystems\sensorsubsystem.data` |
| 1130 | 108 | 61 | `<mech>.torso` |
| 1193 | 236 | 61 | `subsystems\searchlightsubsystem.data` |
| 1142 | 380 | 60 | LRM weapon subsystems |
| 1100 | 108 | 23 | `subsystems\jumpjetsubsystem.data` |
| 1143 | 380 | 23 | streak SRM weapon subsystems |
| 1140 | 380 | 18 | SRM weapon subsystems |
| 1158 | 108 | 11 | `subsystems\ecmsubsystem.data` |
| 1183 | 112 | 10 | `subsystems\lams.data` |
| 1159 | 108 | 7 | `subsystems\beaglesubsystem.data` |
| 1155 | 380 | 3 | `narcbeacon.data` |
Only ~17 distinct classes actually occur in mech `.subsystems`, not the full 51 -- and all
weapons share a 380-byte message, so one weapon decoder covers most of them.
`Model=` is recoverable from `dataListID >> 16` looked up in the package manifest, so the
class table is a cross-check rather than the primary mechanism.
### Alignment: 63 of 65 chassis match 1:1
Only two chassis genuinely differ:
| chassis | messages | source pages | meaning |
|---|---:|---:|---|
| battlemaster | 32 | 41 | source is newer than the package |
| battlemaster2c | 32 | 41 | same |
`battlemaster.subsystems` and `battlemaster2c.subsystems` were rewritten with full IS stock
loadouts on branch `mfdsplit` (commit `e45a67a8`), which is far newer than the packed
`core.mw4`. Do not use those two for a `.subsystems` round-trip test until `core.mw4` is
repacked.
Two other chassis appeared to mismatch during the investigation and did not:
`hellspawn` and `sunder` each had one malformed page header -- `[HeatSink10` and
`[HeatSink16`, **missing the closing bracket**. The engine's NotationFile parser accepts a
header with no closing `]` and still creates the page, which is why the package had one more
message than a strict `\[[^\]]+\]` regex counted. Both were repaired (one byte each, CRLF
preserved); they were the only two malformed headers in the entire mech content set.
> **Lesson for the `.subsystems` decoder:** the runtime parser is more lenient than an obvious
> regex. Parse page headers as "line starts with `[`", not "line matches `\[...\]`", or the
> counts will silently disagree.
### Which commit the packages correspond to
**This is the single most important thing to check before trusting any comparison.** The
extracted trees reflect the *packages*, not `Content/` at HEAD:
| package | last committed | note |
|---|---|---|
| `Resource/core.mw4` | `2b8ca921` (initial mirror) | **never repacked** since the original import |
| `Resource/textures.mw4` | `2b8ca921` | never repacked |
| `Resource/props.mw4` | `2f176310`, working tree current as of `8bfaf9b9` | repacked twice |
So any `Content/` edit made after those commits is invisible to `FS_Ours_extracted`. Before
concluding "the decompiler lost something", check whether the source simply moved on. Run
`git log --oneline -- <the source file>` and compare against the package's commit above.
---
## 10. Tools
Under `MW4COMPARE/tools/decompile/`:
| file | purpose |
|---|---|
| `mw4msg.py` | CreateMessage stream reader. All verified offsets are documented in its docstring -- start here. |
| `armature.py` | `.armature` decompiler. `--verify` runs the harness; `<recordDir> -o out.armature` writes one mech. |
| `verify_armature.py` | 65-chassis harness. Matches pages as a multiset of `(name, rotation, translation)` so duplicate page names are handled. |
| `subsystems.py` | `.subsystems` decompiler. `--verify` runs the harness; `<record> <manifest.tsv> -o out.subsystems` writes one mech. |
| `verify_subsystems.py` | 63-chassis harness. Compares key/value pairs page by page, ignoring page names, and treats an explicit `=0` as equivalent to an omitted key. |
| `destroyed.py` | `*_destroyed` `.data` + `.video` generator. `--verify` regenerates all 89 of ours and diffs. |
```bash
cd MW4COMPARE/tools/decompile
python3 armature.py --verify
python3 subsystems.py --verify
python3 verify_data.py # .data{GameModel} typed field map, 64 chassis
python3 verify_roundtrip.py # whole .data regenerated and diffed, 64 chassis
python3 verify_damage.py # whole .damage regenerated and diffed, 64 chassis
python3 verify_contents.py # whole .contents regenerated and diffed, 64 chassis
python3 verify_smallmodel.py # .torso and .engine regenerated and diffed, 64 chassis
python3 verify_instance.py # whole .instance regenerated and diffed, 64 chassis
python3 datamap.py # print the field map itself, offset order
python3 constants.py # print the symbolic tables
V=/home/rich/Repositories/FS_Build_V4H_extracted
python3 armature.py "$V/_compiled/Content/Mechs/champion" -o "$V/Content/Mechs/champion/champion.armature"
python3 subsystems.py "$V/_compiled/Content/Mechs/champion/champion.subsystems" "$V/_manifest.tsv" \
-o "$V/Content/Mechs/champion/champion.subsystems"
python3 data.py "$V/_compiled/Content/Mechs/champion" -m "$V/_manifest.tsv" \
--retarget-ids -o "$V/Content/Mechs/champion/champion.data"
python3 damage.py "$V/_compiled/Content/Mechs/champion" -m "$V/_manifest.tsv" \
-o "$V/Content/Mechs/champion/champion.damage"
python3 contents.py "$V/_compiled/Content/Mechs/champion" -m "$V/_manifest.tsv" \
-o "$V/Content/Mechs/champion/champion.contents"
python3 instance.py "$V/_compiled/Content/Mechs/champion" -m "$V/_manifest.tsv" \
-o "$V/Content/Mechs/champion/champion.instance"
python3 smallmodel.py torso "$V/_compiled/Content/Mechs/champion" -o "$V/Content/Mechs/champion/champion.torso"
python3 smallmodel.py engine "$V/_compiled/Content/Mechs/champion" -o "$V/Content/Mechs/champion/champion.engine"
```
`datamap.py` derives the layout from the headers plus the tool factories and exposes
`chain_layout()`, `build() -> (corpus, {sourceKey: (offset, type, size, isAngle)})` and
`read(blob, offset, type, size, isAngle)`. `data.py` is the `.data` decompiler;
`--retarget-ids` is required when decompiling from a foreign tree whose roster differs.
Supporting tools one level up (`MW4COMPARE/tools/`): `mw4db.py` (package reader),
`extract-all.py`, `prune-identical.py`, `classify-survivors.py`, `restructure.py`,
`split-source.py`. See `MW4COMPARE/README.md`.
### Input trees
| path | what |
|---|---|
| `/home/rich/Repositories/FS_Ours_extracted` | our own packages, unpacked, **per-package** layout -- the baseline for every comparison. Regenerate in ~4 min. |
| `/home/rich/Repositories/FS_Build_V4H_extracted` | V4H, pruned to differences and reshaped: `Content/` = repackable source, `_compiled/` = records to decompile |
---
## 11. Progress: what exists for the six new chassis
Written to `FS_Build_V4H_extracted/Content/Mechs/<chassis>/`. Everything below is real source,
ready to drop into `Gameleap/mw4/Content/Mechs/`.
| chassis | `.erf` etc | `.armature` | `.subsystems` | `.data` | `.damage` | `.contents` | `.instance` | `.torso` | `.engine` |
|---|---|---|---|---|---|---|---|---|---|
| champion | yes | 64 pages | 22 pages | 137 keys | 21 pages | 66 pages | yes | yes | yes |
| dasher | yes | 58 | 9 | 137 | 17 | 60 | yes | yes | yes |
| griffin | yes | 67 | 17 | 137 | 19 | 65 | yes | yes | yes |
| jenner2c | yes | 53 | 12 | 137 | 17 | 55 | yes | yes | yes |
| marauder | yes | 64 | 23 | 137 | 21 | 66 | yes | yes | yes |
| thunderbolt | yes | 59 | 30 | 137 | 19 | 61 | yes | yes | yes |
**All eight source files now exist for all six chassis**, and every internal reference
(`Model=`, `Armature=`, `Subsystems=`, `DamageObjects=`, `SolidOBB=`, `HierarchicalOBB=`,
`!include=`) resolves to a file that is actually present.
`.data` files were generated with `--retarget-ids`, so each carries `$(M_<Chassis>)` and
`$(IDS_<Chassis>)`. **Those two symbols do not exist in our tree yet** -- they must be added to
`Content/ShellScripts/MechLabHeaders.h` and `Content/Defines/MissionLang.defines` before the
mech will build. That is part of the normal registration chain in `ADDING-A-MECH.md`.
Every folder carries a `STATUS` file listing what is present and what is missing, per the
staging convention below. Delete it in the same commit that adds the chassis to `core.build`.
### Decisions on the two chassis we already had (2026-08-08)
Two of the six exist in our tree already, unregistered. Both were compared in full; **V4H's
version wins in both cases**, for different reasons.
**Jenner IIC -- use V4H's, scrap ours.** Zero art is shared (prefix `j2c_` vs `jec_`, 0 of 27
comparable parts byte-identical), so these are unrelated models rather than two builds of one.
Ours is unfinished scaffolding: `.subsystems` has **no weapons at all**, every armour value is
the placeholder `0.1`, `EngineUpgrades=0`, it uses `Mech_Foot_Large` effects on a 35-ton light
mech, and its `.data` references `content\mechs\jenner2c\jenner2c.animscript` -- **a file that
exists nowhere in our tree**. V4H's has a full loadout (MG/LRM5/SRM4/flare, jump jet, 2 heat
sinks), the animscript, and 154 animation files. The one thing ours had was higher-detail art
(1065 KB against V4H's 250 KB, where shipped light mechs run 390-490 KB) -- 2.5x the roster
norm, and plausibly why it was never finished.
**Dasher -- use V4H's; keep `Dasher_DNU/` in the repo as reference, do not build it.** Worth
recording that **the DNU is not broken**. It is complete and self-consistent, and richer in
places: its own 38 KB `dasher.animscript` (V4H's package has none and borrows Dragon's), real
jump-jet sites, `LEGJUMPMOVETYPE`, and `OmniSlots=8` arms modelling it as a true OmniMech. All
29 shared `.erf` files are **byte-identical**, so the art is interchangeable if the DNU data is
ever revived.
Why it was marked DNU could not be determined. Ruled out by measurement: missing files (it has
the same eight core files as a shipped mech), stub `_dam` geometry (normal -- the shipped
Annihilator shows the same pattern), a 78-byte `_SOLID.obb` (every mech's is 78 bytes),
anomalous top speed (50.0 is the common roster value), cross-referenced assets (that is the
*other* DNU mech, Grimreaper, whose destroyed `.video` points at `grizzly_destroyed.erf`), and
dangling references (none). Git cannot help -- the repo is a single initial-mirror commit, so
the rename predates version control. The strongest remaining hypothesis is a balance decision
rather than a defect: a freely configurable 8-slot OmniMech on a 20-ton chassis at the roster's
top speed. That is inference, not evidence.
### All six carry a movement-type inconsistency
Found while comparing the Jenner, but it is not confined to it:
| V4H chassis | MoveTypeFlag | JumpJetTonnage | JumpJet subsystem | LeftJumpJetSiteName |
|---|---|---|---|---|
| champion | LEGMOVETYPE | 4 | -- | site_lujetport |
| dasher | LEGMOVETYPE | 2 | -- | `;` |
| griffin | LEGMOVETYPE | 3 | **yes** | site_lujetport |
| jenner2c | LEGMOVETYPE | 2 | **yes** | site_lujetport |
| marauder | LEGMOVETYPE | 4 | -- | `;` |
| thunderbolt | LEGMOVETYPE | 4 | -- | site_lujetport |
**All six cannot jump**, yet all six carry jump-jet tonnage, four name a real jump-jet site, and
two have a `JumpJetSubsystem` equipped. Our own roster is 84 `LEGJUMPMOVETYPE` against 5
`LEGMOVETYPE`, and those 5 have *empty* jump-jet site names -- internally consistent. V4H's are
not. This is decompiled faithfully; the inconsistency is in their source data. Decide per
chassis whether to set `LEGJUMPMOVETYPE` or strip the jump-jet gear before building. It is step
3 in every `STATUS` file.
### Staging convention: land the files, mark them, leave `core.build` alone
Decoded output goes into `Gameleap/mw4/Content/Mechs/<chassis>/` as it is produced, **before**
the chassis is complete. That is safe, and it was checked rather than assumed:
* **Packing is manifest-driven.** Every mech file is an explicit `data=` / `armature=` /
`subsystems=` / `damage=` / `instance=` line in `Content/core.build` (528 of them). The only
`directory=` sweeps in the whole tree are `content\shellscripts*`, `content\force` and
`content\ablscripts`. An unreferenced folder is never visited by the packer, so `core.mw4` is
unchanged and `build-resources.ps1` output stays byte-identical.
* **The editor never enumerates `Content\Mechs`.** MW4Ed2 STOPs fatally on the first content
defect, so this was the obvious risk. Its `FindFirstFile` calls cover `Content\Maps`,
`Content\Missions`, `Content\Skies`, `Content\ABLScripts`, `Content\Audio` and
`Resource\UserMissions` only -- mechs come from the compiled package, not a directory scan.
* **The game deploy drops them.** `deploy-mw4.ps1` keeps only `Content\shellscripts\files` and
drops `Content\*`, so `MW4\` is unaffected.
* Mech IDs are positional indices in code arrays and tables, not derived from directory
listings, so extra folders shift nothing.
`core.build` is the switch. Files stay inert right up until those manifest lines are added, at
which point any defect becomes a fatal STOP at resource-build time. **Add the manifest lines only
when a chassis is fully decoded and verified**, one chassis at a time.
Because the folders will look populated while still being incomplete, every incomplete chassis
carries a plain-text **`STATUS`** file naming exactly what is missing, so neither a future
session nor a human mistakes it for ready:
Gameleap/mw4/Content/Mechs/champion/STATUS
INCOMPLETE - do not add to Content/core.build yet.
have: .erf/.mw4anim/.obb source, .armature (64 pages), .subsystems (22 pages)
missing: .data (numeric keys decode; 52 string/bool/enum/resource keys do not),
.damage, .contents, .instance, .torso, .engine
see MW4COMPARE/DECOMPILING.md section 11
Delete the `STATUS` file in the same commit that adds the chassis to `core.build`. Its presence
is the marker for "staged, not wired up"; its absence means the chassis is live.
Note the inverse risk that motivates all of this: a **wrong** decoded file is more dangerous than
a missing one, because it packs and loads and then misbehaves subtly in game. That is the
argument for finishing a record type properly rather than emitting a best-effort file.
"`.erf` etc" is the verbatim-source set already separated by `split-source.py`: geometry
`.erf`, `.mw4anim` animation, `.tga` textures, `.obb` (renamed from `{solidobb}` /
`{hierarchicalobb}`), `.animscript`, `runninglights.erf`, `armaturedata/`, `armaturevideo/`.
### The `*_destroyed` variants -- complete
Each destroyed variant is four files, and all four now exist:
| folder | `.erf` | `_SOLID.obb` | `.data` | `.video` |
|---|---|---|---|---|
| champion_destroyed | package | package | generated | generated |
| griffin_destroyed | package | package | generated | generated |
| jenner2c_destroyed | package | package | generated | generated |
| marauder_destroyed | package | package | generated | generated |
| thunderbolt_destroyed | package | package | generated | generated |
| **Dasher_destroyed** | **already ours** | **already ours** | **already ours** | **already ours** |
**Dasher_destroyed has no folder in the differences-only tree and needs none.** Both of its
packaged files were byte-identical to `Content/Mechs/Dasher_destroyed/` in our repo and were
pruned - they were in fact the only two files in the entire prune that matched via our source
tree rather than via our packages.
`.data` and `.video` are compiled away in the package (a 12-byte stub plus
`{Element}`/`{GameModel}`, and a binary element tree with embedded `#FRE`/`#RLM` blobs), so
`destroyed.py` regenerates them from a template. That is sound because the template is
invariant: across all 89 destroyed variants in our own tree, `Class`, `OBBCollides`,
`Collider`, `CanBeShot`, `CanBeWalkedOn`, `VertexLighting`, `FaceLighting`, `LookupLighting`,
`LightMapLighting` and `CraterName` are identical in every one. Only the three filename
references vary, and those are read from the files actually present rather than guessed.
```
python3 destroyed.py --verify
destroyed variants checked : 89
.data equivalent : 89 (byte-exact 44, +45 case / trailing-blank-line only)
.video equivalent : 89 (byte-exact 62, +27 case / trailing-blank-line only)
```
The non-byte-exact cases are cosmetic: the sources disagree among themselves on a trailing
blank line and on casing (one uses `[renderers]`, the rest `[Renderers]`), and NotationFile
compares with `_stricmp` throughout.
Two content oddities preserved rather than "corrected":
* V4H's Champion destroyed files are named **`champion_stroyed`** - the "de" is missing. That
is the name their build actually uses, so the generated `.data` references
`champion_stroyed_solid.obb` and `champion_stroyed.video` to stay self-consistent.
* Our own `Grimreaper_destroyed/grimreaper_Destroyed.video` points at
`grizzly_destroyed.erf`. Pre-existing bug in our tree, unrelated to this work.
Note we already hold complete original source for two of the six in our own repo --
`Content/Mechs/Dasher_DNU` and `Content/Mechs/jenner2c` -- so those can be cross-checked
against the decompiled output rather than taken on trust. (V4H's Jenner IIC is a *different*
asset from ours: their part prefix is `jec_` and chassis files are `jenner_2c.*`, ours are
`j2c_` and `jenner2c.*`, with zero files in common.)
Still to build for the six main chassis: `.data` (values live in `X.data{GameModel}`),
`.damage` (`MWObject::CreateDamageStream` ->
`MWInternalDamageObject::ConstructMWInternalDamageObjectStream`), `.contents`, `.instance`,
`.torso`, `.engine`.
---
## 12. Hazards
* **Filename case.** V4H's packer lowercased every entry name; ours did not. The same record
is `core/mechs/longbow/longbow.data{element}` there and
`core/Mechs/Longbow/longbow.data{Element}` here. Compare case-insensitively; `diff -r`
between the trees is meaningless.
* **Stored-byte md5 is not an equality test.** The same source can pack to different bytes.
`maps/peaks.mw4` showed 9 stored-byte differences and exactly 1 after decompression. Always
decode before believing a difference.
* **This environment writes files through a CP949 locale.** Any non-ASCII character written
into a repo file gets mangled -- both a shell heredoc and the editor tool did it. The
section sign became byte pair A1 D7, the right-arrow became A1 E6, and em dashes were lost
to literal question marks; line endings also flipped to CRLF. This is the same class of
hazard `CLAUDE.md` documents for the Korean-comment sources.
**Keep these documents pure ASCII** -- write `->` not an arrow, `x` not a multiplication
sign, `--` not an em dash. After editing, check with:
`python3 -c "open('f','rb').read().decode('ascii')"`.
* **`_merged/` uses hardlinks.** Editing a file there edits the per-package copy too.
* **Duplicate page names are legal** in these NotationFiles and the packer resolves them
inconsistently. Never assume page name is a key.