470 lines
17 KiB
Python
470 lines
17 KiB
Python
#!/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"$ <run {directory}/profile/eve_capability_{'{present,absent}'}_check>")
|
|
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())
|