150 lines
7.8 KiB
Python
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()
|