diff --git a/docs/tracks.html b/docs/tracks.html new file mode 100644 index 0000000..8cdc3cb --- /dev/null +++ b/docs/tracks.html @@ -0,0 +1,543 @@ + + + +Red Planet - the tracks + + +
+
+

Red Planet 4.12 · plans drawn from RPL4.RES

+

Every track,
seen from above

+

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: every plan below is that map's own scenery, + placed where the game places it, viewed from overhead with height + shown as brightness.

+
+
+ +
+
+
+ + + + + + +
+ + 18 / 18 TRACKS +
+
+ +
+
+ Scenery placement — brighter is higher ground + Offered for the death race + Offered for football +
+ +
+
+
+
+

Paingod's Passage

+

pain

+

ARCADE 4.10DEATH RACE

+
+
Instances
255
+
Cameras
18
+
Start boxes
12
+
Extent
3256 × 7223
+
Relief
2849
+
+ +
+
+
+
+
+

Blade's Edge

+

blade

+

ARCADE 4.10DEATH RACEFOOTBALL

+
+
Instances
216
+
Cameras
21
+
Start boxes
7
+
Extent
1365 × 6786
+
Relief
466
+
+ +
+
+
+
+
+

Yip's Yahoorama

+

yip

+

ARCADE 4.10DEATH RACEFOOTBALL

+
+
Instances
127
+
Cameras
20
+
Start boxes
17
+
Extent
1451 × 5867
+
Relief
432
+
+ +
+
+
+
+
+

Berserker's Bahnzai

+

otto

+

ARCADE 4.10DEATH RACEFOOTBALL

+
+
Instances
203
+
Cameras
24
+
Start boxes
16
+
Extent
1260 × 5773
+
Relief
442
+
+ +
+
+
+
+
+

Firestorm's Fury

+

frstrm

+

ARCADE 4.10DEATH RACEFOOTBALL

+
+
Instances
325
+
Cameras
20
+
Start boxes
15
+
Extent
3875 × 5773
+
Relief
3057
+
+ +
+
+
+
+
+

Wiseguy's Wake

+

wise

+

ARCADE 4.10DEATH RACEFOOTBALL

+
+
Instances
133
+
Cameras
16
+
Start boxes
9
+
Extent
1655 × 5608
+
Relief
418
+
+

Same footprint and instance count as -- Wiserguy's --

+
+
+
+
+
+

Burnt Cookies

+

burnt

+

ARCADE 4.10DEATH RACEFOOTBALL

+
+
Instances
251
+
Cameras
17
+
Start boxes
3
+
Extent
1500 × 5400
+
Relief
1007
+
+ +
+
+
+
+
+

Brewer's Bane

+

brewers

+

ARCADE 4.10DEATH RACEFOOTBALL

+
+
Instances
607
+
Cameras
20
+
Start boxes
20
+
Extent
3100 × 4485
+
Relief
368
+
+ +
+
+
+
+
+

Lyz's Lane

+

lyzlane

+

ARCADE 4.10DEATH RACEFOOTBALL

+
+
Instances
105
+
Cameras
12
+
Start boxes
44
+
Extent
1298 × 4170
+
Relief
313
+
+ +
+
+
+
+
+

Freezemoon's Freeway

+

headoff

+

ADDED 4.11DEATH RACE

+
+
Instances
264
+
Cameras
18
+
Start boxes
5
+
Extent
1500 × 5000
+
Relief
195
+
+ +
+
+
+
+
+

Freezemoon's Freeway (Football)

+

headmf

+

ADDED 4.11FOOTBALL

+
+
Instances
274
+
Cameras
18
+
Start boxes
5
+
Extent
1500 × 5000
+
Relief
195
+
+ +
+
+
+
+
+

-- Tour De Mars --

+

tourdemars

+

COMMUNITYDEATH RACE

