The track plans are the map screen's own drawing

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>
This commit is contained in:
Cyd
2026-08-07 10:42:03 -05:00
co-authored by Claude Opus 5
parent 6c3127a94d
commit 99030e4aac
4 changed files with 277 additions and 140 deletions
+85 -82
View File
File diff suppressed because one or more lines are too long
+33 -44
View File
@@ -9,6 +9,9 @@ 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)
@@ -75,49 +78,31 @@ def era(key):
if key in RP411: return ('r411', 'ADDED 4.11')
return ('later', 'COMMUNITY')
# ------------------------------------------------------- instance stream
def placements(addr, size, count):
"""Records are 76 bytes: a class id at +0, position at +48, unit
quaternion at +60. A few records are longer, so resync on a bad
header rather than trusting the stride."""
o, end, out, guard = addr + 4, addr + size, [], 0
while len(out) < count and o + 76 <= end and guard < 40000:
guard += 1
cls = struct.unpack_from('<I', RES, o)[0]
if cls not in (0, 1, 76, 80):
o += 4
continue
x, y, z = struct.unpack_from('<3f', RES, o + 48)
qn = sum(v * v for v in struct.unpack_from('<4f', RES, o + 60))
if all(abs(v) < 1e5 for v in (x, y, z)) and abs(qn - 1.0) < 0.05:
out.append((x, y, z))
o += 76
else:
o += 4
return out
# ------------------------------------------------- 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(points, box=360):
"""Top-down alpha mask: the placements, north up, scaled to fit."""
xs = [p[0] for p in points]
zs = [p[2] for p in points]
ys = [p[1] for p in points]
w = max(xs) - min(xs) or 1.0
h = max(zs) - min(zs) or 1.0
scale = (box - 16) / max(w, h)
iw = max(8, int(w * scale) + 16)
ih = max(8, int(h * scale) + 16)
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)
lo_y, hi_y = min(ys), max(ys)
span_y = (hi_y - lo_y) or 1.0
for x, y, z in points:
px = 8 + (x - min(xs)) * scale
# world +z runs away from the camera; screen y grows downward
py = ih - 8 - (z - min(zs)) * scale
# higher scenery reads brighter, so relief survives the flattening
v = 110 + int(145 * (y - lo_y) / span_y)
dr.ellipse([px - 2.5, py - 2.5, px + 2.5, py + 2.5], fill=v)
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)
@@ -139,16 +124,19 @@ for res in TABLE.values():
tracks[m.group(1)][field] = int(m.group(2))
for key, t in tracks.items():
pts = placements(t['addr'], t['size'], t['instances'])
t['parsed'] = len(pts)
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(pts)
t['art'], t['aw'], t['ah'] = plan_view(segs)
else:
t['extent'] = (0, 0, 0)
t['art'] = None
@@ -183,8 +171,9 @@ for key in ORDER:
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 warn">%d of %d instances decoded</p>'
% (t['parsed'], t['instances'])) if t['parsed'] != t['instances'] 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 ''
+142
View File
@@ -0,0 +1,142 @@
"""Recreate the map display's own overhead track drawing.
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.
GaugeImage stream (MUNGA_L4/L4GAUIMA.cpp):
int vertexCount
Point3D vertices[vertexCount]
int LODCount
Scalar LODScales[LODCount]
per LOD: int primitiveCount
per primitive: int type, int colour, int attributes,
int indexCount, int indices[indexCount]
"""
import re, struct
def resource_table(res, listing):
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, n = [], res.find(b'StaticAudioStream\x00') - 8, len(res)
while o + 0x38 <= n:
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 > n:
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):
nm, addr, size = walk[i]
table[rid] = {'name': nm, 'addr': addr, 'size': size, 'desc': desc}
i += 1
return table
def read_gauge_image(res, addr, size):
"""-> (vertices, [polyline of (x,y,z) ...]) using the finest LOD."""
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
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):
return None
scales = [f32() for _ in range(lods)]
lines = []
for lod in range(lods):
pcount = i32()
if not (0 < pcount < 100000):
return None
prims = []
for _ in range(pcount):
ptype = i32(); i32(); i32() # type, colour, attributes
n = 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
def instances(res, addr, size, count, gauge_ids):
"""Map instance records: 76 bytes, model gauge-image id at +44,
position at +48, unit quaternion at +60. The quaternion validates the
record; a record whose +44 is not a gauge image is simply not drawn,
exactly as DrawStatic skips it."""
out, o, end = [], addr + 4, addr + size
while len(out) < count and o + 76 <= end:
pos = struct.unpack_from('<3f', res, o + 48)
q = struct.unpack_from('<4f', res, o + 60)
if abs(sum(v * v for v in q) - 1.0) < 0.02 and all(abs(v) < 1e5 for v in pos):
gid = struct.unpack_from('<i', res, o + 44)[0]
out.append((gid if gid in gauge_ids else None, pos, q))
o += 76
else:
o += 4
return out
def rotate(q, p):
"""Quaternion (x,y,z,w) applied to a point."""
qx, qy, qz, qw = q
x, y, z = p
tx = 2.0 * (qy * z - qz * y)
ty = 2.0 * (qz * x - qx * z)
tz = 2.0 * (qx * y - qy * x)
return (x + qw * tx + qy * tz - qz * ty,
y + qw * ty + qz * tx - qx * tz,
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
for gid, pos, q in instances(res, map_addr, map_size, map_count, set(gauge)):
if gid is None:
skipped += 1
continue
r = gauge[gid]
img = read_gauge_image(res, r['addr'], r['size'])
if img is None:
skipped += 1
continue
verts, prims = img
drawn += 1
for idx in prims:
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 len(pts) > 1:
segs.append(pts)
return segs, drawn, skipped
+17 -14
View File
@@ -9,10 +9,10 @@
<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 &mdash; the console had
pictures of them and those pictures did not survive. So these plans are drawn
from the tracks themselves: <strong>every plan below is that map's own scenery,
placed where the game places it</strong>, viewed from overhead with height
shown as brightness.</p>
pictures of them and those pictures did not survive. But the game draws one every
race: the map screen in the pod renders the track from above. <strong>These plans
are that same drawing, reconstructed outside the game</strong> &mdash; the same
outlines, from the same placements, straight down.</p>
</div>
</header>
@@ -34,7 +34,7 @@
<main class="wrap">
<div class="key">
<span><i style="background:var(--ink-mid)"></i>Scenery placement &mdash; brighter is higher ground</span>
<span><i style="background:var(--ink-mid)"></i>What the map screen draws &mdash; walls, structures, pit</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>
@@ -42,15 +42,18 @@
<div class="grid">{cards}</div>
<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. Each plan
is the map's instance stream &mdash; the scenery the game places &mdash; read out of
<code>assets/RP411/RPL4.RES</code>. Records are 76 bytes carrying a position and a
unit quaternion, so a placement validates itself and a mis-read cannot pass as a
building. Extent and relief are the span of those placements in world units, not a
measured track length: a long thin plan is a point-to-point run, a squat one a
circuit or an arena.</p>
<p class="foot">What is <em>not</em> here: the driving surface. Only placed objects are
stored this way, so a plan shows what lines the route rather than the route itself.
<p class="foot">{n} tracks: {arcade} shipped in the 4.10 arcade cabinets, {r411} added
in 4.11, {later} built by the community afterwards. Each plan follows
<code>NavDisplay::DrawStatic</code>: for every placement in the map's instance
stream, look up that model's <code>GaugeImage</code> &mdash; a small set of outlines
in world units, the same ones the pod's map screen draws &mdash; and lay it down
rotated and positioned. A placement whose model has no gauge image is skipped here
exactly as the map screen skips it, which is why a card can say fewer placements
carry map art than the track contains.</p>
<p class="foot">So the walls are the canyon walls and the chambers are real chambers,
but there is still no driving surface: the map draws what lines the route, never the
tarmac. Extent and relief are the span of the drawn outlines in world units, so a
long thin plan is a point-to-point 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>