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:
File diff suppressed because one or more lines are too long
+13
-2
@@ -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
|
||||
|
||||
@@ -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 3–8 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.
|
||||
@@ -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=(' · 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 · 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–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…"
|
||||
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> —
|
||||
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 — nothing on the pod fires them.</p>
|
||||
<p class="foot">The plan views are the vehicle's own damage-gauge silhouette
|
||||
(<code>GAUGE/s<hull>.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)))
|
||||
@@ -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} × {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', '—'),
|
||||
boxes=t.get('boxes', '—'),
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -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; } }
|
||||
@@ -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();
|
||||
@@ -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 · 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 — 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…"
|
||||
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 — 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 — the scenery the game places — 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>
|
||||
Reference in New Issue
Block a user