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 + + + + +
+ +
+
+ + + + + + +