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>
422 lines
16 KiB
Python
Executable File
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("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
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())
|