From c2df6afd4d0de94867426836393a7c65244b8c13 Mon Sep 17 00:00:00 2001 From: Spartan322 Date: Tue, 30 Jun 2026 00:27:43 -0400 Subject: [PATCH] Overhaul build system scripts tools Consolidate build configuration logic into scripts tool Extract common utilities: - Move platform-independent build setup from SConstruct to tools/scripts.py - Extract string utilities (to_raw_cstring, to_escaped_cstring) to build/string.py - Add common_compiler_flags.py for shared compiler configuration - Simplify SConstruct - Update build modules to use extracted string utilities instead of env methods - Refactor cache.py progress tracking to use atexit callbacks - Parameterize git info functions to support custom prefixes - Remove tools/targets.py and tools/macos_osxcross.py (functionality consolidated) - Update .gitignore for SCons output files - Disable use_static_cpp for hot_release on linux platform --- .gitignore | 4 +- SConstruct | 404 +++---------------- build/author_info.py | 5 +- build/cache.py | 161 ++------ build/color.py | 153 +++++++ build/git_info.py | 3 +- build/glob_recursive.py | 5 +- build/license_info.py | 12 +- build/string.py | 64 +++ tools/common_compiler_flags.py | 152 +++++++ tools/linux.py | 8 +- tools/macos.py | 4 +- tools/macos_osxcross.py | 29 -- tools/scripts.py | 713 +++++++++++++++++++++++++++++++++ tools/targets.py | 126 ------ tools/windows.py | 6 +- 16 files changed, 1208 insertions(+), 641 deletions(-) create mode 100644 build/color.py create mode 100644 build/string.py create mode 100644 tools/common_compiler_flags.py delete mode 100644 tools/macos_osxcross.py create mode 100644 tools/scripts.py delete mode 100644 tools/targets.py diff --git a/.gitignore b/.gitignore index 96e935d..1a25300 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,6 @@ cython_debug/ # When configure fails, SCons outputs these config.log -.sconf_temp \ No newline at end of file +.sconsign*.dblite +.scons_env.json +.scons_node_count diff --git a/SConstruct b/SConstruct index 8f766ab..4a4267e 100644 --- a/SConstruct +++ b/SConstruct @@ -1,6 +1,6 @@ #!/usr/bin/env python -# This file is heavily based on https://github.com/godotengine/godot-cpp/blob/df5b1a9a692b0d972f5ac3c853371594cdec420b/SConstruct and https://github.com/godotengine/godot-cpp/blob/98ea2f60bb3846d6ae410d8936137d1b099cd50b/tools/godotcpp.py +# This file is heavily based on https://github.com/godotengine/godot-cpp/blob/ba0edfed90512ec64aba51d4295a3e7e30112f86/SConstruct import os import platform import sys @@ -8,368 +8,72 @@ from typing import List, Union import SCons -_SCRIPTS_DIR = Dir(".").abspath -if _SCRIPTS_DIR not in sys.path: - sys.path.insert(0, _SCRIPTS_DIR) - -# Local -from build.option_handler import OptionsClass -from build.glob_recursive import GlobRecursive, GlobRecursiveVariant -from build.git_info import get_git_info, git_builder -from build.license_info import license_builder -from build.author_info import author_builder -from build.cache import show_progress -from build.pch import setup_pch - -def normalize_path(val, env): - return val if os.path.isabs(val) else os.path.join(env.Dir("#").abspath, val) - -def validate_parent_dir(key, val, env): - if not os.path.isdir(normalize_path(os.path.dirname(val), env)): - raise UserError("'%s' is not a directory: %s" % (key, os.path.dirname(val))) - -# Try to detect the host platform automatically. -# This is used if no `platform` argument is passed -if sys.platform.startswith("linux"): - default_platform = "linux" -elif sys.platform == "darwin": - default_platform = "macos" -elif sys.platform == "win32" or sys.platform == "msys": - default_platform = "windows" -elif ARGUMENTS.get("platform", ""): - default_platform = ARGUMENTS.get("platform") -else: - raise ValueError("Could not detect platform automatically, please specify with platform=") +# Add scripts folder to sys.path, so that we can import local modules. +sys.path.append(Dir(".").srcnode().abspath) is_standalone = SCons.Script.sconscript_reading == 2 try: Import("env") - old_env = env - env = old_env.Clone() -except: + parent_env = env + env = Environment(tools=["default"], PLATFORM="") + env.parent_env = parent_env +except Exception: # Default tools with no platform defaults to gnu toolchain. # We apply platform specific toolchains via our custom tools. env = Environment(tools=["default"], PLATFORM="") - old_env = env - -env.TOOLPATH = [env.Dir("tools").abspath] -env.is_standalone = is_standalone -env.show_progress = show_progress - -env.PrependENVPath("PATH", os.getenv("PATH")) - -# CPU architecture options. -architecture_array = [ - "", - "universal", - "x86_32", - "x86_64", - "arm32", - "arm64", - "rv64", - "ppc32", - "ppc64", - "wasm32", -] -architecture_aliases = { - "x64": "x86_64", - "amd64": "x86_64", - "armv7": "arm32", - "armv8": "arm64", - "arm64v8": "arm64", - "aarch64": "arm64", - "rv": "rv64", - "riscv": "rv64", - "riscv64": "rv64", - "ppcle": "ppc32", - "ppc": "ppc32", - "ppc64le": "ppc64", -} - -platforms = ("linux", "macos", "windows", "android", "ios", "web") -unsupported_known_platforms = ("android", "ios", "web") - -def SetupOptions(): - # Default num_jobs to local cpu count if not user specified. - # SCons has a peculiarity where user-specified options won't be overridden - # by SetOption, so we can rely on this to know if we should use our default. - initial_num_jobs = env.GetOption("num_jobs") - altered_num_jobs = initial_num_jobs + 1 - env.SetOption("num_jobs", altered_num_jobs) - if env.GetOption("num_jobs") == altered_num_jobs: - cpu_count = os.cpu_count() - if cpu_count is None: - print("Couldn't auto-detect CPU count to configure build parallelism. Specify it with the -j argument.") - else: - safer_cpu_count = cpu_count if cpu_count <= 4 else cpu_count - 1 - print( - "Auto-detected %d CPU cores available for build parallelism. Using %d cores by default. You can override it with the -j argument." - % (cpu_count, safer_cpu_count) - ) - env.SetOption("num_jobs", safer_cpu_count) - - opts = OptionsClass(ARGUMENTS.copy()) - - opts.Add( - EnumVariable( - key="platform", - help="Target platform", - default=env.get("platform", default_platform), - allowed_values=platforms, - ignorecase=2, - ) - ) - - opts.Add( - EnumVariable( - key="target", - help="Compilation target", - default=env.get("target", "template_debug"), - allowed_values=("editor", "template_release", "template_debug"), - ) - ) - - opts.Add( - EnumVariable( - key="precision", - help="Set the floating-point precision level", - default=env.get("precision", "single"), - allowed_values=("single", "double"), - ) - ) - - opts.Add( - BoolVariable( - key="use_hot_reload", - help="Enable the extra accounting required to support hot reload.", - default=env.get("use_hot_reload", None), - ) - ) - - opts.Add( - BoolVariable( - "disable_exceptions", - "Force disabling exception handling code", - default=env.get("disable_exceptions", True), - ) - ) - - opts.Add( - BoolVariable( - "disable_rtti", - "Disabling of runtime type information", - default=env.get("disable_rtti", True) - ) - ) - - opts.Add( - EnumVariable( - key="symbols_visibility", - help="Symbols visibility on GNU platforms. Use 'auto' to apply the default value.", - default=env.get("symbols_visibility", "hidden"), - allowed_values=["auto", "visible", "hidden"], - ) - ) - - # Add platform options. Iterate deterministically with the current platform - # registered last so its defaults win - tools = {} - current_platform = env.get("platform", default_platform) - supported = sorted(set(platforms) - set(unsupported_known_platforms)) - if current_platform in supported: - supported = [pl for pl in supported if pl != current_platform] + [current_platform] - for pl in supported: - tool = Tool(pl, toolpath=env.TOOLPATH) - if hasattr(tool, "options"): - tool.options(opts) - tools[pl] = tool - - # CPU architecture options. - opts.Add( - EnumVariable( - key="arch", - help="CPU architecture", - default=env.get("arch", ""), - allowed_values=architecture_array, - map=architecture_aliases, - ) - ) - - # compiledb - opts.Add( - BoolVariable( - key="compiledb", - help="Generate compilation DB (`compile_commands.json`) for external tools", - default=env.get("compiledb", False), - ) - ) - opts.Add( - PathVariable( - key="compiledb_file", - help="Path to a custom `compile_commands.json` file", - default=env.get("compiledb_file", "compile_commands.json"), - validator=validate_parent_dir, - ) - ) - opts.Add(BoolVariable("verbose", "Enable verbose output for the compilation", False)) - opts.Add(BoolVariable("intermediate_delete", "Enables automatically deleting unassociated intermediate binary files.", True)) - opts.Add(BoolVariable("progress", "Show a progress indicator during compilation", True)) - opts.Add(BoolVariable("use_pch", "Enable precompiled headers when the toolchain supports it", True)) - - # Targets flags tool (optimizations, debug symbols) - target_tool = Tool("targets", toolpath=env.TOOLPATH) - target_tool.options(opts) - env._opts = opts - env._target_tool = target_tool - return opts - -def FinalizeOptions(): - opts = env._opts - target_tool = env._target_tool - # Custom options and profile flags. - opts.Make(["../custom.py"]) - opts.Finalize(env) - Help(opts.GenerateHelpText(env)) - env.extra_suffix = "" - - if env["platform"] in unsupported_known_platforms: - print("Unsupported platform: " + env["platform"]+". Only supports " + ", ".join(set(platforms) - set(unsupported_known_platforms))) - Exit() - - # Process CPU architecture argument. - if env["arch"] == "": - # No architecture specified. Default to arm64 if building for Android, - # universal if building for macOS or iOS, wasm32 if building for web, - # otherwise default to the host architecture. - if env["platform"] in ["macos", "ios"]: - env["arch"] = "universal" - elif env["platform"] == "android": - env["arch"] = "arm64" - elif env["platform"] == "web": - env["arch"] = "wasm32" - else: - host_machine = platform.machine().lower() - if host_machine in architecture_array: - env["arch"] = host_machine - elif host_machine in architecture_aliases.keys(): - env["arch"] = architecture_aliases[host_machine] - elif "86" in host_machine: - # Catches x86, i386, i486, i586, i686, etc. - env["arch"] = "x86_32" - else: - print("Unsupported CPU architecture: " + host_machine) - env.Exit(1) - - print("Building for architecture " + env["arch"] + " on platform " + env["platform"]) - - tool = Tool(env["platform"], toolpath=env.TOOLPATH) - - if tool is None or not tool.exists(env): - raise ValueError("Required toolchain not found for platform " + env["platform"]) - - target_tool.generate(env) - tool.generate(env) - - Decider("MD5-timestamp") - - scons_cache_path = os.environ.get("SCONS_CACHE") - if scons_cache_path != None: - CacheDir(scons_cache_path) - print("Scons cache enabled... (path: '" + scons_cache_path + "')") - - if env["compiledb"] and is_standalone: - # compile_commands.json - env.Tool("compilation_db") - env.Alias("compiledb", env.CompilationDatabase(normalize_path(env["compiledb_file"], env))) - -env.SetupOptions = SetupOptions -env.FinalizeOptions = FinalizeOptions -env.GlobRecursive = GlobRecursive -env.GlobRecursiveVariant = lambda pattern, src, variant, exclude=None: GlobRecursiveVariant(env, pattern, src, variant, exclude) -env.get_git_info = get_git_info -env.license_builder = license_builder -env.git_builder = git_builder -env.author_builder = author_builder -env.SetupPCH = lambda header, source: setup_pch(env, header, source) - - -def to_raw_cstring(value: Union[str, List[str]]) -> str: - MAX_LITERAL = 16380 - - if isinstance(value, list): - value = "\n".join(value) + "\n" - - split: List[bytes] = [] - offset = 0 - encoded = value.encode() - - while offset <= len(encoded): - segment = encoded[offset : offset + MAX_LITERAL] - offset += MAX_LITERAL - if len(segment) == MAX_LITERAL: - # Try to segment raw strings at double newlines to keep readable. - pretty_break = segment.rfind(b"\n\n") - if pretty_break != -1: - segment = segment[: pretty_break + 1] - offset -= MAX_LITERAL - pretty_break - 1 - # If none found, ensure we end with valid utf8. - # https://github.com/halloleo/unicut/blob/master/truncate.py - elif segment[-1] & 0b10000000: - last_11xxxxxx_index = [i for i in range(-1, -5, -1) if segment[i] & 0b11000000 == 0b11000000][0] - last_11xxxxxx = segment[last_11xxxxxx_index] - if not last_11xxxxxx & 0b00100000: - last_char_length = 2 - elif not last_11xxxxxx & 0b0010000: - last_char_length = 3 - elif not last_11xxxxxx & 0b0001000: - last_char_length = 4 - - if last_char_length > -last_11xxxxxx_index: - segment = segment[:last_11xxxxxx_index] - offset += last_11xxxxxx_index - - split += [segment] - - if len(split) == 1: - return f'R"({split[0].decode()})"' - else: - # Wrap multiple segments in parenthesis to suppress `string-concatenation` warnings on clang. - return "({})".format(" ".join(f'R"({segment.decode()})"' for segment in split)) - - -C_ESCAPABLES = [ - ("\\", "\\\\"), - ("\a", "\\a"), - ("\b", "\\b"), - ("\f", "\\f"), - ("\n", "\\n"), - ("\r", "\\r"), - ("\t", "\\t"), - ("\v", "\\v"), - # ("'", "\\'"), # Skip, as we're only dealing with full strings. - ('"', '\\"'), - ] -C_ESCAPE_TABLE = str.maketrans(dict((x, y) for x, y in C_ESCAPABLES)) +try: + Import("build_dir") + env["build_dir"] = build_dir +except Exception: + pass -def to_escaped_cstring(value: str) -> str: - return value.translate(C_ESCAPE_TABLE) +try: + Import("gen_dir") + env["gen_dir"] = gen_dir +except Exception: + pass -def Run(env, function, **kwargs): - return SCons.Action.Action(function, "$GENCOMSTR", **kwargs) +env.is_standalone = is_standalone -def CommandNoCache(env, target, sources, command, **kwargs): - result = env.Command(target, sources, command, **kwargs) - env.NoCache(result) - for key, val in kwargs.items(): - env.Depends(result, env.Value({ key: val })) - return result +env.PrependENVPath("PATH", os.getenv("PATH")) -env.to_raw_cstring = to_raw_cstring -env.to_escaped_cstring = to_escaped_cstring +if not env.get("parent_env", None) is None: + skip_parent_item = True + for key in parent_env.Dictionary(): + if skip_parent_item: + if key == "arch": skip_parent_item = False + else: continue + env[key] = parent_env[key] -env.__class__.Run = Run -env.__class__.CommandNoCache = CommandNoCache +# Custom options and profile flags. +customs = ["custom.py"] +try: + customs += Import("customs") +except Exception: + pass +profile = ARGUMENTS.get("profile", "") +if profile: + if os.path.isfile(profile): + customs.append(profile) + elif os.path.isfile(profile + ".py"): + customs.append(profile + ".py") + +if not "custom_tools" in env: + try: + Import("custom_tools") + env["custom_tools"] = custom_tools + except Exception: + pass + +opts = Variables(customs, ARGUMENTS) +scripts_tool = Tool("scripts", toolpath=[Dir("tools").srcnode().abspath]) +scripts_tool.options(opts, env) +opts.Update(env) + +Help(opts.GenerateHelpText(env)) + +scripts_tool.generate(env) Return("env") \ No newline at end of file diff --git a/build/author_info.py b/build/author_info.py index 93175a6..8225e77 100644 --- a/build/author_info.py +++ b/build/author_info.py @@ -1,3 +1,6 @@ +import build.string + + def author_builder(target, source, env): name_prefix = env.get("name_prefix", "project") prefix_upper = name_prefix.upper() @@ -29,7 +32,7 @@ def close_section(): for line in buffer.decode().splitlines(): if line.startswith(" ") and reading: - file.write(f'\t\t"{env.to_escaped_cstring(line).strip()}",\n') + file.write(f'\t\t"{build.string.to_escaped_cstring(line).strip()}",\n') elif line.startswith("## "): if reading: close_section() diff --git a/build/cache.py b/build/cache.py index d48b0e0..54209f9 100644 --- a/build/cache.py +++ b/build/cache.py @@ -1,127 +1,52 @@ -# Copied from https://github.com/godotengine/godot/blob/c3b0a92c3cd9a219c1b1776b48c147f1d0602f07/methods.py#L1049-L1172 -def show_progress(env): - import os - import sys - import glob - from SCons.Script import Progress, Command, AlwaysBuild - - screen = sys.stdout - # Progress reporting is not available in non-TTY environments since it - # messes with the output (for example, when writing to a file) - show_progress = env["progress"] and sys.stdout.isatty() - node_count = 0 - node_count_max = 0 - node_count_interval = 1 - node_count_fname = str(env.Dir("#")) + "/.scons_node_count" +# Copied from https://github.com/godotengine/godot/blob/77c0879cffe8cb7336fab2b7c18dcad2d9a0176c/methods.py#L815-L861 +import atexit +import sys +from typing import cast - import time, math - - class cache_progress: - # The default is 1 GB cache and 12 hours half life - def __init__(self, path=None, limit=1073741824, half_life=43200): - self.path = path - self.limit = limit - self.exponent_scale = math.log(2) / half_life - if env["verbose"] and path != None: - screen.write( - "Current cache limit is {} (used: {})\n".format( - self.convert_size(limit), self.convert_size(self.get_size(path)) - ) - ) - self.delete(self.file_list()) +def show_progress(env): + # Ninja has its own progress/tracking tool that clashes with ours. + if env.get("ninja", False): + return + + NODE_COUNT_FILENAME = env.Dir("#").srcnode().abspath + "/.scons_node_count" + + class ShowProgress: + def __init__(self): + self.count = 0 + self.max = 0 + try: + with open(NODE_COUNT_FILENAME, "r", encoding="utf-8") as f: + self.max = int(f.readline()) + except OSError: + pass + + # Progress reporting is not available in non-TTY environments since it + # messes with the output (for example, when writing to a file). + self.display = cast(bool, env["progress"] and sys.stdout.isatty()) + if self.display and not self.max: + print("Performing initial build, progress percentage unavailable!") + self.display = False def __call__(self, node, *args, **kw): - nonlocal node_count, node_count_max, node_count_interval, node_count_fname, show_progress - if show_progress: - # Print the progress percentage - node_count += node_count_interval - if node_count_max > 0 and node_count <= node_count_max: - screen.write("\r[%3d%%] " % (node_count * 100 / node_count_max)) - screen.flush() - elif node_count_max > 0 and node_count > node_count_max: - screen.write("\r[100%] ") - screen.flush() - else: - screen.write("\r[Initial build] ") - screen.flush() + self.count += 1 + if self.display: + percent = int(min(self.count * 100 / self.max, 100)) + sys.stdout.write(f"\r[{percent:3d}%] ") + sys.stdout.flush() - def delete(self, files): - if len(files) == 0: - return - if env["verbose"]: - # Utter something - screen.write("\rPurging %d %s from cache...\n" % (len(files), len(files) > 1 and "files" or "file")) - [os.remove(f) for f in files] + from SCons.Script import Progress + from SCons.Script.Main import GetBuildFailures - def file_list(self): - if self.path is None: - # Nothing to do - return [] - # Gather a list of (filename, (size, atime)) within the - # cache directory - file_stat = [(x, os.stat(x)[6:8]) for x in glob.glob(os.path.join(self.path, "*", "*"))] - if file_stat == []: - # Nothing to do - return [] - # Weight the cache files by size (assumed to be roughly - # proportional to the recompilation time) times an exponential - # decay since the ctime, and return a list with the entries - # (filename, size, weight). - current_time = time.time() - file_stat = [(x[0], x[1][0], (current_time - x[1][1])) for x in file_stat] - # Sort by the most recently accessed files (most sensible to keep) first - file_stat.sort(key=lambda x: x[2]) - # Search for the first entry where the storage limit is - # reached - sum, mark = 0, None - for i, x in enumerate(file_stat): - sum += x[1] - if sum > self.limit: - mark = i - break - if mark is None: - return [] - else: - return [x[0] for x in file_stat[mark:]] + progressor = ShowProgress() + Progress(progressor) - def convert_size(self, size_bytes): - if size_bytes == 0: - return "0 bytes" - size_name = ("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") - i = int(math.floor(math.log(size_bytes, 1024))) - p = math.pow(1024, i) - s = round(size_bytes / p, 2) - return "%s %s" % (int(s) if i == 0 else s, size_name[i]) - - def get_size(self, start_path="."): - total_size = 0 - for dirpath, dirnames, filenames in os.walk(start_path): - for f in filenames: - fp = os.path.join(dirpath, f) - total_size += os.path.getsize(fp) - return total_size - - def progress_finish(target, source, env): - nonlocal node_count, progressor + def progress_finish(): + if GetBuildFailures() or not progressor.count: + return try: - with open(node_count_fname, "w") as f: - f.write("%d\n" % node_count) - progressor.delete(progressor.file_list()) - except Exception: + with open(NODE_COUNT_FILENAME, "w", encoding="utf-8", newline="\n") as f: + f.write(f"{progressor.count}\n") + except OSError: pass - try: - with open(node_count_fname) as f: - node_count_max = int(f.readline()) - except Exception: - pass - - cache_directory = os.environ.get("SCONS_CACHE") - # Simple cache pruning, attached to SCons' progress callback. Trim the - # cache directory to a size not larger than cache_limit. - cache_limit = float(os.getenv("SCONS_CACHE_LIMIT", 1024)) * 1024 * 1024 - progressor = cache_progress(cache_directory, cache_limit) - Progress(progressor, interval=node_count_interval) - - progress_finish_command = Command("progress_finish", [], progress_finish) - AlwaysBuild(progress_finish_command) + atexit.register(progress_finish) diff --git a/build/color.py b/build/color.py new file mode 100644 index 0000000..6b5bc95 --- /dev/null +++ b/build/color.py @@ -0,0 +1,153 @@ +# Based on https://github.com/godotengine/godot/blob/f4c57c2824951a5df945392923bdfdc1c4055395/misc/utility/color.py +from __future__ import annotations + +import os +import re +import sys +from enum import Enum +from typing import Final + +# Colors are disabled in non-TTY environments such as pipes. This means if output is redirected +# to a file, it won't contain color codes. Colors are enabled by default on continuous integration. + +IS_CI: Final[bool] = bool(os.environ.get("CI")) +NO_COLOR: Final[bool] = bool(os.environ.get("NO_COLOR")) +CLICOLOR_FORCE: Final[bool] = bool(os.environ.get("CLICOLOR_FORCE")) +STDOUT_TTY: Final[bool] = bool(sys.stdout.isatty()) +STDERR_TTY: Final[bool] = bool(sys.stderr.isatty()) + + +_STDOUT_ORIGINAL: Final[bool] = False if NO_COLOR else CLICOLOR_FORCE or IS_CI or STDOUT_TTY +_STDERR_ORIGINAL: Final[bool] = False if NO_COLOR else CLICOLOR_FORCE or IS_CI or STDERR_TTY +_stdout_override: bool = _STDOUT_ORIGINAL +_stderr_override: bool = _STDERR_ORIGINAL + + +def is_stdout_color() -> bool: + return _stdout_override + + +def is_stderr_color() -> bool: + return _stderr_override + + +def force_stdout_color(value: bool) -> None: + """ + Explicitly set `stdout` support for ANSI escape codes. + If environment overrides exist, does nothing. + """ + if not NO_COLOR or not CLICOLOR_FORCE: + global _stdout_override + _stdout_override = value + + +def force_stderr_color(value: bool) -> None: + """ + Explicitly set `stderr` support for ANSI escape codes. + If environment overrides exist, does nothing. + """ + if not NO_COLOR or not CLICOLOR_FORCE: + global _stderr_override + _stderr_override = value + + +class Ansi(Enum): + """ + Enum class for adding ANSI codepoints directly into strings. Automatically converts values to + strings representing their internal value. + """ + + RESET = "\x1b[0m" + + BOLD = "\x1b[1m" + DIM = "\x1b[2m" + ITALIC = "\x1b[3m" + UNDERLINE = "\x1b[4m" + STRIKETHROUGH = "\x1b[9m" + REGULAR = "\x1b[22;23;24;29m" + + BLACK = "\x1b[30m" + RED = "\x1b[31m" + GREEN = "\x1b[32m" + YELLOW = "\x1b[33m" + BLUE = "\x1b[34m" + MAGENTA = "\x1b[35m" + CYAN = "\x1b[36m" + WHITE = "\x1b[37m" + GRAY = "\x1b[90m" + + def __str__(self) -> str: + return self.value + + +RE_ANSI = re.compile(r"\x1b\[[=\?]?[;\d]+[a-zA-Z]") + + +def color_print(*values: object, sep: str | None = " ", end: str | None = "\n", flush: bool = False) -> None: + """Prints a colored message to `stdout`. If disabled, ANSI codes are automatically stripped.""" + if is_stdout_color(): + print(*values, sep=sep, end=f"{Ansi.RESET}{end}", flush=flush) + else: + print(RE_ANSI.sub("", (sep or " ").join(map(str, values))), sep="", end=end, flush=flush) + + +def color_printerr(*values: object, sep: str | None = " ", end: str | None = "\n", flush: bool = False) -> None: + """Prints a colored message to `stderr`. If disabled, ANSI codes are automatically stripped.""" + if is_stderr_color(): + print(*values, sep=sep, end=f"{Ansi.RESET}{end}", flush=flush, file=sys.stderr) + else: + print(RE_ANSI.sub("", (sep or " ").join(map(str, values))), sep="", end=end, flush=flush, file=sys.stderr) + + +def print_info(*values: object) -> None: + """Prints a informational message with formatting.""" + color_print(f"{Ansi.GRAY}{Ansi.BOLD}INFO:{Ansi.REGULAR}", *values) + + +def print_warning(*values: object) -> None: + """Prints a warning message with formatting.""" + color_printerr(f"{Ansi.YELLOW}{Ansi.BOLD}WARNING:{Ansi.REGULAR}", *values) + + +def print_error(*values: object) -> None: + """Prints an error message with formatting.""" + color_printerr(f"{Ansi.RED}{Ansi.BOLD}ERROR:{Ansi.REGULAR}", *values) + + +if sys.platform == "win32": + + def _win_color_fix(): + """Attempts to enable ANSI escape code support on Windows 10 and later.""" + from ctypes import POINTER, WINFUNCTYPE, WinError, windll + from ctypes.wintypes import BOOL, DWORD, HANDLE + + STDOUT_HANDLE = -11 + STDERR_HANDLE = -12 + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4 + + def err_handler(result, func, args): + if not result: + raise WinError() + return args + + GetStdHandle = WINFUNCTYPE(HANDLE, DWORD)(("GetStdHandle", windll.kernel32), ((1, "nStdHandle"),)) + GetConsoleMode = WINFUNCTYPE(BOOL, HANDLE, POINTER(DWORD))( + ("GetConsoleMode", windll.kernel32), + ((1, "hConsoleHandle"), (2, "lpMode")), + ) + GetConsoleMode.errcheck = err_handler + SetConsoleMode = WINFUNCTYPE(BOOL, HANDLE, DWORD)( + ("SetConsoleMode", windll.kernel32), + ((1, "hConsoleHandle"), (1, "dwMode")), + ) + SetConsoleMode.errcheck = err_handler + + for handle_id in [STDOUT_HANDLE, STDERR_HANDLE]: + try: + handle = GetStdHandle(handle_id) + flags = GetConsoleMode(handle) + SetConsoleMode(handle, flags | ENABLE_VIRTUAL_TERMINAL_PROCESSING) + except OSError: + pass + + _win_color_fix() diff --git a/build/git_info.py b/build/git_info.py index cf4d465..b028c47 100644 --- a/build/git_info.py +++ b/build/git_info.py @@ -101,7 +101,8 @@ def get_git_hash(): } -def get_git_info(name_prefix="project"): +def get_git_info(env, name_prefix=None): + name_prefix = env.get("name_prefix", "project") prefix_upper = name_prefix.upper() return {**get_git_hash(), "git_tag": get_git_tag(prefix_upper), "git_release": get_git_release(prefix_upper)} diff --git a/build/glob_recursive.py b/build/glob_recursive.py index d4fe65d..1ef3305 100644 --- a/build/glob_recursive.py +++ b/build/glob_recursive.py @@ -6,6 +6,7 @@ def GlobRecursive(pattern, nodes=["."], exclude=None): fs = SCons.Node.FS.get_default_fs() Glob = fs.Glob + Dir = fs.Dir if isinstance(exclude, str): exclude = [exclude] @@ -14,12 +15,12 @@ def GlobRecursive(pattern, nodes=["."], exclude=None): for node in nodes: node_str = str(node) - for f in Glob(node_str + "/*", source=True): + for f in Glob("*", cwd=Dir(node_str), source=True): if type(f) is SCons.Node.FS.Dir: child = node_str + "/" + os.path.basename(str(f)) results += GlobRecursive(pattern, [child]) - results += Glob(node_str + "/" + pattern) + results += Glob(pattern, cwd=Dir(node_str)) if isinstance(exclude, list): diff --git a/build/license_info.py b/build/license_info.py index 7652328..cbf0f7f 100644 --- a/build/license_info.py +++ b/build/license_info.py @@ -1,6 +1,8 @@ from collections import OrderedDict from io import TextIOWrapper +import build.string + def get_license_info(src_copyright): class LicenseReader: @@ -95,7 +97,7 @@ def copyright_part_str() -> str: part_indexes[project_name] = part_index for part in project: result += ( - f'\t\t{{ "{env.to_escaped_cstring(part["License"][0])}", ' + f'\t\t{{ "{build.string.to_escaped_cstring(part["License"][0])}", ' + f"{{ &{copyright_data_name}[{part['file_index']}], {len(part['Files'])} }}, " + f"{{ &{copyright_data_name}[{part['copyright_index']}], {len(part['Copyright'])} }} }},\n" ) @@ -106,7 +108,7 @@ def copyright_info_str() -> str: result = "" for project_name, project in iter(src_copyright["projects"].items()): result += ( - f'\t\t{{ "{env.to_escaped_cstring(project_name)}", ' + f'\t\t{{ "{build.string.to_escaped_cstring(project_name)}", ' + f"{{ &{copyright_parts_name}[{part_indexes[project_name]}], {len(project)} }} }},\n" ) return result @@ -115,8 +117,8 @@ def license_list_str() -> str: result = "" for license in iter(src_copyright["licenses"]): result += ( - f'\t\t{{ "{env.to_escaped_cstring(license[0])}",' - + f'\n\t\t {env.to_raw_cstring([line if line != "." else "" for line in license[1:]])} }}, \n' + f'\t\t{{ "{build.string.to_escaped_cstring(license[0])}",' + + f'\n\t\t {build.string.to_raw_cstring([line if line != "." else "" for line in license[1:]])} }}, \n' ) return result @@ -132,7 +134,7 @@ def license_list_str() -> str: namespace OpenVic {{ static constexpr std::string_view {license_text_name} = {{ - {env.to_raw_cstring(license_text)} + {build.string.to_raw_cstring(license_text)} }}; struct {component_copyright_part_name} {{ diff --git a/build/string.py b/build/string.py new file mode 100644 index 0000000..b32f481 --- /dev/null +++ b/build/string.py @@ -0,0 +1,64 @@ +from typing import List, Union + + +def to_raw_cstring(value: Union[str, List[str]]) -> str: + MAX_LITERAL = 16380 + + if isinstance(value, list): + value = "\n".join(value) + "\n" + + split: List[bytes] = [] + offset = 0 + encoded = value.encode() + + while offset <= len(encoded): + segment = encoded[offset : offset + MAX_LITERAL] + offset += MAX_LITERAL + if len(segment) == MAX_LITERAL: + # Try to segment raw strings at double newlines to keep readable. + pretty_break = segment.rfind(b"\n\n") + if pretty_break != -1: + segment = segment[: pretty_break + 1] + offset -= MAX_LITERAL - pretty_break - 1 + # If none found, ensure we end with valid utf8. + # https://github.com/halloleo/unicut/blob/master/truncate.py + elif segment[-1] & 0b10000000: + last_11xxxxxx_index = [i for i in range(-1, -5, -1) if segment[i] & 0b11000000 == 0b11000000][0] + last_11xxxxxx = segment[last_11xxxxxx_index] + if not last_11xxxxxx & 0b00100000: + last_char_length = 2 + elif not last_11xxxxxx & 0b0010000: + last_char_length = 3 + elif not last_11xxxxxx & 0b0001000: + last_char_length = 4 + + if last_char_length > -last_11xxxxxx_index: + segment = segment[:last_11xxxxxx_index] + offset += last_11xxxxxx_index + + split += [segment] + + if len(split) == 1: + return f'R"({split[0].decode()})"' + else: + # Wrap multiple segments in parenthesis to suppress `string-concatenation` warnings on clang. + return "({})".format(" ".join(f'R"({segment.decode()})"' for segment in split)) + + +C_ESCAPABLES = [ + ("\\", "\\\\"), + ("\a", "\\a"), + ("\b", "\\b"), + ("\f", "\\f"), + ("\n", "\\n"), + ("\r", "\\r"), + ("\t", "\\t"), + ("\v", "\\v"), + # ("'", "\\'"), # Skip, as we're only dealing with full strings. + ('"', '\\"'), +] +C_ESCAPE_TABLE = str.maketrans(dict((x, y) for x, y in C_ESCAPABLES)) + + +def to_escaped_cstring(value: str) -> str: + return value.translate(C_ESCAPE_TABLE) diff --git a/tools/common_compiler_flags.py b/tools/common_compiler_flags.py new file mode 100644 index 0000000..0a602ca --- /dev/null +++ b/tools/common_compiler_flags.py @@ -0,0 +1,152 @@ +# Based on https://github.com/godotengine/godot-cpp/blob/ba0edfed90512ec64aba51d4295a3e7e30112f86/tools/common_compiler_flags.py +import os +import subprocess + + +def using_emcc(env): + return "emcc" in os.path.basename(env["CC"]) + + +def using_clang(env): + return "clang" in os.path.basename(env["CC"]) + + +def is_vanilla_clang(env): + if not using_clang(env): + return False + try: + version = subprocess.check_output([env.subst(env["CXX"]), "--version"]).strip().decode("utf-8") + except (subprocess.CalledProcessError, OSError): + print("Couldn't parse CXX environment variable to infer compiler version.") + return False + return not version.startswith("Apple") + + +def exists(env): + return True + + +def generate(env): + assert env["lto"] in ["thin", "full", "none"], "Unrecognized lto: {}".format(env["lto"]) + if env["lto"] != "none": + print("Using LTO: " + env["lto"]) + + # Require C++20 + if env.get("is_msvc", False): + env.Append(CXXFLAGS=["/std:c++20"]) + else: + env.Append(CXXFLAGS=["-std=c++20"]) + + # Disable exception handling. We doesn't use exceptions anywhere, and this + # saves around 20% of binary size and very significant build time. + if env["disable_exceptions"]: + if env.get("is_msvc", False): + env.Append(CPPDEFINES=[("_HAS_EXCEPTIONS", 0)]) + else: + env.Append(CXXFLAGS=["-fno-exceptions"]) + elif env.get("is_msvc", False): + env.Append(CXXFLAGS=["/EHsc"]) + + if not env.get("is_msvc", False): + if env["symbols_visibility"] == "visible": + env.Append(CCFLAGS=["-fvisibility=default"]) + env.Append(LINKFLAGS=["-fvisibility=default"]) + elif env["symbols_visibility"] == "hidden": + env.Append(CCFLAGS=["-fvisibility=hidden"]) + env.Append(LINKFLAGS=["-fvisibility=hidden"]) + + if env["optimize"] == "speed": + env.Append(CPPDEFINES=["OPT_SPEED_ENABLED"]) + elif env["optimize"] == "speed_trace": + env.Append(CPPDEFINES=["OPT_SPEED_TRACE_ENABLED"]) + elif env["optimize"] == "size": + env.Append(CPPDEFINES=["OPT_SIZE_ENABLED"]) + elif env["optimize"] == "debug": + env.Append(CPPDEFINES=["OPT_DEBUG_ENABLED"]) + + if env["harden_memory"] == "fast": + env.Append(CPPDEFINES=["_GLIBCXX_ASSERTIONS", ("_LIBCPP_HARDENING_MODE", "_LIBCPP_HARDENING_MODE_FAST"), ("_MSVC_STL_HARDENING", 1)]) + + # Set optimize and debug_symbols flags. + # "custom" means do nothing and let users set their own optimization flags. + if env.get("is_msvc", False): + if env["debug_symbols"]: + env.Append(CCFLAGS=["/Zi", "/FS"]) + env.Append(LINKFLAGS=["/DEBUG:FULL"]) + + if env["disable_rtti"]: + env.Append(CCFLAGS=["/GR-"]) + + if env["optimize"] == "speed": + env.Append(CCFLAGS=["/O2"]) + env.Append(LINKFLAGS=["/OPT:REF"]) + elif env["optimize"] == "speed_trace": + env.Append(CCFLAGS=["/O2"]) + env.Append(LINKFLAGS=["/OPT:REF", "/OPT:NOICF"]) + elif env["optimize"] == "size": + env.Append(CCFLAGS=["/O1"]) + env.Append(LINKFLAGS=["/OPT:REF"]) + elif env["optimize"] == "debug" or env["optimize"] == "none": + env.Append(CCFLAGS=["/Od"]) + + if env["lto"] == "thin": + if not env["use_llvm"]: + print("ThinLTO is only compatible with LLVM, use `use_llvm=yes` or `lto=full`.") + env.Exit(255) + + env.Append(CCFLAGS=["-flto=thin"]) + env.Append(LINKFLAGS=["-flto=thin"]) + elif env["lto"] == "full": + if env["use_llvm"]: + env.Append(CCFLAGS=["-flto"]) + env.Append(LINKFLAGS=["-flto"]) + else: + env.AppendUnique(CCFLAGS=["/GL"]) + env.AppendUnique(ARFLAGS=["/LTCG"]) + env.AppendUnique(LINKFLAGS=["/LTCG"]) + else: + if env["debug_symbols"]: + # Adding dwarf-4 explicitly makes stacktraces work with clang builds, + # otherwise addr2line doesn't understand them. + env.Append(CCFLAGS=["-gdwarf-4"]) + if using_emcc(env): + # Emscripten only produces dwarf symbols when using "-g3". + env.AppendUnique(CCFLAGS=["-g3"]) + # Emscripten linker needs debug symbols options too. + env.AppendUnique(LINKFLAGS=["-gdwarf-4"]) + env.AppendUnique(LINKFLAGS=["-g3"]) + elif env.dev_build: + env.Append(CCFLAGS=["-g3"]) + else: + env.Append(CCFLAGS=["-g2"]) + else: + if using_clang(env) and not is_vanilla_clang(env) and not env["use_mingw"]: + # Apple Clang, its linker doesn't like -s. + env.Append(LINKFLAGS=["-Wl,-S", "-Wl,-x", "-Wl,-dead_strip"]) + else: + env.Append(LINKFLAGS=["-s"]) + + if env["disable_rtti"]: + env.Append(CCFLAGS=["-fno-rtti"]) + + if env["optimize"] == "speed": + env.Append(CCFLAGS=["-O3"]) + # `-O2` is friendlier to debuggers than `-O3`, leading to better crash backtraces. + elif env["optimize"] == "speed_trace": + env.Append(CCFLAGS=["-O2"]) + elif env["optimize"] == "size": + env.Append(CCFLAGS=["-Os"]) + elif env["optimize"] == "debug": + env.Append(CCFLAGS=["-Og"]) + elif env["optimize"] == "none": + env.Append(CCFLAGS=["-O0"]) + + if env["lto"] == "thin": + if (env["platform"] == "windows" or env["platform"] == "linux") and not env["use_llvm"]: + print("ThinLTO is only compatible with LLVM, use `use_llvm=yes` or `lto=full`.") + env.Exit(255) + env.Append(CCFLAGS=["-flto=thin"]) + env.Append(LINKFLAGS=["-flto=thin"]) + elif env["lto"] == "full": + env.Append(CCFLAGS=["-flto"]) + env.Append(LINKFLAGS=["-flto"]) diff --git a/tools/linux.py b/tools/linux.py index f776bf9..b4af33a 100644 --- a/tools/linux.py +++ b/tools/linux.py @@ -1,5 +1,5 @@ -# Based on https://github.com/godotengine/godot-cpp/blob/e83fd0904c13356ed1d4c3d09f8bb9132bdc6b77/tools/linux.py -from build import common_compiler_flags +# Based on https://github.com/godotengine/godot-cpp/blob/ba0edfed90512ec64aba51d4295a3e7e30112f86/tools/linux.py +import common_compiler_flags from SCons.Variables import BoolVariable from SCons.Tool import clang, clangxx @@ -26,6 +26,10 @@ def generate(env): # Required for hot reload support. env.Append(CXXFLAGS=["-fno-gnu-unique"]) + if env.use_hot_reload: + # Reload won't work with "use_static_cpp", so disable it. + env["use_static_cpp"] = False + env.Append(CCFLAGS=["-fPIC", "-Wwrite-strings"]) env.Append(LINKFLAGS=["-Wl,-R,'$$ORIGIN'"]) diff --git a/tools/macos.py b/tools/macos.py index 35b96b5..33adbe5 100644 --- a/tools/macos.py +++ b/tools/macos.py @@ -1,8 +1,8 @@ -# Based on https://github.com/godotengine/godot-cpp/blob/98ea2f60bb3846d6ae410d8936137d1b099cd50b/tools/macos.py +# Based on https://github.com/godotengine/godot-cpp/blob/ba0edfed90512ec64aba51d4295a3e7e30112f86/tools/macos.py import os import sys -from build import common_compiler_flags +import common_compiler_flags from SCons.Variables import BoolVariable diff --git a/tools/macos_osxcross.py b/tools/macos_osxcross.py deleted file mode 100644 index 8ed9a5d..0000000 --- a/tools/macos_osxcross.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copied from https://github.com/godotengine/godot-cpp/blob/0ee980abae91c481009152cdccab8e61c9625303/tools/macos_osxcross.py -import os - - -def options(opts): - opts.Add("osxcross_sdk", "OSXCross SDK version", "darwin16") - - -def exists(env): - return "OSXCROSS_ROOT" in os.environ - - -def generate(env): - root = os.environ.get("OSXCROSS_ROOT", "") - if env["arch"] == "arm64": - basecmd = root + "/target/bin/arm64-apple-" + env["osxcross_sdk"] + "-" - else: - basecmd = root + "/target/bin/x86_64-apple-" + env["osxcross_sdk"] + "-" - - env["CC"] = basecmd + "clang" - env["CXX"] = basecmd + "clang++" - env["AR"] = basecmd + "ar" - env["RANLIB"] = basecmd + "ranlib" - env["AS"] = basecmd + "as" - - binpath = os.path.join(root, "target", "bin") - if binpath not in env["ENV"]["PATH"]: - # Add OSXCROSS bin folder to PATH (required for linking). - env["ENV"]["PATH"] = "%s:%s" % (binpath, env["ENV"]["PATH"]) diff --git a/tools/scripts.py b/tools/scripts.py new file mode 100644 index 0000000..198eb64 --- /dev/null +++ b/tools/scripts.py @@ -0,0 +1,713 @@ +# Based on https://github.com/godotengine/godot-cpp/blob/ba0edfed90512ec64aba51d4295a3e7e30112f86/tools/godotcpp.py +import atexit +import os +import platform +import sys + +from SCons import __version__ as scons_raw_version +from SCons.Action import Action +from SCons.Builder import Builder +from SCons.Errors import UserError +from SCons.Script import ARGUMENTS +from SCons.Tool import Tool +from SCons.Variables import BoolVariable, EnumVariable, PathVariable +from SCons.Variables.BoolVariable import _text2bool + +from build.glob_recursive import GlobRecursive, GlobRecursiveVariant +from build.pch import setup_pch +from build.cache import show_progress +from build.git_info import get_git_info, git_builder +from build.license_info import license_builder +from build.author_info import author_builder +from build.color import Ansi, is_stdout_color + +def get_cmdline_bool(option, default): + """We use `ARGUMENTS.get()` to check if options were manually overridden on the command line, + and SCons' _text2bool helper to convert them to booleans, otherwise they're handled as strings. + """ + cmdline_val = ARGUMENTS.get(option) + if cmdline_val is not None: + return _text2bool(cmdline_val) + else: + return default + + +def normalize_path(val, env): + """Normalize a path that was provided by the user on the command line + and is thus either an absolute path, or relative to the top level directory (#) + where the command was run. + """ + # If val is an absolute path, it will not be joined. + return os.path.join(env.Dir("#").abspath, val) + + +def validate_file(key, val, env): + if not os.path.isfile(normalize_path(val, env)): + raise UserError("'%s' is not a file: %s" % (key, val)) + + +def validate_dir(key, val, env): + if not os.path.isdir(normalize_path(val, env)): + raise UserError("'%s' is not a directory: %s" % (key, val)) + + +def validate_parent_dir(key, val, env): + if not os.path.isdir(normalize_path(os.path.dirname(val), env)): + raise UserError("'%s' is not a directory: %s" % (key, os.path.dirname(val))) + + +def get_platform_tools_paths(env): + result = [env.Dir("tools").srcnode().abspath] + + project_tools_dir = env.Dir("../tools") + if project_tools_dir.exists(): + result.insert(0, project_tools_dir.srcnode().abspath) + + custom_tools_path = env.get("custom_tools", None) + if not custom_tools_path is None: + result.insert(0, normalize_path(custom_tools_path, env)) + + return result + + +def get_project_platforms(env): + path = env.Dir("../tools") + if not path.exists(): + return [] + platforms = [] + for x in os.listdir(path.srcnode().abspath): + if not x.endswith(".py"): + continue + platforms.append(x.removesuffix(".py")) + return platforms + + +def get_custom_platforms(env): + path = env.get("custom_tools", None) + if path is None: + return [] + platforms = [] + for x in os.listdir(normalize_path(path, env)): + if not x.endswith(".py"): + continue + platforms.append(x.removesuffix(".py")) + return platforms + +def project_info_generator_emitter(target, source, env): + for i in range(len(target)): + target[i] = os.path.join(env["gen_dir"], target[i]) + return target, source + +def no_verbose(env): + colors = [Ansi.BLUE, Ansi.BOLD, Ansi.REGULAR, Ansi.RESET] if is_stdout_color() else ["", "", "", ""] + + # There is a space before "..." to ensure that source file names can be + # Ctrl + clicked in the VS Code terminal. + compile_source_message = "{}Compiling {}$SOURCE{} ...{}".format(*colors) + java_compile_source_message = "{}Compiling {}$SOURCE{} ...{}".format(*colors) + compile_shared_source_message = "{}Compiling shared {}$SOURCE{} ...{}".format(*colors) + link_program_message = "{}Linking Program {}$TARGET{} ...{}".format(*colors) + link_library_message = "{}Linking Static Library {}$TARGET{} ...{}".format(*colors) + ranlib_library_message = "{}Ranlib Library {}$TARGET{} ...{}".format(*colors) + link_shared_library_message = "{}Linking Shared Library {}$TARGET{} ...{}".format(*colors) + java_library_message = "{}Creating Java Archive {}$TARGET{} ...{}".format(*colors) + compiled_resource_message = "{}Creating Compiled Resource {}$TARGET{} ...{}".format(*colors) + zip_archive_message = "{}Archiving {}$TARGET{} ...{}".format(*colors) + generated_file_message = "{}Generating {}$TARGET{} ...{}".format(*colors) + + env["CXXCOMSTR"] = compile_source_message + env["CCCOMSTR"] = compile_source_message + env["SWIFTCOMSTR"] = compile_source_message + env["SHCCCOMSTR"] = compile_shared_source_message + env["SHCXXCOMSTR"] = compile_shared_source_message + env["ARCOMSTR"] = link_library_message + env["RANLIBCOMSTR"] = ranlib_library_message + env["SHLINKCOMSTR"] = link_shared_library_message + env["LINKCOMSTR"] = link_program_message + env["JARCOMSTR"] = java_library_message + env["JAVACCOMSTR"] = java_compile_source_message + env["RCCOMSTR"] = compiled_resource_message + env["ZIPCOMSTR"] = zip_archive_message + env["GENCOMSTR"] = generated_file_message + +def dump(env): + """ + Dumps latest build information for debugging purposes and external tools. + """ + + with open(".scons_env.json", "w", encoding="utf-8", newline="\n") as file: + file.write(env.Dump(format="json")) + +def prepare_purge(env): + from SCons.Script.Main import GetBuildFailures + + def purge_flaky_files(): + paths_to_keep = [] + for build_failure in GetBuildFailures(): + path = build_failure.node.path + if os.path.isfile(path) and path not in paths_to_keep: + os.remove(path) + + atexit.register(purge_flaky_files) + +def prepare_timer(): + import time + + def print_elapsed_time(time_at_start: float): + time_elapsed = time.monotonic() - time_at_start + time_formatted = time.strftime("%Hh %Mm %Ss", time.gmtime(time_elapsed)) + time_centiseconds = (time_elapsed % 1) * 100 + print(f"[BUILD TIMING] elapsed: {time_formatted} {time_centiseconds:02.0f}cs ({time_elapsed:.1f}s total)") + + atexit.register(print_elapsed_time, time.monotonic()) + +def add_library_includes(env, include_dir, expose_includes=True, add_build_dir=False, build_dir=None): + if add_build_dir and not build_dir: + build_dir = env["build_dir"] + + if isinstance(include_dir, list): + result = [] + for d in include_dir: + result.append(add_library_includes(env, d, expose_includes=expose_includes, add_build_dir=add_build_dir, build_dir=build_dir)) + return result + + if isinstance(include_dir, str): + include_dir = env.Dir(include_dir) + + if build_dir: + return add_library_includes(env, [os.path.join(build_dir, include_dir), include_dir], expose_includes=expose_includes) + + env.AppendUnique(CPPPATH=[include_dir]) + + try: + env["INCPATH"] + except Exception: + env["INCPATH"] = [] + env["INCPATH"] += [include_dir] + + if expose_includes: + try: + env.exposed_includes + except Exception: + env.exposed_includes = [] + env.exposed_includes += [include_dir] + + return include_dir + +def add_library_sources(env, src_dir, build_dir=None, glob="*.cpp", exclude=None): + if isinstance(src_dir, list): + result = [] + for d in src_dir: + result.append(add_library_sources(env, src_dir, build_dir, glob, exclude)) + return result + + if not isinstance(src_dir, str): + src_dir = src_dir.srcnode() + + if not build_dir: + build_dir = env["build_dir"] + + try: + env.variant_paths + except Exception: + env.variant_paths = [] + + variant_path = os.path.join(build_dir, src_dir) + if not variant_path in env.variant_paths: + env.VariantDir(variant_path, src_dir, duplicate=False) + + env.variant_paths.append(variant_path) + + env.AppendUnique(CPPPATH=[env.Dir(d) for d in [variant_path, src_dir]]) + + try: + env.sources + except Exception: + env.sources = [] + env.sources += GlobRecursiveVariant(env, glob, src_dir, variant_path, exclude) + + return variant_path + + +def build_base_library(env, target): + library = env.StaticLibrary(target=target, source=env.sources) + env.NoCache(library) + env.Default(library) + env.Clean(library, env.variant_paths) + + env.AppendUnique(LIBPATH=[env.Dir(f.dir) for f in env.File(library)]) + env.PrependUnique(LIBS=library) + return library + +def build_headless_program(env, target, src_dir, defines_prefix, include_lib_src, build_dir=None): + headless_env = env.Clone() + headless_env.Append(CPPDEFINES=[f"{defines_prefix.upper()}_HEADLESS"]) + + if not include_lib_src: + headless_env.sources = [] + + variant_path = add_library_sources(headless_env, src_dir, build_dir=build_dir) + + headless_program = headless_env.Program( + target=target, + source=headless_env.sources, + PROGSUFFIX=".headless" + headless_env["PROGSUFFIX"], + LIBSUFFIX=".headless" + headless_env["LIBSUFFIX"], + OBJSUFFIX=".headless" + headless_env["OBJSUFFIX"], + ) + env.NoCache(headless_program) + env.Default(headless_program) + env.Clean(headless_program, variant_path) + return headless_program + + +def build_unit_test(env, target, src_dir, defines_prefix, build_dir=None, **kwargs): + if not build_dir: + build_dir = env["build_dir"] + + env.Append(CPPDEFINES=[f"{defines_prefix.lower()}_TESTS"]) + + env.sources = [] + variant_path = add_library_sources(env, src_dir, build_dir=os.path.join(build_dir, "tests")) + + env.unit_test = env.Program( + target=target, + source=env.sources, + PROGSUFFIX=".tests" + env["PROGSUFFIX"], + LIBSUFFIX=".tests" + env["LIBSUFFIX"], + OBJSUFFIX=".tests" + env["OBJSUFFIX"], + **kwargs + ) + env.NoCache(env.unit_test) + env.Default(env.unit_test) + env.Clean(env.unit_test, variant_path) + + def run_unit_test(env): + unit_test_action = env.Action(run_unit_test_post_action, None) + test_post_action = env.AddPostAction(env.unit_test, unit_test_action) + env.AlwaysBuild(test_post_action) + + def run_unit_test_post_action(target=None, source=None, env=None): + print() + return subprocess.run([target[0].path]).returncode + + env.AddMethod(run_unit_test, "RunUnitTest") + return env + +def build_dependency_library(parent_env, target, src_dir, include_dir, build_dir=None, glob="*.cpp", exclude=None, shared_library=False): + add_library_includes(parent_env, include_dir) + env = parent_env.Clone() + + if not build_dir: + build_dir = env["build_dir"] + + if exclude is None: + pass + elif isinstance(exclude, str): + exclude = [os.path.join(src_dir, exclude)] + else: + exclude = [os.path.join(src_dir, e) for e in exclude] + + env.sources = [] + variant_path = add_library_sources(env, src_dir, build_dir=build_dir, glob=glob, exclude=exclude) + + if shared_library: + library = env.SharedLibrary( + target=os.path.join(build_dir, target), + source=env.sources + ) + else: + library = env.StaticLibrary( + target=os.path.join(build_dir, target), + source=env.sources + ) + env.NoCache(library) + env.Default(library) + env.Clean(library, variant_path) + + parent_env.AppendUnique(CPPPATH=[env.Dir(include_dir)]) + if parent_env.get("is_msvc", False): + parent_env.Append(CXXFLAGS=["/external:I", env.Dir(include_dir), "/external:W0"]) + else: + parent_env.Append(CXXFLAGS=["-isystem", env.Dir(include_dir)]) + parent_env.AppendUnique(LIBPATH=[env.Dir(build_dir)]) + parent_env.PrependUnique(LIBS=library) + + return env, library + + +# CPU architecture options. +architecture_array = [ + "", + "universal", + "x86_32", + "x86_64", + "arm32", + "arm64", + "rv64", + "ppc32", + "ppc64", + "wasm32", +] +architecture_aliases = { + "x64": "x86_64", + "amd64": "x86_64", + "armv7": "arm32", + "armv8": "arm64", + "arm64v8": "arm64", + "aarch64": "arm64", + "rv": "rv64", + "riscv": "rv64", + "riscv64": "rv64", + "ppcle": "ppc32", + "ppc": "ppc32", + "ppc64le": "ppc64", +} + +platforms = ["linux", "macos", "windows"] + +def exists(env): + return True + +def options(opts, env): + # Try to detect the host platform automatically. + # This is used if no `platform` argument is passed + if sys.platform.startswith("linux"): + default_platform = "linux" + elif sys.platform == "darwin": + default_platform = "macos" + elif sys.platform == "win32" or sys.platform == "msys": + default_platform = "windows" + elif ARGUMENTS.get("platform", ""): + default_platform = ARGUMENTS.get("platform") + else: + raise ValueError("Could not detect platform automatically, please specify with platform=") + + opts.Add( + PathVariable( + key="custom_tools", + help="Path to directory containing custom tools", + default=env.get("custom_tools", None), + validator=validate_dir, + ) + ) + + opts.Update(env) + + project_platforms = get_project_platforms(env) + custom_platforms = get_custom_platforms(env) + + opts.Add( + EnumVariable( + key="platform", + help="Target platform", + default=env.get("platform", default_platform), + allowed_values=platforms + custom_platforms, + ignorecase=2, + ) + ) + + # Editor and template_debug are compatible (i.e. you can use the same binary for editor builds and debug templates). + # Release templates are only compatible with "template_release" builds. + # For this reason, we default to template_debug builds. + opts.Add( + EnumVariable( + key="target", + help="Compilation target", + default=env.get("target", "template_debug"), + allowed_values=("editor", "template_release", "template_debug"), + ) + ) + opts.Add( + EnumVariable( + key="precision", + help="Set the floating-point precision level", + default=env.get("precision", "single"), + allowed_values=("single", "double"), + ) + ) + opts.Add( + EnumVariable( + key="arch", + help="CPU architecture", + default=env.get("arch", ""), + allowed_values=architecture_array, + map=architecture_aliases, + ) + ) + + # compiledb + opts.Add( + BoolVariable( + key="compiledb", + help="Generate compilation DB (`compile_commands.json`) for external tools", + default=env.get("compiledb", False), + ) + ) + opts.Add( + PathVariable( + key="compiledb_file", + help="Path to a custom `compile_commands.json` file", + default=env.get("compiledb_file", "compile_commands.json"), + validator=validate_parent_dir, + ) + ) + + opts.Add( + BoolVariable( + key="use_hot_reload", + help="Enable the extra accounting required to support hot reload.", + default=env.get("use_hot_reload", False), + ) + ) + + opts.Add( + BoolVariable( + "disable_exceptions", "Force disabling exception handling code", default=env.get("disable_exceptions", True) + ) + ) + + opts.Add( + BoolVariable( + "disable_rtti", "Force disabling runtime type information", default=env.get("disable_rtti", True) + ) + ) + + opts.Add( + EnumVariable( + key="symbols_visibility", + help="Symbols visibility on GNU platforms. Use 'auto' to apply the default value.", + default=env.get("symbols_visibility", "hidden"), + allowed_values=["auto", "visible", "hidden"], + ) + ) + + opts.Add( + EnumVariable( + "optimize", + "The desired optimization flags. Inferred from 'target' and 'dev_build' by default.", + "auto", + ("auto", "none", "custom", "debug", "speed", "speed_trace", "size"), + ) + ) + opts.Add( + EnumVariable( + "lto", + "Link-time optimization", + "none", + ("none", "auto", "thin", "full"), + ) + ) + opts.Add(EnumVariable( + "harden_memory", + "Library memory hardening. Inferred from 'dev_build' by default.", + "auto", + ["auto", "none", "fast"], + ignorecase=2 + ) + ) + opts.Add(BoolVariable("debug_symbols", "Build with debugging symbols", True)) + opts.Add(BoolVariable("dev_build", "Developer build with dev-only debugging code (DEV_ENABLED)", False)) + opts.Add(BoolVariable("verbose", "Enable verbose output for the compilation", False)) + opts.Add(BoolVariable("progress", "Show a progress indicator during compilation", True)) + opts.Add(BoolVariable("use_pch", "Enable precompiled headers when the toolchain supports it", True)) + + # Add platform options (custom tools can override platforms) + for pl in sorted(set(platforms + project_platforms + custom_platforms)): + tool = Tool(pl, toolpath=get_platform_tools_paths(env)) + if hasattr(tool, "options"): + tool.options(opts) + +def generate(env): + env.scons_version = env._get_major_minor_revision(scons_raw_version) + + # Default num_jobs to local cpu count if not user specified. + # SCons has a peculiarity where user-specified options won't be overridden + # by SetOption, so we can rely on this to know if we should use our default. + initial_num_jobs = env.GetOption("num_jobs") + altered_num_jobs = initial_num_jobs + 1 + env.SetOption("num_jobs", altered_num_jobs) + if env.GetOption("num_jobs") == altered_num_jobs: + cpu_count = os.cpu_count() + if cpu_count is None: + print("Couldn't auto-detect CPU count to configure build parallelism. Specify it with the -j argument.") + else: + safer_cpu_count = cpu_count if cpu_count <= 4 else cpu_count - 1 + print( + "Auto-detected %d CPU cores available for build parallelism. Using %d cores by default. You can override it with the -j argument." + % (cpu_count, safer_cpu_count) + ) + env.SetOption("num_jobs", safer_cpu_count) + + # Process CPU architecture argument. + if env["arch"] == "": + # No architecture specified. Default to arm64 if building for Android, + # universal if building for macOS or iOS, wasm32 if building for web, + # otherwise default to the host architecture. + if env["platform"] in ["macos", "ios"]: + env["arch"] = "universal" + elif env["platform"] == "android": + env["arch"] = "arm64" + elif env["platform"] == "web": + env["arch"] = "wasm32" + else: + host_machine = platform.machine().lower() + if host_machine in architecture_array: + env["arch"] = host_machine + elif host_machine in architecture_aliases.keys(): + env["arch"] = architecture_aliases[host_machine] + elif "86" in host_machine: + # Catches x86, i386, i486, i586, i686, etc. + env["arch"] = "x86_32" + else: + print("Unsupported CPU architecture: " + host_machine) + env.Exit(1) + + print("Building for architecture " + env["arch"] + " on platform " + env["platform"]) + + # These defaults may be needed by platform tools + env.use_hot_reload = env["use_hot_reload"] + env.editor_build = env["target"] == "editor" + env.dev_build = env["dev_build"] + env.debug_features = env["target"] in ["editor", "template_debug"] + + env.use_hot_reload = env["use_hot_reload"] + env.editor_build = env["target"] == "editor" + env.dev_build = env["dev_build"] + env.debug_features = env["target"] in ["editor", "template_debug"] + + if env["optimize"] == "auto": + if env.dev_build: + opt_level = "none" + elif env.debug_features: + opt_level = "speed_trace" + else: # Release + opt_level = "speed" + + env["optimize"] = ARGUMENTS.get("optimize", opt_level) + + if env["harden_memory"] == "auto": + if env.dev_build: + harden_level = "none" + else: + harden_level = "fast" + + env["harden_memory"] = ARGUMENTS.get("harden_memory", harden_level) + + env["debug_symbols"] = get_cmdline_bool("debug_symbols", env.dev_build) + + tool = Tool(env["platform"], toolpath=get_platform_tools_paths(env)) + + if tool is None or not tool.exists(env): + raise ValueError("Required toolchain not found for platform " + env["platform"]) + + tool.generate(env) + + project_tools_dir = env.Dir("../tools") + if project_tools_dir.exists(): + for pl in sorted(set(get_project_platforms(env)) - set(platforms)): + tool = Tool(pl, toolpath=[project_tools_dir.srcnode().abspath]) + if hasattr(tool, "generate"): + tool.generate(env) + + env.Decider("MD5-timestamp") + + scons_cache_path = os.environ.get("SCONS_CACHE") + if scons_cache_path != None: + CacheDir(scons_cache_path) + print("Scons cache enabled... (path: '" + scons_cache_path + "')") + + # Always presume godot-cpp thread=yes + env.Append(CPPDEFINES=["THREADS_ENABLED"]) + + if env.use_hot_reload: + env.Append(CPPDEFINES=["HOT_RELOAD_ENABLED"]) + + if env.editor_build: + env.Append(CPPDEFINES=["TOOLS_ENABLED"]) + + if env.debug_features: + # DEBUG_ENABLED enables debugging *features* and debug-only code, which is intended + # to give *users* extra debugging information for their development. + env.Append(CPPDEFINES=["DEBUG_ENABLED"]) + + if env.dev_build: + # DEV_ENABLED enables *developer* code which should only be compiled for those + # working on the project itself. + env.Append(CPPDEFINES=["DEV_ENABLED"]) + else: + # Disable assert() for production targets. + env.Append(CPPDEFINES=["NDEBUG"]) + + if env["precision"] == "double": + env.Append(CPPDEFINES=["REAL_T_IS_DOUBLE"]) + + # Suffix + suffix = ".{}.{}".format(env["platform"], env["target"]) + if env.dev_build: + suffix += ".dev" + if env["precision"] == "double": + suffix += ".double" + suffix += "." + env["arch"] + if env["platform"] == "windows": + if env.get("debug_crt", False): + suffix += ".mdd" + elif env.get("use_static_cpp", False): + suffix += ".mt" + else: + suffix += ".md" + if env.get("use_asan", False): + suffix += ".san" + + env["suffix"] = suffix # Exposed when included from another project + env["LIBSUFFIX"] = suffix + env["LIBSUFFIX"] + env["OBJSUFFIX"] = suffix + env["OBJSUFFIX"] + env["PROGSUFFIX"] = suffix + env["PROGSUFFIX"] + + if not "build_dir" in env: + env["build_dir"] = env.Dir(os.path.join(env.get("build_path", "#build"), env["suffix"].lstrip("."))).abspath.replace("\\", "/") + + if "gen_dir" in env: + env["gen_dir"] = os.path.join(env["build_dir"], env["gen_dir"]) + else: + env["gen_dir"] = env["build_dir"] + + if env["compiledb"] and env.is_standalone: + # compile_commands.json + env.Tool("compilation_db") + env.Alias("compiledb", env.CompilationDatabase(normalize_path(env["compiledb_file"], env))) + env.Default("compiledb") + if not env["verbose"]: + env["COMPILATIONDB_COMSTR"] = "$GENCOMSTR" + + # Formatting + if not env["verbose"]: + no_verbose(env) + + # Builders + env.Append( + BUILDERS={ + "License": Builder(action=license_builder, emitter=project_info_generator_emitter), + "Git": Builder(action=git_builder, emitter=project_info_generator_emitter), + "Author": Builder(action=author_builder, emitter=project_info_generator_emitter), + } + ) + env.GlobRecursive = GlobRecursive + env.AddMethod(get_git_info, "GetGitInfo") + env.AddMethod(GlobRecursiveVariant, "GlobRecursiveVariant") + env.AddMethod(setup_pch, "SetupPCH") + + env.AddMethod(add_library_includes, "AddLibraryIncludes") + env.AddMethod(add_library_sources, "AddLibrarySources") + + env.AddMethod(build_base_library, "BuildBaseLibrary") + env.AddMethod(build_headless_program, "BuildHeadlessProgram") + env.AddMethod(build_unit_test, "BuildUnitTest") + env.AddMethod(build_dependency_library, "BuildDependencyLibrary") + + if not env.GetOption("clean") and not env.GetOption("help") and env.is_standalone: + dump(env) + show_progress(env) + prepare_purge(env) + prepare_timer() + diff --git a/tools/targets.py b/tools/targets.py deleted file mode 100644 index 1a562aa..0000000 --- a/tools/targets.py +++ /dev/null @@ -1,126 +0,0 @@ -# Copied from https://github.com/godotengine/godot-cpp/blob/df5b1a9a692b0d972f5ac3c853371594cdec420b/tools/targets.py -import os -import subprocess -import sys -from SCons.Script import ARGUMENTS -from SCons.Variables import EnumVariable, BoolVariable -from SCons.Variables.BoolVariable import _text2bool - - -# Helper methods - - -def get_cmdline_bool(option, default): - """We use `ARGUMENTS.get()` to check if options were manually overridden on the command line, - and SCons' _text2bool helper to convert them to booleans, otherwise they're handled as strings. - """ - cmdline_val = ARGUMENTS.get(option) - if cmdline_val is not None: - return _text2bool(cmdline_val) - else: - return default - - -def using_clang(env): - return "clang" in os.path.basename(env["CC"]) - - -def is_vanilla_clang(env): - if not using_clang(env): - return False - try: - version = subprocess.check_output([env.subst(env["CXX"]), "--version"]).strip().decode("utf-8") - except (subprocess.CalledProcessError, OSError): - print("Couldn't parse CXX environment variable to infer compiler version.") - return False - return not version.startswith("Apple") - - -# Main tool definition - - -def options(opts): - opts.Add( - EnumVariable( - "optimize", - "Optimization level (by default inferred from 'target' and 'dev_build')", - "auto", - ["auto", "none", "custom", "debug", "speed", "speed_trace", "size"], - ignorecase=2, - ) - ) - opts.Add( - EnumVariable( - "lto", - "Link-time optimization", - "none", - ("none", "auto", "thin", "full"), - ) - ) - opts.Add(BoolVariable("debug_symbols", "Build with debugging symbols", True)) - opts.Add(BoolVariable("dev_build", "Developer build with dev-only debugging code (DEV_ENABLED)", False)) - opts.Add(EnumVariable( - "harden_memory", - "Library memory hardening (by default inferred from 'dev_build')", - "auto", - ["auto", "none", "fast"], - ignorecase=2 - ) - ) - - -def exists(env): - return True - - -def generate(env): - # Configuration of build targets: - # - Editor or template - # - Debug features (DEBUG_ENABLED code) - # - Dev only code (DEV_ENABLED code) - # - Optimization level - # - Debug symbols for crash traces / debuggers - - # Keep this configuration in sync with SConstruct in upstream Godot. - - env.use_hot_reload = env.get("use_hot_reload", env["target"] != "template_release") - env.editor_build = env["target"] == "editor" - env.dev_build = env["dev_build"] - env.debug_features = env["target"] in ["editor", "template_debug"] - - if env["optimize"] == "auto": - if env.dev_build: - opt_level = "none" - elif env.debug_features: - opt_level = "speed_trace" - else: # Release - opt_level = "speed" - env["optimize"] = ARGUMENTS.get("optimize", opt_level) - - if env["harden_memory"] == "auto": - if env.dev_build: - harden_level = "none" - else: - harden_level = "fast" - env["harden_memory"] = ARGUMENTS.get("harden_memory", harden_level) - - env["debug_symbols"] = get_cmdline_bool("debug_symbols", env.dev_build) - - if env.use_hot_reload: - env.Append(CPPDEFINES=["HOT_RELOAD_ENABLED"]) - - if env.editor_build: - env.Append(CPPDEFINES=["TOOLS_ENABLED"]) - - if env.debug_features: - # DEBUG_ENABLED enables debugging *features* and debug-only code, which is intended - # to give *users* extra debugging information for their game development. - env.Append(CPPDEFINES=["DEBUG_ENABLED"]) - - if env.dev_build: - # DEV_ENABLED enables *engine developer* code which should only be compiled for those - # working on the engine itself. - env.Append(CPPDEFINES=["DEV_ENABLED"]) - else: - # Disable assert() for production targets (only used in thirdparty code). - env.Append(CPPDEFINES=["NDEBUG"]) diff --git a/tools/windows.py b/tools/windows.py index dbe7222..c6c6553 100644 --- a/tools/windows.py +++ b/tools/windows.py @@ -1,8 +1,8 @@ -# Based on https://github.com/godotengine/godot-cpp/blob/98ea2f60bb3846d6ae410d8936137d1b099cd50b/tools/windows.py +# Based on https://github.com/godotengine/godot-cpp/blob/ba0edfed90512ec64aba51d4295a3e7e30112f86/tools/windows.py import os import sys -from build import common_compiler_flags +import common_compiler_flags import my_spawn from SCons.Tool import mingw, msvc from SCons.Variables import BoolVariable @@ -90,8 +90,6 @@ def exists(env): def generate(env): - base = None - msvc_found = msvc.exists(env) mingw_found = mingw.exists(env)