Files
RP412/tools/pages/build_tracks.py
T
CydandClaude Opus 5 6c3127a94d 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>
2026-08-07 10:27:26 -05:00

227 lines
9.1 KiB
Python

"""Render the track reference page from RPL4.RES.
Every track's plan view is drawn from the map's own instance stream: each
record carries a position at +48 and a quaternion at +60 on a 76-byte
stride, so the scenery placements ARE the map, seen from above.
Usage: build_tracks.py <rpl4tool-listing.txt> <out.html>
"""
import base64, html, io, os, re, struct, sys
from PIL import Image, ImageDraw
ROOT = 'c:/VWE/RP412'
RES = open(ROOT + '/assets/RP411/RPL4.RES', 'rb').read()
NRES = len(RES)
LISTING, OUT = sys.argv[1], sys.argv[2]
# ------------------------------------------------------- resource id table
def resource_table():
rows = []
for line in open(LISTING, encoding='latin1'):
m = re.match(r'\s*(\d+)\s+(\d+)?\s*(.*)$', line.rstrip('\n'))
if m and m.group(3).strip():
rows.append((int(m.group(1)), m.group(3).strip()))
walk = []
o = RES.find(b'StaticAudioStream\x00') - 8
while o + 0x38 <= NRES:
name = RES[o + 8:o + 0x28].split(b'\x00')[0].decode('latin1', 'replace')
addr, size = struct.unpack_from('<II', RES, o + 0x30)
if addr != o + 0x38 or addr + size > NRES:
break
walk.append((name, addr, size))
o = addr + size
table, i = {}, 0
for rid, desc in rows:
if desc == 'Not Used':
continue
if i < len(walk):
name, addr, size = walk[i]
table[rid] = {'name': name, 'addr': addr, 'size': size, 'desc': desc}
i += 1
return table
TABLE = resource_table()
# ------------------------------------------------------------ the console
CONSOLE = {}
xml = open(ROOT + '/tools/console-config/RPConfig.xml', encoding='utf-8-sig').read()
for m in re.finditer(r'<map\s+key="([^"]+)"\s+name="([^"]+)"', xml):
CONSOLE[m.group(1)] = m.group(2)
# What the game actually offers, read from the front end's own catalogs so
# this page cannot drift from the menu.
FE = open(ROOT + '/RP_L4/RPL4FE.cpp', encoding='utf-8', errors='replace').read()
def catalog(name):
m = re.search(r'const CatalogEntry %s\[\] =\s*\{(.*?)\n\t\};' % name, FE, re.S)
return set(re.findall(r'\{\s*"([^"]+)"', m.group(1))) if m else set()
RACE = catalog('kMaps')
FOOTBALL = catalog('kFootballMaps')
# Three eras, established by comparing resource files: the 4.10 retail pod
# image (TeslaRel410/ALPHA_1), the RP411 set RP412 inherited, and the 2014
# community build the current resource file came from.
ARCADE = {'wise', 'yip', 'pain', 'blade', 'otto', 'frstrm', 'burnt', 'brewers',
'lyzlane'}
RP411 = {'headoff', 'headmf'} # absent from the 4.10 retail file
def era(key):
if key in ARCADE: return ('arcade', 'ARCADE 4.10')
if key in RP411: return ('r411', 'ADDED 4.11')
return ('later', 'COMMUNITY')
# ------------------------------------------------------- instance stream
def placements(addr, size, count):
"""Records are 76 bytes: a class id at +0, position at +48, unit
quaternion at +60. A few records are longer, so resync on a bad
header rather than trusting the stride."""
o, end, out, guard = addr + 4, addr + size, [], 0
while len(out) < count and o + 76 <= end and guard < 40000:
guard += 1
cls = struct.unpack_from('<I', RES, o)[0]
if cls not in (0, 1, 76, 80):
o += 4
continue
x, y, z = struct.unpack_from('<3f', RES, o + 48)
qn = sum(v * v for v in struct.unpack_from('<4f', RES, o + 60))
if all(abs(v) < 1e5 for v in (x, y, z)) and abs(qn - 1.0) < 0.05:
out.append((x, y, z))
o += 76
else:
o += 4
return out
def plan_view(points, box=360):
"""Top-down alpha mask: the placements, north up, scaled to fit."""
xs = [p[0] for p in points]
zs = [p[2] for p in points]
ys = [p[1] for p in points]
w = max(xs) - min(xs) or 1.0
h = max(zs) - min(zs) or 1.0
scale = (box - 16) / max(w, h)
iw = max(8, int(w * scale) + 16)
ih = max(8, int(h * scale) + 16)
img = Image.new('L', (iw, ih), 0)
dr = ImageDraw.Draw(img)
lo_y, hi_y = min(ys), max(ys)
span_y = (hi_y - lo_y) or 1.0
for x, y, z in points:
px = 8 + (x - min(xs)) * scale
# world +z runs away from the camera; screen y grows downward
py = ih - 8 - (z - min(zs)) * scale
# higher scenery reads brighter, so relief survives the flattening
v = 110 + int(145 * (y - lo_y) / span_y)
dr.ellipse([px - 2.5, py - 2.5, px + 2.5, py + 2.5], fill=v)
out = Image.merge('RGBA', (Image.new('L', img.size, 255),) * 3 + (img,))
buf = io.BytesIO()
out.save(buf, 'PNG', optimize=True)
return ('data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode(),
iw, ih)
# ---------------------------------------------------------------- gather
tracks = {}
for res in TABLE.values():
m = re.match(r'^(\w+): Map stream of (\d+) instances$', res['desc'])
if m:
tracks[m.group(1)] = {'key': m.group(1), 'instances': int(m.group(2)),
'addr': res['addr'], 'size': res['size']}
for res in TABLE.values():
m = re.match(r'^(\w+): Stream of (\d+) (Cameras|Existance boxes)$', res['desc'])
if m and m.group(1) in tracks:
field = 'cameras' if m.group(3) == 'Cameras' else 'boxes'
tracks[m.group(1)][field] = int(m.group(2))
for key, t in tracks.items():
pts = placements(t['addr'], t['size'], t['instances'])
t['parsed'] = len(pts)
t['name'] = CONSOLE.get(key, key)
t['race'] = key in RACE
t['football'] = key in FOOTBALL
t['era'], t['eraLabel'] = era(key)
if pts:
xs = [p[0] for p in pts]; ys = [p[1] for p in pts]; zs = [p[2] for p in pts]
t['extent'] = (max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs))
t['art'], t['aw'], t['ah'] = plan_view(pts)
else:
t['extent'] = (0, 0, 0)
t['art'] = None
# Tracks with identical footprints are almost certainly one built from the
# other; say so rather than leaving the reader to notice.
twins = {}
for key, t in tracks.items():
sig = (t['instances'], round(t['extent'][0]), round(t['extent'][2]))
twins.setdefault(sig, []).append(key)
ERA_RANK = {'arcade': 0, 'r411': 1, 'later': 2}
ORDER = sorted(tracks, key=lambda k: (ERA_RANK[tracks[k]['era']],
-tracks[k]['extent'][2]))
print('tracks: %d' % len(tracks), file=sys.stderr)
# ------------------------------------------------------------------ page
def chips(t):
out = []
out.append((t['era'], t['eraLabel']))
if t['race']: out.append(('race', 'DEATH RACE'))
if t['football']: out.append(('ball', 'FOOTBALL'))
if not t['race'] and not t['football']:
out.append(('none', 'NOT IN THE MENU'))
return ''.join('<span class="chip chip--%s">%s</span>' % (k, v) for k, v in out)
cards = []
for key in ORDER:
t = tracks[key]
sig = (t['instances'], round(t['extent'][0]), round(t['extent'][2]))
kin = [k for k in twins.get(sig, []) if k != key]
twin = ('<p class="twin">Same footprint and instance count as '
+ ', '.join(tracks[k]['name'] for k in kin) + '</p>') if kin else ''
short = ('<p class="twin warn">%d of %d instances decoded</p>'
% (t['parsed'], t['instances'])) if t['parsed'] != t['instances'] else ''
art = ('<span class="plan__img" role="img" aria-label="%s plan view" '
'style="--art:url(%s);--ar:%d/%d"></span>'
% (html.escape(t['name']), t['art'], t['aw'], t['ah'])) if t['art'] else ''
cards.append("""
<article class="trk" data-tags="{tags}" data-search="{search}">
<figure class="plan">{art}</figure>
<div class="trk__body">
<h3>{name}</h3>
<p class="trk__key">{key}</p>
<p class="trk__chips">{chips}</p>
<dl class="stat">
<div><dt>Instances</dt><dd>{inst}</dd></div>
<div><dt>Cameras</dt><dd>{cams}</dd></div>
<div><dt>Start boxes</dt><dd>{boxes}</dd></div>
<div><dt>Extent</dt><dd>{ex} &times; {ez}</dd></div>
<div><dt>Relief</dt><dd>{ey}</dd></div>
</dl>
{twin}{short}
</div>
</article>""".format(
tags=t['era'] + (' race' if t['race'] else '')
+ (' football' if t['football'] else ''),
search=html.escape((key + ' ' + t['name']).lower()),
art=art, name=html.escape(t['name']), key=html.escape(key), chips=chips(t),
inst=t['instances'], cams=t.get('cameras', '&mdash;'),
boxes=t.get('boxes', '&mdash;'),
ex='%,d'.replace(',', '') % round(t['extent'][0]),
ez='%d' % round(t['extent'][2]), ey='%d' % round(t['extent'][1]),
twin=twin, short=short))
arcade_n = sum(1 for t in tracks.values() if t['era'] == 'arcade')
r411_n = sum(1 for t in tracks.values() if t['era'] == 'r411')
CSS = open(os.path.join(os.path.dirname(__file__), 'tracks.css')).read()
JS = open(os.path.join(os.path.dirname(__file__), 'tracks.js')).read()
PAGE = open(os.path.join(os.path.dirname(__file__), 'tracks.tpl'), encoding='utf-8').read()
open(OUT, 'w', encoding='utf-8').write(PAGE.format(
css=CSS, js=JS, cards=''.join(cards), n=len(tracks), arcade=arcade_n,
r411=r411_n, later=len(tracks) - arcade_n - r411_n))
print('wrote %s (%d tracks)' % (OUT, len(tracks)), file=sys.stderr)