Files
BT411/tools/mkdist.py
T
CydandClaude Opus 5 5f88a4eeb2 The keyboard becomes the button board (RP412 bindings port) + a controls page
Ported RP412's bindings design and made it the DEFAULT: the letter and number
rows are the MFD button banks laid out WHERE THEY SIT ON THE PANEL, flight
moves to the numpad so the board stays free, F1-F12 are the map's two columns,
and G/B stay unbound as the physical gap between the lower clusters.  Expressed
in BT's OWN grammar (slew/deflect/set, the Turn channel) -- bindings.txt is a
documented compatibility surface and was not touched.

Coverage: 61 of 72 addresses on the keyboard, and the 11 absent ones are
exactly those the pod never wired (0x16/0x17/0x1E/0x1F column gaps, 0x38-0x3E
intercom/door).  All 72 stay clickable on the panel.

It lands on BT's addresses unreasonably well: 1-4 + QWER are the ENTIRE coolant
system (Condensers 1-6, flush, balance), F6/F7 the display and control-mode
cycles, F9-F12 Generators A-D, F4 the crouch button reconstructed earlier
today.

THE DELIBERATE COST.  A bound key is removed from the authentic 1995
typed-hotkey channel so it cannot double-dispatch, and this board binds nearly
everything -- so 5 (Quad page), z (Eng1), t/y/u/i/o (pilot select) and +/-
(target zoom) are given up.  Unbind a key to get its 1995 meaning back.
Documented in the file header, the markdown and the page.

NEW RULE: A BINDINGS ROW WINS OVER A BUILT-IN CONVENIENCE KEY
(PadRIO::KeyHasBinding).  V and J/K/L are board buttons now, and those polls
read GetAsyncKeyState directly -- without this they would have fired BOTH the
button and the built-in.  View toggle lives on BACKTICK alone.

CONTROLS.MAP rewritten to mirror the board so glass/pod/dev still feel
identical (the 2026-07-21 settlement): 90 bindings, 0 parse complaints.

HAT LABELS CORRECTED [T1].  INPUT_PATH_AUDIT flagged 0x41-0x44 and was right.
Settled from the streamed mapping (BT_CTRLMAP_LOG): elem 66 -> subsys 17
attrID 14, i.e. 0x42 is the TORSO subsystem, not a look; 0x44/0x43/0x41 are the
mapper's LookLeft/LookRight/LookBehind (attrID 10/11/12).  The .RES has no
"TORSO CENTER" string -- its names are LookBehind/Down/Forward/Left/Right -- so
the audit's wording was loose but its substance correct.  Swept L4GLASSWIN,
L4PADPANEL and L4VB16.

docs/CONTROLS.html: interactive keyboard (hover a key for its address and
meaning, MFD clusters outlined), pad diagram, panel map, the under-glass
press-target figure, radar placement thumbnails and the environ.ini table --
modelled on RP412's page, rewritten for BT's addresses and hazards.  mkdist
wraps the fragment in a doctype/charset shell and ships it beside CONTROLS.txt.

Verified: bindings.txt regenerates and loads (74 keys, 0 errors); CONTROLS.MAP
90 bindings, 0 errors; 72/72 panel addresses still dispatch; surround /
exploded / dock / pod / dev boot and simulate with zero faults.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 03:23:27 -05:00

