"""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(' 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(' {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(' end: break resource = struct.unpack_from(' ([(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)]