Files
BT411/tools/mkdist.py
T
Joe DiPrimaandClaude Fable 5 2cbf9cd88f mkdist: archive the .map beside the .pdb, and stop shipping d3dx9_43.dll twice
Symbols only matter if they survive the build that produced them, so archive
BOTH per version, not just the PDB. The .map is the one that gets used in
practice: tools/symcrash.py resolves btl4+0xNNNN from plain text with no
debugger installed, which is the normal state of an operator box -- this
machine has no cdb either. Warns loudly if either file is missing, since that
silently means a crash in that build can never be read.

Also: d3dx9_43.dll was written into the zip twice. It exists both next to the
exe and in redist/, and both paths land at build/Release/<name> in the archive,
so the entry was duplicated -- about 2 MB wasted and a duplicate-name warning
from Python's zipfile that some extractors also complain about. Redist entries
now skip anything already taken from beside the exe.

Neither the pdb nor the map can reach a zip: .gitignore covers *.pdb and dist/,
and the archive only ever picks up .dll from those directories.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 20:14:22 -05:00

201 lines
9.5 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).
# ...but SKIP any that was already taken from beside the exe: both land at
# build/Release/<name> in the archive, so shipping both wrote a duplicate
# zip entry (d3dx9_43.dll, ~2 MB wasted, and some extractors warn).
beside_exe = {os.path.basename(p).lower() for p in exes}
redist = ["redist/" + f for f in sorted(os.listdir("redist"))
if f.lower().endswith(".dll")
and f.lower() not in beside_exe] 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 symbols next to the zip (operator-
# side only, never shipped) so the [crash] btl4+0xNNNN offsets in player
# logs stay resolvable per distributed version. BOTH files:
# .pdb full symbols, for cdb/WinDbg
# .map plain text -- tools/symcrash.py resolves an offset with NO
# debugger installed, which is the common case on an operator box.
# Keep them: once a version is in players' hands its symbols are the only
# way to read a crash it produces.
import shutil
for src, ext in (("build/Release/btl4.pdb", ".pdb"),
("build/Release/btl4.map", ".map")):
if os.path.exists(src):
dest = zpath[:-4] + ext
shutil.copyfile(src, dest)
print(" symbols archived: %s" % dest)
else:
print(" WARNING: %s missing -- a crash in this build will NOT be "
"resolvable (need /Zi + /DEBUG + /MAP)" % src)
if __name__ == "__main__":
main()