They are not a fixed 76 bytes. The first int of each record is its length, and while most placements are 76, every track also has eight of 140, two of 80 and one of 336 - 560 on Paingod's. Striding a fixed 76 landed mid-record on those, and hunting forward for the next plausible quaternion then locked onto arbitrary bytes: that is where the impossible class ids came from, and the "resource id" 1065353216, which is 0x3F800000 - float 1.0. Reading the length instead, all eighteen tracks parse to exactly their declared instance count with no bytes left over. That is the check that was missing before. It does not change what gets drawn, because the extra records were never drawable anyway. It does mean the parse is no longer guessing.
272 lines
10 KiB
Python
272 lines
10 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 instances(res, addr, size, count, gauge_ids):
|
|
"""Map instance records are variable length and say so: the first int
|
|
of a record is its own length in bytes. Resource id at +44, position
|
|
at +48, quaternion at +60.
|
|
|
|
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.
|
|
|
|
A record whose +44 is not a gauge image is not drawn, exactly as
|
|
DrawStatic skips an entity with no gauge image."""
|
|
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
|
|
gid = struct.unpack_from('<i', res, o + 44)[0]
|
|
pos = struct.unpack_from('<3f', res, o + 48)
|
|
q = struct.unpack_from('<4f', res, o + 60)
|
|
out.append((gid if gid in gauge_ids else None, 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."""
|
|
gauge = {rid for rid, r in table.items() if r['desc'].endswith(': GaugeImage')}
|
|
seen, out = set(), []
|
|
for gid, pos, q in instances(res, map_addr, map_size, map_count, gauge):
|
|
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, set(gauge))
|
|
|
|
# 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
|