SE2Revival/mods/build.py

150 lines
7.8 KiB
Python

"""Build the ASI plugins with the modern MSVC x86 toolset (static CRT, no redistributable needed).
usage: python -I mods/build.py [--release | --only NAME[,NAME...]] [--clang WINSYSROOT] [--deploy DIR]
Outputs build/mods/<name>.asi (+ .ini copied alongside). --deploy copies them into DIR/scripts/.
--release builds only the plugins that ship (not the ds2dev test harness). --only builds the named plugins
(e.g. --only DS2Toolkit,BWCompat: what tools/package_toolkit.py ships).
--clang builds with clang-cl + lld-link instead of cl/link (any host, e.g. Linux CI), with the same flags,
against the MSVC CRT and Windows SDK in WINSYSROOT (the /winsysroot layout: VC/Tools/MSVC/<ver>/ and
Windows Kits/10/; tools/ci_build.py fetches it with xwin). CLANG_CL / LLD_LINK override the tool paths.
The decomp itself uses VS.NET 2003 (AGENTS.md rule 7); mods are free to use a current compiler.
"""
import glob, os, shutil, subprocess, sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, 'build', 'mods')
# source folder -> plugin name (build/mods/<name>.asi)
PLUGINS = {'multiplayer': 'DS2Multiplayer', 'widescreen': 'DS2Widescreen', 'fixes': 'DS2Fixes',
'brokenworld': 'DS2BrokenWorld', 'ds2dev': 'ds2dev', 'toolkit': 'DS2Toolkit',
'bwcompat': 'BWCompat', 'modloader': 'DS2ModLoader'} # BWCompat: a FuBi extension DLL for Siege Editor 2 (deployed as BWCompat.ds2dll)
RELEASE = ['DS2Multiplayer', 'DS2Widescreen', 'DS2Fixes', 'DS2BrokenWorld', 'DS2ModLoader'] # what tools/package.py ships
# Compiler flags and libraries shared by both toolchains (cl and clang-cl take the same syntax).
CFLAGS = '/nologo /std:c++17 /O2 /Oy- /MT /EHsc /W4 /permissive- /DWIN32_LEAN_AND_MEAN /DNOMINMAX'.split()
LIBS = 'user32.lib kernel32.lib advapi32.lib ws2_32.lib gdi32.lib'.split()
# clang-only warnings about deliberate MSVC idioms that cl accepts silently: function <-> data pointer casts
# (hooks), FARPROC -> typed function pointer casts, '= {sizeof(T)}' zero-initialised structs.
CLANG_QUIET = ['-Wno-microsoft-cast', '-Wno-cast-function-type-mismatch', '-Wno-missing-field-initializers']
VSWHERE_DIR = r'C:\Program Files (x86)\Microsoft Visual Studio\Installer'
def vcvars32():
path = subprocess.check_output([os.path.join(VSWHERE_DIR, 'vswhere.exe'), '-latest', '-products', '*',
'-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
'-property', 'installationPath'], text=True).strip()
return os.path.join(path, 'VC', 'Auxiliary', 'Build', 'vcvars32.bat')
def build(folder, name):
srcdir = os.path.join(ROOT, 'mods', folder)
srcs = ' '.join('"%s"' % s for s in sorted(glob.glob(os.path.join(srcdir, '*.cpp'))))
obj = os.path.join(OUT, 'obj', name) + '/' # not os.sep: a trailing \" escapes the quote for cl
os.makedirs(obj, exist_ok=True)
out = os.path.join(OUT, name + '.asi')
defs = sorted(glob.glob(os.path.join(srcdir, '*.def'))) # module-definition file: exports with decorated C++ names
def_arg = ' /DEF:"%s"' % defs[0] if defs else ''
# A .bat keeps cmd.exe from mangling the quoted paths (the repo path contains spaces).
bat = os.path.join(OUT, 'obj', 'build_%s.bat' % name)
with open(bat, 'w') as f:
f.write('@echo off\n')
f.write('call "%s" >nul\n' % vcvars32())
f.write('cl %s /LD /Fo"%s" %s /link /OUT:"%s" /PDB:"%s" /DEBUG /PDBALTPATH:%%%%_PDB%%%%%s %s\n'
% (' '.join(CFLAGS), obj, srcs, out, os.path.join(OUT, name + '.pdb'), def_arg, ' '.join(LIBS)))
f.write('exit /b %errorlevel%\n')
env = dict(os.environ)
# vcvars32.bat breaks on quoted PATH entries (the DirectX SDK installer adds one); unquote them here only.
entries = [e.strip().strip('"') for e in env.get('PATH', '').split(';') if e.strip()]
env['PATH'] = ';'.join([VSWHERE_DIR] + entries)
r = subprocess.run(['cmd.exe', '/d', '/c', bat], capture_output=True, text=True, env=env)
print(r.stdout[-4000:], r.stderr[-2000:])
if r.returncode:
sys.exit('build of %s failed' % name)
return finish(srcdir, out)
def finish(srcdir, out):
for ini in glob.glob(os.path.join(srcdir, '*.ini')):
shutil.copy(ini, OUT)
print('built', out)
return out
def clang_tools():
"""(compiler command, linker command): clang-cl, or clang in cl mode, and lld-link."""
cc = os.environ.get('CLANG_CL')
if cc:
cc = [cc]
elif shutil.which('clang-cl'):
cc = [shutil.which('clang-cl')]
elif shutil.which('clang'):
cc = [shutil.which('clang'), '--driver-mode=cl'] # what the clang-cl name selects
else:
sys.exit('clang-cl not found (set CLANG_CL)')
ld = os.environ.get('LLD_LINK') or shutil.which('lld-link')
if not ld:
sys.exit('lld-link not found (set LLD_LINK)')
return cc, ld
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True)
out = (r.stdout + r.stderr).strip()
if out:
print(out[-6000:])
return r.returncode
def build_clang(folder, name, sysroot):
"""The same build as build() with clang-cl + lld-link (cl /LD = compile, then link /DLL). '--' before the
source: on Linux an absolute path (/home/...) would otherwise be read as a cl option."""
cc, ld = clang_tools()
srcdir = os.path.join(ROOT, 'mods', folder)
objdir = os.path.join(OUT, 'obj-clang', name)
os.makedirs(objdir, exist_ok=True)
objs = []
for src in sorted(glob.glob(os.path.join(srcdir, '*.cpp'))):
obj = os.path.join(objdir, os.path.splitext(os.path.basename(src))[0] + '.obj')
if run(cc + ['--target=i686-pc-windows-msvc', '/winsysroot', sysroot] + CFLAGS + CLANG_QUIET +
['/c', '/Fo' + obj, '--', src]):
sys.exit('compile of %s failed' % src)
objs.append(obj)
out = os.path.join(OUT, name + '.asi')
defs = sorted(glob.glob(os.path.join(srcdir, '*.def'))) # as build(): exports from the module-definition file
if run([ld, '/nologo', '/dll', '/machine:x86', '/winsysroot:' + sysroot, '/out:' + out,
'/pdb:' + os.path.join(OUT, name + '.pdb'), '/debug', '/pdbaltpath:%_PDB%',
'/ignore:4099', '/Brepro'] + ['/def:' + d for d in defs[:1]] + objs + LIBS):
# 4099: the CRT libs name PDBs xwin does not ship (no debug info for CRT code). /Brepro: the PE timestamp
# and PDB id are a content hash instead of the time, so the same sources, toolchain and build paths
# (e.g. two CI runs) give byte-identical plugins.
sys.exit('link of %s failed' % name)
return finish(srcdir, out)
def main():
a = sys.argv[1:]
sysroot = os.path.abspath(a[a.index('--clang') + 1]) if '--clang' in a else None
only = a[a.index('--only') + 1].split(',') if '--only' in a else None
if only and set(only) - set(PLUGINS.values()):
sys.exit('unknown plugin(s): %s' % ', '.join(sorted(set(only) - set(PLUGINS.values()))))
built = []
for folder, name in PLUGINS.items():
if ('--release' in a and name not in RELEASE) or (only and name not in only):
continue
if os.path.isdir(os.path.join(ROOT, 'mods', folder)): # the source release has no ds2dev (test harness)
if sysroot:
build_clang(folder, name, sysroot)
else:
build(folder, name)
built.append(name)
if '--deploy' in sys.argv:
dst = os.path.join(sys.argv[sys.argv.index('--deploy') + 1], 'scripts')
os.makedirs(dst, exist_ok=True)
for name in built: # only this build's plugins (never a stale one left in build/mods)
shutil.copy(os.path.join(OUT, name + '.asi'), dst)
for f in glob.glob(os.path.join(OUT, '*.ini')):
if not os.path.exists(os.path.join(dst, os.path.basename(f))):
shutil.copy(f, dst) # keep the user's edited ini
print('deployed to', dst)
if __name__ == '__main__':
main()