SE2Revival/mods/build.py
DS2Revival b2c1aabf9e
All checks were successful
build / build (push) Successful in 40s
CI: build without the game plugins' settings template
2026-10-27 22:08:33 -04:00

228 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():
if not os.path.exists(INI_TEMPLATE): # a source export without the game plugins (SE2 Revival): nothing reads the header
return
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()