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>
143 lines
4.8 KiB
Python
143 lines
4.8 KiB
Python
"""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
|