#!/usr/bin/python3 # # This is the one-and-only build script for luprex! # # Do not follow any of the build instructions on the Unreal Engine # websites! Instead, use this script. The instructions for this # script are in README.md # import sys, os, json, shutil, subprocess, re, time, tarfile import itertools, hashlib, zipfile, fnmatch from pathlib import Path from types import SimpleNamespace # # Suppress an error in some of Unreal's scripts: # os.environ["GIT_DIR"] = ".git" # # Utility subroutines # def shell(dir, cmd): "Run a shell command in a directory" print("Running:", cmd) subprocess.run(cmd, shell=True, check=True, cwd=dir) def create_tarfile(directory, glob_pattern, outputfile): "Create a tarfile from a source directory and a glob pattern" directory = Path(directory) with tarfile.open(outputfile, mode='w:gz') as tar: for path in directory.rglob(glob_pattern): if path.is_file(): tar.add(path, arcname=path.relative_to(directory)) def find_cpp(dir): "Find all the C++ and C files in a given directory" list1 = list(Path(dir).rglob("*.[ch]pp")) list2 = list(Path(dir).rglob("*.[ch]")) return sorted([str(x) for x in list1 + list2]) def hash_json(data): "Calculate a sha256 hexdigest of any valid json data structure" serialized = json.dumps(data, sort_keys=True).encode("utf-8") return hashlib.sha256(serialized).hexdigest() def read_if_exists(fn): "Read a file if it exists, otherwise, return null string" try: return Path(fn).read_text() except: return "" # # Fix a chmod bug in the Zipfile module. # class JZipFile(zipfile.ZipFile): def _extract_member(self, member, targetpath, pwd): if not isinstance(member, zipfile.ZipInfo): member = self.getinfo(member) targetpath = super()._extract_member(member, targetpath, pwd) attr = member.external_attr >> 16 if attr != 0: os.chmod(targetpath, attr) return targetpath # # A JSON preprocessor. # JSON_VAR_REGEX = re.compile(r'\[([A-Z0-9_]+)\]') def expand_json(data, vars): """ A JSON preprocessor that can expand for-each loops and substitute variables. The for-loop thing is mainly used by the code-workspace template, to avoid extreme code duplication in the launch configurations and build configurations. Variable substitution is used to plug hardwired paths into the file, and also to implement the for-loop thing. """ if isinstance(data, dict): if "for-each" in data and "body" in data: body = data["body"] foreach = data["for-each"] return [ expand_json(body, vars | lvars) for lvars in foreach ] if "append1" in data and "append2" in data: result = [] for i in range(1, 1000): key = f"append{i}" if key not in data: break result = result + expand_json(data[key], vars) return result else: return { expand_json(key, vars): expand_json(value, vars) for key, value in data.items() } elif isinstance(data, list): return [ expand_json(item, vars) for item in data ] elif isinstance(data, str): full = JSON_VAR_REGEX.fullmatch(data) if full: return vars.get(full.group(1), data) else: return JSON_VAR_REGEX.sub(lambda m: str(vars.get(m.group(1), m.group(0))), data) else: return data def expand_json_file(sourcefile, outputfile, config): """ Apply the json preprocessor to a file on disk """ Path(outputfile).unlink(missing_ok=True) data = json.loads(Path(sourcefile).read_text()) expanded = expand_json(data, vars(config)) Path(outputfile).write_text(json.dumps(expanded, indent=4)) # # Extracting a generated module file. # def cpp_files_included_by(module): """ Given the name of a Module.XYZ.cpp file generated by unreal build tool, scan the module for include directives that include another C++ file. Produce a list of these C++ files. """ result = [] for line in Path(module).read_text().splitlines(): if line.startswith("#include "): file = line[9:].strip().strip('"') if file.endswith(".cpp"): result.append(file) return result # # Determining the build configuration. # def get_build_mode_from_command_line(): """ Build.py accepts a single argument, which should be one of the following modes: all, c++, clean. If nothing is specified, the mode is all. We understand cpp and cxx as synonyms for c++. """ mode = sys.argv[1].lower() if len(sys.argv) > 1 else 'all' if mode in ["cpp", "cxx"]: mode = "c++" if not mode in ["all", "c++", "clean", "ccjson"]: sys.exit(f"Invalid build mode: {mode}") return mode def autodetect_system_config(): """ This autodetects where integration and unrealengine are installed, and it also autodetects your operating system. Based on these, it returns a config object containing a variety of useful configuration settings. We haven't tested the Windows version. """ config = SimpleNamespace() # Make sure we're actually inside an integration repository. config.INTEGRATION = os.path.dirname(os.path.abspath(sys.argv[0])) if not Path(f"{config.INTEGRATION}/Source/Integration").is_dir(): sys.exit(f"Integration repository is not valid: {config.INTEGRATION}") config.UNREALENGINE = config.INTEGRATION + ".UE" # Configure other parameters. if sys.platform == "windows": config.OS = "Windows" config.DLL = "dll" config.BAT = "bat" config.DOT_EXE = ".exe" config.USER = "Unknown" config.BUILD_BAT = "Build.bat" config.DEBUG = "DebugGame" else: config.OS = "Linux" config.DLL = "so" config.BAT = "sh" config.DOT_EXE = "" config.USER = os.environ["USER"] config.BUILD_BAT = "Linux/Build.sh" config.DEBUG = "DebugGame" return config def store_system_config_in_globals(config): """ Copy all the config data from the config object into global variables. """ global OS,DLL,BAT,DOT_EXE,USER,BUILD_BAT,DEBUG,INTEGRATION,UNREALENGINE OS = config.OS DLL = config.DLL BAT = config.BAT DOT_EXE = config.DOT_EXE USER = config.USER BUILD_BAT = config.BUILD_BAT DEBUG = config.DEBUG INTEGRATION = config.INTEGRATION UNREALENGINE = config.UNREALENGINE # # The actual build steps. # def unzip_unreal_engine_and_apply_patch(): """ Unzip the unreal engine source, then apply a patch. """ zips = list(Path(INTEGRATION).glob("UnrealEngine-*-release.zip")) if len(zips) == 0: sys.exit("Cannot find UnrealEngine-*-release.zip") if len(zips) > 1: sys.exit("Found multiple files matching UnrealEngine-*-release.zip") print("Unreal version: ", zips[0].stem) zipfn = os.path.join(INTEGRATION, zips[0].name) extracted = os.path.join(INTEGRATION, zips[0].stem) touchfile = os.path.join(UNREALENGINE, zips[0].stem) if not Path(touchfile).is_file(): print("Removing old version of unreal engine source...") shutil.rmtree(UNREALENGINE, ignore_errors=True) shutil.rmtree(extracted, ignore_errors=True) print(f"Unzipping {zipfn}...") with JZipFile(zipfn) as zf: zf.extractall(INTEGRATION) patchfile = f"{INTEGRATION}/EnginePatches/EnginePatch" shell(extracted, f"patch -p1 < {patchfile}") Path(extracted).rename(UNREALENGINE) Path(touchfile).touch() def generate_buildconfiguration_xml(): """ Generates BuildConfiguration.xml. We actually have two versions of this file in git, one for windows, one for linux. This just copies the appropriate version to the relevant target directories. """ dir1 = Path(f"{INTEGRATION}/Saved/UnrealBuildTool") dir2 = Path(f"{UNREALENGINE}/Engine/Saved/UnrealBuildTool") target1 = Path(f"{dir1}/BuildConfiguration.xml") target2 = Path(f"{dir2}/BuildConfiguration.xml") source = Path(f"{INTEGRATION}/EnginePatches/BuildConfiguration{OS}.xml") template = source.read_text(); dir1.mkdir(parents=True, exist_ok=True) dir2.mkdir(parents=True, exist_ok=True) target1.unlink(missing_ok=True) target2.unlink(missing_ok=True) target1.write_text(template) target2.write_text(template) def generate_lpx_paths(): """ Unreal needs to be able to find the Luprex DLL, and it also needs to find the Lua source code. For now, we just compile some hardwired paths into the binary. Someday we'll do something more sophisticated. """ target = Path(f"{INTEGRATION}/Source/Integration/lpx-paths.hpp") line1 = f'#define LUPREX_DLL_PATH "{INTEGRATION}/luprex/build/{OS}/luprexlib.{DLL}"' line2 = f'#define LUPREX_ROOT_PATH "{INTEGRATION}/luprex"' code = line1 + "\n" + line2 + "\n" target.unlink(missing_ok=True) target.write_text(code) def generate_integration_uproject(): """ Generate integration.uproject The uproject file is used by UnrealBuildTool to guide the build process. """ template = f"{INTEGRATION}/Integration.uproject.tpl.json" target = f"{INTEGRATION}/Integration.uproject" expand_json_file(template, target, CONFIG) def run_unrealengine_setup_bat_replacement(): """ The Setup.bat in UnrealEngine does a lot of unnecessary stuff, generates a lot of error messages, and pops up an interactive prompt in the middle of a build. Yuk. So we've written our own script that replaces Setup.bat/Setup.sh. """ touch = Path(f"{UNREALENGINE}/Engine/Build/HaveGitDependencies") if not touch.is_file(): if sys.platform == "windows": shell(UNREALENGINE, "Engine/Binaries/DotNET/GitDependencies/win-x64/GitDependencies.exe") shell(UNREALENGINE, "Engine/Extras/Redist/en-us/UEPrereqSetup_x64.exe /quiet /norestart") else: shell(UNREALENGINE, "Engine/Build/BatchFiles/Linux/GitDependencies.sh") shell(f"{UNREALENGINE}/Engine/Build/BatchFiles/Linux", "./Setup.sh") touch.write_text("Downloaded") def build_unrealbuildtool(): """ Build the unreal build tool itself, also including shader compile worker. """ shell(UNREALENGINE, f"{UNREALENGINE}/Engine/Build/BatchFiles/{BUILD_BAT} -buildubt ShaderCompileWorker {OS} Development") def build_luprex_and_integration(): """ This builds our C++ code, and also UnrealEngine's C++ code. """ shell(f"{INTEGRATION}/luprex", "make all") shell(INTEGRATION, f"{UNREALENGINE}/Engine/Build/BatchFiles/{BUILD_BAT} -waitmutex IntegrationEditor {OS} {DEBUG} {INTEGRATION}/Integration.uproject") # # Make a compile_commands.json file. # def build_intellisense_database_for_clangd(): """ This builds compile_commands.json, which tells the intellisense system based on clangd how to compile each source file. This also installs a .clangd file in the UnrealEngine directory. """ # Find clang compiler. clangs = list(Path(f"{UNREALENGINE}/Engine/Extras/ThirdPartyNotUE/SDKs").rglob("*-linux-gnu/bin/clang++")) if len(clangs) != 1: sys.exit("Couldn't identify correct clang++ compiler in UnrealEngine thirdparty directory") clang = str(clangs[0]) # Build the table of source files and RSP files. mods1 = Path(f"{INTEGRATION}/Intermediate/Build/{OS}").rglob(f"UnrealEditor/{DEBUG}/**/Module.*.o.rsp") mods2 = Path(f"{UNREALENGINE}/Engine/Intermediate/Build/{OS}").rglob("UnrealEditor/Development/**/Module.*.o.rsp") cpp_to_rsp = {} for mod in itertools.chain(mods1, mods2): rsp = str(mod) cpp = os.path.abspath(rsp.removesuffix(".o.rsp")) cpp_to_rsp[cpp] = rsp for subfile in cpp_files_included_by(cpp): abs = os.path.abspath(os.path.join(f"{UNREALENGINE}/Engine/Source", subfile)) cpp_to_rsp[abs] = rsp # Read the luprex compile commands database. ccjson = json.loads(Path(f"{INTEGRATION}/luprex/build/{OS}/compile_commands.json").read_text()) # Generate the new commands ccdir = f"{UNREALENGINE}/Engine/Source"; for cpp in sorted(cpp_to_rsp.keys()): rsp = cpp_to_rsp[cpp] args = [clang, "@"+rsp] ccjson.append({ "file" : cpp, "arguments":args, "directory":ccdir }) Path(f"{INTEGRATION}/.vscode/compile_commands.json").write_text(json.dumps(ccjson, indent=2)) def generate_integration_code_workspace(): """ We build Integration.code-workspace from a template that we wrote ourselves, Integration.code-workspace.tpl.json. We use UnrealBuildTool to generate Integration.code-workspace.ubt, but we don't use it: we just keep it as a reference that you can refer to when editing the template. """ workspace = f"{INTEGRATION}/Integration.code-workspace" workspace_ubt = f"{INTEGRATION}/Integration.code-workspace.ubt" template = f"{INTEGRATION}/Integration.code-workspace.tpl.json" Path(workspace).unlink(missing_ok=True) Path(workspace_ubt).unlink(missing_ok=True) shell(INTEGRATION, f'{UNREALENGINE}/GenerateProjectFiles.{BAT} -projectfiles -project="{INTEGRATION}/Integration.uproject" -game') Path(workspace).rename(workspace_ubt) expand_json_file(template, workspace, CONFIG) def build_clean(): """ This code is underdeveloped. """ shell(f"{INTEGRATION}/luprex", "make clean") shell(INTEGRATION, f"{UNREALENGINE}/Engine/Build/BatchFiles/{BUILD_BAT} -waitmutex IntegrationEditor {OS} {DEBUG} {INTEGRATION}/Integration.uproject -clean") Path(f"{INTEGRATION}/.vscode/compile_commands.json").unlink(missing_ok = True) # # MAIN PROGRAM # MODE = get_build_mode_from_command_line() CONFIG = autodetect_system_config() store_system_config_in_globals(CONFIG) os.chdir(f"{INTEGRATION}/EnginePatches") if MODE == "ccjson": build_intellisense_database_for_clangd() if MODE == "all": unzip_unreal_engine_and_apply_patch() generate_buildconfiguration_xml() generate_lpx_paths() generate_integration_uproject() run_unrealengine_setup_bat_replacement() build_unrealbuildtool() generate_integration_code_workspace() if MODE in ["all", "c++"]: build_luprex_and_integration() build_intellisense_database_for_clangd() if MODE == "clean": build_clean()