The first version of these plans was a scatter of scenery positions - an
impression of a track rather than a picture of one. The game already draws
the real thing: the map screen in the pod renders the track from above every
race, so the plans now reconstruct that instead of approximating it.
NavDisplay::DrawStatic walks the static entities, looks up each one's
L4GaugeImage by resource id, and draws it through localToWorld x
worldToView. navmap.py does the same offline. The pieces that made it
possible:
- a map instance carries its model's GaugeImage id at +44, beside the
position at +48 and the quaternion at +60;
- a GaugeImage is a vertex array plus per-LOD polylines through it, in
world units - cn3 is an 89x5 wall segment, pit1 a 500x300 pit;
- a placement whose model has no gauge image is skipped here exactly as
DrawStatic skips it, which is why a card can report fewer placements
carrying map art than the track contains.
The difference is not subtle. Wiseguy's Wake and Paingod's Passage resolve
into twin canyon walls running their length, Brewer's Bane into an L-shaped
route through junction chambers, and both arenas into a lattice of obstacles
inside a boundary wall. What is still missing is the driving surface: the
map draws what lines the route, never the tarmac.
tools/pages/navmap.py carries the reader, and the README documents both the
instance record and the gauge image stream. Regenerating from the committed
generators reproduces the committed page byte for byte.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
216 lines
8.7 KiB
Python
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=900):
|
|
"""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 carry map art; the rest the '
|
|
'map screen does not draw either</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)
|