226 lines
12 KiB
Python
226 lines
12 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', 'graphics': 'DS2Graphics', 'fixes': 'DS2Fixes',
|
|
'brokenworld': 'DS2BrokenWorld', 'ds2dev': 'ds2dev', 'toolkit': 'DS2Toolkit',
|
|
'bwcompat': 'BWCompat', 'modloader': 'DS2ModLoader', 'revival_loader': 'DS2Revival', 'controller': 'DS2Controller', 'tactics': 'DS2Tactics', 'vtxprobe': 'DS2VtxProbe', 'leakprobe': 'DS2LeakProbe', 'refprobe': 'DS2RefProbe', 'bwvreg': 'BWVregOnly'} # BWCompat: a FuBi extension DLL for Siege Editor 2 (deployed as BWCompat.ds2dll)
|
|
RELEASE = ['DS2Multiplayer', 'DS2Widescreen', 'DS2Graphics', 'DS2Fixes', 'DS2BrokenWorld', 'DS2ModLoader'] # the plugins of scripts\ in the zip (tools/package.py)
|
|
BW_LOADER = 'DS2Revival' # the only plugin in "Broken World\scripts" of the zip: it loads RELEASE from the main game's scripts folder
|
|
RELEASE_BUILD = RELEASE + [BW_LOADER] # what --release builds
|
|
# 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'
|
|
# DS2Controller links SDL3 statically when tools/third_party/sdl3/build.py has built it (build/sdl3, gitignored);
|
|
# without it the plugin is built XInput-only (-DPAD_SDL is what switches the SDL backend on).
|
|
SDL_DIR = os.path.join(ROOT, 'build', 'sdl3')
|
|
SDL_LIBS = 'winmm.lib imm32.lib ole32.lib oleaut32.lib setupapi.lib version.lib cfgmgr32.lib shell32.lib'.split()
|
|
|
|
|
|
def extras(folder):
|
|
"""(extra compiler flags, extra libraries) of one plugin."""
|
|
lib = os.path.join(SDL_DIR, 'lib', 'SDL3-static.lib')
|
|
if folder == 'controller' and os.path.exists(lib) and '--no-sdl' not in sys.argv:
|
|
return ['/DPAD_SDL', '/I' + os.path.join(SDL_DIR, 'include')], [lib] + SDL_LIBS
|
|
return [], []
|
|
|
|
|
|
def quote(arg):
|
|
return '"%s"' % arg if ' ' in arg else arg
|
|
|
|
|
|
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 ''
|
|
xflags, xlibs = extras(folder)
|
|
# 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 + [quote(x) for x in xflags]), obj, srcs, out, os.path.join(OUT, name + '.pdb'), def_arg,
|
|
' '.join(LIBS + [quote(x) for x in xlibs])))
|
|
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()
|
|
xflags, xlibs = extras(folder)
|
|
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 + xflags + 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 + xlibs):
|
|
# 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)
|
|
|
|
|
|
INI_TEMPLATE = os.path.join(ROOT, 'mods', 'common', 'DS2Revival.ini')
|
|
INI_HEADER = os.path.join(ROOT, 'mods', 'common', 'revival_ini_text.h')
|
|
INI_HISTORY = os.path.join(ROOT, 'mods', 'common', 'ini_comment_history.txt')
|
|
|
|
|
|
def ini_header_text():
|
|
"""C++ source of the default settings text: mods/common/DS2Revival.ini, one string literal per line. The plugins
|
|
write it as DS2Revival.ini next to DungeonSiege2.exe on their first start (mods/common/settings_file.h); the zip does not hold it.
|
|
'?' is escaped so that no trigraph can form; tools/test_settings_ini.py reads the literals back and compares."""
|
|
text = open(INI_TEMPLATE, 'rb').read().decode('ascii').replace('\r\n', '\n')
|
|
out = ['// Generated by mods/build.py from mods/common/DS2Revival.ini - do not edit (not in git).',
|
|
'#pragma once', 'namespace ds2 {', 'inline constexpr char kRevivalIniText[] =']
|
|
for line in text.split('\n')[:-1] if text.endswith('\n') else text.split('\n'):
|
|
out.append(' "%s\\n"' % line.replace('\\', '\\\\').replace('"', '\\"').replace('?', '\\?'))
|
|
out[-1] += ';'
|
|
out.append('// Hashes of the comment blocks earlier versions shipped (mods/common/ini_comment_history.txt, kept by')
|
|
out.append("// tools/ini_comment_history.py): the plugins refresh only such blocks in a player's file (settings_file.h RefreshComments).")
|
|
out.append('inline constexpr unsigned long long kShippedCommentRuns[] = {')
|
|
hashes = sorted(comment_run_hashes())
|
|
for i in range(0, len(hashes), 4):
|
|
out.append(' ' + ' '.join('0x%016XULL,' % h for h in hashes[i:i + 4]))
|
|
out.append('};')
|
|
out.append('} // namespace ds2')
|
|
return '\n'.join(out) + '\n'
|
|
|
|
|
|
def comment_run_hashes():
|
|
"""64-bit FNV-1a of each block of mods/common/ini_comment_history.txt (lines trimmed, each followed by a newline)."""
|
|
blocks, cur = [], []
|
|
for l in open(INI_HISTORY, encoding='ascii').read().split('\n') + ['']:
|
|
if l.startswith('#'):
|
|
continue
|
|
if l.strip():
|
|
cur.append(l.strip())
|
|
elif cur:
|
|
blocks.append(cur)
|
|
cur = []
|
|
out = set()
|
|
for b in blocks:
|
|
h = 1469598103934665603
|
|
for c in ''.join(l + '\n' for l in b).encode('latin-1'):
|
|
h = ((h ^ c) * 1099511628211) & 0xFFFFFFFFFFFFFFFF
|
|
out.add(h)
|
|
return out
|
|
|
|
|
|
def write_ini_header():
|
|
text = ini_header_text()
|
|
old = open(INI_HEADER, encoding='ascii').read() if os.path.exists(INI_HEADER) else None
|
|
if old != text: # untouched when unchanged (no needless rebuilds)
|
|
with open(INI_HEADER, 'w', encoding='ascii', newline='\n') as f:
|
|
f.write(text)
|
|
|
|
|
|
def main():
|
|
write_ini_header()
|
|
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_BUILD) 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()
|