From b1b82d5da1753cb8d5e4552d4abe92a740c6fd5c Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 7 Aug 2026 12:40:52 -0500 Subject: [PATCH] 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. --- tools/mapview/README.md | 69 +++++++ tools/mapview/build_mapview.py | 148 ++++++++++++++ tools/mapview/mapview.css | 80 ++++++++ tools/mapview/mapview.html | 358 +++++++++++++++++++++++++++++++++ tools/mapview/mapview.js | 219 ++++++++++++++++++++ tools/mapview/mapview.tpl | 59 ++++++ 6 files changed, 933 insertions(+) create mode 100644 tools/mapview/README.md create mode 100644 tools/mapview/build_mapview.py create mode 100644 tools/mapview/mapview.css create mode 100644 tools/mapview/mapview.html create mode 100644 tools/mapview/mapview.js create mode 100644 tools/mapview/mapview.tpl diff --git a/tools/mapview/README.md b/tools/mapview/README.md new file mode 100644 index 0000000..e22cd44 --- /dev/null +++ b/tools/mapview/README.md @@ -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 tools\mapview\mapview.html +``` + +Then open `mapview.html`. It is self-contained: track data, palette and all. + +| key | | +|---|---| +| | pan north / south / west / east | +| + - | zoom in / out | +| [ ] | previous / next track | +| F / A | fit the course / fit everything | +| G | 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** — `nav(A,ModeAlwaysActive,(448,416),0x00,0x3C,...)`. The + 448×416 radar screen, LOD following the zoom. +* **GPS** — `gps(R,ModeAlwaysActive,(125,203),1.0)`. The 125×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 — 39 of 40 gauge PCCs differ from each +other — 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/). diff --git a/tools/mapview/build_mapview.py b/tools/mapview/build_mapview.py new file mode 100644 index 0000000..4c27b35 --- /dev/null +++ b/tools/mapview/build_mapview.py @@ -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 +""" +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(' + + + + +Red Planet map viewer + + + + +
+ +
+
+ + + + + + + diff --git a/tools/mapview/mapview.js b/tools/mapview/mapview.js new file mode 100644 index 0000000..925a2e5 --- /dev/null +++ b/tools/mapview/mapview.js @@ -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 => '
' + r[0] + '
' + r[1] + '
').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) => '').join(''); +sel.addEventListener('change', () => pick(+sel.value)); +addEventListener('resize', draw); +pick(0); diff --git a/tools/mapview/mapview.tpl b/tools/mapview/mapview.tpl new file mode 100644 index 0000000..2148fbf --- /dev/null +++ b/tools/mapview/mapview.tpl @@ -0,0 +1,59 @@ + + + + + +Red Planet map viewer + + + + +
+ +
+
+ + + + + + +