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:
+147
-153
File diff suppressed because one or more lines are too long
Binary file not shown.
+94
-141
@@ -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’s own map',
|
||||
'route': 'Course traced from the gates',
|
||||
'blocks': 'Wall blocks — 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’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’s picture" '
|
||||
'style="--art:url(%s);--ar:%d/%d"></span>'
|
||||
'<figcaption class="plan__src">The console’s picture '
|
||||
'— 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} × {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', '—'),
|
||||
inst=t['instances'], drawn=t['drawn'], 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]),
|
||||
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
@@ -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
@@ -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
@@ -8,12 +8,11 @@
|
||||
<div class="wrap">
|
||||
<p class="eyebrow">Red Planet 4.12 · 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 — 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> — 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×5 bars at x 19.5–44.5
|
||||
and -44.5–-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 — nearest to nearest, starting from the end
|
||||
furthest out, beginning a new line rather than reaching when the next gate is too far
|
||||
— 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, 0, 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 — Paingod's second canyon is
|
||||
sixty pieces out at x = -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× 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 — 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 — 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 — 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 — 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 — <code>oao</code>, <code>snAwork</code>,
|
||||
<code>pz1</code> — 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 — they are illustrations, not screenshots — 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 — most of the
|
||||
arcade tracks are exactly that, a few hundred units wide and several thousand long
|
||||
— 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>
|
||||
|
||||
Reference in New Issue
Block a user