Checkpoint 1: implement native subsystems and begin the gameplay manual
This commit is contained in:
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user