Checkpoint 3: deliver playable samples and complete native authoring workflows

This commit is contained in:
Emil
2026-09-18 04:29:41 +03:00
parent fad5eb4e55
commit d834cfad67
92 changed files with 8335 additions and 353 deletions
+1 -1
View File
@@ -8,4 +8,4 @@ Objects, mesh datablocks and materials receive `faset_id` custom properties. Ren
The engine imports `manifest.json` or ordinary GLB/glTF. Gameplay components, physics settings and instance overrides are engine-owned data. The exporter does not write them. Without IDs, the engine does not promise reliable matching after renaming internal parts. Arbitrary procedural Blender materials require baking or an explicit engine material; this profile does not claim pixel-identical shading.
`bundle.py` is independent of Blender and has executable fixture tests. Blender UI/export execution still needs validation in an installed Blender version; this repository's tests do not substitute for that check.
`bundle.py` is independent of Blender and has executable fixture tests. The actual helper was also executed in unmodified Blender 4.5.3 LTS, followed by real Editor imports checking stable rename, geometry changes, deleted-output conflict and failure preservation. Reproduce with `python tools/verify_blender_roundtrip.py --blender PATH --editor PATH`. This background integration check does not substitute for testing every Blender UI/platform combination. See `docs/manual/editor/assets.md` for the user workflow and supported content profile.
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Compile Slang and atomically publish SPIR-V with Faset reflection v1.
The layout fingerprint contains the declared shader interface and actual SPIR-V
push-constant decorations, never compiler formatting or generated ID numbers.
A failed compiler invocation or reflection conversion leaves previous files intact.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import struct
import subprocess
import sys
import tempfile
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def canonical(value: object) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def value_type(value: dict) -> str:
kind = value["kind"]
if kind == "scalar":
return value["scalarType"]
if kind == "vector":
return f"{value_type(value['elementType'])}x{value['elementCount']}"
if kind == "matrix":
return f"{value_type(value['elementType'])}x{value['rowCount']}x{value['columnCount']}"
if kind == "array":
return f"{value_type(value['elementType'])}[{value['elementCount']}]"
raise ValueError(f"Unsupported reflected value type: {kind}")
def interface(fields: list[dict], category: str) -> tuple[list[dict], list[dict]]:
locations, builtins = [], []
def visit(field: dict, offset: int = 0) -> None:
ty, binding = field["type"], field.get("binding", {})
if ty["kind"] == "struct":
for child in ty["fields"]:
visit(child, offset + binding.get("index", 0))
elif binding.get("kind") == category:
locations.append({"location": offset + binding["index"], "type": value_type(ty)})
elif field.get("semanticName", "").startswith("SV_"):
builtins.append({"semantic": field["semanticName"], "type": value_type(ty)})
else:
raise ValueError("Shader interface field lacks a supported location or builtin")
for field in fields:
visit(field)
return sorted(locations, key=lambda item: item["location"]), sorted(builtins, key=lambda item: item["semantic"])
def spirv_push_layout(data: bytes) -> list[dict]:
if len(data) < 20 or len(data) % 4:
raise ValueError("Malformed SPIR-V byte length")
words = struct.unpack(f"<{len(data) // 4}I", data)
if words[0] != 0x07230203 or words[1] > 0x00010600 or not words[3] or words[4]:
raise ValueError("Unsupported SPIR-V header")
pointers, variables, decorations = {}, [], {}
position = 5
while position < len(words):
count, opcode = words[position] >> 16, words[position] & 0xFFFF
if not count or position + count > len(words):
raise ValueError("Malformed SPIR-V instruction")
operands = words[position + 1:position + count]
if opcode == 32 and len(operands) == 3: # OpTypePointer
pointers[operands[0]] = (operands[1], operands[2])
elif opcode == 59 and len(operands) >= 3 and operands[2] == 9: # PushConstant OpVariable
variables.append(operands[0])
elif opcode == 72 and len(operands) >= 3: # OpMemberDecorate
member = decorations.setdefault(operands[0], {}).setdefault(operands[1], {})
if operands[2] in (4, 5):
member["matrix_layout"] = "row-major" if operands[2] == 4 else "column-major"
elif operands[2] in (7, 35):
if len(operands) != 4:
raise ValueError("Malformed SPIR-V member decoration")
member["matrix_stride" if operands[2] == 7 else "offset"] = operands[3]
position += count
result = []
for pointer in variables:
storage, structure = pointers[pointer]
if storage != 9:
raise ValueError("Invalid SPIR-V push-constant pointer")
members = [{"member": index, **layout} for index, layout in sorted(decorations.get(structure, {}).items())]
result.append({"members": members})
return result
def normalize(raw: dict, bytecode: bytes, entry_name: str) -> dict:
entry = next(item for item in raw["entryPoints"] if item["name"] == entry_name)
used = {item["name"]: bool(item["binding"].get("used", True)) for item in entry.get("bindings", [])}
descriptors, constants = [], []
for parameter in raw.get("parameters", []):
binding, ty = parameter["binding"], parameter["type"]
if binding["kind"] == "pushConstantBuffer":
block = ty["elementType"]
size = next(item["value"] for item in block["sizes"] if item["kind"] == "uniform")
members = [{"name": member["name"], "offset": member["binding"]["offset"], "size": member["binding"]["size"], "type": value_type(member["type"])} for member in block["fields"]]
constants.append({"name": parameter["name"], "offset": 0, "size": size, "members": sorted(members, key=lambda item: item["offset"])})
elif binding["kind"] == "descriptorTableSlot":
count = binding.get("count", 1)
if ty["kind"] == "array":
count = ty["elementCount"]
ty = ty["elementType"]
if ty["kind"] == "samplerState":
descriptor_type = "sampler"
elif ty["kind"] == "resource" and ty.get("baseShape") == "texture2D":
descriptor_type = "sampled_image_2d"
else:
raise ValueError(f"Unsupported descriptor kind: {ty}")
descriptors.append({"name": parameter["name"], "set": binding.get("space", 0), "binding": binding["index"], "type": descriptor_type, "count": count, "used": used.get(parameter["name"], True)})
else:
raise ValueError(f"Unsupported global shader binding: {binding['kind']}")
inputs, input_builtins = interface(entry.get("parameters", []), "varyingInput")
outputs, output_builtins = interface([entry["result"]] if "result" in entry else [], "varyingOutput")
layout = {"stage": entry["stage"], "descriptors": sorted(descriptors, key=lambda item: (item["set"], item["binding"])), "push_constants": constants, "inputs": inputs, "outputs": outputs, "input_builtins": input_builtins, "output_builtins": output_builtins, "spirv_push_constants": spirv_push_layout(bytecode)}
return {"format": "faset.shader-reflection", "version": 1, "source_entry": entry_name, "entry_point": "main", "matrix_convention": "column-major host matrices; Slang SPIR-V decorations recorded explicitly", "spirv_sha256": digest(bytecode), "layout_fingerprint": digest(canonical(layout)), "layout": layout}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--compiler", required=True)
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--entry", required=True)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".shader-", dir=args.output) as temporary:
directory = Path(temporary)
spirv = directory / f"{args.entry}.spv"
raw = directory / f"{args.entry}.slang-reflection.json"
process = subprocess.run([args.compiler, str(args.source), "-entry", args.entry, "-target", "spirv", "-profile", "spirv_1_6", "-matrix-layout-column-major", "-o", str(spirv), "-reflection-json", str(raw)])
if process.returncode:
return process.returncode
normalized = normalize(json.loads(raw.read_text(encoding="utf-8")), spirv.read_bytes(), args.entry)
manifest = directory / f"{args.entry}.reflection.json"
manifest.write_text(json.dumps(normalized, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
# Reflection is the commit record. A reader racing these replacements rejects
# a hash mismatch and keeps its existing pipelines until the complete pair arrives.
for artifact in (raw, spirv, manifest):
os.replace(artifact, args.output / artifact.name)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, KeyError, StopIteration) as error:
print(f"Shader reflection error: {error}", file=sys.stderr)
raise SystemExit(1)
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Measure real Editor/import/C++ iteration on a disposable copy of a sample project.
Numbers are observations, not CI pass/fail thresholds. Linux peak RSS comes from
GNU time for the command and its waited-for children; other platforms report null.
"""
import argparse
import datetime
import json
import os
from pathlib import Path
import platform
import shutil
import subprocess
import time
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--editor", type=Path, required=True)
parser.add_argument("--project", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
editor, source, output = args.editor.resolve(), args.project.resolve(), args.output.resolve()
if output.exists():
raise RuntimeError("Use a new output directory; existing evidence is never overwritten")
output.mkdir(parents=True)
project = output / "project"
shutil.copytree(source, project, ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1"))
samples = []
def run(label, arguments, timeout=1800):
memory = output / (label + "-memory.json")
command = [str(arg) for arg in arguments]
wrapped = command
if platform.system() == "Linux" and Path("/usr/bin/time").is_file():
wrapped = ["/usr/bin/time", "-f", '{"peak_rss_kib":%M}', "-o", str(memory), *command]
started = time.perf_counter()
result = subprocess.run(wrapped, cwd=output, capture_output=True, text=True,
encoding="utf-8", timeout=timeout)
elapsed = time.perf_counter() - started
(output / (label + "-stdout.txt")).write_text(result.stdout, encoding="utf-8")
(output / (label + "-stderr.txt")).write_text(result.stderr, encoding="utf-8")
sample = {"name": label, "seconds": elapsed, "returncode": result.returncode,
"peak_rss_kib": json.loads(memory.read_text())["peak_rss_kib"] if memory.exists() else None}
samples.append(sample)
(output / "progress.json").write_text(json.dumps(samples, indent=2), encoding="utf-8")
if result.returncode:
raise RuntimeError(f"{label} failed: {result.stderr[-4000:]} {result.stdout[-2000:]}")
print(f"{label}: {elapsed:.3f}s, peak RSS {sample['peak_rss_kib']} KiB", flush=True)
return result
def call(label, name, arguments=None):
result = run(label, [editor, "--project", project, "--command",
json.dumps({"name": name, "arguments": arguments or {}}), "--wait"])
document = json.loads(result.stdout)
if "state" in document and document["state"] != "succeeded":
raise RuntimeError(f"{label} job did not succeed: {document}")
return document
for index in range(3):
call(f"headless-startup-{index + 1}", "faset_project")
# Separate GUI startup from headless command latency. Includes two frames and
# shutdown, not a claimed first-visible-frame timestamp.
run("editor-gui-two-frames", [editor, "--project", project, "--frames", "2"])
manifest = project / "Assets/exit-arch/manifest.json"
if manifest.exists():
call("import-empty-cache", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
call("import-existing-generation", "faset_import", {"path": "Assets/exit-arch/manifest.json"})
call("initial-debug-build", "faset_build")
call("unchanged-debug-build", "faset_build")
gameplay = project / "Scripts/Gameplay.cpp"
original = gameplay.read_bytes()
gameplay.write_bytes(original + b"\n// Measurement: one gameplay translation unit changed.\n")
# Verify the user-facing stale status before a successful replacement.
assert call("schema-stale-after-edit", "faset_schema_status")["stale"]
iteration_started = time.perf_counter()
built = call("changed-debug-build", "faset_build")["result"]
profile = output / "development-player.json"
settings = json.loads((project / "project.faset.json").read_text(encoding="utf-8"))
run("changed-build-player-first-frame", [built["player"], "--scene",
project / settings["start_scene"], "--assets", project / ".faset/cache",
"--headless", "--frames", "1", "--profile", profile])
iteration_seconds = time.perf_counter() - iteration_started
assert not call("schema-current-after-build", "faset_schema_status")["stale"]
# Keep original inputs in this disposable project, without implying its last
# built binary matches the restored source signature.
gameplay.write_bytes(original)
engine = Path(__file__).resolve().parents[1]
revision = subprocess.run(["git", "rev-parse", "HEAD"], cwd=engine,
capture_output=True, text=True).stdout.strip()
dirty = subprocess.run(["git", "status", "--porcelain"], cwd=engine,
capture_output=True, text=True).stdout.strip()
cpu = platform.processor()
if Path("/proc/cpuinfo").exists():
cpu = next((line.split(":", 1)[1].strip() for line in Path("/proc/cpuinfo").read_text().splitlines()
if line.startswith("model name")), cpu)
report = {
"format": "faset.workflow-measurements", "version": 1,
"recorded_at_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"revision": revision, "working_tree_dirty": bool(dirty),
"host": {"platform": platform.platform(), "cpu": cpu,
"logical_cpus": os.cpu_count(), "python": platform.python_version()},
"source_project": str(source), "editor": str(editor),
"build_configuration": "Debug", "samples": samples,
"changed_build_and_one_frame_process_seconds": iteration_seconds,
"player": json.loads(profile.read_text()),
"method": [
"Fresh disposable project, engine dependency archives and OS file caches already available.",
"Each Editor command starts a new process and includes shutdown in wall time.",
"GUI startup sample includes window creation, two frames, and shutdown.",
"Changed build appends a harmless comment, recompiles Gameplay.cpp and relinks native outputs.",
"Combined iteration runs the returned Debug Player in a separate process for one offscreen frame.",
"Peak RSS is GNU time maximum over each command and waited-for children, not a sum.",
"Numbers include ambient host load; there is no warm-up exclusion or real-time frame guarantee."
]}
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Linux clean-checkout acceptance in a network namespace with no external network.
Uses committed HEAD, prefetched checksum-verified archives and local system tools.
Does not download dependencies, install packages or change the source checkout.
"""
import argparse
import datetime
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import time
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--parallel", type=int, default=4)
args = parser.parse_args()
if sys.platform != "linux" or not shutil.which("unshare"):
parser.error("This check requires Linux unshare with user/network namespace support")
if not 1 <= args.parallel <= 64:
parser.error("--parallel must be between 1 and 64")
root, output = Path(__file__).resolve().parents[1], args.output.resolve()
if output.exists():
parser.error("Use a new output directory to preserve previous evidence")
subprocess.run([sys.executable, root / "tools/fetch_dependencies.py", "--verify-only"], check=True)
if not (root / ".cache/slang/bin/slangc").is_file():
parser.error("Prefetch Slang before starting the offline check")
# Verify capability before making a potentially expensive copy.
subprocess.run(["unshare", "--user", "--map-root-user", "--net", "true"], check=True)
output.mkdir(parents=True)
revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip()
archive, checkout = output / "source.tar", output / "checkout"
with archive.open("wb") as stream:
subprocess.run(["git", "archive", "--format=tar", revision], cwd=root, stdout=stream, check=True)
with tarfile.open(archive) as content:
content.extractall(checkout, filter="data")
archive.unlink()
shutil.copytree(root / ".cache/downloads", checkout / ".cache/downloads")
shutil.copytree(root / ".cache/slang", checkout / ".cache/slang", symlinks=True)
script = output / "offline_driver.py"
script.write_text('''import json, pathlib, socket, subprocess, sys
root = pathlib.Path(sys.argv[1])
# No external interfaces exist in this newly created network namespace.
with socket.socket() as connection:
connection.settimeout(2)
try:
connection.connect(("1.1.1.1", 443))
except OSError as error:
print("External network unavailable:", error, flush=True)
else:
raise RuntimeError("Offline check unexpectedly has external connectivity")
subprocess.run([sys.executable, "tools/fetch_dependencies.py", "--verify-only"], cwd=root, check=True)
subprocess.run(["cmake", "--preset", "linux-debug"], cwd=root, check=True)
subprocess.run(["cmake", "--build", "--preset", "linux-debug", "--parallel", sys.argv[2]], cwd=root, check=True)
subprocess.run(["ctest", "--preset", "linux-debug", "-LE", "gpu", "--timeout", "120"], cwd=root, check=True)
''', encoding="utf-8")
started = time.perf_counter()
with (output / "build.log").open("w", encoding="utf-8") as log:
result = subprocess.run(["unshare", "--user", "--map-root-user", "--net",
sys.executable, script, checkout, str(args.parallel)], stdout=log, stderr=subprocess.STDOUT)
report = {"format": "faset.offline-build", "version": 1, "revision": revision,
"recorded_at_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"seconds": time.perf_counter() - started, "returncode": result.returncode,
"network": "fresh user/network namespace; external connection must fail",
"source": "git archive of committed HEAD; no existing build directory",
"inputs": "prefetched archives + Slang; system compiler, SDK and development libraries",
"tests": "full native build, CPU CTests; GPU/window execution verified separately"}
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, indent=2))
return result.returncode
if __name__ == "__main__":
raise SystemExit(main())
+221
View File
@@ -0,0 +1,221 @@
#!/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:
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 / "projects"
evidence.mkdir()
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 / 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())