Files
Faset_Engine/tools/verify_playable_exports.py
T

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())