Checkpoint 1: implement native subsystems and begin the gameplay manual

This commit is contained in:
Emil
2026-09-18 03:01:30 +03:00
parent decf49084d
commit 903c97444b
73 changed files with 3932 additions and 6 deletions
+11
View File
@@ -0,0 +1,11 @@
# Faset GLB helper
Optional add-on for the **unmodified official Blender**. Ordinary `.gltf`/`.glb` imports do not need it.
Zip this directory as `blender_addon/` and install the ZIP through Blender's add-on preferences. Enable **Faset GLB Export**, then use **File → Export → Faset GLB Bundle**. The chosen directory receives immutable `payload/<sha256>.glb` files and `manifest.json`, replaced only after a complete export. Save the `.blend` after the first export to persist assigned custom IDs. The first profile exports static geometry/PBR, without animation playback.
Objects, mesh datablocks and materials receive `faset_id` custom properties. Renaming an object preserves its ID. Ambiguous duplicate IDs stop publication. After deliberately duplicating an object, select the new copy and run **Faset: New IDs for Selected**. This changes object identity and duplicated mesh datablock identity; shared meshes stay shared. Material duplicates can be repaired explicitly in Custom Properties. Linked-library/generated data without persistent identity is outside this first profile.
The engine imports `manifest.json` or ordinary GLB/glTF. Gameplay components, physics settings and instance overrides are engine-owned data. The exporter does not write them. Without IDs, the engine does not promise reliable matching after renaming internal parts. Arbitrary procedural Blender materials require baking or an explicit engine material; this profile does not claim pixel-identical shading.
`bundle.py` is independent of Blender and has executable fixture tests. Blender UI/export execution still needs validation in an installed Blender version; this repository's tests do not substitute for that check.
+133
View File
@@ -0,0 +1,133 @@
"""Faset asset export helper for the unmodified official Blender application."""
bl_info = {
"name": "Faset GLB Export", "author": "Faset Engine", "version": (0, 1, 0),
"blender": (4, 2, 0), "location": "File > Export > Faset GLB Bundle",
"description": "Publish GLB with persistent custom IDs and an atomic manifest", "category": "Import-Export",
}
from pathlib import Path
import tempfile
import uuid
import bpy
from bpy.props import StringProperty
from bpy_extras.io_utils import ExportHelper
from .bundle import publish_bundle
def ensure_persistent_ids(context):
"""Assign only missing IDs. Ambiguous duplicates require an explicit user operation."""
objects = list(context.scene.objects)
meshes = list({obj.data for obj in objects if obj.type == "MESH"})
materials = list({slot.material for obj in objects for slot in obj.material_slots if slot.material})
groups = [objects, meshes, materials]
for group in groups:
seen = {}
for block in group:
if block.library:
raise ValueError(f"Linked data needs local IDs before export: {block.name}")
identity = block.get("faset_id")
if not identity:
identity = str(uuid.uuid4())
block["faset_id"] = identity
try:
uuid.UUID(identity)
except (ValueError, TypeError, AttributeError) as error:
raise ValueError(f"Invalid faset_id on {block.name}") from error
if identity in seen:
raise ValueError(f"DuplicateSourceId: {seen[identity]} / {block.name}. "
"Select the new copy and run Faset: New IDs for Selected.")
seen[identity] = block.name
if not context.scene.get("faset_asset_id"):
context.scene["faset_asset_id"] = str(uuid.uuid4())
return context.scene["faset_asset_id"]
class FASET_OT_new_selected_ids(bpy.types.Operator):
bl_idname = "faset.new_selected_ids"
bl_label = "Faset: New IDs for Selected"
bl_description = "Explicitly fork object identities; shared mesh/material identities remain shared"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return bool(context.selected_objects)
def execute(self, context):
selected = set(context.selected_objects)
for obj in selected:
if obj.library:
self.report({"ERROR"}, "Linked objects must be made local first")
return {"CANCELLED"}
obj["faset_id"] = str(uuid.uuid4())
# A copied mesh owns its new identity; a genuinely shared mesh stays shared.
for obj in selected:
mesh = obj.data if obj.type == "MESH" else None
if mesh and not mesh.library:
duplicate = any(other is not mesh and other.get("faset_id") == mesh.get("faset_id")
for other in bpy.data.meshes)
if duplicate:
mesh["faset_id"] = str(uuid.uuid4())
return {"FINISHED"}
class FASET_OT_export(bpy.types.Operator, ExportHelper):
bl_idname = "export_scene.faset_bundle"
bl_label = "Faset GLB Bundle"
filename_ext = ".json"
filter_glob: StringProperty(default="*.json", options={"HIDDEN"})
def execute(self, context):
frame, subframe = context.scene.frame_current, context.scene.frame_subframe
active = context.view_layer.objects.active
selected = list(context.selected_objects)
mode = active.mode if active else "OBJECT"
try:
asset_id = ensure_persistent_ids(context)
directory = Path(self.filepath).resolve().parent
with tempfile.TemporaryDirectory(prefix="faset-export-") as temporary:
payload = Path(temporary) / "scene.glb"
result = bpy.ops.export_scene.gltf(filepath=str(payload), export_format="GLB",
export_extras=True, export_yup=True, export_animations=False,
export_materials="EXPORT", use_selection=False, use_active_scene=True)
if "FINISHED" not in result:
raise RuntimeError("Blender glTF export did not finish")
manifest = publish_bundle(payload, directory, asset_id,
bpy.app.version_string, bpy.data.filepath)
self.report({"INFO"}, f"Published {manifest['generation'][:12]}; save .blend to persist IDs")
return {"FINISHED"}
except Exception as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
finally:
context.scene.frame_set(frame, subframe=subframe)
for obj in context.selected_objects:
obj.select_set(False)
for obj in selected:
if obj.name in context.view_layer.objects:
obj.select_set(True)
if active and active.name in context.view_layer.objects:
context.view_layer.objects.active = active
if active.mode != mode:
try:
bpy.ops.object.mode_set(mode=mode)
except RuntimeError:
pass
def menu_export(self, context):
self.layout.operator(FASET_OT_export.bl_idname, text="Faset GLB Bundle (.json)")
def register():
bpy.utils.register_class(FASET_OT_new_selected_ids)
bpy.utils.register_class(FASET_OT_export)
bpy.types.TOPBAR_MT_file_export.append(menu_export)
def unregister():
bpy.types.TOPBAR_MT_file_export.remove(menu_export)
bpy.utils.unregister_class(FASET_OT_export)
bpy.utils.unregister_class(FASET_OT_new_selected_ids)
if __name__ == "__main__":
register()
+98
View File
@@ -0,0 +1,98 @@
"""Pure-Python GLB bundle publication; no Blender import, usable in unit tests."""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import struct
import tempfile
import uuid
def read_glb(path: Path) -> dict:
raw = path.read_bytes()
if len(raw) < 20:
raise ValueError("Truncated GLB")
magic, version, size = struct.unpack_from("<III", raw)
if magic != 0x46546C67 or version != 2 or size != len(raw):
raise ValueError("Invalid GLB 2 header")
cursor, document = 12, None
while cursor < len(raw):
if cursor + 8 > len(raw):
raise ValueError("Truncated GLB chunk header")
length, kind = struct.unpack_from("<II", raw, cursor)
cursor += 8
if length % 4 or cursor + length > len(raw):
raise ValueError("Invalid GLB chunk")
if kind == 0x4E4F534A:
if document is not None:
raise ValueError("Duplicate GLB JSON chunk")
document = json.loads(raw[cursor:cursor + length])
cursor += length
if document is None or document.get("asset", {}).get("version") != "2.0":
raise ValueError("Missing glTF 2 document")
return document
def _write_atomic(path: Path, raw: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix=".faset-", dir=path.parent)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def publish_bundle(glb: Path, directory: Path, asset_id: str,
blender_version: str, source_hint: str = "") -> dict:
"""Immutable payload first, atomic manifest last; failures preserve previous manifest."""
uuid.UUID(asset_id)
document = read_glb(glb)
for collection in ("buffers", "images"):
for entry in document.get(collection, []):
uri = entry.get("uri", "")
if uri and not uri.startswith("data:"):
raise ValueError("Bundle exporter requires embedded GLB dependencies")
outputs, seen = [], set()
for kind, collection in (("node", "nodes"), ("mesh", "meshes"), ("material", "materials")):
for index, item in enumerate(document.get(collection, [])):
identity = item.get("extras", {}).get("faset_id")
# Some generated exporter subresources have no persistent datablock.
if identity is None:
if kind == "node" and "mesh" in item:
raise ValueError("Exported mesh node has no faset_id; enable custom properties")
continue
uuid.UUID(identity)
key = (kind, identity)
if key in seen:
raise ValueError(f"DuplicateSourceId: {kind} {identity}")
seen.add(key)
outputs.append({"source_id": identity, "kind": kind, "name": item.get("name", ""),
"locator": f"/{collection}/{index}"})
raw = glb.read_bytes()
digest = hashlib.sha256(raw).hexdigest()
payload = f"payload/{digest}.glb"
manifest = {
"schema_version": 1, "asset_id": asset_id,
"source": {"path_hint": source_hint},
"exporter": {"blender_version": blender_version, "addon_version": "0.1.0"},
"recipe": {"profile": "faset-gltf-static-v1", "export_extras": True,
"export_yup": True, "export_animations": False},
"files": [{"path": payload, "sha256": digest, "size": len(raw)}],
"outputs": outputs,
}
canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode()
manifest["generation"] = hashlib.sha256(canonical).hexdigest()
destination = directory / payload
if destination.exists():
if hashlib.sha256(destination.read_bytes()).hexdigest() != digest:
raise ValueError("Existing immutable payload is corrupt")
else:
_write_atomic(destination, raw)
_write_atomic(directory / "manifest.json", json.dumps(manifest, sort_keys=True, indent=2).encode())
return manifest
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Download pinned source archives once; CMake reuses them without network access."""
import argparse
import concurrent.futures
import hashlib
import json
from pathlib import Path
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--verify-only", action="store_true")
args = parser.parse_args()
lock = json.loads((ROOT / "dependencies.lock.json").read_text())
directory = ROOT / ".cache" / "downloads"
directory.mkdir(parents=True, exist_ok=True)
def fetch(item):
name, dep = item
path = directory / f"{name}-{dep['commit']}.tar.gz"
if not path.exists():
if args.verify_only:
raise RuntimeError(f"Missing archive: {path}")
with urllib.request.urlopen(dep["url"], timeout=180) as response:
content = response.read()
if hashlib.sha256(content).hexdigest() != dep["sha256"]:
raise RuntimeError(f"Checksum mismatch: {name}")
temporary = path.with_suffix(".download")
temporary.write_bytes(content)
temporary.replace(path)
if hashlib.sha256(path.read_bytes()).hexdigest() != dep["sha256"]:
raise RuntimeError(f"Checksum mismatch: {name}")
print(f"Verified {name}: {dep['commit']}", flush=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(fetch, lock["dependencies"].items()))
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Fetch the pinned Slang compiler and verify its upstream release digest."""
import argparse
import hashlib
import pathlib
import platform
import tarfile
import urllib.request
import zipfile
VERSION = '2026.18'
PACKAGES = {
'Linux': ('linux-x86_64-glibc-2.28.tar.gz', '8f27819f6bce2e37f3549e204b57a954d8daee67a5a5735cdc437b8bc7b87a50'),
'Windows': ('windows-x86_64.zip', '6ffa4827b519fd0a85b38407049d87ab0c1f045fe2289cb1e6831f965169f8a1'),
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('.cache/slang'))
args = parser.parse_args()
if platform.machine().lower() not in ('x86_64', 'amd64'):
raise SystemExit('Pinned Slang packages currently support x86-64 hosts; supply SLANGC_EXECUTABLE for another host.')
suffix, digest = PACKAGES[platform.system()]
name = f'slang-{VERSION}-{suffix}'
args.output.mkdir(parents=True, exist_ok=True)
archive = args.output / name
if not archive.exists():
url = f'https://github.com/shader-slang/slang/releases/download/v{VERSION}/{name}'
temporary = archive.with_suffix('.download')
urllib.request.urlretrieve(url, temporary)
temporary.replace(archive)
actual = hashlib.sha256(archive.read_bytes()).hexdigest()
if actual != digest:
raise SystemExit(f'Slang checksum mismatch for {archive}; expected {digest}, received {actual}')
if suffix.endswith('.zip'):
with zipfile.ZipFile(archive) as package:
for item in package.infolist():
target = (args.output / item.filename).resolve()
if not target.is_relative_to(args.output.resolve()):
raise SystemExit('Unsafe archive path')
package.extractall(args.output)
else:
with tarfile.open(archive) as package:
package.extractall(args.output, filter='data')
executable = args.output / 'bin' / ('slangc.exe' if platform.system() == 'Windows' else 'slangc')
if not executable.is_file():
raise SystemExit(f'Compiler missing from package: {executable}')
print(executable.resolve())
if __name__ == '__main__':
main()