Checkpoint 4: harden native paths, authoring workflows and Player lifecycle
This commit is contained in:
@@ -89,12 +89,17 @@ def main() -> int:
|
||||
args = parser.parse_args()
|
||||
if args.cache_key:
|
||||
identity = cache_identity()
|
||||
key = "windows-2025-vulkan-v2-" + identity
|
||||
# Cache restoration can prefix-match the primary key. Keep SDK-only and
|
||||
# full-driver keys in disjoint namespaces, never suffix variants.
|
||||
key = "windows-2025-vulkan-full-v3-" + identity
|
||||
sdk_key = "windows-2025-vulkan-sdk-v3-" + identity
|
||||
legacy = LEGACY_CACHE_KEY if identity == LEGACY_SOURCE_IDENTITY else ""
|
||||
sdk_legacy = ("windows-2025-vulkan-v2-" + identity + "-loader-only"
|
||||
if identity == LEGACY_SOURCE_IDENTITY else "")
|
||||
print(key)
|
||||
if "GITHUB_OUTPUT" in os.environ:
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
||||
output.write(f"key={key}\nlegacy={legacy}\n")
|
||||
output.write(f"key={key}\nsdk_key={sdk_key}\nlegacy={legacy}\nsdk_legacy={sdk_legacy}\n")
|
||||
return 0
|
||||
if sys.platform != "win32":
|
||||
parser.error("Run this helper on Windows in a Visual Studio x64 developer environment")
|
||||
|
||||
@@ -10,7 +10,7 @@ import tempfile
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def verify(blender, editor, output):
|
||||
def verify(blender, editor, output, ui_probe=None):
|
||||
fixture = output / "fixture"
|
||||
result = subprocess.run([str(blender), "--background", "--factory-startup", "--python-exit-code", "1",
|
||||
"--python", str(ROOT / "tests/blender/generate_fixture.py"), "--", str(ROOT), str(fixture)],
|
||||
@@ -64,6 +64,12 @@ def verify(blender, editor, output):
|
||||
"renamed_generation": renamed["result"]["generation"],
|
||||
"stable_output_ids": True, "removed_output_conflict": True,
|
||||
"failed_import_keeps_generation": True, "authoring_preserved": True}
|
||||
if ui_probe:
|
||||
probe = subprocess.run([str(ui_probe), str(fixture), str(output / "live-editor")],
|
||||
capture_output=True, text=True, encoding="utf-8")
|
||||
(output / "live-editor.log").write_text(probe.stdout + probe.stderr, encoding="utf-8")
|
||||
probe.check_returncode()
|
||||
report["live_editor"] = json.loads((output / "live-editor/probe.json").read_text())
|
||||
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(json.dumps(report, indent=2))
|
||||
|
||||
@@ -73,10 +79,14 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--blender", type=pathlib.Path, required=True)
|
||||
parser.add_argument("--editor", type=pathlib.Path, required=True)
|
||||
parser.add_argument("--output", type=pathlib.Path)
|
||||
parser.add_argument("--ui-probe", type=pathlib.Path,
|
||||
help="faset_blender_editor_probe: verify two live viewport instances (Vulkan required)")
|
||||
args = parser.parse_args()
|
||||
if args.output:
|
||||
args.output.resolve().mkdir(parents=True, exist_ok=True)
|
||||
verify(args.blender.resolve(), args.editor.resolve(), args.output.resolve())
|
||||
verify(args.blender.resolve(), args.editor.resolve(), args.output.resolve(),
|
||||
args.ui_probe.resolve() if args.ui_probe else None)
|
||||
else:
|
||||
with tempfile.TemporaryDirectory(prefix="faset-blender-") as temporary:
|
||||
verify(args.blender.resolve(), args.editor.resolve(), pathlib.Path(temporary))
|
||||
verify(args.blender.resolve(), args.editor.resolve(), pathlib.Path(temporary),
|
||||
args.ui_probe.resolve() if args.ui_probe else None)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise crash recovery, failed Player startup and failed C++ build over real MCP.
|
||||
|
||||
Uses a disposable copy of the 2D sample. Requires native build tools and a Vulkan
|
||||
desktop for the successful Play/Stop check. No runtime entity API is used.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, editor, engine, project):
|
||||
self.process = subprocess.Popen([str(editor), "--engine", str(engine), "--project",
|
||||
str(project), "--mcp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, encoding="utf-8")
|
||||
self.output, self.errors, self.sequence = queue.Queue(), [], 0
|
||||
def output():
|
||||
try:
|
||||
for line in self.process.stdout:
|
||||
self.output.put(json.loads(line))
|
||||
except Exception as error:
|
||||
self.output.put(error)
|
||||
finally:
|
||||
self.output.put(None)
|
||||
def errors():
|
||||
for line in self.process.stderr:
|
||||
self.errors.append(line)
|
||||
self.readers = [threading.Thread(target=output, daemon=True), threading.Thread(target=errors, daemon=True)]
|
||||
for reader in self.readers:
|
||||
reader.start()
|
||||
self.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "recovery-acceptance", "version": "1"}})
|
||||
self.process.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n')
|
||||
self.process.stdin.flush()
|
||||
|
||||
def request(self, method, params):
|
||||
self.sequence += 1
|
||||
self.process.stdin.write(json.dumps({"jsonrpc": "2.0", "id": self.sequence,
|
||||
"method": method, "params": params}) + "\n")
|
||||
self.process.stdin.flush()
|
||||
response = self.output.get(timeout=30)
|
||||
assert isinstance(response, dict) and response.get("id") == self.sequence, response
|
||||
assert "error" not in response, response
|
||||
return response["result"]
|
||||
|
||||
def call(self, name, arguments=None, error=False):
|
||||
result = self.request("tools/call", {"name": name, "arguments": arguments or {}})
|
||||
assert result.get("isError", False) == error, result
|
||||
return result["structuredContent"]
|
||||
|
||||
def job(self, identity, expected="succeeded"):
|
||||
deadline = time.monotonic() + 1800
|
||||
while time.monotonic() < deadline:
|
||||
value = self.call("faset_job", {"id": identity})
|
||||
if value["state"] in ("succeeded", "failed", "cancelled", "conflict"):
|
||||
assert value["state"] == expected, value
|
||||
return value
|
||||
time.sleep(.1)
|
||||
raise RuntimeError("Editor job timed out")
|
||||
|
||||
def close(self, crash=False):
|
||||
if self.process.poll() is None:
|
||||
if crash:
|
||||
self.process.kill()
|
||||
else:
|
||||
self.process.stdin.close()
|
||||
try:
|
||||
self.process.wait(timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
raise
|
||||
for reader in self.readers:
|
||||
reader.join(timeout=5)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--editor", type=Path, required=True)
|
||||
parser.add_argument("--engine", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
engine, editor, output = args.engine.resolve(), args.editor.resolve(), args.output.resolve()
|
||||
if output.exists():
|
||||
parser.error("Use a new output directory")
|
||||
output.mkdir(parents=True)
|
||||
project = output / "project"
|
||||
shutil.copytree(engine / "examples/projects/collect-2d", project,
|
||||
ignore=shutil.ignore_patterns(".faset", "Exports"))
|
||||
checks = []
|
||||
def record(name):
|
||||
checks.append(name)
|
||||
(output / "progress.json").write_text(json.dumps(checks, indent=2) + "\n")
|
||||
print(name, flush=True)
|
||||
client = Client(editor, engine, project)
|
||||
try:
|
||||
doc = client.call("faset_documents")["documents"][0]
|
||||
identity = doc["id"]
|
||||
client.call("faset_document_save", {"document": identity})
|
||||
before = client.call("faset_document_query", {"document": identity})
|
||||
changed = client.call("faset_scene_edit", {"document": identity, "revision": before["revision"],
|
||||
"operations": [{"op": "scene.rename", "name": "Unsaved recovery acceptance"}]})
|
||||
client.close(crash=True) # Kill only our idle owned Editor, after transaction acknowledgement.
|
||||
client = Client(editor, engine, project)
|
||||
reopened = client.call("faset_document_query", {"document": identity})
|
||||
assert reopened["scene"]["name"] == before["scene"]["name"]
|
||||
restored = client.call("faset_recovery_restore", {"document": identity, "revision": reopened["revision"]})
|
||||
assert restored["scene"] == changed["scene"]
|
||||
record("acknowledged unsaved edit survives killed Editor and explicit recovery")
|
||||
(project / "Scenes/blocked.scene.json").mkdir()
|
||||
client.call("faset_document_save", {"document": identity, "path": "Scenes/blocked.scene.json"}, error=True)
|
||||
assert client.call("faset_document_query", {"document": identity})["scene"] == restored["scene"]
|
||||
assert json.loads((project / "Scenes/main.scene.json").read_text())["name"] == before["scene"]["name"]
|
||||
record("failed save preserves live authoring and last saved file")
|
||||
bad = client.call("faset_scene_edit", {"document": identity, "revision": restored["revision"],
|
||||
"operations": [{"op": "entity.create", "entity": {
|
||||
"id": "future-version-object", "name": "Future component recovery fixture", "parent": None,
|
||||
"components": [{"id": "future-transform", "type": "faset.transform", "version": 999,
|
||||
"fields": {"kept": True}}]}}]})
|
||||
play = client.call("faset_play", {"document": identity})
|
||||
client.job(play["job"])
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline:
|
||||
logs = client.call("faset_editor_logs")["logs"]
|
||||
if any("Player exited with code 1" in line for line in logs):
|
||||
break
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise RuntimeError("Expected failed Player startup was not observed: " + str(logs))
|
||||
assert client.call("faset_document_query", {"document": identity})["scene"] == bad["scene"]
|
||||
record("failed Player process is reported without changing authoring data")
|
||||
restored = client.call("faset_undo", {"document": identity, "revision": bad["revision"]})
|
||||
assert restored["scene"] == changed["scene"]
|
||||
old_logs = len(client.call("faset_editor_logs")["logs"])
|
||||
client.job(client.call("faset_play", {"document": identity})["job"])
|
||||
time.sleep(2)
|
||||
logs = client.call("faset_editor_logs")["logs"][old_logs:]
|
||||
assert any("Play started" in line for line in logs), logs
|
||||
assert not any("Player exited with code" in line for line in logs), logs
|
||||
client.call("faset_stop")
|
||||
assert client.call("faset_document_query", {"document": identity})["scene"] == restored["scene"]
|
||||
record("Undo repairs the scene, then Play and Stop leave authoring unchanged")
|
||||
metadata = client.call("faset_schema")
|
||||
gameplay = project / "Scripts/Gameplay.cpp"
|
||||
original = gameplay.read_bytes()
|
||||
gameplay.write_bytes(original + b"\n#error intentional_recovery_acceptance_compile_failure\n")
|
||||
try:
|
||||
client.job(client.call("faset_build")["job"], "failed")
|
||||
assert client.call("faset_schema") == metadata
|
||||
assert client.call("faset_schema_status")["stale"]
|
||||
record("failed C++ build keeps prior schema and reports stale status")
|
||||
finally:
|
||||
gameplay.write_bytes(original)
|
||||
client.job(client.call("faset_build")["job"])
|
||||
assert not client.call("faset_schema_status")["stale"]
|
||||
record("fixed C++ rebuild restores current schema status")
|
||||
result = client.call("faset_import", {"path": "Assets/missing.glb"})
|
||||
client.job(result["job"], "failed")
|
||||
assert client.call("faset_document_query", {"document": identity})["scene"] == restored["scene"]
|
||||
record("failed import preserves authoring")
|
||||
(output / "report.json").write_text(json.dumps({"format": "faset.editor-recovery-verification",
|
||||
"version": 1, "status": "passed", "checks": checks,
|
||||
"recorded_at_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"platform": platform.platform(),
|
||||
"editor_sha256": hashlib.sha256(editor.read_bytes()).hexdigest(),
|
||||
"logs": client.call("faset_editor_logs")["logs"]}, indent=2) + "\n")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -109,6 +109,8 @@ def verify_capture(path: Path) -> dict:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Keep captured CI logs portable even when the Windows console uses a legacy code page.
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--engine", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--editor", type=Path, required=True)
|
||||
@@ -127,9 +129,11 @@ 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 / "projects"
|
||||
evidence, projects = output / "evidence", output / "Faset Café 世界"
|
||||
evidence.mkdir()
|
||||
projects.mkdir()
|
||||
standalone_projects = standalone / "Faset Café 世界"
|
||||
standalone_projects.mkdir()
|
||||
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),
|
||||
@@ -162,7 +166,7 @@ def main() -> int:
|
||||
manifest = verify_package(generation)
|
||||
require(dimension != 3 or bool(manifest["asset_generations"]),
|
||||
"3D game did not package its imported Blender asset")
|
||||
relocated = standalone / name
|
||||
relocated = standalone_projects / name
|
||||
shutil.copytree(generation, relocated)
|
||||
verify_package(relocated)
|
||||
shutil.copy2(relocated / "manifest.json", evidence / f"{name}-manifest.json")
|
||||
|
||||
Reference in New Issue
Block a user