Drawing what the map screen draws never was going to give a map. Nearly every placement in every track is one piece, cn3, and its gauge image is two 25x5 bars at x 19.5..44.5 and -44.5..-19.5 - not a wall along the route but a wall across it with a 39 unit gate in the middle. The collision solid agrees exactly. A few hundred of those is a row of ticks. The gate is the point: cn3's origin sits in the opening, so every placement marks somewhere the race passes through. Walking the gates nearest to nearest, from the end furthest out, draws the track itself - Brewer's Bane comes out as its L with the junction chambers, Zaxxis as a circuit, and the small arena as the maze it always was. Guarded, because chaining nearest neighbours across a regular grid invents a maze-like path out of nothing but visit order. Each track is tested first on how many neighbours a gate has within 1.6x the typical spacing: a corridor gives 2, a floor of obstacles gives 4 or more. The separation is not close - seventeen tracks score 1 or 2, the demolition arena scores 8 on an exact 100 unit grid and keeps its wall blocks. Most of the arcade tracks really are near-straight canyon runs, a few hundred units wide and several thousand long. The plans say so now rather than implying otherwise.
227 lines
9.4 KiB
Python
227 lines
9.4 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
|
|
|
|
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 = {}
|
|
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')
|
|
|
|
# ------------------------------------------------- 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')}
|
|
|
|
|
|
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)
|
|
for seg in segs:
|
|
xy = [(10 + (p[0] - min(xs)) * 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))
|
|
if t['kind'] == 'route' and chains:
|
|
t['art'], t['aw'], t['ah'] = plan_view(chains, flat=True)
|
|
else:
|
|
t['art'], t['aw'], t['ah'] = plan_view(segs)
|
|
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">%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 ''
|
|
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} × {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', '—'),
|
|
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)
|