#!/usr/bin/env python3 """Configure, build and smoke-test the supported module profiles. The matrix has two layers: * ``--check`` is a fast, toolchain-free contract check used by CI. It resolves the manifest in Python and verifies that hostless profiles cannot acquire a renderer or window. * ``--configure --build --smoke`` invokes CMake directly (never ``make``). The build target is ``eve_profile_smoke``: selected module OBJECT targets, standalone public-header translation units and independent capability probes. It intentionally does not link the full ``unit_test`` executable. Examples:: python3 scripts/profile_matrix.py --check python3 scripts/profile_matrix.py --profile physics-core-only \ --configure --build --smoke python3 scripts/profile_matrix.py --all --dry-run The ``web`` profile uses ``BUILD_PLATFORM=webgpu``. Native Dawn or an Emscripten toolchain must be supplied by the caller; the script does not hide that platform requirement behind a soft skip. """ from __future__ import annotations import argparse import os import shlex import subprocess import sys from dataclasses import dataclass from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "scripts")) import check_module_manifest as manifest # noqa: E402 PROFILES = ( "minimal", "2d", "3d", "runtime-3d", "web", "procgen-core-only", "physics-core-only", "asset-core-only", "headless", "server", ) HOSTLESS = {"procgen-core-only", "physics-core-only", "asset-core-only", "headless", "server"} RUNTIME_ONLY = {"runtime-3d"} _MODULE_DIRS: dict[str, str] | None = None def is_editor_module(name: str) -> bool: global _MODULE_DIRS if _MODULE_DIRS is None: _MODULE_DIRS = {item.name: item.dir for item in manifest.parse_manifest()} return manifest.is_authoring_facet(name, _MODULE_DIRS.get(name)) CORE_SEEDS = { "procgen-core-only": ("common",), "physics-core-only": ("common", "platform_event"), "asset-core-only": ("common", "data", "asset"), "headless": ("common", "data", "platform_event", "timer"), "server": ( "common", "data", "platform_event", "timer", "network", "authority", "decision", "definitions", "effects", "game_event", "orders", "schema", "social", "statepatch", "steering", "tags", "transaction", "economy", "attributes", "sensing", "spatial", "action", "settlement", ), } FORBIDDEN_HOSTLESS = {"cmdline", "window", "graphics", "physics", "procgen"} PHYSICS_CORE_SOURCES = ( "physics/Body.cpp", "physics/Body3D.cpp", "physics/Fixture.cpp", "physics/Joint3D.cpp", "physics/PhysicsHandles.cpp", "physics/PhysicsLink.cpp", "physics/Shape3D.cpp", "physics/backend/SimulationBackend.cpp", "physics/World.cpp", "physics/World3D.cpp", ) @dataclass(frozen=True) class ModuleContract: name: str deps: tuple[str, ...] third_party: tuple[str, ...] groups: tuple[str, ...] required: bool core: bool _KEYWORDS = { "NAME", "LIB", "LAYER", "DIR", "DEPS", "OPTIONAL_DEPS", "THIRDPARTY", "SCRIPT", "SLOT", "GROUP", "REQUIRED", "CORE", } _ONE_VALUE = {"NAME", "LIB", "LAYER", "DIR"} _MULTI_VALUE = {"DEPS", "OPTIONAL_DEPS", "THIRDPARTY", "SCRIPT", "SLOT", "GROUP"} def _cmake_blocks(path: Path) -> list[str]: lines = path.read_text(encoding="utf-8").splitlines(keepends=True) blocks: list[str] = [] for start, line in enumerate(lines): if not line.lstrip().startswith("eve_declare_module("): continue depth = 0 quoted = False escaped = False block: list[str] = [] for current in lines[start:]: block.append(current) for char in current: if escaped: escaped = False elif char == "\\" and quoted: escaped = True elif char == '"': quoted = not quoted elif not quoted and char == "(": depth += 1 elif not quoted and char == ")": depth -= 1 if depth == 0: blocks.append("".join(block)) break else: raise ValueError(f"unterminated eve_declare_module at line {start + 1}") return blocks def _parse_contract(block: str) -> ModuleContract: tokens = shlex.split(block.replace("\n", " ")) if not tokens or not tokens[0].startswith("eve_declare_module("): raise ValueError(f"invalid module declaration: {block!r}") tokens[0] = tokens[0][len("eve_declare_module("):] if not tokens[0]: tokens.pop(0) values: dict[str, list[str] | bool] = {} index = 0 while index < len(tokens): key = tokens[index].rstrip(")") index += 1 if key in {"REQUIRED", "CORE"}: values[key] = True elif key in _ONE_VALUE: if index >= len(tokens): raise ValueError(f"{key} has no value in {block!r}") values[key] = [tokens[index].rstrip(")")] index += 1 elif key in _MULTI_VALUE: result: list[str] = [] while index < len(tokens) and tokens[index].rstrip(")") not in _KEYWORDS: result.append(tokens[index].rstrip(")")) index += 1 values[key] = result else: # The first token is the command spelling; an unknown token after # it indicates a manifest syntax that this matrix cannot resolve. raise ValueError(f"unknown manifest token {key!r} in {block!r}") name = values.get("NAME", []) if not isinstance(name, list) or not name: raise ValueError(f"manifest declaration has no NAME: {block!r}") return ModuleContract( name=name[0], deps=tuple(values.get("DEPS", []) if isinstance(values.get("DEPS", []), list) else []), third_party=tuple( values.get("THIRDPARTY", []) if isinstance(values.get("THIRDPARTY", []), list) else [] ), groups=tuple(values.get("GROUP", []) if isinstance(values.get("GROUP", []), list) else []), required=bool(values.get("REQUIRED", False)), core=bool(values.get("CORE", False)), ) def contracts() -> list[ModuleContract]: return [ _parse_contract(block) for path in manifest.manifest_files() for block in _cmake_blocks(path) ] def resolve(profile: str, declared: list[ModuleContract] | None = None) -> tuple[list[str], list[str]]: modules = declared or contracts() by_name = {module.name: module for module in modules} if profile in CORE_SEEDS: wanted = list(CORE_SEEDS[profile]) else: wanted = [] for module in modules: selection_profile = "3d" if profile == "runtime-3d" else profile enabled = module.required or profile == "full" or selection_profile in module.groups if profile in RUNTIME_ONLY and is_editor_module(module.name): enabled = False if enabled: wanted.append(module.name) pending = list(wanted) while pending: name = pending.pop(0) if name not in by_name: raise ValueError(f"profile {profile}: undeclared module {name}") for dependency in by_name[name].deps: if dependency not in by_name: raise ValueError(f"module {name} depends on undeclared {dependency}") if dependency not in wanted: wanted.append(dependency) pending.append(dependency) ordered = [module.name for module in modules if module.name in wanted] groups: list[str] = [] for module in modules: if module.name in wanted: for group in module.third_party: if group not in groups: groups.append(group) return ordered, groups def check_contracts() -> int: declarations = manifest.parse_manifest() errors = manifest.validate(declarations, manifest.MODULES) try: module_contracts = contracts() except ValueError as error: errors.append(str(error)) module_contracts = [] names = {module.name for module in module_contracts} if set(PROFILES) & names: errors.append("profile name collides with a module name") for profile in PROFILES: try: enabled, groups = resolve(profile, module_contracts) except ValueError as error: errors.append(str(error)) continue enabled_set = set(enabled) if profile in RUNTIME_ONLY: leaked = sorted(name for name in enabled_set if is_editor_module(name)) if leaked: errors.append(f"{profile}: runtime-only profile enables {', '.join(leaked)}") if profile in HOSTLESS: leaked = sorted(enabled_set & FORBIDDEN_HOSTLESS) if leaked: errors.append(f"{profile}: hostless profile enables {', '.join(leaked)}") if profile == "procgen-core-only" and "EVProcgenCoreCheck" not in ( ROOT / "src" / "modules" / "CMakeLists.txt" ).read_text(encoding="utf-8"): errors.append("procgen-core-only: EVProcgenCoreCheck target is not wired") if profile == "physics-core-only" and "EVPhysicsCoreCheck" not in ( ROOT / "src" / "modules" / "CMakeLists.txt" ).read_text(encoding="utf-8"): errors.append("physics-core-only: EVPhysicsCoreCheck target is not wired") if profile == "physics-core-only": physics_cmake = (ROOT / "src" / "modules" / "CMakeLists.txt").read_text( encoding="utf-8" ) missing_sources = [ source for source in PHYSICS_CORE_SOURCES if source not in physics_cmake ] if missing_sources: errors.append( "physics-core-only: domain sources missing from core check: " + ", ".join(missing_sources) ) core_block = physics_cmake.split("set(_eve_physics_core_sources", 1)[-1].split( "set(_eve_physics_core_boundary_files", 1 )[0] if "graphics/" in core_block or "gpgpu/" in core_block: errors.append("physics-core-only: core source block mentions graphics/gpgpu") print( f"profile {profile:18s}: {len(enabled):2d} modules; " f"third-party groups: {', '.join(groups) or '(none)'}" ) profile_cmake = (ROOT / "cmake" / "profile_checks.cmake").read_text(encoding="utf-8") if "eve_public_header_checks" not in profile_cmake: errors.append("independent public-header target is missing") if "eve_capability_present_check" not in profile_cmake or "eve_capability_absent_check" not in profile_cmake: errors.append("independent optional-capability targets are incomplete") if any( "target_link_libraries" in line and "unit_test" in line for line in profile_cmake.splitlines() ): errors.append("profile checks must not link unit_test") test_cmake = (ROOT / "test" / "CMakeLists.txt").read_text(encoding="utf-8") if "eve_thirdparty_libs(_eve_test_tp_libs" not in test_cmake: errors.append("test runner is not using manifest-derived third-party groups") if errors: for error in errors: print(f"FAIL {error}", file=sys.stderr) return 1 print(f"profile matrix contract OK: {len(PROFILES)} profiles") return 0 def build_directory(root: Path, profile: str) -> Path: return root / profile def cmake_configure_command(args: argparse.Namespace, profile: str) -> list[str]: platform = args.platform or ("webgpu" if profile == "web" else "linux") cmake_command = shlex.split(args.cmake_command) if not cmake_command: raise ValueError("--cmake-command must not be empty") return cmake_command + [ "-S", str(ROOT), "-B", str(build_directory(Path(args.build_root), profile)), "-G", args.generator, "-DCMAKE_BUILD_TYPE=Debug", f"-DBUILD_PLATFORM={platform}", "-DBUILD_TESTING=OFF", "-DEVENGINE_BUILD_DEMO=OFF", "-DEVENGINE_BUILD_PROFILE_CHECKS=ON", f"-DEVENGINE_PROFILE={profile}", ] def run_command(command: list[str], cwd: Path, env: dict[str, str] | None = None) -> None: print("$ " + shlex.join(command)) subprocess.run(command, cwd=cwd, env=env, check=True) def run_smoke(args: argparse.Namespace, profile: str) -> None: directory = build_directory(Path(args.build_root), profile) candidates: list[Path] = [] for stem in ("eve_capability_present_check", "eve_capability_absent_check"): candidates.extend(sorted(directory.rglob(stem))) candidates.extend(sorted(directory.rglob(stem + ".js"))) if not candidates: raise RuntimeError( f"{profile}: capability smoke binaries are missing; build eve_profile_smoke first" ) for candidate in candidates: if candidate.suffix == ".js": node = os.environ.get("NODE", "node") run_command([node, str(candidate)], ROOT) elif candidate.is_file() and os.access(candidate, os.X_OK): run_command([str(candidate)], ROOT) def execute_profile(args: argparse.Namespace, profile: str) -> None: command = cmake_configure_command(args, profile) directory = build_directory(Path(args.build_root), profile) if args.dry_run: if args.configure: print("$ " + shlex.join(command)) if args.build: print( "$ " + shlex.join( [ "cmake", "--build", str(directory), "--target", "eve_profile_smoke", "--parallel", str(args.jobs), ] ) ) if args.smoke: print(f"$ ") return if args.configure: run_command(command, ROOT) if args.build: run_command( [ "cmake", "--build", str(directory), "--target", "eve_profile_smoke", "--parallel", str(args.jobs), ], ROOT, ) if args.smoke: run_smoke(args, profile) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) selection = parser.add_mutually_exclusive_group() selection.add_argument("--profile", choices=PROFILES) selection.add_argument("--all", action="store_true", help="run every profile in matrix order") parser.add_argument("--check", action="store_true", help="run the static contract check") parser.add_argument("--configure", action="store_true", help="run CMake configure") parser.add_argument("--build", action="store_true", help="build eve_profile_smoke") parser.add_argument("--smoke", action="store_true", help="run independent capability probes") parser.add_argument("--dry-run", action="store_true", help="print commands without executing them") parser.add_argument("--build-root", default=str(ROOT / "build" / "profile-matrix")) parser.add_argument("--generator", default="Ninja") parser.add_argument("--platform", help="override BUILD_PLATFORM (default: linux, webgpu for web)") parser.add_argument( "--cmake-command", default=os.environ.get("EVENGINE_CMAKE_COMMAND", "cmake"), help="configure launcher, e.g. 'emcmake cmake' for Emscripten", ) parser.add_argument("--jobs", type=int, default=min(os.cpu_count() or 1, 4)) args = parser.parse_args(argv) if not any((args.check, args.configure, args.build, args.smoke, args.dry_run)): args.check = True if args.dry_run and not any((args.configure, args.build, args.smoke)): args.configure = args.build = args.smoke = True if args.check: result = check_contracts() if result: return result selected = list(PROFILES) if args.all else ([args.profile] if args.profile else []) if (args.configure or args.build or args.smoke or args.dry_run) and not selected: parser.error("--profile or --all is required with configure/build/smoke/dry-run") for profile in selected: execute_profile(args, profile) return 0 if __name__ == "__main__": sys.exit(main())