Verify relocated Lua Release games with checked rendering
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user