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

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

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

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

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

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

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

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

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

285 lines
12 KiB
Python

#!/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()