Every track, seen from above

docs/tracks.html joins the roster page: all 18 tracks with a plan view, what
the console calls them, which scenarios offer them, and how big they are.

There are no track maps in the game's files. The console had pictures of
them and those pictures did not survive - RPConfig.xml still points at
"images/red planet maps/Wiseguy's Wake.bmp" and the folder is gone. So the
plans are drawn from the tracks themselves. A map's instance stream places
its scenery: 76-byte records carrying a position at +48 and a unit
quaternion at +60, a few of them longer, so the reader resyncs on an
unexpected class id rather than trusting the stride. The quaternion doubles
as a checksum - a mis-read almost never yields a unit one - and 17 of the 18
decode every instance the header promises. Trough gives up 631 of 633 and
the card says so.

Seen this way the tracks have obvious shapes: Brewer's Bane turns two
corners, Tour De Mars is one 23,000-unit run, and both arenas are a regular
lattice of obstacles rather than a route at all.

The eras come from the resource-file archaeology rather than a guess: 9
tracks shipped in the 4.10 cabinets, headoff and headmf arrived with 4.11,
and 7 were built by the community afterwards. Scenario legality is read out
of the front end's own kMaps and kFootballMaps, so the page cannot claim a
track is offered when the menu does not offer it.

tools/pages carries the generators for both reference pages, with a README
covering the two formats they read and the id-alignment the listing is
needed for. They were scratch scripts until now, which made a committed
page harder to regenerate than to rebuild by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-07 10:27:26 -05:00
co-authored by Claude Opus 5
parent 6df309e9c4
commit 6c3127a94d
9 changed files with 1803 additions and 2 deletions
+543
View File
File diff suppressed because one or more lines are too long
+13 -2
View File
@@ -9,8 +9,8 @@
# - libsndfile-1.dll beside the exe; OpenAL32.dll copied from the system
# when installed, with oalinst.exe included as the fallback installer
# (environ.ini is NOT shipped - the exe writes it on first run)
# - start/joyconfig scripts, HANDBOOK.html, VTV-PRESETS.html, CONTROLS.txt
# and a README
# - start/joyconfig scripts, HANDBOOK.html, VTV-PRESETS.html, TRACKS.html,
# CONTROLS.txt and a README
#
# Usage: powershell -ExecutionPolicy Bypass -File pack-dist.ps1 [-Zip] [-Fresh]
#
@@ -167,6 +167,12 @@ $handbookPage
"$dist\VTV-PRESETS.html",
[System.IO.File]::ReadAllBytes((Join-Path $root 'docs\vtv-presets.html')))
# The tracks, drawn from their own instance streams. Same deal - the
# source is already a complete document, so it ships verbatim too.
[System.IO.File]::WriteAllBytes(
"$dist\TRACKS.html",
[System.IO.File]::ReadAllBytes((Join-Path $root 'docs\tracks.html')))
# --- OpenAL runtime --------------------------------------------------------
# The exe links OpenAL32.dll (32-bit). Prefer shipping the already-installed
# runtime beside the exe; oalinst.exe covers machines where that misses.
@@ -295,6 +301,11 @@ down the right of the map screen pick between them, and each one is a
complete factory layout for the four stick buttons - so the same button
does different jobs depending on which preset is lit.
TRACKS.html is every track seen from above. The game has no track maps
in it - the console had pictures and they did not survive - so each plan
is drawn from the track's own scenery, placed where the game places it,
with higher ground shown brighter.
Four files here are yours. None of them ship - the game writes each one
the first time it needs it and then leaves it alone, so a new build
unzipped over this folder keeps everything you have set. Delete any of
+45
View File
@@ -0,0 +1,45 @@
# Regenerating the reference pages
`docs/vtv-presets.html` and `docs/tracks.html` are generated from
`assets/RP411/RPL4.RES`, the gauge config, and the console's `RPConfig.xml`.
Both are committed, so this is only needed when the resource file changes.
Both need a resource listing from the original tool, which is the authority
for resource ids — the directory can be walked in file order, but the ids
have holes (this file leaves 53 and 56 unassigned) so the walk has to be
aligned against the listing:
```powershell
Release\RPL4TOOL.exe -l assets\RP411\RPL4.RES > listing.txt # ends in _getch(); close it
python tools\pages\extract_presets.py vtv.json listing.txt
python tools\pages\build_page.py vtv.json docs\vtv-presets.html
python tools\pages\build_tracks.py listing.txt docs\tracks.html
```
`RPL4TOOL` finishes with `_getch()` (`RP_L4/RPL4TOOL.cpp`), so with stdout
redirected it writes the whole listing and then waits for a keypress —
close it once the file stops growing.
## What each reads
| script | reads | writes |
|---|---|---|
| `extract_presets.py` | RES, `L4GAUGE.CFG`, `tools/console-config/RPConfig.xml`, listing | `vtv.json` |
| `build_page.py` | `vtv.json`, `GAUGE/s*.pcc` silhouettes | `docs/vtv-presets.html` |
| `build_tracks.py` | RES, listing, `RPConfig.xml`, `RPL4FE.cpp` catalogs, `tracks.css/js/tpl` | `docs/tracks.html` |
`build_tracks.py` reads the front end's own `kMaps` / `kFootballMaps` arrays
so the page cannot claim a track is offered when the menu does not offer it.
## Two formats worth knowing
**Control mappings.** A vehicle's `ControlsMappings List` holds the resource
ids of its `L4` and `Thrustmaster` streams. Streams are named plainly, so
there is nothing in a stream saying whose it is — resolve by id, never by
which vehicle name sits nearest in the file. Records are 24 bytes and carry
their own mode mask; bits 38 are `ModePreset1..6`.
**Map instances.** 76-byte records: class id at +0, position at +48, unit
quaternion at +60. A few records are longer, so resync on an unexpected
class id rather than trusting the stride. The quaternion doubles as a
checksum — a mis-read almost never produces a unit one.
+475
View File
@@ -0,0 +1,475 @@
"""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
ORDER = sorted(data, key=lambda v: (-len(data[v]['weapons']), v))
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'):
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-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, 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'])))
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; }
.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';
function apply() {
const q = search.value.trim().toLowerCase();
let shown = 0;
for (const c of cards) {
const tags = c.dataset.tags;
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 && 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();
});
}
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>
<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))
print('wrote %s (%d VTVs)' % (OUT, len(data)))
+226
View File
@@ -0,0 +1,226 @@
"""Render the track reference page from RPL4.RES.
Every track's plan view is drawn from the map's own instance stream: each
record carries a position at +48 and a quaternion at +60 on a 76-byte
stride, so the scenery placements ARE the map, seen from above.
Usage: build_tracks.py <rpl4tool-listing.txt> <out.html>
"""
import base64, html, io, os, re, struct, sys
from PIL import Image, ImageDraw
ROOT = 'c:/VWE/RP412'
RES = open(ROOT + '/assets/RP411/RPL4.RES', 'rb').read()
NRES = len(RES)
LISTING, OUT = sys.argv[1], sys.argv[2]
# ------------------------------------------------------- resource id table
def resource_table():
rows = []
for line in open(LISTING, encoding='latin1'):
m = re.match(r'\s*(\d+)\s+(\d+)?\s*(.*)$', line.rstrip('\n'))
if m and m.group(3).strip():
rows.append((int(m.group(1)), m.group(3).strip()))
walk = []
o = RES.find(b'StaticAudioStream\x00') - 8
while o + 0x38 <= NRES:
name = RES[o + 8:o + 0x28].split(b'\x00')[0].decode('latin1', 'replace')
addr, size = struct.unpack_from('<II', RES, o + 0x30)
if addr != o + 0x38 or addr + size > NRES:
break
walk.append((name, addr, size))
o = addr + size
table, i = {}, 0
for rid, desc in rows:
if desc == 'Not Used':
continue
if i < len(walk):
name, addr, size = walk[i]
table[rid] = {'name': name, 'addr': addr, 'size': size, 'desc': desc}
i += 1
return table
TABLE = resource_table()
# ------------------------------------------------------------ the console
CONSOLE = {}
xml = open(ROOT + '/tools/console-config/RPConfig.xml', encoding='utf-8-sig').read()
for m in re.finditer(r'<map\s+key="([^"]+)"\s+name="([^"]+)"', xml):
CONSOLE[m.group(1)] = m.group(2)
# What the game actually offers, read from the front end's own catalogs so
# this page cannot drift from the menu.
FE = open(ROOT + '/RP_L4/RPL4FE.cpp', encoding='utf-8', errors='replace').read()
def catalog(name):
m = re.search(r'const CatalogEntry %s\[\] =\s*\{(.*?)\n\t\};' % name, FE, re.S)
return set(re.findall(r'\{\s*"([^"]+)"', m.group(1))) if m else set()
RACE = catalog('kMaps')
FOOTBALL = catalog('kFootballMaps')
# Three eras, established by comparing resource files: the 4.10 retail pod
# image (TeslaRel410/ALPHA_1), the RP411 set RP412 inherited, and the 2014
# community build the current resource file came from.
ARCADE = {'wise', 'yip', 'pain', 'blade', 'otto', 'frstrm', 'burnt', 'brewers',
'lyzlane'}
RP411 = {'headoff', 'headmf'} # absent from the 4.10 retail file
def era(key):
if key in ARCADE: return ('arcade', 'ARCADE 4.10')
if key in RP411: return ('r411', 'ADDED 4.11')
return ('later', 'COMMUNITY')
# ------------------------------------------------------- instance stream
def placements(addr, size, count):
"""Records are 76 bytes: a class id at +0, position at +48, unit
quaternion at +60. A few records are longer, so resync on a bad
header rather than trusting the stride."""
o, end, out, guard = addr + 4, addr + size, [], 0
while len(out) < count and o + 76 <= end and guard < 40000:
guard += 1
cls = struct.unpack_from('<I', RES, o)[0]
if cls not in (0, 1, 76, 80):
o += 4
continue
x, y, z = struct.unpack_from('<3f', RES, o + 48)
qn = sum(v * v for v in struct.unpack_from('<4f', RES, o + 60))
if all(abs(v) < 1e5 for v in (x, y, z)) and abs(qn - 1.0) < 0.05:
out.append((x, y, z))
o += 76
else:
o += 4
return out
def plan_view(points, box=360):
"""Top-down alpha mask: the placements, north up, scaled to fit."""
xs = [p[0] for p in points]
zs = [p[2] for p in points]
ys = [p[1] for p in points]
w = max(xs) - min(xs) or 1.0
h = max(zs) - min(zs) or 1.0
scale = (box - 16) / max(w, h)
iw = max(8, int(w * scale) + 16)
ih = max(8, int(h * scale) + 16)
img = Image.new('L', (iw, ih), 0)
dr = ImageDraw.Draw(img)
lo_y, hi_y = min(ys), max(ys)
span_y = (hi_y - lo_y) or 1.0
for x, y, z in points:
px = 8 + (x - min(xs)) * scale
# world +z runs away from the camera; screen y grows downward
py = ih - 8 - (z - min(zs)) * scale
# higher scenery reads brighter, so relief survives the flattening
v = 110 + int(145 * (y - lo_y) / span_y)
dr.ellipse([px - 2.5, py - 2.5, px + 2.5, py + 2.5], fill=v)
out = Image.merge('RGBA', (Image.new('L', img.size, 255),) * 3 + (img,))
buf = io.BytesIO()
out.save(buf, 'PNG', optimize=True)
return ('data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode(),
iw, ih)
# ---------------------------------------------------------------- gather
tracks = {}
for res in TABLE.values():
m = re.match(r'^(\w+): Map stream of (\d+) instances$', res['desc'])
if m:
tracks[m.group(1)] = {'key': m.group(1), 'instances': int(m.group(2)),
'addr': res['addr'], 'size': res['size']}
for res in TABLE.values():
m = re.match(r'^(\w+): Stream of (\d+) (Cameras|Existance boxes)$', res['desc'])
if m and m.group(1) in tracks:
field = 'cameras' if m.group(3) == 'Cameras' else 'boxes'
tracks[m.group(1)][field] = int(m.group(2))
for key, t in tracks.items():
pts = placements(t['addr'], t['size'], t['instances'])
t['parsed'] = len(pts)
t['name'] = CONSOLE.get(key, key)
t['race'] = key in RACE
t['football'] = key in FOOTBALL
t['era'], t['eraLabel'] = era(key)
if pts:
xs = [p[0] for p in pts]; ys = [p[1] for p in pts]; zs = [p[2] for p in pts]
t['extent'] = (max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs))
t['art'], t['aw'], t['ah'] = plan_view(pts)
else:
t['extent'] = (0, 0, 0)
t['art'] = None
# Tracks with identical footprints are almost certainly one built from the
# other; say so rather than leaving the reader to notice.
twins = {}
for key, t in tracks.items():
sig = (t['instances'], round(t['extent'][0]), round(t['extent'][2]))
twins.setdefault(sig, []).append(key)
ERA_RANK = {'arcade': 0, 'r411': 1, 'later': 2}
ORDER = sorted(tracks, key=lambda k: (ERA_RANK[tracks[k]['era']],
-tracks[k]['extent'][2]))
print('tracks: %d' % len(tracks), file=sys.stderr)
# ------------------------------------------------------------------ page
def chips(t):
out = []
out.append((t['era'], t['eraLabel']))
if t['race']: out.append(('race', 'DEATH RACE'))
if t['football']: out.append(('ball', 'FOOTBALL'))
if not t['race'] and not t['football']:
out.append(('none', 'NOT IN THE MENU'))
return ''.join('<span class="chip chip--%s">%s</span>' % (k, v) for k, v in out)
cards = []
for key in ORDER:
t = tracks[key]
sig = (t['instances'], round(t['extent'][0]), round(t['extent'][2]))
kin = [k for k in twins.get(sig, []) if k != key]
twin = ('<p class="twin">Same footprint and instance count as '
+ ', '.join(tracks[k]['name'] for k in kin) + '</p>') if kin else ''
short = ('<p class="twin warn">%d of %d instances decoded</p>'
% (t['parsed'], t['instances'])) if t['parsed'] != t['instances'] else ''
art = ('<span class="plan__img" role="img" aria-label="%s plan view" '
'style="--art:url(%s);--ar:%d/%d"></span>'
% (html.escape(t['name']), t['art'], t['aw'], t['ah'])) if t['art'] else ''
cards.append("""
<article class="trk" data-tags="{tags}" data-search="{search}">
<figure class="plan">{art}</figure>
<div class="trk__body">
<h3>{name}</h3>
<p class="trk__key">{key}</p>
<p class="trk__chips">{chips}</p>
<dl class="stat">
<div><dt>Instances</dt><dd>{inst}</dd></div>
<div><dt>Cameras</dt><dd>{cams}</dd></div>
<div><dt>Start boxes</dt><dd>{boxes}</dd></div>
<div><dt>Extent</dt><dd>{ex} &times; {ez}</dd></div>
<div><dt>Relief</dt><dd>{ey}</dd></div>
</dl>
{twin}{short}
</div>
</article>""".format(
tags=t['era'] + (' race' if t['race'] else '')
+ (' football' if t['football'] else ''),
search=html.escape((key + ' ' + t['name']).lower()),
art=art, name=html.escape(t['name']), key=html.escape(key), chips=chips(t),
inst=t['instances'], cams=t.get('cameras', '&mdash;'),
boxes=t.get('boxes', '&mdash;'),
ex='%,d'.replace(',', '') % round(t['extent'][0]),
ez='%d' % round(t['extent'][2]), ey='%d' % round(t['extent'][1]),
twin=twin, short=short))
arcade_n = sum(1 for t in tracks.values() if t['era'] == 'arcade')
r411_n = sum(1 for t in tracks.values() if t['era'] == 'r411')
CSS = open(os.path.join(os.path.dirname(__file__), 'tracks.css')).read()
JS = open(os.path.join(os.path.dirname(__file__), 'tracks.js')).read()
PAGE = open(os.path.join(os.path.dirname(__file__), 'tracks.tpl'), encoding='utf-8').read()
open(OUT, 'w', encoding='utf-8').write(PAGE.format(
css=CSS, js=JS, cards=''.join(cards), n=len(tracks), arcade=arcade_n,
r411=r411_n, later=len(tracks) - arcade_n - r411_n))
print('wrote %s (%d tracks)' % (OUT, len(tracks)), file=sys.stderr)
+246
View File
@@ -0,0 +1,246 @@
"""Decode every VTV's loadout and six-preset control table from RPL4.RES.
Everything here is resolved by resource id rather than by guesswork:
* RPL4TOOL -l gives the authoritative id -> description table.
* The resource directory is walked in file order (an 8-byte prefix, a
32-byte name, addr/size at +0x30, data at +0x38, next descriptor after
the data) and aligned against that listing, skipping the ids the
listing marks "Not Used". Sizes are cross-checked on every row.
* A vehicle's "ControlsMappings List of 2 elements" holds the resource
ids of its Thrustmaster and L4 streams, so each stream is attributed
to its owner exactly - no nearest-name proximity, which quietly
mis-attributed the community vehicles.
* Subsystem names come from the vehicle's own "Stream of N Subsystems".
VTV::BasicSubsystemCount = 9, so list index i is subsystem id 9+i.
Usage: extract_presets.py <out.json> <rpl4tool-listing.txt>
"""
import json, re, struct, sys
ROOT = 'c:/VWE/RP412'
RES = open(ROOT + '/assets/RP411/RPL4.RES', 'rb').read()
CFG = open(ROOT + '/assets/RP411/GAUGE/L4GAUGE.CFG', 'r', errors='replace').read()
NRES = len(RES)
OUT, LISTING = sys.argv[1], sys.argv[2]
BASIC_SUBSYSTEM_COUNT = 9 # VTV::BasicSubsystemCount
PRESET_BITS = 0x1F8 # ModePreset1..6
STICK = {0x40: 'trigger', 0x45: 'pinky', 0x46: 'thumb low', 0x47: 'thumb high'}
CM_ATTR = {2: 'stick', 3: 'throttle', 4: 'pedals', 5: 'reverse thrust',
6: 'LIFT CUT', 7: 'SIDESLIP'}
CM_MSG = {10: 'HORN', 14: 'reticle'}
# Authored bindings whose hardware never shipped past prototype cockpits.
NO_HARDWARE = {13} # VTVControlsMapper ActivatePTT
SILHOUETTE = {'l': 'lepton', 'p': 'puck', 's': 'speck', 'm': 'mule', 'b': 'bull'}
# ------------------------------------------------------------------ the CFG
blocks = dict(re.findall(r'^([A-Za-z0-9_]+)\s*\n\{(.*?)^\}', CFG, re.S | re.M))
hull_art = {}
for name, body in blocks.items():
if len(name) == 2 and name[0] in 'blpsm':
pccs = re.findall(r'([a-z0-9]+\.pcc)', body)
if pccs:
hull_art[name] = pccs[0]
# ------------------------------------------------- the console's own names
# TeslaConsole's RPConfig.xml is the only place the vehicles and their hull
# classes are named in words. The class comes from the picture the console
# showed for each machine ("images/red planet vehicles/Bull.bmp"), so every
# vehicle sharing a picture shares a class.
CONSOLE = {}
xml = open(ROOT + '/tools/console-config/RPConfig.xml', encoding='utf-8-sig').read()
for m in re.finditer(r'<vehicle\s+key="([^"]+)"\s+name="([^"]+)"'
r'(?:\s+image="[^"]*/([^"/]+)\.bmp")?', xml):
CONSOLE[m.group(1)] = {'display': m.group(2), 'consoleClass': m.group(3)}
cfg_veh = {}
for name, body in blocks.items():
if not name.endswith('Init'):
continue
refs = re.findall(r'^\s*([A-Za-z0-9_]+)\s*;', body, re.M)
if not any(r in ('common_setup', 'uncommon_setup') for r in refs):
continue
art = next((r for r in refs if len(r) == 2 and r[0] in SILHOUETTE), None)
cfg_veh[name[:-4]] = {
'silhouette': art,
'hullArt': hull_art.get(art),
'components': [r for r in refs if r not in ('common_setup', 'uncommon_setup')
and r != art],
}
# ------------------------------------------------- the resource id table
def resource_table():
rows = []
for line in open(LISTING, encoding='latin1'):
m = re.match(r'\s*(\d+)\s+(\d+)?\s*(.*)$', line.rstrip('\n'))
if m and m.group(3).strip():
rows.append((int(m.group(1)), m.group(2), m.group(3).strip()))
walk = []
o = RES.find(b'StaticAudioStream\x00') - 8
while o + 0x38 <= NRES:
name = RES[o + 8:o + 0x28].split(b'\x00')[0].decode('latin1', 'replace')
addr, size = struct.unpack_from('<II', RES, o + 0x30)
if addr != o + 0x38 or addr + size > NRES:
break
walk.append((name, addr, size))
o = addr + size
table, i, mismatched = {}, 0, 0
for rid, rsize, desc in rows:
if desc == 'Not Used':
continue # an id the file never assigned
if i >= len(walk):
break
name, addr, size = walk[i]
if rsize is not None and int(rsize) != size:
mismatched += 1
table[rid] = {'name': name, 'addr': addr, 'size': size, 'desc': desc}
i += 1
if mismatched:
print('!! %d id/size mismatches - the walk and the listing disagree'
% mismatched, file=sys.stderr)
return table
TABLE = resource_table()
# ------------------------------------------------------- subsystem names
RAWNAME = re.compile(rb'([A-Za-z][A-Za-z0-9_]{2,23})\x00')
SUBNAME = re.compile(r'(Booster\d*|Chute\d*|RivetGun\d*|LaserGun\d*|'
r'Demo[Pp]ackDropper\d*|Slaver\d*|Stinger\d*|Eject\d*)$')
def clean(raw):
"""Records are variable length; a float tail can leave printable junk
glued to the front of the next name. Longest valid suffix wins."""
for i in range(len(raw)):
if SUBNAME.fullmatch(raw[i:]):
return raw[i:]
return None
def subsystems(veh, warn):
want = re.compile(r'^%s: Stream of (\d+) Subsystems$' % re.escape(veh))
for res in TABLE.values():
m = want.match(res['desc'])
if not m:
continue
expect = int(m.group(1))
body = RES[res['addr'] + 4:res['addr'] + res['size']]
names = [clean(x.group(1).decode()) for x in RAWNAME.finditer(body)]
names = [n for n in names if n]
if len(names) != expect:
warn.append('subsystem stream lists %d names, header says %d'
% (len(names), expect))
return names
return []
# ------------------------------------------------------- mapping streams
def control_streams(veh):
"""The vehicle's ControlsMappings List names its streams by id."""
want = '%s: ControlsMappings List of' % veh
found = {}
for res in TABLE.values():
if not res['desc'].startswith(want):
continue
count = struct.unpack_from('<I', RES, res['addr'])[0]
for k in range(count):
rid = struct.unpack_from('<I', RES, res['addr'] + 4 + k * 4)[0]
target = TABLE.get(rid)
if target is None:
continue
m = re.match(r'^(L4|Thrustmaster)(?:: Stream of (\d+) ControlsMappings)?$',
target['desc'])
if m and m.group(2):
found[m.group(1)] = (target['addr'], int(m.group(2)))
return found
def decode(addr, count, subs, warn):
recs = [struct.unpack_from('<IIIIII', RES, addr + 4 + i * 24)
for i in range(count)]
def label(t, s, x):
if s == 0:
return CM_ATTR.get(x, 'attr%d' % x) if t == 0 else CM_MSG.get(x, 'msg%d' % x)
i = s - BASIC_SUBSYSTEM_COUNT
if 0 <= i < len(subs):
return subs[i]
warn.append('subsystem id %d outside the %d-entry list' % (s, len(subs)))
return 'subsystem %d' % s
def live(t, s, x):
return not (s == 0 and t == 1 and x in NO_HARDWARE)
presets = []
for p in range(6):
row = {}
for g, e, t, s, m, x in recs:
if (m & (8 << p)) and e in STICK and live(t, s, x):
row[STICK[e]] = label(t, s, x)
presets.append(row)
used = {label(t, s, x) for g, e, t, s, m, x in recs
if (m & PRESET_BITS) and e in STICK and live(t, s, x)}
return {'presets': presets, 'records': count, 'onStick': sorted(used)}
# ------------------------------------------------------------------ build
out, warnings = {}, {}
for veh in sorted(cfg_veh):
streams = control_streams(veh)
if 'L4' not in streams:
continue # no vehicle resource in this file
warn = warnings.setdefault(veh, [])
subs = subsystems(veh, warn)
if not subs:
warn.append('no subsystem stream found')
addr, count = streams['L4']
out[veh] = {
'name': veh, 'subsystems': subs, **cfg_veh[veh],
'boosters': sum(1 for s in subs if s.startswith('Booster')),
'chute': any(s.startswith('Chute') for s in subs),
'weapons': [s for s in subs if not s.startswith(('Booster', 'Chute'))],
'armed': any(not s.startswith(('Booster', 'Chute')) for s in subs),
'L4': decode(addr, count, subs, warn),
}
# --------------------------------------------- name the hulls, not the codes
# The silhouette code is what the game draws; the console names it. Decide
# each hull's name by majority vote of the vehicles drawn with it, so one
# mis-set picture in the config cannot rename a whole class.
votes = {}
for veh, entry in out.items():
cls = CONSOLE.get(veh, {}).get('consoleClass')
if cls:
votes.setdefault(entry.get('silhouette'), []).append(cls)
hull_name = {}
for sil, names in votes.items():
hull_name[sil] = max(set(names), key=names.count)
for veh, entry in out.items():
console = CONSOLE.get(veh, {})
entry['display'] = console.get('display', veh)
entry['consoleClass'] = console.get('consoleClass')
entry['hullClass'] = hull_name.get(entry.get('silhouette'))
# The console picture and the hull the game actually draws disagree for
# two community vehicles - worth showing rather than smoothing over.
entry['hullMismatch'] = bool(
entry['consoleClass'] and entry['hullClass'] and
entry['consoleClass'] != entry['hullClass'])
if entry['hullMismatch']:
print('!! %s: console shows a %s picture, the game draws the %s hull'
% (veh, entry['consoleClass'], entry['hullClass']), file=sys.stderr)
for veh, w in sorted(warnings.items()):
for line in sorted(set(w)):
print('!! %s: %s' % (veh, line), file=sys.stderr)
print('vehicles with preset tables: %d' % len(out), file=sys.stderr)
print('in the gauge config but not this resource file: %s'
% sorted(set(cfg_veh) - set(out)), file=sys.stderr)
json.dump(out, open(OUT, 'w'), indent=1)
print('wrote %s' % OUT, file=sys.stderr)
+167
View File
@@ -0,0 +1,167 @@
*, *::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;
--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.25rem, 1.1rem + .6vw, 1.6rem);
--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: 82rem; 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; }
.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.4rem; font-size: var(--step-1); }
.lede strong { color: var(--ink); font-weight: 600; }
.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; }
.grid {
display: grid; gap: 1px; background: var(--rule);
border: 1px solid var(--rule); margin: 1.75rem 0 4rem;
grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
}
.trk { background: var(--panel); padding: 1.1rem 1.25rem 1.25rem; }
.trk[hidden] { display: none; }
.plan { margin: 0 0 1rem; display: flex; justify-content: center; }
.plan__img {
display: block; width: 100%; max-width: 15rem; 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: center; mask-position: center;
}
.trk h3 {
font: 700 var(--step-2)/1.1 var(--mono); margin: 0 0 .2rem; color: var(--ink);
text-wrap: balance;
}
.trk__key {
font: 500 var(--step--1)/1 var(--mono); letter-spacing: .12em;
color: var(--ink-low); margin: 0 0 .7rem;
}
.trk__chips { display: flex; flex-wrap: wrap; gap: .3rem; margin: 0 0 .8rem; }
.chip {
font: 500 var(--step--1)/1 var(--mono); letter-spacing: .08em;
padding: .3rem .45rem; border: 1px solid currentColor;
}
.chip--arcade { color: var(--ink-mid); }
.chip--r411 { color: var(--ink-low); }
.chip--later { color: var(--ink-low); }
.chip--race { color: var(--sys); }
.chip--ball { color: var(--pilot); }
.chip--none { color: var(--wep); }
.stat { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: .15rem .8rem; }
.stat div { display: contents; }
.stat dt {
font: 500 var(--step--1) var(--mono); letter-spacing: .1em;
text-transform: uppercase; color: var(--ink-low);
}
.stat dd {
margin: 0; text-align: right; font: 500 var(--step--1) var(--mono);
color: var(--ink); font-variant-numeric: tabular-nums;
}
.twin {
margin: .8rem 0 0; font: 400 var(--step--1)/1.45 var(--mono); color: var(--ink-low);
}
.twin.warn { color: var(--wep); }
.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; }
.foot {
border-top: 1px solid var(--rule); padding: 1.5rem 0 4rem;
color: var(--ink-low); font-size: var(--step--1); max-width: 78ch;
}
.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; } }
+30
View File
@@ -0,0 +1,30 @@
const cards = [...document.querySelectorAll('.trk')];
const count = document.getElementById('count');
const search = document.getElementById('search');
let filter = 'all';
function apply() {
const q = search.value.trim().toLowerCase();
let shown = 0;
for (const c of cards) {
const tags = c.dataset.tags.split(' ');
const okTag = filter === 'all' || tags.includes(filter);
const okQ = !q || c.dataset.search.includes(q);
const on = okTag && okQ;
c.hidden = !on;
if (on) shown++;
}
count.textContent = shown + ' / ' + cards.length + ' TRACKS';
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();
});
}
search.addEventListener('input', apply);
apply();
+58
View File
@@ -0,0 +1,58 @@
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Red Planet - the tracks</title>
<style>{css}</style>
<header class="top">
<div class="wrap">
<p class="eyebrow">Red Planet 4.12 &middot; plans drawn from RPL4.RES</p>
<h1>Every track,<br><em>seen from above</em></h1>
<p class="lede">There are no track maps in the game's files &mdash; the console had
pictures of them and those pictures did not survive. So these plans are drawn
from the tracks themselves: <strong>every plan below is that map's own scenery,
placed where the game places it</strong>, viewed from overhead with height
shown as brightness.</p>
</div>
</header>
<div class="bar">
<div class="wrap bar__in">
<div class="seg" role="group" aria-label="Filter">
<button type="button" data-filter="all" aria-pressed="true">All</button>
<button type="button" data-filter="arcade" aria-pressed="false">Arcade</button>
<button type="button" data-filter="r411" aria-pressed="false">4.11</button>
<button type="button" data-filter="later" aria-pressed="false">Community</button>
<button type="button" data-filter="race" aria-pressed="false">Death race</button>
<button type="button" data-filter="football" aria-pressed="false">Football</button>
</div>
<input id="search" class="search" type="search" placeholder="Search track&hellip;"
aria-label="Search track">
<span class="count" id="count">{n} / {n} TRACKS</span>
</div>
</div>
<main class="wrap">
<div class="key">
<span><i style="background:var(--ink-mid)"></i>Scenery placement &mdash; brighter is higher ground</span>
<span><i style="background:var(--sys)"></i>Offered for the death race</span>
<span><i style="background:var(--pilot)"></i>Offered for football</span>
</div>
<div class="grid">{cards}</div>
<p class="empty-state" id="nomatch" hidden>No track matches that.</p>
<p class="foot">{n} tracks: {arcade} shipped in the 4.10 arcade cabinets, {r411} added in 4.11, {later} built by the community afterwards. Each plan
is the map's instance stream &mdash; the scenery the game places &mdash; read out of
<code>assets/RP411/RPL4.RES</code>. Records are 76 bytes carrying a position and a
unit quaternion, so a placement validates itself and a mis-read cannot pass as a
building. Extent and relief are the span of those placements in world units, not a
measured track length: a long thin plan is a point-to-point run, a squat one a
circuit or an arena.</p>
<p class="foot">What is <em>not</em> here: the driving surface. Only placed objects are
stored this way, so a plan shows what lines the route rather than the route itself.
Tracks marked NOT IN THE MENU exist in the resource file but the setup screen does
not offer them.</p>
</main>
<script>{js}</script>