Files
firestorm/MECHSPECSHEET/generate-mech-specs.py
T
653265c928 Add MECHSPECSHEET: generate the stock-mech spec sheet from the source tree
Turns the hand-maintained 5.0.7D mech specification spreadsheet into something
generated from Content/, so the numbers come from the data the game loads
rather than being transcribed. 65 stock chassis, 55 columns, every non-weapon
column populated.

Standard library only. An .xlsx is a zip of XML and only sheet1.xml is
rewritten, so the template's formatting and drawings survive untouched.

Two findings worth recording, both in the README:

The Armor block's per-zone values in .subsystems are TONS, not multipliers.
MechLab.cpp GetCurrentMechArmorData does tonnage = points / points_per, so
installed points = tons x points-per-ton, with the rates in
Subsystems/Armor.data (Standard 32, Ferro 38, Reflective/Reactive 30,
Solarian 60). Battlemaster = 13.0 x 32 = 416 points. Cross-checked against
MechLab.cpp:796 armor_bar = (TotalArmor/535)*100: 50 of 65 chassis land within
5 points of their stored ArmorRating. An earlier pass summed MaxArmorValue from
.damage instead, which gives armour CAPACITY (1024 for the Battlemaster), not
what is fitted.

The Gladiator mounts six Clan Medium Pulse Lasers in its right arm, so the
template's three arm slots were silently dropping three weapons. Maximum usage
was measured per location across all 65 chassis before widening anything: only
the right arm overflowed. Right_Arm_4/5/6 inserted, trailing columns shifted
+3. The generator now warns by name if any location overflows again.

Judgement calls, all documented:
  - columns 10-15 are "installed in the stock loadout", read from .subsystems,
    not the CanLoad* flags in .data. The template sample marks the Battlemaster
    n for ECM/AMS/LAMS/Beagle even though it can load all four.
  - column 7 follows the data (Standard) over the sample (Ferro-Fibrous).
  - rating bars are pulled from .instance rather than recomputed. MechLab
    recalculates them live while editing, so stored and computed can drift;
    15 chassis diverge by more than 5 points, Fafnir worst at 38.

verify-mech-specs.py runs five checks. The load-bearing one calibrates the
generated Battlemaster row against the template's hand-made sample: those 11
weapon cells are an independent statement of the loadout, and they match
exactly, which is the evidence that .subsystems is being read correctly.

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

