Files
metabuilder/gameengine/python/dev_commands.py
johndoe6345789 7ac5ef1d20 feat(gameengine): Windows/AMD build, SPIRV shaders, spotlight, volumetric beam, FPS flashlight
Build system:
- Fix generate_cmake.py backslash paths and UTF-8 encoding for Windows
- Auto C++20 in conan deps, auto-detect VS install location
- dev_commands.py: add generate, all --run commands, platform-aware bootstrap
- Add assimp to cmake_config.json link libraries
- Fix CMakeUserPresets.json duplicate preset issue

Cross-platform C++:
- UUID generation: Windows rpc.h/UuidCreate with #ifdef _WIN32
- HOME env var fallback to USERPROFILE on Windows
- Shader format detection for D3D12/DXIL Vulkan driver

Shader pipeline (12 new SPIRV shaders):
- Port all Metal shaders to Vulkan GLSL (PBR, shadows, post-FX, compute)
- SDL3 GPU descriptor set convention (set 0-3)
- Combined image samplers for Vulkan compatibility
- Bootstrap-driven shader path rewriting (msl↔spirv automatic per platform)

Rendering features:
- spotlight.setup: generic atomic workflow step, attach to camera or static
- PBR spotlight with cone attenuation, distance falloff, wrap lighting
- Volumetric light beam (16-step ray march through dust/fog in spotlight cone)
- geometry.create_flashlight: procedural flashlight mesh (cylinder + head + lens)
- draw.viewmodel: FPS weapon-style rendering locked to camera view
- model.load: Assimp-based 3D model loader (OBJ/GLB/FBX/BLEND)
- Indoor ambient lighting fix, SSAO bypass for Vulkan clip-space

Performance:
- Frame loop logging suppressed via _in_frame_loop context flag

Assets:
- Real PBR textures from ambientCG (CC0): wood floor, concrete ceiling
- Seed demo: dark room + flashlight beam + Quake-style viewmodel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 08:33:35 +00:00

