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:
+290
File diff suppressed because one or more lines are too long
@@ -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 × %d' % (round(ex), round(ez))
|
||||
else:
|
||||
art = '<p class="plan plan--none">Nothing this stream places is drawn</p>'
|
||||
extent = '—'
|
||||
top = ', '.join('%s ×%d' % (html.escape(n), c)
|
||||
for n, c in m['tally'].most_common(5)) or '—'
|
||||
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
@@ -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 — 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 · 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> — 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 — 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 — 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
|
||||
— drop points and wind emitters — 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>
|
||||
Reference in New Issue
Block a user