Files
RP412/tools/pages/build_tracks.py
T
Cyd 0e39075a20 The track plans are the map screen's drawing again
With the models resolving properly there is nothing left to infer, so the
inference goes. Out: the gate tracing, the route/field test, the collapse
of each wall bar to a centreline, the dropping of "isolated" placements.
Every one of those existed to make sense of a track that appeared to be
one model repeated, and it is not.

What is left is what the map screen does. Every placement, its model's
GaugeImage looked up by name, laid down rotated and positioned, at the
LOD the engine would pick for that scale, in the palette the display is
configured with, on the display's own black. So the walls are grey
because index 51 is grey and the score zones are amber because sc50 and
sc500a are drawn in 56 - nothing on the page is a styling choice.

Also right way round now: +X runs right and +Z up, matching the engine and
the map viewer. The nine console pictures are still here, below each
drawing where they exist, captioned as mirrored - they are illustrations
rather than screenshots, and the page no longer quietly adopts their
handedness for everything else.
2026-08-07 15:07:36 -05:00

234 lines
9.9 KiB
Python

"""Render the track reference page from RPL4.RES.
Each plan is what the pod's map screen draws: every placement in the
track's instance stream, its model's GaugeImage looked up by name, laid
down rotated and positioned, at the LOD the engine would pick for that
scale, in the palette the display is configured with. See navmap.py.
Usage: build_tracks.py <rpl4tool-listing.txt> <out.html>
"""
import base64, html, io, os, re, 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()
LISTING, OUT = sys.argv[1], sys.argv[2]
TABLE = navmap.resource_table(RES, LISTING)
# The nav display's own palette and default colour:
# nav(A,ModeAlwaysActive,(448,416),0x00,0x3C,...) on the pod's secondary
# port, which L4GAUGE.CFG configures with secpal.pcc.
PALETTE = navmap.palette(ROOT + '/assets/RP411/GAUGE/secpal.pcc')
STATIC_COLOUR = 0x3C
BACKGROUND = 0x00
if PALETTE is None:
sys.exit('secpal.pcc has no VGA palette where PCX keeps one')
# ------------------------------------------------------------ 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):
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')
# ----------------------------------------------------------------- plans
ART_DIR = os.path.join(ROOT, 'assets', 'red planet maps')
def plan(addr, size, count, box=460):
"""The map screen's drawing of this track, whole.
Scaled to fit the way GPS::UpdateStaticEntities scales it: take the
static bounds, divide by the display, and allow the same 1.2x it does
"for large objects at edges of map". Seen from above the engine's +X
runs right and +Z up - L4GaugeImagePrimitive::Draw plots (x, z) and the
graphics view's origin is bottom left."""
ext = navmap.bounds(RES, TABLE, addr, size, count)
if ext is None:
return None
x0, x1, z0, z1 = ext
mpp = max((x1 - x0) / box, (z1 - z0) / box, 1e-4) * 1.2
segs, drawn, skipped = navmap.track_lines(RES, TABLE, addr, size, count, mpp)
if not segs:
return None
pts = [p for _, seg in segs for p in seg]
xs = [p[0] for p in pts]
zs = [p[1] for p in pts]
pad = 8
iw = max(16, int((max(xs) - min(xs)) / mpp) + pad * 2)
ih = max(16, int((max(zs) - min(zs)) / mpp) + pad * 2)
img = Image.new('RGB', (iw, ih), PALETTE[BACKGROUND])
dr = ImageDraw.Draw(img)
for colour, seg in segs:
xy = [(pad + (p[0] - min(xs)) / mpp,
ih - pad - (p[1] - min(zs)) / mpp) for p in seg]
dr.line(xy, fill=PALETTE[colour or STATIC_COLOUR], width=1)
buf = io.BytesIO()
img.convert('P', palette=Image.ADAPTIVE, colors=32).save(
buf, 'PNG', optimize=True)
return ('data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode(),
iw, ih, drawn, skipped, mpp, (x1 - x0, z1 - z0))
def console_plan(filename, box=760):
"""The picture the setup console showed, as an alpha mask so the page
can tint it. Greyscale line art on white, hence the inversion."""
path = os.path.join(ART_DIR, filename)
if not os.path.exists(path):
return None
im = ImageOps.invert(Image.open(path).convert('L'))
crop = im.getbbox()
if crop:
im = im.crop(crop)
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)
# ---------------------------------------------------------------- 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():
t['name'] = CONSOLE.get(key, key)
t['race'] = key in RACE
t['football'] = key in FOOTBALL
t['era'], t['eraLabel'] = era(key)
drawing = plan(t['addr'], t['size'], t['instances'])
if drawing:
(t['art'], t['aw'], t['ah'], t['drawn'], t['skipped'],
t['mpp'], t['extent']) = drawing
else:
t['art'], t['drawn'], t['skipped'] = None, 0, t['instances']
t['extent'] = (0, 0)
t['console'] = console_plan(CONSOLE_ART[key]) if key in CONSOLE_ART else None
print(' %-14s %3d drawn, %3d skipped' % (key, t['drawn'], t['skipped']),
file=sys.stderr)
# 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'][1]))
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'][1]))
# ------------------------------------------------------------------ page
def chips(t):
out = [(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'][1]))
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 are '
'models with no map art, which the map screen skips too</p>'
% (t['drawn'], t['instances'])) if t['skipped'] else ''
art = ('<figure class="plan"><img class="plan__img" src="%s" width="%d" '
'height="%d" alt="%s as the map screen draws it" loading="lazy">'
'<figcaption class="plan__src">The map screen&rsquo;s own drawing</figcaption>'
'</figure>' % (t['art'], t['aw'], t['ah'],
html.escape(t['name']))) if t['art'] else ''
if t['console']:
uri, cw, ch = t['console']
art += ('<figure class="plan plan--console"><span class="plan__mask" '
'role="img" aria-label="%s, the console&rsquo;s picture" '
'style="--art:url(%s);--ar:%d/%d"></span>'
'<figcaption class="plan__src">The console&rsquo;s picture '
'&mdash; mirrored against the game</figcaption></figure>'
% (html.escape(t['name']), uri, cw, ch))
cards.append("""
<article class="trk" data-tags="{tags}" data-search="{search}">
{art}
<div class="trk__body">
<h3>{name}</h3>
<p class="trk__key">{key}</p>
<p class="trk__chips">{chips}</p>
<dl class="stat">
<div><dt>Placements</dt><dd>{inst}</dd></div>
<div><dt>Drawn</dt><dd>{drawn}</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>
</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'], drawn=t['drawn'], cams=t.get('cameras', '&mdash;'),
boxes=t.get('boxes', '&mdash;'),
ex='%d' % round(t['extent'][0]), ez='%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')
here = os.path.dirname(__file__)
read = lambda n: open(os.path.join(here, n), encoding='utf-8').read()
open(OUT, 'w', encoding='utf-8').write(read('tracks.tpl').format(
css=read('tracks.css'), js=read('tracks.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)