An HTML roster of all 18 selectable mechs in the VTV-PRESETS layout style:
per-mech portrait, loadout chips, manual stat sheet, and the authored
weapons/ammo table, with filter (all/base/variant), search and live count.
Nothing is transcribed by hand where the game can be read directly
(tools/build_mechs.py + tools/mechs.tpl -> docs/MECHS.html):
- ROSTER: kVehicles[] parsed from game/glass/btl4fe.cpp -- the exact
18-entry catalog the menu offers, in menu order (8 base chassis + 10
variants).
- WEAPONS + AMMO BINS: each mech's cmCrit(...) lines in
content/GAUGE/L4GAUGE.CFG (the critical-schematic's authored subsystem
roster; independently cross-checked against the rtype-17 subsystem
records in BTL4.RES, 2026-08-12 -- all 18 agree).
- PORTRAITS: the game's own mission-review images (GAUGE/<stem>_MR.PCC,
PCC=PCX) resolved through mrpal.pcc with the zone slots (32..63) held
at adpal entry 0 -- the review screen's undamaged green. Variants
without their own portrait share the base's, as the RES does.
- MECH STATS: the original player's manual stat sheets, transcribed [T1]
from page renders (pp. 25 Loki/Thor, 26 Vulture/MadCat, 29
Owens/Blackhawk). The manual covers the six 4.0 hulls only; Avatar,
Sunder and the variants show a dash, never an invented number.
- FIXED vs TWISTING torso: derived from the shipped RES joint tables
(jointtorso/jointtshadow presence; torso.cpp:270 gate) -- blkhawk,
bhk1, owens, own1 are fixed, matching the manual's Torso 0/0 rows.
Rendered + verified headless (top and bottom, all 18 cards, filters wired).
Not yet shipped by mkdist (MAPS.html is not either); add a block modelled
on the CONTROLS.html one if testers should get it in the zip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
261 lines
12 KiB
Python
261 lines
12 KiB
Python
"""Render the mech roster the way the game knows it.
|
|
|
|
Data is drawn from the shipped files, not transcribed:
|
|
|
|
* The ROSTER is `kVehicles[]` in game/glass/btl4fe.cpp -- the exact
|
|
18-entry catalog the in-game menu offers (key + display name, menu
|
|
order). Nothing is filtered, so a menu change flows through.
|
|
* Each mech's WEAPONS and AMMO BINS are its `cmCrit(...)` lines in
|
|
content/GAUGE/L4GAUGE.CFG -- the critical-damage schematic's authored
|
|
subsystem roster. (Independently confirmed against the rtype-17
|
|
subsystem records in BTL4.RES, 2026-08-12.)
|
|
* The PORTRAIT is the mech's own mission-review image
|
|
(GAUGE/<stem>_MR.PCC, shown by the cameraInit review context through
|
|
mrpal.pcc). Its zone indexes 32..63 are runtime ColorMapper slots --
|
|
the review screen paints damage per zone -- so the page shows the
|
|
HEALTHY state: every zone slot at adpal.pcc entry 0, the same green
|
|
the armor doll idles at. PCC is PCX; the palette is the file tail.
|
|
* MECH STATS come from the original player's manual stat sheets
|
|
(reference/manual/Tesla40_BT_manual.pdf pp. 25/26/29, transcribed
|
|
from page renders 2026-08-12). The manual documents the six 4.0
|
|
hulls only; the 4.10 additions (Avatar, Sunder) and all variants have
|
|
no sheet, and this page shows a dash rather than an invented number.
|
|
* FIXED vs TWISTING torso is derived from the shipped BTL4.RES: a mech
|
|
twists only if its subsystem record carries the jointtorso +
|
|
jointtshadow skeleton joints (Torso resolves them only when
|
|
torsoHorizontalEnabled, game/reconstructed/torso.cpp:270). The four
|
|
fixed mechs match the manual's Torso 0/0 rows exactly.
|
|
|
|
Usage: python tools/build_mechs.py [out.html]
|
|
"""
|
|
import base64, html, io, os, re, sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
GAUGE = os.path.join(ROOT, 'content', 'GAUGE')
|
|
OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, 'docs', 'MECHS.html')
|
|
|
|
# --------------------------------------------------------------- roster
|
|
src = open(os.path.join(ROOT, 'game', 'glass', 'btl4fe.cpp'),
|
|
encoding='utf-8', errors='replace').read()
|
|
block = re.search(r'kVehicles\[\]\s*=\s*\{(.*?)\};', src, re.S).group(1)
|
|
ROSTER = re.findall(r'\{\s*"(\w+)"\s*,\s*"([^"]+)"\s*\}', block)
|
|
if len(ROSTER) < 18:
|
|
sys.exit('roster parse found only %d entries' % len(ROSTER))
|
|
|
|
# Variant -> base chassis (kVehicles order groups them; the stems below agree).
|
|
VARIANT_OF = {'bhk1': 'blkhawk', 'thr1': 'thor', 'lok1': 'loki',
|
|
'lok2': 'loki', 'mad1': 'madcat', 'mad2': 'madcat',
|
|
'own1': 'owens', 'ava1': 'avatar', 'snd1': 'sunder',
|
|
'vul1': 'vulture'}
|
|
|
|
# Mission-review portrait stem per key (GAUGE/<stem>_MR.PCC; variants with no
|
|
# portrait of their own share the base's, as the RES does).
|
|
MR_STEM = {'blkhawk': 'BLH', 'bhk1': 'BLH', 'loki': 'LOKI', 'lok1': 'LOK1',
|
|
'lok2': 'LOKI', 'madcat': 'MAD', 'mad1': 'MAD1', 'mad2': 'MAD2',
|
|
'thor': 'THOR', 'thr1': 'THR1', 'owens': 'OWEN', 'own1': 'OWEN',
|
|
'avatar': 'AVA', 'ava1': 'AVA', 'sunder': 'SUN', 'snd1': 'SUN',
|
|
'vulture': 'VULT', 'vul1': 'VUL1'}
|
|
|
|
# Fixed-torso set, derived from BTL4.RES joint tables (see module docstring).
|
|
FIXED_TORSO = {'blkhawk', 'bhk1', 'owens', 'own1'}
|
|
|
|
# The manual stat sheets (pp. 25 Loki/Thor, 26 Vulture/MadCat, 29 Owens/
|
|
# Blackhawk). Keys: t tons, ap armor points, res reservoir liters, hs heat
|
|
# sinks, ts torso deg/s, tl torso limit deg, a100 0-100s, b100 100-0s,
|
|
# vn/vs/vg top speed normal / super charged / gimped kph.
|
|
MANUAL = {
|
|
'loki': dict(t=75, ap=2110, res=3800, hs=38, ts=60, tl=110,
|
|
a100=4.5, b100=4.5, vn=143, vs=182, vg=40, page=25),
|
|
'thor': dict(t=75, ap=2110, res=3800, hs=38, ts=60, tl=110,
|
|
a100=4.5, b100=4.5, vn=143, vs=182, vg=40, page=25),
|
|
'vulture': dict(t=75, ap=2025, res=2400, hs=24, ts=50, tl=130,
|
|
a100=4.7, b100=3, vn=175, vs=235, vg=62, page=26),
|
|
'madcat': dict(t=75, ap=2195, res=2800, hs=28, ts=80, tl=90,
|
|
a100=4.2, b100=4, vn=175, vs=220, vg=62, page=26),
|
|
'owens': dict(t=25, ap=760, res=1500, hs=15, ts=0, tl=0,
|
|
a100=4.2, b100=5, vn=154, vs=265, vg=63, page=29),
|
|
'blkhawk': dict(t=50, ap=1362, res=1600, hs=16, ts=0, tl=0,
|
|
a100=5.1, b100=4.6, vn=190, vs=280, vg=60, page=29),
|
|
}
|
|
|
|
# ------------------------------------------------------------- weapons
|
|
CFG = open(os.path.join(GAUGE, 'L4GAUGE.CFG'),
|
|
encoding='utf-8', errors='replace').read()
|
|
|
|
INFRA = {'HUD', 'Avionics', 'Gyroscope', 'Torso', 'Myomers'}
|
|
|
|
|
|
def init_block(key):
|
|
name = key.capitalize() + 'Init'
|
|
m = re.search(r'^%s\b' % re.escape(name), CFG, re.M)
|
|
if not m:
|
|
sys.exit('no %s block in L4GAUGE.CFG' % name)
|
|
n = re.search(r'^\w+Init\b', CFG[m.end():], re.M)
|
|
return CFG[m.start(): m.end() + n.start() if n else len(CFG)]
|
|
|
|
|
|
def family(sub):
|
|
"""ERPPC_2 -> ERPPC, MLaser1 -> MLaser; AFC100/LRM15/NRK5 keep their digits
|
|
(they are family names, and they are in FAM already)."""
|
|
fam = re.sub(r'_\d+$', '', sub)
|
|
if fam not in FAM:
|
|
stripped = re.sub(r'\d+$', '', fam)
|
|
if stripped in FAM:
|
|
fam = stripped
|
|
return fam
|
|
|
|
|
|
def loadout(key):
|
|
weapons, bins = {}, {}
|
|
for sub in re.findall(r'cmCrit\([^,]+,[^,]+,\d+,[^,]+,[^,]+,(\w+)\)',
|
|
init_block(key)):
|
|
if sub.startswith('AmmoBin'):
|
|
fam = family(sub[len('AmmoBin'):])
|
|
bins[fam] = bins.get(fam, 0) + 1
|
|
elif not (sub in INFRA
|
|
or sub.startswith(('Generator', 'Condenser', 'Myomers'))):
|
|
fam = family(sub)
|
|
weapons[fam] = weapons.get(fam, 0) + 1
|
|
return weapons, bins
|
|
|
|
|
|
# Family -> (display name, class). Classes: e energy, b ballistic, m missile.
|
|
FAM = {
|
|
'PPC': ('PPC', 'e'), 'ERPPC': ('ER PPC', 'e'),
|
|
'LLaser': ('Large Laser', 'e'), 'MLaser': ('Medium Laser', 'e'),
|
|
'SLaser': ('Small Laser', 'e'), 'ERLLaser': ('ER Large Laser', 'e'),
|
|
'ERMLaser': ('ER Medium Laser', 'e'), 'ERSLaser': ('ER Small Laser', 'e'),
|
|
'AFC25': ('AFC 25', 'b'), 'AFC50': ('AFC 50', 'b'),
|
|
'AFC100': ('AFC 100', 'b'), 'GAUSS': ('Gauss', 'b'), 'NRK5': ('NRK 5', 'b'),
|
|
'LRM5': ('LRM 5', 'm'), 'LRM10': ('LRM 10', 'm'), 'LRM15': ('LRM 15', 'm'),
|
|
'LRM20': ('LRM 20', 'm'), 'SRM2': ('SRM 2', 'm'), 'SRM4': ('SRM 4', 'm'),
|
|
'SRM6': ('SRM 6', 'm'),
|
|
}
|
|
CLASS_NAME = {'e': 'Energy', 'b': 'Ballistic', 'm': 'Missile'}
|
|
CLASS_ORDER = {'e': 0, 'b': 1, 'm': 2}
|
|
|
|
# ------------------------------------------------------------- portraits
|
|
from PIL import Image
|
|
|
|
mrpal = open(os.path.join(GAUGE, 'MRPAL.PCC'), 'rb').read()
|
|
if mrpal[-769] != 0x0C:
|
|
sys.exit('MRPAL.PCC has no VGA palette where PCX keeps one')
|
|
BASEPAL = list(mrpal[-768:])
|
|
adpal = open(os.path.join(GAUGE, 'ADPAL.PCC'), 'rb').read()
|
|
HEALTHY = list(adpal[-768:][0:3]) # ramp entry 0 = undamaged green
|
|
|
|
_portraits = {}
|
|
|
|
|
|
def portrait(stem):
|
|
if stem not in _portraits:
|
|
im = Image.open(os.path.join(GAUGE, stem + '_MR.PCC'))
|
|
im.load()
|
|
flat = BASEPAL[:]
|
|
for s in range(32, 64): # zone slots -> healthy state
|
|
flat[s * 3:s * 3 + 3] = HEALTHY
|
|
im.putpalette(flat)
|
|
buf = io.BytesIO()
|
|
im.save(buf, 'PNG', optimize=True)
|
|
_portraits[stem] = ('data:image/png;base64,'
|
|
+ base64.b64encode(buf.getvalue()).decode(),
|
|
im.width, im.height)
|
|
return _portraits[stem]
|
|
|
|
|
|
# ----------------------------------------------------------------- page
|
|
def dash(v, unit=''):
|
|
return '—' if v is None else '%s%s' % (v, unit)
|
|
|
|
|
|
NAMES = dict(ROSTER)
|
|
cards = []
|
|
for key, name in ROSTER:
|
|
weapons, bins = loadout(key)
|
|
base = VARIANT_OF.get(key)
|
|
man = MANUAL.get(key)
|
|
uri, w, h = portrait(MR_STEM[key])
|
|
|
|
fams = sorted(weapons, key=lambda f: (CLASS_ORDER[FAM[f][1]], FAM[f][0]))
|
|
chips = ''.join(
|
|
'<span class="chip chip--%s">%s%s</span>' % (
|
|
FAM[f][1],
|
|
'%d× ' % weapons[f] if weapons[f] > 1 else '',
|
|
html.escape(FAM[f][0].upper()))
|
|
for f in fams)
|
|
|
|
rows = ''.join(
|
|
'<tr><th scope="row">%s</th><td>%s</td>'
|
|
'<td class="num">%d</td><td class="num">%s</td></tr>' % (
|
|
html.escape(FAM[f][0]), CLASS_NAME[FAM[f][1]], weapons[f],
|
|
'%d×' % bins[f] if f in bins else '—')
|
|
for f in fams)
|
|
|
|
torso = ('FIXED' if key in FIXED_TORSO
|
|
else '%s°/s · %s°' % (man['ts'], man['tl'])
|
|
if man else 'twists')
|
|
spec = ''.join('<div><dt>%s</dt><dd>%s</dd></div>' % kv for kv in (
|
|
('Tonnage', dash(man and man['t'], ' t')),
|
|
('Armor points', dash(man and man['ap'])),
|
|
('Reservoir', dash(man and man['res'], ' L')),
|
|
('Heat sinks', dash(man and man['hs'])),
|
|
('Torso', torso),
|
|
('0–100 kph', dash(man and man['a100'], ' s')),
|
|
('Top speed', dash(man and man['vn'], ' kph')),
|
|
('Super charged', dash(man and man['vs'], ' kph')),
|
|
('Gimped', dash(man and man['vg'], ' kph')),
|
|
))
|
|
sheet = ('manual p. %d' % man['page'] if man else
|
|
'chassis sheet: see %s' % html.escape(NAMES[base]) if base in MANUAL
|
|
else 'no manual sheet (4.10 addition)')
|
|
|
|
fam_base = base or key
|
|
kin = ([NAMES[fam_base]] if base else []) + \
|
|
[NAMES[v] for v, b in VARIANT_OF.items() if b == fam_base and v != key]
|
|
cap = ('%s chassis · with %s' % (
|
|
html.escape(NAMES.get(base, name)), html.escape(', '.join(kin)))
|
|
if kin else '%s chassis' % html.escape(name))
|
|
|
|
search = ' '.join([key, name.lower()] +
|
|
[FAM[f][0].lower() for f in fams] + fams).lower()
|
|
cards.append("""
|
|
<article class="mech" data-tags="{tags}" data-search="{search}">
|
|
<header class="mech__id">
|
|
<figure class="hull">
|
|
<img class="hull__img" src="{uri}" width="{w}" height="{h}"
|
|
alt="{name} mission-review portrait" loading="lazy">
|
|
<figcaption>{cap}</figcaption>
|
|
</figure>
|
|
<h3>{name}</h3>
|
|
<p class="mech__key">{key} · {kind}</p>
|
|
<p class="mech__load">{chips}</p>
|
|
<p class="mech__meta"><span>{nw} weapons</span><span>{nb} ammo bins</span><span>torso {torsoshort}</span></p>
|
|
<dl class="spec">{spec}</dl>
|
|
<p class="mech__sheet">{sheet}</p>
|
|
</header>
|
|
<div class="mech__tables"><div class="tablewrap"><table>
|
|
<caption class="sr-only">Weapons carried by the {name}</caption>
|
|
<thead><tr><th scope="col">WEAPON</th><th scope="col">TYPE</th>
|
|
<th scope="col" class="num">COUNT</th><th scope="col" class="num">AMMO BIN</th></tr></thead>
|
|
<tbody>{rows}</tbody>
|
|
</table></div></div>
|
|
</article>""".format(
|
|
tags='variant' if base else 'base', search=html.escape(search),
|
|
uri=uri, w=w, h=h, name=html.escape(name), key=key,
|
|
kind='variant of %s' % html.escape(NAMES[base]) if base else 'base chassis',
|
|
cap=cap, chips=chips, nw=sum(weapons.values()), nb=sum(bins.values()),
|
|
torsoshort='FIXED' if key in FIXED_TORSO else 'TWISTS',
|
|
spec=spec, sheet=sheet, rows=rows))
|
|
print(' %-8s %-12s %2d weapons, %d bins%s' % (
|
|
key, name, sum(weapons.values()), sum(bins.values()),
|
|
' [manual]' if man else ''), file=sys.stderr)
|
|
|
|
PAGE = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
'mechs.tpl'), encoding='utf-8').read()
|
|
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
|
open(OUT, 'w', encoding='utf-8').write(PAGE.format(
|
|
cards=''.join(cards), n=len(ROSTER),
|
|
nbase=sum(1 for k, _ in ROSTER if k not in VARIANT_OF),
|
|
nvar=sum(1 for k, _ in ROSTER if k in VARIANT_OF)))
|
|
print('wrote %s (%d mechs)' % (OUT, len(ROSTER)), file=sys.stderr)
|