From 6446f8917eec9df2f134684d642e0c2da1790a51 Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:20:32 +0300 Subject: [PATCH] Verify relocated Lua Release games with checked rendering --- .github/workflows/ci.yml | 1 + .github/workflows/windows-graphics.yml | 4 +- tests/verify_playable_exports_test.py | 76 +++++++++++++++++ tools/verify_playable_exports.py | 108 ++++++++++++++++++++++--- 4 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 tests/verify_playable_exports_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adfbe59..e9002a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/windows-graphics.yml b/.github/workflows/windows-graphics.yml index e481222..05c1381 100644 --- a/.github/workflows/windows-graphics.yml +++ b/.github/workflows/windows-graphics.yml @@ -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 diff --git a/tests/verify_playable_exports_test.py b/tests/verify_playable_exports_test.py new file mode 100644 index 0000000..6fa98a9 --- /dev/null +++ b/tests/verify_playable_exports_test.py @@ -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() diff --git a/tools/verify_playable_exports.py b/tools/verify_playable_exports.py index 4ce1df2..20a884e 100644 --- a/tools/verify_playable_exports.py +++ b/tools/verify_playable_exports.py @@ -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