Files
BT411/tools/mkdist.py
T
arcattackandClaude Opus 5 c4202d92ca zip-doc audit: last stale key claim + a keylight-stub warning at cut time
Full audit of every user-facing artifact in a freshly cut zip (33 checks, all
passing -- README substitutions/controls/migration story, CONTROLS.txt ASCII
flatten, CONTROLS.html loader + wrap, launchers byte-identical, exe carries the
migration):

  * README's intro still said 'Press V any time to toggle the external camera'
    -- the one key claim outside the rewritten controls block, stale for every
    fresh/migrated install since the board took V.  Backtick now.
  * mkdist warns at cut time when the exe carries the KEYLIGHT STUB (build
    machine's SDK < 10.0.22000), because the README promises the RGB feature
    -- nobody should ship a stub build believing that promise ships with it.

The audit zip itself was deleted after verification: its version stamp predates
today's doc commits, and a release cut should rebuild the exe at final HEAD
first (the 554 procedure).

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

202 lines
9.3 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"]
# HONESTY CHECK (2026-07-26): the RGB keylight compiles to a dormant stub
# on machines whose Windows SDK is too old (< 10.0.22000 -- see
# CMakeLists), and the README promises the feature. Say so at cut time,
# so nobody ships a stub build believing the promise.
try:
with open(exes[0], "rb") as f:
if b"built without Dynamic Lighting support" in f.read():
print(" *** NOTE: this exe carries the KEYLIGHT STUB (build "
"machine's SDK too old). The README's RGB-keyboard "
"feature will be dormant in this zip -- build on a "
"machine with SDK >= 10.0.22000 to ship the real "
"mirror. ***")
except OSError:
pass
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()