Files
RP412/tools/pages/navmap.py
T
Cyd 0e39075a20 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.
2026-08-07 15:07:36 -05:00

217 lines
8.0 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 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
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 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
vcount = i32()
if not (0 < vcount < 100000):
return None
verts = [(f32(), f32(), f32()) for _ in range(vcount)]
lodcount = i32()
if not (0 < lodcount < 64):
return None
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):
i32() # type: only primitiveVector
colour, attrs, n = i32(), i32(), i32()
if not (0 < n <= vcount * 4):
return None
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):
"""-> {resource id in a map record: that model's GaugeImage id}.
A record names the entity's *Model List*, not its gauge image. Both are
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: 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():
if r['desc'].endswith(': GaugeImage'):
by_name[r['desc'].split(':')[0]] = rid
out = {}
for rid, r in table.items():
name = r['desc'].split(':')[0]
if name in by_name:
out[rid] = by_name[name]
return out
def instances(res, addr, size, count, lookup):
"""Map instance records are variable length and say so: the first int
of a record is its own length in bytes.
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. Reading
the length makes all eighteen tracks parse to exactly their declared
count with no bytes left over.
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.
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]
if not (40 <= length <= 1024) or o + length > end:
break
resource = struct.unpack_from('<i', res, o + 40)[0]
pos = struct.unpack_from('<3f', res, o + 48)
q = struct.unpack_from('<4f', res, o + 60)
out.append((lookup.get(resource), pos, q))
o += length
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, meters_per_pixel):
"""-> ([(colour index, [(x, z), ...]), ...], drawn, skipped).
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 gid not in cache:
cache[gid] = gauge_image(res, table[gid]['addr'])
img = cache[gid]
if img is None:
skipped += 1
continue
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 colour, attrs, idx in img['l'][lod]['p']:
pts = []
for k in idx:
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:
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)]