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.
234 lines
9.9 KiB
Python
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’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’s picture" '
|
|
'style="--art:url(%s);--ar:%d/%d"></span>'
|
|
'<figcaption class="plan__src">The console’s picture '
|
|
'— 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} × {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', '—'),
|
|
boxes=t.get('boxes', '—'),
|
|
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)
|