Files
RP412/tools/mapview/build_mapview.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

153 lines
6.1 KiB
Python

"""Build the map viewer: every track, drawn the way the pod's map draws it.
This is a dev tool, not something that ships. It exists to answer "what
does the map screen actually show here?" without booting the game, which
matters because the answer is not obvious: the engine drops whole objects
at low zoom rather than simplifying them, and you cannot see that happen
from a static picture.
What it reproduces, from the engine:
NavDisplay::CalculateBounds (RP_L4/RPL4GAUG.cpp:1587)
pixelsPerMeter = (halfWidth<<1) / currentScale
metersPerPixel = 1 / pixelsPerMeter
LODIndex = metersPerPixel // N = 1.0 pixels/meter
L4GaugeImage::Draw (MUNGA_L4/L4GAUIMA.cpp:908)
for (i = 0; i < LODCount; ++i)
if (LOD_value <= LODScales[i]) break;
if (i < LODCount) draw LODList[i]; // else NOT DRAWN AT ALL
That last line is the whole point. LODScales ascend, so index 0 is the
finest, and when the LOD value runs past the largest scale the object is
skipped entirely. Zooming out in this viewer makes objects vanish exactly
where the pod makes them vanish.
The GPS gauge is the other case: L4GAUGE.CFG has gps(R,ModeAlwaysActive,
(125,203),1.0), so its LOD value is pinned at 1.0 by the config and only
its scale follows the track. Both modes are here - G switches.
Usage: build_mapview.py <rpl4tool-listing.txt> <out.html>
"""
import json, os, re, struct, sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, '..', 'pages'))
import navmap # noqa: E402 (path first)
ROOT = 'c:/VWE/RP412'
RES = open(ROOT + '/assets/RP411/RPL4.RES', 'rb').read()
LISTING, OUT = sys.argv[1], sys.argv[2]
TABLE = navmap.resource_table(RES, LISTING)
def gauge_image(addr):
"""The whole stream, every LOD - not just the finest, which is what
the page builder wants and exactly what a viewer must not assume."""
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 = [[round(f32(), 2) for _ in range(3)] for _ in range(vcount)]
lodcount = i32()
if not (0 < lodcount < 64):
return None
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):
i32() # type: only primitiveVector
colour = i32()
attrs = i32()
n = i32()
if not (0 < n <= vcount * 4):
return None
prims.append({'c': colour, 'a': attrs,
'i': [i32() for _ in range(n)]})
lods.append({'s': scales[lod], 'p': prims})
return {'v': verts, 'l': lods}
images, bad = {}, 0
for rid, r in TABLE.items():
if r['desc'].endswith(': GaugeImage'):
img = gauge_image(r['addr'])
if img is None:
bad += 1
else:
img['n'] = r['desc'].split(':')[0]
images[rid] = img
print('gauge images: %d (%d unreadable)' % (len(images), bad), file=sys.stderr)
CONSOLE = {}
xml = open(ROOT + '/tools/console-config/RPConfig.xml', encoding='utf-8-sig').read()
for m in re.finditer(r'<map\s+key="([^"]+)"\s+name="([^"]+)"', xml):
CONSOLE[m.group(1)] = m.group(2)
# A record names the model's Model List; its GaugeImage is filed under the
# same model name. navmap.gauge_lookup does that join.
LOOKUP = {k: v for k, v in navmap.gauge_lookup(TABLE).items() if v in images}
tracks = []
for rid, r in sorted(TABLE.items()):
m = re.match(r'^(\w+): Map stream of (\d+) instances$', r['desc'])
if not m:
continue
key, count = m.group(1), int(m.group(2))
placed, noart = [], 0
for gid, pos, q in navmap.instances(RES, r['addr'], r['size'], count,
LOOKUP):
if gid is None:
noart += 1 # no gauge image: the map skips these too
continue
placed.append([gid,
round(pos[0], 2), round(pos[1], 2), round(pos[2], 2),
round(q[0], 5), round(q[1], 5), round(q[2], 5),
round(q[3], 5)])
tracks.append({'key': key, 'name': CONSOLE.get(key, key),
'noart': noart, 'i': placed})
print(' %-14s %4d placed, %2d without map art'
% (key, len(placed), noart), file=sys.stderr)
# The map is not a phosphor screen. Primitives carry palette indices, and
# the palette is whatever the port was configured with - for the pod's
# secondary screen, where the nav display lives, L4GAUGE.CFG says
# configure(0, sec, 270, 0x00ff, clut0, rgb, secpal.pcc). So the walls
# (index 51) come out grey, not green. PCC is PCX: 0x0C then 256 RGB
# triples in the last 769 bytes.
PALETTE_FILE = ROOT + '/assets/RP411/GAUGE/secpal.pcc'
raw = open(PALETTE_FILE, 'rb').read()
if len(raw) < 769 or raw[-769] != 0x0C:
sys.exit('%s has no VGA palette where PCX keeps one' % PALETTE_FILE)
base = len(raw) - 768 # absolute: -768+255*3 wraps round to 0
palette = ['#%02x%02x%02x' % tuple(raw[base + i * 3:base + i * 3 + 3])
for i in range(256)]
# nav(A, ModeAlwaysActive, (448,416), 0x00, 0x3C, ...): the screen is
# 448x416, the background is colour 0 and static objects default to 0x3C.
# A primitive whose own colour is 0 keeps the colour already set, which is
# that default - see L4GaugeImage::Draw.
data = {'images': images, 'tracks': tracks, 'palette': palette,
'staticColor': 0x3C, 'background': 0x00, 'screen': [448, 416]}
blob = json.dumps(data, separators=(',', ':'))
read = lambda n: open(os.path.join(HERE, n), encoding='utf-8').read()
open(OUT, 'w', encoding='utf-8').write(
read('mapview.tpl').format(css=read('mapview.css'), js=read('mapview.js'),
data=blob))
print('wrote %s (%.0f KB, %d tracks)'
% (OUT, len(blob) / 1024, len(tracks)), file=sys.stderr)