docs: the maps page (docs/MAPS.html + generator)

Companion page to MECHS.html: every map in BTL4.RES drawn the way the
cockpit draws it (tools/build_maps.py + tools/maps.tpl -> docs/MAPS.html).
Each plan is rendered from the map's own Entity::MakeMessage stream --
every placement, the rtype-18 GaugeImage outline its Model List carries,
in the game's own btspal.pcc palette.  Nothing is traced or redrawn.
The eight playable maps lead; supporting streams (arena geometry, drop
points, wind emitters) are labeled and filterable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-12 21:06:15 -05:00
co-authored by Claude Opus 4.8
parent 150492054a
commit 1f54592595
3 changed files with 666 additions and 0 deletions
+290
View File
File diff suppressed because one or more lines are too long
+229
View File
@@ -0,0 +1,229 @@
"""Render every map in BTL4.RES the way the cockpit draws them.
The technique is the one worked out for Red Planet (RP412
tools/pages/navmap.py) and it transfers because both games are MUNGA:
a map stream is a run of Entity::MakeMessage records, each carrying its
own length, and each naming a Model List whose GaugeImage is the outline
the display draws.
Where BT differs from RP:
* The resource file has a real directory. Header is
(labOnly, maxID) at +4 and an offset table at +12; each descriptor
holds rid/rtype at +0, a 32-byte name at +8, and priority, flags,
offset and length at +40. RP412's file inlines the data after each
descriptor instead, so its walk does not apply here.
* rtype 1 is a Model List, 18 a GaugeImage, 14 a map stream. A model's
outline is the rtype-18 member of its list - a direct join, rather
than RP412's match on the model's name.
* Records are advanced by (length + 3) & ~3. RP's are not padded.
* Every primitive carries an explicit palette index; nothing falls back
to a display default the way RP's colour 0 does.
Colours are indices into btspal.pcc, the palette L4GAUGE.CFG configures
the secondary port with: configure(0,sec,270,0x003F,clut0,rgb,btspal.pcc).
PCC is PCX, so the palette is the last 769 bytes.
Usage: python tools/build_maps.py [out.html]
"""
import base64, collections, html, io, os, struct, sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RES = open(os.path.join(ROOT, 'content', 'BTL4.RES'), 'rb').read()
OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, 'docs', 'MAPS.html')
MODEL_LIST, SOLIDS, MAP_STREAM, GAUGE_IMAGE = 1, 9, 14, 18
# ------------------------------------------------------------- directory
labOnly, maxID = struct.unpack_from('<ii', RES, 4)
RESOURCES = {}
for off in struct.unpack_from('<%dI' % maxID, RES, 12):
if off == 0:
continue
rid, rtype = struct.unpack_from('<ii', RES, off)
name = RES[off + 8:off + 40].split(b'\0')[0].decode('ascii', 'replace')
prio, flags, addr, size = struct.unpack_from('<iIII', RES, off + 40)
RESOURCES[rid] = {'t': rtype, 'name': name, 'off': addr, 'len': size}
def members(rid):
r = RESOURCES.get(rid)
if not r or r['t'] != MODEL_LIST:
return ()
n = struct.unpack_from('<i', RES, r['off'])[0]
return struct.unpack_from('<%di' % n, RES, r['off'] + 4)
# A model's outline is simply the GaugeImage in its own Model List.
OUTLINE = {}
for rid, r in RESOURCES.items():
if r['t'] == MODEL_LIST:
g = [m for m in members(rid)
if RESOURCES.get(m, {}).get('t') == GAUGE_IMAGE]
if g:
OUTLINE[rid] = g[0]
def gauge_image(off):
p = off
vcount = struct.unpack_from('<i', RES, p)[0]; p += 4
verts = [struct.unpack_from('<3f', RES, p + 12 * i) for i in range(vcount)]
p += 12 * vcount
lods = struct.unpack_from('<i', RES, p)[0]; p += 4
scales = struct.unpack_from('<%df' % lods, RES, p); p += 4 * lods
out = []
for lod in range(lods):
pcount = struct.unpack_from('<i', RES, p)[0]; p += 4
prims = []
for _ in range(pcount):
_t, colour, attrs, n = struct.unpack_from('<4i', RES, p); p += 16
prims.append((colour, attrs, struct.unpack_from('<%di' % n, RES, p)))
p += 4 * n
out.append((scales[lod], prims))
return verts, out
def rotate(q, p):
qx, qy, qz, qw = q
x, y, z = p
tx = 2 * (qy * z - qz * y)
ty = 2 * (qz * x - qx * z)
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)
def placements(rid):
"""-> ([(outline id, position, quaternion)], total records, model tally).
Each record is an Entity::MakeMessage: its own length at +0, flags at
+8, classToCreate at +28, the resource it names at +40, instanceFlags
at +44 and the origin at +48. Records shorter than 76 bytes cannot
carry an origin and are skipped - the arenas hold a couple of those."""
m = RESOURCES[rid]
count = struct.unpack_from('<i', RES, m['off'])[0]
p, end = m['off'] + 4, m['off'] + m['len']
drawn, total, tally = [], 0, collections.Counter()
for _ in range(count):
if p + 12 > end:
break
length = struct.unpack_from('<I', RES, p)[0]
if not (12 <= length <= 4096) or p + length > end:
break
total += 1
if length >= 76:
target = struct.unpack_from('<i', RES, p + 40)[0]
if target in OUTLINE:
drawn.append((OUTLINE[target],
struct.unpack_from('<3f', RES, p + 48),
struct.unpack_from('<4f', RES, p + 60)))
tally[RESOURCES[target]['name']] += 1
p += (length + 3) & ~3 # BT pads records; RP does not
return drawn, total, tally
# ---------------------------------------------------------------- palette
pal = open(os.path.join(ROOT, 'content', 'GAUGE', 'btspal.pcc'), 'rb').read()
if len(pal) < 769 or pal[-769] != 0x0C:
sys.exit('btspal.pcc has no VGA palette where PCX keeps one')
base = len(pal) - 768
PALETTE = [tuple(pal[base + i * 3:base + i * 3 + 3]) for i in range(256)]
def plan(drawn, box=520):
from PIL import Image, ImageDraw
xs = [d[1][0] for d in drawn]
zs = [d[1][2] for d in drawn]
mpp = max((max(xs) - min(xs)) / box, (max(zs) - min(zs)) / box, 1e-4) * 1.15
pad = 10
iw = max(24, int((max(xs) - min(xs)) / mpp) + pad * 2)
ih = max(24, int((max(zs) - min(zs)) / mpp) + pad * 2)
cx, cz = (min(xs) + max(xs)) / 2, (min(zs) + max(zs)) / 2
img = Image.new('RGB', (iw, ih), PALETTE[0])
dr = ImageDraw.Draw(img)
cache = {}
for gid, pos, q in drawn:
if gid not in cache:
cache[gid] = gauge_image(RESOURCES[gid]['off'])
verts, lods = cache[gid]
lod = 0
while lod < len(lods) and mpp > lods[lod][0]:
lod += 1
if lod >= len(lods):
continue
for colour, attrs, idx in lods[lod][1]:
pts = []
for k in idx:
if not (0 <= k < len(verts)):
continue
v = verts[k]
if attrs & 1: # attributeUnscaled
v = (v[0] * mpp, v[1] * mpp, v[2] * mpp)
g = rotate(q, v)
pts.append((iw / 2 + ((g[0] + pos[0]) - cx) / mpp,
ih / 2 - ((g[2] + pos[2]) - cz) / mpp))
if len(pts) > 1:
dr.line(pts, fill=PALETTE[colour], width=1)
buf = io.BytesIO()
img.convert('P', palette=Image.ADAPTIVE, colors=64).save(
buf, 'PNG', optimize=True)
return ('data:image/png;base64,' + base64.b64encode(buf.getvalue()).decode(),
iw, ih, max(xs) - min(xs), max(zs) - min(zs))
# The eight the game offers, from tools/mapscan.py.
PLAYABLE = ['cavern', 'grass', 'rav', 'polar3', 'polar4', 'arena1', 'arena2',
'dbase']
maps = []
for rid, r in RESOURCES.items():
if r['t'] != MAP_STREAM:
continue
drawn, total, tally = placements(rid)
art = plan(drawn) if drawn else None
maps.append({'name': r['name'], 'rid': rid, 'total': total,
'drawn': len(drawn), 'tally': tally, 'art': art,
'playable': r['name'] in PLAYABLE})
print(' %-10s %4d records, %4d drawn' % (r['name'], total, len(drawn)),
file=sys.stderr)
maps.sort(key=lambda m: (not m['playable'], -m['drawn']))
cards = []
for m in maps:
if m['art']:
uri, w, h, ex, ez = m['art']
art = ('<img class="plan" src="%s" width="%d" height="%d" alt="%s from '
'above" loading="lazy">' % (uri, w, h, html.escape(m['name'])))
extent = '%d &times; %d' % (round(ex), round(ez))
else:
art = '<p class="plan plan--none">Nothing this stream places is drawn</p>'
extent = '&mdash;'
top = ', '.join('%s &times;%d' % (html.escape(n), c)
for n, c in m['tally'].most_common(5)) or '&mdash;'
cards.append("""
<article class="map" data-tags="{tags}" data-search="{search}">
<figure class="fig">{art}</figure>
<h2>{name}</h2>
<p class="tag">{label}</p>
<dl class="stat">
<div><dt>Placements</dt><dd>{total}</dd></div>
<div><dt>Drawn</dt><dd>{drawn}</dd></div>
<div><dt>Extent</dt><dd>{extent}</dd></div>
</dl>
<p class="models">{top}</p>
</article>""".format(
tags='playable' if m['playable'] else 'aux',
search=html.escape(m['name'].lower()), art=art,
name=html.escape(m['name']),
label='OFFERED IN THE GAME' if m['playable'] else 'SUPPORTING STREAM',
total=m['total'], drawn=m['drawn'], extent=extent, top=top))
PAGE = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
'maps.tpl'), encoding='utf-8').read()
os.makedirs(os.path.dirname(OUT), exist_ok=True)
open(OUT, 'w', encoding='utf-8').write(PAGE.format(
cards=''.join(cards), n=len(maps),
playable=sum(1 for m in maps if m['playable'])))
print('wrote %s (%d maps)' % (OUT, len(maps)), file=sys.stderr)
+147
View File
@@ -0,0 +1,147 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BattleTech 4.11 &mdash; every map from above</title>
<style>
*, *::before, *::after {{ box-sizing: border-box; }}
:root {{
--ground: #0e0f11; --panel: #16181b; --rule: #2a2e33;
--ink: #e8e6e2; --ink-mid: #a3a8ae; --ink-low: #71777e;
--hot: #f39313; /* palette 6, the map's own amber */
--mono: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
--sans: ui-sans-serif, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}}
@media (prefers-color-scheme: light) {{
:root {{
--ground: #f4f2ee; --panel: #fbfaf8; --rule: #ddd8d0;
--ink: #14161a; --ink-mid: #565c63; --ink-low: #7d838a; --hot: #a35f00;
}}
}}
body {{
margin: 0; background: var(--ground); color: var(--ink);
font: 400 15px/1.6 var(--sans);
}}
.wrap {{ max-width: 78rem; margin: 0 auto; padding: 0 clamp(1rem, 4vw, 2.5rem); }}
header.top {{ border-bottom: 1px solid var(--rule); padding: clamp(2.5rem,6vw,4rem) 0 1.75rem; }}
.eyebrow {{
font: 500 12px/1 var(--mono); letter-spacing: .2em; text-transform: uppercase;
color: var(--ink-low); margin: 0 0 1rem;
}}
h1 {{
font: 700 clamp(1.9rem,1.4rem+2.2vw,3rem)/1.05 var(--mono);
letter-spacing: -.02em; text-transform: uppercase; margin: 0 0 .9rem;
}}
h1 em {{ font-style: normal; color: var(--hot); }}
.lede {{ max-width: 62ch; color: var(--ink-mid); margin: 0; font-size: 1.05rem; }}
.lede strong {{ color: var(--ink); font-weight: 600; }}
.bar {{ border-bottom: 1px solid var(--rule); padding: .8rem 0; }}
.seg {{ display: inline-flex; border: 1px solid var(--rule); }}
.seg button {{
font: 500 12px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase;
background: none; border: 0; color: var(--ink-mid); padding: .55rem .9rem; cursor: pointer;
}}
.seg button[aria-pressed="true"] {{ background: var(--hot); color: var(--ground); }}
.grid {{
display: grid; gap: 1px; background: var(--rule);
border: 1px solid var(--rule); margin: 1.75rem 0 3rem;
grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
}}
.map {{ background: var(--panel); padding: 1.1rem 1.25rem 1.35rem; }}
.map[hidden] {{ display: none; }}
.fig {{ margin: 0 0 .9rem; display: flex; justify-content: center; }}
/* The game's own palette on the display's own black, so it reads the same
in either theme. */
.plan {{
display: block; max-width: 100%; height: auto; max-height: 20rem;
background: #000; border: 1px solid var(--rule); image-rendering: pixelated;
}}
.plan--none {{
margin: 0; padding: 2rem 1rem; text-align: center; color: var(--ink-low);
font: 400 12px var(--mono); border: 1px dashed var(--rule); width: 100%;
}}
h2 {{ font: 700 1.3rem/1.1 var(--mono); margin: 0 0 .25rem; text-transform: uppercase; }}
.tag {{
font: 500 11px/1 var(--mono); letter-spacing: .14em; color: var(--ink-low);
margin: 0 0 .8rem;
}}
.stat {{ margin: 0; display: grid; grid-template-columns: 1fr auto; gap: .2rem .8rem; }}
.stat div {{ display: contents; }}
.stat dt {{ font: 500 12px var(--mono); letter-spacing: .08em; color: var(--ink-low); }}
.stat dd {{
margin: 0; text-align: right; font: 500 12px var(--mono); color: var(--ink);
font-variant-numeric: tabular-nums;
}}
.models {{
margin: .75rem 0 0; font: 400 11px/1.6 var(--mono); color: var(--ink-low);
word-break: break-word;
}}
.foot {{
border-top: 1px solid var(--rule); padding: 1.5rem 0 4rem; max-width: 76ch;
color: var(--ink-low); font-size: 13px;
}}
.foot code {{ font-family: var(--mono); color: var(--ink-mid); }}
</style>
</head>
<body>
<header class="top">
<div class="wrap">
<p class="eyebrow">BattleTech 4.11 &middot; drawn from BTL4.RES</p>
<h1>Every map,<br><em>seen from above</em></h1>
<p class="lede">Each plan is drawn the way the cockpit draws it: every placement in
the map's own stream, the outline its model carries, in the game's own palette.
<strong>Nothing here is traced or redrawn</strong> &mdash; the buttes are circles
because the game draws them as circles.</p>
</div>
</header>
<div class="bar">
<div class="wrap">
<div class="seg" role="group" aria-label="Filter">
<button type="button" data-filter="all" aria-pressed="true">All {n}</button>
<button type="button" data-filter="playable" aria-pressed="false">Playable {playable}</button>
<button type="button" data-filter="aux" aria-pressed="false">Supporting</button>
</div>
</div>
</div>
<main class="wrap">
<div class="grid">{cards}</div>
<p class="foot">A map stream is a run of <code>Entity::MakeMessage</code> records. Each
carries its own length at +0, its class at +28, the resource it names at +40, its
instance flags at +44 and its origin at +48 &mdash; and BT pads each record to the
next four bytes, which Red Planet does not. The resource named is a <em>Model List</em>;
the outline the display draws is the GaugeImage inside that list, so the join is direct
rather than by name. A model with no GaugeImage is not drawn, which is why several
streams place far more than they draw.</p>
<p class="foot">Colours are palette indices resolved through <code>btspal.pcc</code>, the
palette <code>L4GAUGE.CFG</code> configures the secondary port with:
<code>configure(0,sec,270,0x003F,clut0,rgb,btspal.pcc)</code>. Unlike Red Planet,
every primitive here carries an explicit colour &mdash; nothing falls back to a display
default. Seen from above, +X runs right and +Z up.</p>
<p class="foot">Two of the eight playable maps, <code>arena1</code> and
<code>arena2</code>, place almost nothing: a sky and a pair of records too short to
carry an origin. Their arena geometry is <code>arenall</code>, which is a stream of its
own. <code>adrop</code> and <code>pol4sfx</code> are likewise supporting streams
&mdash; drop points and wind emitters &mdash; rather than places you fight in.</p>
</main>
<script>
const cards = [...document.querySelectorAll('.map')];
for (const b of document.querySelectorAll('[data-filter]')) {{
b.addEventListener('click', () => {{
const f = b.dataset.filter;
for (const o of document.querySelectorAll('[data-filter]'))
o.setAttribute('aria-pressed', String(o === b));
for (const c of cards)
c.hidden = f !== 'all' && !c.dataset.tags.split(' ').includes(f);
}});
}}
</script>
</body>
</html>