+
+
Instances
607
+
Cameras
20
+
Start boxes
20
+
Extent
1400 × 23298
+
Relief
635
+
+ +
+
+
+
+
+

-- Donut --

+

donut

+

COMMUNITYDEATH RACE

+
+
Instances
325
+
Cameras
20
+
Start boxes
15
+
Extent
4275 × 9600
+
Relief
1430
+
+ +
+
+
+
+
+

-- Ares' Armpits --

+

trough

+

COMMUNITYDEATH RACE

+
+
Instances
633
+
Cameras
16
+
Start boxes
9
+
Extent
5130 × 9122
+
Relief
2115
+
+

631 of 633 instances decoded

+
+
+
+
+
+

-- Zaxxis --

+

rpweekend2014

+

COMMUNITYDEATH RACE

+
+
Instances
695
+
Cameras
40
+
Start boxes
3
+
Extent
3885 × 5915
+
Relief
266
+
+ +
+
+
+
+
+

-- Wiserguy's --

+

wiserguys

+

COMMUNITYDEATH RACE

+
+
Instances
133
+
Cameras
16
+
Start boxes
9
+
Extent
1655 × 5608
+
Relief
466
+
+

Same footprint and instance count as Wiseguy's Wake

+
+
+
+
+
+

-- Arena (Large) --

+

demoderb

+

COMMUNITYDEATH RACE

+
+
Instances
455
+
Cameras
17
+
Start boxes
3
+
Extent
2200 × 1900
+
Relief
63
+
+ +
+
+
+
+
+

-- Arena (Small) --

+

arena

+

COMMUNITYDEATH RACE

+
+
Instances
695
+
Cameras
17
+
Start boxes
3
+
Extent
1775 × 1750
+
Relief
80
+
+ +
+
+ + +

18 tracks: 9 shipped in the 4.10 arcade cabinets, 2 added in 4.11, 7 built by the community afterwards. Each plan + is the map's instance stream — the scenery the game places — read out of + assets/RP411/RPL4.RES. 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.

+

What is not 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.

