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.
This commit is contained in:
+87
-176
@@ -3,7 +3,10 @@
|
||||
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.
|
||||
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
|
||||
@@ -14,7 +17,7 @@ GaugeImage stream (MUNGA_L4/L4GAUIMA.cpp):
|
||||
per primitive: int type, int colour, int attributes,
|
||||
int indexCount, int indices[indexCount]
|
||||
"""
|
||||
import math, re, statistics, struct
|
||||
import re, struct
|
||||
|
||||
|
||||
def resource_table(res, listing):
|
||||
@@ -42,12 +45,14 @@ def resource_table(res, listing):
|
||||
return table
|
||||
|
||||
|
||||
def read_gauge_image(res, addr, size):
|
||||
"""-> (vertices, [polyline of (x,y,z) ...]) using the finest LOD."""
|
||||
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
|
||||
@@ -55,31 +60,25 @@ def read_gauge_image(res, addr, size):
|
||||
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):
|
||||
verts = [(f32(), f32(), f32()) for _ in range(vcount)]
|
||||
lodcount = i32()
|
||||
if not (0 < lodcount < 64):
|
||||
return None
|
||||
scales = [f32() for _ in range(lods)]
|
||||
lines = []
|
||||
for lod in range(lods):
|
||||
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):
|
||||
ptype = i32(); i32(); i32() # type, colour, attributes
|
||||
n = i32()
|
||||
i32() # type: only primitiveVector
|
||||
colour, attrs, n = i32(), i32(), 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
|
||||
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):
|
||||
@@ -89,8 +88,8 @@ def gauge_lookup(table):
|
||||
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
|
||||
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():
|
||||
@@ -111,26 +110,19 @@ def instances(res, addr, size, count, lookup):
|
||||
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 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 (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.
|
||||
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.
|
||||
|
||||
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."""
|
||||
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]
|
||||
@@ -144,96 +136,6 @@ def instances(res, addr, size, count, lookup):
|
||||
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
|
||||
@@ -246,60 +148,69 @@ def rotate(q, p):
|
||||
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))
|
||||
def track_lines(res, table, map_addr, map_size, map_count, meters_per_pixel):
|
||||
"""-> ([(colour index, [(x, z), ...]), ...], drawn, skipped).
|
||||
|
||||
# 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):
|
||||
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 isolated(i):
|
||||
skipped += 1
|
||||
continue
|
||||
r = gauge[gid]
|
||||
img = read_gauge_image(res, r['addr'], r['size'])
|
||||
if gid not in cache:
|
||||
cache[gid] = gauge_image(res, table[gid]['addr'])
|
||||
img = cache[gid]
|
||||
if img is None:
|
||||
skipped += 1
|
||||
continue
|
||||
verts, prims = img
|
||||
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 idx in prims:
|
||||
for colour, attrs, idx in img['l'][lod]['p']:
|
||||
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 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:
|
||||
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)
|
||||
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)]
|
||||
|
||||
Reference in New Issue
Block a user