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' python-version: '3.12'
- run: python -m pip install -r docs/requirements.txt - run: python -m pip install -r docs/requirements.txt
- run: python -m mkdocs build --strict - 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 run: ctest --preset windows-debug --timeout 180
- name: Real Release exports, 2D and 3D execution, incremental Debug rebuild - 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 run: build/windows-debug/faset_build_service_tests.exe --integration .cache/windows-export-e2e
- name: Export and relocate both checked-in playable games - 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 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 - name: Preserve graphics and export evidence
if: always() if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 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 json
import os import os
from pathlib import Path from pathlib import Path
import re
import shutil import shutil
import signal import signal
import subprocess import subprocess
@@ -41,6 +42,17 @@ def sha256(path: Path) -> str:
return hashlib.file_digest(stream, "sha256").hexdigest() 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: def run(arguments: list[str | Path], cwd: Path, log: Path, timeout: int = 1800) -> dict:
command = [str(argument) for argument in arguments] command = [str(argument) for argument in arguments]
print(f"{log.stem}: {subprocess.list2cmdline(command)}", flush=True) print(f"{log.stem}: {subprocess.list2cmdline(command)}", flush=True)
@@ -96,7 +108,7 @@ def verify_package(directory: Path) -> dict:
return manifest 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) header, dimensions, maximum, pixels = path.read_bytes().split(b"\n", 3)
require(header == b"P6" and maximum == b"255", "Expected RGB PPM screenshot") require(header == b"P6" and maximum == b"255", "Expected RGB PPM screenshot")
width, height = (int(value) for value in dimensions.split()) width, height = (int(value) for value in dimensions.split())
@@ -104,10 +116,50 @@ def verify_capture(path: Path) -> dict:
"Incomplete screenshot") "Incomplete screenshot")
step = 3 * max(1, width * height // 10000) step = 3 * max(1, width * height // 10000)
colors = len({pixels[index:index + 3] for index in range(0, len(pixels), step)}) 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)} 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: def main() -> int:
# Keep captured CI logs portable even when the Windows console uses a legacy code page. # Keep captured CI logs portable even when the Windows console uses a legacy code page.
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
@@ -117,6 +169,10 @@ def main() -> int:
parser.add_argument("--output", type=Path, required=True) parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--standalone-root", type=Path, parser.add_argument("--standalone-root", type=Path,
help="New/empty directory outside the engine and output trees") 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() args = parser.parse_args()
engine, editor, output = args.engine.resolve(), args.editor.resolve(), args.output.resolve() engine, editor, output = args.engine.resolve(), args.editor.resolve(), args.output.resolve()
require(editor.is_file(), f"Editor does not exist: {editor}") 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()), require(not standalone.exists() or not any(standalone.iterdir()),
"Standalone root must be new/empty") "Standalone root must be new/empty")
standalone.mkdir(parents=True, exist_ok=True) 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() evidence.mkdir()
projects.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 = standalone / "Faset Café 世界"
standalone_projects.mkdir() standalone_projects.mkdir()
matrix = project_matrix(args.include_lua, args.only_lua)
report = {"format": "faset.playable-export-verification", "version": 1, report = {"format": "faset.playable-export-verification", "version": 1,
"started_utc": datetime.now(timezone.utc).isoformat(), "platform": sys.platform, "started_utc": datetime.now(timezone.utc).isoformat(), "platform": sys.platform,
"engine": str(engine), "editor": str(editor), "standalone_root": str(standalone), "engine": str(engine), "editor": str(editor), "standalone_root": str(standalone),
"frames_per_game": 120, "status": "running", "projects": []} "engine_revision": revision, "editor_sha256": sha256(editor),
disabled = output / "projects-offline" "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: try:
for dimension in (2, 3): for name, dimension, language in matrix:
name = f"collect-{dimension}d" source = engine / "examples" / ("lua" if language == "lua" else f"projects/{name}")
source, project = engine / "examples/projects" / name, projects / name project = projects / name
shutil.copytree(source, project, ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1")) shutil.copytree(source, project, ignore=shutil.ignore_patterns(".faset", "Exports", "*.blend1"))
inputs = [{"path": path.relative_to(project).as_posix(), "sha256": sha256(path)} inputs = [{"path": path.relative_to(project).as_posix(), "sha256": sha256(path)}
for path in sorted(project.rglob("*")) if path.is_file()] for path in sorted(project.rglob("*")) if path.is_file()]
settings = read_json(project / "project.faset.json") settings = read_json(project / "project.faset.json")
scene = read_json(relative_path(project, settings["start_scene"])) 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) report["projects"].append(item)
if dimension == 3: if dimension == 3:
imported = command(editor, engine, project, "faset_import", imported = command(editor, engine, project, "faset_import",
@@ -164,18 +241,22 @@ def main() -> int:
require(generation == Path(exported["result"]["directory"]).resolve(), require(generation == Path(exported["result"]["directory"]).resolve(),
"Export response and published pointer disagree") "Export response and published pointer disagree")
manifest = verify_package(generation) manifest = verify_package(generation)
if language == "lua":
verify_lua_export(generation, manifest)
require(dimension != 3 or bool(manifest["asset_generations"]), require(dimension != 3 or bool(manifest["asset_generations"]),
"3D game did not package its imported Blender asset") "3D game did not package its imported Blender asset")
relocated = standalone_projects / name relocated = standalone_projects / name
shutil.copytree(generation, relocated) shutil.copytree(generation, relocated)
verify_package(relocated) verify_package(relocated)
if language == "lua":
verify_lua_export(relocated, manifest)
shutil.copy2(relocated / "manifest.json", evidence / f"{name}-manifest.json") shutil.copy2(relocated / "manifest.json", evidence / f"{name}-manifest.json")
item.update({"generation": pointer["generation"], "configuration": "Release", item.update({"generation": pointer["generation"], "configuration": "Release",
"standalone_directory": str(relocated), "executable": manifest["executable"], "standalone_directory": str(relocated), "executable": manifest["executable"],
"package_file_count": len(manifest["files"]), "package_file_count": len(manifest["files"]),
"asset_generations": manifest["asset_generations"]}) "asset_generations": manifest["asset_generations"]})
write_json(output / "report.json", report) 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. # An empty, unrelated cwd also catches assumptions about the current directory.
projects.rename(disabled) projects.rename(disabled)
working = standalone / "empty-working-directory" working = standalone / "empty-working-directory"
@@ -202,8 +283,10 @@ def main() -> int:
require(all(frame["gpu_allocated_bytes"] > 0 for frame in measured["samples"]), require(all(frame["gpu_allocated_bytes"] > 0 for frame in measured["samples"]),
"Frame profile lacks Vulkan allocation measurements") "Frame profile lacks Vulkan allocation measurements")
item.update({"device": measured["device"], "validation_enabled": measured["validation_enabled"], item.update({"device": measured["device"], "validation_enabled": measured["validation_enabled"],
"driver": vulkan_device_details(vulkan_summary, measured["device"]),
"validation_errors": 0, "completed_frames": 120, "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"}) "source_project_paths_unavailable": True, "status": "passed"})
shutil.copy2(capture, evidence / f"{name}.ppm") shutil.copy2(capture, evidence / f"{name}.ppm")
shutil.copy2(profile, evidence / f"{name}-profile.json") shutil.copy2(profile, evidence / f"{name}-profile.json")
@@ -217,7 +300,8 @@ def main() -> int:
disabled.rename(projects) disabled.rename(projects)
report["finished_utc"] = datetime.now(timezone.utc).isoformat() report["finished_utc"] = datetime.now(timezone.utc).isoformat()
write_json(output / "report.json", report) 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 return 0