Files
RP412/tools/pages/build_tracks.py
T
Cyd 5ad55aa1eb Nine tracks get the console's own map, and the projection is corrected
RPConfig.xml has always named a picture for nine of the eighteen tracks.
The pictures exist after all, so the page reads the mapping straight out
of the config and uses them: score zones, drop zone, the chambers drawn
properly and labelled. No reconstruction beats the real thing. The other
nine keep the course traced from their gates, and each card now says
which of the two it is showing.

The pictures also check the reconstruction. Brewer's Bane is the one
track shaped distinctively enough to be obviously wrong, and it matches
the console picture turn for turn - long leg up one side to Score Zone 1,
the corner, the run out to Score Zone 2, junction chambers spaced along
it. It matched MIRRORED. Seen from above with +Z up the page the engine's
+X runs to the left, and every plan here had been drawn the other way
round. Fixed, so the nine tracks without a picture are drawn the same way
round as the nine with one.
2026-08-07 12:08:11 -05:00

281 lines
12 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, ImageOps
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import navmap
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, CONSOLE_ART = {}, {}
xml = open(ROOT + '/tools/console-config/RPConfig.xml', encoding='utf-8-sig').read()
for m in re.finditer(r'<map\s+key="([^"]+)"\s+name="([^"]+)"'
r'(?:\s+image="([^"]*)")?', xml):
CONSOLE[m.group(1)] = m.group(2)
if m.group(3):
# The config names the picture; the picture itself now sits in
# assets/. Take the mapping from the config rather than matching
# filenames to track names by eye.
CONSOLE_ART[m.group(1)] = os.path.basename(m.group(3).replace('\\', '/'))
# 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')
# ------------------------------------------------- the map display itself
# NavDisplay::DrawStatic looks up each static entity's L4GaugeImage and
# draws it through localToWorld x worldToView, skipping entities that have
# none. navmap.py does the same offline, so these plans are the outlines
# the pod's own map screen draws - not an impression of them.
GAUGE_IDS = {rid for rid, r in TABLE.items() if r['desc'].endswith(': GaugeImage')}
ART_DIR = os.path.join(ROOT, 'assets', 'red planet maps')
def console_plan(filename, box=900):
"""The console's own picture of the track, as an alpha mask.
These are the pictures the setup console showed, and they are the real
maps - score zones, drop zone, the chambers drawn properly and labelled.
Nine of the eighteen tracks have one. Where there is one it beats any
reconstruction, so it wins. Greyscale line art on white, so inverting
luminance to alpha lets the page tint it like every other plan and keeps
it readable in both themes."""
path = os.path.join(ART_DIR, filename)
if not os.path.exists(path):
return None
im = ImageOps.invert(Image.open(path).convert('L'))
box_ = im.getbbox()
if box_:
im = im.crop(box_)
if max(im.size) > box:
k = box / max(im.size)
im = im.resize((max(1, int(im.width * k)), max(1, int(im.height * k))),
Image.LANCZOS)
out = Image.merge('RGBA', (Image.new('L', im.size, 255),) * 3 + (im,))
buf = io.BytesIO()
out.save(buf, 'PNG', optimize=True)
return ('data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode(),
im.width, im.height)
def plan_view(segs, box=520, flat=False):
"""The course, straight down, as an alpha mask. `flat` for the route
chains, which are (x, z) pairs rather than (x, y, z) world points."""
pts = [p for seg in segs for p in seg]
xs = [p[0] for p in pts]
zs = [p[1] if flat else p[2] for p in pts]
w = (max(xs) - min(xs)) or 1.0
h = (max(zs) - min(zs)) or 1.0
scale = (box - 20) / max(w, h)
iw = max(8, int(w * scale) + 20)
ih = max(8, int(h * scale) + 20)
img = Image.new('L', (iw, ih), 0)
dr = ImageDraw.Draw(img)
# X runs left, not right. Seen from above with +Z up the page, the
# engine's +X goes to the LEFT - drawn the other way every plan came out
# mirrored. The console's own picture of Brewer's Bane settles it: its
# long leg runs up the right side to Score Zone 1, turns at the corner
# and runs left to Score Zone 2, which is this projection and not its
# mirror. The nine tracks the console never had a picture of are drawn
# the same way round as the nine it did.
for seg in segs:
xy = [(10 + (max(xs) - p[0]) * scale,
ih - 10 - ((p[1] if flat else p[2]) - min(zs)) * scale)
for p in seg]
if len(xy) > 1:
dr.line(xy, fill=255, width=3 if flat else 2, joint='curve')
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():
segs, drawn, skipped = navmap.track_lines(
RES, TABLE, t['addr'], t['size'], t['instances'])
t['drawn'] = drawn
t['skipped'] = skipped
t['name'] = CONSOLE.get(key, key)
t['race'] = key in RACE
t['football'] = key in FOOTBALL
t['era'], t['eraLabel'] = era(key)
# The plan is the course the gates describe, not the gate markers - see
# navmap.route_lines. Where the gates are a floor of obstacles rather
# than a course there is no route to draw, so fall back to the wall
# blocks themselves rather than invent one.
chains, t['kind'] = navmap.route_lines(
RES, TABLE, t['addr'], t['size'], t['instances'])
pts = [p for seg in segs for p in seg]
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))
shown = console_plan(CONSOLE_ART[key]) if key in CONSOLE_ART else None
t['source'] = 'console'
if shown is None:
t['source'] = 'route' if (t['kind'] == 'route' and chains) else 'blocks'
shown = (plan_view(chains, flat=True) if t['source'] == 'route'
else plan_view(segs))
t['art'], t['aw'], t['ah'] = shown
else:
t['extent'] = (0, 0, 0)
t['art'] = None
t['source'] = '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">%d of %d placements are drawn; the rest carry no '
'map art or stand alone off the course</p>'
% (t['drawn'], t['instances'])) if t['skipped'] else ''
SOURCE = {
'console': 'The console&rsquo;s own map',
'route': 'Course traced from the gates',
'blocks': 'Wall blocks &mdash; no single course to trace',
}
art = ('<figcaption class="plan__src">%s</figcaption>' % SOURCE[t['source']]
) if t['art'] else ''
art = ('<span class="plan__img" role="img" aria-label="%s plan view" '
'style="--art:url(%s);--ar:%d/%d"></span>%s'
% (html.escape(t['name']), t['art'], t['aw'], t['ah'], art)) 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)