Files
RP412/tools/pages/build_tracks.py
T
Cyd 5135476088 Track plans read as outlines, not hatching
Nearly every track is one model repeated: cn3, a wall bar whose gauge
image is two closed 25x5 rectangles. Five metres of wall thickness is
finer than the plan can resolve, so each bar was landing as two parallel
lines plus two end caps - several hundred times over, which is the
hatching that swamped the arenas. Collapse a thin closed quad to the
centreline between its short edges: one stroke for one wall.

Walls stacked to build height coincide seen from above, so draw each
distinct wall once. And drop placements standing alone more than 200
units from any other - fourteen tracks park a single bar at (1200,0,0)
well off the course, and that one placement stretched the frame to
twelve times the width of the track. The four tracks without it are
exactly the four that always framed correctly. A real branch keeps its
neighbours and stays: Paingod's second canyon is sixty bars out at
x=-400.
2026-08-07 11:05:16 -05:00

216 lines
8.7 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):
"""The track's own outlines, straight down, as an alpha mask."""
pts = [p for seg in segs for p in seg]
xs = [p[0] for p in pts]
zs = [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[2] - min(zs)) * scale)
for p in seg]
if len(xy) > 1:
dr.line(xy, fill=255, width=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)
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))
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)