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>
230 lines
9.0 KiB
Python
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 × %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)
|