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.
This commit is contained in:
Cyd
2026-08-07 11:31:25 -05:00
parent b12eaa8bb2
commit 56b2af5208
4 changed files with 179 additions and 83 deletions
+55 -46
View File
File diff suppressed because one or more lines are too long
+17 -6
View File
@@ -86,11 +86,12 @@ def era(key):
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."""
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[2] 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)
@@ -99,10 +100,11 @@ def plan_view(segs, box=520):
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)
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=2, joint='curve')
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)
@@ -132,11 +134,20 @@ for key, t in tracks.items():
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))
t['art'], t['aw'], t['ah'] = plan_view(segs)
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
+69 -2
View File
@@ -14,7 +14,7 @@ GaugeImage stream (MUNGA_L4/L4GAUIMA.cpp):
per primitive: int type, int colour, int attributes,
int indexCount, int indices[indexCount]
"""
import re, struct
import math, re, statistics, struct
def resource_table(res, listing):
@@ -100,6 +100,74 @@ def instances(res, addr, size, count, gauge_ids):
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."""
gauge = {rid for rid, r in table.items() if r['desc'].endswith(': GaugeImage')}
seen, out = set(), []
for gid, pos, q in instances(res, map_addr, map_size, map_count, gauge):
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
@@ -111,7 +179,6 @@ def spine(seg):
p = seg[:-1] if len(seg) >= 5 and seg[0] == seg[-1] else None
if not p or len(p) != 4:
return [seg]
import math
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:
+38 -29
View File
@@ -9,10 +9,11 @@
<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. 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>
pictures of them and those pictures did not survive. But the track itself knows
where it goes. Almost every one is built from a single piece repeated: a wall
thrown across the route with a gate in the middle. <strong>These plans are the
course those gates describe</strong> &mdash; every gate is a point the race passes
through, walked in the order the course visits them.</p>
</div>
</header>
@@ -34,7 +35,7 @@
<main class="wrap">
<div class="key">
<span><i style="background:var(--ink-mid)"></i>What the map screen draws &mdash; walls, structures, pit</span>
<span><i style="background:var(--ink-mid)"></i>The course, gate by gate</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>
@@ -43,30 +44,38 @@
<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 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">Two things are drawn plainer than the pod draws them, so the plan reads
as an outline. Nearly every track is one model repeated &mdash; <code>cn3</code>, a
wall bar whose gauge image is two closed 25&times;5 rectangles. Five metres of wall
thickness is finer than the plan can resolve, so each bar is collapsed to the
centreline between its short edges: one stroke for one wall, instead of two parallel
lines and two end caps several hundred times over. Walls stacked to build height land
on each other seen from above, and are drawn once. Placements standing alone more than
200 units from any other are dropped &mdash; fourteen tracks park a single bar at
(1200,&nbsp;0,&nbsp;0) far 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:
Paingod's second canyon is sixty bars out at x&nbsp;=&nbsp;-400.</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>
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">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>
</main>
<script>{js}</script>