The track plans are the map screen's drawing again

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.
This commit is contained in:
Cyd
2026-08-07 15:07:36 -05:00
parent 9f0a77cc16
commit 0e39075a20
6 changed files with 374 additions and 522 deletions
+147 -153
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+94 -141
View File
@@ -1,12 +1,13 @@
"""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.
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, struct, sys
import base64, html, io, os, re, sys
from PIL import Image, ImageDraw, ImageOps
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -14,37 +15,17 @@ 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]
TABLE = navmap.resource_table(RES, LISTING)
# ------------------------------------------------------- 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 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 = {}, {}
@@ -53,9 +34,6 @@ 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):
# The config names the picture; the picture itself now sits in
# assets/. Take the mapping from the config rather than matching
# filenames to track names by eye.
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
@@ -84,33 +62,56 @@ def era(key):
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')}
# ----------------------------------------------------------------- plans
ART_DIR = os.path.join(ROOT, 'assets', 'red planet maps')
def console_plan(filename, box=900):
"""The console's own picture of the track, as an alpha mask.
def plan(addr, size, count, box=460):
"""The map screen's drawing of this track, whole.
These are the pictures the setup console showed, and they are the real
maps - score zones, drop zone, the chambers drawn properly and labelled.
Nine of the eighteen tracks have one. Where there is one it beats any
reconstruction, so it wins. Greyscale line art on white, so inverting
luminance to alpha lets the page tint it like every other plan and keeps
it readable in both themes."""
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'))
box_ = im.getbbox()
if box_:
im = im.crop(box_)
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))),
@@ -122,39 +123,6 @@ def console_plan(filename, box=900):
im.width, im.height)
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)
# X runs left, not right. Seen from above with +Z up the page, the
# engine's +X goes to the LEFT - drawn the other way every plan came out
# mirrored. The console's own picture of Brewer's Bane settles it: its
# long leg runs up the right side to Score Zone 1, turns at the corner
# and runs left to Score Zone 2, which is this projection and not its
# mirror. The nine tracks the console never had a picture of are drawn
# the same way round as the nine it did.
for seg in segs:
xy = [(10 + (max(xs) - p[0]) * 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():
@@ -169,52 +137,35 @@ for res in TABLE.values():
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))
shown = console_plan(CONSOLE_ART[key]) if key in CONSOLE_ART else None
t['source'] = 'console'
if shown is None:
t['source'] = 'route' if (t['kind'] == 'route' and chains) else 'blocks'
shown = (plan_view(chains, flat=True) if t['source'] == 'route'
else plan_view(segs))
t['art'], t['aw'], t['ah'] = shown
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['extent'] = (0, 0, 0)
t['art'] = None
t['source'] = 'none'
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'][2]))
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'][2]))
print('tracks: %d' % len(tracks), file=sys.stderr)
-tracks[k]['extent'][1]))
# ------------------------------------------------------------------ page
def chips(t):
out = []
out.append((t['era'], t['eraLabel']))
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']:
@@ -225,36 +176,39 @@ def chips(t):
cards = []
for key in ORDER:
t = tracks[key]
sig = (t['instances'], round(t['extent'][0]), round(t['extent'][2]))
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 carry no '
'map art or stand alone off the course</p>'
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 ''
SOURCE = {
'console': 'The console&rsquo;s own map',
'route': 'Course traced from the gates',
'blocks': 'Wall blocks &mdash; no single course to trace',
}
art = ('<figcaption class="plan__src">%s</figcaption>' % SOURCE[t['source']]
) if t['art'] else ''
art = ('<span class="plan__img" role="img" aria-label="%s plan view" '
'style="--art:url(%s);--ar:%d/%d"></span>%s'
% (html.escape(t['name']), t['art'], t['aw'], t['ah'], art)) if t['art'] 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&rsquo;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&rsquo;s picture" '
'style="--art:url(%s);--ar:%d/%d"></span>'
'<figcaption class="plan__src">The console&rsquo;s picture '
'&mdash; mirrored against the game</figcaption></figure>'
% (html.escape(t['name']), uri, cw, ch))
cards.append("""
<article class="trk" data-tags="{tags}" data-search="{search}">
<figure class="plan">{art}</figure>
{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>Instances</dt><dd>{inst}</dd></div>
<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} &times; {ez}</dd></div>
<div><dt>Relief</dt><dd>{ey}</dd></div>
</dl>
{twin}{short}
</div>
@@ -263,18 +217,17 @@ for key in ORDER:
+ (' 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;'),
inst=t['instances'], drawn=t['drawn'], 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]),
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')
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))
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)
+87 -176
View File
@@ -3,7 +3,10 @@
NavDisplay::DrawStatic (RP_L4/RPL4GAUG.cpp) walks the static entities, looks
up each one's L4GaugeImage by resource id, and draws it through
localToWorld x worldToView. An entity with no gauge image is skipped. This
does the same thing offline: same outlines, same placements, straight down.
does the same thing offline: same models, same placements, straight down.
A map record is an Entity::MakeMessage (MUNGA/ENTITY3.h), read by
InterestManager::LoadMapStream (MUNGA/INTEREST.cpp).
GaugeImage stream (MUNGA_L4/L4GAUIMA.cpp):
int vertexCount
@@ -14,7 +17,7 @@ GaugeImage stream (MUNGA_L4/L4GAUIMA.cpp):
per primitive: int type, int colour, int attributes,
int indexCount, int indices[indexCount]
"""
import math, re, statistics, struct
import re, struct
def resource_table(res, listing):
@@ -42,12 +45,14 @@ def resource_table(res, listing):
return table
def read_gauge_image(res, addr, size):
"""-> (vertices, [polyline of (x,y,z) ...]) using the finest LOD."""
def gauge_image(res, addr):
"""The whole stream, every LOD. -> {'v': verts, 'l': [{'s', 'p'}]}"""
o = addr
def i32():
nonlocal o
v = struct.unpack_from('<i', res, o)[0]; o += 4; return v
def f32():
nonlocal o
v = struct.unpack_from('<f', res, o)[0]; o += 4; return v
@@ -55,31 +60,25 @@ def read_gauge_image(res, addr, size):
vcount = i32()
if not (0 < vcount < 100000):
return None
verts = []
for _ in range(vcount):
verts.append((f32(), f32(), f32()))
lods = i32()
if not (0 < lods < 64):
verts = [(f32(), f32(), f32()) for _ in range(vcount)]
lodcount = i32()
if not (0 < lodcount < 64):
return None
scales = [f32() for _ in range(lods)]
lines = []
for lod in range(lods):
scales = [f32() for _ in range(lodcount)]
lods = []
for lod in range(lodcount):
pcount = i32()
if not (0 < pcount < 100000):
return None
prims = []
for _ in range(pcount):
ptype = i32(); i32(); i32() # type, colour, attributes
n = i32()
i32() # type: only primitiveVector
colour, attrs, n = i32(), i32(), i32()
if not (0 < n <= vcount * 4):
return None
idx = [i32() for _ in range(n)]
prims.append(idx)
if lod == 0: # finest detail
lines = prims
if o > addr + size:
return None
return verts, lines
prims.append((colour, attrs, [i32() for _ in range(n)]))
lods.append({'s': scales[lod], 'p': prims})
return {'v': verts, 'l': lods}
def gauge_lookup(table):
@@ -89,8 +88,8 @@ def gauge_lookup(table):
filed under the model's own name - "cn3: Model List of 3 elements" and
"cn3: GaugeImage" - so the name is the join. DrawStatic looks the image
up by the entity's resource id and skips the entity when there is none,
which is why plenty of models resolve to nothing here: oao, snAwork and
pz1 have Model Lists and no gauge image, and the map does not draw them
which is why plenty of models resolve to nothing: oao, snAwork and pz1
have Model Lists and no gauge image, and the map does not draw them
either."""
by_name = {}
for rid, r in table.items():
@@ -111,26 +110,19 @@ def instances(res, addr, size, count, lookup):
Most records are 76 bytes - a scenery placement - but every track also
carries eight of 140, two of 80 and one of 336 (560 on Paingod's).
Striding a fixed 76 lands mid-record on those, and hunting forward for
the next plausible quaternion then locks onto arbitrary bytes: that is
where the impossible class ids and the "resource id" of 1065353216
came from, which is 0x3F800000, float 1.0. Reading the length instead
makes all eighteen tracks parse to exactly their declared count with
no bytes left over.
the next plausible quaternion then locks onto arbitrary bytes. Reading
the length makes all eighteen tracks parse to exactly their declared
count with no bytes left over.
The record is an Entity::MakeMessage (MUNGA/ENTITY3.h): classToCreate,
owningPlayerID, resourceID, instanceFlags, then localOrigin. With the
origin ending the 76-byte case that puts classToCreate at +28,
resourceID at +40 and instanceFlags at +44.
The record is an Entity::MakeMessage: classToCreate, owningPlayerID,
resourceID, instanceFlags, then localOrigin. The origin ends the
76-byte case, so classToCreate is at +28, resourceID at +40 and
instanceFlags at +44.
Reading +44 as the resource id was wrong and quietly convincing:
instanceFlags is 524 on every scenery record, and 524 happens to be
cn3's GaugeImage - so every track came out built from one model
repeated. It is not. The id at +40 varies per placement, and the
tracks use cn1, cn4, cn5, cn7, br1, ft1, cq2, the score zones and
plenty more.
An entity whose model has no gauge image is not drawn, exactly as
DrawStatic skips it."""
Read +44 and everything still looks plausible, which is the trap:
instanceFlags is 524 on every scenery record and 524 happens to be
cn3's GaugeImage, so every track comes out built from that one model
repeated. It is not. -> [(gauge id or None, position, quaternion)]"""
out, o, end = [], addr + 4, addr + size
while len(out) < count and o + 8 <= end:
length = struct.unpack_from('<i', res, o)[0]
@@ -144,96 +136,6 @@ def instances(res, addr, size, count, lookup):
return out
def gate_positions(res, table, map_addr, map_size, map_count, snap=20.0):
"""Where the course goes, one point per gate.
cn3 is not a wall along the route - it is a wall ACROSS it, spanning
x -44.5..44.5 with a 39 unit opening in the middle, and the collision
solid agrees exactly. The piece's own origin sits in that opening, so
every placement marks a point the course passes through. Walls stacked
for height repeat the same opening, hence the snap."""
lookup = gauge_lookup(table)
seen, out = set(), []
for gid, pos, q in instances(res, map_addr, map_size, map_count, lookup):
if gid is None:
continue
key = (round(pos[0] / snap), round(pos[2] / snap))
if key not in seen:
seen.add(key)
out.append((pos[0], pos[2]))
return [a for a in out
if any(b is not a and math.dist(a, b) < 200 for b in out)]
def route_lines(res, table, map_addr, map_size, map_count):
"""-> (chains, kind). Walk the gates in the order the course visits them.
kind is 'route' when the gates really do form a course and 'field' when
they do not. The test is how many neighbours a gate has within 1.6x the
typical spacing: a corridor gives each gate the one ahead and the one
behind, a floor of obstacles gives it four or more. The two cases are
nowhere near each other - seventeen tracks score 1 or 2, and the
demolition arena scores 8 on an exact 100 unit grid. That matters,
because chaining nearest neighbours across a grid invents a maze-like
path out of nothing but the order they happened to be visited in, and
a drawing has no business inventing a track layout."""
P = gate_positions(res, table, map_addr, map_size, map_count)
if len(P) < 3:
return [], 'field'
spacing = statistics.median(
min(math.dist(a, b) for b in P if b is not a) for a in P)
if spacing <= 0:
return [], 'field'
radius = spacing * 1.6
degree = statistics.median(
sum(1 for b in P if b is not a and math.dist(a, b) <= radius) for a in P)
if degree > 2.5:
return [], 'field'
# Nearest neighbour from the end furthest out, restarting when the next
# gate is too far to be the next gate. Restarting rather than forcing one
# line is what keeps a branch or a separate loop honest.
maxlink = max(260.0, spacing * 4)
left, chains = set(P), []
while left:
cx = sum(p[0] for p in left) / len(left)
cz = sum(p[1] for p in left) / len(left)
cur = max(left, key=lambda p: math.dist(p, (cx, cz)))
left.discard(cur)
path = [cur]
while left:
nxt = min(left, key=lambda p: math.dist(path[-1], p))
if math.dist(path[-1], nxt) > maxlink:
break
path.append(nxt)
left.discard(nxt)
if len(path) > 1:
chains.append(path)
return chains, 'route'
def spine(seg):
"""A track is built almost entirely from one model, cn3: a wall bar
drawn as two closed 25x5 rectangles. Five metres of wall thickness is
below the map's own resolution, so drawing the rectangle puts two
parallel lines and two end caps where the wall is one line - and a few
hundred bars of that is the hatching that swamps the plan. Collapse a
thin closed quad to the centreline joining its two short edges: the
same wall, drawn as the single stroke it reads as."""
p = seg[:-1] if len(seg) >= 5 and seg[0] == seg[-1] else None
if not p or len(p) != 4:
return [seg]
edge = [math.hypot(p[(i + 1) % 4][0] - p[i][0],
p[(i + 1) % 4][2] - p[i][2]) for i in range(4)]
if max(edge) == 0 or min(edge) / max(edge) > 0.5:
return [seg] # not a bar - leave it alone
lo = min(range(4), key=lambda i: edge[i])
a, b = p[lo], p[(lo + 1) % 4]
c, d = p[(lo + 2) % 4], p[(lo + 3) % 4]
mid = lambda u, v: tuple((u[k] + v[k]) / 2 for k in range(3))
return [[mid(a, b), mid(c, d)]]
def rotate(q, p):
"""Quaternion (x,y,z,w) applied to a point."""
qx, qy, qz, qw = q
@@ -246,60 +148,69 @@ def rotate(q, p):
z + qw * tz + qx * ty - qy * tx)
def track_lines(res, table, map_addr, map_size, map_count):
"""Every drawn outline in the track, in world space, flattened to XZ."""
gauge = {}
for rid, r in table.items():
if r['desc'].endswith(': GaugeImage'):
gauge[rid] = r
segs = []
drawn = skipped = 0
placed = instances(res, map_addr, map_size, map_count,
gauge_lookup(table))
def track_lines(res, table, map_addr, map_size, map_count, meters_per_pixel):
"""-> ([(colour index, [(x, z), ...]), ...], drawn, skipped).
# Fourteen of the eighteen tracks carry a single bar parked at exactly
# (1200, 0, 0), well off the course; the four that don't are the four
# that always framed correctly. One stray placement drags the bounding
# box out to twelve times the width of the course and squeezes the
# track into a sliver, so drop placements that stand alone. A real
# branch - Paingod's second canyon is sixty bars out at x=-400 - has
# neighbours and stays.
def isolated(i):
x, _, z = placed[i][1]
for j, (g, p, _) in enumerate(placed):
if j != i and g is not None and (p[0] - x) ** 2 + (p[2] - z) ** 2 < 200 ** 2:
return False
return True
for i, (gid, pos, q) in enumerate(placed):
Placed, rotated, flattened to XZ, at the LOD the engine would pick for
this scale. L4GaugeImage::Draw takes the first LOD whose scale is at
least the LOD value and draws nothing once it runs past the largest, so
an object can drop out entirely rather than simplify."""
lookup = gauge_lookup(table)
cache = {}
out, drawn, skipped = [], 0, 0
for gid, pos, q in instances(res, map_addr, map_size, map_count, lookup):
if gid is None:
skipped += 1
continue
if isolated(i):
skipped += 1
continue
r = gauge[gid]
img = read_gauge_image(res, r['addr'], r['size'])
if gid not in cache:
cache[gid] = gauge_image(res, table[gid]['addr'])
img = cache[gid]
if img is None:
skipped += 1
continue
verts, prims = img
lod = 0
while lod < len(img['l']) and meters_per_pixel > img['l'][lod]['s']:
lod += 1
if lod >= len(img['l']):
skipped += 1
continue
drawn += 1
for idx in prims:
for colour, attrs, idx in img['l'][lod]['p']:
pts = []
for k in idx:
if 0 <= k < len(verts):
wx, wy, wz = rotate(q, verts[k])
pts.append((wx + pos[0], wy + pos[1], wz + pos[2]))
if not (0 <= k < len(img['v'])):
continue
v = img['v'][k]
if attrs & 1: # attributeUnscaled: constant on screen
v = (v[0] * meters_per_pixel, v[1] * meters_per_pixel,
v[2] * meters_per_pixel)
wx, wy, wz = rotate(q, v)
pts.append((wx + pos[0], wz + pos[2]))
if len(pts) > 1:
segs.extend(spine(pts))
# Walls are stacked to build height. Seen from above those copies land
# on each other exactly, so draw each distinct wall once.
seen, out = set(), []
for s in segs:
key = tuple(round(v, 1) for p in s for v in (p[0], p[2]))
if key not in seen:
seen.add(key)
out.append(s)
out.append((colour, pts))
return out, drawn, skipped
def bounds(res, table, map_addr, map_size, map_count):
"""Placement extent, which is what the engine scales the map to fit -
GPS::UpdateStaticEntities asks the renderer for the static bounds."""
lookup = gauge_lookup(table)
xs, zs = [], []
for gid, pos, q in instances(res, map_addr, map_size, map_count, lookup):
if gid is not None:
xs.append(pos[0])
zs.append(pos[2])
if not xs:
return None
return min(xs), max(xs), min(zs), max(zs)
def palette(path):
"""PCC is PCX: the last 769 bytes are 0x0C then 256 RGB triples. The
map's colours are indices into whichever palette the port was
configured with - secpal.pcc for the pod's secondary screen."""
raw = open(path, 'rb').read()
if len(raw) < 769 or raw[-769] != 0x0C:
return None
base = len(raw) - 768
return [tuple(raw[base + i * 3:base + i * 3 + 3]) for i in range(256)]
+14 -6
View File
@@ -106,17 +106,25 @@ h1 em { font-style: normal; color: var(--sys); }
.plan {
margin: 0 0 1rem; display: flex; flex-direction: column;
align-items: center; gap: .5rem;
align-items: center; gap: .45rem;
}
.plan--console { margin-top: -.4rem; opacity: .85; }
.plan__src {
font: 500 var(--step--1)/1 var(--mono); letter-spacing: .1em;
font: 500 var(--step--1)/1.3 var(--mono); letter-spacing: .08em;
text-transform: uppercase; color: var(--ink-low); text-align: center;
}
/* The map screen's own drawing: a real image in the game's palette on the
display's own black, so it reads the same in either theme. */
.plan__img {
/* The mask is drawn with contain, so clamping the box letterboxes the
art rather than stretching it - a portrait console map stays legible
beside a squat arena. */
display: block; width: 100%; max-width: 15rem; max-height: 22rem;
display: block; width: 100%; max-width: 15rem; max-height: 24rem;
height: auto; object-fit: contain; background: #000;
border: 1px solid var(--rule); image-rendering: pixelated;
}
/* The console's picture is line art, so it is tinted like type instead. */
.plan__mask {
display: block; width: 100%; max-width: 11rem; max-height: 18rem;
aspect-ratio: var(--ar);
background-color: var(--ink-mid);
-webkit-mask-image: var(--art); mask-image: var(--art);
+32 -46
View File
@@ -8,12 +8,11 @@
<div class="wrap">
<p class="eyebrow">Red Planet 4.12 &middot; plans drawn from RPL4.RES</p>
<h1>Every track,<br><em>seen from above</em></h1>
<p class="lede">There are no track maps in the game's files. Nine of these are the
next best thing: <strong>the setup console's own pictures</strong>, score zones and
drop zone labelled, recovered and shown here as they were drawn. The other nine
never had one &mdash; so their plan comes from the track itself. Almost every track
is a single piece repeated, a wall thrown across the route with a gate in the
middle, and <strong>every gate is a point the race passes through</strong>.</p>
<p class="lede">There are no track maps in the game's files, but the game draws
one every race: the map screen renders the track from above. <strong>These plans
are that same drawing, reconstructed outside the game</strong> &mdash; the same
models, the same placements, the same palette, straight down. Nine tracks also
have the picture the setup console showed, and those are here too.</p>
</div>
</header>
@@ -35,7 +34,9 @@
<main class="wrap">
<div class="key">
<span><i style="background:var(--ink-mid)"></i>The course, gate by gate</span>
<span><i style="background:#4b4b4b"></i>Walls</span>
<span><i style="background:#bf9300"></i>Score zones</span>
<span><i style="background:#870700"></i>Drop zone and markers</span>
<span><i style="background:var(--sys)"></i>Offered for the death race</span>
<span><i style="background:var(--pilot)"></i>Offered for football</span>
</div>
@@ -44,46 +45,31 @@
<p class="empty-state" id="nomatch" hidden>No track matches that.</p>
<p class="foot">{n} tracks: {arcade} shipped in the 4.10 arcade cabinets, {r411} added
in 4.11, {later} built by the community afterwards. Drawing what the pod's map screen
draws turns out not to give a map. That screen follows
<code>NavDisplay::DrawStatic</code>, laying down each placement's
<code>GaugeImage</code>, and nearly every placement in every track is the same
piece: <code>cn3</code>, whose image is two 25&times;5 bars at x&nbsp;19.5&ndash;44.5
and -44.5&ndash;-19.5. That is not a wall running along the route, it is a wall thrown
across it with a 39 unit gate in the middle, and the piece's collision solid says the
same. A few hundred of those is a row of ticks, not a course.</p>
<p class="foot">But the gate is the point: <code>cn3</code>'s own origin sits in the
opening, so every placement marks somewhere the race passes through. Walk the gates in
the order the course visits them &mdash; nearest to nearest, starting from the end
furthest out, beginning a new line rather than reaching when the next gate is too far
&mdash; and the track draws itself. Walls stacked for height repeat the same opening
and count once. Placements standing alone more than 200 units from any other are
dropped: fourteen tracks park a single piece at (1200,&nbsp;0,&nbsp;0) well off the
course, and one stray placement stretches the frame to twelve times the width of the
track. A real branch keeps its neighbours and stays &mdash; Paingod's second canyon is
sixty pieces out at x&nbsp;=&nbsp;-400.</p>
<p class="foot">One track is drawn the other way. Chaining nearest neighbours across a
regular grid invents a maze-like path out of nothing but the order the points happened
to be visited in, and a plan has no business inventing a track layout. So each track is
tested first: count how many neighbours a gate has within 1.6&times; the typical
spacing. A corridor gives each gate the one ahead and the one behind; a floor of
obstacles gives it four or more. Seventeen tracks score 1 or 2 and are drawn as a
course. The large arena scores 8 on an exact 100 unit grid, so it keeps its wall blocks
instead &mdash; and the small arena, which scores 2, really is the maze it looks like.</p>
<p class="foot">The console's nine pictures also check the other nine. Brewer's Bane is
the one track with a shape distinctive enough to be wrong in an obvious way, and the
reconstruction matches the picture turn for turn &mdash; long leg up one side to Score
Zone 1, the corner, the run out to Score Zone 2, junction chambers spaced along it. It
matched mirrored, which is how the projection came to be corrected: seen from above
with +Z up the page the engine's +X runs to the <em>left</em>, and every plan here had
been drawn the other way round. The tracks with no picture are now drawn the same way
round as the tracks with one.</p>
in 4.11, {later} built by the community afterwards. Each plan follows
<code>NavDisplay::DrawStatic</code>: for every placement in the track's instance
stream, look up that model's <code>GaugeImage</code> and lay it down rotated and
positioned, at the LOD the engine would choose for this scale. Colours are palette
indices resolved through <code>secpal.pcc</code>, the palette the pod's secondary
port is configured with, on the display's own black &mdash; so the walls really are
grey, the score zones really are amber, and nothing here is a styling choice.</p>
<p class="foot">A record is an <code>Entity::MakeMessage</code>
(<code>MUNGA/ENTITY3.h</code>), read by <code>InterestManager::LoadMapStream</code>,
and it carries its own length &mdash; most placements are 76 bytes but every track
has eight of 140, two of 80 and one of 336. It names the model's <em>Model List</em>,
whose <code>GaugeImage</code> is filed under the same model name, so the name is the
join. Models with no gauge image &mdash; <code>oao</code>, <code>snAwork</code>,
<code>pz1</code> &mdash; are skipped here exactly as the map screen skips them, which
is why a card can draw fewer placements than the track contains.</p>
<p class="foot">Seen from above the engine's +X runs right and +Z up:
<code>L4GaugeImagePrimitive::Draw</code> plots <code>(x, z)</code> and the graphics
view's origin is bottom left. The console's own pictures are <em>mirrored</em>
against that &mdash; they are illustrations, not screenshots &mdash; so where a card
shows both, the two are handed differently and each is right for what it is.</p>
<p class="foot">There is still no driving surface anywhere in the file: the track is
walls and gates, never tarmac. Extent and relief are the span of the placed geometry in
world units, so a long thin plan is a point-to-point canyon run &mdash; most of the
arcade tracks are exactly that, a few hundred units wide and several thousand long
&mdash; and a squat one is a circuit or an arena. Tracks marked NOT IN THE MENU exist
in the resource file but the setup screen does not offer them.</p>
walls, zones and markers, never tarmac. Extent is the span of the placements in world
units, so a long thin plan is a point-to-point canyon run and a squat one a circuit
or an arena. Tracks marked NOT IN THE MENU exist in the resource file but the setup
screen does not offer them.</p>
</main>
<script>{js}</script>