Verify relocated Lua Release games with checked rendering

This commit is contained in:
Emil
2026-09-24 02:26:10 +03:00
parent 45c5d2d188
commit 6446f8917e
4 changed files with 175 additions and 14 deletions
+1
View File
@@ -96,3 +96,4 @@ jobs:
python-version: '3.12'
- run: python -m pip install -r docs/requirements.txt
- run: python -m mkdocs build --strict
- run: python tests/verify_playable_exports_test.py
+2 -2
View File
@@ -72,8 +72,8 @@ jobs:
run: ctest --preset windows-debug --timeout 180
- name: Real Release exports, 2D and 3D execution, incremental Debug rebuild
run: build/windows-debug/faset_build_service_tests.exe --integration .cache/windows-export-e2e
- name: Export and relocate both checked-in playable games
run: python tools/verify_playable_exports.py --editor build/windows-debug/faset_editor.exe --output .cache/windows-playable-exports
- name: Export and relocate C++ and Lua playable games
run: python tools/verify_playable_exports.py --editor build/windows-debug/faset_editor.exe --output .cache/windows-playable-exports --include-lua
- name: Preserve graphics and export evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+76
View File
@@ -0,0 +1,76 @@
"""Small contract checks for the cross-platform export verifier's Lua mode."""
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
from verify_playable_exports import (project_matrix, project_workspace, verify_capture,
verify_lua_export, vulkan_device_details)
class ExportProjectMatrixTests(unittest.TestCase):
def test_native_project_workspace_is_outside_engine(self):
with tempfile.TemporaryDirectory() as directory:
engine = Path(directory) / "engine"
engine.mkdir()
internal_output = engine / ".cache" / "exports"
workspace = project_workspace(engine, internal_output)
try:
self.assertFalse(workspace.is_relative_to(engine))
finally:
workspace.rmdir()
external_output = Path(directory) / "evidence"
self.assertEqual(project_workspace(engine, external_output), external_output)
def test_lua_is_explicit_and_can_run_alone(self):
self.assertEqual([name for name, _, _ in project_matrix(False, False)],
["collect-2d", "collect-3d"])
self.assertEqual([name for name, _, _ in project_matrix(True, False)],
["collect-2d", "collect-3d", "lua"])
self.assertEqual(project_matrix(False, True), [("lua", 2, "lua")])
def test_lua_package_requires_source_and_license_without_cpp_stub(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "Scripts").mkdir()
(root / "Notices/lua").mkdir(parents=True)
(root / "Scripts/player.lua").write_text("return {}\n", encoding="utf-8")
(root / "Notices/lua/LICENSE.txt").write_text("License\n", encoding="utf-8")
manifest = {"lua_enabled": True, "files": [
{"path": "Scripts/player.lua"}, {"path": "Notices/lua/LICENSE.txt"}]}
verify_lua_export(root, manifest)
with self.assertRaisesRegex(RuntimeError, "license"):
verify_lua_export(root, {**manifest, "files": manifest["files"][:1]})
(root / "Scripts/Gameplay.cpp").write_text("// stray\n", encoding="utf-8")
with self.assertRaisesRegex(RuntimeError, "C\\+\\+"):
verify_lua_export(root, manifest)
(root / "Scripts/Gameplay.cpp").unlink()
(root / "Notices/lua/LICENSE.txt").unlink()
with self.assertRaisesRegex(RuntimeError, "license"):
verify_lua_export(root, manifest)
def test_lua_capture_requires_its_small_scene_palette(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "capture.ppm"
header = b"P6\n4 1\n255\n"
path.write_bytes(header + bytes.fromhex("0e1116334c6633bfe5ffb233"))
self.assertEqual(verify_capture(path, language="lua")["sampled_colors"], 4)
with self.assertRaisesRegex(RuntimeError, "geometry/colors"):
verify_capture(path)
path.write_bytes(header + bytes.fromhex("0e1116334c6633bfe5334c66"))
with self.assertRaisesRegex(RuntimeError, "geometry/colors"):
verify_capture(path, language="lua")
def test_driver_probe_selects_the_rendering_device(self):
summary = ("GPU0:\n deviceName = llvmpipe\n driverName = Mesa\n"
"GPU1:\n deviceName = Discrete GPU\n driverVersion = 123\n"
" driverName = Vendor\n driverInfo = 1.2.3\n")
self.assertEqual(vulkan_device_details(summary, "Discrete GPU"),
{"deviceName": "Discrete GPU", "driverVersion": "123",
"driverName": "Vendor", "driverInfo": "1.2.3"})
self.assertEqual(vulkan_device_details(summary, "Unknown"), {})
if __name__ == "__main__":
unittest.main()
+96 -12
View File
@@ -15,6 +15,7 @@ import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
@@ -41,6 +42,17 @@ def sha256(path: Path) -> str:
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 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)
@@ -96,7 +108,7 @@ def verify_package(directory: Path) -> dict:
return manifest
def verify_capture(path: Path) -> dict:
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())
@@ -104,10 +116,50 @@ def verify_capture(path: Path) -> dict:
"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")
# 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 (directory / "Scripts/Gameplay.cpp").exists() and
not (directory / "Scripts/Gameplay.hpp").exists(),
"Lua-only export unexpectedly contains C++ gameplay source")
require(not (directory / ".luarc.json").exists(),
"Lua-only export includes Editor language-server configuration")
def main() -> int:
# Keep captured CI logs portable even when the Windows console uses a legacy code page.
sys.stdout.reconfigure(encoding="utf-8")
@@ -117,6 +169,10 @@ def main() -> int:
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}")
@@ -129,26 +185,47 @@ def main() -> int:
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é 世界"
workspace = project_workspace(engine, output)
evidence, projects = output / "evidence", workspace / "Faset Café 世界"
evidence.mkdir()
projects.mkdir()
revision = subprocess.run(["git", "-C", str(engine), "rev-parse", "HEAD"],
capture_output=True, text=True, check=True).stdout.strip()
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),
"frames_per_game": 120, "status": "running", "projects": []}
disabled = output / "projects-offline"
"engine_revision": revision, "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 dimension in (2, 3):
name = f"collect-{dimension}d"
source, project = engine / "examples/projects" / name, projects / name
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, "source_inputs": inputs}
item = {"name": name, "dimension": dimension, "language": language,
"source_inputs": inputs}
report["projects"].append(item)
if dimension == 3:
imported = command(editor, engine, project, "faset_import",
@@ -164,18 +241,22 @@ def main() -> int:
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 both games.
# 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"
@@ -202,8 +283,10 @@ def main() -> int:
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"],
"driver": vulkan_device_details(vulkan_summary, measured["device"]),
"validation_errors": 0, "completed_frames": 120,
"summary_ms": measured["summary_ms"], "capture": verify_capture(capture),
"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")
@@ -217,7 +300,8 @@ def main() -> int:
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)
print(f"{len(matrix)} relocated Release game(s) passed: {output / 'report.json'}",
flush=True)
return 0