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