Files
RP412/tools/pages/navmap.py
T
Cyd 9f0a77cc16 The tracks were never built from one model
A map record is an Entity::MakeMessage (MUNGA/ENTITY3.h): classToCreate,
owningPlayerID, resourceID, instanceFlags, localOrigin. The origin ends
the 76-byte case, which puts classToCreate at +28, resourceID at +40 and
instanceFlags at +44.

I had been reading +44. That is instanceFlags, and it is 524 on every
scenery record - and 524 happens to be cn3's GaugeImage. So every track
resolved to cn3 repeated a few hundred times, consistently and wrongly,
and every conclusion drawn from that followed: the "one wall bar with a
gate", the ticks, the claim that the LOD machinery is never exercised.

The id at +40 varies per placement. The tracks use cn1, cn3, cn4, cn5,
cn7, br1, br3, ft1, cq1, cq2, md3, md4, the pits, and the score zones
sc50/sc50a/sc500a that gave the amber boxes at each end. A record names
the model's Model List; the GaugeImage is filed under the same model
name, so the name is the join - and models with no gauge image (oao,
snAwork, pz1) are skipped here exactly as DrawStatic skips them.

Found by following the map loader: InterestManager::LoadMapStream reads
the stream as MakeMessages and names the map entity classes, one of which
is 95 in these records - CulturalIconClassID.
2026-08-07 14:45:20 -05:00

306 lines
12 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 math, re, statistics, 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 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 here: 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: 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 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.
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."""
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 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
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
placed = instances(res, map_addr, map_size, map_count,
gauge_lookup(table))
# 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):
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 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.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)
return out, drawn, skipped