Checkpoint 3: deliver playable samples and complete native authoring workflows
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user