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>
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
.armature2,938 / 2,976 pages (65 chassis; the 38 residuals are data the packer discarded)..subsystems7,579 / 7,585 keys (63 chassis; 6 residuals are a later source edit)..data8,291 / 8,306 keys,.damage6,605 / 6,605,.contents7,480 / 7,480,.instance896 / 896,.torso+.engine1,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.
ResourceIDis packed -- the record id is the high word, so usevalue >> 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:
- Decompile our own packed record.
- Diff against the known source text. (this is what is automated today)
- 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
- Read all
{sites}records first, then all{armature}records. {sites}: each entry is a page -- name, rotation (radians -> degrees), translation. Append the name to its parent's child list.{armature}: each message is a page --jointName, translation from the matrix, rotation(0,0,0)when the matrix is the identity, otherwise viaypr_from_matrix.- 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_rfootrotation on 18 chassis.MWMover_Tool.cppdeliberately 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
.armaturedeclares[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
- 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. - Do not double-count.
site_lfoot/site_rfootappear in both streams; counting both doubles them in the parent's child list. - Key pages per occurrence, not by name. Victor legitimately has two
[site_lshellport]pages and the Annihilator listssite_lfoottwice under one parent. Adict[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
- Find the writer (see the table in section 2) and read what it emits, in order.
- 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. - Check the arithmetic:
sizeof(base) + own fieldsmust equal the observedmessageLength. This is the fastest way to confirm a layout -- it caught the 280-byte joint message and the 108-byte Subsystem message immediately. - Write the decoder against
mw4msg.walk(). - Wire it into a verifier over all 65 chassis before trusting a single value.
- 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 stringproduced 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.
GroupIndexlooked like a plain integer and decoded plausibly for most chassis; onlyWeapon_Tool.cppreveals it is a bitmask that can carry several groups. The same rule cracked the.dataangle fields: sixteen of them are declared plainStuff::Scalar, and onlyMech_Tool.cppshows the writer multiplying them byRadians_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
.datafields 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
GroupIndexis a bitmask, not an index.Weapon_Tool.cpp~76 starts fromDefaultWeaponGroupFlagsand ORs1 << (n-1)for eachGroupIndex=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.m_weaponFacingis 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.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 arenova.*insidemechs/blackhawk/.- 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.mw4adds no entries undersubsystems/,weaponsubsystems/,weapons/,tables/oreffects/-- all five sets are identical to ours. Every V4H-only entry is undermechs/<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_weaponFacingtail 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 putmaxHeatat 1060 instead of 1572.boolis one byte, not four. The sixm_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__GameModelhas no declaration -- it istypedef Adept::Mover__GameModel(MWMover.hpp:173), so it contributes nothing.Entity__GameModeldeclares no base class, and wraps its members in#if NSWIZZLE.NSWIZZLEis defined nowhere in the tree, so the#elsebranch 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.obbfile, 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.obbfor 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, achar[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.datafiles, emitted as a constant block.CraterName->m_craterIDholdsMString::GetHashValue(DeathEntity_Tool.cpp:34), which is one-way. All 64 chassis store the same hash, so the single authored valuecrater01is recovered by constancy, not by inversion.Classand the four lighting flags -> constant across all 64.DefaultFootStepTexture/FootStepTexture-> the{FootSteps}record:int material(-1 = default),int lengthnot counting the terminator, the characters, a NUL, then a one-byte isDefault flag. Material indices are the enum atAdept.hpp:212, whereNoMaterialis 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>Internalfrom thedamageZonethat is stored. All 89 mech.damagefiles follow that convention exactly (LeftArmInternal,CenterTorsoInternal,Special1Internal). ArmorZoneandInternalZoneare different enums.ArmorZone(DamageObject.hpp:363) hasCenterRearTorsoat 7 andHeadat 8;InternalZone(DamageObject.hpp:204) hasHeadat 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_valueanddamage_mode = GeneralDamageMode. No source writesGeneralDamageModeexplicitly, so a zero there is safely emitted as "omitted".MaxArmorValueis not safe: 32 pages omit it while 51 pages state it explicitly equal toBaseArmorValue, 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 returnsmechs\annihilator\armaturedata\ann_rfoot.data; the source saysarmaturedata\ann_rfoot.data. Strip themechs\<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.contentsfiles carry the identical pairbasic.data/AlwaysExecuteState, so they are emitted as constants rather than guessed. - Which sites had a
.contentspage is not recoverable..contentsdeclares a subset of the sites in.armature--site_eject2appears in 89.armaturefiles 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::ScalarbutTorso_Tool.cppmultiplies them byRadians_Per_Degree, exactly like the Mech spring fields in section 8b. TotalCritLocationsis read by nothing: only the 3DS Max exporter writes it, and the factory readsTotalSlotsTaken, 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
ClassisMechwarrior4::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
.subsystemsdecoder: 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 inContent/core.build(528 of them). The onlydirectory=sweeps in the whole tree arecontent\shellscripts*,content\forceandcontent\ablscripts. An unreferenced folder is never visited by the packer, socore.mw4is unchanged andbuild-resources.ps1output stays byte-identical. - The editor never enumerates
Content\Mechs. MW4Ed2 STOPs fatally on the first content defect, so this was the obvious risk. ItsFindFirstFilecalls coverContent\Maps,Content\Missions,Content\Skies,Content\ABLScripts,Content\AudioandResource\UserMissionsonly -- mechs come from the compiled package, not a directory scan. - The game deploy drops them.
deploy-mw4.ps1keeps onlyContent\shellscripts\filesand dropsContent\*, soMW4\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.datareferenceschampion_stroyed_solid.obbandchampion_stroyed.videoto stay self-consistent. - Our own
Grimreaper_destroyed/grimreaper_Destroyed.videopoints atgrizzly_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 andcore/Mechs/Longbow/longbow.data{Element}here. Compare case-insensitively;diff -rbetween the trees is meaningless. - Stored-byte md5 is not an equality test. The same source can pack to different bytes.
maps/peaks.mw4showed 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.mddocuments for the Korean-comment sources. Keep these documents pure ASCII -- write->not an arrow,xnot 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.