349 lines
18 KiB
Python
349 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Export the checked-in playable projects through the real Editor and run relocated games.
|
|
|
|
Requires a built Editor, native C++ build tools, Slang and a Vulkan 1.3 driver.
|
|
Uses --headless for offscreen rendering. Windows CI can use the pinned SwiftShader
|
|
setup; the report records whether Khronos validation was actually available.
|
|
Output must be new or empty. Relocated games are retained in an external temporary
|
|
directory, and captures/profiles/logs are copied into the evidence directory.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
|
|
def require(condition: bool, message: str) -> None:
|
|
if not condition:
|
|
raise RuntimeError(message)
|
|
|
|
|
|
def read_json(path: Path) -> dict:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def write_json(path: Path, value: object) -> None:
|
|
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
|
|
|
|
def sha256(path: Path) -> str:
|
|
with path.open("rb") as stream:
|
|
return hashlib.file_digest(stream, "sha256").hexdigest()
|
|
|
|
|
|
def vulkan_device_details(summary: str, device: str) -> dict:
|
|
"""Return the driver identity from vulkaninfo for the Player-selected device."""
|
|
for section in re.split(r"(?m)^GPU\d+:\s*$", summary)[1:]:
|
|
fields = dict(re.findall(
|
|
r"(?m)^\s*(apiVersion|driverVersion|driverID|driverName|driverInfo|deviceName)"
|
|
r"\s*=\s*(.+?)\s*$", section))
|
|
if fields.get("deviceName") == device:
|
|
return fields
|
|
return {}
|
|
|
|
|
|
def device_class(device: str, driver: dict) -> str:
|
|
identity = " ".join((device, driver.get("driverName", ""), driver.get("driverInfo", ""))).lower()
|
|
if any(name in identity for name in ("swiftshader", "llvmpipe", "lavapipe", "softpipe")):
|
|
return "software"
|
|
if any(name in identity for name in ("nvidia", "geforce", "radeon", "amd", "intel", "arc")):
|
|
return "physical"
|
|
return "unknown"
|
|
|
|
|
|
def git_identity(engine: Path) -> dict:
|
|
def git(*arguments: str) -> str:
|
|
return subprocess.run(["git", "-C", str(engine), *arguments], check=True,
|
|
capture_output=True, text=True).stdout
|
|
revision = git("rev-parse", "HEAD").strip()
|
|
status = git("status", "--porcelain", "--untracked-files=normal")
|
|
diff = git("diff", "--binary", "HEAD")
|
|
return {"revision": revision, "dirty": bool(status),
|
|
"status_lines": status.splitlines(),
|
|
"diff_sha256": hashlib.sha256(diff.encode("utf-8")).hexdigest() if diff else None}
|
|
|
|
|
|
def run(arguments: list[str | Path], cwd: Path, log: Path, timeout: int = 1800) -> dict:
|
|
command = [str(argument) for argument in arguments]
|
|
print(f"{log.stem}: {subprocess.list2cmdline(command)}", flush=True)
|
|
started = time.monotonic()
|
|
process = subprocess.Popen(command, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
text=True, encoding="utf-8", errors="replace",
|
|
start_new_session=os.name != "nt",
|
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0)
|
|
timed_out = False
|
|
try:
|
|
stdout, stderr = process.communicate(timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
timed_out = True
|
|
if os.name == "nt":
|
|
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
|
else:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
stdout, stderr = process.communicate()
|
|
record = {"arguments": command, "cwd": str(cwd), "seconds": time.monotonic() - started,
|
|
"exit_code": process.returncode, "timed_out": timed_out,
|
|
"stdout": stdout, "stderr": stderr}
|
|
write_json(log, record)
|
|
require(not timed_out and process.returncode == 0,
|
|
f"Command failed; inspect {log}: {stderr[-2000:]} {stdout[-2000:]}")
|
|
return record
|
|
|
|
|
|
def command(editor: Path, engine: Path, project: Path, name: str, arguments: dict, log: Path) -> dict:
|
|
response = run([editor, "--engine", engine, "--project", project, "--command",
|
|
json.dumps({"name": name, "arguments": arguments}), "--wait"], engine, log)
|
|
result = json.loads(response["stdout"])
|
|
require(result.get("state") == "succeeded", f"Editor job did not succeed: {log}")
|
|
return result
|
|
|
|
|
|
def relative_path(root: Path, relative: str) -> Path:
|
|
path = (root / relative).resolve()
|
|
require(path.is_relative_to(root.resolve()), f"Package path escapes its root: {relative}")
|
|
return path
|
|
|
|
|
|
def verify_package(directory: Path) -> dict:
|
|
manifest = read_json(directory / "manifest.json")
|
|
require(manifest.get("format") == "faset.export" and manifest.get("version") == 1,
|
|
"Unsupported export manifest")
|
|
require(manifest["configuration"] == "Release", "Playable exports must use Release")
|
|
for entry in manifest["files"]:
|
|
path = relative_path(directory, entry["path"])
|
|
require(path.is_file() and not path.is_symlink(), f"Missing packaged file: {path}")
|
|
require(path.stat().st_size == entry["size"] and sha256(path) == entry["sha256"],
|
|
f"Packaged file checksum mismatch: {path}")
|
|
declared = [entry["path"] for entry in manifest["files"]]
|
|
actual = {path.relative_to(directory).as_posix() for path in directory.rglob("*")
|
|
if path.is_file() or path.is_symlink()}
|
|
require(len(declared) == len(set(declared)) and actual == set(declared) | {"manifest.json"},
|
|
"Package has undeclared, duplicate or missing files")
|
|
return manifest
|
|
|
|
|
|
def verify_capture(path: Path, *, language: str = "cpp") -> dict:
|
|
header, dimensions, maximum, pixels = path.read_bytes().split(b"\n", 3)
|
|
require(header == b"P6" and maximum == b"255", "Expected RGB PPM screenshot")
|
|
width, height = (int(value) for value in dimensions.split())
|
|
require(width > 0 and height > 0 and len(pixels) == width * height * 3,
|
|
"Incomplete screenshot")
|
|
step = 3 * max(1, width * height // 10000)
|
|
colors = len({pixels[index:index + 3] for index in range(0, len(pixels), step)})
|
|
# The checked-in Lua sample deliberately has four flat palette colors.
|
|
require(colors >= (4 if language == "lua" else 6),
|
|
"Screenshot lacks the expected game geometry/colors")
|
|
if language == "lua":
|
|
sampled = {pixels[index:index + 3] for index in range(0, len(pixels), step)}
|
|
require(any(r > 220 and g > 120 and b < 100 for r, g, b in sampled) and
|
|
any(r < 100 and g > 150 and b > 180 for r, g, b in sampled),
|
|
"Lua screenshot lacks its gold collectible or cyan player")
|
|
return {"width": width, "height": height, "sampled_colors": colors, "sha256": sha256(path)}
|
|
|
|
|
|
def project_matrix(include_lua: bool, only_lua: bool) -> list[tuple[str, int, str]]:
|
|
"""Keep the existing two-game default; explicitly opt in to the Lua export."""
|
|
if only_lua:
|
|
return [("lua", 2, "lua")]
|
|
projects = [("collect-2d", 2, "cpp"), ("collect-3d", 3, "cpp")]
|
|
if include_lua:
|
|
projects.append(("lua", 2, "lua"))
|
|
return projects
|
|
|
|
|
|
def project_workspace(engine: Path, output: Path) -> Path:
|
|
"""Native builds must never be staged under the engine source tree."""
|
|
if output.is_relative_to(engine):
|
|
return Path(tempfile.mkdtemp(prefix="faset-playable-projects-"))
|
|
return output
|
|
|
|
|
|
def verify_lua_export(directory: Path, manifest: dict) -> None:
|
|
require(manifest.get("lua_enabled") is True, "Lua export did not enable the Lua runtime")
|
|
scripts = [entry["path"] for entry in manifest["files"]
|
|
if entry["path"].startswith("Scripts/") and entry["path"].endswith(".lua")]
|
|
require(scripts and all((directory / path).is_file() for path in scripts),
|
|
"Lua export has no packaged gameplay source")
|
|
require("Notices/lua/LICENSE.txt" in {entry["path"] for entry in manifest["files"]} and
|
|
(directory / "Notices/lua/LICENSE.txt").is_file(),
|
|
"Lua export is missing its license notice")
|
|
require(not any(path.startswith("Scripts/") and
|
|
path.lower().endswith((".cpp", ".cc", ".cxx", ".hpp", ".hh", ".h"))
|
|
for path in (entry["path"] for entry in manifest["files"])),
|
|
"Lua-only export unexpectedly contains C++ gameplay source")
|
|
require(not any(path.endswith(".luarc.json") for path in
|
|
(entry["path"] for entry in manifest["files"])),
|
|
"Lua-only export includes Editor language-server configuration")
|
|
|
|
|
|
def verify_lua_execution(stderr: str) -> None:
|
|
require("Lua player ready: A/D move, Space jump, E reset" in stderr,
|
|
"Lua gameplay on_start did not execute in the relocated Player")
|
|
|
|
|
|
def main() -> int:
|
|
# Keep captured CI logs portable even when the Windows console uses a legacy code page.
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--engine", type=Path, default=Path(__file__).resolve().parents[1])
|
|
parser.add_argument("--editor", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
parser.add_argument("--standalone-root", type=Path,
|
|
help="New/empty directory outside the engine and output trees")
|
|
parser.add_argument("--include-lua", action="store_true",
|
|
help="Also export, relocate, validate and render examples/lua")
|
|
parser.add_argument("--only-lua", action="store_true",
|
|
help="Run only the Lua project (useful for focused local verification)")
|
|
args = parser.parse_args()
|
|
engine, editor, output = args.engine.resolve(), args.editor.resolve(), args.output.resolve()
|
|
require(editor.is_file(), f"Editor does not exist: {editor}")
|
|
require(not output.exists() or not any(output.iterdir()), "Output directory must be new/empty")
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
standalone = (args.standalone_root.resolve() if args.standalone_root else
|
|
Path(tempfile.mkdtemp(prefix="faset-playable-exports-")))
|
|
require(not standalone.is_relative_to(engine) and not standalone.is_relative_to(output),
|
|
"Standalone games must be outside the engine and evidence trees")
|
|
require(not standalone.exists() or not any(standalone.iterdir()),
|
|
"Standalone root must be new/empty")
|
|
standalone.mkdir(parents=True, exist_ok=True)
|
|
workspace = project_workspace(engine, output)
|
|
evidence, projects = output / "evidence", workspace / "Faset Café 世界"
|
|
evidence.mkdir()
|
|
projects.mkdir()
|
|
source_identity = git_identity(engine)
|
|
vulkan_summary = ""
|
|
if shutil.which("vulkaninfo"):
|
|
try:
|
|
probe = subprocess.run(["vulkaninfo", "--summary"], capture_output=True,
|
|
text=True, encoding="utf-8", errors="replace", timeout=30)
|
|
if probe.returncode == 0:
|
|
vulkan_summary = probe.stdout
|
|
(evidence / "vulkaninfo-summary.txt").write_text(
|
|
probe.stdout + probe.stderr, encoding="utf-8")
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
pass
|
|
standalone_projects = standalone / "Faset Café 世界"
|
|
standalone_projects.mkdir()
|
|
matrix = project_matrix(args.include_lua, args.only_lua)
|
|
report = {"format": "faset.playable-export-verification", "version": 1,
|
|
"started_utc": datetime.now(timezone.utc).isoformat(), "platform": sys.platform,
|
|
"engine": str(engine), "editor": str(editor), "standalone_root": str(standalone),
|
|
"engine_revision": source_identity["revision"],
|
|
"engine_dirty": source_identity["dirty"],
|
|
"engine_git_status": source_identity["status_lines"],
|
|
"engine_diff_sha256": source_identity["diff_sha256"],
|
|
"editor_sha256": sha256(editor),
|
|
"project_workspace": str(workspace),
|
|
"vulkaninfo_available": bool(vulkan_summary),
|
|
"driver_override": os.environ.get("VK_DRIVER_FILES") or os.environ.get("VK_ICD_FILENAMES"),
|
|
"frames_per_game": 120, "status": "running", "projects": [],
|
|
"requested_projects": [name for name, _, _ in matrix]}
|
|
disabled = workspace / "projects-offline"
|
|
try:
|
|
for name, dimension, language in matrix:
|
|
source = engine / "examples" / ("lua" if language == "lua" else f"projects/{name}")
|
|
project = projects / name
|
|
shutil.copytree(source, project, ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1"))
|
|
inputs = [{"path": path.relative_to(project).as_posix(), "sha256": sha256(path)}
|
|
for path in sorted(project.rglob("*")) if path.is_file()]
|
|
settings = read_json(project / "project.faset.json")
|
|
scene = read_json(relative_path(project, settings["start_scene"]))
|
|
item = {"name": name, "dimension": dimension, "language": language,
|
|
"source_inputs": inputs}
|
|
report["projects"].append(item)
|
|
if dimension == 3:
|
|
imported = command(editor, engine, project, "faset_import",
|
|
{"path": "Assets/exit-arch/manifest.json"}, evidence / f"{name}-import.json")
|
|
item["import"] = imported["result"]
|
|
exported = command(editor, engine, project, "faset_export",
|
|
{"document": scene["id"], "output": "Exports"},
|
|
evidence / f"{name}-export.json")
|
|
pointer = read_json(project / "Exports/current.json")
|
|
require(pointer["format"] == "faset.export-pointer" and pointer["version"] == 1,
|
|
"Invalid current export pointer")
|
|
generation = relative_path(project / "Exports", pointer["directory"])
|
|
require(generation == Path(exported["result"]["directory"]).resolve(),
|
|
"Export response and published pointer disagree")
|
|
manifest = verify_package(generation)
|
|
if language == "lua":
|
|
verify_lua_export(generation, manifest)
|
|
require(dimension != 3 or bool(manifest["asset_generations"]),
|
|
"3D game did not package its imported Blender asset")
|
|
relocated = standalone_projects / name
|
|
shutil.copytree(generation, relocated)
|
|
verify_package(relocated)
|
|
if language == "lua":
|
|
verify_lua_export(relocated, manifest)
|
|
shutil.copy2(relocated / "manifest.json", evidence / f"{name}-manifest.json")
|
|
item.update({"generation": pointer["generation"], "configuration": "Release",
|
|
"standalone_directory": str(relocated), "executable": manifest["executable"],
|
|
"package_file_count": len(manifest["files"]),
|
|
"asset_generations": manifest["asset_generations"]})
|
|
write_json(output / "report.json", report)
|
|
# Hide exactly our disposable source-project paths while launching the games.
|
|
# An empty, unrelated cwd also catches assumptions about the current directory.
|
|
projects.rename(disabled)
|
|
working = standalone / "empty-working-directory"
|
|
working.mkdir()
|
|
for item in report["projects"]:
|
|
require(not projects.exists(), "Source project paths must be unavailable during launch")
|
|
name, dimension = item["name"], item["dimension"]
|
|
relocated = Path(item["standalone_directory"])
|
|
player = relative_path(relocated, item["executable"])
|
|
validation = run([player, "--validate"], working, evidence / f"{name}-validate.json", 120)
|
|
require(json.loads(validation["stdout"]).get("validated") is True,
|
|
"Standalone CPU validation failed")
|
|
capture, profile = relocated / "verification.ppm", relocated / "profile.json"
|
|
rendered = run([player, "--headless", "--frames", "120", "--capture", capture,
|
|
"--profile", profile], working, evidence / f"{name}-run.json", 300)
|
|
if item["language"] == "lua":
|
|
verify_lua_execution(rendered["stderr"])
|
|
summary = json.loads(rendered["stdout"].strip().splitlines()[-1])
|
|
require(summary["frames"] == 120 and summary["dimension"] == dimension and
|
|
summary["validation_errors"] == 0, "Standalone GPU run reported an error")
|
|
measured = read_json(profile)
|
|
require(measured["format"] == "faset.player-profile" and measured["version"] == 1 and
|
|
measured["completed_frames"] == 120 and measured["dimension"] == dimension and
|
|
measured["presentation_mode"] == "offscreen" and measured["validation_errors"] == 0 and
|
|
len(measured["samples"]) == 120, "Invalid or incomplete frame profile")
|
|
require(all(frame["gpu_allocated_bytes"] > 0 for frame in measured["samples"]),
|
|
"Frame profile lacks Vulkan allocation measurements")
|
|
driver = vulkan_device_details(vulkan_summary, measured["device"])
|
|
item.update({"device": measured["device"], "device_class": device_class(measured["device"], driver),
|
|
"driver": driver, "validation_enabled": measured["validation_enabled"],
|
|
"validation_errors": 0, "completed_frames": 120,
|
|
"summary_ms": measured["summary_ms"],
|
|
"capture": verify_capture(capture, language=item["language"]),
|
|
"source_project_paths_unavailable": True, "status": "passed"})
|
|
shutil.copy2(capture, evidence / f"{name}.ppm")
|
|
shutil.copy2(profile, evidence / f"{name}-profile.json")
|
|
write_json(output / "report.json", report)
|
|
report["status"] = "passed"
|
|
except Exception as error:
|
|
report.update({"status": "failed", "error": str(error)})
|
|
raise
|
|
finally:
|
|
if disabled.exists() and not projects.exists():
|
|
disabled.rename(projects)
|
|
report["finished_utc"] = datetime.now(timezone.utc).isoformat()
|
|
write_json(output / "report.json", report)
|
|
print(f"{len(matrix)} relocated Release game(s) passed: {output / 'report.json'}",
|
|
flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|