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