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>
158 lines
6.5 KiB
Python
Executable File
158 lines
6.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Verify a generated mech specification workbook.
|
|
|
|
python3 verify-mech-specs.py [WORKBOOK.xlsx]
|
|
|
|
Checks, in order:
|
|
1. layout -- column count and weapon/rating header positions
|
|
2. fill rates -- how many chassis have a value in each non-weapon column
|
|
3. calibration -- the Battlemaster row against the hand-made template sample
|
|
4. weapon names -- every distinct name, to catch filename-derived artefacts
|
|
5. armour cross-check -- installed points vs the stored ArmorRating bar
|
|
|
|
The calibration check is the important one: the template ships a single
|
|
hand-filled Battlemaster row, and the 11 weapon cells in it are an independent
|
|
statement of what the loadout should be. If those still match, the extraction
|
|
is reading .subsystems correctly.
|
|
"""
|
|
import os
|
|
import re
|
|
import sys
|
|
import zipfile
|
|
from collections import Counter
|
|
from xml.etree import ElementTree as ET
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
|
|
TEMPLATE = os.path.join(HERE, "template", "mech-spec-template.xlsx")
|
|
DEFAULT = os.path.join(HERE, "mech-specifications-5.1.0b.xlsx")
|
|
|
|
|
|
def load(path):
|
|
"""Return {(row, col): value} for sheet1, resolving shared + inline strings."""
|
|
z = zipfile.ZipFile(path)
|
|
shared = []
|
|
if "xl/sharedStrings.xml" in z.namelist():
|
|
for si in ET.fromstring(z.read("xl/sharedStrings.xml")).findall(NS + "si"):
|
|
shared.append("".join(t.text or "" for t in si.iter(NS + "t")))
|
|
cells = {}
|
|
for c in ET.fromstring(z.read("xl/worksheets/sheet1.xml")).iter(NS + "c"):
|
|
m = re.match(r"([A-Z]+)(\d+)", c.get("r"))
|
|
n = 0
|
|
for ch in m.group(1):
|
|
n = n * 26 + (ord(ch) - 64)
|
|
v, inline = c.find(NS + "v"), c.find(NS + "is")
|
|
if v is not None:
|
|
val = shared[int(v.text)] if c.get("t") == "s" else v.text
|
|
elif inline is not None:
|
|
val = "".join(x.text or "" for x in inline.iter(NS + "t"))
|
|
else:
|
|
continue
|
|
cells[(int(m.group(2)), n)] = val
|
|
return cells
|
|
|
|
|
|
def main():
|
|
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
|
|
new = load(path)
|
|
nrows = max(r for r, _ in new)
|
|
ncols = max(c for _, c in new)
|
|
hdr = {c: (new.get((1, c)) or "").replace("\n", " ") for c in range(1, ncols + 1)}
|
|
problems = []
|
|
|
|
print("workbook: %s" % os.path.basename(path))
|
|
print("layout: %d columns x %d chassis\n" % (ncols, nrows - 1))
|
|
|
|
# 1. layout ---------------------------------------------------------------
|
|
expect = {26: "Left_Arm_1", 29: "Right_Arm_1", 34: "Right_Arm_6",
|
|
35: "Left_Torso_1", 39: "Right_Torso_1", 43: "Center_Torso_1",
|
|
45: "Special_1_1", 48: "Special_2_1"}
|
|
bad = [(c, w, hdr.get(c)) for c, w in expect.items() if hdr.get(c) != w]
|
|
print("1. layout: %s" % ("OK" if not bad else "MISPLACED %s" % bad))
|
|
if bad:
|
|
problems.append("layout")
|
|
|
|
# 2. fill rates -----------------------------------------------------------
|
|
print("\n2. fill rates (non-weapon columns):")
|
|
thin = []
|
|
for c in list(range(1, 25)) + list(range(51, 55)):
|
|
filled = sum(1 for r in range(2, nrows + 1) if (new.get((r, c)) or "").strip())
|
|
if filled < nrows - 1:
|
|
thin.append((c, hdr[c][:30], filled))
|
|
if thin:
|
|
for c, h, f in thin:
|
|
print(" col %-2d %-30s only %d/%d" % (c, h, f, nrows - 1))
|
|
problems.append("fill")
|
|
else:
|
|
print(" all populated for all %d chassis" % (nrows - 1))
|
|
|
|
# 3. calibration against the template sample ------------------------------
|
|
print("\n3. calibration vs template Battlemaster sample:")
|
|
if os.path.exists(TEMPLATE):
|
|
old = load(TEMPLATE)
|
|
bm = next((r for r in range(2, nrows + 1)
|
|
if (new.get((r, 1)) or "").lower().startswith("battlemaster")), None)
|
|
# weapon cells only: template cols 25..47. Cols 48-51 are the rating
|
|
# bars, which the template holds as hand-made guesses ('53?', '98?')
|
|
# while we pull the authored values -- see README, Judgement calls.
|
|
shift = lambda c: c if c <= 31 else c + 3
|
|
mismatch = []
|
|
for c in range(25, 48):
|
|
a = (old.get((2, c)) or "").strip()
|
|
b = (new.get((bm, shift(c))) or "").strip()
|
|
if a and a != b:
|
|
mismatch.append((c, a, b))
|
|
if mismatch:
|
|
for c, a, b in mismatch:
|
|
print(" col %-2d sample %-24r generated %r" % (c, a, b))
|
|
problems.append("calibration")
|
|
else:
|
|
print(" all 11 sample weapon cells match")
|
|
else:
|
|
print(" template missing, skipped")
|
|
|
|
# 4. weapon names ---------------------------------------------------------
|
|
names = set()
|
|
for r in range(2, nrows + 1):
|
|
for c in range(25, 51):
|
|
v = new.get((r, c))
|
|
if v:
|
|
names.add(re.sub(r"\s*\(.*\)$", "", v))
|
|
# SSRM/LRM/PPC are legitimately all-caps; only flag camelCase remnants or
|
|
# runs long enough to be an unsplit filename such as CLANLRM.
|
|
odd = [n for n in names if re.search(r"[a-z][A-Z]|[A-Z]{5,}", n)]
|
|
print("\n4. weapon names: %d distinct" % len(names))
|
|
if odd:
|
|
print(" possible un-split filenames: %s" % sorted(odd))
|
|
problems.append("names")
|
|
else:
|
|
print(" all names look cleanly separated")
|
|
|
|
# 5. armour cross-check ---------------------------------------------------
|
|
# MechLab.cpp:796 armor_bar = (TotalArmor / 535) * 100
|
|
# The stored bars are authored per chassis, so they are a sanity signal, not
|
|
# ground truth. Most sit close to the computed value; a handful do not.
|
|
print("\n5. armour points vs stored ArmorRating bar:")
|
|
deltas = []
|
|
for r in range(2, nrows + 1):
|
|
try:
|
|
pts = float(new.get((r, 9)))
|
|
bar = float(new.get((r, 52)))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
deltas.append((abs(pts / 535.0 * 100.0 - bar), new.get((r, 1)), pts, bar))
|
|
deltas.sort(reverse=True)
|
|
close = sum(1 for d, *_ in deltas if d <= 5)
|
|
print(" within 5 points: %d/%d chassis" % (close, len(deltas)))
|
|
print(" largest divergences (authored bars, not recomputed):")
|
|
for d, nm, pts, bar in deltas[:5]:
|
|
print(" %-18s %6.0f pts -> %5.1f%% stored %5.1f%% (delta %.1f)"
|
|
% (nm, pts, pts / 535.0 * 100.0, bar, d))
|
|
|
|
print("\nresult: %s" % ("PASS" if not problems else "ISSUES: %s" % ", ".join(problems)))
|
|
return 1 if problems else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|