"""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('%s' % (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('%s' % (kind(fn), html.escape(pretty(fn)))) rows.append('%d%s' % (i, ''.join(cells))) return ( '
' '' '' + ''.join('' % b.upper() for b in BUTTONS) + '' + ''.join(rows) + '
Preset assignments
Preset%s
') 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 = (' ' '(console says %s)' % (html.escape(v['consoleClass']), html.escape(hull_class), html.escape(v['consoleClass']))) hull = ('
' '
{cls} hull{kin}{note}
').format( display=html.escape(display), uri=masks[art], w=w, h=h, cls=html.escape(hull_class), note=note, kin=(' · shared with ' + html.escape(', '.join(kin))) if kin else '') else: hull = '' cards.append("""
{hull}

{display}

{key}

{chips}

{nsub} subsystems{nrec} mappings

{l4}
""".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( '' % (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 = """ Red Planet - VTV control presets

Red Planet 4.12 · decoded from RPL4.RES

Every VTV and its
six control presets

The six amber switches down the right flank of the map screen are not a display option. Each is a complete factory layout for the four mappable joystick buttons, authored per vehicle and shipped in the game's resource file. Pressing one swaps the whole stick over, live, mid-race.

{hulltabs}
{n} / {n} VTV
Vehicle systems Weapons Pilot controls
{cards}

{n} VTVs carry a preset table; {armed} are armed. Decoded from the pod RIO ControlMappings stream in assets/RP411/RPL4.RES — subsystem names from each vehicle's own subsystem list, functions from ModePreset1..6 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 — nothing on the pod fires them.

The plan views are the vehicle's own damage-gauge silhouette (GAUGE/s<hull>.pcc), 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 VIDEO/, which is keyed by the same two-letter code. What distinguishes vehicles inside a family is the loadout, not the shape.

""" 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)))