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

58 KiB

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.

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 Scalars 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 !includes <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 !included 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.
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.