422 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate the stock-mech specification spreadsheet from the FireStorm source tree.
Reads every chassis registered in Content/core.build and writes one row per mech
into a copy of template/mech-spec-template.xlsx.
python3 generate-mech-specs.py [-o OUTPUT.xlsx]
Standard library only -- no openpyxl. An .xlsx is a zip of XML, and the only
part that needs rewriting is xl/worksheets/sheet1.xml, so the template's styling,
column widths and drawings survive untouched.
See README.md for the column-by-column derivation and the engine-source evidence
behind the armour and rating figures.
"""
import argparse
import math
import os
import re
import sys
import zipfile
from xml.etree import ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, ".."))
MW4 = os.path.join(REPO, "Gameleap", "mw4")
CONTENT = os.path.join(MW4, "Content")
MECHS = os.path.join(CONTENT, "Mechs")
TEMPLATE = os.path.join(HERE, "template", "mech-spec-template.xlsx")
NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
Q = "{%s}" % NS
ET.register_namespace("", NS)
# Source-tree version stamped into the "Release Version" column.
SOURCE_VERSION = "5.1.0b"
# --------------------------------------------------------------------- parsing
def read(path):
"""Content files are CRLF and latin-1 (some carry legacy high bytes)."""
with open(path, "rb") as f:
return f.read().decode("latin-1")
def kv_pages(text):
"""Parse a [page] / key=value file into an ordered list of (page, dict)."""
pages, name, cur = [], None, {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("//"):
continue
m = re.match(r"^\[([^\]]+)\]$", line)
if m:
if name is not None:
pages.append((name, cur))
name, cur = m.group(1), {}
continue
m = re.match(r"^([A-Za-z_0-9]+)\s*=\s*(.*)$", line)
if m and name is not None:
cur.setdefault(m.group(1).lower(), m.group(2).strip())
if name is not None:
pages.append((name, cur))
return pages
def flat_keys(text):
"""Parse a file as a single flat key=value map, ignoring page headers."""
out = {}
for line in text.splitlines():
m = re.match(r"^([A-Za-z_0-9]+)\s*=\s*(.*)$", line.strip())
if m:
out.setdefault(m.group(1).lower(), m.group(2).strip())
return out
def num(d, key, default=None):
v = d.get(key.lower())
if v is None:
return default
m = re.match(r"^[-+]?[\d.]+", v)
return float(m.group(0)) if m else default
# ------------------------------------------------------------------- constants
TORSO_DEFINES = {}
for _line in read(os.path.join(CONTENT, "Defines", "MechTorso.defines")).splitlines():
_m = re.match(r"^!?([A-Za-z_0-9]+)\s*=\s*([-\d.]+)", _line.strip())
if _m:
TORSO_DEFINES[_m.group(1)] = float(_m.group(2))
def resolve_torso(val):
""".torso fields are usually $(MACRO) references into MechTorso.defines."""
if val is None:
return None
m = re.match(r"^\$\(([^)]+)\)$", val.strip())
if m:
return TORSO_DEFINES.get(m.group(1))
try:
return float(val)
except ValueError:
return None
# Armour points per ton, by type, from Content/Subsystems/Armor.data.
ARMOR_PTS = {}
for _line in read(os.path.join(CONTENT, "Subsystems", "Armor.data")).splitlines():
_m = re.match(r"^PointsPer([A-Za-z]+)Ton\s*=\s*([\d.]+)", _line.strip())
if _m:
ARMOR_PTS[_m.group(1).lower()] = float(_m.group(2))
# The subsystem spells it FerroFiberus; Armor.data keys that rate as "Ferro".
ARMOR_KEY = {"ferrofiberus": "ferro", "standard": "standard", "reactive": "reactive",
"reflective": "reflective", "solarian": "solarian"}
ARMOR_PRETTY = {"ferrofiberus": "Ferro-Fibrous", "standard": "Standard",
"reactive": "Reactive", "reflective": "Reflective",
"solarian": "Solarian"}
INTERNAL_PRETTY = {"endosteel": "Endo Steel", "standard": "Standard"}
CLASS_BANDS = [(35, "Light"), (55, "Medium"), (75, "Heavy"), (1000, "Assault")]
# Coolant is spent in fixed 5-point flushes, so MaxCoolant / 5 = flush count.
COOLANT_PER_FLUSH = 5.0
# Column layout. Right arm carries 6 slots because the Gladiator mounts six
# Clan Medium Pulse Lasers there; every other location fits the original sheet.
LOC_COLS = {
"head": (25, 1),
"leftarm": (26, 3), "rightarm": (29, 6),
"lefttorso": (35, 4), "righttorso": (39, 4),
"centertorso": (43, 2),
"special1": (45, 3), "special2": (48, 3),
}
COL_POWER, COL_ARMOR_R, COL_SPEED_R, COL_HEAT_R = 51, 52, 53, 54
NCOLS = 55
# Old template column -> new column: everything from old col 32 on slides +3.
SHIFT_FROM = 31
NEW_HEADERS = {32: "Right_Arm_4", 33: "Right_Arm_5", 34: "Right_Arm_6"}
# Electronics are recognised by their subsystem model path.
ELECTRONICS = {"ecmsubsystem": "ecm", "beaglesubsystem": "bap", "ams": "ams",
"lams": "lams", "jumpjetsubsystem": "jj"}
ROMAN = {"Ii": "II", "Iic": "IIc", "Iii": "III", "Mkii": "MkII"}
def titlecase(s):
words = [w[:1].upper() + w[1:] for w in s.split(" ")]
return " ".join(ROMAN.get(w, w) for w in words)
def pretty_weapon(model):
"""WeaponSubsystems\\...\\ClanERSmallLaser.data -> 'Clan ER Small Laser'."""
name = re.sub(r"\.data$", "", os.path.basename(model.replace("\\", "/")), flags=re.I)
name = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", name) # camelCase -> spaced
name = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", " ", name) # ERSmall -> ER Small
name = re.sub(r"(?<=[A-Za-z])(?=\d)", " ", name) # SRM6 -> SRM 6
# a couple of source files are all-caps, which no general rule can split
name = re.sub(r"^CLANLRM\b", "Clan LRM", name)
name = re.sub(r"\bERPPC\b", "ER PPC", name)
return re.sub(r"\s+", " ", name).strip()
# ----------------------------------------------------------------- extraction
def build_rows():
roster = []
for line in read(os.path.join(CONTENT, "core.build")).splitlines():
m = re.match(r"^\s*instance\s*=\s*mechs\\([^\\]+)\\([^\\]+)\.instance\s*$",
line, re.I)
if m:
roster.append((m.group(1), m.group(2)))
# core.build spells folder names lowercase; the tree does not
dirs = {n.lower(): n for n in os.listdir(MECHS)
if os.path.isdir(os.path.join(MECHS, n))}
# Display names come from the portrait filenames, which are the localized
# names the game shows (e.g. folder "Blackhawk" -> "black hawk.bmp").
portraits = {}
pdir = os.path.join(MW4, "hsh", "Mechs")
if os.path.isdir(pdir):
for f in os.listdir(pdir):
if f.lower().endswith(".bmp"):
stem = f[:-4]
portraits[stem.lower().replace(" ", "").replace("-", "")] = titlecase(stem)
rows, warnings = [], []
for folder, base in roster:
folder = dirs.get(folder.lower(), folder)
d = os.path.join(MECHS, folder)
if not os.path.isdir(d):
warnings.append("%s: folder not found" % folder)
continue
listing = os.listdir(d)
def path(ext):
want = (base + ext).lower()
for f in listing:
if f.lower() == want:
return os.path.join(d, f)
return None
p_data, p_sub = path(".data"), path(".subsystems")
if not (p_data and p_sub):
warnings.append("%s: missing .data or .subsystems" % folder)
continue
data = flat_keys(read(p_data))
inst = flat_keys(read(path(".instance"))) if path(".instance") else {}
eng = flat_keys(read(path(".engine"))) if path(".engine") else {}
tor = flat_keys(read(path(".torso"))) if path(".torso") else {}
subs = kv_pages(read(p_sub))
disp = portraits.get(folder.lower().replace(" ", "").replace("-", ""), folder)
tech = "Clan" if "clan" in (data.get("techtype") or "").lower() else "IS"
tons = num(data, "MaxVehicleTonnage", 0.0)
klass = next(n for lim, n in CLASS_BANDS if tons <= lim)
armor_type = internal_type = ""
zone_tons = {}
heatsinks, double_hs, upgrades = 0, False, 0
installed = dict(jj=False, ecm=False, bap=False, ams=False, lams=False)
weapons = []
for page, kv in subs:
model = (kv.get("model") or "").replace("/", "\\")
low = model.lower()
if "subsystems\\armor.data" in low:
armor_type = kv.get("armortype", "")
internal_type = kv.get("internaltype", "")
for k, v in kv.items():
if k in ("model", "executionstate", "internallocation",
"armortype", "internaltype"):
continue
try:
zone_tons[k] = float(v)
except ValueError:
pass
elif "heatsinksubsystem" in low:
heatsinks += 1
double_hs = double_hs or ("double" in low)
elif low.endswith(".engine"):
upgrades = int(num(kv, "EngineUpgrades", 0) or 0)
elif "weaponsubsystems" in low:
weapons.append((page, kv, model))
else:
for token, flag in ELECTRONICS.items():
if token in low:
installed[flag] = True
# Installed armour: the Armor block's per-zone values are TONS.
ppt = ARMOR_PTS.get(ARMOR_KEY.get(armor_type.lower(), armor_type.lower()), 0.0)
if armor_type and not ppt:
warnings.append("%s: unknown armour type %r" % (folder, armor_type))
armor_pts = sum(zone_tons.values()) * ppt
minmax = num(data, "MinMaxSpeed", 0.0) or 0.0
maxspd = num(data, "MaxSpeed", 0.0) or 0.0
mps_up = num(eng, "MPSPerUpgrade", 0.0) or 0.0
top_mps = min(minmax + mps_up * upgrades, maxspd) if maxspd else minmax
turn_rad = (num(data, "TopSpeedTurnRate", 0.0) or 0.0) * math.pi / 180.0
twist_radius = resolve_torso(tor.get("twistradius"))
twist_speed = resolve_torso(tor.get("twistspeed"))
slots = {}
for page, kv, model in weapons:
loc = (kv.get("internallocation") or "").lower().replace(" ", "")
col0, cap = LOC_COLS.get(loc, (None, 0))
if col0 is None:
warnings.append("%s: weapon %s has unmapped location %r"
% (folder, page, loc))
continue
label = pretty_weapon(model)
ammo = kv.get("ammocount")
if ammo and re.match(r"^\d+$", ammo) and int(ammo) > 0:
label += " (%s)" % ammo
if (kv.get("weaponfacing") or "0").strip() == "1":
label += " (Rear)"
for i in range(cap):
if (col0 + i) not in slots:
slots[col0 + i] = label
break
else:
warnings.append("%s: %s overflowed %s (cap %d) -- widen LOC_COLS"
% (folder, label, loc, cap))
row = {
1: disp, 2: "y", 3: SOURCE_VERSION, 4: tech, 5: tons, 6: klass,
7: ARMOR_PRETTY.get(armor_type.lower(), armor_type),
8: INTERNAL_PRETTY.get(internal_type.lower(), internal_type),
9: round(armor_pts) if armor_pts else "",
10: "Y" if installed["jj"] else "n",
11: "n" if str(inst.get("doeshavelightamp", "1")).strip() == "0" else "Y",
12: "Y" if installed["ecm"] else "n",
13: "Y" if installed["ams"] else "n",
14: "Y" if installed["lams"] else "n",
15: "Y" if installed["bap"] else "n",
16: (num(inst, "MaxCoolant", 0.0) or 0.0) / COOLANT_PER_FLUSH,
17: heatsinks * (2 if double_hs else 1),
18: round(top_mps * 3.6, 2),
19: round((num(data, "MaxGimpSpeed", 0.0) or 0.0) * 3.6, 2),
20: num(data, "Acceleration", ""),
21: num(data, "Decceleration", ""), # double-c is canonical
22: round(turn_rad, 3),
23: twist_radius * 2 if twist_radius is not None else "",
24: twist_speed if twist_speed is not None else "",
COL_POWER: num(inst, "PowerRating", ""),
COL_ARMOR_R: num(inst, "ArmorRating", ""),
COL_SPEED_R: num(inst, "SpeedRating", ""),
COL_HEAT_R: num(inst, "HeatRating", ""),
}
row.update(slots)
rows.append(row)
rows.sort(key=lambda r: str(r[1]).lower())
return rows, warnings
# --------------------------------------------------------------------- writing
def colletter(n):
s = ""
while n:
n, rem = divmod(n - 1, 26)
s = chr(65 + rem) + s
return s
def esc(s):
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def cell(ref, val, style=None):
st = ' s="%s"' % style if style else ""
if isinstance(val, (int, float)) and not isinstance(val, bool):
return '<c r="%s"%s><v>%s</v></c>' % (ref, st, val)
return ('<c r="%s"%s t="inlineStr"><is><t xml:space="preserve">%s</t></is></c>'
% (ref, st, esc(val)))
def write_xlsx(rows, out_path):
zin = zipfile.ZipFile(TEMPLATE)
sheet = zin.read("xl/worksheets/sheet1.xml").decode("utf-8")
shared = []
if "xl/sharedStrings.xml" in zin.namelist():
for si in ET.fromstring(zin.read("xl/sharedStrings.xml")).findall(Q + "si"):
shared.append("".join(t.text or "" for t in si.iter(Q + "t")))
# Recover the template header text and per-cell style so the rebuilt header
# keeps the original wording and formatting.
row1 = re.search(r"<row[^>]*r=\"1\".*?</row>", sheet, re.S).group(0)
old_hdr, old_style = {}, {}
for m in re.finditer(r'<c r="([A-Z]+)1"([^>]*)>(.*?)</c>', row1, re.S):
col, attrs, body = m.group(1), m.group(2), m.group(3)
n = 0
for ch in col:
n = n * 26 + (ord(ch) - 64)
sm = re.search(r's="(\d+)"', attrs)
old_style[n] = sm.group(1) if sm else None
v = re.search(r"<v>(\d+)</v>", body)
if v is not None and 't="s"' in attrs:
old_hdr[n] = shared[int(v.group(1))]
else:
t = re.search(r"<t[^>]*>(.*?)</t>", body, re.S)
old_hdr[n] = t.group(1) if t else ""
shift = lambda n: n if n <= SHIFT_FROM else n + len(NEW_HEADERS)
new_hdr, new_style = {}, {}
for n, txt in old_hdr.items():
new_hdr[shift(n)] = txt
new_style[shift(n)] = old_style.get(n)
for n, txt in NEW_HEADERS.items():
new_hdr[n] = txt
new_style[n] = old_style.get(SHIFT_FROM)
out_rows = ['<row r="1">%s</row>' % "".join(
cell("%s1" % colletter(c), new_hdr[c], new_style.get(c))
for c in sorted(new_hdr) if new_hdr[c] != "")]
for i, row in enumerate(rows, start=2):
cells = [cell("%s%d" % (colletter(c), i), row[c])
for c in range(1, NCOLS + 1)
if row.get(c, "") not in ("", None)]
out_rows.append('<row r="%d">%s</row>' % (i, "".join(cells)))
body = re.search(r"(<sheetData>)(.*?)(</sheetData>)", sheet, re.S)
new_sheet = sheet[:body.start(2)] + "".join(out_rows) + sheet[body.end(2):]
new_sheet = re.sub(r"<dimension[^/]*/>",
'<dimension ref="A1:%s%d"/>' % (colletter(NCOLS), len(rows) + 1),
new_sheet)
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
payload = zin.read(item.filename)
if item.filename == "xl/worksheets/sheet1.xml":
payload = new_sheet.encode("utf-8")
zout.writestr(item, payload)
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("-o", "--output",
default=os.path.join(HERE, "mech-specifications-%s.xlsx" % SOURCE_VERSION))
args = ap.parse_args()
rows, warnings = build_rows()
print("chassis parsed: %d" % len(rows))
if warnings:
print("\nwarnings (%d):" % len(warnings))
for w in warnings:
print(" %s" % w)
write_xlsx(rows, args.output)
print("\nwrote %s" % args.output)
return 1 if warnings else 0
if __name__ == "__main__":
sys.exit(main())