Files
RP412/tools/pages/build_tracks.py
T
Cyd 56b2af5208 The track plans are the course, not the wall markers
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.
2026-08-07 11:31:25 -05:00

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} &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)