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..08f311c 100644 --- a/SConstruct +++ b/SConstruct @@ -1,375 +1,83 @@ #!/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 -from typing import List, Union import SCons -_SCRIPTS_DIR = Dir(".").abspath -if _SCRIPTS_DIR not in sys.path: - sys.path.insert(0, _SCRIPTS_DIR) +import build.scripts as scripts_tool -# 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 +try: + Import("build_dir") + env["build_dir"] = build_dir +except Exception: + pass -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)) +try: + Import("gen_dir") + env["gen_dir"] = gen_dir +except Exception: + pass - env.extra_suffix = "" +env.is_standalone = is_standalone - if env["platform"] in unsupported_known_platforms: - print("Unsupported platform: " + env["platform"]+". Only supports " + ", ".join(set(platforms) - set(unsupported_known_platforms))) - Exit() +env.PrependENVPath("PATH", os.getenv("PATH")) - # 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" +if env.get("parent_env", None) is not None: + skip_parent_item = True + for key in parent_env.Dictionary(): + if skip_parent_item: + if key == "arch": + skip_parent_item = False 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() + continue + env[key] = parent_env[key] - 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) - -def Run(env, function, **kwargs): - return SCons.Action.Action(function, "$GENCOMSTR", **kwargs) - -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.to_raw_cstring = to_raw_cstring -env.to_escaped_cstring = to_escaped_cstring - -env.__class__.Run = Run -env.__class__.CommandNoCache = CommandNoCache - -Return("env") \ No newline at end of file +# 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 "custom_tools" not in env: + try: + Import("custom_tools") + env["custom_tools"] = custom_tools + except Exception: + pass + +opts = Variables(customs, ARGUMENTS) +scripts_tool.options(opts, env) +opts.Update(env) + +Help(opts.GenerateHelpText(env)) + +scripts_tool.generate(env) + +# Prevent this scripts' from polluting later module imports. +sys.path.remove(Dir(".").srcnode().abspath) + +Return("env") 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/gch.py b/build/gch.py new file mode 100644 index 0000000..45f1e33 --- /dev/null +++ b/build/gch.py @@ -0,0 +1,93 @@ +import SCons.Action +import SCons.Builder +import SCons.Errors +import SCons.Tool +import SCons.Util + + +def gch_emitter(target, source, env): + """Ensure proper suffix and dependency tracking.""" + + gch = None + + for t in target: + if SCons.Util.splitext(str(t))[1] == ".gch": + gch = t + + target = [gch] + return (target, source) + + +gch_action = SCons.Action.Action("$GCHCOM", "$GCHCOMSTR") +gch_builder = SCons.Builder.Builder( + action=gch_action, suffix=".gch", emitter=gch_emitter, source_scanner=SCons.Tool.SourceFileScanner +) + +gchsh_action = SCons.Action.Action("$GCHSHCOM", "$GCHSHCOMSTR") +gchsh_builder = SCons.Builder.Builder( + action=gchsh_action, suffix=".gch", emitter=gch_emitter, source_scanner=SCons.Tool.SourceFileScanner +) + + +def get_gch_node(env, target, source): + """ + Get the actual GCH file node + """ + gch_subst = env.get("GCH", False) and env.subst("$GCH", target=target, source=source, conv=lambda x: x) + + if not gch_subst: + return "" + + if SCons.Util.is_String(gch_subst): + gch_subst = target[0].dir.File(gch_subst) + + return gch_subst + + +def add_gch_builder(env): + """Add Gch / GchSh builders to the environment.""" + + if not env.Detect(["g++", "gcc", "clang++"]): + return + + # Command to build a gch from a header + env["GCHCOM"] = "$CXX -x c++-header -o $TARGET $CXXFLAGS $CCFLAGS $_CCCOMCOM $SOURCE" + env["BUILDERS"]["GCH"] = gch_builder + + env["GCHSHCOM"] = "$CXX -x c++-header -o $TARGET $SHCXXFLAGS $SHCCFLAGS $_CCCOMCOM $SOURCE" + env["BUILDERS"]["GCHSH"] = gchsh_builder + + def gch_dependent_emitter(target, source, env, parent_emitter): + parent_emitter(target, source, env) + + if not env.get("GCH"): + return target, source + + gch = get_gch_node(env, target, source) + if gch: + if str(target[0]) not in [SCons.Util.splitext(str(gch))[0] + s for s in [".so", ".os", ".o"]]: + env.Depends(target, gch) + + return target, source + + def gch_dependent_static_emitter(target, source, env): + return gch_dependent_emitter(target, source, env, SCons.Defaults.StaticObjectEmitter) + + def gch_dependent_shared_emitter(target, source, env): + return gch_dependent_emitter(target, source, env, SCons.Defaults.SharedObjectEmitter) + + modify_builders = [ + ("StaticObject", gch_dependent_static_emitter), + ("SharedObject", gch_dependent_shared_emitter), + ("Object", gch_dependent_static_emitter), + ] + + for builder_tuple in modify_builders: + if builder_tuple[0] not in env["BUILDERS"]: + continue + + bld = env["BUILDERS"][builder_tuple[0]] + bld.add_emitter(".cpp", builder_tuple[1]) + bld.add_emitter(".cc", builder_tuple[1]) + bld.add_emitter(".cxx", builder_tuple[1]) + bld.add_emitter(".c", builder_tuple[1]) 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/library_builders.py b/build/library_builders.py new file mode 100644 index 0000000..5e8f0b5 --- /dev/null +++ b/build/library_builders.py @@ -0,0 +1,240 @@ +import os + +from build.glob_recursive import GlobRecursiveVariant + + +def add_library_includes(env, include_dir, expose_includes=True, add_build_dir=False, build_dir=None): + """ + include_dir is the location(s) to add to the CPPPATH + expose_includes determines whether location(s) are added to env.exposed_includes + add_build_dir determines whether the include automatically includes env["build_dir"] (does nothing if build_dir is specified) + build_dir is the VariantDir to include alongside include_dir as env["build_dir"]/include_dir + """ + + 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): + """ + src_dir is the root directory to glob for files from + build_dir is the VariantDir to source from and build files into + glob is a directory recursive glob over files inside src_dir + exclude are the files or list of files to exclude from the glob + """ + + 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 variant_path not 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, build_dir=None): + """ + target is the target to build + build_dir is the VariantDir to build the library into + """ + + if not build_dir: + build_dir = env["build_dir"] + + library = env.StaticLibrary(target=os.path.join(build_dir, target), source=env.sources) + env.NoCache(library) + env.Default(library) + env.Clean(library, env.variant_paths) + if env.is_standalone: + env.Default(env.Install(env.Dir(env.File(target).dir), library)) + + 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): + """ + target is the target to build + src_dir is the headless source directory + defines_prefix is the prefix (to be uppercased) for the HEADLESS define + include_lib_src determines whether the headless environment includes previous sources + build_dir is the VariantDir to build the program into + """ + + 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=os.path.join(build_dir, 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) + if env.is_standalone: + env.Default(env.Install(env.Dir(env.File(target).dir), headless_program)) + + return headless_program + + +def build_unit_test(env, target, src_dir, defines_prefix, build_dir=None, **kwargs): + """ + target is the target to build + src_dir is the unit test source directory + defines_prefix is the prefix (to be uppercased) for the TESTS define + build_dir is the VariantDir to build the program into + kwargs are passed to env.Program as is + """ + + 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=os.path.join(build_dir, 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) + if env.is_standalone: + env.Default(env.Install(env.Dir(env.File(target).dir), env.unit_test)) + + def run_unit_test(env): + def run_unit_test_post_action(target=None, source=None, env=None): + import subprocess + + print() + return subprocess.run([target[0].path]).returncode + + 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) + + 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 +): + """ + target is the target to build + src_dir is the library source directory + include_dir is library include directory + build_dir is the VariantDir to build the library into + glob is a directory recursive glob over files inside src_dir + exclude are the files or list of files to exclude from the glob + shared_library determines whether the library is built as a shared library + """ + + 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 diff --git a/build/license_info.py b/build/license_info.py index 7652328..daa3056 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/no_verbose.py b/build/no_verbose.py new file mode 100644 index 0000000..3d53148 --- /dev/null +++ b/build/no_verbose.py @@ -0,0 +1,37 @@ +def no_verbose(env): + from build.color import Ansi, is_stdout_color + + 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) + precompile_header_message = "{}Precompiling {}$SOURCE{} ...{}".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["PCHCOMSTR"] = precompile_header_message + env["GCHCOMSTR"] = precompile_header_message + env["GCHSHCOMSTR"] = precompile_header_message + env["GENCOMSTR"] = generated_file_message diff --git a/build/pch.py b/build/pch.py deleted file mode 100644 index a93271e..0000000 --- a/build/pch.py +++ /dev/null @@ -1,25 +0,0 @@ -def setup_pch(env, header_rel, source_cpp_variant): - if not env.get("use_pch", True): - return - - handler = _select_handler(env) - if handler is None: - return - - name = handler(env, header_rel, source_cpp_variant) - print(f"[PCH] enabled ({name}): {header_rel}") - - -def _select_handler(env): - if env.get("is_msvc", False): - return _msvc_pch - # TODO: GCC/Clang/MinGW - return None - - -def _msvc_pch(env, header_rel, source_cpp_variant): - env["PCHSTOP"] = header_rel - pch_pch, _pch_obj = env.PCH(source_cpp_variant) - env["PCH"] = pch_pch - env.Append(CCFLAGS=["/FI" + header_rel]) - return "msvc" diff --git a/build/scripts.py b/build/scripts.py new file mode 100644 index 0000000..d852f3b --- /dev/null +++ b/build/scripts.py @@ -0,0 +1,520 @@ +# 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.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.author_info import author_builder +from build.cache import show_progress +from build.gch import add_gch_builder +from build.git_info import get_git_info, git_builder +from build.glob_recursive import GlobRecursive, GlobRecursiveVariant +from build.library_builders import ( + add_library_includes, + add_library_sources, + build_base_library, + build_dependency_library, + build_headless_program, + build_unit_test, +) +from build.license_info import license_builder +from build.no_verbose import no_verbose + + +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 custom_tools_path is not 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 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()) + + +# 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"] + + opt_level = env["optimize"] + 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) + + harden_level = env["harden_memory"] + 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 is not None: + env.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 "build_dir" not 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(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") + + add_gch_builder(env) + + 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/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/pyproject.toml b/pyproject.toml index 383a144..2dcb92e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ explicit_package_bases = true [tool.ruff] extend-include = ["SConstruct", "SCsub"] line-length = 120 -target-version = "py37" +target-version = "py310" [tool.ruff.lint] extend-select = [ diff --git a/tools/linux.py b/tools/linux.py index f776bf9..e5e0cbd 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 scripts_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'"]) @@ -91,4 +95,4 @@ def generate(env): if env["lto"] == "auto": env["lto"] = "full" - common_compiler_flags.generate(env) + scripts_flags.generate(env) diff --git a/tools/macos.py b/tools/macos.py index 35b96b5..cc8cce4 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 scripts_flags from SCons.Variables import BoolVariable @@ -102,4 +102,4 @@ def generate(env): if env["lto"] == "auto": env["lto"] = "none" - common_compiler_flags.generate(env) + scripts_flags.generate(env) 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/build/common_compiler_flags.py b/tools/scripts_flags.py similarity index 89% rename from build/common_compiler_flags.py rename to tools/scripts_flags.py index 67e5ac1..0a602ca 100644 --- a/build/common_compiler_flags.py +++ b/tools/scripts_flags.py @@ -1,8 +1,12 @@ -# Based on https://github.com/godotengine/godot-cpp/blob/98ea2f60bb3846d6ae410d8936137d1b099cd50b/tools/common_compiler_flags.py +# 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"]) @@ -33,10 +37,7 @@ def generate(env): else: env.Append(CXXFLAGS=["-std=c++20"]) - if env["precision"] == "double": - env.Append(CPPDEFINES=["REAL_T_IS_DOUBLE"]) - - # Disable exception handling. Godot doesn't use exceptions anywhere, and this + # 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): @@ -108,7 +109,13 @@ def generate(env): # Adding dwarf-4 explicitly makes stacktraces work with clang builds, # otherwise addr2line doesn't understand them. env.Append(CCFLAGS=["-gdwarf-4"]) - if env.dev_build: + 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"]) 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..36f35ee 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 scripts_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) @@ -230,4 +228,4 @@ def generate(env): else: # Release env["lto"] = "full" - common_compiler_flags.generate(env) + scripts_flags.generate(env)