186 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""mkdist.py -- build the tester-distribution zip.
Layout (matches what the player bats expect):
BT411-<date>-<hash>/
play_solo.bat join.bat join_lan.bat (from players/)
build/Release/btl4.exe (pod build -- fallback)
build-glass/Release/btl4.exe (preferred: clickable cockpit)
content/... (git-TRACKED files only --
the working tree carries dev
junk that must not ship)
The bats prefer build-glass when present and set BT_PLATFORM=glass with it,
so testers boot the glass experience (PadRIO clicks, trigger config,
bindings.txt keymap) with the pod build as fallback.
Usage: python tools/mkdist.py [outdir] (default outdir = dist/)
"""
import os
import re
import subprocess
import sys
import time
import zipfile
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def main():
os.chdir(REPO)
outdir = sys.argv[1] if len(sys.argv) > 1 else os.path.join(REPO, "dist")
os.makedirs(outdir, exist_ok=True)
# Name by the repo version convention (BT411_4.11.NNN, tools/btversion.cmake:
# build = commit count). Falls back to date+hash if the header is absent.
name = None
for h in ("build/btversion.h", "build-glass/btversion.h"):
if os.path.exists(h):
m = re.search(r'BT_VERSION_STRING\s+"([^"]+)"', open(h).read())
if m:
name = "BT411_" + m.group(1)
break
if name is None:
githash = subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"]).decode().strip()
name = "BT411-%s-%s" % (time.strftime("%Y-%m-%d"), githash)
tracked = subprocess.check_output(
["git", "ls-files", "-z", "content/"]).decode().split("\0")
tracked = [t for t in tracked if t]
# ONE unified build (2026-07-21): every layer lives in the single exe.
# THE LICENSE GATE: BT_STEAM is compile-time (Steamworks SDK terms bar a
# public release with Steam inside). Read the configure's answer from
# the CMake cache; a steam-OFF zip must carry neither play_steam.bat nor
# steam_api.dll (a stale DLL can linger from an earlier ON build).
steam_on = False
cache = ""
try:
cache = open("build/CMakeCache.txt", encoding="utf-8", errors="ignore").read()
steam_on = "BT_STEAM:BOOL=ON" in cache
except OSError:
pass
print(" BT_STEAM=%s (from build/CMakeCache.txt)" % ("ON" if steam_on else "OFF"))
if not steam_on:
name += "-nosteam" # distinct name: never clobbers the dev-flavor zip
# TESTER-BUILD EXPIRY (BT_EXPIRE, default ON): a zip cut from an
# expire-OFF build never dies in the field -- mark it loudly so it is a
# deliberate act, not an accident.
expire_on = "BT_EXPIRE:BOOL=ON" in cache if cache else True
print(" BT_EXPIRE=%s (from build/CMakeCache.txt)" % ("ON" if expire_on else "OFF"))
if not expire_on:
name += "-noexpire"
print(" WARNING: this zip NEVER EXPIRES -- intended for distribution?")
zpath = os.path.join(outdir, name + ".zip")
exes = ["build/Release/btl4.exe"]
bats = ["players/play_solo.bat", "players/join.bat", "players/join_lan.bat",
"players/joyconfig.bat"]
if steam_on:
bats.append("players/play_steam.bat")
for p in exes + bats:
if not os.path.exists(p):
sys.exit("MISSING: %s -- build/refresh it first" % p)
# Runtime DLLs living next to each exe (OpenAL32.dll etc.) -- the exe
# fails to load without them (caught by the clean-extract boot test).
for exe in list(exes):
d = os.path.dirname(exe)
for f in sorted(os.listdir(d)):
if not f.lower().endswith(".dll"):
continue
if f.lower() == "steam_api.dll" and not steam_on:
print(" (steam OFF: leaving stale %s out of the zip)" % f)
continue
exes.append(d + "/" + f)
# System runtime DLLs the exe needs on a bare tester machine (32-bit
# d3dx9 + MSVC runtimes -- carried in redist/, shipped next to the exe;
# the first zips included them and machines without the redists fail
# to load the exe otherwise).
redist = ["redist/" + f for f in sorted(os.listdir("redist"))
if f.lower().endswith(".dll")] if os.path.isdir("redist") else []
total = 0
with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
# versioned tester README at the zip root
if os.path.exists("players/README.txt"):
readme = open("players/README.txt", encoding="utf-8").read()
readme = readme.replace("{VERSION}", name.replace("BT411_", ""))
lines = readme.splitlines(True)
if steam_on: # keep steam lines, drop marker
lines = [l.replace("{STEAM}", "") for l in lines]
else: # steam-free zip: strip them
lines = [l for l in lines if "{STEAM}" not in l]
if expire_on: # same treatment for expiry note
lines = [l.replace("{EXPIRE}", "") for l in lines]
else:
lines = [l for l in lines if "{EXPIRE}" not in l]
readme = "".join(lines)
z.writestr(name + "/README.txt", readme)
# The full controls reference travels with the game, so players get the
# 72-button panel map without the repo. Flattened to ASCII so it reads
# correctly in Notepad -- the markdown source keeps its typography.
if os.path.exists("docs/CONTROLS.md"):
controls = open("docs/CONTROLS.md", encoding="utf-8").read()
for bad, good in ((u"—", "-"), (u"", "-"), (u"", "'"),
(u"", "'"), (u"“", '"'), (u"”", '"'),
(u"×", "x"), (u"→", "->"), (u"…", "..."),
(u"↑", "Up"), (u"↓", "Down"),
(u"←", "Left"), (u"→", "Right"),
(u"⚠", "!"), (u"·", "-")):
controls = controls.replace(bad, good)
controls = controls.encode("ascii", "replace").decode("ascii")
z.writestr(name + "/CONTROLS.txt", controls)
print(" controls reference -> CONTROLS.txt")
# The same map as a page, for anyone who would rather look at the
# diagrams than read them. The source is a fragment (no doctype or
# <head>), so wrap it: without a doctype the browser drops into quirks
# mode, and without a charset the typography arrives as mojibake.
if os.path.exists("docs/CONTROLS.html"):
page = open("docs/CONTROLS.html", encoding="utf-8").read()
page = ('<!doctype html>\n<html lang="en">\n<head>\n'
'<meta charset="utf-8">\n'
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
'</head>\n<body>\n' + page + '\n</body>\n</html>\n')
z.writestr(name + "/CONTROLS.html", page.encode("utf-8"))
print(" controls page -> CONTROLS.html")
for p in bats: # bats at the zip ROOT (next to
arc = os.path.basename(p) # content\ + build\, per the guard)
z.write(p, name + "/" + arc)
total += os.path.getsize(p)
for p in exes:
z.write(p, name + "/" + p)
total += os.path.getsize(p)
for p in redist: # next to the exe
z.write(p, name + "/build/Release/" + os.path.basename(p))
total += os.path.getsize(p)
for p in tracked:
if not os.path.exists(p):
print(" (skipping tracked-but-absent: %s)" % p)
continue
z.write(p, name + "/" + p)
total += os.path.getsize(p)
zsz = os.path.getsize(zpath)
print("wrote %s" % zpath)
print(" %d files, %.1f MB raw -> %.1f MB zipped"
% (len(bats) + len(exes) + len(tracked), total / 1048576.0,
zsz / 1048576.0))
# Crash-forensics: archive this build's PDB next to the zip (operator-side
# only, never shipped) so the [crash] btl4+0xNNNN offsets in player logs
# stay resolvable per distributed version.
pdb = "build/Release/btl4.pdb"
if os.path.exists(pdb):
import shutil
pdb_dest = zpath[:-4] + ".pdb"
shutil.copyfile(pdb, pdb_dest)
print(" symbols archived: %s" % pdb_dest)
if __name__ == "__main__":
main()