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

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

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

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

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

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

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

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

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

240 lines
11 KiB
Python

#!/usr/bin/env python3
"""
subsystems.py - rebuild a mech's .subsystems source from its packed record.
python3 subsystems.py <record.subsystems> <manifest.tsv> [-o out.subsystems]
python3 subsystems.py --verify # check against all known chassis
The packed record is `WORD span` followed by one CreateMessage per [Page]
(MWObject::CreateSubsystemStream, MWObject_Tool.cpp:1196). Message layout and
the derivation of every offset below is documented in ../../DECOMPILING.md.
Layout beyond the Entity header (Subsystem.hpp, Weapon.hpp, Armor.hpp):
off 96 i32 subsystemIndex
off 100 u8 locationID -> InternalLocation
off 104 i32 criticalHitsTaken -> CriticalHitsTaken
Armor (152):
off 108 9xf32 armour points -> tons, see ARMOR_POINTS_PER_TON
off 144 i32 m_armorType -> ArmorType
off 148 i32 m_internalType -> InternalType
Engine (112):
off 108 i32 m_engineUpgrades -> EngineUpgrades
SearchLight (236):
off 108 char[128] siteName -> Site
Weapon (380, or 376 without the trailing field):
off 108 char[128] siteName -> Site
off 236 char[128] ejectSiteName -> EjectSite
off 364 i32 groupIndex -> GroupIndex
off 368 i32 ammoCount -> AmmoCount
off 372 i32 initialAmmoCount
off 376 i32 m_weaponFacing -> WeaponFacing (absent when len == 376)
`m_weaponFacing` is the "MSL 5.04 Rear Firing Weapons" field. V4H packed
champion/griffin/marauder without it (376 bytes) - matching their own release
note "Any new mech will not have rear facing weapons" - while dasher, jenner2c
and thunderbolt have it. Both forms are read; re-packing with our own exe always
emits the 380-byte form.
PAGE NAMES ARE NOT STORED. The packer only serialises page order, so names are
regenerated from the Model= reference with a per-kind counter. They are labels;
the runtime keys on order.
"""
import sys, os, re, glob, struct, argparse, collections
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import mw4msg
OUR_MECH_SOURCE = "/home/rich/Repositories/firestorm/Gameleap/mw4/Content/Mechs"
OUR_RECORDS = "/home/rich/Repositories/FS_Ours_extracted/core/mechs"
OUR_MANIFEST = "/home/rich/Repositories/FS_Ours_extracted/_manifest.tsv"
ARMOR_DATA = "/home/rich/Repositories/firestorm/Gameleap/mw4/Content/Subsystems/Armor.data"
# Derived empirically from 63 aligned chassis and cross-checked against the
# engine's own text tables.
EXEC_STATE = {1: "NeverExecuteState", 2: "AlwaysExecuteState", 6: "ActiveState"}
# InternalDamageObject enum, DamageObject.hpp:205
ZONE = {255: "NullZone", 0: "LeftLeg", 1: "RightLeg", 2: "LeftArm", 3: "RightArm",
4: "RightTorso", 5: "LeftTorso", 6: "CenterTorso", 7: "Head",
8: "Special1", 9: "Special2", 10: "VehicleHull", 11: "VehicleWeapon",
12: "VehicleSpecial", 13: "DefaultZone"}
ARMOR_TYPE = {0: "Standard", 1: "FerroFiberus", 2: "Reactive", 3: "Reflective",
4: "Solarian"}
# Armor.hpp:275 - a separate 2-value enum, not the armour table. (The engine's own
# InternalTypeAsciiToText returns the typo "Statndard"; TextToAscii wants "Standard".)
INTERNAL_TYPE = {0: "Standard", 1: "EndoSteel"}
ARMOR_KEYS = ["LeftLeg", "RightLeg", "LeftArm", "RightArm", "LeftFrontTorso",
"RightFrontTorso", "CenterFrontTorso", "CenterRearTorso", "Head"]
CLASS_ENGINE, CLASS_LAMS = 1073, 1183
# Model= basename -> page-name stem. Anything unmatched falls back to the
# weapon-category table below.
PAGE_STEM = {
"heatsinksubsystem.data": "HeatSink", "armor.data": "Armor",
"advancedgyrosubsystem.data": "AdvancedGyro", "sensorsubsystem.data": "Sensor",
"searchlightsubsystem.data": "SearchLight", "jumpjetsubsystem.data": "JumpJet",
"ecmsubsystem.data": "ECM", "beaglesubsystem.data": "Beagle",
"lams.data": "LAMS", "narcbeacon.data": "Narc",
}
WEAPON_CATEGORY = [
("laserweaponsubsystem", "Beam"), ("pulselaserweaponsubsystem", "Beam"),
("ppcweaponsubsystem", "Beam"), ("flamerweaponsubsystem", "Beam"),
("lrmweaponsubsystem", "Missile"), ("srmweaponsubsystem", "Missile"),
("ssrmweaponsubsystem", "Missile"), ("smrmweaponsubsystem", "Missile"),
("missileweaponsubsystem", "Missile"), ("narcbeaconweaponsubsystem", "Narc"),
("machinegunweaponsubsystem", "Ballistic"), ("ultraacweaponsubsystem", "Ballistic"),
("acweaponsubsystem", "Ballistic"), ("gaussweaponsubsystem", "Ballistic"),
("lbxweaponsubsystem", "Ballistic"), ("rtxweaponsubsystem", "Ballistic"),
]
def armor_points_per_ton(path=ARMOR_DATA):
txt = open(path, "rb").read().decode("latin-1")
def get(key, default):
m = re.search(rf"^{key}=(\d+)", txt, re.M | re.I)
return int(m.group(1)) if m else default
return {0: get("PointsPerStandardTon", 32), 1: get("PointsPerFerroTon", 38),
2: get("PointsPerReactiveTon", 30), 3: get("PointsPerReflectiveTon", 30),
4: get("PointsPerSolarianTon", 60)}
def load_manifest(path, package="core.mw4"):
"""record id -> entry name, for resolving dataListID back to a Model= path."""
out = {}
with open(path, encoding="latin-1") as fh:
for line in fh:
parts = line.rstrip("\n").split("\t")
if len(parts) > 2 and parts[0].lower() == package.lower():
out[int(parts[1])] = parts[2]
return out
def cstr(msg, off, size=128):
return msg[off:off + size].split(b"\0")[0].decode("latin-1").strip()
def group_flags_to_list(flags):
"""groupIndex is a BITMASK, not an index (Weapon_Tool.cpp ~76).
A page may carry several `GroupIndex=` lines; the factory ORs
`1 << (n-1)` for each. Returns the group numbers, so the caller can emit one
line per group.
"""
return [n for n in range(1, 7) if flags & (1 << (n - 1))]
def page_name(model, counters):
base = os.path.basename(model.replace("\\", "/")).lower()
stem = PAGE_STEM.get(base)
if stem is None:
low = model.lower().replace("\\", "/")
stem = next((s for key, s in WEAPON_CATEGORY if key in low), None)
if stem is None:
stem = "Torso" if base.endswith(".torso") else \
"Engine" if base.endswith(".engine") else "Subsystem"
counters[stem] += 1
# Singletons keep a bare name, matching how the sources are written.
if stem in ("Armor", "AdvancedGyro", "Sensor", "SearchLight", "Torso",
"Engine", "ECM", "Beagle", "LAMS"):
return stem if counters[stem] == 1 else f"{stem}{counters[stem]}"
return f"{stem}{counters[stem]}"
def decompile(record_path, manifest, ppt=None):
ppt = ppt or armor_points_per_ton()
data = open(record_path, "rb").read()
# Model= is written relative to the mech's own directory in the source, but the
# package stores the full entry path. Use the parent folder, not the file name:
# Black Hawk's chassis files are nova.* inside mechs/blackhawk/.
own_dir = "mechs\\" + os.path.basename(os.path.dirname(record_path)).lower() + "\\"
counters = collections.Counter()
pages = []
for _off, msg in mw4msg.walk(data):
cid, n = mw4msg.class_id(msg), len(msg)
model = manifest.get(mw4msg.record_id(msg), "?")
if model.lower().startswith(own_dir):
model = model[len(own_dir):]
kv = collections.OrderedDict()
kv["Model"] = model
kv["ExecutionState"] = EXEC_STATE.get(struct.unpack_from("<i", msg, 76)[0], "?")
kv["InternalLocation"] = ZONE.get(msg[100], f"?{msg[100]}")
crit = struct.unpack_from("<i", msg, 104)[0]
if n == 152: # Armor
vals = struct.unpack_from("<9f", msg, 108)
atype, itype = struct.unpack_from("<2i", msg, 144)
kv["ArmorType"] = ARMOR_TYPE.get(atype, str(atype))
kv["InternalType"] = INTERNAL_TYPE.get(itype, str(itype))
mult = ppt.get(atype, 32)
for key, v in zip(ARMOR_KEYS, vals):
kv[key] = f"{v / mult:g}"
elif n == 112 and cid == CLASS_ENGINE:
kv["EngineUpgrades"] = str(struct.unpack_from("<i", msg, 108)[0])
elif n == 112 and cid == CLASS_LAMS:
ammo = struct.unpack_from("<i", msg, 108)[0]
if ammo >= 0:
kv["AmmoCount"] = str(ammo)
elif n == 236: # SearchLight
kv["Site"] = cstr(msg, 108)
elif n in (376, 380): # Weapon
kv["Site"] = cstr(msg, 108)
eject = cstr(msg, 236)
grp, ammo, _init = struct.unpack_from("<3i", msg, 364)
kv["GroupIndex"] = group_flags_to_list(grp)
if ammo >= 0:
kv["AmmoCount"] = str(ammo)
if eject:
kv["EjectSite"] = eject
if n == 380:
facing = struct.unpack_from("<i", msg, 376)[0]
if facing:
kv["WeaponFacing"] = str(facing)
if crit:
kv["CriticalHitsTaken"] = str(crit)
pages.append((page_name(model, counters), kv))
return pages
def emit(pages):
out = []
for name, kv in pages:
out.append(f"[{name}]")
for k, v in kv.items():
if isinstance(v, list): # GroupIndex: one line per group
out.extend(f"{k}={n}" for n in v)
else:
out.append(f"{k}={v}")
out.append("")
return "\r\n".join(out) + "\r\n"
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("record", nargs="?")
ap.add_argument("manifest", nargs="?", default=OUR_MANIFEST)
ap.add_argument("-o", "--out")
ap.add_argument("--verify", action="store_true")
args = ap.parse_args()
if args.verify:
here = os.path.dirname(os.path.abspath(__file__))
os.execvp("python3", ["python3", os.path.join(here, "verify_subsystems.py")])
if not args.record:
ap.error("give a packed .subsystems record, or --verify")
pages = decompile(args.record, load_manifest(args.manifest))
text = emit(pages)
if args.out:
os.makedirs(os.path.dirname(args.out), exist_ok=True)
open(args.out, "wb").write(text.encode("latin-1"))
print(f"{os.path.basename(args.record)}: {len(pages)} pages -> {args.out}")
else:
sys.stdout.write(text)
if __name__ == "__main__":
main()