A map viewer that draws tracks the way the map screen does

Pan with the arrows, zoom with plus and minus, [ and ] for the next
track. Self-contained HTML with the track data and palette embedded;
nothing here ships, and pack-dist.ps1 does not look at it.

It follows the engine rather than approximating it. NavDisplay derives
metersPerPixel from the zoom and sets LODIndex to it; L4GaugeImage::Draw
takes the first LOD whose scale is at least that value and draws nothing
once the value runs past the largest, so objects vanish rather than
simplify. Both map gauges are here because they disagree - nav is the
448x416 radar screen with LOD following zoom, gps the 125x203 panel whose
config pins LOD at 1.0. The HUD reports what is dropped, and is honest
that this content barely exercises it: every placement in every track is
cn3 with one LOD at scale 1000.

The map is not a phosphor screen. Primitives carry palette indices and
the palette is whichever the port was configured with - for the pod's
secondary port, configure(0,sec,270,0x00ff,clut0,rgb,secpal.pcc). PCC is
PCX, so the palette is the last 769 bytes. Walls are grey because index
51 is #4b4b4b; background is index 0, black; a primitive with colour 0
keeps the display's staticColor, 0x3C. Per-file palettes, not a global
one - 39 of 40 gauge PCCs differ - so the port's configured palette is
the one that counts.
This commit is contained in:
Cyd
2026-08-07 12:40:52 -05:00
parent 53c4eac3fd
commit b1b82d5da1
6 changed files with 933 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
# mapview
A dev tool: every track drawn the way the pod's map screen draws it, with pan
and zoom, so you can ask "what does the map actually show here?" without booting
the game. Nothing here ships — [pack-dist.ps1](../../pack-dist.ps1) does not
look at it.
```powershell
python tools\mapview\build_mapview.py <rpl4tool-listing.txt> tools\mapview\mapview.html
```
Then open `mapview.html`. It is self-contained: track data, palette and all.
| key | |
|---|---|
| <kbd>&uarr;</kbd> <kbd>&darr;</kbd> <kbd>&larr;</kbd> <kbd>&rarr;</kbd> | pan north / south / west / east |
| <kbd>+</kbd> <kbd>-</kbd> | zoom in / out |
| <kbd>[</kbd> <kbd>]</kbd> | previous / next track |
| <kbd>F</kbd> / <kbd>A</kbd> | fit the course / fit everything |
| <kbd>G</kbd> | GPS or NavDisplay LOD behaviour |
Dragging pans and the wheel zooms.
## What it reproduces
`NavDisplay::CalculateBounds` ([RPL4GAUG.cpp:1587](../../RP_L4/RPL4GAUG.cpp#L1587))
derives `metersPerPixel` from the zoom and sets `LODIndex` to it. `L4GaugeImage::Draw`
([L4GAUIMA.cpp:908](../../MUNGA_L4/L4GAUIMA.cpp#L908)) then walks the LOD scales:
```c
for (lod_index = 0; lod_index < LODCount; ++lod_index)
if (LOD_value <= LODScales[lod_index]) break;
if (lod_index < LODCount) { ...Draw... } // else nothing is drawn
```
Scales ascend, so index 0 is the finest and running past the largest drops the
object entirely rather than simplifying it. The viewer does the same and reports
the count, though this content barely exercises it: every placement in every
track is `cn3`, which carries a single LOD at scale 1000, so nothing is dropped
until 1000 m/px and then all of it goes at once.
Two display modes, because the game has two map gauges and they disagree:
* **NavDisplay** &mdash; `nav(A,ModeAlwaysActive,(448,416),0x00,0x3C,...)`. The
448&times;416 radar screen, LOD following the zoom.
* **GPS** &mdash; `gps(R,ModeAlwaysActive,(125,203),1.0)`. The 125&times;203 panel,
LOD pinned at 1.0 by the config however far out it is scaled.
## Colour
Not a phosphor screen. Primitives carry palette indices and the palette is
whichever the port was configured with; for the pod's secondary port, where the
nav display lives, `L4GAUGE.CFG` says
`configure(0, sec, 270, 0x00ff, clut0, rgb, secpal.pcc)`. PCC is PCX, so the last
769 bytes are `0x0C` and 256 RGB triples. The walls come out grey because index
51 is `#4b4b4b`; the background is index 0, black. A primitive whose own colour
is 0 keeps the colour already set, which is the display's `staticColor`, `0x3C`.
Palettes are per-file, not global &mdash; 39 of 40 gauge PCCs differ from each
other &mdash; so the port's configured palette is the one that matters, not any
convenient nearby image.
## Orientation
Seen from above with +Z up the page, the engine's +X runs to the **left**. The
console's own picture of Brewer's Bane is what settles it: long leg up the right
side to Score Zone 1, the corner, then the run out to Score Zone 2. Drawn the
other way round every plan comes out mirrored, which is the bug this shares a
fix with in [../pages/](../pages/).
+148
View File
@@ -0,0 +1,148 @@
"""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)
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,
set(images)):
if gid is None:
noart += 1 # the map screen skips these
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)
+80
View File
@@ -0,0 +1,80 @@
*, *::before, *::after { box-sizing: border-box; }
:root {
/* Chrome only. Everything inside the screen is painted from the game's
own palette (secpal.pcc) and must not be tinted by any of this. */
--chrome: #17181a;
--panel: #1e2023;
--rule: #2f3237;
--ink: #e6e4e0;
--ink-mid: #9aa0a6;
--ink-low: #6d737a;
--accent: #bf9300; /* palette 56, the map's own amber */
--mono: ui-monospace, "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace;
}
html, body { height: 100%; }
body {
margin: 0; background: var(--chrome); color: var(--ink);
font: 400 13px/1.5 var(--mono); overflow: hidden;
display: grid; grid-template-columns: 1fr 17rem;
}
/* The screen. Background is palette index 0 - black - so the chrome must
not bleed into it: this is what the gauge actually draws onto. */
#screen { position: relative; background: #000; overflow: hidden; }
#view { display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; }
#view:active { cursor: grabbing; }
#scale {
position: absolute; left: 1rem; bottom: 1rem;
display: flex; align-items: center; gap: .5rem;
color: var(--ink-mid); font-size: 11px; letter-spacing: .08em;
text-shadow: 0 0 4px #000, 0 0 4px #000;
}
#barline { height: 0; border-top: 2px solid var(--ink-mid); }
aside {
background: var(--panel); border-left: 1px solid var(--rule);
padding: 1rem; overflow-y: auto;
display: flex; flex-direction: column; gap: 1rem;
}
h1 { font: 700 15px/1.2 var(--mono); margin: 0; letter-spacing: .02em; }
#key { color: var(--ink-low); font-size: 11px; letter-spacing: .14em; margin: .2rem 0 0; }
select {
width: 100%; font: 400 12px var(--mono); padding: .45rem .5rem;
background: var(--chrome); color: var(--ink); border: 1px solid var(--rule);
}
select:focus-visible, #view:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
dl#stats {
margin: 0; display: grid; grid-template-columns: 1fr auto;
gap: .3rem .75rem; font-size: 12px;
}
dl#stats dt { color: var(--ink-low); }
dl#stats dd {
margin: 0; text-align: right; color: var(--ink);
font-variant-numeric: tabular-nums;
}
.note {
border-top: 1px solid var(--rule); padding-top: .8rem;
color: var(--ink-low); font-size: 11px; line-height: 1.6;
}
.note b { color: var(--ink-mid); font-weight: 500; }
kbd {
display: inline-block; min-width: 1.5em; text-align: center;
border: 1px solid var(--rule); border-bottom-width: 2px;
padding: .1em .35em; margin-right: .15em; border-radius: 3px;
background: var(--chrome); color: var(--ink-mid); font: 400 11px var(--mono);
}
.keys { display: grid; grid-template-columns: auto 1fr; gap: .35rem .6rem; font-size: 11px; }
.keys span:nth-child(even) { color: var(--ink-low); align-self: center; }
@media (max-width: 700px) {
body { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
aside { border-left: 0; border-top: 1px solid var(--rule); max-height: 45vh; }
}
File diff suppressed because one or more lines are too long
+219
View File
@@ -0,0 +1,219 @@
'use strict';
const DATA = JSON.parse(document.getElementById('data').textContent);
const cv = document.getElementById('view');
const ctx = cv.getContext('2d');
// ---------------------------------------------------------------- state
const S = {
track: 0,
cx: 0, cz: 0, // view centre, world units
mpp: 10, // meters per pixel - the engine's own scale term
gps: false, // false = NavDisplay (LOD follows zoom), true = GPS
stats: {},
};
// Seen from above with +Z up the page the engine's +X runs to the LEFT.
// The console's own picture of Brewer's Bane is what settles the
// handedness; drawn the other way every plan comes out mirrored.
function toScreen(x, z, w, h) {
return [w / 2 - (x - S.cx) / S.mpp, h / 2 - (z - S.cz) / S.mpp];
}
// Quaternion (x,y,z,w) applied to a point - MUNGA's own convention.
function rotate(q, p) {
const [qx, qy, qz, qw] = q, [x, y, z] = p;
const tx = 2 * (qy * z - qz * y);
const ty = 2 * (qz * x - qx * z);
const tz = 2 * (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];
}
// The map screen is not a phosphor display. Primitives carry palette
// indices and the palette is the one the port was configured with -
// secpal.pcc for the pod's secondary screen, where the nav display lives.
// Walls (index 51) are grey. L4GaugeImage::Draw only overrides the colour
// when it is non-zero, so 0 means "keep what is set", which is the
// display's own staticColor (0x3C).
function ink(c) {
return DATA.palette[c ? c : DATA.staticColor] || '#535353';
}
// ------------------------------------------------------------- bounds
function bounds(list) {
let x0 = Infinity, x1 = -Infinity, z0 = Infinity, z1 = -Infinity;
for (const r of list) {
if (r[1] < x0) x0 = r[1];
if (r[1] > x1) x1 = r[1];
if (r[3] < z0) z0 = r[3];
if (r[3] > z1) z1 = r[3];
}
return list.length ? {x0, x1, z0, z1} : {x0: -1, x1: 1, z0: -1, z1: 1};
}
// Fourteen tracks park a single piece far off the course; fitting to
// everything squeezes the track into a sliver, so "fit" ignores loners
// and "fit all" (A) is there when you want the literal extent.
function course(t) {
if (t._course) return t._course;
const keep = t.i.filter(a =>
t.i.some(b => b !== a && (b[1] - a[1]) ** 2 + (b[3] - a[3]) ** 2 < 200 * 200));
return (t._course = keep.length ? keep : t.i);
}
function fit(all) {
const t = DATA.tracks[S.track];
const b = bounds(all ? t.i : course(t));
S.cx = (b.x0 + b.x1) / 2;
S.cz = (b.z0 + b.z1) / 2;
const w = cv.clientWidth || 800, h = cv.clientHeight || 600;
// The GPS gauge allows 1.2x "for large objects at edges of map"
// (RPL4GAUG.cpp:2060); same here so nothing clips at the border.
S.mpp = Math.max((b.x1 - b.x0) / w, (b.z1 - b.z0) / h, 1e-4) * 1.2;
draw();
}
// -------------------------------------------------------------- render
function draw() {
const dpr = window.devicePixelRatio || 1;
const w = cv.clientWidth, h = cv.clientHeight;
if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) {
cv.width = Math.round(w * dpr);
cv.height = Math.round(h * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const t = DATA.tracks[S.track];
// NavDisplay sets LODIndex = metersPerPixel every time it recalculates
// bounds. The GPS gauge takes its LOD from the config and never moves it.
const lodValue = S.gps ? 1.0 : S.mpp;
let drawn = 0, culled = 0, prims = 0;
ctx.lineWidth = 1;
ctx.lineJoin = 'round';
for (const r of t.i) {
const img = DATA.images[r[0]];
if (!img) { culled++; continue; }
// L4GaugeImage::Draw - scales ascend, so index 0 is the FINEST, and
// running past the largest means the object is not drawn at all.
let k = 0;
while (k < img.l.length && lodValue > img.l[k].s) k++;
if (k >= img.l.length) { culled++; continue; }
drawn++;
const q = [r[4], r[5], r[6], r[7]];
for (const p of img.l[k].p) {
ctx.strokeStyle = ink(p.c);
ctx.beginPath();
for (let n = 0; n < p.i.length; n++) {
let v = img.v[p.i[n]];
if (!v) continue;
// attributeUnscaled: pre-multiply by the scale factor so the
// primitive keeps a constant size on screen (L4GAUIMA.cpp:1599).
if (p.a & 1) v = [v[0] * S.mpp, v[1] * S.mpp, v[2] * S.mpp];
const g = rotate(q, v);
const [sx, sy] = toScreen(g[0] + r[1], g[2] + r[3], w, h);
n ? ctx.lineTo(sx, sy) : ctx.moveTo(sx, sy);
}
ctx.stroke();
prims++;
}
}
S.stats = {drawn, culled, prims, lodValue};
hud();
}
// ----------------------------------------------------------------- hud
function hud() {
const t = DATA.tracks[S.track];
const s = S.stats;
const px = 1 / S.mpp;
document.getElementById('name').textContent = t.name;
document.getElementById('key').textContent =
t.key + ' ' + (S.track + 1) + '/' + DATA.tracks.length;
const rows = [
['mode', S.gps ? 'GPS (LOD pinned 1.0)' : 'NavDisplay (LOD = m/px)'],
['metersPerPixel', S.mpp.toFixed(3)],
['pixelsPerMeter', px.toFixed(4)],
['LOD value', s.lodValue.toFixed(3)],
['centre X / Z', S.cx.toFixed(0) + ' / ' + S.cz.toFixed(0)],
['objects drawn', s.drawn],
['dropped by LOD', s.culled],
['primitives', s.prims],
['no map art', t.noart],
];
document.getElementById('stats').innerHTML = rows.map(
r => '<dt>' + r[0] + '</dt><dd>' + r[1] + '</dd>').join('');
// Scale bar: a round number of meters, whatever fits under 160px.
let m = Math.pow(10, Math.floor(Math.log10(160 * S.mpp)));
for (const f of [5, 2, 1]) if (m * f <= 160 * S.mpp) { m *= f; break; }
document.getElementById('barline').style.width = (m / S.mpp) + 'px';
document.getElementById('bartext').textContent = m + ' m';
}
// ------------------------------------------------------------ controls
function pan(dx, dz) {
const step = 0.1 * Math.min(cv.clientWidth, cv.clientHeight) * S.mpp;
S.cx += dx * step;
S.cz += dz * step;
draw();
}
function zoom(f) {
S.mpp = Math.max(0.02, Math.min(4000, S.mpp * f));
draw();
}
function pick(n) {
S.track = (n + DATA.tracks.length) % DATA.tracks.length;
document.getElementById('track').value = S.track;
fit(false);
}
addEventListener('keydown', e => {
if (e.target.tagName === 'SELECT') return;
const k = e.key;
// Right on screen is -X, up is +Z: north/south/east/west as drawn.
if (k === 'ArrowUp') pan(0, 1);
else if (k === 'ArrowDown') pan(0, -1);
else if (k === 'ArrowLeft') pan(1, 0);
else if (k === 'ArrowRight') pan(-1, 0);
else if (k === '+' || k === '=') zoom(1 / 1.25);
else if (k === '-' || k === '_') zoom(1.25);
else if (k === '[') pick(S.track - 1);
else if (k === ']') pick(S.track + 1);
else if (k === 'f' || k === 'F') fit(false);
else if (k === 'a' || k === 'A') fit(true);
else if (k === 'g' || k === 'G') { S.gps = !S.gps; draw(); }
else return;
e.preventDefault();
});
let drag = null;
cv.addEventListener('pointerdown', e => {
drag = {x: e.clientX, y: e.clientY};
cv.setPointerCapture(e.pointerId);
});
cv.addEventListener('pointermove', e => {
if (!drag) return;
S.cx -= (e.clientX - drag.x) * S.mpp;
S.cz += (e.clientY - drag.y) * S.mpp;
drag = {x: e.clientX, y: e.clientY};
draw();
});
cv.addEventListener('pointerup', () => { drag = null; });
cv.addEventListener('wheel', e => {
e.preventDefault();
zoom(e.deltaY > 0 ? 1.1 : 1 / 1.1);
}, {passive: false});
const sel = document.getElementById('track');
sel.innerHTML = DATA.tracks.map(
(t, i) => '<option value="' + i + '">' + t.name + '</option>').join('');
sel.addEventListener('change', () => pick(+sel.value));
addEventListener('resize', draw);
pick(0);
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Red Planet map viewer</title>
<style>{css}</style>
</head>
<body>
<div id="screen">
<canvas id="view" tabindex="0" aria-label="Track map"></canvas>
<div id="scale"><span id="barline"></span><span id="bartext"></span></div>
</div>
<aside>
<div>
<h1 id="name">&mdash;</h1>
<p id="key"></p>
</div>
<select id="track" aria-label="Track"></select>
<dl id="stats"></dl>
<div class="note">
<div class="keys">
<span><kbd>&uarr;</kbd><kbd>&darr;</kbd><kbd>&larr;</kbd><kbd>&rarr;</kbd></span><span>pan N/S/E/W</span>
<span><kbd>+</kbd><kbd>-</kbd></span><span>zoom</span>
<span><kbd>[</kbd><kbd>]</kbd></span><span>previous / next track</span>
<span><kbd>F</kbd></span><span>fit the course</span>
<span><kbd>A</kbd></span><span>fit everything, strays included</span>
<span><kbd>G</kbd></span><span>GPS / NavDisplay LOD</span>
</div>
</div>
<p class="note"><b>Dropped by LOD</b> counts what the map screen does not draw
at all. <code>L4GaugeImage::Draw</code> takes the first LOD whose scale is at
least the LOD value and draws <em>nothing</em> once the value runs past the
largest, so objects vanish rather than simplify. Worth knowing what the data
does with that: every placement in every track is the same model,
<code>cn3</code>, and it carries one LOD at scale 1000. So the count stays at
zero until you pass 1000 m/px, when the whole track disappears at once. The
rule is the engine's; this content just never leans on it.</p>
<p class="note"><b>NavDisplay</b> sets its LOD from the zoom
(<code>LODIndex = metersPerPixel</code>), which is the pannable radar screen,
448&times;416 in <code>L4GAUGE.CFG</code>. <b>GPS</b> is the little 125&times;203
panel whose LOD the config pins at 1.0 however far out it is scaled.</p>
<p class="note">Colours are the game's own: palette indices resolved through
<code>secpal.pcc</code>, the palette the pod's secondary port is configured
with. The walls are grey because index 51 is grey.</p>
</aside>
<script id="data" type="application/json">{data}</script>
<script>{js}</script>
</body>
</html>