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.
306 lines
12 KiB
Python
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
|