Files
BT411/tools/build_maps.py
T
CydandClaude Opus 4.8 1f54592595 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>
2026-08-12 21:06:15 -05:00

230 lines
9.0 KiB
Python

"""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)