226 lines
12 KiB
Python
226 lines
12 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 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 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}")
|
|
return manifest
|
|
|
|
|
|
def verify_capture(path: Path) -> 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)})
|
|
require(colors >= 6, "Screenshot lacks the expected game geometry/colors")
|
|
return {"width": width, "height": height, "sampled_colors": colors, "sha256": sha256(path)}
|
|
|
|
|
|
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")
|
|
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)
|
|
evidence, projects = output / "evidence", output / "Faset Café 世界"
|
|
evidence.mkdir()
|
|
projects.mkdir()
|
|
standalone_projects = standalone / "Faset Café 世界"
|
|
standalone_projects.mkdir()
|
|
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),
|
|
"frames_per_game": 120, "status": "running", "projects": []}
|
|
disabled = output / "projects-offline"
|
|
try:
|
|
for dimension in (2, 3):
|
|
name = f"collect-{dimension}d"
|
|
source, project = engine / "examples/projects" / name, 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, "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)
|
|
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)
|
|
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 both 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)
|
|
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")
|
|
item.update({"device": measured["device"], "validation_enabled": measured["validation_enabled"],
|
|
"validation_errors": 0, "completed_frames": 120,
|
|
"summary_ms": measured["summary_ms"], "capture": verify_capture(capture),
|
|
"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"Both relocated Release games passed: {output / 'report.json'}", flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|