Checkpoint 4: harden native paths, authoring workflows and Player lifecycle

This commit is contained in:
Emil
2026-09-18 05:14:50 +03:00
parent 5ac4db438d
commit 45bc352d46
88 changed files with 12606 additions and 504 deletions
+7 -2
View File
@@ -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")
+13 -3
View File
@@ -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)
+181
View File
@@ -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()
+6 -2
View File
@@ -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")