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>
285 lines
12 KiB
Python
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()
|