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>
This commit is contained in:
2026-08-08 16:47:57 -05:00
co-authored by Claude Opus 5 GitHub Copilot
parent e088555b96
commit 83478b7666
60 changed files with 13442 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
__pycache__/
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
classify-survivors.py - split a pruned extracted tree into NEW vs DIFFERS.
python3 classify-survivors.py <prunedExtractedDir>
Run after prune-identical.py. Everything still present is something we do not
have byte-for-byte; this reports which of two reasons applies:
NEW no file at that entry path exists on our side at all
DIFFERS we have that entry path, but the bytes differ
"Our side" means our extracted packages OR our Content/ source tree, matched
case-insensitively - the same notion of "have it" the pruner uses.
Writes _classified.tsv next to the tree and prints a summary.
"""
import sys, os, collections
OURS_EXTRACTED = "/home/rich/Repositories/FS_Ours_extracted"
OUR_SOURCE = "/home/rich/Repositories/firestorm/Gameleap/mw4/Content"
def index_paths(root, skip_top=()):
out = {}
for dp, dirs, fs in os.walk(root):
if dp == root:
dirs[:] = [d for d in dirs if d.lower() not in skip_top]
for f in fs:
p = os.path.join(dp, f)
out[os.path.relpath(p, root).replace("\\", "/").lower()] = p
return out
def package_of(rel):
parts = rel.split("/")
head = parts[0].lower()
if head in ("core", "props", "textures"):
return parts[0], "/".join(parts[1:])
if head in ("maps", "missions", "variants") and len(parts) > 2:
return "/".join(parts[:2]), "/".join(parts[2:])
if head == "pilots" and len(parts) > 3:
return "/".join(parts[:3]), "/".join(parts[3:])
return parts[0], "/".join(parts[1:])
def main():
if len(sys.argv) < 2:
sys.exit(__doc__)
root = sys.argv[1]
merged = os.path.join(root, "_merged")
ours = index_paths(OURS_EXTRACTED, skip_top={"_merged"})
ours_entries = set()
for rel in ours:
ours_entries.add(package_of(rel)[1])
src_entries = set(index_paths(OUR_SOURCE))
rows, summary = [], collections.Counter()
for dp, _dirs, fs in os.walk(root):
if dp == merged or dp.startswith(merged + os.sep):
continue
for f in fs:
rel = os.path.relpath(os.path.join(dp, f), root).replace("\\", "/")
if rel.startswith("_"):
continue
pkg, entry = package_of(rel)
e = entry.lower()
verdict = "DIFFERS" if (rel.lower() in ours or e in ours_entries
or e in src_entries) else "NEW"
rows.append((verdict, rel, pkg, entry))
summary[(verdict, pkg.split("/")[0])] += 1
with open(os.path.join(root, "_classified.tsv"), "w", encoding="utf-8") as fh:
fh.write("verdict\tpath\tpackage\tentry\n")
for r in sorted(rows):
fh.write("\t".join(r) + "\n")
tot = collections.Counter(v for v, _, _, _ in rows)
print(f"survivors: {len(rows)} NEW={tot['NEW']} DIFFERS={tot['DIFFERS']}\n")
print(f"{'package group':16s} {'NEW':>7s} {'DIFFERS':>8s}")
groups = sorted({g for _v, g in summary})
for g in groups:
print(f"{g:16s} {summary[('NEW', g)]:7d} {summary[('DIFFERS', g)]:8d}")
print(f"\n-> {os.path.join(root, '_classified.tsv')}")
if __name__ == "__main__":
main()
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""
armature.py - rebuild a mech's .armature source from its packed records.
python3 armature.py <mechRecordDir> [-o out.armature]
python3 armature.py --verify # check against all 65 known chassis
The packer merges <mech>.armature into <mech>.contents (via `!include=`) and
then, for every contents page that has Child= entries, emits two records
(MWMover_Tool.cpp ~78):
<mech>.contents[<joint>]{sites} children whose name starts 'site_'
but is not 'site_eye*' - stored as
YawPitchRoll + Point3D + name
<mech>.contents[<joint>]{armature} every other child, as a CreateMessage
carrying jointName + localToParent
Between them those two records hold every page of the original .armature.
Verified on Atlas: 61 of 61 page names recovered, values exact.
Rotation notes
--------------
* {sites} stores YawPitchRoll directly, so those angles are exact.
* Joint messages only carry a matrix. Across all 65 chassis, 1348 of 1453 joint
matrices are the identity, i.e. `Rotation=0 0 0`.
* site_lfoot / site_rfoot appear in BOTH streams - a deliberate hack in
MWMover_Tool.cpp ("Jerry this will get deleted when you fix your foot
problem") - and their matrix is the identity while the real angle is in the
{sites} record. The {sites} value always wins.
* site_eye* goes only to the armature stream, so its angle comes from the
matrix.
"""
import sys, os, glob, math, struct, re, 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"
REC_RE = re.compile(r'^(?P<chassis>.+?)\.contents\[(?P<parent>[^\]]+)\]\{(?P<kind>armature|sites)\}$',
re.I)
def ypr_from_matrix(r):
"""3x3 -> the (a, b, c) triple as written in Rotation=, in degrees.
Stuff's YawPitchRoll writes the X-axis angle in the middle slot; confirmed
against kodiak/site_eyepoint (matrix gives 0.0688 deg, source says
0.069104).
"""
m00, m01, m02, m10, m11, m12, m20, m21, m22 = r
pitch = math.asin(max(-1.0, min(1.0, m21)))
if abs(m21) < 0.999999:
yaw = math.atan2(-m20, m22)
roll = math.atan2(-m01, m11)
else:
yaw = math.atan2(m02, m00)
roll = 0.0
return (math.degrees(yaw), math.degrees(pitch), math.degrees(roll))
def is_identity(r, eps=1e-5):
ident = (1, 0, 0, 0, 1, 0, 0, 0, 1)
return all(abs(a - b) < eps for a, b in zip(r, ident))
def rebuild(mech_dir):
"""-> (chassisName, entries, children)
entries list of {'name','parent','rot','trans'} - one per armature page.
Keyed on (parent, name), not name alone: Victor legitimately has
two different [site_lshellport] pages hanging off different joints.
children {parentName: [childName, ...]} preserving order and multiplicity.
"""
entries = [] # one dict per armature page occurrence
children = collections.defaultdict(list)
site_children = collections.defaultdict(set)
chassis = None
# {sites} first: site_lfoot/site_rfoot are written to BOTH streams and only
# the {sites} copy carries their real angle.
records = (sorted(glob.glob(os.path.join(mech_dir, "*{sites}"))) +
sorted(glob.glob(os.path.join(mech_dir, "*{armature}"))))
for path in records:
m = REC_RE.match(os.path.basename(path))
if not m:
continue
chassis = chassis or m.group("chassis")
parent, kind = m.group("parent"), m.group("kind").lower()
with open(path, "rb") as fh:
data = fh.read()
children[parent] # ensure the parent exists
if kind == "sites":
for name, rot, trans in mw4msg.read_sites(data):
entries.append({
"name": name, "parent": parent,
"rot": tuple(math.degrees(a) for a in rot), "trans": trans})
children[parent].append(name)
site_children[parent].add(name)
else:
for _off, msg in mw4msg.walk(data):
name = mw4msg.joint_name(msg)
if not name:
continue
# site_lfoot/site_rfoot are deliberately written to both streams;
# counting the armature copy too would double them.
if name in site_children[parent]:
continue
children[parent].append(name)
r = mw4msg.rotation3x3(msg)
entries.append({
"name": name, "parent": parent,
"rot": (0.0, 0.0, 0.0) if is_identity(r) else ypr_from_matrix(r),
"trans": mw4msg.translation(msg)})
return chassis, entries, children
def emit(entries, children):
"""Render as .armature text, children before the joint that owns them."""
by_name = {}
for e in entries:
by_name.setdefault(e["name"], []).append(e)
out, done = [], set()
def visit(name):
if name in done:
return
done.add(name)
for c in children.get(name, ()):
visit(c)
for e in by_name.get(name, [{"rot": (0.0, 0.0, 0.0), "trans": (0.0, 0.0, 0.0)}]):
out.append(f"[{name}]")
out.append("Rotation=%.6f %.6f %.6f" % (e["rot"] or (0.0, 0.0, 0.0)))
out.append("Translation=%.6f %.6f %.6f" % (e["trans"] or (0.0, 0.0, 0.0)))
for c in children.get(name, ()):
out.append(f"Child={c}")
out.append("")
all_children = {c for lst in children.values() for c in lst}
for name in list(children) + list(by_name):
if name not in all_children:
visit(name)
for name in list(children) + list(by_name):
visit(name)
return "\r\n".join(out) + "\r\n"
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("mechdir", nargs="?", help="directory of packed records for one mech")
ap.add_argument("-o", "--out", help="write .armature here instead of stdout")
ap.add_argument("--verify", action="store_true",
help="rebuild all 65 known chassis and diff against their source")
args = ap.parse_args()
if args.verify:
here = os.path.dirname(os.path.abspath(__file__))
os.execvp("python3", ["python3", os.path.join(here, "verify_armature.py")])
if not args.mechdir:
ap.error("give a mech record directory, or --verify")
chassis, entries, children = rebuild(args.mechdir)
text = emit(entries, children)
if args.out:
os.makedirs(os.path.dirname(args.out), exist_ok=True)
open(args.out, "wb").write(text.encode("latin-1"))
print(f"{chassis}: {len(entries)} pages -> {args.out}")
else:
sys.stdout.write(text)
if __name__ == "__main__":
main()
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
armature_parts.py - generate a mech's `armaturedata/` and `armaturevideo/` files.
Every joint in a mech's `.contents` points at `armaturedata\\<part>.data`, which
in turn points at `armaturevideo\\<part>.video`, which names the geometry. Both
are packed as compiled records -- the `.data` becomes a 12-byte stub and the
`.video` becomes a binary renderer graph -- so neither survives extraction as
text and both have to be regenerated.
They are formulaic. Measured over the 89 shipped mechs:
* `armaturedata/<part>.data` -- **one single shape** across all 1336 files,
parameterised only by the part name.
* `armaturevideo/<part>.video` -- three shapes cover 91% of 1354 files:
with a damaged LOD, without one, and with a running-lights block.
Rules, and their measured accuracy:
* A `DAMLOD` block iff `<part>_dam.erf` exists next to the mech. This holds for
1327 of 1354 shipped files. **All 27 exceptions run the same way**: the
`_dam.erf` exists but the author's `.video` ignores it. No shipped `.video`
ever references a `_dam.erf` that is absent, so generating by this rule is
safe by construction -- it can never produce a dangling reference, only
occasionally more damage-state switching than the original author chose.
The exceptions are mostly `_gun` and `_toe` parts and follow no clean rule.
* A running-lights block iff the part is the torso and a running-lights `.erf`
is present. 68 of the 71 torsos carry it; almost nothing else does.
python3 armature_parts.py <mech-source-dir>
python3 armature_parts.py --verify
"""
import argparse, os, re, sys, collections
DATA_TEMPLATE = """[gamedata]
Class=MechWarrior4::MWMover
[renderers]
VideoRenderer=armaturevideo\\{part}.video
"""
LIGHT_BLOCK = """[lightlod]
Type=ShapeComponent
Geometry={lights}
Unique=true
[lightwatcher]
Type=AttributeWatcherOfInt
Attribute=IsDark
SimulationShouldExecute=1
[lightswitch]
Type=SwitchComponent
Input=LightWatcher
Child=LightLOD
"""
LOD_BLOCK = """[lod]
Type=ShapeComponent
Geometry={part}.erf
Unique=true
"""
DAM_BLOCK = """[damlod]
Type=ShapeComponent
Geometry={part}_DAM.erf
Unique=true
"""
# joint_cage has its own shape: two empty group components, then the cage
# geometry twice (intact and destroyed), shadow-disabled.
CAGE_TEMPLATE = """[fake1]
Type=GroupComponent
[fake2]
Type=GroupComponent
[cage]
Type=ShapeComponent
Geometry={cage}
Unique=true
DisableShadow=true
[destroyedcage]
Type=ShapeComponent
Geometry={cage}
Unique=true
DisableShadow=true
[watcher]
Type=AttributeWatcherOfInt
Attribute=VisualRepresentation
SimulationShouldExecute=1
[damageappearance]
Type=SwitchComponent
Input=Watcher
Child=Fake1
Child=Fake2
Child=Cage
Child=DestroyedCage
[locator]
Child=DamageAppearance
"""
def parts_of(mech_dir):
"""Part names referenced as armaturedata\\<part>.data by the .contents."""
out = []
for fn in os.listdir(mech_dir):
if not fn.lower().endswith(".contents"):
continue
t = open(os.path.join(mech_dir, fn), "rb").read().decode("latin-1")
for m in re.finditer(r'armaturedata[\\/]([A-Za-z0-9_\-]+)\.data', t, re.I):
if m.group(1) not in out:
out.append(m.group(1))
return out
def video_text(mech_dir, part):
files = {f.lower(): f for f in os.listdir(mech_dir)}
cage = next((files[f] for f in files if f.endswith("_cage.erf")), None)
if part.lower() == "joint_cage" and cage:
return CAGE_TEMPLATE.format(cage=cage)
has_dam = f"{part.lower()}_dam.erf" in files
lights = next((files[f] for f in files
if f.startswith("runninglights") and f.endswith(".erf")), None)
is_torso = part.lower().endswith("torso")
text = ""
if lights and is_torso:
text += LIGHT_BLOCK.format(lights=lights)
text += LOD_BLOCK.format(part=part)
if has_dam:
text += DAM_BLOCK.format(part=part)
text += """[watcher]
Type=AttributeWatcherOfInt
Attribute=VisualRepresentation
SimulationShouldExecute=1
[damageappearance]
Type=SwitchComponent
Input=Watcher
Child=LOD
"""
if has_dam:
text += "Child=DAMLOD\n"
text += "\n[locator]\n"
if lights and is_torso:
text += "Child=LightSwitch\n"
text += "Child=DamageAppearance\n"
return text
def generate(mech_dir, write=True):
"""-> {relativePath: text} for every part."""
out = {}
for part in parts_of(mech_dir):
out[f"armaturedata/{part}.data"] = DATA_TEMPLATE.format(part=part)
out[f"armaturevideo/{part}.video"] = video_text(mech_dir, part)
if write:
for rel, text in out.items():
p = os.path.join(mech_dir, rel)
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "wb") as fh:
fh.write(text.replace("\n", "\r\n").encode("latin-1"))
return out
def norm(text, part):
t = re.sub(r'//[^\n]*', '', text).replace("\r", "")
t = re.sub(re.escape(part), "PART", t, flags=re.I)
return re.sub(r'\n+', "\n", t).strip().lower()
def verify():
import glob
MECHS = "/home/rich/Repositories/firestorm/Gameleap/mw4/Content/Mechs"
t = collections.Counter()
diffs = collections.Counter()
for d in sorted(glob.glob(MECHS + "/*")):
if not os.path.isdir(d) or not glob.glob(d + "/armaturedata/*.data"):
continue
gen = generate(d, write=False)
for rel, text in gen.items():
p = os.path.join(d, rel)
part = os.path.basename(rel).rsplit(".", 1)[0]
kind = rel.split("/")[0]
if not os.path.exists(p):
t[kind + " absent"] += 1
continue
t[kind] += 1
want = open(p, "rb").read().decode("latin-1")
if norm(want, part) == norm(text, part):
t[kind + " ok"] += 1
else:
diffs[kind] += 1
print(f"armaturedata : {t['armaturedata ok']}/{t['armaturedata']} reproduced")
print(f"armaturevideo: {t['armaturevideo ok']}/{t['armaturevideo']} reproduced")
if t["armaturedata absent"] or t["armaturevideo absent"]:
print(f" referenced but absent on disk: "
f"{t['armaturedata absent']} data, {t['armaturevideo absent']} video")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("mech_dir", nargs="?")
ap.add_argument("--verify", action="store_true")
args = ap.parse_args()
if args.verify:
verify()
return
if not args.mech_dir:
ap.error("mech_dir required")
out = generate(args.mech_dir)
print(f"wrote {len(out)} files into {args.mech_dir}")
if __name__ == "__main__":
main()
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
assembly.py - show how a mech's parts bolt together.
There is no `.erf` viewer on Linux, but the assembly itself is not hidden: the
`.armature` gives every joint's parent, offset and rotation, and the `.contents`
plus `armaturevideo/` say which geometry hangs off each joint. Printing that as
a tree answers "what are the parts and how do they fit" without a renderer.
For actual 3D you need MW4Ed2 on the Windows box (Gameleap/mw4/run-editor.bat) --
its Game View renders a loaded mech through DDrawCompat.
python3 assembly.py <mech-source-dir> [--geometry-only]
"""
import argparse, collections, os, re, sys
def pages(path):
"""-> OrderedDict pageName -> [(key, value)]"""
txt = re.sub(r'//[^\n]*', '', open(path, "rb").read().decode("latin-1"))
out, cur = collections.OrderedDict(), None
for line in txt.splitlines():
line = line.strip()
if not line or line.startswith("!"):
continue
m = re.match(r'^\[([^\]]+)\]$', line)
if m:
cur = m.group(1)
out.setdefault(cur, [])
elif "=" in line and cur is not None:
k, v = line.split("=", 1)
out[cur].append((k.strip(), v.strip()))
return out
def find(mech_dir, ext):
for f in sorted(os.listdir(mech_dir)):
if f.lower().endswith(ext) and "}" not in f:
return os.path.join(mech_dir, f)
return None
def geometry_for(mech_dir, part):
"""The .erf a joint draws, via armaturevideo/<part>.video.
Takes the [lod] block specifically -- a torso's file starts with the
running-lights block, whose geometry is not the part.
"""
vid = os.path.join(mech_dir, "armaturevideo", part + ".video")
if not os.path.exists(vid):
return None
t = open(vid, "rb").read().decode("latin-1")
m = re.search(r'^\[lod\][^\[]*?Geometry=([^\r\n]+)', t, re.M | re.I | re.S)
if not m:
m = re.search(r'Geometry=([^\r\n]+)', t)
return m.group(1).strip() if m else None
def build(mech_dir):
arm = find(mech_dir, ".armature")
con = find(mech_dir, ".contents")
if not arm:
raise SystemExit(f"no .armature in {mech_dir}")
apages = pages(arm)
children = collections.defaultdict(list)
info = {}
for name, kv in apages.items():
d = dict(kv)
info[name] = d
for k, v in kv:
if k.lower() == "child":
children[name].append(v.strip())
models = {}
if con:
for name, kv in pages(con).items():
for k, v in kv:
if k.lower() == "model":
models[name.lower()] = v.strip()
parented = {c for cs in children.values() for c in cs}
roots = [n for n in apages if n not in parented]
return apages, children, info, models, roots
def render(mech_dir, geometry_only=False):
apages, children, info, models, roots = build(mech_dir)
sizes = {f.lower(): os.path.getsize(os.path.join(mech_dir, f))
for f in os.listdir(mech_dir) if f.lower().endswith(".erf")}
lines = []
def walk(name, depth, last, prefix):
d = info.get(name, {})
model = models.get(name.lower(), "")
part = None
m = re.match(r'armaturedata[\\/](.+)\.data', model, re.I)
if m:
part = m.group(1)
geo = geometry_for(mech_dir, part) if part else None
size = sizes.get((geo or "").lower())
tag = ""
if geo:
tag = f" <- {geo}" + (f" ({size:,} B)" if size else "")
elif model and model.lower() != "basic.data":
tag = f" <- {model}"
trans = d.get("Translation", d.get("translation", ""))
rot = d.get("Rotation", d.get("rotation", ""))
pos = f" @[{trans}]" if trans and trans.strip() not in ("0.0 0.0 0.0", "0 0 0") else ""
if geometry_only and not geo:
pass
else:
branch = "" if depth == 0 else ("`-- " if last else "|-- ")
lines.append(f"{prefix}{branch}{name}{tag}{pos}")
kids = children.get(name, [])
newprefix = prefix + ("" if depth == 0 else (" " if last else "| "))
for i, k in enumerate(kids):
walk(k, depth + 1, i == len(kids) - 1, newprefix)
for r in roots:
walk(r, 0, True, "")
return lines, sizes
def main():
ap = argparse.ArgumentParser()
ap.add_argument("mech_dir")
ap.add_argument("--geometry-only", action="store_true",
help="hide joints and sites that draw nothing")
args = ap.parse_args()
lines, sizes = render(args.mech_dir, args.geometry_only)
name = os.path.basename(args.mech_dir.rstrip("/"))
print(f"=== {name}: {len(lines)} nodes, {len(sizes)} .erf, "
f"{sum(sizes.values()):,} B of geometry ===")
print("\n".join(lines))
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
constants.py - reverse tables for the `.data` keys authored as symbolic names.
Four `[GameData]` keys are written in the source as a symbol rather than a
literal, and the record stores only the resolved integer. Emitting a faithful
`.data` therefore needs the int -> symbol direction:
MechID $(M_Annihilator) MechLabHeaders.h #define M_Annihilator 0
TechType $(Tech_IS) MechLabHeaders.h #define Tech_IS 0
NameIndex $(IDS_Annihilator) MissionLang.defines #define IDS_ANNIHILATOR 576
MoveTypeFlag LEGJUMPMOVETYPE MWObject.hpp enum + MWObject_Tool.cpp:20
MoveTypeFlag is the odd one out: a bare token, not `$(...)`, matched by the
`stricmp` chain in `MWObject__GameModel::ConvertStringToMoveType`. Its values
come from the anonymous enum at MWObject.hpp:136, where LEG is 0 and the order
is NOT the same as the stricmp chain, so the enum is the authority.
The `.defines` and `.h` files spell symbols in mixed case while sources
reference them in any case, so lookups here are case-insensitive.
"""
import re
REPO = "/home/rich/Repositories/firestorm/Gameleap"
MECHLAB_HEADERS = REPO + "/mw4/Content/ShellScripts/MechLabHeaders.h"
MISSIONLANG_DEFINES = REPO + "/mw4/Content/Defines/MissionLang.defines"
# MWObject.hpp:136. Declaration order is the value order.
MOVE_TYPE = [
"LEGMOVETYPE", "LEGJUMPMOVETYPE", "TRACKMOVETYPE", "WHEELMOVETYPE",
"FLYERMOVETYPE", "HOVERMOVETYPE", "HELIMOVETYPE", "NONEMOVETYPE",
"DROPSHIPMOVETYPE", "WATERMOVETYPE",
]
_DEFINE = re.compile(r'^\s*#define\s+(\w+)\s+(-?\d+)\s*$', re.M)
def defines(path, prefix):
"""-> {value: [symbols]} for every `#define <prefix>NAME <int>` in a file.
Values are not unique: IDS_FIRSTSKIN and IDS_WOLFHOUND are both 501, so a
single winner cannot be picked here without knowing which mech is being
emitted.
"""
txt = open(path, encoding="latin-1", errors="replace").read()
out = {}
for name, val in _DEFINE.findall(txt):
if name.lower().startswith(prefix.lower()):
out.setdefault(int(val), []).append(name)
return out
def tables():
"""-> {sourceKey: {intValue: [sourceToken]}} for the four symbolic keys."""
wrap = lambda d: {v: [f"$({s})" for s in syms] for v, syms in d.items()}
return {
"MechID": wrap(defines(MECHLAB_HEADERS, "M_")),
"TechType": wrap(defines(MECHLAB_HEADERS, "Tech_")),
"NameIndex": wrap(defines(MISSIONLANG_DEFINES, "IDS_")),
"MoveTypeFlag": {i: [s] for i, s in enumerate(MOVE_TYPE)},
}
def symbol(tbl, key, value, hint=None):
"""Pick the source token for a value, preferring one naming the chassis."""
syms = tbl.get(key, {}).get(value)
if not syms:
return None
if hint and len(syms) > 1:
h = norm(hint)
for s in syms:
if h and h in norm(s):
return s
return syms[0]
def norm(tok):
"""Compare symbols ignoring case and $() wrapping."""
return re.sub(r'[^a-z0-9_]', '', tok.lower())
if __name__ == "__main__":
for key, tbl in tables().items():
print(f"{key:14s} {len(tbl):5d} values e.g. {list(tbl.items())[:2]}")
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""
contents.py - decompiler for a mech `<chassis>.contents` file.
`.contents` is the thin half of the pair `armature.py` already handles. It
`!include`s `<chassis>.armature` and then gives every joint and site exactly two
entries:
[joint_torso]
Model=basic.data
ExecutionState=AlwaysExecuteState
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.
So nothing new has to be located: `Model` is `dataListID` (record id in the HIGH
word) and `ExecutionState` is the enum at offset 76.
Site pages are the exception. `{sites}` records store only name, rotation and
translation, so a site's Model and ExecutionState are not in the package at all.
They do not need to be: 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 per mech.
python3 contents.py <chassis-record-dir> [-o out.contents]
python3 contents.py --verify
"""
import argparse, collections, glob, os, re, struct, subprocess, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import mw4msg, subsystems
MANIFEST = "/home/rich/Repositories/FS_Ours_extracted/_manifest.tsv"
REC_RE = re.compile(r'^(?P<chassis>.+?)\.contents\[(?P<parent>[^\]]+)\]\{(?P<kind>armature|sites)\}$',
re.I)
EXEC_STATE = {1: "NeverExecuteState", 2: "AlwaysExecuteState", 6: "ActiveState"}
SITE_DEFAULTS = [("Model", "basic.data"), ("ExecutionState", "AlwaysExecuteState")]
EXEC_OFF = 76
DATALIST_OFF = 84
def relative(path, chassis, folder=None):
"""Model= is written relative to the mech folder, not from the package root.
The package path uses the FOLDER name, which need not match the record stem
(V4H's jenner2c/ holds jenner_2c.* records), so both are tried.
"""
if not path:
return path
for name in (chassis, folder):
if name:
path = re.sub(rf'^mechs[\\/]{re.escape(name)}[\\/]', '', path, flags=re.I)
return path
def decompile(mech_dir, manifest=None):
"""-> (chassis, [(pageName, [(key, value)])])"""
manifest = manifest if manifest is not None else subsystems.load_manifest(MANIFEST)
chassis = None
folder = os.path.basename(mech_dir.rstrip("/"))
pages = collections.OrderedDict()
root = [p for p in glob.glob(os.path.join(mech_dir, "*.contents"))
if not re.search(r'[\[\{]', os.path.basename(p))]
for path in sorted(glob.glob(os.path.join(mech_dir, "*{armature}"))) + root:
base = os.path.basename(path)
m = REC_RE.match(base)
if m:
chassis = chassis or m.group("chassis")
else:
chassis = chassis or base[:-len(".contents")]
with open(path, "rb") as fh:
data = fh.read()
for _off, msg in mw4msg.walk(data):
name = mw4msg.joint_name(msg)
if not name:
continue
model = manifest.get(mw4msg.record_id(msg, DATALIST_OFF))
state = EXEC_STATE.get(struct.unpack_from("<i", msg, EXEC_OFF)[0])
pages.setdefault(name, [("Model", relative(model, chassis, folder)),
("ExecutionState", state)])
for path in sorted(glob.glob(os.path.join(mech_dir, "*{sites}"))):
with open(path, "rb") as fh:
for name, _rot, _trans in mw4msg.read_sites(fh.read()):
pages.setdefault(name, list(SITE_DEFAULTS))
return chassis, list(pages.items())
def emit(chassis, pages):
lines = ["[includes]", f"!include={chassis}.armature", ""]
for name, kv in pages:
lines.append(f"[{name}]")
lines.extend(f"{k}={v}" for k, v in kv)
lines.append("")
return "\r\n".join(lines)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("mech_dir", nargs="?")
ap.add_argument("-o", "--output")
ap.add_argument("-m", "--manifest", default=MANIFEST)
ap.add_argument("--verify", action="store_true")
args = ap.parse_args()
if args.verify:
here = os.path.dirname(os.path.abspath(__file__))
raise SystemExit(subprocess.call([sys.executable,
os.path.join(here, "verify_contents.py")]))
if not args.mech_dir:
ap.error("mech_dir required")
chassis, pages = decompile(args.mech_dir, subsystems.load_manifest(args.manifest))
text = emit(chassis, pages)
if args.output:
with open(args.output, "wb") as fh:
fh.write(text.encode("latin-1"))
print(f"wrote {args.output}")
else:
sys.stdout.write(text)
if __name__ == "__main__":
main()
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""
damage.py - decompiler for a mech `<chassis>.damage` file.
The record is a bare concatenation of variable-length objects with no index and
no length prefixes: parsing means walking forward, reading a classID, and using
it to decide what follows. Written by `MWObject::CreateDamageStream`
(MWObject_Tool.cpp:1149), which iterates the source pages in order and dispatches
on whether a page carries a `DamageZone` entry.
Armour page -- DamageObject::ConstructDamageObjectStream (DamageObject.cpp:157):
classID, baseArmorValue, currentArmorValue, scaleSplashDamage,
damageObjectName (MString), internalDamageZoneID, armorZone, damageLevel,
armorType, maxArmorValue, attachedToZone
Internal page -- InternalDamageObject::ConstructInternalDamageObjectStream
(DamageObject.cpp:931) plus the MW4 subclass (MWDamageObject.cpp:88):
classID, baseInternalDamage, currentInternalDamage,
parentEntityName (MString), damageMode, damageZone, damagePropagationZone,
internalType, attachedTo, damageEffects[], 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.
Two traps:
* The armour page stores its own name, but the internal page does NOT. Internal
page names are reconstructed as `<Zone>Internal`, which every one of the 89
mech .damage files follows, using the damageZone that IS stored.
* ArmorZone and InternalZone are DIFFERENT enums. ArmorZone has
CenterRearTorso at 7 and Head at 8; InternalZone has Head at 7 and no rear
torso entry. Conflating them silently mislabels head and torso zones.
python3 damage.py <chassis-record-dir> [-o out.damage]
python3 damage.py --verify
"""
import argparse, collections, os, re, struct, subprocess, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import subsystems
MANIFEST = "/home/rich/Repositories/FS_Ours_extracted/_manifest.tsv"
ARMOR_CLASS = 468 # Adept::DamageObject
INTERNAL_CLASS = 1162 # MechWarrior4::MWInternalDamageObject
# DamageObject.hpp:363 -- note CenterRearTorso, absent from the internal enum.
ARMOR_ZONE = {
-1: "NullZone", 0: "LeftLeg", 1: "RightLeg", 2: "LeftArm", 3: "RightArm",
4: "RightTorso", 5: "LeftTorso", 6: "CenterTorso", 7: "CenterRearTorso",
8: "Head", 9: "Special1", 10: "Special2", 11: "DefaultZone",
}
# DamageObject.hpp:204
INTERNAL_ZONE = {
-1: "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",
}
# DamageObject.hpp:185
DAMAGE_MODE = {
0: "GeneralDamageMode", 1: "GimpLeftDamageMode", 2: "GimpRightDamageMode",
3: "DestructionDamageMode", 4: "DetachableDamageMode", 5: "EngineDamageMode",
6: "NextDamageMode", 7: "HeadShotDamageMode", 8: "GyroHitDamageMode",
9: "TorsoLeftDamageMode", 10: "TorsoRightDamageMode",
11: "ArmLeftDamageMode", 12: "ArmRightDamageMode",
}
class Reader:
def __init__(self, blob):
self.b, self.o = blob, 0
def i32(self):
v = struct.unpack_from("<i", self.b, self.o)[0]
self.o += 4
return v
def u32(self):
v = struct.unpack_from("<I", self.b, self.o)[0]
self.o += 4
return v
def f32(self):
v = struct.unpack_from("<f", self.b, self.o)[0]
self.o += 4
return v
def mstring(self):
n = self.u32()
s = self.b[self.o:self.o + n].decode("latin-1")
self.o += n + 1 # length excludes the terminator
return s
def done(self):
return self.o >= len(self.b)
def parse(blob):
"""-> [(kind, {field: value})] in stream order."""
r = Reader(blob)
out = []
while not r.done():
class_id = r.i32()
if class_id == ARMOR_CLASS:
rec = {
"baseArmorValue": r.f32(),
"currentArmorValue": r.f32(),
"scaleSplashDamage": r.f32(),
"name": r.mstring(),
"internalDamageZone": r.i32(),
"armorZone": r.i32(),
"damageLevel": r.i32(),
"armorType": r.i32(),
"maxArmorValue": r.f32(),
"attachedToZone": r.i32(),
}
out.append(("armor", rec))
elif class_id == INTERNAL_CLASS:
rec = {
"baseInternalDamage": r.f32(),
"currentInternalDamage": r.f32(),
"parentEntityName": r.mstring(),
"damageMode": r.i32(),
"damageZone": r.i32(),
"damagePropagationZone": r.i32(),
"internalType": r.i32(),
"attachedTo": r.i32(),
}
count = r.i32()
rec["effects"] = [(r.u32(), r.f32()) for _ in range(count)]
rec["missileSlots"] = r.i32()
rec["projectileSlots"] = r.i32()
rec["beamSlots"] = r.i32()
rec["omniSlots"] = r.i32()
out.append(("internal", rec))
else:
raise ValueError(f"unknown classID {class_id} at offset {r.o - 4}")
return out
def fmt(x):
"""Match the authored style: 1.0, 80.0, 0.14, .99"""
if x == int(x):
return f"{x:.1f}"
return f"{x:g}"
def decompile(mech_dir, manifest=None):
"""-> [(pageName, [(key, value)])] in stream order."""
manifest = manifest if manifest is not None else subsystems.load_manifest(MANIFEST)
blob = record(mech_dir)
pages = []
for kind, r in parse(blob):
if kind == "armor":
kv = [
("BaseArmorValue", fmt(r["baseArmorValue"])),
("MaxArmorValue", fmt(r["maxArmorValue"])),
("ScaleSplashDamage", f"{r['scaleSplashDamage']:g}"),
("InternalDamageZone", INTERNAL_ZONE.get(r["internalDamageZone"])),
("ArmorZone", ARMOR_ZONE.get(r["armorZone"])),
]
if r["attachedToZone"] != -1:
kv.append(("SpecialAttachedToZone", ARMOR_ZONE.get(r["attachedToZone"])))
pages.append((r["name"], kv))
else:
zone = INTERNAL_ZONE.get(r["damageZone"], "Null")
kv = [
("DamageZone", zone),
("BaseInternalDamage", fmt(r["baseInternalDamage"])),
]
for rid, pct in r["effects"]:
path = manifest.get(rid >> 16, f"<unresolved:{rid}>")
kv.append(("DamageEffect", f"{path},{pct:g}"))
# no source writes GeneralDamageMode explicitly, so 0 means "omitted"
if r["damageMode"]:
kv.append(("DamageMode", DAMAGE_MODE.get(r["damageMode"])))
kv.append(("ParentEntityName", r["parentEntityName"]))
if r["damagePropagationZone"] != -1:
kv.append(("DamagePropagationZone",
INTERNAL_ZONE.get(r["damagePropagationZone"])))
if r["attachedTo"] != -1:
kv.append(("SpecialAttachedToZone", ARMOR_ZONE.get(r["attachedTo"])))
for key, field in (("MissileSlots", "missileSlots"),
("ProjectileSlots", "projectileSlots"),
("BeamSlots", "beamSlots"),
("OmniSlots", "omniSlots")):
if r[field]:
kv.append((key, str(r[field])))
pages.append((zone + "Internal", kv))
return pages
def record(mech_dir):
ch = os.path.basename(mech_dir.rstrip("/")).lower()
for fn in os.listdir(mech_dir):
if fn.lower().endswith(".damage"):
return open(os.path.join(mech_dir, fn), "rb").read()
raise SystemExit(f"no .damage record in {mech_dir}")
def emit(pages):
lines = []
for name, kv in pages:
lines.append(f"[{name}]")
lines.extend(f"{k}={v}" for k, v in kv)
lines.append("")
return "\r\n".join(lines)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("mech_dir", nargs="?")
ap.add_argument("-o", "--output")
ap.add_argument("-m", "--manifest", default=MANIFEST)
ap.add_argument("--verify", action="store_true")
args = ap.parse_args()
if args.verify:
here = os.path.dirname(os.path.abspath(__file__))
raise SystemExit(subprocess.call([sys.executable,
os.path.join(here, "verify_damage.py")]))
if not args.mech_dir:
ap.error("mech_dir required")
text = emit(decompile(args.mech_dir, subsystems.load_manifest(args.manifest)))
if args.output:
with open(args.output, "wb") as fh:
fh.write(text.encode("latin-1"))
print(f"wrote {args.output}")
else:
sys.stdout.write(text)
if __name__ == "__main__":
main()
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""
data.py - decompiler for a mech `<chassis>.data` file.
Rebuilds the `[GameData]` page from the compiled records:
<chassis>.data{GameModel} 1636-byte flat struct (datamap.py)
<chassis>.data{FootSteps} foot-step texture list
<chassis>.data[shadow] inline Shadow notation block
Value sources, in order of preference:
* a struct member - typed read through datamap.chain_layout()
* a ResourceID member - record id (HIGH word) resolved via the manifest
* a symbolic constant - int reversed through constants.py
* an explicit factory key - handled below, because SaveGameModel writes these
outside the attribute table
Four keys cannot be recovered and are deliberately omitted: BattleDamageRatio,
BattleKillBonus, DragoonValue and VehicleTradeValue. They appear in every source
.data but are read by NOTHING in the engine - no factory, no attribute
registration, no runtime reference - so they never enter the package. They are
authoring metadata; omitting them changes no behaviour.
python3 data.py <chassis-record-dir> [-o out.data]
python3 data.py --verify
"""
import argparse, collections, os, glob, re, struct, subprocess, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import datamap, constants, subsystems
MANIFEST = "/home/rich/Repositories/FS_Ours_extracted/_manifest.tsv"
# Adept.hpp:212. NoMaterial is 0.
MATERIALS = [
"NoMaterial", "Grass", "Water", "Concrete", "GreyDirt", "BrownDirt", "Rock",
"DarkConcrete", "DarkGreyDirt", "DarkBrownDirt", "DarkRock", "Blacktop",
"Snow", "Wood", "Lava", "Glass", "Steel", "Us", "Them", "LightMineral",
"DarkMineral", "Ash", "CrackedLava", "OpenLava",
]
# Identical in all 64 chassis; SaveGameModel writes them outside the attribute table.
CONSTANTS = {
"Class": "MechWarrior4::Mech",
"FaceLighting": "yes",
"LookupLighting": "yes",
"VertexLighting": "yes",
"LightMapLighting": "no",
# byte-identical in all 89 mech .data files, destroyed variants included
"Shadow": "{\n[shadow]\nLightType=Shadow\nInnerRadius=4.0\nOuterRadius=10.0\n"
"BlobDistance=200.0\nShadowMap=ShadowMask\nIntensity=0.4\n}",
}
# Keys whose source name differs from the struct member name.
ALIASES = {
"AnimationScript": "animScriptName",
"HeatManager": "heatManagerResource",
"FootEffectsFile": "footFallEffectsTable",
}
# CraterName is stored as MString::GetHashValue (DeathEntity_Tool.cpp:34), which
# is one-way. All 64 chassis hold the same hash, so the single authored value is
# recoverable by constancy rather than by inversion.
CRATER_HASH = {217688981: "crater01"}
# Read by nothing in the engine - see module docstring.
UNRECOVERABLE = ["BattleDamageRatio", "BattleKillBonus", "DragoonValue", "VehicleTradeValue"]
SYMBOLIC = ("MechID", "TechType", "NameIndex", "MoveTypeFlag")
def stem(mech_dir):
"""File stem used inside a mech folder; it need not match the folder name.
jenner2c/ holds jenner_2c.*, the same trap Black Hawk/nova sprang on
.subsystems -- never assume the folder name.
"""
names = [f for f in os.listdir(mech_dir) if f.lower().endswith(".data")]
if names:
return names[0][:-len(".data")]
for f in os.listdir(mech_dir):
m = re.match(r'(.+)\.data[\{\[]', f, re.I)
if m:
return m.group(1)
return os.path.basename(mech_dir.rstrip("/"))
def record(mech_dir, suffix):
"""Read one qualified record. Not glob -- '[shadow]' is a character class."""
want = (stem(mech_dir) + suffix).lower()
for fn in os.listdir(mech_dir):
if fn.lower() == want:
return open(os.path.join(mech_dir, fn), "rb").read()
return None
def bool_words():
"""-> {key: (trueWord, falseWord)}; sources spell these inconsistently.
Collider and CanBeShot are written true/false, the CanLoad* flags Yes/No.
"""
seen = collections.defaultdict(collections.Counter)
for _ch, kv, _b in datamap.corpus():
for k, v in kv.items():
w = v.strip().lower()
if w in ("true", "false", "yes", "no"):
seen[k][w] += 1
out = {}
for k, c in seen.items():
plain = c["true"] + c["false"] >= c["yes"] + c["no"]
out[k] = ("true", "false") if plain else ("Yes", "No")
return out
def foot_steps(blob):
"""-> (defaultTexture, [(texture, materialName)]).
Stream written by Mech_Tool.cpp:196: int material (-1 = default), int length
NOT counting the terminator, the characters, a NUL, then a one-byte
isDefault flag.
"""
default, rows, off = None, [], 0
while off + 8 <= len(blob):
material, length = struct.unpack_from("<iI", blob, off)
off += 8
text = blob[off:off + length].decode("latin-1")
off += length + 1 # skip the terminator
is_default = blob[off] if off < len(blob) else 0
off += 1
if is_default or material < 0:
default = text
else:
name = MATERIALS[material] if 0 <= material < len(MATERIALS) else str(material)
rows.append((text, name))
return default, rows
def decompile(mech_dir, manifest=None, retarget_ids=False, obb_dir=None):
"""-> OrderedDict key -> value or [values]."""
manifest = manifest if manifest is not None else subsystems.load_manifest(MANIFEST)
ch = os.path.basename(mech_dir.rstrip("/"))
gm = record(mech_dir, ".data{GameModel}")
if gm is None:
raise SystemExit(f"no GameModel record in {mech_dir}")
layout = datamap.chain_layout()[0]
angles = datamap.angle_fields()
tables = constants.tables()
words = bool_words()
by_member = {datamap.norm(n): n for n in layout}
out = collections.OrderedDict()
def member(key):
n = ALIASES.get(key) or by_member.get(datamap.norm(key))
return (n, *layout[n]) if n in layout else None
# every key the corpus knows about, so output matches the authored shape.
# Keys only a handful of mechs author (VehicleBattleValue: 1 of 64) are left
# out rather than emitted at their default.
corpus = datamap.corpus()
common = collections.Counter(k for _c, kv, _b in corpus for k in kv)
for key in sorted(common):
if key in UNRECOVERABLE or common[key] * 2 < len(corpus):
continue
if key in CONSTANTS:
out[key] = CONSTANTS[key]
continue
if key == "CraterName":
name = CRATER_HASH.get(datamap.read(gm, *layout["m_craterID"]))
if name:
out[key] = name
continue
m = member(key)
if key in SYMBOLIC and m:
_n, off, typ, size = m
raw = datamap.read(gm, off, typ, size)
sym = constants.symbol(tables, key, raw, hint=ch)
# MechID/NameIndex are authored as $(M_Chassis)/$(IDS_Chassis). A
# foreign package may store an int from a different roster -- V4H's
# are shifted by one against ours -- so --retarget-ids emits the
# chassis's own symbol and lets the build resolve it.
if retarget_ids and key in ("MechID", "NameIndex"):
prefix = "M_" if key == "MechID" else "IDS_"
if not sym or constants.norm(ch) not in constants.norm(sym):
out[key] = f"$({prefix}{ch[:1].upper() + ch[1:]})"
continue
if sym:
out[key] = sym
continue
if m:
name, off, typ, size = m
val = datamap.read(gm, off, typ, size, datamap.norm(name) in angles
or typ == "Radian")
if typ == "ResourceID":
path = manifest.get(val >> 16)
if path:
out[key] = path
elif typ in datamap.VECTORS:
out[key] = " ".join(f"{v:g}" for v in val)
elif typ in ("bool", "BYTE"):
yes, no = words.get(key, ("true", "false"))
out[key] = yes if val else no
elif typ in datamap.FLOATS:
out[key] = f"{val:g}"
elif typ == "char":
if val:
out[key] = val
else:
out[key] = str(val)
# The OBB filenames are source-side names the package never stores. Prefer the
# .obb files actually shipped next to the output so the keys cannot disagree
# with them; fall back to the usual convention when none are present.
solid = hier = None
if obb_dir and os.path.isdir(obb_dir):
for fn in sorted(os.listdir(obb_dir)):
if not fn.lower().endswith(".obb"):
continue
if fn.lower().endswith("_solid.obb"):
solid = fn
else:
hier = fn
out["SolidOBB"] = solid or f"{ch}_Skeleton_SOLID.obb"
out["HierarchicalOBB"] = hier or f"{ch}_Skeleton.obb"
fs = record(mech_dir, ".data{FootSteps}")
if fs:
default, rows = foot_steps(fs)
if default:
out["DefaultFootStepTexture"] = default
if rows:
out["FootStepTexture"] = [f"{t},{m}" for t, m in rows]
sh = record(mech_dir, ".data[shadow]")
if sh is None:
out.pop("Shadow", None)
return out
def emit(kv):
lines = ["[GameData]"]
for key, val in kv.items():
for v in (val if isinstance(val, list) else [val]):
lines.append(f"{key}={v}")
return "\r\n".join(lines) + "\r\n"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("mech_dir", nargs="?")
ap.add_argument("-o", "--output")
ap.add_argument("-m", "--manifest", default=MANIFEST,
help="package manifest for resolving ResourceIDs")
ap.add_argument("--retarget-ids", action="store_true",
help="emit the chassis's own MechID/NameIndex symbol when the "
"stored int belongs to a different roster")
ap.add_argument("--verify", action="store_true")
args = ap.parse_args()
if args.verify:
here = os.path.dirname(os.path.abspath(__file__))
raise SystemExit(subprocess.call([sys.executable,
os.path.join(here, "verify_roundtrip.py")]))
if not args.mech_dir:
ap.error("mech_dir required")
text = emit(decompile(args.mech_dir, subsystems.load_manifest(args.manifest),
retarget_ids=args.retarget_ids,
obb_dir=os.path.dirname(args.output) if args.output else None))
if args.output:
with open(args.output, "wb") as fh:
fh.write(text.encode("latin-1"))
print(f"wrote {args.output}")
else:
sys.stdout.write(text)
if __name__ == "__main__":
main()
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""
datamap.py - field map for the mech `<chassis>.data{GameModel}` record (1636 B).
Built from the engine headers, not from value matching.
Why: value matching alone cannot separate keys that hold the same value in every
mech. `dampenWorldJoint` and `fallAdjustmentSeconds` are both 0.5 everywhere, so
34 of the 80 numeric keys came out ambiguous, and ordering them by their
appearance in the source file assigned several of them wrongly - it put
`tiltSpeed` at 672, which is really `slopeDecel2`, and swapped
`percentageOfTurnToStartTilt` with `percentageOfSpeedToStartTilt`.
So the layout is taken from the declaration order in
Vehicle__GameModel mw4/Code/MW4/Vehicle.hpp
Mech__GameModel mw4/Code/MW4/Mech.hpp
and each block is anchored using the fields that value matching *did* resolve
unambiguously. Every anchor agrees:
Mech block base 756: footReturnSeconds 764, dampenTorsoJoint 800,
undampenRootJoint 816, undampenHipJoint 824,
scaleInternalTiltDegree 832
Vehicle block base 664: minSpeed 692, maxSpeed 696, acceleration 720,
decceleration 724, reverseAccelerationMultiplier 728
All fields in both blocks are 4-byte Scalar/Radian, so field i sits at
base + 4*i.
"""
import glob, math, os, re, struct, collections
REPO = "/home/rich/Repositories/firestorm/Gameleap"
SRC = REPO + "/mw4/Content/Mechs"
CODE = REPO + "/code"
REC = "/home/rich/Repositories/FS_Ours_extracted/core/mechs"
GAMEMODEL_SIZE = 1636
# The GameModel inheritance chain for a mech, base class first. Bases are NOT
# hardcoded: each block starts where the previous one ended. MWMover__GameModel
# is just `typedef Adept::Mover__GameModel` (MWMover.hpp:173), so it adds
# nothing. `Entity__GameModel` declares no base class and wraps its members in
# `#if NSWIZZLE`, which is never defined anywhere in the tree - the #else branch
# is live, and the two branches order their members DIFFERENTLY.
CHAIN = [
("Entity__GameModel", CODE + "/mw4/Libraries/Adept/Entity.hpp"),
("Mover__GameModel", CODE + "/mw4/Libraries/Adept/Mover.hpp"),
("MWObject__GameModel", CODE + "/mw4/Code/MW4/MWObject.hpp"),
("Vehicle__GameModel", CODE + "/mw4/Code/MW4/Vehicle.hpp"),
("Mech__GameModel", CODE + "/mw4/Code/MW4/Mech.hpp"),
]
# Independently measured block starts, used to check the computed chain.
ANCHORS = {"Vehicle__GameModel": 664, "Mech__GameModel": 756}
# The factories that convert authored degrees into stored radians.
FACTORIES = [CODE + "/mw4/Code/MW4/Mech_Tool.cpp", CODE + "/mw4/Code/MW4/Vehicle_Tool.cpp"]
SCALAR_TYPES = r'(?:Stuff::Scalar|Stuff::Radian|Stuff::Angle|float)'
DEG2RAD = math.pi / 180.0
MAX_STRING_LENGTH = 256 # Entity.hpp:199
# Member sizes. bool is ONE byte, not four -- getting this wrong shifts every
# field after the six m_canLoad* flags by exactly 16 bytes.
SIZES = {
"Scalar": 4, "Radian": 4, "Angle": 4, "float": 4, "int": 4, "unsigned": 4,
"DWORD": 4, "WORD": 2, "BYTE": 1, "bool": 1, "ResourceID": 4,
"Point3D": 12, "Vector3D": 12, "UnitQuaternion": 16, "RGBAColor": 16,
"Motion3D": 24, "LinearMatrix4D": 48, "char": 1,
"ClassID": 4, "ReplicatorID": 4, "FactoryRequest": 4, "ObjectID": 4,
}
MEMBER_RE = re.compile(
r'\b((?:Stuff::|Adept::)?(?:Scalar|Radian|Angle|Point3D|Vector3D|UnitQuaternion'
r'|RGBAColor|Motion3D|LinearMatrix4D|ResourceID)|(?:Stuff::)?(?:RegisteredClass::)?ClassID'
r'|ReplicatorID|ObjectID|(?:\w+::)?FactoryRequest|float|int|bool|BYTE|WORD|DWORD'
r'|unsigned|char)\s+([A-Za-z_][\w\s,\[\]]*?);', re.S)
def members(cls, path):
"""-> [(typeName, memberName, sizeInBytes)] in declaration order."""
txt = open(path, encoding="latin-1").read()
m = re.search(rf'class\s+{cls}\s*(?::[^{{;]*)?\{{', txt, re.S)
if not m:
return []
depth, i = 1, m.end() # brace-match; indentation is inconsistent between headers
while i < len(txt) and depth:
depth += (txt[i] == "{") - (txt[i] == "}")
i += 1
body = txt[m.end():i - 1]
ctor = body.find(f"{cls}(")
if ctor > 0:
body = body[:ctor]
body = re.sub(r'//[^\n]*', '', body)
# `typedef int AttributeID;` is not a member. This masked a missing ClassID
# for a while: two 4-byte errors that happened to cancel.
body = re.sub(r'\btypedef\b[^;]*;', '', body, flags=re.S)
body = re.sub(r'#\s*if\s+NSWIZZLE\b.*?#\s*else', '', body, flags=re.S)
body = re.sub(r'#\s*(endif|else|if\w*|ifdef|ifndef)[^\n]*', '', body)
out = []
for d in MEMBER_RE.finditer(body):
base = (d.group(1).replace("Stuff::", "").replace("Adept::", "")
.replace("RegisteredClass::", ""))
base = base.rsplit("::", 1)[-1]
for name in d.group(2).split(","):
name = name.strip()
arr = re.fullmatch(r'([A-Za-z_]\w*)\s*\[\s*([A-Za-z_]\w*|\d+)\s*\]', name)
if arr:
n = arr.group(2)
count = MAX_STRING_LENGTH if n == "MaxStringLength" else int(n)
out.append((base, arr.group(1), SIZES[base] * count))
elif re.fullmatch(r'[A-Za-z_]\w*', name):
out.append((base, name, SIZES[base]))
return out
def chain_layout(chain=None, anchors=None, start=0):
"""-> ({memberName: (offset, typeName, size)}, {className: baseOffset}).
Each block starts where the previous ended; /Zp4 means align = min(4, size).
Raises if a computed base contradicts an independently measured anchor.
Defaults to the mech chain; pass another for Torso/Engine and friends.
"""
chain = CHAIN if chain is None else chain
anchors = ANCHORS if anchors is None else anchors
fields, bases, off = {}, {}, start
for cls, path in chain:
off += (-off) % 4
bases[cls] = off
want = anchors.get(cls)
if want is not None and off != want:
raise AssertionError(f"{cls} computed at {off}, measured {want}")
for typ, name, size in members(cls, path):
off += (-off) % min(4, size)
fields.setdefault(name, (off, typ, size))
off += size
return fields, bases
def angle_fields():
"""Fields the tool factory scales by Radians_Per_Degree.
This cannot be inferred from the header: torsoHitSpringMotionLimit and its
siblings are declared plain Stuff::Scalar, yet Mech_Tool.cpp:889 stores
`model->torsoHitSpringMotionLimit * Radians_Per_Degree`. Only the writer
knows. Stuff::Radian fields (tiltSpeed, tiltDegree, topSpeedTurnRate,
fullStopTurnRate) are handled by their declared type as well.
"""
out = set()
for path in FACTORIES:
if not os.path.exists(path):
continue
txt = open(path, encoding="latin-1").read()
for m in re.finditer(r'model->(\w+)\s*=\s*model->\w+\s*\*\s*Radians_Per_Degree', txt):
out.add(norm(m.group(1)))
return out
def declared_fields(cls, path):
"""-> [(fieldName, isAngle)] in declaration order, scalars only.
isAngle marks Stuff::Radian / Stuff::Angle. Those are authored in DEGREES in
the .data but stored in RADIANS in the record, so they need a pi/180 factor
on the way in and 180/pi on the way back out.
"""
txt = open(path, encoding="latin-1").read()
m = re.search(rf'class\s+{cls}\s*:(.*?)^\t\t\}};', txt, re.S | re.M)
if not m:
return []
body = m.group(1)
ctor = body.find(f"{cls}(")
if ctor > 0:
body = body[:ctor]
body = re.sub(r'//[^\n]*', '', body)
out = []
for decl in re.finditer(rf'\b({SCALAR_TYPES})\s+([A-Za-z_][\w\s,]*?);', body, re.S):
angle = decl.group(1) in ("Stuff::Radian", "Stuff::Angle")
for name in decl.group(2).split(","):
n = name.strip()
if re.fullmatch(r'[A-Za-z_]\w*', n):
out.append((n, angle))
return out
def norm(name):
return re.sub(r'^m_', '', name).replace("_", "").lower()
def header_field_map():
"""normalised member name -> (offset, typeName, size, isAngle)."""
angles = angle_fields()
out = {}
for name, (off, typ, size) in chain_layout()[0].items():
key = norm(name)
out.setdefault(key, (off, typ, size, typ == "Radian" or key in angles))
return out
def gamedata(path):
txt = open(path, "rb").read().decode("latin-1")
# Shadow={...} contains a line reading "[shadow]"; without hiding braced
# blocks the page scan stops there and every later key is lost silently.
txt = re.sub(r'\{.*?\}',
lambda x: x.group(0).replace("\r", "").replace("\n", "\x01"),
txt, flags=re.S)
m = re.search(r'^\[GameData\]\r?\n(.*?)(?=^\[[A-Za-z]|\Z)', txt, re.M | re.S)
if not m:
return collections.OrderedDict()
kv = collections.OrderedDict()
for k, v in re.findall(r'^([A-Za-z0-9_]+)=([^\r\n]*)', m.group(1), re.M):
kv.setdefault(k, v.replace("\x01", "\n"))
return kv
def corpus():
"""-> [(chassis, {key: value}, gameModelBytes)] for our 64 mech chassis."""
out = []
for d in sorted(glob.glob(REC + "/*")):
ch = os.path.basename(d)
gm = [g for g in glob.glob(d + "/*.data{GameModel}")
if os.path.basename(g).lower().startswith(ch.lower() + ".data")]
if not gm:
continue
blob = open(gm[0], "rb").read()
if len(blob) != GAMEMODEL_SIZE:
continue
s = [p for p in glob.glob(SRC + "/*/*.data")
if os.path.basename(p).lower() == ch.lower() + ".data"]
if s:
out.append((ch, gamedata(s[0]), blob))
return out
def build():
"""-> (corpus, {sourceKey: (offset, typeName, size, isAngle)})"""
pairs = corpus()
hmap = header_field_map()
keys = collections.Counter()
for _c, kv, _b in pairs:
keys.update(kv.keys())
return pairs, {k: hmap[norm(k)] for k in keys if norm(k) in hmap}
FLOATS = {"Scalar", "Radian", "Angle", "float"}
VECTORS = {"Point3D": 3, "Vector3D": 3, "RGBAColor": 4, "UnitQuaternion": 4}
def read(blob, off, typ="Scalar", size=4, angle=False):
"""Decoded value in the units and form the .data source uses."""
if typ in FLOATS:
v = struct.unpack_from("<f", blob, off)[0]
return v / DEG2RAD if angle else v
if typ in VECTORS:
return list(struct.unpack_from(f"<{VECTORS[typ]}f", blob, off))
if typ == "char":
return blob[off:off + size].split(b"\0")[0].decode("latin-1")
if typ in ("bool", "BYTE"):
return blob[off]
if typ == "WORD":
return struct.unpack_from("<H", blob, off)[0]
if typ == "ResourceID":
return struct.unpack_from("<I", blob, off)[0]
return struct.unpack_from("<i", blob, off)[0]
if __name__ == "__main__":
pairs, field_map = build()
print(f"chassis: {len(pairs)} mapped keys: {len(field_map)}")
for k, (off, typ, size, angle) in sorted(field_map.items(), key=lambda kv: kv[1][0]):
note = " [degrees]" if angle else ""
print(f" {off:5d} {typ:14s} {k}{note}")
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
destroyed.py - regenerate the .data and .video of a *_destroyed mech variant.
python3 destroyed.py <mechDir> [--erf X.erf] [--obb X.obb] [-o outDir]
python3 destroyed.py --verify # regenerate all 89 of ours and diff
A destroyed variant is four files:
<stem>.data text, DeathEntity boilerplate
<stem>.video text, four-page render graph
<stem>.erf geometry, verbatim in the package
<stem>_SOLID.obb collision, verbatim in the package
The .erf and .obb come straight out of the package. The .data and .video are
compiled away (a 12-byte stub plus {Element}/{GameModel}, and a binary element
tree with embedded #FRE/#RLM blobs), so they are regenerated from a template.
That is safe here because the template is invariant. Across all 89 destroyed
variants in our own Content tree, every one of Class, OBBCollides, Collider,
CanBeShot, CanBeWalkedOn, VertexLighting, FaceLighting, LookupLighting,
LightMapLighting and CraterName is identical. The only things that vary are the
three filename references, and those are read from the files actually present
rather than guessed - which also preserves V4H's own `champion_stroyed` typo,
since that is the name their build really uses.
The .data and .video each appear in two forms in our tree, differing only by a
trailing blank line; the majority form is emitted.
"""
import sys, os, re, glob, argparse, collections
DATA_TEMPLATE = (
"[GameData]\r\n"
"Class=MechWarrior4::DeathEntity\r\n"
"SolidOBB={obb}\r\n"
"OBBCollides=false\r\n"
"Collider=false\r\n"
"CanBeShot=false\r\n"
"CanBeWalkedOn=false\r\n"
"VertexLighting=yes\r\n"
"FaceLighting=yes\r\n"
"LookupLighting=no\r\n"
"LightMapLighting=no\r\n"
"CraterName=crater1\r\n"
"\r\n"
"[Renderers]\r\n"
"VideoRenderer={video}\r\n"
"\r\n"
)
VIDEO_TEMPLATE = (
"[lod]\r\n"
"Type=ShapeComponent\r\n"
"Geometry={erf}\r\n"
"\r\n"
"[watcher]\r\n"
"Type=AttributeWatcherOfInt\r\n"
"Attribute=VisualRepresentation\r\n"
"SimulationShouldExecute=1\r\n"
"\r\n"
"[damageappearance]\r\n"
"Type=SwitchComponent\r\n"
"Input=Watcher\r\n"
"Child=LOD\r\n"
"\r\n"
"[locator]\r\n"
"Child=DamageAppearance\r\n"
"\r\n"
)
OUR_MECH_SOURCE = "/home/rich/Repositories/firestorm/Gameleap/mw4/Content/Mechs"
def find_parts(mech_dir, compiled_dir=None):
"""-> (stem, erfName, obbName). Looks in mech_dir, then the compiled tree."""
erfs = [f for f in os.listdir(mech_dir) if f.lower().endswith(".erf")]
obbs = [f for f in os.listdir(mech_dir) if f.lower().endswith(".obb")]
stem = None
if compiled_dir and os.path.isdir(compiled_dir):
datas = [f for f in os.listdir(compiled_dir)
if f.lower().endswith(".data")]
if datas:
stem = datas[0][:-len(".data")]
if stem is None and obbs:
stem = re.sub(r"_solid\.obb$", "", obbs[0], flags=re.I)
stem = re.sub(r"\.obb$", "", stem, flags=re.I)
return stem, (erfs[0] if erfs else None), (obbs[0] if obbs else None)
def generate(stem, erf, obb):
return (DATA_TEMPLATE.format(obb=obb, video=f"{stem}.video"),
VIDEO_TEMPLATE.format(erf=erf))
def verify():
ok = collections.Counter()
problems = []
for src_dir in sorted(glob.glob(OUR_MECH_SOURCE + "/*_[Dd]estroyed") +
glob.glob(OUR_MECH_SOURCE + "/*_DESTROYED")):
datas = glob.glob(src_dir + "/*.data")
videos = glob.glob(src_dir + "/*.video")
if not datas or not videos:
continue
stem = os.path.basename(datas[0])[:-len(".data")]
want_data = open(datas[0], "rb").read().decode("latin-1")
want_video = open(videos[0], "rb").read().decode("latin-1")
kv = dict(re.findall(r'^([A-Za-z0-9_]+)=([^\r\n]*)', want_data, re.M))
erf = re.search(r'Geometry=([^\r\n]*)', want_video).group(1).strip()
got_data, got_video = generate(stem, erf, kv["SolidOBB"].strip())
ok["files"] += 1
for what, want, got, exact_key, soft_key in (
(".data", want_data, got_data, "data_exact", "data_soft"),
(".video", want_video, got_video, "video_exact", "video_soft")):
if got == want:
ok[exact_key] += 1
elif got.rstrip("\r\n").lower() == want.rstrip("\r\n").lower():
# NotationFile compares with _stricmp throughout, and the sources
# themselves are inconsistent (one uses [renderers], others
# [Renderers]), so case and a trailing blank line are cosmetic.
ok[soft_key] += 1
else:
problems.append((os.path.basename(src_dir), what, want, got))
print(f"destroyed variants checked : {ok['files']}")
print(f" .data equivalent : {ok['data_exact'] + ok['data_soft']}"
f" (byte-exact {ok['data_exact']}, +{ok['data_soft']} case / trailing-blank-line only)")
print(f" .video equivalent : {ok['video_exact'] + ok['video_soft']}"
f" (byte-exact {ok['video_exact']}, +{ok['video_soft']} case / trailing-blank-line only)")
if problems:
print(f"\n{len(problems)} real differences:")
for name, what, want, got in problems[:6]:
print(f" {name} {what}")
for w, g in zip(want.splitlines(), got.splitlines()):
if w != g:
print(f" source={w!r}\n got ={g!r}")
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("mechdir", nargs="?", help="the *_destroyed folder holding .erf/.obb")
ap.add_argument("--compiled", help="matching folder in the _compiled tree, for the stem")
ap.add_argument("-o", "--out", help="output folder (defaults to mechdir)")
ap.add_argument("--verify", action="store_true")
args = ap.parse_args()
if args.verify:
verify()
return
if not args.mechdir:
ap.error("give a *_destroyed folder, or --verify")
stem, erf, obb = find_parts(args.mechdir, args.compiled)
if not (stem and erf and obb):
sys.exit(f"{args.mechdir}: need a stem, an .erf and an .obb "
f"(got stem={stem!r} erf={erf!r} obb={obb!r})")
data, video = generate(stem, erf, obb)
out = args.out or args.mechdir
os.makedirs(out, exist_ok=True)
open(os.path.join(out, f"{stem}.data"), "wb").write(data.encode("latin-1"))
open(os.path.join(out, f"{stem}.video"), "wb").write(video.encode("latin-1"))
print(f"{os.path.basename(args.mechdir)}: {stem}.data + {stem}.video "
f"(SolidOBB={obb}, Geometry={erf})")
if __name__ == "__main__":
main()
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""
instance.py - decompiler for a mech `<chassis>.instance` file.
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
`datamap.chain_layout(..., start=16)` computes it -- 16 because the
`Connection__Message` header (messageLength, priority, flags) sits in front and
is not declared in any of these classes. The result ends at 341 and pads to
exactly the 344-byte record, and every offset established independently while
decoding `.armature` lands on the nose: classID 16, replicatorID 24,
localToParent 28, dataListID 84, alignment 88, jointName 152.
Two parser gaps had to be closed to get there, both fields typed with names the
member regex did not know: `Stuff::RegisteredClass::ClassID` / `ReplicatorID` in
the Replicator base, and `Entity__ExecutionStateEngine::FactoryRequest` /
`ObjectID` in Entity. Missing them silently shifted everything after offset 76
by 8 bytes while still producing a plausible-looking table.
python3 instance.py <chassis-record-dir> [-o out.instance]
python3 instance.py --verify
"""
import argparse, os, re, struct, subprocess, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import datamap, mw4msg, subsystems
MANIFEST = "/home/rich/Repositories/FS_Ours_extracted/_manifest.tsv"
C = datamap.CODE
CHAIN = [
("Replicator__CreateMessage", C + "/mw4/Libraries/Adept/Replicator.hpp"),
("Entity__CreateMessage", C + "/mw4/Libraries/Adept/Entity.hpp"),
("Mover__CreateMessage", C + "/mw4/Libraries/Adept/Mover.hpp"),
("MWMover__CreateMessage", C + "/mw4/Code/MW4/MWMover.hpp"),
("MWObject__CreateMessage", C + "/mw4/Code/MW4/MWObject.hpp"),
("Vehicle__CreateMessage", C + "/mw4/Code/MW4/Vehicle.hpp"),
("Mech__CreateMessage", C + "/mw4/Code/MW4/Mech.hpp"),
]
CONNECTION_HEADER = 16
EXEC_STATE = {1: "NeverExecuteState", 2: "AlwaysExecuteState", 6: "ActiveState"}
# Entity.hpp:894
ALIGNMENT = {0: "DefaultAlignment", 1: "Player", 2: "Enemy",
3: "Team1", 4: "Team2", 5: "Team3", 6: "Team4"}
# source key -> message member, in the order 61 of 89 sources use
KEYS = [
("Model", "dataListID"),
("ExecutionState", "executionState"),
("Armature", "armatureStreamResourceID"),
("Subsystems", "subsystemStreamResourceID"),
("DamageObjects", "damageStreamResourceID"),
("Alignment", "alignment"),
("CurrentHeat", "currentHeat"),
("CurrentCoolant", "currentCoolant"),
("MaxCoolant", "maxCoolant"),
("DoesHaveInstanceName", "doesHaveInstanceName"),
("PowerRating", "m_powerBar"),
("ArmorRating", "m_armorBar"),
("SpeedRating", "m_speedBar"),
("HeatRating", "m_heatBar"),
]
def record(mech_dir):
for fn in os.listdir(mech_dir):
if fn.lower().endswith(".instance"):
return open(os.path.join(mech_dir, fn), "rb").read()
raise SystemExit(f"no .instance record in {mech_dir}")
def relative(path, chassis):
"""References are written relative to the mech folder."""
if not path:
return path
return re.sub(rf'^mechs[\\/]{re.escape(chassis or "")}[\\/]', '', path, flags=re.I)
def num(x):
return f"{int(x)}" if float(x) == int(x) else f"{x:g}"
def decompile(mech_dir, manifest=None):
"""-> (pageName, [(key, value)])"""
manifest = manifest if manifest is not None else subsystems.load_manifest(MANIFEST)
blob = record(mech_dir)
layout, _ = datamap.chain_layout(CHAIN, {}, start=CONNECTION_HEADER)
msgs = list(mw4msg.walk(blob, start=0))
if len(msgs) != 1:
raise SystemExit(f"expected one message in {mech_dir}, got {len(msgs)}")
msg = msgs[0][1]
name = datamap.read(msg, *layout["jointName"])
folder = os.path.basename(mech_dir.rstrip("/"))
kv = []
for key, member in KEYS:
off, typ, size = layout[member]
val = datamap.read(msg, off, typ, size)
if member == "executionState":
kv.append((key, EXEC_STATE.get(val, str(val))))
elif member == "alignment":
kv.append((key, ALIGNMENT.get(val, str(val))))
elif typ == "ResourceID":
path = manifest.get(val >> 16)
kv.append((key, relative(path, folder) if path else ""))
elif typ == "bool":
kv.append((key, "yes" if val else "no"))
elif typ in datamap.FLOATS:
kv.append((key, num(val)))
else:
kv.append((key, str(val)))
return name, kv
def emit(name, kv):
lines = [f"[{name}]"] + [f"{k}={v}" for k, v in kv]
return "\r\n".join(lines) + "\r\n"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("mech_dir", nargs="?")
ap.add_argument("-o", "--output")
ap.add_argument("-m", "--manifest", default=MANIFEST)
ap.add_argument("--verify", action="store_true")
args = ap.parse_args()
if args.verify:
here = os.path.dirname(os.path.abspath(__file__))
raise SystemExit(subprocess.call([sys.executable,
os.path.join(here, "verify_instance.py")]))
if not args.mech_dir:
ap.error("mech_dir required")
name, kv = decompile(args.mech_dir, subsystems.load_manifest(args.manifest))
text = emit(name, kv)
if args.output:
with open(args.output, "wb") as fh:
fh.write(text.encode("latin-1"))
print(f"wrote {args.output}")
else:
sys.stdout.write(text)
if __name__ == "__main__":
main()
@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""
make_generic_doll.py - build a generic MFD/Radar damage paper doll.
The six chassis imported from V4H have no damage art. V4H shipped Mad Cat copies
under six names, which renders an aligned doll that lies about the mech. Instead
this builds one honest generic doll from art the game already owns.
Source: `hsh/mfd_texture.bmp`, the MFD sprite sheet, which contains a generic
mech already separated into components, together with a validated 11-zone
mapping in `huddamage.cpp` (`sm2_texture` / `sm2_offset`, lines 472 and 487) --
the same zone order `coord.cpp` uses. `RenderAux4SmallMech` draws it, so the
geometry below is proven in game rather than measured by us.
Output is the exploded runtime BMP: every zone appears exactly once, and no two
source rectangles touch, so each `texuv` isolates one component. The game
reassembles the figure from the `offset` values.
python3 make_generic_doll.py [-o OUTDIR]
"""
import argparse, os, sys
from PIL import Image, ImageDraw
HSH = "/home/rich/Repositories/firestorm/Gameleap/mw4/hsh"
# huddamage.cpp:472 -- source rects into mfd_texture.bmp, in coord.cpp zone order
SM2_TEXTURE = [
(172, 0, 231, 140, "LL", "left leg"),
(109, 0, 168, 140, "RL", "right leg"),
(30, 144, 67, 246, "LA", "left arm"),
(0, 36, 36, 128, "RA", "right arm"),
(37, 24, 66, 101, "RT", "right torso"),
(0, 132, 28, 209, "LT", "left torso"),
(67, 33, 99, 125, "CT", "center torso"),
(None, None, None, None, "CTR", "center torso rear"),
(67, 0, 99, 31, "HD", "head"),
(None, None, None, None, "S1", "special 1"),
(None, None, None, None, "S2", "special 2"),
]
# huddamage.cpp:487 -- assembled positions (the *3 is already applied here)
SM2_OFFSET = [
(29 * 3, 35 * 3), (6 * 3, 35 * 3), (43 * 3, 11 * 3), (0 * 3, 11 * 3),
(12 * 3, 7 * 3), (33 * 3, 7 * 3), (22 * 3, 10 * 3), None,
(22 * 3, 0 * 3), None, None,
]
# The native figure is 166x245. Real dolls assemble to roughly 330x335 (Atlas,
# Assassin II), so 1.5 lands in the right range and keeps the arithmetic exact:
# the generic centre torso becomes 48x138 against Atlas's 48x174.
#
# Radar is authored on a 410 working canvas against the MFD's 340, and real rows
# follow that -- Assassin II's radar rects run about 1.27x its MFD ones -- so the
# Radar doll is scaled up to match. The runtime halves Radar values at draw time.
MFD_SCALE = 1.5
RADAR_SCALE = 1.8
CANVAS = 512
def even(v):
"""Coordinates are authored even; runtime Radar halves them with integer /2."""
return int(round(v / 2.0)) * 2
def components(scale):
sheet = Image.open(os.path.join(HSH, "mfd_texture.bmp")).convert("L")
out = []
for (rect, off) in zip(SM2_TEXTURE, SM2_OFFSET):
x0, y0, x1, y1, zone, label = rect
if x0 is None:
out.append((zone, label, None, None, None))
continue
piece = sheet.crop((x0, y0, x1, y1))
w = even((x1 - x0) * scale)
h = even((y1 - y0) * scale)
piece = piece.resize((w, h), Image.LANCZOS)
out.append((zone, label, piece, (even(off[0] * scale), even(off[1] * scale)), (w, h)))
return out
def layout(comps, outline=False):
"""Place every piece in the 512 canvas without touching. -> (image, {zone: rect})"""
img = Image.new("L", (CANVAS, CANVAS), 0)
draw = ImageDraw.Draw(img)
rects = {}
x, y, row_h, gap = 4, 4, 0, 8
for zone, _label, piece, _off, size in comps:
if piece is None:
rects[zone] = (0, 0, 0, 0)
continue
w, h = size
if x + w + gap > CANVAS:
x = 4
y += row_h + gap
row_h = 0
img.paste(piece, (x, y))
if outline:
draw.rectangle([x, y, x + w - 1, y + h - 1], outline=255, width=2)
rects[zone] = (x, y, x + w, y + h)
x += w + gap
row_h = max(row_h, h)
return img, rects
def row(values, width=3):
return "{" + ",".join("{" + ",".join(f"{v:{width}d}" for v in t) + "}" for t in values) + "}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-o", "--outdir", default="/home/rich/Repositories/FS_Build_V4H_extracted")
args = ap.parse_args()
comps = components(MFD_SCALE)
order = [c[0] for c in comps]
written = []
tables = {}
for kind, sub, outlined, scale in (("MFD", "hsh/hud", False, MFD_SCALE),
("Radar", "hsh/radar/hud", True, RADAR_SCALE)):
comps = components(scale)
img, rects = layout(comps, outline=outlined)
d = os.path.join(args.outdir, sub)
os.makedirs(d, exist_ok=True)
p = os.path.join(d, "generic.bmp")
img.save(p)
written.append(p)
texuv = [rects[z] for z in order]
offset = [(c[3] if c[3] else (0, 0)) for c in comps]
offset = [o if rects[z] != (0, 0, 0, 0) else (0, 0) for z, o in zip(order, offset)]
tables[kind] = (texuv, offset)
doc = os.path.join(args.outdir, "hsh", "GENERIC-DOLL-COORDS.txt")
with open(doc, "w", newline="\r\n") as fh:
fh.write(TEXT.format(
scale=MFD_SCALE,
rscale=RADAR_SCALE,
zones=" ".join(f"{i}:{z}" for i, z in enumerate(order)),
mfd_texuv=row(tables["MFD"][0]),
mfd_offset=row(tables["MFD"][1]),
rad_texuv=row(tables["Radar"][0]),
rad_offset=row(tables["Radar"][1]),
))
written.append(doc)
for p in written:
print("wrote", p)
TEXT = """GENERIC MECH DAMAGE PAPER DOLL - coordinates for coord.cpp
=========================================================
WHAT THIS IS
A generic 'Mech damage doll for chassis that have no bespoke MFD/Radar art.
Six imported V4H chassis (champion, dasher, griffin, jenner2c, marauder,
thunderbolt) ship with none. V4H filled the gap with pixel-identical copies
of the Mad Cat doll under six names, which renders an aligned display that
misrepresents the mech. This is the honest placeholder instead.
WHERE THE ART CAME FROM
Not drawn from scratch. The game already owns a generic 'Mech, separated
into components, inside hsh/mfd_texture.bmp (the MFD sprite sheet), with a
validated eleven-zone mapping in huddamage.cpp:
sm2_texture[][4] line 472 source rectangles
sm2_offset[][2] line 487 assembled positions
drawn by RenderAux4SmallMech(). Those tables use the same zone order as
coord.cpp, so the geometry below is proven in game, not measured by us.
Components were scaled {scale}x for the MFD and {rscale}x for the Radar. The
native figure is 166x245 and real dolls assemble to roughly 330x335, so this
lands in the right range; the generic centre torso ends up 48x138 against the
Atlas's 48x174. The Radar is larger because it is authored on a 410 working
canvas against the MFD's 340, and the runtime halves Radar values at draw.
FILES
hsh/hud/generic.bmp 512x512 external MFD doll
hsh/radar/hud/generic.bmp 512x512 Radar doll, components outlined 2px
white per the Radar pipeline
Both are the EXPLODED runtime view: each zone appears exactly once and no
two source rectangles touch, so a texuv isolates one component. The game
reassembles the figure from the offsets.
ZONE ORDER (index: zone)
{zones}
CTR, S1 and S2 are all-zero, and that is normal rather than a shortcut. Across
the 65 shipped rows CTR is zero in ALL 65, S1 in 31 and S2 in 49. Neither
draw loop guards zero rectangles - a degenerate quad simply renders nothing -
so absent zones are safe. The generic figure has no rear silhouette and no
special hardpoints.
ONE FILE SERVES ALL SIX
The BMP name comes only from `texturename[]` in huddamage.cpp; both displays
use it (`LoadDamageTexture` and `LoadRadarDamageTexture` are both called with
`texturename[m_MechID]`). Nothing in the mech data files names it. So six
entries pointing at "hud\\\\generic" share one pair of images - do not make
per-chassis copies. (`m_HudMap` is unrelated: that is the mission map.)
--------------------------------------------------------------------------
HOW TO INSTALL
--------------------------------------------------------------------------
1. Copy the art
hsh/hud/generic.bmp -> Gameleap/mw4/hsh/hud/generic.bmp
hsh/radar/hud/generic.bmp -> Gameleap/mw4/hsh/radar/hud/generic.bmp
Loose hsh art is not packed into a .mw4, so no resource rebuild is needed.
2. huddamage.cpp - add one texturename[] entry per new chassis, all pointing at
the same generic art. The array is sized [LastMechID+1], so LastMechID in
MechLabHeaders.h must also rise from 64 to 70.
"hud\\\\generic", // M_Champion 65
"hud\\\\generic", // M_Jenner2c 66
"hud\\\\generic", // M_Dasher 67
"hud\\\\generic", // M_Marauder 68
"hud\\\\generic", // M_Thunderbolt 69
"hud\\\\generic", // M_Griffin 70
Keep V4H's append order (65-70). Inserting alphabetically would renumber
every existing chassis, and Mech IDs are positional across code, tables and
shell scripts.
3. coord.cpp - widen all four arrays from [65] to [71] and append the SAME row
six times to each, once per new chassis. All six share one doll, so all six
rows are identical.
texuv2 / offset2 are the external MFD. texuv3 / offset3 are the Radar, which
the runtime divides by two at draw time - store the full-size values here,
do not pre-divide.
texuv2 (MFD source rectangles)
{mfd_texuv}
offset2 (MFD exploded positions)
{mfd_offset}
texuv3 (Radar source rectangles)
{rad_texuv}
offset3 (Radar exploded positions)
{rad_offset}
4. Rebuild. DXRasterizer.cpp includes coord.cpp directly, so a coordinate change
needs a Release/Profile rebuild and redeploy of MW4.exe. The art alone does
not.
--------------------------------------------------------------------------
NOTES AND LIMITS
--------------------------------------------------------------------------
* Dasher's four commented-out rows were REMOVED from coord.cpp on 2026-08-08.
They were unique authored Dasher geometry rather than a copy, but the art they
mapped has never existed, and leaving them invited someone to enable them
against wrong art. Recoverable from git (last touched in deafc2b0) if real
Dasher art is ever produced. The matching commented `"hud\\\\dasher"` entry at
huddamage.cpp:81 is still there and should go with them.
* The generic cannot represent chassis-specific zones. Marauder's cage and
Dasher's S1/S2 will not appear.
* This is a placeholder. The point of a generic silhouette over a borrowed one
is that it reads as "no art yet" rather than as the wrong mech. Replace it
per chassis when real 1024x1024 renders exist; the authoring pipelines are
in MFD-RADAR-MAPPINGS.md.
* PRE-EXISTING BUGS, unrelated to this work but found while checking it. Three
chassis share another mech's rows while having their own art. Overlaying each
row on its own art shows:
Rifleman (id 49, uses Mad Cat's rows) - BROKEN. CT lands in empty
space beside the mech, S1/S2 float in empty corners, and the
leg boxes run well past the feet.
Battlemaster (id 9, uses Atlas's rows) - BROKEN. CT sits over a
left-side arm piece and the leg boxes are narrow centre
strips while the real legs fall outside them.
Templar (id 54, uses Archer's rows) - fine. Its art was evidently
authored to that layout; only the leg boxes clip slightly.
Rifleman and Battlemaster need their own measured rows, or art authored to
the borrowed layout.
"""
if __name__ == "__main__":
main()
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
mw4msg.py - reader for the GameOS "CreateMessage" streams found inside .mw4
records (.subsystems, [joint_*]{armature}, .contents, .instance, ...).
Layout, derived from the engine source rather than guessed:
MWObject::CreateSubsystemStream / CreateArmatureStream (MWObject_Tool.cpp)
WORD span number of replicator IDs consumed
N x CreateMessage one per [Page] that was serialised
Each message is a plain C struct, /Zp4, built by a per-class factory in one of
the 51 mw4/Code/MW4/*_Tool.cpp files. The common prefix is:
off 0 u32 messageLength Connection__Message
off 4 u32 messageID
off 8 u32 priority
off 12 u32 messageFlags
off 16 u32 classID Replicator__CreateMessage
off 20 u32 replicatorFlags
off 24 u32 replicatorID
off 28 12f localToParent Entity__CreateMessage (LinearMatrix4D)
off 76 u32 executionState
off 80 f32 initialAge
off 84 u32 dataListID (ResourceID; record id is the HIGH word)
off 88 u32 alignment
off 92 u32 nameID
Mover__CreateMessage then adds two 24-byte Motion3D fields (96, 120), and
MWMover__CreateMessage adds:
off 144 u32 siteStreamResourceID
off 148 u32 armatureStreamResourceID
off 152 char jointName[128]
Verified: a joint message is exactly 280 bytes, which is 152 + 128.
localToParent is 3 rows of 4 floats; the rotation is columns 0..2 and the
translation is column 3 of each row. Checked against every joint of all 65
chassis: 1453/1453 translations matched the source .armature exactly.
"""
import struct
HDR_LEN = 0
HDR_CLASSID = 16
HDR_REPLICATORID = 24
ENT_MATRIX = 28
ENT_EXECSTATE = 76
ENT_INITIALAGE = 80
ENT_DATALISTID = 84
ENT_ALIGNMENT = 88
ENT_NAMEID = 92
MWMOVER_SITEID = 144
MWMOVER_ARMID = 148
MWMOVER_JOINTNAME = 152
def walk(data, start=2):
"""Yield (offset, messageBytes) for each message in a span-prefixed stream."""
off = start
while off + 4 <= len(data):
length, = struct.unpack_from("<I", data, off)
if length < 16 or off + length > len(data):
raise ValueError(f"bad messageLength {length} at offset {off}")
yield off, data[off:off + length]
off += length
if off != len(data):
raise ValueError(f"trailing {len(data) - off} bytes")
def span(data):
return struct.unpack_from("<H", data, 0)[0]
def u32(msg, off):
return struct.unpack_from("<I", msg, off)[0]
def class_id(msg):
return u32(msg, HDR_CLASSID)
def record_id(msg, off=ENT_DATALISTID):
"""ResourceID packs the package record id in its high word."""
return u32(msg, off) >> 16
def matrix(msg):
return struct.unpack_from("<12f", msg, ENT_MATRIX)
def translation(msg):
m = matrix(msg)
return (m[3], m[7], m[11])
def rotation3x3(msg):
m = matrix(msg)
return (m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10])
def joint_name(msg):
if len(msg) <= MWMOVER_JOINTNAME:
return None
return msg[MWMOVER_JOINTNAME:].split(b"\0")[0].decode("latin-1")
def read_sites(data):
"""{sites} record: repeated [YawPitchRoll 3f][Point3D 3f][u32 len][name][NUL].
Written by MWMover__CreateMessage::ConstructCreateMessage (MWMover_Tool.cpp
~145): `site_stream << rotation; << translation; << site_name;`
Angles are radians.
"""
out, off = [], 0
while off + 28 <= len(data):
rx, ry, rz, tx, ty, tz = struct.unpack_from("<6f", data, off)
off += 24
n, = struct.unpack_from("<I", data, off)
off += 4
name = data[off:off + n].decode("latin-1")
off += n + 1 # names are length-prefixed AND NUL-terminated
out.append((name, (rx, ry, rz), (tx, ty, tz)))
return out
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""
smallmodel.py - decompilers for a mech's `.torso` and `.engine` files.
Both are single-page `[GameData]` subsystem models stored as flat structs, so
they reuse `datamap.chain_layout()` with their own class chains:
Entity__GameModel -> Subsystem__GameModel -> Torso__GameModel 1608 bytes
Entity__GameModel -> Subsystem__GameModel -> Engine__GameModel 64 bytes
Both chains compute to exactly the record size with no anchoring, which is the
check that the member list and alignment are right.
Sources author the numbers as `$(SYMBOL)` macros from a `!include`d defines file
(`!NAME=value` syntax, not `#define`), and the record keeps only the resolved
float, so the symbol is restored by reverse lookup where one matches.
Two quirks worth keeping:
* 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.
* `TotalCritLocations` is read by **nothing** in the engine -- only the 3DS Max
export plugin 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 rather than derived.
python3 smallmodel.py torso|engine <chassis-record-dir> [-o out]
python3 smallmodel.py --verify
"""
import argparse, collections, os, re, subprocess, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import datamap
C = datamap.CODE
CONTENT = "/home/rich/Repositories/firestorm/Gameleap/mw4/Content"
SPECS = {
"torso": {
"chain": [("Entity__GameModel", C + "/mw4/Libraries/Adept/Entity.hpp"),
("Subsystem__GameModel", C + "/mw4/Code/MW4/Subsystem.hpp"),
("Torso__GameModel", C + "/mw4/Code/MW4/Torso.hpp")],
"size": 1608,
"factory": C + "/mw4/Code/MW4/Torso_Tool.cpp",
"defines": CONTENT + "/Defines/MechTorso.defines",
"include": r"Content\Defines\MechTorso.defines",
"class": "MechWarrior4::Torso",
"keys": [("TwistJointName", "twistJointName"),
("PitchJointName", "pitchJointName"),
("LeftArmJointName", "leftArmJointName"),
("RightArmJointName", "rightArmJointName"),
("EyeJointName", "eyeJointName"),
("ArmRatioAngle", "armRatioAngle"),
("TwistSpeed", "twistSpeed"),
("PitchSpeed", "pitchSpeed"),
("TwistRadius", "twistRadius"),
("PitchRadius", "pitchRadius"),
("CageJointName", "cageJointName"),
("CageRatioAngle", "cageRatioAngle")],
},
"engine": {
"chain": [("Entity__GameModel", C + "/mw4/Libraries/Adept/Entity.hpp"),
("Subsystem__GameModel", C + "/mw4/Code/MW4/Subsystem.hpp"),
("Engine__GameModel", C + "/mw4/Code/MW4/Engine.hpp")],
"size": 64,
"factory": C + "/mw4/Code/MW4/Engine_Tool.cpp",
"defines": CONTENT + "/Subsystems/HeatSink.Defines",
"include": r"Content\Subsystems\HeatSink.defines",
"class": "Mechwarrior4::Engine", # lowercase 'w' in every source
"keys": [("NumHeatSinks", "m_numHeatSinks"),
("TonsPerUpgrade", "m_tonsPerUpgrade"),
("MPSPerUpgrade", "m_mpsPerUpgrade"),
("HeatSinkEfficiency", "m_heatSinkEfficiency")],
},
}
# Written only by the 3DS Max exporter; the engine never reads it. Uniformly 2.
TOTAL_CRIT_LOCATIONS = "2"
def defines(path):
"""-> {value: symbol} from a `!NAME=value` defines file."""
out = {}
if not os.path.exists(path):
return out
txt = open(path, encoding="latin-1", errors="replace").read()
txt = re.sub(r'//[^\n]*', '', txt)
for name, val in re.findall(r'^\s*!(\w+)\s*=\s*([-\d.]+)\s*$', txt, re.M):
out.setdefault(round(float(val), 6), name)
return out
def angle_members(factory):
out = set()
if not os.path.exists(factory):
return out
txt = open(factory, encoding="latin-1").read()
for m in re.finditer(r'model->(\w+)\s*=\s*model->\w+\s*\*\s*Radians_Per_Degree', txt):
out.add(m.group(1))
return out
def record(mech_dir, kind):
for fn in os.listdir(mech_dir):
if fn.lower().endswith(f".{kind}{{gamemodel}}"):
return open(os.path.join(mech_dir, fn), "rb").read()
return None
def num(x):
return f"{int(x)}" if float(x) == int(x) else f"{x:g}"
def decompile(mech_dir, kind):
"""-> [(key, value)] for the [GameData] page."""
spec = SPECS[kind]
blob = record(mech_dir, kind)
if blob is None:
raise SystemExit(f"no .{kind}{{GameModel}} record in {mech_dir}")
layout, _ = datamap.chain_layout(spec["chain"], {})
angles = angle_members(spec["factory"])
symbols = defines(spec["defines"])
kv = [("Class", spec["class"]), ("TotalCritLocations", TOTAL_CRIT_LOCATIONS)]
for key, member in spec["keys"]:
off, typ, size = layout[member]
val = datamap.read(blob, off, typ, size, member in angles)
if typ == "char":
kv.append((key, val))
elif typ == "int":
kv.append((key, str(val)))
else:
sym = symbols.get(round(val, 6))
kv.append((key, f"$({sym})" if sym else num(val)))
return kv
def emit(kind, kv):
lines = [f"!include = {SPECS[kind]['include']}", "", "[GameData]"]
lines += [f"{k}={v}" for k, v in kv]
return "\r\n".join(lines) + "\r\n"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("kind", nargs="?", choices=sorted(SPECS))
ap.add_argument("mech_dir", nargs="?")
ap.add_argument("-o", "--output")
ap.add_argument("--verify", action="store_true")
args = ap.parse_args()
if args.verify:
here = os.path.dirname(os.path.abspath(__file__))
raise SystemExit(subprocess.call([sys.executable,
os.path.join(here, "verify_smallmodel.py")]))
if not args.kind or not args.mech_dir:
ap.error("kind and mech_dir required")
text = emit(args.kind, decompile(args.mech_dir, args.kind))
if args.output:
with open(args.output, "wb") as fh:
fh.write(text.encode("latin-1"))
print(f"wrote {args.output}")
else:
sys.stdout.write(text)
if __name__ == "__main__":
main()
+239
View File
@@ -0,0 +1,239 @@
#!/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()
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Verification harness for the .armature decompiler.
Rebuilds every chassis from our own packed records and compares against the
known source .armature. Compares page transforms as a multiset keyed on
(name, transform) so duplicate page names - Victor has two [site_lshellport]
pages under different joints - are handled.
python3 verify_armature.py
"""
import sys, os, glob, re, collections
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import armature
def source_entries(path):
"""-> (list of (name, rot, trans), {parent: [child, ...]})"""
txt = open(path, "rb").read().decode("latin-1")
entries, children = [], {}
for m in re.finditer(r'^\[([^\]]+)\]\s*\r?\n(.*?)(?=^\[|\Z)', txt, re.M | re.S):
name, body = m.group(1).lower(), m.group(2)
r = re.search(r'Rotation=([-\d.eE ]+)', body)
t = re.search(r'Translation=([-\d.eE ]+)', body)
kids = [c.lower() for c in re.findall(r'Child=(\S+)', body)]
if kids:
children[name] = kids
if r and t:
entries.append((name,
tuple(float(x) for x in r.group(1).split()),
tuple(float(x) for x in t.group(1).split())))
return entries, children
def close(a, b, tol):
return a and b and max(abs(x - y) for x, y in zip(a, b)) <= tol
def angles_close(a, b, tol=0.01):
return a and b and max(abs(((x - y + 180) % 360) - 180) for x, y in zip(a, b)) <= tol
def main():
src_paths = {os.path.basename(p)[:-len(".armature")].lower(): p
for p in glob.glob(armature.OUR_MECH_SOURCE + "/*/*.armature")}
t = collections.Counter()
residual = collections.Counter()
for mech_dir in sorted(glob.glob(armature.OUR_RECORDS + "/*")):
if not glob.glob(mech_dir + "/*{armature}"):
continue
chassis, entries, children = armature.rebuild(mech_dir)
if not chassis or chassis.lower() not in src_paths:
continue
t["chassis"] += 1
src_list, src_children = source_entries(src_paths[chassis.lower()])
got = [(e["name"].lower(), e["rot"], e["trans"]) for e in entries]
pool = list(got)
for name, rot, tr in src_list:
t["pages"] += 1
hit = next((g for g in pool if g[0] == name
and close(g[2], tr, 2e-3) and angles_close(g[1], rot)), None)
if hit:
pool.remove(hit)
t["exact"] += 1
continue
near = next((g for g in pool if g[0] == name and close(g[2], tr, 2e-3)), None)
if near:
pool.remove(near)
t["rot_only"] += 1
residual[name] += 1
else:
t["missing"] += 1
residual["MISSING " + name] += 1
for parent, kids in src_children.items():
t["childlists"] += 1
if sorted(kids) == sorted(c.lower() for c in children.get(parent, ())):
t["childlists_ok"] += 1
else:
residual["CHILDREN " + parent] += 1
print(f"chassis : {t['chassis']}")
print(f"pages compared : {t['pages']}")
print(f" fully exact : {t['exact']}")
print(f" translation ok, rotation lost by the packer : {t['rot_only']}")
print(f" not recovered : {t['missing']}")
print(f"child lists : {t['childlists_ok']}/{t['childlists']} exact")
if residual:
print("\nresidual, by page:")
for k, v in residual.most_common(10):
print(f" {k:22s} x{v}")
if __name__ == "__main__":
main()
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Round-trip verifier for the .contents decompiler.
Regenerates each `.contents` and compares page names and both key values against
the authored source. Page order is not compared -- NotationFile is
order-independent, and the packer groups pages by parent joint rather than
preserving the authored sequence.
python3 verify_contents.py [--show CHASSIS]
"""
import argparse, collections, glob, os, re, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import contents, subsystems
REC = "/home/rich/Repositories/FS_Ours_extracted/core/mechs"
SRC = "/home/rich/Repositories/firestorm/Gameleap/mw4/Content/Mechs"
def source_pages(path):
txt = open(path, "rb").read().decode("latin-1")
txt = re.sub(r'//[^\n]*', '', txt)
out = {}
for m in re.finditer(r'^\[([^\]]+)\]\r?\n(.*?)(?=^\[|\Z)', txt, re.M | re.S):
name, body = m.group(1), m.group(2)
if name.lower() == "includes":
continue
kv = dict(re.findall(r'^([A-Za-z_]\w*)=([^\r\n]*)', body, re.M))
out[name.lower()] = {k.lower(): v.strip().lower().replace("/", "\\")
for k, v in kv.items()}
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--show")
args = ap.parse_args()
manifest = subsystems.load_manifest(contents.MANIFEST)
t = collections.Counter()
bad = collections.Counter()
examples = []
missing_pages = collections.Counter()
extra_pages = collections.Counter()
for d in sorted(glob.glob(REC + "/*")):
ch = os.path.basename(d)
src = [p for p in glob.glob(SRC + "/*/*.contents")
if os.path.basename(p).lower() == ch.lower() + ".contents"]
if not src:
continue
t["chassis"] += 1
want = source_pages(src[0])
chassis, pages = contents.decompile(d, manifest)
got = {n.lower(): {k.lower(): (v or "").lower().replace("/", "\\")
for k, v in kv} for n, kv in pages}
if args.show and args.show.lower() == ch.lower():
sys.stdout.write(contents.emit(chassis, pages))
return
for name in want:
if name not in got:
missing_pages[name] += 1
for name in got:
if name not in want:
extra_pages[name] += 1
for name in set(want) & set(got):
t["pages"] += 1
for key in ("model", "executionstate"):
t["keys"] += 1
if want[name].get(key) == got[name].get(key):
t["ok"] += 1
else:
bad[key] += 1
if len(examples) < 10:
examples.append((ch, name, key,
want[name].get(key), got[name].get(key)))
print(f"chassis : {t['chassis']}")
print(f"pages compared : {t['pages']}")
print(f"keys compared : {t['keys']} exact: {t['ok']} wrong: {t['keys'] - t['ok']}")
if missing_pages:
print(f"\npages in source but not decoded: {sum(missing_pages.values())}")
for n, c in missing_pages.most_common(10):
print(f" {n:28s} x{c}")
if extra_pages:
print(f"\npages decoded but not in source: {sum(extra_pages.values())}")
for n, c in extra_pages.most_common(10):
print(f" {n:28s} x{c}")
if bad:
print("\nvalue mismatches:")
for k, c in bad.most_common():
print(f" {k:20s} x{c}")
for e in examples:
print(f" {e[0]:14s} {e[1]:24s} {e[2]:16s} want={e[3]!r} got={e[4]!r}")
if __name__ == "__main__":
main()
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Round-trip verifier for the .damage decompiler.
Regenerates a whole `.damage` from the compiled record for every chassis and
compares it page by page against the authored source: page names, page order,
key sets and values.
Comparison is semantic. Sources carry an `!include` line and comments, spell
numbers freely (`.99` vs `0.99`, `1.0` vs `1`), and Windows path lookup is
case-insensitive, so none of those count as differences.
python3 verify_damage.py [--show CHASSIS]
"""
import argparse, collections, glob, os, re, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import damage, subsystems
REC = "/home/rich/Repositories/FS_Ours_extracted/core/mechs"
SRC = "/home/rich/Repositories/firestorm/Gameleap/mw4/Content/Mechs"
NUM = re.compile(r'^-?(?:\d+\.?\d*|\.\d+)$')
def canon(v):
v = str(v).strip()
if NUM.match(v):
return f"{float(v):.4g}"
if "," in v: # DamageEffect: path,percent
path, _, pct = v.rpartition(",")
return canon(path) + "," + (f"{float(pct):.4g}" if NUM.match(pct.strip()) else pct)
v = re.sub(r'^content[\\/]', '', v.replace("/", "\\"), flags=re.I)
return v.lower()
def source_pages(path):
"""-> [(pageName, [(key, value)])] preserving order and repeats."""
txt = open(path, "rb").read().decode("latin-1")
txt = re.sub(r'//[^\n]*', '', txt)
pages, cur = [], None
for line in txt.splitlines():
line = line.strip()
if not line or line.startswith("!"):
continue
m = re.match(r'^\[([^\]]+)\]$', line)
if m:
cur = (m.group(1), [])
pages.append(cur)
elif "=" in line and cur is not None:
k, v = line.split("=", 1)
cur[1].append((k.strip(), v.strip()))
return pages
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--show")
args = ap.parse_args()
manifest = subsystems.load_manifest(damage.MANIFEST)
t = collections.Counter()
bad = collections.Counter()
examples = []
order_problems = []
for d in sorted(glob.glob(REC + "/*")):
ch = os.path.basename(d)
src = [p for p in glob.glob(SRC + "/*/*.damage")
if os.path.basename(p).lower() == ch.lower() + ".damage"]
if not src or not glob.glob(os.path.join(d, "*.damage")):
continue
t["chassis"] += 1
want = source_pages(src[0])
got = damage.decompile(d, manifest)
if args.show and args.show.lower() == ch.lower():
sys.stdout.write(damage.emit(got))
return
if [n.lower() for n, _ in want] != [n.lower() for n, _ in got]:
order_problems.append((ch, [n for n, _ in want], [n for n, _ in got]))
continue
t["pages"] += len(want)
for (wname, wkv), (_gname, gkv) in zip(want, got):
wmap = collections.defaultdict(list)
for k, v in wkv:
wmap[k.lower()].append(canon(v))
gmap = collections.defaultdict(list)
for k, v in gkv:
gmap[k.lower()].append(canon(v))
for key in set(wmap) | set(gmap):
t["keys"] += 1
w, g = sorted(wmap.get(key, [])), sorted(gmap.get(key, []))
# The writer defaults MaxArmorValue to BaseArmorValue, so a source
# that omits it and one that states it equal produce identical
# bytes -- 51 pages do state it. The distinction is unrecoverable
# and harmless, so emitting it explicitly counts as a match.
if not w and key == "maxarmorvalue" and g == sorted(gmap.get("basearmorvalue", [])):
t["ok"] += 1
t["implicit_max"] += 1
elif w == g:
t["ok"] += 1
else:
bad[key] += 1
if len(examples) < 12:
examples.append((ch, wname, key, wmap.get(key), gmap.get(key)))
print(f"chassis : {t['chassis']}")
print(f"pages compared : {t['pages']}")
print(f"keys compared : {t['keys']} exact: {t['ok']} wrong: {t['keys'] - t['ok']}")
print(f" MaxArmorValue omitted by source, implied by BaseArmorValue: {t['implicit_max']}")
if order_problems:
print(f"\npage name/order mismatches: {len(order_problems)}")
for ch, w, g in order_problems[:3]:
print(f" {ch}\n want {w}\n got {g}")
if bad:
print("\nkeys not matching:")
for k, n in bad.most_common(15):
print(f" {k:28s} x{n}")
print("\nexamples (chassis, page, key, source, decoded):")
for e in examples:
print(f" {e[0]:14s} {e[1]:22s} {e[2]:22s} {e[3]} != {e[4]}")
if __name__ == "__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Verification harness for the mech .data{GameModel} field map.
Reads every mapped value through its header-derived offset and compares against
the source .data, across all 64 chassis we hold both forms for.
Unlike a value-discovered map this one can genuinely fail: the offsets come from
declaration order, so a mis-parsed header shows up at once as a whole column of
wrong values. Keys that miss consistently by a constant factor are reported with
their decoded/source ratio, which is how unit conversions (deg -> rad, kph ->
m/s) announce themselves.
python3 verify_data.py
"""
import sys, os, re, collections
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import datamap
def close(a, b):
return abs(a - b) <= max(1e-4, abs(b) * 1e-5)
def matches(got, want_text, typ):
"""Compare a decoded value against the raw source text."""
if typ in datamap.VECTORS:
nums = [float(x) for x in re.findall(r'-?\d+\.?\d*(?:[eE][-+]?\d+)?', want_text)]
return len(nums) == len(got) and all(close(a, b) for a, b in zip(got, nums))
if typ == "char":
return got.lower() == want_text.strip().lower()
if typ in ("bool", "BYTE"):
w = want_text.strip().lower()
if w in ("true", "yes", "1"):
return got == 1
if w in ("false", "no", "0"):
return got == 0
return None
try:
return close(float(got), float(want_text))
except ValueError:
return None
def main():
pairs, field_map = datamap.build()
t = collections.Counter()
bad = collections.Counter()
examples = []
for ch, kv, blob in pairs:
for key, (off, typ, size, angle) in field_map.items():
if key not in kv:
continue
got = datamap.read(blob, off, typ, size, angle)
ok = matches(got, kv[key], typ)
if ok is None:
t["skipped"] += 1
continue
t["values"] += 1
if ok:
t["ok"] += 1
else:
bad[key] += 1
if len(examples) < 12:
examples.append((ch, key, typ, kv[key], got, off))
print(f"chassis : {len(pairs)}")
print(f"mapped keys : {len(field_map)}")
print(f"values compared : {t['values']} exact: {t['ok']} wrong: {t['values'] - t['ok']}")
print(f"not comparable : {t['skipped']} (enum/resource text, decoded separately)")
if bad:
print("\nkeys not matching:")
for k, v in bad.most_common(25):
print(f" {k:34s} x{v}")
print("\nexamples (chassis, key, type, source, decoded, offset):")
for ch, key, typ, src, got, off in examples:
print(f" {ch:14s} {key:28s} {typ:10s} src={src!r:24s} got={got!r} @{off}")
if __name__ == "__main__":
main()
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Round-trip verifier for the .instance decompiler.
Regenerates each `.instance` and compares the page name and every key against
the authored source. Keys the source carries but the decompiler does not emit
are reported separately, so an unhandled key can never be mistaken for a pass.
python3 verify_instance.py [--show CHASSIS]
"""
import argparse, collections, glob, os, re, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import instance, subsystems
REC = "/home/rich/Repositories/FS_Ours_extracted/core/mechs"
SRC = "/home/rich/Repositories/firestorm/Gameleap/mw4/Content/Mechs"
NUM = re.compile(r'^-?(?:\d+\.?\d*|\.\d+)$')
def canon(v):
v = str(v).strip()
if NUM.match(v):
return f"{float(v):.5g}"
return re.sub(r'^content[\\/]', '', v.replace("/", "\\"), flags=re.I).lower()
def source_page(path):
txt = re.sub(r'//[^\n]*', '', open(path, "rb").read().decode("latin-1"))
m = re.search(r'^\[([^\]]+)\]', txt, re.M)
kv = [(k, v.strip()) for k, v in re.findall(r'^([A-Za-z]\w*)=([^\r\n]*)', txt, re.M)]
return (m.group(1) if m else None), kv
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--show")
args = ap.parse_args()
manifest = subsystems.load_manifest(instance.MANIFEST)
t = collections.Counter()
bad = collections.Counter()
unhandled = collections.Counter()
examples = []
name_bad = []
for d in sorted(glob.glob(REC + "/*")):
ch = os.path.basename(d)
src = [p for p in glob.glob(SRC + "/*/*.instance")
if os.path.basename(p).lower() == ch.lower() + ".instance"]
if not src or not glob.glob(os.path.join(d, "*.instance")):
continue
t["chassis"] += 1
want_name, want = source_page(src[0])
got_name, got = instance.decompile(d, manifest)
if args.show and args.show.lower() == ch.lower():
sys.stdout.write(instance.emit(got_name, got))
return
if (want_name or "").lower() != (got_name or "").lower():
name_bad.append((ch, want_name, got_name))
gmap = {k.lower(): v for k, v in got}
for key, wv in want:
if key.lower() not in gmap:
unhandled[key] += 1
continue
t["keys"] += 1
if canon(wv) == canon(gmap[key.lower()]):
t["ok"] += 1
else:
bad[key] += 1
if len(examples) < 12:
examples.append((ch, key, wv, gmap[key.lower()]))
print(f"chassis : {t['chassis']}")
print(f"page names : {t['chassis'] - len(name_bad)}/{t['chassis']} match")
print(f"keys compared : {t['keys']} exact: {t['ok']} wrong: {t['keys'] - t['ok']}")
if unhandled:
print("\nkeys present in source but NOT emitted:")
for k, n in unhandled.most_common():
print(f" {k:26s} x{n}")
if name_bad:
print("\npage name mismatches:")
for e in name_bad[:5]:
print(f" {e[0]:14s} want={e[1]!r} got={e[2]!r}")
if bad:
print("\nvalue mismatches:")
for k, n in bad.most_common(15):
print(f" {k:26s} x{n}")
print("\nexamples (chassis, key, source, decoded):")
for e in examples:
print(f" {e[0]:14s} {e[1]:22s} want={e[2]!r:32s} got={e[3]!r}")
if __name__ == "__main__":
main()
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Round-trip verifier for the mech .data decompiler.
Regenerates a whole `.data` from the compiled records for every chassis we hold
both forms of, and compares it key by key against the authored source. This is
the standard `.armature` and `.subsystems` were held to; nothing should be
emitted for the new chassis until this passes.
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), so keys are matched case-insensitively and numbers within tolerance.
python3 verify_roundtrip.py [--show CHASSIS]
"""
import argparse, collections, os, re, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import datamap, data, subsystems
REC = "/home/rich/Repositories/FS_Ours_extracted/core/mechs"
NUM = re.compile(r'^-?\d+\.?\d*(?:[eE][-+]?\d+)?$')
def canon(v):
"""Normalise one value for comparison."""
v = str(v).strip()
v = re.sub(r'^content[\\/]', '', v.replace("/", "\\"), flags=re.I)
v = re.sub(r'\s+', " ", v).lower()
# normalise every number in place so "20.0 20.0 20.0" == "20 20 20"
return re.sub(r'-?\d+\.\d+(?:[eE][-+]?\d+)?|-?\d+',
lambda m: f"{float(m.group(0)):.4g}", v)
def canon_set(val):
"""Sorted, de-duplicated: some sources repeat a key with the same value."""
return sorted({canon(v) for v in (val if isinstance(val, list) else [val])})
def source_kv(path):
"""Authored [GameData] page, keeping repeated keys as lists."""
txt = open(path, "rb").read().decode("latin-1")
# protect braced blocks first: Shadow={...} contains a line reading
# "[shadow]", which otherwise looks like the start of the next page.
# Both CR and LF must go -- splitlines() splits on a bare CR too.
txt = re.sub(r'\{.*?\}',
lambda x: x.group(0).replace("\r", "").replace("\n", "\x01"),
txt, flags=re.S)
m = re.search(r'^\[GameData\]\r?\n(.*?)(?=^\[[A-Za-z]|\Z)', txt, re.M | re.S)
body = m.group(1) if m else ""
kv = collections.OrderedDict()
for line in body.splitlines():
if "=" not in line or line.lstrip().startswith("//"):
continue
k, v = line.split("=", 1)
kv.setdefault(k.strip(), []).append(v.replace("\x01", "\n").strip())
return kv
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--show")
args = ap.parse_args()
manifest = subsystems.load_manifest(data.MANIFEST)
totals = collections.Counter()
wrong = collections.Counter()
missing = collections.Counter()
extra = collections.Counter()
examples = []
chassis = 0
for ch, kv_ignored, _blob in datamap.corpus():
src_path = [p for p in
__import__("glob").glob(datamap.SRC + "/*/*.data")
if os.path.basename(p).lower() == ch.lower() + ".data"]
if not src_path:
continue
chassis += 1
want = source_kv(src_path[0])
got = data.decompile(os.path.join(REC, ch), manifest)
got_l = {k.lower(): v for k, v in got.items()}
for key, wvals in want.items():
if key in data.UNRECOVERABLE:
totals["omitted"] += 1
continue
totals["keys"] += 1
if key.lower() not in got_l:
missing[key] += 1
continue
if canon_set(got_l[key.lower()]) == canon_set(wvals):
totals["ok"] += 1
else:
wrong[key] += 1
if len(examples) < 12:
examples.append((ch, key, wvals, got_l[key.lower()]))
for key in got:
if key.lower() not in {k.lower() for k in want}:
extra[key] += 1
if args.show and args.show.lower() == ch.lower():
sys.stdout.write(data.emit(got))
return
print(f"chassis : {chassis}")
print(f"keys compared : {totals['keys']} exact: {totals['ok']} "
f"wrong: {sum(wrong.values())} missing: {sum(missing.values())}")
print(f"deliberately omitted: {totals['omitted']} (unreadable by the engine)")
if missing:
print("\nmissing from output:")
for k, n in missing.most_common(15):
print(f" {k:30s} x{n}")
if wrong:
print("\nvalue mismatches:")
for k, n in wrong.most_common(15):
print(f" {k:30s} x{n}")
print("\nexamples:")
for ch, k, w, g in examples:
print(f" {ch:12s} {k:26s} want={w!r:44s} got={g!r}")
if extra:
print("\nemitted but not in source:")
for k, n in extra.most_common(10):
print(f" {k:30s} x{n}")
if __name__ == "__main__":
main()
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Round-trip verifier for the .torso and .engine decompilers.
Regenerates both files for every chassis and compares key order and values
against the authored source. `$(SYMBOL)` and its numeric expansion are treated
as equal, since the record stores only the resolved float.
python3 verify_smallmodel.py [--show KIND CHASSIS]
"""
import argparse, collections, glob, os, re, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import smallmodel
REC = "/home/rich/Repositories/FS_Ours_extracted/core/mechs"
SRC = "/home/rich/Repositories/firestorm/Gameleap/mw4/Content/Mechs"
NUM = re.compile(r'^-?(?:\d+\.?\d*|\.\d+)$')
def resolve(value, symbols):
"""$(NAME) -> its numeric value, so symbol and literal compare equal."""
m = re.fullmatch(r'\$\((\w+)\)', value.strip())
if m:
return symbols.get(m.group(1).lower(), value.strip().lower())
return value.strip().lower()
def canon(value, symbols):
v = resolve(value, symbols)
return f"{float(v):.5g}" if NUM.match(str(v)) else str(v)
def symbol_values(path):
"""-> {symbolNameLower: numericString}"""
out = {}
if os.path.exists(path):
txt = re.sub(r'//[^\n]*', '', open(path, encoding="latin-1", errors="replace").read())
for name, val in re.findall(r'^\s*!(\w+)\s*=\s*([-\d.]+)\s*$', txt, re.M):
out[name.lower()] = val
return out
def source_kv(path):
txt = re.sub(r'//[^\n]*', '', open(path, "rb").read().decode("latin-1"))
return [(m.group(1), m.group(2).strip())
for m in re.finditer(r'^([A-Za-z]\w*)=([^\r\n]*)', txt, re.M)]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--show", nargs=2, metavar=("KIND", "CHASSIS"))
args = ap.parse_args()
t = collections.Counter()
bad = collections.Counter()
examples = []
order_bad = []
for kind, spec in sorted(smallmodel.SPECS.items()):
symbols = symbol_values(spec["defines"])
for d in sorted(glob.glob(REC + "/*")):
ch = os.path.basename(d)
src = [p for p in glob.glob(f"{SRC}/*/*.{kind}")
if os.path.basename(p).lower() == f"{ch.lower()}.{kind}"]
if not src or smallmodel.record(d, kind) is None:
continue
t[f"{kind} files"] += 1
want = source_kv(src[0])
got = smallmodel.decompile(d, kind)
if args.show and args.show[0] == kind and args.show[1].lower() == ch.lower():
sys.stdout.write(smallmodel.emit(kind, got))
return
if [k.lower() for k, _ in want] != [k.lower() for k, _ in got]:
order_bad.append((kind, ch, [k for k, _ in want], [k for k, _ in got]))
continue
for (wk, wv), (_gk, gv) in zip(want, got):
t["keys"] += 1
if canon(wv, symbols) == canon(gv, symbols):
t["ok"] += 1
else:
bad[f"{kind}.{wk}"] += 1
if len(examples) < 12:
examples.append((kind, ch, wk, wv, gv))
for kind in sorted(smallmodel.SPECS):
print(f"{kind:8s} files : {t[kind + ' files']}")
print(f"keys compared : {t['keys']} exact: {t['ok']} wrong: {t['keys'] - t['ok']}")
if order_bad:
print(f"\nkey order mismatches: {len(order_bad)}")
for kind, ch, w, g in order_bad[:3]:
print(f" {kind} {ch}\n want {w}\n got {g}")
if bad:
print("\nvalue mismatches:")
for k, n in bad.most_common(15):
print(f" {k:34s} x{n}")
print("\nexamples (kind, chassis, key, source, decoded):")
for e in examples:
print(f" {e[0]:7s} {e[1]:14s} {e[2]:22s} want={e[3]!r:22s} got={e[4]!r}")
if __name__ == "__main__":
main()
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Verification harness for the .subsystems decompiler.
Rebuilds every chassis from our own packed records and compares the resulting
key/value pairs, page by page, against the known source .subsystems.
Page names are ignored - the packer does not store them (see subsystems.py), so
only order and content can be verified.
Chassis whose source has moved on since the package was built are reported
separately rather than counted as failures; core.mw4 has not been repacked since
the initial mirror, so battlemaster and battlemaster2c legitimately differ.
python3 verify_subsystems.py
"""
import sys, os, glob, re, collections
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import subsystems
def source_pages(path):
"""Parse a source .subsystems leniently.
The runtime NotationFile accepts a page header with no closing bracket -
hellspawn had '[HeatSink10' and sunder '[HeatSink16' - so match on a leading
'[' rather than a full bracketed pattern.
GroupIndex may appear several times in one page, so values are collected as
lists rather than overwritten.
"""
txt = open(path, "rb").read().decode("latin-1")
out = []
for m in re.finditer(r'^\[([^\r\n\]]*)\]?[ \t]*\r?\n(.*?)(?=^\[|\Z)', txt, re.M | re.S):
kv = collections.OrderedDict()
for k, v in re.findall(r'^([A-Za-z0-9_]+)=([^\r\n]*)', m.group(2), re.M):
if k in kv:
kv[k] = (kv[k] if isinstance(kv[k], list) else [kv[k]]) + [v]
else:
kv[k] = v
out.append((m.group(1), kv))
return out
def norm(key, value):
"""Compare as a sorted list so a single value and a one-element list match."""
if not isinstance(value, list):
value = [value]
return sorted(_norm1(key, v) for v in value)
def _norm1(key, value):
v = str(value).strip()
if key in ("Model", "Site", "EjectSite", "InternalLocation", "ExecutionState",
"ArmorType", "InternalType"):
return v.lower().replace("\\", "/")
try:
return f"{float(v):.4g}"
except ValueError:
return v.lower()
def main():
manifest = subsystems.load_manifest(subsystems.OUR_MANIFEST)
ppt = subsystems.armor_points_per_ton()
srcs = {os.path.basename(p)[:-len(".subsystems")].lower(): p
for p in glob.glob(subsystems.OUR_MECH_SOURCE + "/*/*.subsystems")}
t = collections.Counter()
mismatched_keys = collections.Counter()
stale, examples = [], []
for rec in sorted(glob.glob(subsystems.OUR_RECORDS + "/*/*.subsystems")):
chassis = os.path.basename(rec)[:-len(".subsystems")].lower()
if chassis not in srcs:
continue
src = source_pages(srcs[chassis])
got = subsystems.decompile(rec, manifest, ppt)
if len(src) != len(got):
stale.append((chassis, len(got), len(src)))
continue
t["chassis"] += 1
for (_sname, skv), (_gname, gkv) in zip(src, got):
t["pages"] += 1
page_ok = True
for key, sval in skv.items():
if key in ("SubsystemIndex",): # not emitted; defaults to 0
continue
# An explicit "=0" and an omitted key are identical to the packer.
if gkv.get(key) is None and str(sval).strip() in ("0", "0.0"):
continue
t["keys"] += 1
gval = gkv.get(key)
if gval is not None and norm(key, gval) == norm(key, sval):
t["keys_ok"] += 1
else:
page_ok = False
mismatched_keys[key] += 1
if len(examples) < 12:
examples.append((chassis, key, sval, gval))
extra = [k for k in gkv if k not in skv]
if extra:
page_ok = False
for k in extra:
mismatched_keys["EXTRA:" + k] += 1
t["pages_ok"] += page_ok
print(f"chassis verified : {t['chassis']}")
print(f"pages compared : {t['pages']} fully exact: {t['pages_ok']}")
print(f"keys compared : {t['keys']} exact: {t['keys_ok']}"
f" wrong: {t['keys'] - t['keys_ok']}")
if mismatched_keys:
print("\nmismatches by key:")
for k, v in mismatched_keys.most_common(12):
print(f" {k:22s} x{v}")
if examples:
print("\nexamples (chassis, key, source, decompiled):")
for e in examples:
print(f" {e[0]:14s} {e[1]:18s} src={e[2]!r:28s} got={e[3]!r}")
if stale:
print("\nskipped - source has moved on since the package was built:")
for ch, g, s in stale:
print(f" {ch:16s} messages={g} sourcePages={s}")
if __name__ == "__main__":
main()
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""
diffindex.py - case-insensitive package/entry diff between two mw4index manifests.
python3 diffindex.py <A.tsv> <B.tsv> [labelA] [labelB]
Per package it prints:
* name-set diff - which source assets exist on each side (the reliable signal)
* blob multiset - stored-byte md5 counts, names ignored (a rough upper bound
on how much payload differs; see the caveat in mw4index.py)
Packages present on only one side are reported as A-ONLY / B-ONLY.
"""
import sys, collections
def load(path):
names = collections.defaultdict(set)
blobs = collections.defaultdict(collections.Counter)
sizes = collections.defaultdict(dict)
with open(path, encoding="latin-1") as fh:
for line in fh:
line = line.rstrip("\n")
if not line:
continue
pkg, _rid, name, dlen, _rlen, h = line.split("\t")
pkg = pkg.lower()
key = name.lower().replace("\\", "/")
names[pkg].add(key)
blobs[pkg][h] += 1
sizes[pkg][key] = int(dlen)
return names, blobs, sizes
def main():
if len(sys.argv) < 3:
sys.exit(__doc__)
la = sys.argv[3] if len(sys.argv) > 3 else "A"
lb = sys.argv[4] if len(sys.argv) > 4 else "B"
an, ab, asz = load(sys.argv[1])
bn, bb, bsz = load(sys.argv[2])
for pkg in sorted(set(an) | set(bn)):
if pkg not in an:
print(f"== {pkg}: {lb}-ONLY PACKAGE ({len(bn[pkg])} entries)")
continue
if pkg not in bn:
print(f"== {pkg}: {la}-ONLY PACKAGE ({len(an[pkg])} entries)")
continue
aonly = sorted(an[pkg] - bn[pkg])
bonly = sorted(bn[pkg] - an[pkg])
shared = sum((ab[pkg] & bb[pkg]).values())
print(f"== {pkg}: {la}={len(an[pkg])} {lb}={len(bn[pkg])} | "
f"names: common={len(an[pkg] & bn[pkg])} {la}_only={len(aonly)} {lb}_only={len(bonly)} | "
f"blobs: identical={shared} {la}_uniq={sum(ab[pkg].values()) - shared} "
f"{lb}_uniq={sum(bb[pkg].values()) - shared}")
for n in aonly:
print(f" +{la} {n} ({asz[pkg][n]}B)")
for n in bonly:
print(f" -{lb} {n} ({bsz[pkg][n]}B)")
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""
dumprec.py - extract one record from a *.mw4, decoded.
python3 dumprec.py <pkg.mw4> "<entry name>" [outfile]
The entry name is matched case-insensitively with backslashes normalised to
'/', e.g. "mechs/atlas/atlas.subsystems". With no outfile the bytes go to
stdout. Pass "--list" as the entry name to print every entry name instead.
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mw4db import read_records, norm
def main():
if len(sys.argv) < 3:
sys.exit(__doc__)
path, want = sys.argv[1], norm(sys.argv[2])
_content, recs = read_records(path)
if want == "--list":
for rid, name, dlen, rlen, _blob in recs:
print(f"{rid}\t{name}\t{dlen}\t{rlen}")
return
for _rid, name, _dlen, _rlen, blob in recs:
if norm(name) == want:
if len(sys.argv) > 3:
open(sys.argv[3], "wb").write(blob)
else:
sys.stdout.buffer.write(blob)
return
sys.exit(f"not found: {want}")
if __name__ == "__main__":
main()
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
extract-all.py - unpack every *.mw4 under a resource root into a real directory tree.
python3 extract-all.py <resourceRoot> <outDir> [--no-merged]
Layout
------
<outDir>/<packagePathWithoutExtension>/<entryPath>
e.g. core.mw4 entry mechs\\atlas\\atlas.subsystems
-> <outDir>/core/mechs/atlas/atlas.subsystems
Missions/freezer.mw4 entry missions\\freezer\\freezer.contents
-> <outDir>/Missions/freezer/missions/freezer/freezer.contents
This is lossless: 842 entry paths are claimed by more than one package (281 of
them with different content - typically a mission packing its own copy of a
global props.mw4 asset), so a single flat tree cannot represent the data.
<outDir>/_merged/ is then built as a flattened view of the whole set using
HARDLINKS (no extra disk). It mirrors the layout of Gameleap/mw4/Content, which
makes it directly diffable against our source tree. Where two packages disagree,
precedence is props > core > textures > maps > missions and every conflict is
listed in _conflicts.tsv.
Entry-name qualifiers are preserved verbatim in the filename:
foo.data{gamemodel} foo.contents[joint_hip]{armature} bar.tga{hint}
All characters used by these packages are legal on Linux ('!' '#' '$' '%' '^'
appear in lobby skin names; no ':' or NUL).
"""
import sys, os, hashlib
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mw4db import read_records, walk_packages
# Higher wins when the same entry path comes from several packages.
PRECEDENCE = {"props": 50, "core": 40, "textures": 30, "maps": 20, "missions": 10}
# Saved mechlab loadouts and pilot options name their records '{Mech}',
# '<chassis>{Subsystem}' etc. with no directory part, so they have no place in a
# Content-shaped tree - 797 variants would all collide on the same few names.
MERGE_EXCLUDE = ("variants/", "pilots/")
def in_merged(pkgrel):
return not pkgrel.lower().startswith(MERGE_EXCLUDE)
def precedence(pkgrel):
head = pkgrel.split("/")[0].lower()
if head.endswith(".mw4"):
head = head[:-4]
return PRECEDENCE.get(head, 0)
def entry_path(name):
"""Entry name -> relative filesystem path. Qualifiers kept as-is."""
p = name.replace("\\", "/").strip("/")
# Defensive: never let an entry escape the output directory.
parts = [seg for seg in p.split("/") if seg not in ("", ".", "..")]
return "/".join(parts) if parts else "_unnamed"
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
if len(args) != 2:
sys.exit(__doc__)
root, out = args
want_merged = "--no-merged" not in sys.argv
os.makedirs(out, exist_ok=True)
manifest = open(os.path.join(out, "_manifest.tsv"), "w", encoding="utf-8")
manifest.write("package\trecordId\tentryName\tdataLen\trecLen\tmd5\toutPath\n")
notes = open(os.path.join(out, "_notes.txt"), "w", encoding="utf-8")
# merged bookkeeping: mergedRelPath -> (precedence, pkgrel, md5, absSourceFile)
best = {}
seen_paths = {} # mergedRelPath -> {md5: [pkgrel, ...]}
total_files = total_bytes = 0
packages = 0
for path, pkgrel in walk_packages(root):
res = read_records(path)
if res is None:
notes.write(f"SKIP not-a-#VBD-package: {pkgrel}\n")
continue
_content, recs = res
packages += 1
pkgdir = os.path.join(out, pkgrel[:-4] if pkgrel.lower().endswith(".mw4") else pkgrel)
written = {} # relPath -> md5, for intra-package dupes
for rid, name, dlen, rlen, blob in recs:
rel = entry_path(name)
h = hashlib.md5(blob).hexdigest()
if rel in written: # duplicate entry name in one package
if written[rel] == h:
notes.write(f"DUP-IDENTICAL {pkgrel}\t{name}\trec{rid}\n")
continue
rel = f"{rel}#rec{rid}"
notes.write(f"DUP-DIFFERENT {pkgrel}\t{name}\trec{rid} -> {rel}\n")
written[rel] = h
dest = os.path.join(pkgdir, rel)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "wb") as fh:
fh.write(blob)
total_files += 1
total_bytes += len(blob)
manifest.write(f"{pkgrel}\t{rid}\t{name}\t{dlen}\t{rlen}\t{h}\t"
f"{os.path.relpath(dest, out)}\n")
if want_merged and in_merged(pkgrel):
seen_paths.setdefault(rel, {}).setdefault(h, []).append(pkgrel)
pr = precedence(pkgrel)
cur = best.get(rel)
if cur is None or pr > cur[0]:
best[rel] = (pr, pkgrel, h, dest)
print(f" {pkgrel}: {len(recs)} records", flush=True)
manifest.close()
conflicts = 0
if want_merged:
mroot = os.path.join(out, "_merged")
with open(os.path.join(out, "_conflicts.tsv"), "w", encoding="utf-8") as cf:
cf.write("mergedPath\tchosenPackage\tallVersions\n")
for rel, (_pr, pkgrel, _h, src) in best.items():
dest = os.path.join(mroot, rel)
os.makedirs(os.path.dirname(dest), exist_ok=True)
if os.path.lexists(dest):
os.unlink(dest)
os.link(src, dest)
versions = seen_paths[rel]
if len(versions) > 1:
conflicts += 1
detail = "; ".join(f"{h[:8]}={','.join(ps)}" for h, ps in versions.items())
cf.write(f"{rel}\t{pkgrel}\t{detail}\n")
notes.write(f"\npackages={packages} files={total_files} bytes={total_bytes} "
f"mergedPaths={len(best)} conflicts={conflicts}\n")
notes.close()
print(f"\npackages : {packages}")
print(f"files : {total_files}")
print(f"bytes : {total_bytes/1e9:.2f} GB")
if want_merged:
print(f"merged : {len(best)} paths, {conflicts} with conflicting versions")
print(f"out : {out}")
if __name__ == "__main__":
main()
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
mw4db.py - reader for GameOS "#VBD" resource packages (*.mw4).
Format (from mw4\\Libraries\\stuff\\Database.cpp):
header (20 bytes, little-endian)
char[4] tag "#VBD"
u32 format
u32 contentVersion (63 for this codebase, VER_CONTENTVERSION)
u32 indexSize byte offset of the payload area
u16 recordCount
u16 nextId
then `recordCount` directory records, packed, no alignment:
s64 FILETIME when this entry was last (re)packed
u32 dataLength decompressed size
u32 recLength stored size
u32 dataOffset offset from `indexSize`
u16 recordId
u8 nameLength
char[] name (latin-1, backslash-separated, may carry
{qualifier} / [page] suffixes)
payload at indexSize + dataOffset, `recLength` bytes.
Storage rule (Database.cpp:451): recLength == dataLength -> raw,
otherwise LZW-compressed with gos_LZCompress.
The decompressor is a faithful port of gos_LZDecompress from
CoreTech\\Libraries\\GameOS\\FileIO.cpp - variable-width LZW, LSB-first,
9 -> 12 bits, CLEAR = 256, EOF = 257, dictionary starts at 258.
"""
import struct, hashlib, os
def lz_decompress(src: bytes) -> bytes:
src = bytes(src) + b"\x00\x00\x00\x00" # u32-read padding margin
bitpos = 0
def getcode(width, mask):
nonlocal bitpos
i = bitpos >> 3
v = src[i] | (src[i + 1] << 8) | (src[i + 2] << 16) | (src[i + 3] << 24)
v = (v >> (bitpos & 7)) & mask
bitpos += width
return v
CLEAR, EOF, FREE = 256, 257, 258
chain = [0] * 8192
suffix = [0] * 8192
width, mask, maxindex, free = 9, 511, 512, FREE
oldchain = oldsuffix = 0
out = bytearray()
while True:
code = getcode(width, mask)
if code == EOF:
break
if code == CLEAR:
width, mask, maxindex, free = 9, 511, 512, FREE
code = getcode(width, mask) # ClearHash emits one literal
out.append(code & 0xFF)
oldchain, oldsuffix = code, code & 0xFF
continue
stack = []
if code >= free: # KwKwK special case
stack.append(oldsuffix)
suffix[code] = oldsuffix
chain[code] = oldchain
walk = oldchain
else:
walk = code
while walk >= 256:
stack.append(suffix[walk])
walk = chain[walk]
stack.append(walk)
oldsuffix = walk & 0xFF
out += bytes(reversed(stack))
suffix[free] = oldsuffix
chain[free] = oldchain
free += 1
oldchain = code
if free == maxindex and width != 12:
width += 1
maxindex <<= 1
mask = (mask << 1) | 1
return bytes(out)
def read_index(path):
"""Parse directory only. Fast - no decompression.
Returns (contentVersion, [(recordId, name, dataLen, recLen, storedBytes), ...])
or None when the file is not a #VBD package.
"""
with open(path, "rb") as fh:
data = fh.read()
if data[:4] != b"#VBD":
return None
_tag, _fmt, content, indexsize, nrec, _nextid = struct.unpack_from("<4sIIIHH", data, 0)
out, off = [], 20
for _ in range(nrec):
off += 8 # FILETIME
dlen, rlen, doff = struct.unpack_from("<III", data, off); off += 12
rid, nlen = struct.unpack_from("<HB", data, off); off += 3
name = data[off:off + nlen].decode("latin-1"); off += nlen
raw = data[indexsize + doff: indexsize + doff + rlen] if dlen else b""
out.append((rid, name, dlen, rlen, raw))
return content, out
def read_records(path):
"""Parse and decode every record.
Returns (contentVersion, [(recordId, name, dataLen, recLen, decodedBytes), ...])
"""
r = read_index(path)
if r is None:
return None
content, recs = r
out = []
for rid, name, dlen, rlen, raw in recs:
blob = b"" if not dlen else (raw if rlen == dlen else lz_decompress(raw))
out.append((rid, name, dlen, rlen, blob))
return content, out
def norm(name: str) -> str:
"""Canonical comparison key for an entry name.
Package entry names are case-inconsistent between builds (V4H's packer
lowercased everything) and use backslashes. Always compare through this.
"""
return name.lower().replace("\\", "/")
def md5(b: bytes) -> str:
return hashlib.md5(b).hexdigest()
def walk_packages(root):
"""Yield (absolutePath, relativePathWithForwardSlashes) for every *.mw4 under root."""
for dirpath, _dirs, files in os.walk(root):
for f in sorted(files):
if f.lower().endswith(".mw4"):
p = os.path.join(dirpath, f)
yield p, os.path.relpath(p, root).replace("\\", "/")
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""
mw4index.py - fast TSV manifest of every *.mw4 package under a directory.
python3 mw4index.py <resource-root> > manifest.tsv
Columns: pkgRelPath \t recordId \t entryName \t dataLen \t recLen \t md5(storedBytes)
Directory-only, so a 780 MB resource tree indexes in about a second.
CAUTION: md5 of the *stored* bytes is NOT a reliable equality test. Two builds
that packed identical source can still produce different stored bytes (raw vs
LZW selection, dictionary state). Use it only as a cheap "definitely identical"
signal; confirm real differences with pkgcmp.py, which decodes.
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mw4db import read_index, walk_packages, md5
def main():
if len(sys.argv) != 2:
sys.exit(__doc__)
root = sys.argv[1]
for path, rel in walk_packages(root):
try:
r = read_index(path)
except Exception as e: # truncated / unreadable package
print(f"!ERR\t{rel}\t{e}", file=sys.stderr)
continue
if r is None:
print(f"!NOTVBD\t{rel}", file=sys.stderr)
continue
_content, recs = r
for rid, name, dlen, rlen, raw in recs:
print(f"{rel}\t{rid}\t{name}\t{dlen}\t{rlen}\t{md5(raw)}")
if __name__ == "__main__":
main()
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""
pkgcmp.py - authoritative content diff of two versions of the same *.mw4.
python3 pkgcmp.py <A.mw4> <B.mw4>
Decodes every record on both sides and compares the *decompressed* bytes, so
the result is free of packer noise. This is the only trustworthy equality test;
stored-byte comparison badly over-reports (peaks.mw4: 9 stored-byte diffs, 1
real one).
Throughput is roughly 2 MB/s of package (props.mw4, 114 MB, takes ~50 s).
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mw4db import read_records, norm, md5
def load(path):
_content, recs = read_records(path)
return {norm(name): (dlen, md5(blob), blob) for _rid, name, dlen, _rlen, blob in recs}
def main():
if len(sys.argv) != 3:
sys.exit(__doc__)
a, b = load(sys.argv[1]), load(sys.argv[2])
aonly = sorted(set(a) - set(b))
bonly = sorted(set(b) - set(a))
diff = sorted(k for k in set(a) & set(b) if a[k][1] != b[k][1])
print(f"# entries: A={len(a)} B={len(b)} "
f"A_only={len(aonly)} B_only={len(bonly)} decoded-differ={len(diff)}")
for k in aonly:
print(f"+A {k} {a[k][0]}B")
for k in bonly:
print(f"-B {k} {b[k][0]}B")
for k in diff:
print(f"~ {k} A={a[k][0]}B B={b[k][0]}B")
if __name__ == "__main__":
main()
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
prune-identical.py - delete everything from an extracted tree that we already have,
leaving only genuine differences and additions.
python3 prune-identical.py <extractedDir> [--apply]
Dry-run by default; nothing is deleted without --apply.
Why two baselines
-----------------
A .mw4 is not a zip of the source tree. Our own packer rewrites ~7,400 files on
the way in (.data .video .instance .contents .audio .damage .torso .subsystems
.engine .lights) and generates a further ~17,700 qualified records that have no
source file at all ('foo.data{gamemodel}', 'foo.contents[joint_hip]{armature}',
'bar.tga{hint}'). Comparing only against Content/ would therefore "keep" about
24,000 files we in fact already have.
So each candidate is tested, in order:
1. same package + same entry path in OUR extracted packages, identical bytes
2. same entry path anywhere in OUR extracted packages, identical bytes
3. unqualified name + same path in our Content/ source tree, identical bytes
Any hit means we have that exact asset -> delete. Rule 3 is what recognises
raw pass-through assets (.tga, .wav, .erf, scripts); rules 1-2 are what
recognise the packer-generated forms.
Afterwards _merged/ is rebuilt from the survivors and empty directories removed.
"""
import sys, os, hashlib, collections, shutil
OURS_EXTRACTED = "/home/rich/Repositories/FS_Ours_extracted"
OUR_SOURCE = "/home/rich/Repositories/firestorm/Gameleap/mw4/Content"
PRECEDENCE = {"props": 50, "core": 40, "textures": 30, "maps": 20, "missions": 10}
MERGE_EXCLUDE = ("variants/", "pilots/")
def digest(path, cache={}):
key = (path, os.path.getsize(path))
if key not in cache:
with open(path, "rb") as fh:
cache[key] = hashlib.md5(fh.read()).hexdigest()
return cache[key]
def index_paths(root, skip_top=()):
"""relative-lowercased-path -> absolute path. No hashing (lazy)."""
out = {}
for dp, dirs, fs in os.walk(root):
if dp == root:
dirs[:] = [d for d in dirs if d.lower() not in skip_top]
for f in fs:
p = os.path.join(dp, f)
out[os.path.relpath(p, root).replace("\\", "/").lower()] = p
return out
def package_of(rel):
"""Split '<pkgdir>/<entryPath>' for an extracted file.
Package directories are: core, props, textures, maps/<x>, Missions/<x>,
Variants/<x>, Pilots/Tesla/options
"""
parts = rel.split("/")
head = parts[0].lower()
if head in ("core", "props", "textures"):
return parts[0], "/".join(parts[1:])
if head in ("maps", "missions", "variants") and len(parts) > 2:
return "/".join(parts[:2]), "/".join(parts[2:])
if head == "pilots" and len(parts) > 3:
return "/".join(parts[:3]), "/".join(parts[3:])
return parts[0], "/".join(parts[1:])
def merged_precedence(pkg):
return PRECEDENCE.get(pkg.split("/")[0].lower(), 0)
def main():
if len(sys.argv) < 2:
sys.exit(__doc__)
root = sys.argv[1]
apply_ = "--apply" in sys.argv
merged_root = os.path.join(root, "_merged")
print("indexing our extracted packages ...", flush=True)
ours_pkg = index_paths(OURS_EXTRACTED, skip_top={"_merged"})
ours_by_entry = {}
for rel, p in ours_pkg.items():
if rel.startswith("_"):
continue
_pkg, entry = package_of(rel)
ours_by_entry.setdefault(entry, []).append(p)
print(f" {len(ours_pkg)} files, {len(ours_by_entry)} distinct entry paths")
print("indexing our Content source tree ...", flush=True)
src = index_paths(OUR_SOURCE)
print(f" {len(src)} files")
stats = collections.Counter()
doomed, kept = [], []
for dp, _dirs, fs in os.walk(root):
if dp == merged_root or dp.startswith(merged_root + os.sep):
continue
for f in fs:
p = os.path.join(dp, f)
rel = os.path.relpath(p, root).replace("\\", "/")
if rel.startswith("_"):
continue
pkg, entry = package_of(rel)
h = digest(p)
reason = None
cand = ours_pkg.get(rel.lower())
if cand and digest(cand) == h:
reason = "same-package"
if reason is None:
for c in ours_by_entry.get(entry.lower(), ()):
if digest(c) == h:
reason = "other-package"
break
if reason is None and "{" not in f and "[" not in f:
c = src.get(entry.lower())
if c and digest(c) == h:
reason = "our-source"
if reason:
stats["deleted:" + reason] += 1
doomed.append((rel, reason))
else:
stats["kept"] += 1
kept.append((rel, pkg, entry, h))
total = len(doomed) + len(kept)
print(f"\nexamined {total} files")
for k, v in sorted(stats.items()):
print(f" {v:7d} {k}")
print(f"\n -> would delete {len(doomed)}, keep {len(kept)}")
with open(os.path.join(root, "_survivors.tsv"), "w", encoding="utf-8") as fh:
fh.write("path\tpackage\tentry\tmd5\n")
for rel, pkg, entry, h in sorted(kept):
fh.write(f"{rel}\t{pkg}\t{entry}\t{h}\n")
if not apply_:
print(f"\nDRY RUN - nothing changed. Survivor list written to "
f"{os.path.join(root, '_survivors.tsv')}. Re-run with --apply.")
return
for rel, _reason in doomed:
os.remove(os.path.join(root, rel))
with open(os.path.join(root, "_pruned.tsv"), "w", encoding="utf-8") as fh:
fh.write("deletedPath\tmatchedVia\n")
for rel, reason in sorted(doomed):
fh.write(f"{rel}\t{reason}\n")
# rebuild _merged from survivors
shutil.rmtree(merged_root, ignore_errors=True)
best = {}
for rel, pkg, entry, h in kept:
if pkg.lower().startswith(MERGE_EXCLUDE):
continue
pr = merged_precedence(pkg)
cur = best.get(entry.lower())
if cur is None or pr > cur[0]:
best[entry.lower()] = (pr, entry, os.path.join(root, rel))
for _pr, entry, srcfile in best.values():
dest = os.path.join(merged_root, entry)
os.makedirs(os.path.dirname(dest), exist_ok=True)
os.link(srcfile, dest)
removed = 0
for dp, dirs, fs in os.walk(root, topdown=False):
if dp == root or not os.path.isdir(dp):
continue
if not os.listdir(dp):
os.rmdir(dp)
removed += 1
print(f"\ndeleted {len(doomed)} files, removed {removed} empty directories")
print(f"_merged rebuilt with {len(best)} paths")
print(f"log: {os.path.join(root, '_pruned.tsv')}")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""
restructure.py - reshape an extracted tree to match the repo's directory layout.
python3 restructure.py <extractedDir> [--apply]
Dry-run by default.
The extractor writes one directory per package, which is lossless but does not
look like the repo. This flattens it to the shape of Gameleap/mw4/Content:
<root>/Content/Mechs/Champion/champion.subsystems
<root>/Content/Maps/alpine02/...
<root>/Resource/Variants/<name>/... (variant records have no path)
<root>/Resource/Pilots/Tesla/options/...
<root>/_pkgroots/... (4-byte package-root pseudo-entries)
Path components are re-cased to match the repo wherever a counterpart exists, so
'mechs/longbow/longbow.data{element}' becomes
'Content/Mechs/Longbow/longbow.data{Element}'. Components with no counterpart
(the new chassis, for instance) keep the name the packer used.
REFUSES TO RUN if two packages would land on the same path with different bytes.
Flatten only trees where that has been checked - the pruned V4H tree has zero
collisions; our own full extraction has 60 (mission-local copies of shared
Culturals props) and must stay per-package.
Provenance is preserved in _layout.tsv: newPath, package, original entry path.
"""
import sys, os, hashlib, collections
REPO_CONTENT = "/home/rich/Repositories/firestorm/Gameleap/mw4/Content"
def build_case_map(root):
"""lowercased relative path -> the repo's actual spelling, for dirs and files."""
out = {}
for dp, dirs, files in os.walk(root):
rel = os.path.relpath(dp, root).replace("\\", "/")
base = "" if rel == "." else rel
for name in dirs + files:
r = f"{base}/{name}" if base else name
out[r.lower()] = r
return out
def recase(path, case_map):
"""Re-case each component against the repo, keeping unknown ones as-is."""
done = []
for part in path.split("/"):
probe = "/".join(done + [part]).lower()
canonical = case_map.get(probe)
done.append(canonical.rsplit("/", 1)[-1] if canonical else part)
return "/".join(done)
def package_of(rel):
parts = rel.split("/")
head = parts[0].lower()
if head in ("core", "props", "textures"):
return parts[0], "/".join(parts[1:])
if head in ("maps", "missions", "variants") and len(parts) > 2:
return "/".join(parts[:2]), "/".join(parts[2:])
if head == "pilots" and len(parts) > 3:
return "/".join(parts[:3]), "/".join(parts[3:])
return parts[0], "/".join(parts[1:])
def main():
if len(sys.argv) < 2:
sys.exit(__doc__)
root = sys.argv[1]
apply_ = "--apply" in sys.argv
print("indexing repo Content/ for canonical casing ...", flush=True)
case_map = build_case_map(REPO_CONTENT)
print(f" {len(case_map)} path components")
plan = [] # (oldRel, newRel, package, entry)
claims = collections.defaultdict(dict) # newRel -> md5 -> [package]
for dp, dirs, fs in os.walk(root):
if dp == root:
dirs[:] = [d for d in dirs if d not in ("_merged", "Content", "Resource", "_pkgroots")]
for f in fs:
old = os.path.relpath(os.path.join(dp, f), root).replace("\\", "/")
if old.startswith("_"):
continue
pkg, entry = package_of(old)
low = pkg.lower()
if low.startswith("variants/"):
new = f"Resource/{pkg}/{entry}"
elif low.startswith("pilots/"):
new = f"Resource/{pkg}/{entry}"
elif entry.lower().startswith("resource/"):
new = f"_pkgroots/{pkg}/{entry}"
else:
new = "Content/" + recase(entry, case_map)
plan.append((old, new, pkg, entry))
if new.startswith("Content/"):
with open(os.path.join(root, old), "rb") as fh:
h = hashlib.md5(fh.read()).hexdigest()
claims[new].setdefault(h, []).append(pkg)
clashes = {k: v for k, v in claims.items() if len(v) > 1}
dupes = {k: v for k, v in claims.items()
if len(v) == 1 and len(next(iter(v.values()))) > 1}
print(f"\n{len(plan)} files -> {len({n for _o, n, _p, _e in plan})} destinations")
print(f" identical copies from several packages : {len(dupes)}")
print(f" CONFLICTING copies (different bytes) : {len(clashes)}")
if clashes:
for k, v in list(clashes.items())[:10]:
print(f" {k} " + "; ".join(f"{h[:6]}={','.join(p)}" for h, p in v.items()))
sys.exit("\nrefusing to flatten: resolve the conflicts first")
sample = [(o, n) for o, n, _p, _e in plan if o != n][:8]
print("\nsample:")
for o, n in sample:
print(f" {o}\n -> {n}")
if not apply_:
print("\nDRY RUN - nothing changed. Re-run with --apply.")
return
import shutil
shutil.rmtree(os.path.join(root, "_merged"), ignore_errors=True)
moved, deduped = 0, 0
with open(os.path.join(root, "_layout.tsv"), "w", encoding="utf-8") as fh:
fh.write("newPath\tpackage\toriginalEntry\n")
for old, new, pkg, entry in sorted(plan):
src, dest = os.path.join(root, old), os.path.join(root, new)
os.makedirs(os.path.dirname(dest), exist_ok=True)
if os.path.exists(dest): # identical copy from another package
os.remove(src)
deduped += 1
else:
os.replace(src, dest)
moved += 1
fh.write(f"{new}\t{pkg}\t{entry}\n")
removed = 0
for dp, _dirs, _fs in os.walk(root, topdown=False):
if dp == root or not os.path.isdir(dp):
continue
if not os.listdir(dp):
os.rmdir(dp)
removed += 1
print(f"\nmoved {moved}, dropped {deduped} identical duplicates, "
f"removed {removed} empty directories")
print(f"provenance: {os.path.join(root, '_layout.tsv')}")
if __name__ == "__main__":
main()
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""
split-source.py - separate repackable source files from compiled records, and
rename the ones that need it, so the tree can be dropped into Content/.
python3 split-source.py <extractedDir> [--apply]
Dry-run by default.
The problem
-----------
A .mw4 does not store the source tree. Some records are the source file byte for
byte; others are what the packer produced from it. Classification was derived
empirically by extracting OUR OWN packages and hash-matching every record against
Gameleap/mw4/Content (22,138 matched, 28,728 did not):
verbatim source .tga .erf .mw4anim .bid .wav .bsp .material .abl .obb .ebf
.bounds .animscript .fgd .tcf .mlr .d3f .gaf .script .h .abi
compiled .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
Worth being concrete: 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 the {gamemodel}/{element} records. So a mech's .data .damage
.subsystems .instance CANNOT be recovered from a package - they have to be
re-authored.
.armature likewise does not exist in a package. The packer splits it into
per-joint X.contents[joint_*]{armature} records, which are compiled.
What this does
--------------
* X.data{solidobb} -> X_skeleton_SOLID.obb (mechs)
X.data{hierarchicalobb} -> X_skeleton.obb (mechs)
...or _SOLID.obb / .obb outside Content/Mechs. These really are .obb files
('#BBO' magic, headers byte-identical to ours). The exact source spelling is
declared inside the .data text, which we do not have, so a convention is
applied and recorded in _source-vs-compiled.tsv - whatever .data gets
authored later must reference the same name.
* everything classified as compiled moves to _compiled/, keeping its path
* everything left under Content/ is genuine, repackable source
Resource/ (variants, pilot options) is left alone: those are whole .mw4
packages, best taken from FS_Build_V4H/resource/Variants/*.mw4 directly rather
than from their unpacked records.
"""
import sys, os, re, collections
SOURCE_EXT = {".tga", ".erf", ".mw4anim", ".bid", ".wav", ".bsp", ".material",
".abl", ".obb", ".ebf", ".bounds", ".animscript", ".fgd", ".tcf",
".mlr", ".d3f", ".gaf", ".h", ".abi", ".hpp", ".include", ".tpl"}
COMPILED_EXT = {".data", ".instance", ".subsystems", ".damage", ".torso",
".engine", ".audio", ".video", ".lights", ".mw4"}
OBB_QUALS = {"{solidobb}": ("_skeleton_SOLID.obb", "_SOLID.obb"),
"{hierarchicalobb}": ("_skeleton.obb", ".obb")}
QUAL = re.compile(r'(\[[^\]]*\]|\{[^}]*\})')
def is_text(path, limit=8192):
with open(path, "rb") as fh:
chunk = fh.read(limit)
if not chunk:
return False
printable = sum(1 for b in chunk if 9 <= b <= 13 or 32 <= b < 127)
return printable / len(chunk) > 0.90
def classify(path, name, rel):
quals = "".join(QUAL.findall(name)).lower()
stem = QUAL.sub("", name)
ext = os.path.splitext(stem)[1].lower()
if quals in OBB_QUALS:
mech = "/mechs/" in rel.lower()
suffix = OBB_QUALS[quals][0 if mech else 1]
return "source", stem.rsplit(".", 1)[0] + suffix
if quals:
return "compiled", name
if ext in SOURCE_EXT:
return "source", name
if ext in COMPILED_EXT:
return "compiled", name
# .script and .contents are genuinely mixed - decide on content
return ("source" if is_text(path) else "compiled"), name
def main():
if len(sys.argv) < 2:
sys.exit(__doc__)
root = sys.argv[1]
apply_ = "--apply" in sys.argv
content = os.path.join(root, "Content")
if not os.path.isdir(content):
sys.exit(f"no Content/ in {root} - run restructure.py first")
plan, stats = [], collections.Counter()
renames = collections.Counter()
for dp, _dirs, fs in os.walk(content):
for f in fs:
p = os.path.join(dp, f)
rel = os.path.relpath(p, root).replace("\\", "/")
verdict, newname = classify(p, f, rel)
new = ("Content/" if verdict == "source" else "_compiled/Content/") + \
os.path.relpath(os.path.join(dp, newname), content).replace("\\", "/")
plan.append((rel, new, verdict))
stats[verdict] += 1
if verdict == "source" and newname != f:
renames[os.path.splitext(f)[1] or f] += 1
print(f"Content/: {sum(stats.values())} files")
print(f" repackable source : {stats['source']}")
print(f" compiled records : {stats['compiled']} (-> _compiled/)")
if renames:
print("\nrenamed:")
for k, v in renames.most_common():
print(f" {v:5d} {k}")
print("\nsample renames:")
shown = 0
for old, new, v in plan:
if v == "source" and os.path.basename(old) != os.path.basename(new) and shown < 6:
print(f" {os.path.basename(old)} -> {os.path.basename(new)}")
shown += 1
if not apply_:
print("\nDRY RUN - nothing changed. Re-run with --apply.")
return
for old, new, _v in plan:
src, dest = os.path.join(root, old), os.path.join(root, new)
os.makedirs(os.path.dirname(dest), exist_ok=True)
os.replace(src, dest)
with open(os.path.join(root, "_source-vs-compiled.tsv"), "w", encoding="utf-8") as fh:
fh.write("verdict\toriginalPath\tnewPath\n")
for old, new, v in sorted(plan):
fh.write(f"{v}\t{old}\t{new}\n")
removed = 0
for dp, _dirs, _fs in os.walk(root, topdown=False):
if dp == root or not os.path.isdir(dp):
continue
if not os.listdir(dp):
os.rmdir(dp)
removed += 1
print(f"\nmoved {len(plan)} files, removed {removed} empty directories")
print(f"log: {os.path.join(root, '_source-vs-compiled.tsv')}")
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""
treediff.py - case-insensitive loose-file diff of two directory trees.
python3 treediff.py <dirA> <dirB> [labelA] [labelB]
Compares by relative path (lowercased, '/'-normalised) and md5. Written for the
hsh/, content/ and root config comparisons, where the two builds disagree on
filename case but not on identity.
"""
import sys, os, hashlib
def scan(root):
out = {}
for dirpath, _dirs, files in os.walk(root):
for f in files:
p = os.path.join(dirpath, f)
rel = os.path.relpath(p, root).replace("\\", "/")
with open(p, "rb") as fh:
h = hashlib.md5(fh.read()).hexdigest()
out[rel.lower()] = (os.path.getsize(p), h, rel)
return out
def main():
if len(sys.argv) < 3:
sys.exit(__doc__)
la = sys.argv[3] if len(sys.argv) > 3 else "A"
lb = sys.argv[4] if len(sys.argv) > 4 else "B"
a, b = scan(sys.argv[1]), scan(sys.argv[2])
aonly = sorted(set(a) - set(b))
bonly = sorted(set(b) - set(a))
diff = sorted(k for k in set(a) & set(b) if a[k][1] != b[k][1])
print(f"# {la}={len(a)} files, {lb}={len(b)} files | "
f"identical={len(set(a) & set(b)) - len(diff)} differ={len(diff)} "
f"{la}_only={len(aonly)} {lb}_only={len(bonly)}")
for k in aonly:
print(f"+{la}\t{a[k][2]}\t{a[k][0]}")
for k in bonly:
print(f"-{lb}\t{b[k][2]}\t{b[k][0]}")
for k in diff:
print(f"~DIF\t{a[k][2]}\t{la}={a[k][0]}B {lb}={b[k][0]}B")
if __name__ == "__main__":
main()
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
variants-report.py - inventory of resource/Variants/*.mw4 (saved mechlab loadouts).
python3 variants-report.py <variantsDir> <v4hManifest.tsv> <oursManifest.tsv>
A variant package holds 4 records:
[0] 'Resource\\Variants\\<name>' 4 B (raw id)
[1] '{Mech}' ~350 B
[2] '<chassis>{Subsystem}' ~4 KB <- names the chassis it needs
[3] '<8-char token>' 16 B
So the chassis a variant depends on is readable straight from the manifest,
with no decompression: the record whose name ends in '{Subsystem}'.
The report groups variants by chassis and flags any whose chassis is absent
from the target build's core.mw4 (those will not load).
"""
import sys, os, collections
def chassis_in_core(manifest):
"""Set of chassis names that core.mw4 defines (one *.subsystems per chassis)."""
out = set()
with open(manifest, encoding="latin-1") as fh:
for line in fh:
pkg, _rid, name, *_ = line.rstrip("\n").split("\t")
if pkg.lower() != "core.mw4":
continue
n = name.lower().replace("\\", "/")
if n.endswith(".subsystems"):
out.add(n.rsplit("/", 1)[-1][:-len(".subsystems")])
return out
def variant_chassis(manifest):
"""variantPkgRelPath -> chassis name it requires."""
out = {}
with open(manifest, encoding="latin-1") as fh:
for line in fh:
pkg, _rid, name, *_ = line.rstrip("\n").split("\t")
if not pkg.lower().startswith("variants/"):
continue
if name.endswith("{Subsystem}"):
out[pkg] = name[:-len("{Subsystem}")].lower()
return out
def main():
if len(sys.argv) != 4:
sys.exit(__doc__)
vdir, v4h_manifest, ours_manifest = sys.argv[1:4]
have_v4h = chassis_in_core(v4h_manifest)
have_ours = chassis_in_core(ours_manifest)
vc = variant_chassis(v4h_manifest)
print(f"# variant packages: {len(vc)} (files on disk: "
f"{len([f for f in os.listdir(vdir) if f.lower().endswith('.mw4')])})")
print(f"# chassis defined in V4H core.mw4 : {len(have_v4h)}")
print(f"# chassis defined in OUR core.mw4 : {len(have_ours)}")
print(f"# chassis only V4H defines : {sorted(have_v4h - have_ours)}")
print()
by = collections.defaultdict(list)
for pkg, ch in vc.items():
by[ch].append(pkg)
loadable_now = broken = needs_new_mech = 0
print(f"{'chassis':22s} {'count':>5s} status")
for ch in sorted(by, key=lambda c: (-len(by[c]), c)):
n = len(by[ch])
if ch in have_ours:
status = "loadable in OUR build today"
loadable_now += n
elif ch in have_v4h:
status = "needs the new chassis ported"
needs_new_mech += n
else:
status = "BROKEN - chassis missing from V4H too"
broken += n
print(f"{ch:22s} {n:5d} {status}")
print()
print(f"loadable in our build today : {loadable_now}")
print(f"blocked on new chassis : {needs_new_mech}")
print(f"broken / orphaned : {broken}")
for ch in sorted(by):
if ch not in have_v4h:
print(f" orphan chassis '{ch}': " + ", ".join(sorted(by[ch])))
if __name__ == "__main__":
main()