# 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/.subsystems (text NotationFile, [Page] + Key=Value) | | MWTool::BuildResource mw4/Code/MW4/MWTool.cpp:142 v MWObject::CreateSubsystemStream mw4/Code/MW4/MWObject_Tool.cpp:1196 | | per [Page]: Tool::ConstructCreateMessage mw4/Libraries/Adept/Tool.cpp:680 | -> script.classData->messageFactory v one of 51 per-class factories mw4/Code/MW4/*_Tool.cpp | e.g. Armor_Tool.cpp, MWMover_Tool.cpp v a plain C struct (/Zp4) appended to the stream ``` The struct definitions are **in our own headers** -- `Armor__CreateMessage` in `Armor.hpp`, `MWMover__CreateMessage` in `MWMover.hpp`, `Mover__CreateMessage` in `Adept/Mover.hpp`, `Entity__CreateMessage` and `Replicator__CreateMessage` in `Adept/Entity.hpp` / `Adept/Replicator.hpp`. There is no reverse-engineering to do; we have both the writer and the reader. Useful entry points when adding a new type: | record type | writer | |---|---| | `.subsystems` | `MWObject::CreateSubsystemStream`, `MWObject_Tool.cpp:1196` | | `.armature` | `MWObject::CreateArmatureStream`, `MWObject_Tool.cpp` (just above) | | `.damage` | `MWObject::CreateDamageStream` -> `MWInternalDamageObject::ConstructMWInternalDamageObjectStream` | | `[joint_*]{armature}` / `{sites}` | `MWMover__CreateMessage::ConstructCreateMessage`, `MWMover_Tool.cpp:78-84` | | registration of packable types | `MWTool::IsRegisterable`, `MWTool.cpp:38` | --- ## 3. Stream format Both `CreateSubsystemStream` and `CreateArmatureStream` write: ``` WORD span number of replicator IDs consumed N x CreateMessage one per serialised [Page] ``` `span` equals the message count and, for `.subsystems`, the source `[Page]` count. **Verified: all 65 chassis walk from offset 2 to exactly EOF with no slack.** ### Verified message offsets ``` off 0 u32 messageLength <- walk the chain with this off 4 u32 messageID off 8 u32 priority off 12 u32 messageFlags off 16 u32 classID <- see the table in section 9 off 20 u32 replicatorFlags off 24 u32 replicatorID off 28 12f localToParent rotation = cols 0..2, translation = col 3 of each row off 76 u32 executionState off 80 f32 initialAge off 84 u32 dataListID ResourceID; the package record id is the HIGH word off 88 u32 alignment off 92 u32 nameID --- Mover__CreateMessage --- off 96 24B worldSpaceVelocity (Motion3D) off 120 24B worldSpaceAcceleration (Motion3D) --- MWMover__CreateMessage --- off 144 u32 siteStreamResourceID off 148 u32 armatureStreamResourceID off 152 char jointName[128] ``` **How the layout was confirmed:** a joint message is exactly **280 bytes**, and `152 + 128 = 280` accounts for every byte. Independently, `dataListID` in Atlas's `[Armor]` message resolves to record **884**, which is exactly the `core.mw4` record id of `subsystems\armor.data`. > `ResourceID` is packed -- the record id is the **high word**, so use `value >> 16`. An early > byte-granular search found 884 at byte offset 86; that was a coincidental overlap with the > real field at 84. ### `{sites}` records A flat array, no message header (`MWMover_Tool.cpp` ~145: `site_stream << rotation; << translation; << site_name;`): ``` repeat: 3 x f32 YawPitchRoll, in RADIANS 3 x f32 Point3D translation u32 name length bytes name u8 NUL <- names are length-prefixed AND NUL-terminated ``` Forgetting the trailing NUL desynchronises everything after the first entry. That was the one-byte bug that made the second site decode as garbage. --- ## 4. The test harness -- why any of this is trustworthy We hold **both the source text and the compiled record for 65 chassis**. That gives a closed-loop check: 1. Decompile our own packed record. 2. Diff against the known source text. *(this is what is automated today)* 3. Recompile the regenerated text with the real VC6 packer and compare bytes. *(not yet automated -- needs the Windows build machine)* Never add a record type without wiring it into step 2 first. ```bash python3 MW4COMPARE/tools/decompile/armature.py --verify ``` --- ## 5. `.armature` -- solved ### How it is stored The packer merges `.armature` into `.contents` via `!include=`, then for every contents page that has `Child=` entries emits two records: | record | holds | |---|---| | `.contents[]{sites}` | children named `site_*` but **not** `site_eye*` | | `.contents[]{armature}` | every other child (joints, and `site_eye*`), as CreateMessages carrying `jointName` + `localToParent` | Between them they hold every page of the original `.armature`. ### Algorithm 1. Read all `{sites}` records **first**, then all `{armature}` records. 2. `{sites}`: each entry is a page -- name, rotation (radians -> degrees), translation. Append the name to its parent's child list. 3. `{armature}`: each message is a page -- `jointName`, translation from the matrix, rotation `(0,0,0)` when the matrix is the identity, otherwise via `ypr_from_matrix`. 4. Emit depth-first, children before the joint that owns them. ### Results | | | |---|---| | chassis rebuilt | **65** | | pages compared | **2,976** | | fully exact | **2,938** | | child lists exact | **1,849 / 1,849** | | residual | 38 | Sample, Atlas `[site_cageright]`: | | rotation | translation | |---|---|---| | decompiled | `0.000000 -90.000003 0.000000` | `-0.650000 -0.499999 1.150000` | | source | `0.000000 -90.000000 0.000000` | `-0.650000 -0.499999 1.150000` | The 3e-6 is float32 rounding of a value the source stored as text. ### The 38 residuals are packer-side losses, not decoder bugs The game reads the package, not the source, so **it never sees this data either**. Our regenerated source is runtime-faithful. * **36** -- `site_lfoot` / `site_rfoot` rotation on 18 chassis. `MWMover_Tool.cpp` deliberately writes those two sites to *both* streams (the comment reads *"Jerry this will get deleted when you fix your foot problem"*); on those chassis both copies came out zeroed. The value is ~90 degrees on every chassis where it did survive, so it can be restored by convention if ever needed. * **2** -- Victor's `.armature` declares `[site_lshellport]` and `[site_rshellport]` **twice**, under different joints. `GetPage()` resolved both `{sites}` records to the first page. Confirmed by reading both records directly: they hold identical transforms, so the second page's values are genuinely absent from the package. ### Three traps, all now handled 1. **Order matters.** Read `{sites}` before `{armature}`. Alphabetically `{armature}` sorts first, and if it wins, the foot sites get the zeroed matrix instead of the real angle. 2. **Do not double-count.** `site_lfoot` / `site_rfoot` appear in both streams; counting both doubles them in the parent's child list. 3. **Key pages per occurrence, not by name.** Victor legitimately has two `[site_lshellport]` pages and the Annihilator lists `site_lfoot` twice under one parent. A `dict[name]` collapses them. ### Output `.armature` files generated for all six new chassis into `FS_Build_V4H_extracted/Content/Mechs/*/`: | chassis | pages | |---|---:| | champion | 64 | | dasher | 58 | | griffin | 67 | | jenner2c | 53 | | marauder | 64 | | thunderbolt | 59 | --- ## 6. Values are invertible -- worked example Atlas `[Armor]`, class 1201, message length 152. The last 44 bytes are nine `Scalar`s then `m_armorType` and `m_internalType` (`Armor.hpp`): | | compiled | source | ratio | |---|---:|---:|---:| | LeftLeg | 68.80 | 2.15 t | 32.0000 | | RightLeg | 68.80 | 2.15 t | 32.0000 | | LeftArm | 62.40 | 1.95 t | 32.0000 | | RightArm | 62.40 | 1.95 t | 32.0000 | | LeftFrontTorso | 78.40 | 2.45 t | 32.0000 | | RightFrontTorso | 78.40 | 2.45 t | 32.0000 | | CenterFrontTorso | 70.40 | 2.20 t | 32.0000 | | CenterRearTorso | 35.20 | 1.10 t | 32.0000 | | Head | 9.60 | 0.30 t | 32.0000 | `Armor_Tool.cpp` multiplies tons by `m_pointsPerStandardTon` from `Subsystems\Armor.data`, selected by `m_armorType`. So `tons = points / multiplier`, exactly -- but the multiplier depends on armour type (Standard / FerroFiberus / Reactive / Reflective / Solarian), so read it from the referenced `.data` rather than hardcoding 32. `.data` values survive too: Atlas `VehicleTonnage=28.0` sits at offset 108 of `atlas.data{GameModel}` and `MaxVehicleTonnage=100.0` at 112, adjacent and in declaration order. --- ## 7. Recipe for adding the next record type 1. Find the writer (see the table in section 2) and read what it emits, in order. 2. Read the message struct in the corresponding `*.hpp`. Fields are laid out in declaration order after the base classes, `/Zp4`, no padding surprises so far. 3. Check the arithmetic: `sizeof(base) + own fields` must equal the observed `messageLength`. This is the fastest way to confirm a layout -- it caught the 280-byte joint message and the 108-byte Subsystem message immediately. 4. Write the decoder against `mw4msg.walk()`. 5. **Wire it into a verifier over all 65 chassis before trusting a single value.** 6. Expect residuals; classify each one as *decoder bug*, *packer-side loss*, or *source newer than the package* before dismissing it. Every residual so far has been one of the last two, but only because each was chased down. Two habits that paid off repeatedly: * **Derive enums empirically, then check them against the engine's text tables.** Walking the aligned page/message pairs and tabulating `int -> source string` produced the ExecutionState, locationID, ArmorType and InternalType maps in one pass, and simultaneously proved the field offsets were right. * **Read the factory, do not infer from the data.** `GroupIndex` looked like a plain integer and decoded plausibly for most chassis; only `Weapon_Tool.cpp` reveals it is a bitmask that can carry several groups. The same rule cracked the `.data` angle fields: sixteen of them are declared plain `Stuff::Scalar`, and *only* `Mech_Tool.cpp` shows the writer multiplying them by `Radians_Per_Degree`. * **Never let a field map be discovered by the same values that verify it.** Searching for the offset whose float equals the source value looks rigorous and reports 100%, but it is circular: any field holding an identical value in every chassis has several valid-looking candidates, and a verifier built on the same principle cannot tell them apart. Locate fields by declaration order, anchored on the few that *are* unique, and keep value matching for confirmation only. See section 8b -- this mistake silently mis-assigned a third of the `.data` fields while reporting a perfect score. --- ## 8. `.subsystems` -- solved ### Results | | | |---|---| | chassis rebuilt | **63** | | pages compared | **1,729** -- fully exact **1,723** | | keys compared | **7,585** -- exact **7,579** | | residual | 6 | The 6 residuals are Behemoth and Behemoth II `GroupIndex`, 3 each: commit `e45a67a8` (`mfdsplit`) moved their Gauss rifles from weapon group 3 to group 1, and `core.mw4` has not been repacked since. The decompiler reads 3, which is what the package genuinely holds. battlemaster and battlemaster2c are skipped entirely for the same reason (41 source pages vs 32 packed messages). ### Field map Beyond the Entity header, from `Subsystem.hpp`, `Weapon.hpp`, `Armor.hpp`, `AMS.hpp`: ``` off 96 i32 subsystemIndex off 100 u8 locationID -> InternalLocation off 104 i32 criticalHitsTaken -> CriticalHitsTaken Armor (len 152) off 108 9xf32 armour POINTS -> tons = points / pointsPerTon off 144 i32 m_armorType -> ArmorType off 148 i32 m_internalType -> InternalType Engine (len 112, class 1073) off 108 i32 m_engineUpgrades -> EngineUpgrades LAMS / AMS (len 112, class 1183) off 108 i32 ammoCount -> AmmoCount (-1 means absent) SearchLight (len 236) off 108 char[128] siteName -> Site Weapon (len 380, or 376 without the last field) off 108 char[128] siteName -> Site off 236 char[128] ejectSiteName -> EjectSite off 364 i32 groupIndex -> GroupIndex (BITMASK - see below) off 368 i32 ammoCount -> AmmoCount (-1 means absent) off 372 i32 initialAmmoCount off 376 i32 m_weaponFacing -> WeaponFacing (absent when len == 376) ``` Enum tables, derived empirically from the aligned pairs and cross-checked against the engine's own text functions: | field | values | |---|---| | ExecutionState | 1 NeverExecuteState, 2 AlwaysExecuteState, 6 ActiveState | | locationID | `InternalDamageObject` enum, `DamageObject.hpp:205`: 0 LeftLeg .. 7 Head, 8 Special1, 9 Special2 | | ArmorType | 0 Standard, 1 FerroFiberus, 2 Reactive, 3 Reflective, 4 Solarian | | InternalType | **separate 2-value enum** (`Armor.hpp:275`): 0 Standard, 1 EndoSteel | Armour is stored in **points**, not tons. Divide by the multiplier for the armour type, read from `Content/Subsystems/Armor.data`: Standard 32, Ferro 38, Reactive 30, Reflective 30, Solarian 60. Confirmed empirically -- every chassis inverts to the exact source tonnage. ### Four traps 1. **`GroupIndex` is a bitmask, not an index.** `Weapon_Tool.cpp` ~76 starts from `DefaultWeaponGroupFlags` and ORs `1 << (n-1)` for *each* `GroupIndex=` line in the page. So a stored 4 means group 3, and a page may legitimately list several groups. Emit one line per set bit. 2. **`m_weaponFacing` is optional.** V4H packed champion/griffin/marauder at 376 bytes without it -- matching their release note *"Any new mech will not have rear facing weapons"* -- while dasher, jenner2c and thunderbolt have it at 380. Read both; our packer always emits 380. 3. **`Model=` is relative to the mech's own directory** in the source but absolute in the package. Strip the prefix using the **parent folder name**, not the file name: Black Hawk's chassis files are `nova.*` inside `mechs/blackhawk/`. 4. **Page names are not stored.** The packer serialises order only, so names are regenerated from `Model=` with a per-kind counter. They are labels; the runtime keys on order. ### Was the index space disturbed by V4H's extra mechs? No, and this was checked rather than assumed: * V4H's `core.mw4` adds **no** entries under `subsystems/`, `weaponsubsystems/`, `weapons/`, `tables/` or `effects/` -- all five sets are identical to ours. Every V4H-only entry is under `mechs/`. * 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 `.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_)` / `$(IDS_)` 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 `Internal` from the `damageZone` that *is* stored. All 89 mech `.damage` files follow that convention exactly (`LeftArmInternal`, `CenterTorsoInternal`, `Special1Internal`). * **`ArmorZone` and `InternalZone` are different enums.** `ArmorZone` (`DamageObject.hpp:363`) has `CenterRearTorso` at 7 and `Head` at 8; `InternalZone` (`DamageObject.hpp:204`) has `Head` at 7 and no rear-torso entry. Conflating them silently mislabels head and torso zones -- the values differ by one exactly where it is least obvious. * **Defaults are indistinguishable from explicit values.** The writer defaults `max_armor_value = base_armor_value` and `damage_mode = GeneralDamageMode`. No source writes `GeneralDamageMode` explicitly, so a zero there is safely emitted as "omitted". `MaxArmorValue` is *not* safe: 32 pages omit it while **51 pages state it explicitly equal to `BaseArmorValue`**, and both produce identical bytes. The distinction is unrecoverable, so the decompiler always writes it and the verifier accepts either form. --- ## 8e. `.contents` -- solved ### Results chassis : 64 pages compared : 3740 keys compared : 7480 exact: 7480 wrong: 0 `.contents` is the thin half of the pair section 5 already handles. It `!include`s `.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\\` prefix. This alone accounted for all 1124 initial mismatches. * **Site pages carry no Model in the package.** `{sites}` records store only name, rotation and translation. They do not need to: all **2926** site pages across the 89 mech `.contents` files carry the identical pair `basic.data` / `AlwaysExecuteState`, so they are emitted as constants rather than guessed. * **Which sites had a `.contents` page is not recoverable.** `.contents` declares a *subset* of the sites in `.armature` -- `site_eject2` appears in 89 `.armature` files but only 8 `.contents`. Emitting every site over-emits ~2.4 pages per chassis (152 total); emitting only the `{armature}` messages would lose ~21 per chassis. The former is far closer and harmless, since the added pair is the same default every other site carries. 12 site pages across all 64 chassis appear in the source but in no record at all, and are lost. ### Name consistency is on us The package stores neither the `.obb` filenames nor the `.armature` filename, so the decompiler chooses them -- and they must agree with the files actually shipped. V4H's Jenner IIC exposed this: its records use the stem `jenner_2c` while its folder is `jenner2c`, so a chassis-derived name produced `SolidOBB=jenner2c_Skeleton_SOLID.obb` next to a file called `jenner_2c_skeleton_SOLID.obb`, and a `!include=jenner_2c.armature` next to `jenner2c.armature`. `data.py` now reads the `.obb` names from the output directory rather than inventing them, and the generated set is checked so that every referenced file exists. --- ## 8f. `.torso` and `.engine` -- solved ### Results torso files : 64 engine files : 64 keys compared : 1280 exact: 1279 wrong: 1 Both are single-page `[GameData]` subsystem models stored as flat structs, so they reuse `datamap.chain_layout()` with their own chains. Both compute to **exactly** the record size with no anchoring, which is the check that the member list and alignment are right: | model | chain | computed | record | |---|---|---|---| | Torso | `Entity -> Subsystem -> Torso` | 1608 | 1608 | | Engine | `Entity -> Subsystem -> Engine` | 64 | 64 | Sources author the numbers as `$(SYMBOL)` macros from an `!include`d defines file -- note these use **`!NAME=value`** syntax, not `#define` like `MissionLang.defines` -- and the record keeps only the resolved float, so the symbol is restored by reverse lookup where one matches. * The five Torso angles are declared plain `Stuff::Scalar` but `Torso_Tool.cpp` multiplies them by `Radians_Per_Degree`, exactly like the Mech spring fields in section 8b. * `TotalCritLocations` is read by **nothing**: only the 3DS Max exporter writes it, and the factory reads `TotalSlotsTaken`, which no source sets. The record holds the default 1 while every source says 2. It is emitted as the constant it always is. * The engine's `Class` is `Mechwarrior4::Engine` -- lowercase 'w', in all 89 sources. Preserve the typo. ### A latent typo, and why the fix was to write the value down `cauldronborn.torso` said `TwistRadius=$(OBSTUSE_TRADIUS)` -- a typo for `OBTUSE`, defined nowhere, so `Torso_Tool.cpp:137` fell back to its default `100.0f`. The source therefore said one thing and the game did another. **100 is the intended value**, confirmed by the project owner, so the fix was *not* to correct the symbol to `$(OBTUSE_TRADIUS)` (which would have silently changed the Cauldron Born's twist radius from 100 to 140). It is now the literal `TwistRadius=100` -- behaviour-preserving, and matching the 25 other `.torso` files that write a literal rather than a symbol. There is no symbol for 100 in `MechTorso.defines`; inventing one for a single user was not worth it. `verify_smallmodel.py` reports **1280/1280** with the source and package now agreeing, which is itself the evidence that 100 was the operative value all along. A sweep of every `$(SYMBOL)` in all mech sources against all defines found no other undefined macro. The general lesson: an undefined macro does not fail loudly here -- the factory quietly substitutes a default, so the source can drift from the shipped behaviour indefinitely. Check what the package actually holds before "fixing" a symbol. --- ## 8g. `.instance` -- solved ### Results chassis : 64 page names : 64/64 match keys compared : 896 exact: 896 wrong: 0 One page named after the chassis, holding the model/armature/subsystem/damage references and the mechlab bar ratings. The record is a single `Mech__CreateMessage`, so the layout comes from the **CreateMessage** chain rather than the GameModel one: Replicator -> Entity -> Mover -> MWMover -> MWObject -> Vehicle -> Mech `chain_layout(..., start=16)` computes it; the 16 is the `Connection__Message` header (messageLength, priority, flags) which sits in front and is declared in none of these classes. The result ends at 341 and pads to exactly the 344-byte record, and **every offset established independently back in section 3 lands on the nose** -- classID 16, replicatorID 24, localToParent 28, dataListID 84, alignment 88, jointName 152. That is six independent confirmations from a layout that was never told about any of them. Two parser gaps had to be closed first, both fields typed with names the member regex did not know: `Stuff::RegisteredClass::ClassID` and `ReplicatorID` in the Replicator base, and `Entity__ExecutionStateEngine::FactoryRequest` and `ObjectID` in Entity. Missing them shifted everything after offset 76 by 8 bytes **while still producing a plausible-looking table** -- the known offsets were what caught it. References are written relative to the mech folder, same as `Model=` in section 8e. `CollideeType` and `CollisionMask` are not emitted: they appear in 5 of 64 sources, are uniformly `Zone` / `-1`, 11 of their occurrences are commented out, and no factory reads them -- only `TCTb`, `InterestBSP` and the old `MW4GameEd` ever write them. --- ## 9. Reference tables and package baseline ### classID map The message chain aligns 1:1 with source pages, so `classID` can be mapped by walking both in lockstep. Derived from the 61 chassis that aligned before the malformed-header repair below (63 align now): | classID | msgLen | count | class (from the page's `Model=`) | |---:|---:|---:|---| | 1181 | 108 | 842 | `subsystems\heatsinksubsystem.data` | | 1106 | 380 | 210 | laser / pulse-laser weapon subsystems | | 1134 | 380 | 98 | machine gun, ultra AC | | 1073 | 112 | 61 | `.engine` | | 1201 | 152 | 61 | `subsystems\armor.data` | | 1223 | 108 | 61 | `subsystems\advancedgyrosubsystem.data` | | 1157 | 108 | 61 | `subsystems\sensorsubsystem.data` | | 1130 | 108 | 61 | `.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 -- ` 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; ` -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; ` -o out.subsystems` writes one mech. | | `verify_subsystems.py` | 63-chassis harness. Compares key/value pairs page by page, ignoring page names, and treats an explicit `=0` as equivalent to an omitted key. | | `destroyed.py` | `*_destroyed` `.data` + `.video` generator. `--verify` regenerates all 89 of ours and diffs. | ```bash cd MW4COMPARE/tools/decompile python3 armature.py --verify python3 subsystems.py --verify python3 verify_data.py # .data{GameModel} typed field map, 64 chassis python3 verify_roundtrip.py # whole .data regenerated and diffed, 64 chassis python3 verify_damage.py # whole .damage regenerated and diffed, 64 chassis python3 verify_contents.py # whole .contents regenerated and diffed, 64 chassis python3 verify_smallmodel.py # .torso and .engine regenerated and diffed, 64 chassis python3 verify_instance.py # whole .instance regenerated and diffed, 64 chassis python3 datamap.py # print the field map itself, offset order python3 constants.py # print the symbolic tables V=/home/rich/Repositories/FS_Build_V4H_extracted python3 armature.py "$V/_compiled/Content/Mechs/champion" -o "$V/Content/Mechs/champion/champion.armature" python3 subsystems.py "$V/_compiled/Content/Mechs/champion/champion.subsystems" "$V/_manifest.tsv" \ -o "$V/Content/Mechs/champion/champion.subsystems" python3 data.py "$V/_compiled/Content/Mechs/champion" -m "$V/_manifest.tsv" \ --retarget-ids -o "$V/Content/Mechs/champion/champion.data" python3 damage.py "$V/_compiled/Content/Mechs/champion" -m "$V/_manifest.tsv" \ -o "$V/Content/Mechs/champion/champion.damage" python3 contents.py "$V/_compiled/Content/Mechs/champion" -m "$V/_manifest.tsv" \ -o "$V/Content/Mechs/champion/champion.contents" python3 instance.py "$V/_compiled/Content/Mechs/champion" -m "$V/_manifest.tsv" \ -o "$V/Content/Mechs/champion/champion.instance" python3 smallmodel.py torso "$V/_compiled/Content/Mechs/champion" -o "$V/Content/Mechs/champion/champion.torso" python3 smallmodel.py engine "$V/_compiled/Content/Mechs/champion" -o "$V/Content/Mechs/champion/champion.engine" ``` `datamap.py` derives the layout from the headers plus the tool factories and exposes `chain_layout()`, `build() -> (corpus, {sourceKey: (offset, type, size, isAngle)})` and `read(blob, offset, type, size, isAngle)`. `data.py` is the `.data` decompiler; `--retarget-ids` is required when decompiling from a foreign tree whose roster differs. Supporting tools one level up (`MW4COMPARE/tools/`): `mw4db.py` (package reader), `extract-all.py`, `prune-identical.py`, `classify-survivors.py`, `restructure.py`, `split-source.py`. See `MW4COMPARE/README.md`. ### Input trees | path | what | |---|---| | `/home/rich/Repositories/FS_Ours_extracted` | our own packages, unpacked, **per-package** layout -- the baseline for every comparison. Regenerate in ~4 min. | | `/home/rich/Repositories/FS_Build_V4H_extracted` | V4H, pruned to differences and reshaped: `Content/` = repackable source, `_compiled/` = records to decompile | --- ## 11. Progress: what exists for the six new chassis Written to `FS_Build_V4H_extracted/Content/Mechs//`. 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_)` and `$(IDS_)`. **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//` 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.