restructuing dev command
This commit is contained in:
@@ -32,6 +32,13 @@ pids
|
|||||||
*.pid.lock
|
*.pid.lock
|
||||||
imgui.ini
|
imgui.ini
|
||||||
|
|
||||||
|
# Python bytecode (bin/dev and bin/dev_commands)
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
# Tracy captures
|
||||||
|
traces/
|
||||||
|
*.tracy
|
||||||
|
|
||||||
# dotenv environment variables file
|
# dotenv environment variables file
|
||||||
.envrc
|
.envrc
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import struct
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
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 = {
|
COMMAND_TO_METHOD = {
|
||||||
"branch-from": "branch_from",
|
"branch-from": "branch_from",
|
||||||
"bash-completions": "bash_completions",
|
"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"
|
WAIT_FOR_PROCESS = "wait_for_process"
|
||||||
|
|
||||||
class DevHelper:
|
class DevCommand(GiteaCommand, RenderDocCommand, ShadersCommand, TracyCommand):
|
||||||
SHADER_EXTENSIONS = (".vert", ".frag", ".comp", ".geom", ".tesc", ".tese")
|
|
||||||
PROJECT_NAME = "sophia"
|
PROJECT_NAME = "sophia"
|
||||||
DEFAULT_PRESET = "debug"
|
DEFAULT_PRESET = "debug"
|
||||||
|
|
||||||
@@ -78,111 +80,6 @@ class DevHelper:
|
|||||||
os.chdir(preset_dir)
|
os.chdir(preset_dir)
|
||||||
os.execv(binary, [binary, *args])
|
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-<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 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
|
# Custom helpers
|
||||||
|
|
||||||
def ownit(self, *args, **kwargs):
|
def ownit(self, *args, **kwargs):
|
||||||
@@ -204,8 +101,8 @@ class DevHelper:
|
|||||||
def _commands(self):
|
def _commands(self):
|
||||||
return sorted(
|
return sorted(
|
||||||
METHOD_TO_COMMAND.get(name, name)
|
METHOD_TO_COMMAND.get(name, name)
|
||||||
for name in vars(DevHelper)
|
for name in dir(DevCommand)
|
||||||
if not name.startswith("_") and name != "call" and callable(getattr(DevHelper, name))
|
if not name.startswith("_") and name != "call" and callable(getattr(DevCommand, name))
|
||||||
)
|
)
|
||||||
|
|
||||||
def _parse_preset_args(self, args, command):
|
def _parse_preset_args(self, args, command):
|
||||||
@@ -254,166 +151,6 @@ class DevHelper:
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
sys.exit(result.returncode)
|
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):
|
def _wait_for_process_with_logging(self, command):
|
||||||
process = self._exec(command, capture=True)
|
process = self._exec(command, capture=True)
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
@@ -457,4 +194,4 @@ class DevHelper:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
DevHelper().call(*sys.argv[1:])
|
DevCommand().call(*sys.argv[1:])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)")
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user