1493 lines
54 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Improved build helper CLI.
This version addresses Windows quoting issues with the `msvc-quick` command and
provides a cleaner approach for executing `vcvarsall.bat` followed by a CMake
build. The rest of the commands remain platform-neutral and avoid using
`shell=True` wherever possible for greater safety and consistency.
Key changes:
* Simplified the construction of the one-liner used to call `vcvarsall.bat`
and run the subsequent command, avoiding nested quoting that caused errors
under PowerShell. The `cmd.exe` invocation now looks like:
cmd.exe /d /s /c call "<bat>" <arch> && <then command>
where `<then command>` is properly quoted using `subprocess.list2cmdline`.
* Updated `msvc-quick` to use the above construction, while still allowing
users to override the follow-on command via positional arguments after `--`.
* Other commands continue to build argument lists rather than shell strings,
preventing injection and ensuring predictable behavior across platforms.
* Added descriptive comments throughout for maintainability.
Use this script as a drop-in replacement for the original `dev_commands.py`.
"""
from __future__ import annotations
import argparse
import os
import platform
import subprocess
from pathlib import Path
from typing import Iterable, Sequence
IS_WINDOWS = platform.system() == "Windows"
DEFAULT_GENERATOR = "ninja-msvc" if IS_WINDOWS else "ninja"
GENERATOR_DEFAULT_DIR = {
"vs": "build",
"ninja": "build-ninja",
"ninja-msvc": "build-ninja-msvc",
}
CMAKE_GENERATOR = {
"vs": "Visual Studio 17 2022",
"ninja": "Ninja",
"ninja-msvc": "Ninja",
}
DEFAULT_BUILD_DIR = GENERATOR_DEFAULT_DIR[DEFAULT_GENERATOR]
TRACE_ENV_VAR = "DEV_COMMANDS_TRACE"
def _find_vcvarsall() -> str:
"""Auto-detect vcvarsall.bat across VS editions and versions."""
if not IS_WINDOWS:
return ""
base = "C:\\Program Files\\Microsoft Visual Studio"
# Search newest VS version first, then editions
for version in ["18", "2022", "2019"]:
for edition in ["Community", "Professional", "Enterprise", "BuildTools"]:
bat = f"{base}\\{version}\\{edition}\\VC\\Auxiliary\\Build\\vcvarsall.bat"
if os.path.isfile(bat):
return bat
return ""
DEFAULT_VCVARSALL = _find_vcvarsall()
def _sh_quote(s: str) -> str:
"""Minimal POSIX-style quoting for display purposes on non-Windows."""
if not s:
return "''"
safe = set(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
"._-/:@=+"
)
if all(c in safe for c in s):
return s
return "'" + s.replace("'", "'\"'\"'") + "'"
def _print_cmd(argv: Sequence[str]) -> None:
"""
Print a command list in a way that approximates how it would appear on the
command line. Uses Windows-specific quoting on Windows via
`subprocess.list2cmdline`, and POSIX-style quoting elsewhere.
"""
if IS_WINDOWS:
rendered = subprocess.list2cmdline(list(argv))
else:
rendered = " ".join(_sh_quote(a) for a in argv)
print("\n> " + rendered)
def _trace(message: str) -> None:
if os.environ.get(TRACE_ENV_VAR) == "1":
print(f"[trace] {message}")
def _strip_leading_double_dash(args: Sequence[str] | None) -> list[str]:
"""Drop a leading `--` that argparse keeps with REMAINDER arguments."""
if not args:
return []
args_list = list(args)
if args_list and args_list[0] == "--":
return args_list[1:]
return args_list
def _has_runtime_config_arg(args: Sequence[str] | None) -> bool:
if not args:
return False
for arg in args:
if arg in {"-j", "--json-file-in"}:
return True
if arg.startswith("--json-file-in="):
return True
if arg.startswith("-j") and len(arg) > 2:
return True
return False
def _merge_env(env_overrides: dict[str, str] | None) -> dict[str, str] | None:
if not env_overrides:
return None
merged = os.environ.copy()
merged.update(env_overrides)
return merged
def run_argvs(
argvs: Iterable[Sequence[str]],
dry_run: bool,
cwd: str | None = None,
env_overrides: dict[str, str] | None = None,
) -> None:
"""
Run a sequence of commands represented as lists of arguments. Each command
is printed before execution. If `dry_run` is True, commands are printed
but not executed.
"""
merged_env = _merge_env(env_overrides)
for argv in argvs:
_print_cmd(argv)
if dry_run:
continue
subprocess.run(list(argv), check=True, cwd=cwd, env=merged_env)
def _as_build_dir(path_str: str | None, fallback: str) -> str:
"""Return the provided path if not None, otherwise the fallback."""
return path_str or fallback
def _has_cache_arg(cmake_args: Sequence[str] | None, name: str) -> bool:
"""Return True if the CMake args already define a cache variable."""
if not cmake_args:
return False
key = f"-D{name}"
prefix = f"-D{name}="
for arg in cmake_args:
if arg == key or arg.startswith(prefix):
return True
return False
def _find_conan_toolchain(build_type: str) -> Path | None:
"""
Look for the Conan toolchain file in common output locations.
The default `conan install -of build-ninja` + `cmake_layout()` layout produces
`build-ninja/build/<build_type>/generators/conan_toolchain.cmake`.
"""
candidates = [
Path("build-ninja") / "build" / build_type / "generators" / "conan_toolchain.cmake",
Path("build-ninja") / build_type / "generators" / "conan_toolchain.cmake",
Path("build-ninja") / "generators" / "conan_toolchain.cmake",
Path("build-ninja") / "conan_toolchain.cmake",
]
for candidate in candidates:
if candidate.is_file():
return candidate.resolve()
return None
def _has_cmake_cache(build_dir: str) -> bool:
"""Return True if the build directory already has a CMake cache."""
return (Path(build_dir) / "CMakeCache.txt").is_file()
def dependencies(args: argparse.Namespace) -> None:
"""Run Conan profile detection and install dependencies with C++20."""
cmd_detect = ["conan", "profile", "detect", "-f"]
cmd_install = ["conan", "install", ".", "-of", "build-ninja", "-b", "missing",
"-s", "compiler.cppstd=20"]
conan_install_args = _strip_leading_double_dash(args.conan_install_args)
if conan_install_args:
cmd_install.extend(conan_install_args)
run_argvs([cmd_detect, cmd_install], args.dry_run)
def generate(args: argparse.Namespace) -> None:
"""Generate CMakeLists.txt from cmake_config.json using Jinja2."""
env = {"PYTHONIOENCODING": "utf-8"}
cmd = [
"python", "generate_cmake.py",
"--config", args.config,
"--output", args.output,
]
if args.template:
cmd.extend(["--template", args.template])
if args.validate:
cmd.append("--validate")
run_argvs([cmd], args.dry_run, env_overrides=env)
# Fix CMakeUserPresets.json to only include existing preset files
if not args.dry_run and not args.validate:
_fix_cmake_user_presets()
def _fix_cmake_user_presets() -> None:
"""Ensure CMakeUserPresets.json only includes existing preset files."""
import json as json_mod
presets_path = Path("CMakeUserPresets.json")
if not presets_path.exists():
return
try:
data = json_mod.loads(presets_path.read_text())
includes = data.get("include", [])
valid = [p for p in includes if Path(p).exists()]
if len(valid) != len(includes):
data["include"] = valid
presets_path.write_text(json_mod.dumps(data, indent=4) + "\n")
print(f" Fixed CMakeUserPresets.json: kept {len(valid)}/{len(includes)} includes")
except (json_mod.JSONDecodeError, OSError):
pass
def full_build(args: argparse.Namespace) -> None:
"""Run the full build pipeline: dependencies + generate + configure + build."""
print("=== Step 1/4: Installing dependencies ===")
deps_args = argparse.Namespace(
dry_run=args.dry_run,
conan_install_args=None,
)
dependencies(deps_args)
print("\n=== Step 2/4: Generating CMakeLists.txt ===")
gen_args = argparse.Namespace(
dry_run=args.dry_run,
config="cmake_config.json",
template=None,
output="CMakeLists.txt",
validate=False,
)
generate(gen_args)
print("\n=== Step 3/4: Configuring CMake ===")
conf_args = argparse.Namespace(
dry_run=args.dry_run,
preset="conan-default",
generator=None,
build_dir=None,
build_type=args.build_type,
cmake_args=["-DBUILD_SDL3_APP=ON", "-DSDL_VERSION=SDL3"],
)
configure(conf_args)
print("\n=== Step 4/4: Building ===")
bld_args = argparse.Namespace(
dry_run=args.dry_run,
build_dir="build-ninja/build",
config=args.build_type,
target=args.target,
build_tool_args=None,
)
build(bld_args)
if args.run:
print("\n=== Running ===")
run_args = argparse.Namespace(
dry_run=args.dry_run,
build_dir="build-ninja/build/" + args.build_type,
target=None,
no_sync=False,
args=["--bootstrap", args.bootstrap, "--game", args.game],
)
run_demo(run_args)
def configure(args: argparse.Namespace) -> None:
"""Configure a CMake project based on the chosen generator and options."""
if args.preset:
cmake_args = ["cmake", "--preset", args.preset]
cmake_extra_args = _strip_leading_double_dash(args.cmake_args)
if cmake_extra_args:
cmake_args.extend(cmake_extra_args)
run_argvs([cmake_args], args.dry_run)
return
generator = args.generator or DEFAULT_GENERATOR
build_dir = _as_build_dir(
args.build_dir, GENERATOR_DEFAULT_DIR.get(generator, "build")
)
cmake_args: list[str] = ["cmake", "-B", build_dir, "-S", "."]
conan_toolchain = _find_conan_toolchain(args.build_type)
if (
conan_toolchain
and not _has_cache_arg(args.cmake_args, "CMAKE_TOOLCHAIN_FILE")
and not _has_cmake_cache(build_dir)
):
cmake_args.append(f"-DCMAKE_TOOLCHAIN_FILE={conan_toolchain}")
if conan_toolchain and not _has_cache_arg(args.cmake_args, "CMAKE_PREFIX_PATH"):
conan_generators_dir = conan_toolchain.parent
cmake_args.append(f"-DCMAKE_PREFIX_PATH={conan_generators_dir}")
if generator == "vs":
cmake_args.extend(["-G", CMAKE_GENERATOR["vs"]])
else:
cmake_args.extend(["-G", CMAKE_GENERATOR[generator]])
cmake_args.append(f"-DCMAKE_BUILD_TYPE={args.build_type}")
cmake_extra_args = _strip_leading_double_dash(args.cmake_args)
if cmake_extra_args:
cmake_args.extend(cmake_extra_args)
run_argvs([cmake_args], args.dry_run)
def build(args: argparse.Namespace) -> None:
"""Run the `cmake --build` command for a given build directory."""
cmd: list[str] = ["cmake", "--build", args.build_dir]
if args.config:
cmd.extend(["--config", args.config])
if args.target:
cmd.extend(["--target", args.target])
build_tool_args = _strip_leading_double_dash(args.build_tool_args)
if build_tool_args:
cmd.append("--")
cmd.extend(build_tool_args)
run_argvs([cmd], args.dry_run)
def tests(args: argparse.Namespace) -> None:
"""Build (optional) and run ctest for a given build directory."""
build_dir = _as_build_dir(args.build_dir, DEFAULT_BUILD_DIR)
argvs: list[list[str]] = []
if args.build_first:
build_cmd: list[str] = ["cmake", "--build", build_dir]
if args.config:
build_cmd.extend(["--config", args.config])
if args.target:
build_cmd.extend(["--target", args.target])
build_tool_args = _strip_leading_double_dash(args.build_tool_args)
if build_tool_args:
build_cmd.append("--")
build_cmd.extend(build_tool_args)
argvs.append(build_cmd)
ctest_cmd: list[str] = ["ctest", "--output-on-failure", "--test-dir", build_dir]
if args.config:
ctest_cmd.extend(["-C", args.config])
ctest_args = _strip_leading_double_dash(args.ctest_args)
if ctest_args:
ctest_cmd.extend(ctest_args)
argvs.append(ctest_cmd)
run_argvs(argvs, args.dry_run)
def _cmd_one_liner_vcvars_then(bat: str, arch: str, then_parts: Sequence[str]) -> list[str]:
"""
Construct a command to call a Visual Studio environment setup batch file and
then run another command. The returned list of arguments can be passed to
subprocess.run with shell=False.
On Windows, we use:
cmd.exe /d /s /c call "<bat>" <arch> && <then...>
The path to the batch file is quoted to handle spaces. The follow-on
command (`then_parts`) is converted to a command string using
`subprocess.list2cmdline`, which properly quotes arguments for cmd.exe.
"""
then_cmdline = subprocess.list2cmdline(list(then_parts))
full_cmd = f'call "{bat}" {arch} && {then_cmdline}'
return ["cmd.exe", "/d", "/s", "/c", full_cmd]
def msvc_quick(args: argparse.Namespace) -> None:
"""
Set up the Visual Studio environment and build the project.
On Windows, this command calls `vcvarsall.bat` (or a custom batch file)
with the specified architecture, then runs a follow-on command. By
default, the follow-on command is `cmake --build <build_dir>` with
optional configuration, target, and extra build-tool arguments. Users can
override the follow-on command entirely by specifying positional arguments
after `--`.
On non-Windows platforms, this command will exit with an error, as there is
no Visual Studio environment to initialize.
"""
if not IS_WINDOWS:
raise SystemExit("msvc-quick is only supported on Windows")
bat = args.bat_path or DEFAULT_VCVARSALL
arch = args.arch or "x64"
if args.then_command:
then_cmd = _strip_leading_double_dash(args.then_command)
else:
build_dir = _as_build_dir(args.build_dir, DEFAULT_BUILD_DIR)
then_cmd = ["cmake", "--build", build_dir]
if args.config:
then_cmd.extend(["--config", args.config])
if args.target:
then_cmd.extend(["--target", args.target])
build_tool_args = _strip_leading_double_dash(args.build_tool_args)
if build_tool_args:
then_cmd.append("--")
then_cmd.extend(build_tool_args)
cmd = _cmd_one_liner_vcvars_then(bat, arch, then_cmd)
run_argvs([cmd], args.dry_run)
def _sync_assets(build_dir: str, dry_run: bool) -> None:
"""
Sync asset files (scripts, shaders, models) from the project root to the
build directory before running the application.
"""
import shutil
build_path = Path(build_dir)
project_root = Path(".")
# Define asset directories to sync
asset_dirs = [
("packages", ["*.json"]),
]
asset_trees = [
"MaterialX/libraries",
"MaterialX/resources",
]
print("\n=== Syncing Assets ===")
for src_dir, patterns in asset_dirs:
src_path = project_root / src_dir
dst_path = build_path / src_dir
if not src_path.exists():
continue
# Create destination directory if needed
if not dry_run:
dst_path.mkdir(parents=True, exist_ok=True)
# Sync files matching patterns
for pattern in patterns:
for src_file in src_path.glob(pattern):
if src_file.is_file() and src_file.name != "dev_commands.py":
dst_file = dst_path / src_file.name
print(f" {src_file} -> {dst_file}")
if not dry_run:
shutil.copy2(src_file, dst_file)
for src_dir in asset_trees:
src_path = project_root / src_dir
dst_path = build_path / src_dir
if not src_path.exists():
continue
print(f" {src_path} -> {dst_path}")
if not dry_run:
shutil.copytree(src_path, dst_path, dirs_exist_ok=True)
print("=== Assets Synced ===\n")
def run_demo(args: argparse.Namespace) -> None:
"""
Run a compiled demo application from the build directory. The default
executable is `sdl3_app` (or `sdl3_app.exe` on Windows). Additional
arguments can be passed to the executable after `--`.
By default, syncs asset files before running.
Use --no-sync to skip asset synchronization.
"""
build_dir = _as_build_dir(args.build_dir, DEFAULT_BUILD_DIR)
if not args.no_sync:
_sync_assets(build_dir, args.dry_run)
exe_name = args.target or ("sdl3_app.exe" if IS_WINDOWS else "sdl3_app")
binary = str(Path(build_dir).resolve() / exe_name)
run_args = _strip_leading_double_dash(args.args)
cmd: list[str] = [binary]
if run_args:
cmd.extend(run_args)
_print_cmd(cmd)
import os
os.chdir(build_dir)
os.execv(binary, cmd)
def gui(args: argparse.Namespace) -> None:
"""
Launch a PyQt6 GUI launcher similar to Steam's interface for managing builds
and running demos.
"""
try:
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QTextEdit, QComboBox, QListWidget, QListWidgetItem,
QDialog, QDialogButtonBox, QFormLayout, QMessageBox,
)
from PyQt6.QtCore import Qt, QProcess, QProcessEnvironment
from PyQt6.QtGui import QFont, QPalette, QColor, QAction
except ImportError:
raise SystemExit(
"PyQt6 is not installed. Install it with:\n"
" pip install PyQt6"
)
import sys
class BuildSettingsDialog(QDialog):
"""Dialog for configuring build settings"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Build Settings")
self.setMinimumWidth(400)
layout = QFormLayout(self)
self.preset_combo = QComboBox()
self.preset_combo.addItems(["default"])
self.preset_combo.setCurrentText("default")
layout.addRow("Preset:", self.preset_combo)
self.generator_combo = QComboBox()
self.generator_combo.addItems(["ninja", "ninja-msvc", "vs"])
self.generator_combo.setCurrentText(DEFAULT_GENERATOR)
layout.addRow("Generator:", self.generator_combo)
self.build_type_combo = QComboBox()
self.build_type_combo.addItems(["Release", "Debug", "RelWithDebInfo"])
layout.addRow("Build Type:", self.build_type_combo)
self.target_combo = QComboBox()
self.target_combo.addItems([
"sdl3_app",
"all",
"script_engine_tests",
"gpu_gui_service_tests",
])
layout.addRow("Target:", self.target_combo)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addRow(buttons)
class BuildLauncherGUI(QMainWindow):
def __init__(self):
super().__init__()
self.process = None
self.current_game = None
self.current_bootloader = None
self.current_game_package = None
# Build settings
self.preset = "default"
self.generator = DEFAULT_GENERATOR
self.build_type = "Release"
self.target = "sdl3_app"
# Load bootloader packages
self.bootloaders = self.load_bootloader_packages()
# Load game packages
self.game_packages = self.load_game_packages()
self.init_ui()
def load_bootloader_packages(self):
"""Load bootloader packages from packages/ directory"""
import json
from pathlib import Path
bootloaders = []
packages_dir = Path("packages")
if not packages_dir.exists():
return []
# Look for bootstrap_* packages
for pkg_dir in sorted(packages_dir.glob("bootstrap_*")):
if not pkg_dir.is_dir():
continue
package_json = pkg_dir / "package.json"
if not package_json.exists():
continue
try:
with open(package_json, 'r') as f:
data = json.load(f)
pkg_type = data.get("type", "bootloader")
pkg_category = data.get("category", "")
display_name = data.get("name", pkg_dir.name)
if pkg_category:
display_name = f"{display_name} ({pkg_category})"
bootloaders.append({
"id": pkg_dir.name,
"name": display_name,
"description": data.get("description", ""),
"type": pkg_type,
"category": pkg_category,
"package_dir": str(pkg_dir),
})
except (json.JSONDecodeError, IOError) as e:
print(f"Warning: Could not load bootloader {package_json}: {e}")
continue
return bootloaders
def load_game_packages(self):
"""Load game packages from packages/ directory (exclude bootstraps and libraries)"""
import json
from pathlib import Path
games = []
packages_dir = Path("packages")
if not packages_dir.exists():
return []
# Load all packages except bootstrap_* packages and libraries
for pkg_dir in sorted(packages_dir.glob("*")):
if not pkg_dir.is_dir():
continue
if pkg_dir.name.startswith("bootstrap_"):
continue
package_json = pkg_dir / "package.json"
if not package_json.exists():
continue
try:
with open(package_json, 'r') as f:
data = json.load(f)
pkg_type = data.get("type", "game")
# Only show game packages, not libraries
if pkg_type != "game":
continue
pkg_category = data.get("category", "")
display_name = data.get("name", pkg_dir.name)
if pkg_category:
display_name = f"{display_name} ({pkg_category})"
games.append({
"id": pkg_dir.name,
"name": display_name,
"description": data.get("description", ""),
"type": pkg_type,
"category": pkg_category,
"package_dir": str(pkg_dir),
})
except (json.JSONDecodeError, IOError) as e:
print(f"Warning: Could not load game package {package_json}: {e}")
continue
return games
def init_ui(self):
self.setWindowTitle("SDL3 C++ Launcher")
self.setMinimumSize(1000, 700)
# Set dark theme similar to Steam
self.set_dark_theme()
# Central widget with horizontal splitter
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QHBoxLayout(central_widget)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# Left sidebar - Game library
sidebar = QWidget()
sidebar.setMaximumWidth(250)
sidebar.setStyleSheet("background-color: #171a21;")
sidebar_layout = QVBoxLayout(sidebar)
sidebar_layout.setContentsMargins(0, 0, 0, 0)
# Library header
library_header = QLabel("LIBRARY")
library_header.setStyleSheet("""
background-color: #1b2838;
color: #c6d1db;
padding: 12px;
font-weight: bold;
font-size: 11pt;
""")
sidebar_layout.addWidget(library_header)
# Game list
self.game_list = QListWidget()
self.game_list.setStyleSheet("""
QListWidget {
background-color: #171a21;
border: none;
color: #c6d1db;
font-size: 10pt;
outline: none;
}
QListWidget::item {
padding: 12px;
border-bottom: 1px solid #0e1216;
}
QListWidget::item:selected {
background-color: #2a475e;
}
QListWidget::item:hover {
background-color: #1b2838;
}
""")
self.game_list.currentItemChanged.connect(self.on_game_selected)
for game_pkg in self.game_packages:
item = QListWidgetItem(game_pkg["name"])
item.setData(Qt.ItemDataRole.UserRole, game_pkg)
self.game_list.addItem(item)
sidebar_layout.addWidget(self.game_list)
main_layout.addWidget(sidebar)
# Right side - Game details and console
right_panel = QWidget()
right_layout = QVBoxLayout(right_panel)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(0)
# Game detail header
detail_header = QWidget()
detail_header.setStyleSheet("background-color: #1b2838;")
detail_header.setMinimumHeight(200)
detail_layout = QVBoxLayout(detail_header)
detail_layout.setContentsMargins(30, 30, 30, 30)
# Game title
self.game_title = QLabel("Select a game")
title_font = QFont()
title_font.setPointSize(24)
title_font.setBold(True)
self.game_title.setFont(title_font)
self.game_title.setStyleSheet("color: #ffffff;")
detail_layout.addWidget(self.game_title)
# Game description
self.game_description = QLabel("")
self.game_description.setWordWrap(True)
self.game_description.setStyleSheet("color: #8f98a0; font-size: 11pt;")
detail_layout.addWidget(self.game_description)
# Package selection lists side by side
packages_row = QHBoxLayout()
# Bootloader list
bootloader_column = QVBoxLayout()
bootloader_label = QLabel("BOOTLOADER")
bootloader_label.setStyleSheet("color: #8f98a0; font-weight: bold; font-size: 9pt;")
bootloader_column.addWidget(bootloader_label)
list_style = """
QListWidget {
background-color: #171a21;
border: 1px solid #0e1216;
border-radius: 3px;
color: #c6d1db;
font-size: 10pt;
outline: none;
}
QListWidget::item {
padding: 6px 8px;
border-bottom: 1px solid #0e1216;
}
QListWidget::item:selected {
background-color: #2a475e;
}
QListWidget::item:hover {
background-color: #1b2838;
}
"""
self.bootloader_list = QListWidget()
self.bootloader_list.setStyleSheet(list_style)
self.bootloader_list.setMaximumHeight(120)
for bootloader in self.bootloaders:
item = QListWidgetItem(bootloader["name"])
item.setData(Qt.ItemDataRole.UserRole, bootloader)
self.bootloader_list.addItem(item)
if self.bootloaders:
self.current_bootloader = self.bootloaders[0]
self.bootloader_list.setCurrentRow(0)
self.bootloader_list.currentItemChanged.connect(self.on_bootloader_selected)
bootloader_column.addWidget(self.bootloader_list)
packages_row.addLayout(bootloader_column)
packages_row.addSpacing(10)
# Game package list
game_pkg_column = QVBoxLayout()
game_pkg_label = QLabel("GAME PACKAGE")
game_pkg_label.setStyleSheet("color: #8f98a0; font-weight: bold; font-size: 9pt;")
game_pkg_column.addWidget(game_pkg_label)
self.game_package_list = QListWidget()
self.game_package_list.setStyleSheet(list_style)
self.game_package_list.setMaximumHeight(120)
for game_pkg in self.game_packages:
item = QListWidgetItem(game_pkg["name"])
item.setData(Qt.ItemDataRole.UserRole, game_pkg)
self.game_package_list.addItem(item)
if self.game_packages:
self.current_game_package = self.game_packages[0]
self.game_package_list.setCurrentRow(0)
self.game_package_list.currentItemChanged.connect(self.on_game_package_selected)
game_pkg_column.addWidget(self.game_package_list)
packages_row.addLayout(game_pkg_column)
detail_layout.addLayout(packages_row)
# Play button container
button_container = QHBoxLayout()
self.play_btn = QPushButton("▶ PLAY")
self.play_btn.setEnabled(False)
self.play_btn.setMinimumHeight(50)
self.play_btn.setMinimumWidth(200)
play_font = QFont()
play_font.setPointSize(14)
play_font.setBold(True)
self.play_btn.setFont(play_font)
self.play_btn.setStyleSheet("""
QPushButton {
background-color: #5c7e10;
color: white;
border: none;
border-radius: 4px;
padding: 10px 40px;
}
QPushButton:hover {
background-color: #6a9612;
}
QPushButton:pressed {
background-color: #4a6a0d;
}
QPushButton:disabled {
background-color: #3f4e5f;
color: #7a8896;
}
""")
self.play_btn.clicked.connect(self.play_game)
button_container.addWidget(self.play_btn)
self.stop_btn = QPushButton("⏹ STOP")
self.stop_btn.setEnabled(False)
self.stop_btn.setMinimumHeight(50)
self.stop_btn.setMinimumWidth(120)
stop_font = QFont()
stop_font.setPointSize(12)
stop_font.setBold(True)
self.stop_btn.setFont(stop_font)
self.stop_btn.setStyleSheet("""
QPushButton {
background-color: #a83232;
color: white;
border: none;
border-radius: 4px;
padding: 10px 20px;
}
QPushButton:hover {
background-color: #c13838;
}
QPushButton:pressed {
background-color: #8f2828;
}
QPushButton:disabled {
background-color: #3f4e5f;
color: #7a8896;
}
""")
self.stop_btn.clicked.connect(self.stop_process)
button_container.addWidget(self.stop_btn)
button_container.addStretch()
detail_layout.addLayout(button_container)
right_layout.addWidget(detail_header)
# Console output panel
console_container = QWidget()
console_container.setStyleSheet("background-color: #1b2838;")
console_layout = QVBoxLayout(console_container)
console_layout.setContentsMargins(10, 10, 10, 10)
console_label = QLabel("OUTPUT")
console_label.setStyleSheet("color: #8f98a0; font-weight: bold; font-size: 9pt;")
console_layout.addWidget(console_label)
self.console = QTextEdit()
self.console.setReadOnly(True)
console_font = QFont("Courier New")
console_font.setPointSize(9)
self.console.setFont(console_font)
self.console.setStyleSheet("""
QTextEdit {
background-color: #0e1216;
color: #c6d1db;
border: 1px solid #0e1216;
border-radius: 3px;
}
""")
# Console toolbar
console_toolbar = QHBoxLayout()
self.copy_console_btn = QPushButton("Copy")
self.copy_console_btn.setStyleSheet("""
QPushButton {
background-color: #2a475e;
color: #c6d1db;
border: none;
border-radius: 3px;
padding: 5px 15px;
}
QPushButton:hover {
background-color: #3e5c78;
}
""")
self.copy_console_btn.clicked.connect(self.copy_console)
console_toolbar.addWidget(self.copy_console_btn)
self.clear_console_btn = QPushButton("Clear")
self.clear_console_btn.setStyleSheet("""
QPushButton {
background-color: #2a475e;
color: #c6d1db;
border: none;
border-radius: 3px;
padding: 5px 15px;
}
QPushButton:hover {
background-color: #3e5c78;
}
""")
self.clear_console_btn.clicked.connect(self.console.clear)
console_toolbar.addWidget(self.clear_console_btn)
console_toolbar.addStretch()
console_layout.addLayout(console_toolbar)
console_layout.addWidget(self.console)
right_layout.addWidget(console_container)
main_layout.addWidget(right_panel, 1)
# Create menu bar after all widgets are initialized
self.create_menu_bar()
# Select first game by default
if self.game_packages:
self.game_list.setCurrentRow(0)
def create_menu_bar(self):
"""Create menu bar with developer tools"""
menubar = self.menuBar()
menubar.setStyleSheet("""
QMenuBar {
background-color: #171a21;
color: #c6d1db;
padding: 4px;
}
QMenuBar::item:selected {
background-color: #2a475e;
}
QMenu {
background-color: #1b2838;
color: #c6d1db;
border: 1px solid #0e1216;
}
QMenu::item:selected {
background-color: #2a475e;
}
""")
# File menu
file_menu = menubar.addMenu("File")
exit_action = QAction("Exit", self)
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
# Developer menu
dev_menu = menubar.addMenu("Developer")
deps_action = QAction("Install Dependencies", self)
deps_action.triggered.connect(self.run_dependencies)
dev_menu.addAction(deps_action)
config_action = QAction("Configure CMake", self)
config_action.triggered.connect(self.run_configure)
dev_menu.addAction(config_action)
build_action = QAction("Build Project", self)
build_action.triggered.connect(self.run_build)
dev_menu.addAction(build_action)
tests_action = QAction("Run Tests", self)
tests_action.triggered.connect(self.run_tests)
dev_menu.addAction(tests_action)
sync_action = QAction("Sync Assets", self)
sync_action.triggered.connect(self.sync_assets)
dev_menu.addAction(sync_action)
dev_menu.addSeparator()
settings_action = QAction("Build Settings...", self)
settings_action.triggered.connect(self.show_settings)
dev_menu.addAction(settings_action)
# View menu
view_menu = menubar.addMenu("View")
clear_console_action = QAction("Clear Console", self)
clear_console_action.triggered.connect(self.console.clear)
view_menu.addAction(clear_console_action)
def show_settings(self):
"""Show build settings dialog"""
dialog = BuildSettingsDialog(self)
dialog.preset_combo.setCurrentText(self.preset)
dialog.generator_combo.setCurrentText(self.generator)
dialog.build_type_combo.setCurrentText(self.build_type)
dialog.target_combo.setCurrentText(self.target)
if dialog.exec() == QDialog.DialogCode.Accepted:
self.preset = dialog.preset_combo.currentText()
self.generator = dialog.generator_combo.currentText()
self.build_type = dialog.build_type_combo.currentText()
self.target = dialog.target_combo.currentText()
self.log(f"Settings updated: Preset={self.preset}, Generator={self.generator}, Build Type={self.build_type}, Target={self.target}")
def on_bootloader_selected(self, current, previous):
"""Handle bootloader selection"""
if current:
self.current_bootloader = current.data(Qt.ItemDataRole.UserRole)
self.log(f"Selected bootloader: {self.current_bootloader['name']}")
def on_game_package_selected(self, current, previous):
"""Handle game package selection"""
if current:
self.current_game_package = current.data(Qt.ItemDataRole.UserRole)
self.log(f"Selected game package: {self.current_game_package['name']}")
def on_game_selected(self, current, previous):
"""Handle game selection from library"""
if current:
game = current.data(Qt.ItemDataRole.UserRole)
self.current_game = game
self.game_title.setText(game["name"])
self.game_description.setText(game["description"])
self.play_btn.setEnabled(True)
else:
self.current_game = None
self.game_title.setText("Select a game")
self.game_description.setText("")
self.play_btn.setEnabled(False)
def copy_console(self):
"""Copy console output to clipboard"""
from PyQt6.QtWidgets import QApplication
clipboard = QApplication.clipboard()
selected_text = self.console.textCursor().selectedText()
if selected_text:
clipboard.setText(selected_text)
else:
# If no selection, copy all text
clipboard.setText(self.console.toPlainText())
def play_game(self):
"""Launch the selected game with chosen bootloader and game package"""
if not self.current_game:
return
base_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR)
# Check if using Conan nested layout
nested_dir = Path(base_dir) / "build" / self.build_type
if nested_dir.exists():
build_dir = str(nested_dir)
else:
build_dir = base_dir
exe_name = "sdl3_app.exe" if IS_WINDOWS else "sdl3_app"
binary = str(Path(build_dir) / exe_name)
cmd = [binary]
# Add bootloader and game package parameters
if self.current_bootloader:
cmd.extend(["--bootstrap", self.current_bootloader["id"]])
if self.current_game:
cmd.extend(["--game", self.current_game["id"]])
self.log(f"Launching with bootloader: {self.current_bootloader.get('name', 'default') if self.current_bootloader else 'default'}")
self.log(f"Launching with game package: {self.current_game_package.get('name', 'default') if self.current_game_package else 'default'}")
self.run_command(cmd)
def stop_process(self):
"""Stop the running process"""
if self.process and self.process.state() == QProcess.ProcessState.Running:
self.log("\n⏸ Stopping process...")
self.process.kill()
self.process.waitForFinished(3000)
def set_dark_theme(self):
"""Apply a dark theme similar to Steam"""
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(27, 40, 56))
palette.setColor(QPalette.ColorRole.WindowText, QColor(198, 209, 219))
palette.setColor(QPalette.ColorRole.Base, QColor(35, 47, 62))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(27, 40, 56))
palette.setColor(QPalette.ColorRole.Text, QColor(198, 209, 219))
palette.setColor(QPalette.ColorRole.Button, QColor(42, 71, 94))
palette.setColor(QPalette.ColorRole.ButtonText, QColor(198, 209, 219))
palette.setColor(QPalette.ColorRole.Highlight, QColor(91, 124, 153))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255))
self.setPalette(palette)
def log(self, message: str):
"""Add a message to the console"""
self.console.append(message)
# Auto-scroll to bottom
self.console.verticalScrollBar().setValue(
self.console.verticalScrollBar().maximum()
)
def run_command(self, args: list[str], env_overrides: dict[str, str] | None = None):
"""Execute a command using QProcess"""
if self.process and self.process.state() == QProcess.ProcessState.Running:
self.log("⚠️ A process is already running. Stop it first.")
return
self.console.clear()
self.log(f"▶ Running: {' '.join(args)}\n")
self.process = QProcess(self)
if env_overrides:
env = QProcessEnvironment.systemEnvironment()
for key, value in env_overrides.items():
env.insert(key, value)
self.process.setProcessEnvironment(env)
self.process.readyReadStandardOutput.connect(self.handle_stdout)
self.process.readyReadStandardError.connect(self.handle_stderr)
self.process.finished.connect(self.process_finished)
self.process.start(args[0], args[1:])
self.play_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
def handle_stdout(self):
"""Handle stdout from the process"""
if self.process:
data = self.process.readAllStandardOutput()
text = bytes(data).decode('utf-8', errors='replace')
self.log(text)
def handle_stderr(self):
"""Handle stderr from the process"""
if self.process:
data = self.process.readAllStandardError()
text = bytes(data).decode('utf-8', errors='replace')
self.log(text)
def process_finished(self, exit_code: int, exit_status):
"""Handle process completion"""
if exit_code == 0:
self.log(f"\n✓ Process completed successfully")
else:
self.log(f"\n❌ Process exited with code {exit_code}")
self.stop_btn.setEnabled(False)
if self.current_game:
self.play_btn.setEnabled(True)
def run_dependencies(self):
"""Run conan dependencies installation"""
cmd = [sys.executable, __file__, "dependencies"]
self.run_command(cmd)
def run_configure(self):
"""Run CMake configuration"""
cmd = [sys.executable, __file__, "configure"]
if self.preset != "default":
cmd.extend(["--preset", self.preset])
else:
cmd.extend([
"--generator", self.generator,
"--build-type", self.build_type
])
self.run_command(cmd)
def run_build(self):
"""Run build command"""
base_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR)
# Check if using Conan nested layout (has build/<type>/ subdirectory)
nested_dir = Path(base_dir) / "build" / self.build_type
if nested_dir.exists() and (nested_dir / "CMakeCache.txt").exists():
build_dir = str(nested_dir)
elif (Path(base_dir) / "CMakeCache.txt").exists():
build_dir = base_dir
else:
# Default to nested layout (Conan 2.x standard)
build_dir = str(nested_dir)
cmd = [
sys.executable, __file__, "build",
"--build-dir", build_dir,
"--target", self.target
]
self.run_command(cmd)
def run_tests(self):
"""Build (optional) and run tests"""
base_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR)
# Check if using Conan nested layout
nested_dir = Path(base_dir) / "build" / self.build_type
if nested_dir.exists() and (nested_dir / "CMakeCache.txt").exists():
build_dir = str(nested_dir)
elif (Path(base_dir) / "CMakeCache.txt").exists():
build_dir = base_dir
else:
build_dir = str(nested_dir)
cmd = [
sys.executable, __file__, "tests",
"--build-dir", build_dir,
"--config", self.build_type,
"--target", "all"
]
self.run_command(cmd)
def sync_assets(self):
"""Sync assets into the active build directory"""
base_dir = GENERATOR_DEFAULT_DIR.get(self.generator, DEFAULT_BUILD_DIR)
# Check if using Conan nested layout
nested_dir = Path(base_dir) / "build" / self.build_type
if nested_dir.exists():
build_dir = str(nested_dir)
else:
build_dir = base_dir
self.console.clear()
self.log("=== Syncing Assets ===\n")
_sync_assets(build_dir, dry_run=False)
self.log("\n✓ Asset sync completed")
app = QApplication(sys.argv)
window = BuildLauncherGUI()
window.show()
sys.exit(app.exec())
def main() -> int:
parser = argparse.ArgumentParser(description="Run build helper commands.")
parser.add_argument(
"--dry-run",
action="store_true",
help="print commands without executing them",
)
subparsers = parser.add_subparsers(dest="command", required=True)
deps = subparsers.add_parser("dependencies", help="run Conan setup from README")
deps.add_argument(
"--conan-install-args",
nargs=argparse.REMAINDER,
help=(
"extra arguments forwarded to `conan install` "
"(prefix with '--' before conan flags if needed)"
),
)
deps.set_defaults(func=dependencies)
gen = subparsers.add_parser("generate", help="generate CMakeLists.txt from JSON config")
gen.add_argument(
"--config", default="cmake_config.json",
help="path to cmake_config.json (default: cmake_config.json)",
)
gen.add_argument(
"--template", default=None,
help="path to Jinja2 template (default: CMakeLists.txt.jinja2)",
)
gen.add_argument(
"--output", default="CMakeLists.txt",
help="output CMakeLists.txt path (default: CMakeLists.txt)",
)
gen.add_argument(
"--validate", action="store_true",
help="validate config without generating",
)
gen.set_defaults(func=generate)
allp = subparsers.add_parser(
"all", help="full pipeline: dependencies + generate + configure + build [+ run]"
)
allp.add_argument(
"--build-type", default="Release",
help="build type (default: Release)",
)
allp.add_argument(
"--target", default="sdl3_app",
help="build target (default: sdl3_app)",
)
allp.add_argument(
"--run", action="store_true",
help="run the app after building",
)
allp.add_argument(
"--bootstrap", default="bootstrap_windows" if IS_WINDOWS else "bootstrap_mac",
help="bootstrap package (auto-detected from platform)",
)
allp.add_argument(
"--game", default="seed",
help="game package to run (default: seed)",
)
allp.set_defaults(func=full_build)
conf = subparsers.add_parser("configure", help="configure CMake project")
conf.add_argument(
"--preset",
help="use a CMake preset instead of manual configuration",
)
conf.add_argument(
"--generator",
choices=["vs", "ninja", "ninja-msvc"],
help=(
"which generator to invoke (default: Ninja+MSVC on Windows, Ninja elsewhere)"
),
)
conf.add_argument("--build-dir", help="override the directory where CMake writes build files")
conf.add_argument(
"--build-type",
default="Release",
help="single-config builds need an explicit CMAKE_BUILD_TYPE",
)
conf.add_argument(
"--cmake-args",
nargs=argparse.REMAINDER,
help=(
"extra arguments forwarded to `cmake` configure step "
"(prefix with '--' before cmake flags if needed)"
),
)
conf.set_defaults(func=configure)
bld = subparsers.add_parser("build", help="run cmake --build")
bld.add_argument(
"--build-dir", default=DEFAULT_BUILD_DIR, help="which directory to build"
)
bld.add_argument(
"--config", default="Release", help="configuration for multi-config generators"
)
bld.add_argument(
"--target",
default="sdl3_app",
help="target to build (e.g. sdl3_app, spinning_cube)",
)
bld.add_argument(
"--build-tool-args",
nargs=argparse.REMAINDER,
help=(
"extra args forwarded to the underlying build tool after `--` "
"(prefix with '--' before the tool args if needed)"
),
)
bld.set_defaults(func=build)
tst = subparsers.add_parser("tests", help="build (optional) and run ctest")
tst.add_argument(
"--build-dir", default=DEFAULT_BUILD_DIR, help="which directory to test"
)
tst.add_argument(
"--config", default="Release", help="configuration for multi-config generators"
)
tst.add_argument(
"--target",
default="all",
help="target to build before tests (use --no-build to skip)",
)
tst.add_argument(
"--no-build",
action="store_true",
help="skip build step and only run tests",
)
tst.add_argument(
"--build-tool-args",
nargs=argparse.REMAINDER,
help=(
"extra args forwarded to the underlying build tool after `--` "
"(prefix with '--' before the tool args if needed)"
),
)
tst.add_argument(
"--ctest-args",
nargs=argparse.REMAINDER,
help=(
"extra arguments forwarded to ctest "
"(prefix with '--' before ctest flags if needed)"
),
)
tst.set_defaults(func=tests, build_first=True)
msvc = subparsers.add_parser(
"msvc-quick", help="run a VS env setup + follow-on command (README one-liner style)"
)
msvc.add_argument("--bat-path", help="full path to vcvarsall.bat")
msvc.add_argument(
"--arch", default="x64", help="architecture argument passed to vcvarsall.bat"
)
msvc.add_argument(
"--build-dir",
default=DEFAULT_BUILD_DIR,
help="build directory (used by default follow-on build command)",
)
msvc.add_argument(
"--config", default="Release", help="configuration for multi-config generators"
)
msvc.add_argument(
"--target",
default="sdl3_app",
help="target to build (used by default follow-on build)",
)
msvc.add_argument(
"--build-tool-args",
nargs=argparse.REMAINDER,
help=(
"extra args forwarded to the underlying build tool after `--` "
"when using the default follow-on build"
),
)
msvc.add_argument(
"then_command",
nargs=argparse.REMAINDER,
help=(
"optional command to run after vcvarsall (overrides default build). "
"Example: msvc-quick -- cmake -B build -S ."
),
)
msvc.set_defaults(func=msvc_quick)
runp = subparsers.add_parser(
"run", help="execute a built binary from the build folder"
)
runp.add_argument("--build-dir", help="where the binary lives")
runp.add_argument(
"--target",
help="executable name to run (defaults to `sdl3_app[.exe]`)",
)
runp.add_argument(
"--no-sync",
action="store_true",
help="skip asset syncing before running",
)
runp.add_argument(
"args",
nargs=argparse.REMAINDER,
help=(
"arguments forwarded to the executable "
"(prefix with '--' before positional args when needed)"
),
)
runp.set_defaults(func=run_demo)
guip = subparsers.add_parser(
"gui", help="launch PyQt6 GUI launcher (Steam-like interface)"
)
guip.set_defaults(func=gui)
args = parser.parse_args()
if hasattr(args, "no_build") and args.no_build:
args.build_first = False
try:
args.func(args)
except subprocess.CalledProcessError as exc:
return int(exc.returncode)
return 0
if __name__ == "__main__":
raise SystemExit(main())