+
+ + diff --git a/pack-dist.ps1 b/pack-dist.ps1 index 3600fec..fe917b3 100644 --- a/pack-dist.ps1 +++ b/pack-dist.ps1 @@ -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 diff --git a/tools/pages/README.md b/tools/pages/README.md new file mode 100644 index 0000000..108bc9d --- /dev/null +++ b/tools/pages/README.md @@ -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. diff --git a/tools/pages/build_page.py b/tools/pages/build_page.py new file mode 100644 index 0000000..e5bf09e --- /dev/null +++ b/tools/pages/build_page.py @@ -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('%s' % (k, html.escape(t)) + for k, t in chips) + + +def table(stream): + rows = [] + for i, row in enumerate(stream['presets'], 1): + cells = [] + for b in BUTTONS: + fn = row.get(b) + cells.append('%s' % (kind(fn), html.escape(pretty(fn)))) + rows.append('%d%s' + % (i, ''.join(cells))) + return ( + '
' + '' + '' + + ''.join('' % b.upper() for b in BUTTONS) + + '' + ''.join(rows) + '
Preset assignments
Preset%s
') + + +family = {} +for n, v in data.items(): + family.setdefault(v.get('silhouette'), []).append(n) + +cards = [] +for name in ORDER: + v = data[name] + tags = ' '.join(['armed' if v['weapons'] else 'unarmed'] + + [kind(w) for w in v['weapons']]) + code = v.get('silhouette') or '' + display = v.get('display') or name + # Hulls are named by the console, not by their two-letter art code. + hull_class = v.get('hullClass') or (code.upper() if code else 'unknown') + kin = sorted((data[x].get('display') or x) + for x in family.get(code, []) if x != name) + search = ' '.join([name, display, hull_class, code] + v['subsystems']).lower() + + art = v.get('hullArt') + if art: + w, h = sizes[art] + note = '' + if v.get('hullMismatch'): + note = (' ' + '(console says %s)' + % (html.escape(v['consoleClass']), html.escape(hull_class), + html.escape(v['consoleClass']))) + hull = ('
' + '
{cls} hull{kin}{note}
').format( + display=html.escape(display), uri=masks[art], w=w, h=h, + cls=html.escape(hull_class), note=note, + kin=(' · shared with ' + html.escape(', '.join(kin))) if kin else '') + else: + hull = '' + + cards.append(""" +
+
+ {hull} +

{display}

+

{key}

+

{chips}

+

{nsub} subsystems{nrec} mappings

+
+
{l4}
+
""".format( + tags=tags, 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 = """ + + +Red Planet - VTV control presets + + +
+
+

Red Planet 4.12 · decoded from RPL4.RES

+

Every VTV and its
six control presets

+

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

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

{n} VTVs carry a preset table; {armed} are armed. Decoded from the pod + RIO ControlMappings stream in assets/RP411/RPL4.RES — + subsystem names from each vehicle's own subsystem list, functions from + ModePreset1..6 mask bits. Boosters, chute and weapons are the vehicle's + activatable systems; LIFT CUT and HORN are pilot controls that also live on the + panel. Empty cells are unbound — nothing on the pod fires them.

+

The plan views are the vehicle's own damage-gauge silhouette + (GAUGE/s<hull>.pcc), redrawn from the 3-colour original. The game has + no per-vehicle art: vehicles are grouped into hull families and share both the + silhouette and the 3D mesh in VIDEO/, which is keyed by the same two-letter + code. What distinguishes vehicles inside a family is the loadout, not the shape.

+
+ + +""" + +open(OUT, 'w', encoding='utf-8').write(PAGE.format( + css=CSS, js=JS, cards=''.join(cards), n=len(data), armed=armed)) +print('wrote %s (%d VTVs)' % (OUT, len(data))) diff --git a/tools/pages/build_tracks.py b/tools/pages/build_tracks.py new file mode 100644 index 0000000..0659a36 --- /dev/null +++ b/tools/pages/build_tracks.py @@ -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 +""" +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(' 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'%s' % (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 = ('

Same footprint and instance count as ' + + ', '.join(tracks[k]['name'] for k in kin) + '

') if kin else '' + short = ('

%d of %d instances decoded

' + % (t['parsed'], t['instances'])) if t['parsed'] != t['instances'] else '' + art = ('' + % (html.escape(t['name']), t['art'], t['aw'], t['ah'])) if t['art'] else '' + cards.append(""" +
+
{art}
+
+

{name}

+

{key}

+

{chips}

+
+
Instances
{inst}
+
Cameras
{cams}
+
Start boxes
{boxes}
+
Extent
{ex} × {ez}
+
Relief
{ey}
+
+ {twin}{short} +
+
""".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) diff --git a/tools/pages/extract_presets.py b/tools/pages/extract_presets.py new file mode 100644 index 0000000..5cf7f85 --- /dev/null +++ b/tools/pages/extract_presets.py @@ -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 +""" +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' 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(' 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(); diff --git a/tools/pages/tracks.tpl b/tools/pages/tracks.tpl new file mode 100644 index 0000000..d440f7d --- /dev/null +++ b/tools/pages/tracks.tpl @@ -0,0 +1,58 @@ + + + +Red Planet - the tracks + + +
+
+

Red Planet 4.12 · plans drawn from RPL4.RES

+

Every track,
seen from above

+

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: every plan below is that map's own scenery, + placed where the game places it, viewed from overhead with height + shown as brightness.

+
+
+ +
+
+
+ + + + + + +
+ + {n} / {n} TRACKS +
+
+ +
+
+ Scenery placement — brighter is higher ground + Offered for the death race + Offered for football +
+ +
{cards}
+ + +

{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 + assets/RP411/RPL4.RES. 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.

+

What is not 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.

+
+ +