diff --git a/.gitignore b/.gitignore index 02d09c9..d3eb7d1 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,13 @@ pids *.pid.lock imgui.ini +# Python bytecode (bin/dev and bin/dev_commands) +__pycache__/ + +# Tracy captures +traces/ +*.tracy + # dotenv environment variables file .envrc .env diff --git a/bin/dev b/bin/dev index fc9ca41..ccd8c2c 100755 --- a/bin/dev +++ b/bin/dev @@ -2,13 +2,16 @@ import json import os -import re import shlex import shutil -import struct import subprocess import sys +from helpers.gitea import GiteaCommand +from helpers.renderdoc import RenderDocCommand +from helpers.shaders import ShadersCommand +from helpers.tracy import TracyCommand + COMMAND_TO_METHOD = { "branch-from": "branch_from", "bash-completions": "bash_completions", @@ -17,8 +20,7 @@ METHOD_TO_COMMAND = {method: command for command, method in COMMAND_TO_METHOD.it WAIT_FOR_PROCESS = "wait_for_process" -class DevHelper: - SHADER_EXTENSIONS = (".vert", ".frag", ".comp", ".geom", ".tesc", ".tese") +class DevCommand(GiteaCommand, RenderDocCommand, ShadersCommand, TracyCommand): PROJECT_NAME = "sophia" DEFAULT_PRESET = "debug" @@ -78,111 +80,6 @@ class DevHelper: os.chdir(preset_dir) os.execv(binary, [binary, *args]) - ## - # Checks out a branch named after a Gitea issue URL. - # If the issue is from this project's repo, the branch name will be: - # issue-/ - # If the issue is from another repo (presumably upstream), the branch name will be: - # -issue-/ - def branch_from(self, *args, **kwargs): - issue_url = args[0] if args else None - if issue_url is None: - raise ValueError("Must provide a Gitea issue URL.") - - base_url, issue_repo, issue_number = self._parse_issue_url(issue_url) - issue_title = self._fetch_issue_title(base_url, issue_repo, issue_number) - branch_name = self._format_branch_name(issue_repo, issue_number, issue_title) - - exists = subprocess.run( - ["git", "-C", self._project_root(), "rev-parse", "--verify", "--quiet", f"refs/heads/{branch_name}"], - stdout=subprocess.DEVNULL, - ).returncode == 0 - flag = "" if exists else "-b " - self._exec_in_root(f"git checkout {flag}{shlex.quote(branch_name)}", replace=True) - - ## - # Creates an issue on this project's Gitea repo. - # dev issue [-b|--body <text>] [-l|--label <name>]... [-a|--assignee <user>]... [-o|--open] - # The body is read from stdin if --body is not given and stdin is piped. - # Gitea's API cannot add issues to a project (only the web UI can), so the - # issue URL is printed and --open opens it in the browser to do so. - def issue(self, *args, **kwargs): - title, body, labels, assignees, open_in_browser = self._parse_issue_args(args) - - repo = self._origin_repo() - if repo is None: - raise RuntimeError("could not determine the Gitea repo from the origin remote") - - payload = {"title": title, "body": body} - if labels: - label_ids = self._fetch_label_ids(repo) - unknown = [label for label in labels if label not in label_ids] - if unknown: - raise ValueError(f"Unknown label(s): {', '.join(unknown)} (available: {', '.join(sorted(label_ids))})") - payload["labels"] = [label_ids[label] for label in labels] - if assignees: - payload["assignees"] = assignees - - issue = self._tea_api(f"repos/{repo}/issues", payload) - print(f"Created issue #{issue['number']}: {issue['title']}") - print(issue["html_url"]) - print(f"Add it to the project from the issue sidebar: Projects > {self.PROJECT_NAME}") - - if open_in_browser: - opener = shutil.which("xdg-open") or shutil.which("open") - if opener is None: - raise RuntimeError("xdg-open not found on PATH") - subprocess.Popen([opener, issue["html_url"]], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - def renderdoc(self, *args, **kwargs): - qrenderdoc = self._find_qrenderdoc() - self._exec_in_root(qrenderdoc, replace=True) - - ## - # Builds the Tracy profiler (viewer) from the third_party/tracy submodule, - # then runs it. Building from the submodule keeps the viewer's version in - # sync with the client compiled into the engine. - # dev tracy [--fresh] [args...] - # Configures only when there is no build yet (or --fresh), then builds - # incrementally. Other arguments are passed through to the profiler. - def tracy(self, *args, **kwargs): - fresh = "--fresh" in args - args = [arg for arg in args if arg != "--fresh"] - - source_dir = os.path.join("third_party", "tracy", "profiler") - build_dir = os.path.join(source_dir, "build") - if fresh or not os.path.isfile(os.path.join(self._project_root(), build_dir, "CMakeCache.txt")): - configure = ["cmake", "-B", build_dir, "-S", source_dir, "-DCMAKE_BUILD_TYPE=Release"] - if fresh: - configure.append("--fresh") - self._run_in_root(configure) - self._run_in_root(["cmake", "--build", build_dir, "--parallel"]) - - binary = os.path.join(self._project_root(), build_dir, "tracy-profiler") - print(f"Running: {shlex.join([binary, *args])}") - os.execv(binary, [binary, *args]) - - def shaders(self, *args, **kwargs): - shaders_dir = os.path.join(self._project_root(), "engine", "src", "shaders") - if not os.path.isdir(shaders_dir): - raise ValueError(f"Shaders directory not found: {shaders_dir}") - - glslc = self._find_glslc() - - shader_paths = sorted( - os.path.join(root, filename) - for root, _dirs, files in os.walk(shaders_dir) - for filename in files - if filename.endswith(self.SHADER_EXTENSIONS) - ) - - if not shader_paths: - print(f"No shaders found in {shaders_dir}") - return - - for shader_path in shader_paths: - self._generate_shader_header(glslc, shader_path) - # Custom helpers def ownit(self, *args, **kwargs): @@ -204,8 +101,8 @@ class DevHelper: def _commands(self): return sorted( METHOD_TO_COMMAND.get(name, name) - for name in vars(DevHelper) - if not name.startswith("_") and name != "call" and callable(getattr(DevHelper, name)) + for name in dir(DevCommand) + if not name.startswith("_") and name != "call" and callable(getattr(DevCommand, name)) ) def _parse_preset_args(self, args, command): @@ -254,166 +151,6 @@ class DevHelper: if result.returncode != 0: sys.exit(result.returncode) - def _parse_issue_url(self, url): - match = re.match(r"^(https?://[^/]+)/([^/]+)/([^/]+)/issues/(\d+)", url) - if match is None: - raise ValueError(f"Not a Gitea issue URL: {url}") - return match[1], f"{match[2]}/{match[3]}", match[4] - - def _fetch_issue_title(self, base_url, repo, issue_number): - if shutil.which("tea") is None: - raise RuntimeError("tea not found on PATH (install gitea tea and run `tea login add`)") - - endpoint = f"{base_url}/api/v1/repos/{repo}/issues/{issue_number}" - print(f"Running: tea api {endpoint}") - result = subprocess.run(["tea", "api", endpoint], stdout=subprocess.PIPE, text=True) - if result.returncode != 0: - raise RuntimeError(f"failed to fetch issue title from {endpoint}") - return json.loads(result.stdout)["title"] - - def _parse_issue_args(self, args): - title = None - body = None - labels = [] - assignees = [] - open_in_browser = False - - args = list(args) - while args: - arg = args.pop(0) - if arg in ("-b", "--body", "-l", "--label", "-a", "--assignee"): - if not args: - raise ValueError(f"{arg} requires a value") - value = args.pop(0) - if arg in ("-b", "--body"): - body = value - elif arg in ("-l", "--label"): - labels.append(value) - else: - assignees.append(value) - elif arg in ("-o", "--open"): - open_in_browser = True - elif arg.startswith("-"): - raise ValueError(f"Unknown option: {arg}") - elif title is None: - title = arg - else: - title = f"{title} {arg}" - - if not title: - raise ValueError("Must provide an issue title. Usage: dev issue <title> [-b <body>] [-l <label>]... [-a <user>]... [-o]") - if body is None: - body = "" if sys.stdin.isatty() else sys.stdin.read() - return title, body, labels, assignees, open_in_browser - - def _tea_api(self, endpoint, payload=None): - if shutil.which("tea") is None: - raise RuntimeError("tea not found on PATH (install gitea tea and run `tea login add`)") - - command = ["tea", "api", endpoint] - if payload is not None: - command += ["-d", "@-"] - print(f"Running: {' '.join(command)}") - result = subprocess.run( - command, - input=json.dumps(payload) if payload is not None else None, - stdout=subprocess.PIPE, - text=True, - ) - if result.returncode != 0: - raise RuntimeError(f"tea api request failed: {endpoint}") - return json.loads(result.stdout) - - def _fetch_label_ids(self, repo): - labels = self._tea_api(f"repos/{repo}/labels?limit=100") - return {label["name"]: label["id"] for label in labels} - - def _format_branch_name(self, issue_repo, issue_number, issue_title): - formatted_title = re.sub(r"-+", "-", re.sub(r"[^a-z0-9\-]", "", re.sub(r"\s+", "-", issue_title.lower().strip()))) - if issue_repo == self._origin_repo(): - return f"issue-{issue_number}/{formatted_title}" - issue_owner = issue_repo.split("/")[0] - return f"{issue_owner}-issue-{issue_number}/{formatted_title}" - - def _origin_repo(self): - result = subprocess.run( - ["git", "-C", self._project_root(), "remote", "get-url", "origin"], - stdout=subprocess.PIPE, - text=True, - ) - match = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", result.stdout.strip()) - return match[1] if match else None - - def _find_glslc(self): - glslc = shutil.which("glslc") - if glslc is None: - raise RuntimeError( - "glslc not found on PATH (install the Vulkan SDK)" - ) - return glslc - - def _find_qrenderdoc(self): - qrenderdoc = shutil.which("qrenderdoc") - if qrenderdoc is None: - raise RuntimeError( - "qrenderdoc not found on PATH (install RenderDoc)" - ) - return qrenderdoc - - def _generate_shader_header(self, glslc, shader_path): - relative_path = os.path.relpath(shader_path, self._project_root()) - print(f"Compiling shader: {relative_path}") - - result = subprocess.run( - [glslc, shader_path, "-o", "-"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - if result.returncode != 0: - raise RuntimeError( - f"failed to compile {shader_path}: {result.stderr.decode()}" - ) - - spirv = result.stdout - if len(spirv) % 4 != 0: - raise RuntimeError( - f"invalid SPIR-V output for {shader_path} (size not a multiple of 4)" - ) - - word_count = len(spirv) // 4 - words = struct.unpack(f"<{word_count}I", spirv) - - filename = os.path.basename(shader_path) - variable_name = f"{filename.replace('.', '_')}_spv" - header_path = shader_path + ".spv.h" - - body_lines = [ - " " + ", ".join(f"0x{word:08x}" for word in words[i:i + 4]) + "," - for i in range(0, len(words), 4) - ] - - header = "\n".join([ - "#pragma once", - "", - "#include <array>", - "#include <cstdint>", - "", - "namespace sophia::shaders {", - "", - f"inline constexpr std::array<uint32_t, {word_count}> {variable_name} = {{", - *body_lines, - "};", - "", - "} // namespace sophia::shaders", - "", - ]) - - with open(header_path, "w") as f: - f.write(header) - - print(f"Wrote {os.path.relpath(header_path, self._project_root())} " - f"({word_count * 4} bytes)") - def _wait_for_process_with_logging(self, command): process = self._exec(command, capture=True) for line in process.stdout: @@ -457,4 +194,4 @@ class DevHelper: if __name__ == "__main__": - DevHelper().call(*sys.argv[1:]) + DevCommand().call(*sys.argv[1:]) diff --git a/bin/helpers/gitea.py b/bin/helpers/gitea.py new file mode 100644 index 0000000..fa1d087 --- /dev/null +++ b/bin/helpers/gitea.py @@ -0,0 +1,156 @@ +import json +import re +import shlex +import shutil +import subprocess +import sys + + +# Mixed into DevCommand (bin/dev), which provides PROJECT_NAME, _project_root +# and _exec_in_root. +class GiteaCommand: + ## + # Checks out a branch named after a Gitea issue URL. + # If the issue is from this project's repo, the branch name will be: + # issue-<issue_number>/<issue_title> + # If the issue is from another repo (presumably upstream), the branch name will be: + # <issue_owner>-issue-<issue_number>/<issue_title> + def branch_from(self, *args, **kwargs): + issue_url = args[0] if args else None + if issue_url is None: + raise ValueError("Must provide a Gitea issue URL.") + + base_url, issue_repo, issue_number = self._parse_issue_url(issue_url) + issue_title = self._fetch_issue_title(base_url, issue_repo, issue_number) + branch_name = self._format_branch_name(issue_repo, issue_number, issue_title) + + exists = subprocess.run( + ["git", "-C", self._project_root(), "rev-parse", "--verify", "--quiet", f"refs/heads/{branch_name}"], + stdout=subprocess.DEVNULL, + ).returncode == 0 + flag = "" if exists else "-b " + self._exec_in_root(f"git checkout {flag}{shlex.quote(branch_name)}", replace=True) + + ## + # Creates an issue on this project's Gitea repo. + # dev issue <title> [-b|--body <text>] [-l|--label <name>]... [-a|--assignee <user>]... [-o|--open] + # The body is read from stdin if --body is not given and stdin is piped. + # Gitea's API cannot add issues to a project (only the web UI can), so the + # issue URL is printed and --open opens it in the browser to do so. + def issue(self, *args, **kwargs): + title, body, labels, assignees, open_in_browser = self._parse_issue_args(args) + + repo = self._origin_repo() + if repo is None: + raise RuntimeError("could not determine the Gitea repo from the origin remote") + + payload = {"title": title, "body": body} + if labels: + label_ids = self._fetch_label_ids(repo) + unknown = [label for label in labels if label not in label_ids] + if unknown: + raise ValueError(f"Unknown label(s): {', '.join(unknown)} (available: {', '.join(sorted(label_ids))})") + payload["labels"] = [label_ids[label] for label in labels] + if assignees: + payload["assignees"] = assignees + + issue = self._tea_api(f"repos/{repo}/issues", payload) + print(f"Created issue #{issue['number']}: {issue['title']}") + print(issue["html_url"]) + print(f"Add it to the project from the issue sidebar: Projects > {self.PROJECT_NAME}") + + if open_in_browser: + opener = shutil.which("xdg-open") or shutil.which("open") + if opener is None: + raise RuntimeError("xdg-open not found on PATH") + subprocess.Popen([opener, issue["html_url"]], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + def _parse_issue_url(self, url): + match = re.match(r"^(https?://[^/]+)/([^/]+)/([^/]+)/issues/(\d+)", url) + if match is None: + raise ValueError(f"Not a Gitea issue URL: {url}") + return match[1], f"{match[2]}/{match[3]}", match[4] + + def _fetch_issue_title(self, base_url, repo, issue_number): + if shutil.which("tea") is None: + raise RuntimeError("tea not found on PATH (install gitea tea and run `tea login add`)") + + endpoint = f"{base_url}/api/v1/repos/{repo}/issues/{issue_number}" + print(f"Running: tea api {endpoint}") + result = subprocess.run(["tea", "api", endpoint], stdout=subprocess.PIPE, text=True) + if result.returncode != 0: + raise RuntimeError(f"failed to fetch issue title from {endpoint}") + return json.loads(result.stdout)["title"] + + def _parse_issue_args(self, args): + title = None + body = None + labels = [] + assignees = [] + open_in_browser = False + + args = list(args) + while args: + arg = args.pop(0) + if arg in ("-b", "--body", "-l", "--label", "-a", "--assignee"): + if not args: + raise ValueError(f"{arg} requires a value") + value = args.pop(0) + if arg in ("-b", "--body"): + body = value + elif arg in ("-l", "--label"): + labels.append(value) + else: + assignees.append(value) + elif arg in ("-o", "--open"): + open_in_browser = True + elif arg.startswith("-"): + raise ValueError(f"Unknown option: {arg}") + elif title is None: + title = arg + else: + title = f"{title} {arg}" + + if not title: + raise ValueError("Must provide an issue title. Usage: dev issue <title> [-b <body>] [-l <label>]... [-a <user>]... [-o]") + if body is None: + body = "" if sys.stdin.isatty() else sys.stdin.read() + return title, body, labels, assignees, open_in_browser + + def _tea_api(self, endpoint, payload=None): + if shutil.which("tea") is None: + raise RuntimeError("tea not found on PATH (install gitea tea and run `tea login add`)") + + command = ["tea", "api", endpoint] + if payload is not None: + command += ["-d", "@-"] + print(f"Running: {' '.join(command)}") + result = subprocess.run( + command, + input=json.dumps(payload) if payload is not None else None, + stdout=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"tea api request failed: {endpoint}") + return json.loads(result.stdout) + + def _fetch_label_ids(self, repo): + labels = self._tea_api(f"repos/{repo}/labels?limit=100") + return {label["name"]: label["id"] for label in labels} + + def _format_branch_name(self, issue_repo, issue_number, issue_title): + formatted_title = re.sub(r"-+", "-", re.sub(r"[^a-z0-9\-]", "", re.sub(r"\s+", "-", issue_title.lower().strip()))) + if issue_repo == self._origin_repo(): + return f"issue-{issue_number}/{formatted_title}" + issue_owner = issue_repo.split("/")[0] + return f"{issue_owner}-issue-{issue_number}/{formatted_title}" + + def _origin_repo(self): + result = subprocess.run( + ["git", "-C", self._project_root(), "remote", "get-url", "origin"], + stdout=subprocess.PIPE, + text=True, + ) + match = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", result.stdout.strip()) + return match[1] if match else None diff --git a/bin/helpers/renderdoc.py b/bin/helpers/renderdoc.py new file mode 100644 index 0000000..973a0ef --- /dev/null +++ b/bin/helpers/renderdoc.py @@ -0,0 +1,16 @@ +import shutil + + +# Mixed into DevCommand (bin/dev), which provides _exec_in_root. +class RenderDocCommand: + def renderdoc(self, *args, **kwargs): + qrenderdoc = self._find_qrenderdoc() + self._exec_in_root(qrenderdoc, replace=True) + + def _find_qrenderdoc(self): + qrenderdoc = shutil.which("qrenderdoc") + if qrenderdoc is None: + raise RuntimeError( + "qrenderdoc not found on PATH (install RenderDoc)" + ) + return qrenderdoc diff --git a/bin/helpers/shaders.py b/bin/helpers/shaders.py new file mode 100644 index 0000000..bc11e1f --- /dev/null +++ b/bin/helpers/shaders.py @@ -0,0 +1,92 @@ +import os +import shutil +import struct +import subprocess + + +# Mixed into DevCommand (bin/dev), which provides _project_root. +class ShadersCommand: + SHADER_EXTENSIONS = (".vert", ".frag", ".comp", ".geom", ".tesc", ".tese") + + def shaders(self, *args, **kwargs): + shaders_dir = os.path.join(self._project_root(), "engine", "src", "shaders") + if not os.path.isdir(shaders_dir): + raise ValueError(f"Shaders directory not found: {shaders_dir}") + + glslc = self._find_glslc() + + shader_paths = sorted( + os.path.join(root, filename) + for root, _dirs, files in os.walk(shaders_dir) + for filename in files + if filename.endswith(self.SHADER_EXTENSIONS) + ) + + if not shader_paths: + print(f"No shaders found in {shaders_dir}") + return + + for shader_path in shader_paths: + self._generate_shader_header(glslc, shader_path) + + def _find_glslc(self): + glslc = shutil.which("glslc") + if glslc is None: + raise RuntimeError( + "glslc not found on PATH (install the Vulkan SDK)" + ) + return glslc + + def _generate_shader_header(self, glslc, shader_path): + relative_path = os.path.relpath(shader_path, self._project_root()) + print(f"Compiling shader: {relative_path}") + + result = subprocess.run( + [glslc, shader_path, "-o", "-"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + raise RuntimeError( + f"failed to compile {shader_path}: {result.stderr.decode()}" + ) + + spirv = result.stdout + if len(spirv) % 4 != 0: + raise RuntimeError( + f"invalid SPIR-V output for {shader_path} (size not a multiple of 4)" + ) + + word_count = len(spirv) // 4 + words = struct.unpack(f"<{word_count}I", spirv) + + filename = os.path.basename(shader_path) + variable_name = f"{filename.replace('.', '_')}_spv" + header_path = shader_path + ".spv.h" + + body_lines = [ + " " + ", ".join(f"0x{word:08x}" for word in words[i:i + 4]) + "," + for i in range(0, len(words), 4) + ] + + header = "\n".join([ + "#pragma once", + "", + "#include <array>", + "#include <cstdint>", + "", + "namespace sophia::shaders {", + "", + f"inline constexpr std::array<uint32_t, {word_count}> {variable_name} = {{", + *body_lines, + "};", + "", + "} // namespace sophia::shaders", + "", + ]) + + with open(header_path, "w") as f: + f.write(header) + + print(f"Wrote {os.path.relpath(header_path, self._project_root())} " + f"({word_count * 4} bytes)") diff --git a/bin/helpers/tracy.py b/bin/helpers/tracy.py new file mode 100644 index 0000000..6a819d4 --- /dev/null +++ b/bin/helpers/tracy.py @@ -0,0 +1,85 @@ +import os +import re +import shlex +import time + + +# Mixed into DevCommand (bin/dev), which provides _project_root and +# _run_in_root. +class TracyCommand: + ## + # Builds a Tracy tool from the third_party/tracy submodule, then runs it. + # Building from the submodule keeps the tool's version in sync with the + # client compiled into the engine. + # dev tracy [--fresh] [args...] Opens the profiler (viewer). + # dev tracy --save [name] [--fresh] [args...] + # Captures a trace headlessly to + # traces/<name>.tracy (default + # name: a timestamp) until the app + # exits or Ctrl+C. + # The profiler uses the X11 backend unless WAYLAND_DISPLAY is set. Tools + # are configured on first use (or --fresh, or when the backend changes), + # then built incrementally. Other arguments are passed through to the tool + # (e.g. a .tracy file to open, or `-s 10` to capture for 10 seconds). + def tracy(self, *args, **kwargs): + fresh, save, name, args = self._parse_tracy_args(args) + + if save: + binary = self._build_tracy_tool("capture", "tracy-capture", {}, fresh) + traces_dir = os.path.join(self._project_root(), "traces") + os.makedirs(traces_dir, exist_ok=True) + name = name or time.strftime("%Y-%m-%d-%H%M%S") + if not name.endswith(".tracy"): + name += ".tracy" + args = ["-o", os.path.join(traces_dir, name), *args] + else: + legacy = "OFF" if os.environ.get("WAYLAND_DISPLAY") else "ON" + binary = self._build_tracy_tool("profiler", "tracy-profiler", {"LEGACY": legacy}, fresh) + + print(f"Running: {shlex.join([binary, *args])}") + os.execv(binary, [binary, *args]) + + def _parse_tracy_args(self, args): + fresh = False + save = False + name = None + rest = [] + + args = list(args) + while args: + arg = args.pop(0) + if arg == "--fresh": + fresh = True + elif arg == "--save": + save = True + if args and not args[0].startswith("-"): + name = args.pop(0) + else: + rest.append(arg) + return fresh, save, name, rest + + # Configures third_party/tracy/<tool> into <tool>/build when it has no + # build yet, --fresh is given, or a cached define differs from `defines`. + def _build_tracy_tool(self, tool, binary_name, defines, fresh): + source_dir = os.path.join("third_party", "tracy", tool) + build_dir = os.path.join(source_dir, "build") + cache_path = os.path.join(self._project_root(), build_dir, "CMakeCache.txt") + + stale = not os.path.isfile(cache_path) + if not stale: + with open(cache_path) as f: + cache = f.read() + stale = any( + re.search(rf"^{key}:BOOL={value}$", cache, re.MULTILINE) is None + for key, value in defines.items() + ) + + if fresh or stale: + configure = ["cmake", "-B", build_dir, "-S", source_dir, "-DCMAKE_BUILD_TYPE=Release"] + configure += [f"-D{key}={value}" for key, value in defines.items()] + if fresh: + configure.append("--fresh") + self._run_in_root(configure) + self._run_in_root(["cmake", "--build", build_dir, "--parallel"]) + + return os.path.join(self._project_root(), build_dir, binary_name)