Files
RP412/tools/pages/build_page.py
T
Cyd b12eaa8bb2 The blacker VTVs sort to the end, and Blacker Broccoli loses a note
They are palette variants of machines already in the list, so interleaved
they read as duplicates. The console's own name decides it - eleven of
the thirty-eight are called Black or Blacker something - so they sort
last within a hull tab as well as overall.

Blacker Broccoli's console picture is a Bug, but the game draws it on the
Mule hull like every other Broccoli: the console entry is simply wrong,
and saying so on the card raised a question the card could not answer.
Blacker Tarantula keeps its note, where the console and the game really
do disagree about the hull.
2026-08-07 11:19:27 -05:00

531 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Render the VTV preset reference page from the decoded JSON."""
import base64, html, io, json, os, sys
from PIL import Image
data = json.load(open(sys.argv[1]))
OUT = sys.argv[2]
GAUGE = 'c:/VWE/RP412/assets/RP411/GAUGE'
SCALE = 4 # pre-scale nearest-neighbour; masks get smoothed otherwise
def hull_mask(pcc):
"""The plan view as an alpha mask, so it takes the page's own colour.
Index 0 is the black ground; the two greys become two alpha levels,
which keeps the interior panel detail readable."""
im = Image.open(os.path.join(GAUGE, pcc.upper())).convert('P')
pal = im.getpalette()
lut = {}
for i in set(im.tobytes()):
r, g, b = pal[i * 3:i * 3 + 3]
lut[i] = 0 if (r, g, b) == (0, 0, 0) else int(max(r, g, b))
top = max(lut.values()) or 1
alpha = Image.frombytes('L', im.size,
bytes(round(lut[p] * 255 / top) for p in im.tobytes()))
out = Image.merge('RGBA', (Image.new('L', im.size, 255),) * 3 + (alpha,))
out = out.resize((im.width * SCALE, im.height * SCALE), Image.NEAREST)
buf = io.BytesIO()
out.save(buf, 'PNG', optimize=True)
return ('data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode(),
im.size)
masks, sizes = {}, {}
for v in data.values():
art = v.get('hullArt')
if art and art not in masks:
masks[art], sizes[art] = hull_mask(art)
BUTTONS = ['trigger', 'thumb high', 'thumb low', 'pinky'] # index to little finger
# The blacker machines are palette variants of ones already in the list, so
# they read as duplicates when interleaved. Sort them to the end of whatever
# list they land in - within a hull tab as well as overall - and the originals
# come first. The console's own name is what decides it: eleven of the
# thirty-eight are called Black or Blacker something.
def is_variant(key):
return (data[key].get('display') or key).lower().startswith('black')
ORDER = sorted(data, key=lambda v: (is_variant(v), -len(data[v]['weapons']), v))
# blkrbroc's console picture is a Bug, but the game draws it on the Mule hull
# like every other Broccoli - the console entry is simply wrong. Saying so on
# the card raises a question the card cannot answer.
CONSOLE_PICTURE_WRONG = {'blkrbroc'}
def kind(fn):
if fn is None:
return 'empty'
if fn.startswith('Booster'):
return 'booster'
if fn.startswith('Chute'):
return 'chute'
if fn in ('LIFT CUT', 'SIDESLIP', 'reverse thrust'):
return 'handling'
if fn in ('HORN', 'reticle'):
return 'comms'
return 'weapon'
def pretty(fn):
if fn is None:
return '—'
if fn.startswith('Booster'):
return 'BOOST ' + fn[7:] if fn[7:] else 'BOOST'
if fn.startswith('Chute'):
return 'CHUTE'
return {'RivetGun': 'RIVET', 'RivetGun1': 'RIVET 1', 'RivetGun2': 'RIVET 2',
'LaserGun': 'LASER', 'LaserGun1': 'LASER 1', 'LaserGun2': 'LASER 2',
'DemopackDropper': 'DEMO PACK', 'DemoPackDropper': 'DEMO PACK',
'Slaver3': 'SLAVER'}.get(fn, fn.upper())
def loadout_chips(v):
chips = []
if v['boosters']:
chips.append(('booster', '%d× BOOST' % v['boosters']))
if v['chute']:
chips.append(('chute', 'CHUTE'))
for w in v['weapons']:
chips.append(('weapon', pretty(w)))
if not v['weapons']:
chips.append(('empty', 'UNARMED'))
return ''.join('<span class="chip chip--%s">%s</span>' % (k, html.escape(t))
for k, t in chips)
def table(stream):
rows = []
for i, row in enumerate(stream['presets'], 1):
cells = []
for b in BUTTONS:
fn = row.get(b)
cells.append('<td class="fn fn--%s">%s</td>' % (kind(fn), html.escape(pretty(fn))))
rows.append('<tr><th scope="row"><span class="pnum">%d</span></th>%s</tr>'
% (i, ''.join(cells)))
return (
'<div class="tablewrap"><table>'
'<caption class="sr-only">Preset assignments</caption><thead><tr>'
'<th scope="col"><span class="sr-only">Preset</span></th>'
+ ''.join('<th scope="col">%s</th>' % b.upper() for b in BUTTONS)
+ '</tr></thead><tbody>' + ''.join(rows) + '</tbody></table></div>')
family = {}
for n, v in data.items():
family.setdefault(v.get('silhouette'), []).append(n)
cards = []
for name in ORDER:
v = data[name]
tags = ' '.join(['armed' if v['weapons'] else 'unarmed']
+ [kind(w) for w in v['weapons']])
code = v.get('silhouette') or ''
display = v.get('display') or name
# Hulls are named by the console, not by their two-letter art code.
hull_class = v.get('hullClass') or (code.upper() if code else 'unknown')
kin = sorted((data[x].get('display') or x)
for x in family.get(code, []) if x != name)
search = ' '.join([name, display, hull_class, code] + v['subsystems']).lower()
art = v.get('hullArt')
if art:
w, h = sizes[art]
note = ''
if v.get('hullMismatch') and name not in CONSOLE_PICTURE_WRONG:
note = (' <span class="warn" title="The console showed a %s '
'picture for this vehicle; the game draws the %s hull">'
'(console says %s)</span>'
% (html.escape(v['consoleClass']), html.escape(hull_class),
html.escape(v['consoleClass'])))
hull = ('<figure class="hull"><span class="hull__img" role="img" '
'aria-label="{display} plan view" style="--art:url({uri});'
'--ar:{w}/{h}"></span>'
'<figcaption>{cls} hull{kin}{note}</figcaption></figure>').format(
display=html.escape(display), uri=masks[art], w=w, h=h,
cls=html.escape(hull_class), note=note,
kin=(' &middot; shared with ' + html.escape(', '.join(kin))) if kin else '')
else:
hull = ''
cards.append("""
<article class="vtv" data-hull="{hullcode}" data-tags="{tags}" data-search="{search}">
<header class="vtv__id">
{hull}
<h3>{display}</h3>
<p class="vtv__key">{key}</p>
<p class="vtv__load">{chips}</p>
<p class="vtv__meta"><span>{nsub} subsystems</span><span>{nrec} mappings</span></p>
</header>
<div class="vtv__tables">{l4}</div>
</article>""".format(
tags=tags, hullcode=(code[:1] or '?'),
search=html.escape(search), display=html.escape(display.upper()),
key=html.escape(name), chips=loadout_chips(v), nsub=len(v['subsystems']),
hull=hull, nrec=v['L4']['records'], l4=table(v['L4'])))
# Tabs by hull. The first letter of a vehicle's two-letter art code is the
# hull the game actually draws it with; the console's class names are
# subdivisions of those five - Bull and Roadblock are both 'b', Bug and
# Skeeter both 's'. Grouping by the letter keeps kin together whatever the
# console called them.
HULL_TABS = [('l', 'Particles'), ('s', 'Bugs'), ('m', 'Mules'),
('b', 'Bulls'), ('p', 'Police')]
hull_tabs = ''.join(
'<button type="button" data-hull="%s" aria-pressed="false">%s'
'<span class="seg__n">%d</span></button>'
% (letter, label,
sum(1 for v in data.values() if (v.get('silhouette') or '?')[:1] == letter))
for letter, label in HULL_TABS)
loose = sorted(n for n, v in data.items()
if (v.get('silhouette') or '?')[:1] not in dict(HULL_TABS))
if loose:
print('!! not in any hull tab: %s' % loose)
armed = sum(1 for v in data.values() if v['weapons'])
CSS = """
*, *::before, *::after { box-sizing: border-box; }
:root {
/* The pod's own lamp fills - MUNGA_L4/L4MFDVIEW.cpp:112 */
--amber: #ffd230;
--red: #ff482c;
--phosphor: #6fdc8c;
--ground: #0d0b07;
--panel: #16130d;
--rule: #2f2819;
--ink: #f3ead6;
--ink-mid: #b3a68a;
--ink-low: #7d7360;
--sys: var(--amber);
--wep: var(--red);
--pilot: var(--phosphor);
--on-accent: #0d0b07; /* text that sits ON --sys */
--step--1: clamp(.72rem, .70rem + .1vw, .78rem);
--step-0: clamp(.88rem, .85rem + .15vw, .95rem);
--step-1: clamp(1.05rem, 1rem + .3vw, 1.2rem);
--step-2: clamp(1.4rem, 1.2rem + .9vw, 2rem);
--step-3: clamp(1.9rem, 1.5rem + 2vw, 3.1rem);
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
--sans: ui-sans-serif, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
@media (prefers-color-scheme: light) {
:root {
--ground: #f7f3e8;
--panel: #fffdf7;
--rule: #ddd2b8;
--ink: #17140d;
--ink-mid: #5c5340;
--ink-low: #857a63;
--sys: #8a6400;
--wep: #b3260f;
--pilot: #1c7a3f;
--on-accent: #fffdf7;
}
}
:root[data-theme="light"] {
--ground: #f7f3e8;
--panel: #fffdf7;
--rule: #ddd2b8;
--ink: #17140d;
--ink-mid: #5c5340;
--ink-low: #857a63;
--sys: #8a6400;
--wep: #b3260f;
--pilot: #1c7a3f;
--on-accent: #fffdf7;
}
:root[data-theme="dark"] {
--ground: #0d0b07;
--panel: #16130d;
--rule: #2f2819;
--ink: #f3ead6;
--ink-mid: #b3a68a;
--ink-low: #7d7360;
--sys: #ffd230;
--wep: #ff482c;
--pilot: #6fdc8c;
--on-accent: #0d0b07;
}
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font: 400 var(--step-0)/1.6 var(--sans);
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 78rem; margin: 0 auto; padding: 0 clamp(1rem, 4vw, 3rem); }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
:focus-visible { outline: 2px solid var(--sys); outline-offset: 2px; }
/* ---------------------------------------------------------- masthead */
.top { border-bottom: 1px solid var(--rule); padding: clamp(2.5rem,6vw,4.5rem) 0 2rem; }
.eyebrow {
font: 500 var(--step--1)/1 var(--mono);
letter-spacing: .22em; text-transform: uppercase; color: var(--ink-low);
margin: 0 0 1.1rem;
}
h1 {
font: 700 var(--step-3)/1.02 var(--mono);
letter-spacing: -.02em; text-transform: uppercase; text-wrap: balance;
margin: 0 0 1rem;
}
h1 em { font-style: normal; color: var(--sys); }
.lede { max-width: 62ch; color: var(--ink-mid); margin: 0 0 1.6rem; font-size: var(--step-1); }
.lede strong { color: var(--ink); font-weight: 600; }
.rules { display: flex; flex-wrap: wrap; gap: .6rem 2rem; margin: 0; padding: 0; list-style: none; }
.rules li {
font: 500 var(--step--1)/1.5 var(--mono);
color: var(--ink-mid); padding-left: 1.1rem; position: relative;
}
.rules li::before {
content: ""; position: absolute; left: 0; top: .5em;
width: .45rem; height: .45rem; background: var(--sys);
}
.rules b { color: var(--ink); font-weight: 700; }
/* ----------------------------------------------------------- controls */
.bar {
position: sticky; top: 0; z-index: 5;
background: var(--ground);
background: color-mix(in srgb, var(--ground) 92%, transparent);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--rule);
padding: .75rem 0;
}
.bar__in { display: flex; flex-wrap: wrap; gap: .75rem 1.25rem; align-items: center; }
.seg { display: inline-flex; border: 1px solid var(--rule); }
.seg button {
font: 500 var(--step--1)/1 var(--mono); letter-spacing: .12em; text-transform: uppercase;
background: none; border: 0; color: var(--ink-mid);
padding: .55rem .9rem; cursor: pointer;
}
.seg button[aria-pressed="true"] { background: var(--sys); color: var(--on-accent); }
.search {
flex: 1 1 12rem; min-width: 9rem;
font: 400 var(--step--1) var(--mono);
background: var(--panel); color: var(--ink);
border: 1px solid var(--rule); padding: .55rem .7rem;
}
.search::placeholder { color: var(--ink-low); }
.count { font: 500 var(--step--1) var(--mono); color: var(--ink-low); letter-spacing: .1em; }
/* -------------------------------------------------------------- key */
.key { display: flex; flex-wrap: wrap; gap: .4rem .9rem; padding: 1.25rem 0 0; }
.key span { display: inline-flex; align-items: center; gap: .4rem;
font: 500 var(--step--1) var(--mono); letter-spacing: .08em; color: var(--ink-low); }
.key i { width: .7rem; height: .7rem; display: inline-block; }
/* ------------------------------------------------------------- roster */
.roster { display: grid; gap: 1px; background: var(--rule); border: 1px solid var(--rule);
margin: 1.75rem 0 4rem; }
.vtv { background: var(--panel); display: grid; gap: 0;
grid-template-columns: minmax(13rem, 17rem) 1fr; }
@media (max-width: 46rem) { .vtv { grid-template-columns: 1fr; } }
.vtv[hidden] { display: none; }
.seg__n {
margin-left: .45em; font-size: .85em; opacity: .55;
font-variant-numeric: tabular-nums;
}
.seg button[aria-pressed="true"] .seg__n { opacity: .75; }
.vtv__id { padding: 1.1rem 1.25rem; border-right: 1px solid var(--rule); }
@media (max-width: 46rem) { .vtv__id { border-right: 0; border-bottom: 1px solid var(--rule); } }
/* plan view, straight off the damage gauge, as a mask so it takes the
page colour in either theme */
.hull { margin: 0 0 .9rem; display: flex; flex-direction: column; gap: .45rem;
align-items: flex-start; }
.hull__img {
display: block; width: 100%; max-width: 6.5rem; aspect-ratio: var(--ar);
background-color: var(--ink-mid);
-webkit-mask-image: var(--art); mask-image: var(--art);
-webkit-mask-size: contain; mask-size: contain;
-webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
-webkit-mask-position: left center; mask-position: left center;
}
.hull figcaption {
font: 500 var(--step--1)/1.35 var(--mono); letter-spacing: .1em;
text-transform: uppercase; color: var(--ink-low);
}
.vtv__id h3 {
font: 700 var(--step-2)/1 var(--mono); letter-spacing: .01em;
margin: 0 0 .7rem; color: var(--ink);
}
.vtv__key {
font: 500 var(--step--1)/1 var(--mono); letter-spacing: .12em;
color: var(--ink-low); margin: -.45rem 0 .7rem;
}
.warn { color: var(--wep); }
.vtv__load { display: flex; flex-wrap: wrap; gap: .3rem; margin: 0 0 .7rem; }
.chip {
font: 500 var(--step--1)/1 var(--mono); letter-spacing: .08em;
padding: .3rem .45rem; border: 1px solid currentColor;
}
.chip--booster, .chip--chute { color: var(--sys); }
.chip--weapon { color: var(--wep); }
.chip--empty { color: var(--ink-low); }
.vtv__meta { display: flex; gap: .8rem; margin: 0; font: 400 var(--step--1) var(--mono);
color: var(--ink-low); font-variant-numeric: tabular-nums; }
.vtv__tables { min-width: 0; }
.tablewrap { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
thead th {
font: 500 var(--step--1)/1 var(--mono); letter-spacing: .14em;
color: var(--ink-low); text-align: left; font-weight: 500;
padding: .8rem .7rem .5rem; border-bottom: 1px solid var(--rule); white-space: nowrap;
}
tbody th {
width: 3.4rem; padding: .42rem .7rem; text-align: left; font-weight: 500;
border-bottom: 1px solid var(--rule); white-space: nowrap;
}
tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }
.pnum { font: 500 var(--step--1) var(--mono); color: var(--ink-mid); }
td.fn {
padding: .42rem .7rem; border-bottom: 1px solid var(--rule);
font: 500 var(--step--1)/1.3 var(--mono); letter-spacing: .06em; white-space: nowrap;
}
.fn--booster, .fn--chute { color: var(--sys); }
.fn--weapon { color: var(--wep); }
.fn--handling, .fn--comms { color: var(--pilot); }
.fn--empty { color: var(--ink-low); }
.fn--chute, .fn--comms { opacity: .82; }
.foot { border-top: 1px solid var(--rule); padding: 1.5rem 0 4rem;
color: var(--ink-low); font-size: var(--step--1); }
.foot code { font-family: var(--mono); color: var(--ink-mid); }
.empty-state { padding: 2rem; background: var(--panel); color: var(--ink-low);
font: 400 var(--step-0) var(--mono); }
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
"""
JS = """
const root = document.documentElement;
const cards = [...document.querySelectorAll('.vtv')];
const count = document.getElementById('count');
const search = document.getElementById('search');
let filter = 'all';
let hull = 'all';
function apply() {
const q = search.value.trim().toLowerCase();
let shown = 0;
for (const c of cards) {
const tags = c.dataset.tags;
const okHull = hull === 'all' || c.dataset.hull === hull;
const okTag = filter === 'all'
|| (filter === 'armed' && tags.includes('armed'))
|| (filter === 'unarmed' && tags.includes('unarmed'))
|| tags.split(' ').includes(filter);
const okQ = !q || c.dataset.search.includes(q);
const on = okTag && okHull && okQ;
c.hidden = !on;
if (on) shown++;
}
count.textContent = shown + ' / ' + cards.length + ' VTV';
document.getElementById('nomatch').hidden = shown > 0;
}
for (const b of document.querySelectorAll('[data-filter]')) {
b.addEventListener('click', () => {
filter = b.dataset.filter;
for (const o of document.querySelectorAll('[data-filter]'))
o.setAttribute('aria-pressed', String(o === b));
apply();
});
}
for (const b of document.querySelectorAll('#hulls button')) {
b.addEventListener('click', () => {
hull = b.dataset.hull;
for (const o of document.querySelectorAll('#hulls button'))
o.setAttribute('aria-pressed', String(o === b));
apply();
});
}
search.addEventListener('input', apply);
apply();
"""
PAGE = """<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Red Planet - VTV control presets</title>
<style>{css}</style>
<header class="top">
<div class="wrap">
<p class="eyebrow">Red Planet 4.12 &middot; decoded from RPL4.RES</p>
<h1>Every VTV and its<br><em>six control presets</em></h1>
<p class="lede">The six amber switches down the right flank of the map screen are not a
display option. <strong>Each is a complete factory layout for the four mappable
joystick buttons</strong>, authored per vehicle and shipped in the game's resource
file. Pressing one swaps the whole stick over, live, mid-race.</p>
<ul class="rules">
<li><b>Preset 2</b> always puts LIFT CUT on the pinky</li>
<li><b>Preset 6</b> always puts LIFT CUT on the trigger</li>
<li><b>Presets 1&ndash;5</b> keep the primary weapon on the trigger</li>
</ul>
</div>
</header>
<div class="bar">
<div class="wrap bar__in">
<div class="seg" role="group" aria-label="Filter by armament">
<button type="button" data-filter="all" aria-pressed="true">All</button>
<button type="button" data-filter="armed" aria-pressed="false">Armed</button>
<button type="button" data-filter="unarmed" aria-pressed="false">Unarmed</button>
</div>
<div class="seg" id="hulls" role="group" aria-label="Filter by hull">
<button type="button" data-hull="all" aria-pressed="true">All hulls</button>
{hulltabs}
</div>
<input id="search" class="search" type="search" placeholder="Search vehicle or system&hellip;"
aria-label="Search vehicle or system">
<span class="count" id="count">{n} / {n} VTV</span>
</div>
</div>
<main class="wrap">
<div class="key">
<span><i style="background:var(--sys)"></i>Vehicle systems</span>
<span><i style="background:var(--wep)"></i>Weapons</span>
<span><i style="background:var(--pilot)"></i>Pilot controls</span>
</div>
<div class="roster">{cards}</div>
<p class="empty-state" id="nomatch" hidden>No VTV matches that.</p>
<p class="foot">{n} VTVs carry a preset table; {armed} are armed. Decoded from the pod
RIO <code>ControlMappings</code> stream in <code>assets/RP411/RPL4.RES</code> &mdash;
subsystem names from each vehicle's own subsystem list, functions from
<code>ModePreset1..6</code> mask bits. Boosters, chute and weapons are the vehicle's
activatable systems; LIFT CUT and HORN are pilot controls that also live on the
panel. Empty cells are unbound &mdash; nothing on the pod fires them.</p>
<p class="foot">The plan views are the vehicle's own damage-gauge silhouette
(<code>GAUGE/s&lt;hull&gt;.pcc</code>), redrawn from the 3-colour original. The game has
no per-vehicle art: vehicles are grouped into hull families and share both the
silhouette and the 3D mesh in <code>VIDEO/</code>, which is keyed by the same two-letter
code. What distinguishes vehicles inside a family is the loadout, not the shape.</p>
</main>
<script>{js}</script>
"""
open(OUT, 'w', encoding='utf-8').write(PAGE.format(
css=CSS, js=JS, cards=''.join(cards), n=len(data), armed=armed,
hulltabs=hull_tabs))
print('wrote %s (%d VTVs)' % (OUT, len(data)))