diff --git a/bindings/dotnet/src/Gua.Core/GuaSpatialHost.cs b/bindings/dotnet/src/Gua.Core/GuaSpatialHost.cs
index 44b325e5..adb5e39e 100644
--- a/bindings/dotnet/src/Gua.Core/GuaSpatialHost.cs
+++ b/bindings/dotnet/src/Gua.Core/GuaSpatialHost.cs
@@ -10,6 +10,14 @@ public struct GuaSpatialHostOptions
public uint MaxProviders, MaxOwners, MaxQueueDepth, MaxQueriesPerBatch, MaxHitsPerQuery;
public double QueryDeadlineMs, MaxBatchWorkMs;
}
+/// Conservative binary64 bounds of prepared engine geometry plus the
+/// original query. Trusted adapter input; never accept this from a client.
+[StructLayout(LayoutKind.Sequential)]
+public struct GuaSpatialEngineBounds
+{
+ internal uint StructSize;
+ public double MinX, MinY, MinZ, MaxX, MaxY, MaxZ;
+}
internal sealed class SpatialHostHandle : SafeHandleZeroOrMinusOneIsInvalid
{
internal SpatialHostHandle(nint p) : base(true) => SetHandle(p);
@@ -84,6 +92,15 @@ public void Complete(ulong lease, GuaSpatialDocument execution)
using var refs = new SpatialReferences();
Check(Native.gua_spatial_host_complete(_handle, lease, execution.Use(refs), out var e), e);
}
+ /// After Take and before physics, check prepared engine bounds
+ /// against current grants. Retained for subsequent revocation checks.
+ /// Returns whether the explicit loaded region contains the entire bound.
+ public bool CheckEngineBounds(ulong lease, GuaSpatialEngineBounds bounds)
+ {
+ bounds.StructSize = (uint)Marshal.SizeOf();
+ Check(Native.gua_spatial_host_check_engine_bounds(_handle, lease, in bounds, out var loaded, out var e), e);
+ return loaded != 0;
+ }
public void End(ulong lease) => Check(Native.gua_spatial_host_end(_handle, lease, out var e), e);
public void Dispose() => _handle.Dispose();
}
@@ -103,5 +120,6 @@ internal static partial class Native
[DllImport(ValueLibrary, CallingConvention = CallingConvention.Cdecl)] internal static extern int gua_spatial_host_begin(SpatialHostHandle host, ulong provider, nint doc, out ulong lease, out SpatialError e);
[DllImport(ValueLibrary, CallingConvention = CallingConvention.Cdecl)] internal static extern int gua_spatial_host_take(SpatialHostHandle host, ulong lease, out nint doc, out SpatialError e);
[DllImport(ValueLibrary, CallingConvention = CallingConvention.Cdecl)] internal static extern int gua_spatial_host_complete(SpatialHostHandle host, ulong lease, nint doc, out SpatialError e);
+ [DllImport(ValueLibrary, CallingConvention = CallingConvention.Cdecl)] internal static extern int gua_spatial_host_check_engine_bounds(SpatialHostHandle host, ulong lease, in GuaSpatialEngineBounds bounds, out uint loaded, out SpatialError e);
[DllImport(ValueLibrary, CallingConvention = CallingConvention.Cdecl)] internal static extern int gua_spatial_host_end(SpatialHostHandle host, ulong lease, out SpatialError e);
}
diff --git a/bindings/dotnet/tests/Gua.Selector.Tests/SpatialHostTests.cs b/bindings/dotnet/tests/Gua.Selector.Tests/SpatialHostTests.cs
index ff4c9c1b..0b75b49d 100644
--- a/bindings/dotnet/tests/Gua.Selector.Tests/SpatialHostTests.cs
+++ b/bindings/dotnet/tests/Gua.Selector.Tests/SpatialHostTests.cs
@@ -33,6 +33,27 @@ public void OrderedBoundaryHasOptionalTickAndPartialEnd()
Assert.That(Assert.Throws(()=>h.Poll(o,1))!.Code,Is.EqualTo(GuaSpatialErrorCode.Stale));
}
[Test]
+ public void PreparedEngineBoundsUseNativeGrantsAndRemainRevocable()
+ {
+ Assert.That(Marshal.SizeOf(),Is.EqualTo(56));
+ using var h=Host(); using var registration=Doc(0); using var grants=Doc(1); using var batch=Doc(2); using var boundary=Doc(3); using var execution=Doc(4);
+ var p=h.Register(registration); var o=h.OpenOwner(grants); h.Enqueue(o,batch); var l=h.Begin(p,boundary)!.Value;
+ try
+ {
+ using var q=h.Take(l)!;
+ var bounds=new GuaSpatialEngineBounds {MinX=-9,MinY=-9,MinZ=-9,MaxX=11,MaxY=9,MaxZ=9};
+ Assert.That(Assert.Throws(()=>h.CheckEngineBounds(l,bounds))!.Code,Is.EqualTo(GuaSpatialErrorCode.NotAuthorized));
+ bounds.MaxX=9; Assert.That(h.CheckEngineBounds(l,bounds),Is.True); h.Complete(l,execution);
+ h.End(l); l=0;
+ var narrowed=grants.ReadOwner(); narrowed.Region=narrowed.Region with {Max=new GuaSpatialVector(8,10,10)};
+ using var updated=GuaSpatialDocument.FromOwner(narrowed); h.SetOwner(o,updated);
+ using var result=h.Poll(o,1)!;
+ Assert.That(result.ReadBatchResult().Items[0].Reason,Is.EqualTo("not_authorized"));
+ Assert.That(result.ReadBatchResult().Items[0].Result,Is.Null);
+ }
+ finally {if(l!=0) h.End(l);}
+ }
+ [Test]
public void RevocationAndLateCompletionCannotPublishGeometry()
{
using var h=Host(); using var registration=Doc(0); using var grants=Doc(1); using var batch=Doc(2); using var boundary=Doc(3); using var execution=Doc(4);
diff --git a/bindings/unity/Runtime/GuaUnitySpatial.cs b/bindings/unity/Runtime/GuaUnitySpatial.cs
new file mode 100644
index 00000000..0d9ac22f
--- /dev/null
+++ b/bindings/unity/Runtime/GuaUnitySpatial.cs
@@ -0,0 +1,290 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Gua.Core;
+using UnityEngine;
+
+namespace Gua.Unity
+{
+ /// Trusted collision policy. Self exclusion must use excluded layers;
+ /// post-query predicates cannot satisfy the spatial host prefilter contract.
+ public sealed class GuaUnitySpatialPolicy
+ {
+ public readonly string Id;
+ public readonly int LayerMask;
+ public readonly QueryTriggerInteraction Triggers;
+ public readonly bool Backfaces;
+ public GuaUnitySpatialPolicy(string id, int layerMask, QueryTriggerInteraction triggers, bool backfaces)
+ {
+ if (triggers == QueryTriggerInteraction.UseGlobal) throw new ArgumentException("Explicit trigger policy required");
+ Id = id; LayerMask = layerMask; Triggers = triggers; Backfaces = backfaces;
+ }
+ }
+
+ /// Opt-in, main-thread PhysicsScene reader. The host must call Pump
+ /// at its approved synchronized physics boundary, with no mutations during
+ /// the call. This reader never SyncTransforms, Simulate or changes globals.
+ /// Dispose before scene destruction/origin changes, then register a new epoch.
+ public sealed class GuaUnitySpatial : IDisposable
+ {
+ readonly GuaSpatialHost host;
+ readonly PhysicsScene scene;
+ readonly GuaSpatialRegistration registration;
+ readonly Dictionary policies = new Dictionary();
+ readonly RaycastHit[] casts;
+ readonly Collider[] overlaps;
+ readonly int thread = Thread.CurrentThread.ManagedThreadId;
+ readonly string samplePrefix = Guid.NewGuid().ToString("N");
+ long sample;
+ bool disposed, pumping;
+ public ulong Provider { get; private set; }
+
+ public GuaUnitySpatial(GuaSpatialHost host, PhysicsScene scene, GuaSpatialRegistration registration,
+ GuaUnitySpatialPolicy[] policies, int bufferCapacity = 64)
+ {
+ if (!scene.IsValid()) throw new ArgumentException("Registered scene must be valid");
+ if (bufferCapacity < 33 || bufferCapacity > 1024) throw new ArgumentOutOfRangeException(nameof(bufferCapacity));
+ this.host = host; this.scene = scene;
+ // Freeze mutable authoring DTOs through the native validator.
+ using (var doc = GuaSpatialDocument.FromRegistration(registration)) this.registration = doc.ReadRegistration();
+ foreach (var policy in policies) this.policies.Add(policy.Id, policy);
+ foreach (var policy in this.registration.Policies)
+ if (!this.policies.ContainsKey(policy.Id)) throw new ArgumentException("Missing host collision policy");
+ if (this.registration.Provider.Engine.Name != "Unity" || this.registration.Provider.Engine.Version != Application.unityVersion
+ || this.registration.Provider.Engine.Backend != "PhysX" || this.registration.Provider.Precision.Representation != "binary32")
+ throw new ArgumentException("Registration must identify the running Unity patch version");
+ casts = new RaycastHit[bufferCapacity]; overlaps = new Collider[bufferCapacity];
+ using (var doc = GuaSpatialDocument.FromRegistration(this.registration)) Provider = host.Register(doc);
+ }
+
+ void CheckThread()
+ {
+ if (thread != Thread.CurrentThread.ManagedThreadId) throw new InvalidOperationException("Physics requires the registering main thread");
+ if (disposed) throw new ObjectDisposedException(nameof(GuaUnitySpatial));
+ }
+
+ /// One bounded batch per boundary. Tick is optional actual host
+ /// tick, never inferred from frameCount. No World snapshot is fabricated.
+ public bool Pump(long? tick = null)
+ {
+ CheckThread();
+ if (pumping) throw new InvalidOperationException("Reentrant physics boundary");
+ if (!scene.IsValid()) { Dispose(); return false; }
+ pumping = true;
+ ulong? lease = null;
+ try
+ {
+ using (var boundary = GuaSpatialDocument.FromBoundary(new GuaSpatialBoundary
+ { PhysicsSampleId = samplePrefix + ":" + (++sample), Tick = tick }))
+ lease = host.Begin(Provider, boundary);
+ if (!lease.HasValue) return false;
+ while (true)
+ {
+ using (var request = host.Take(lease.Value))
+ {
+ if (request == null) break;
+ var query = request.ReadRequest();
+ var result = ExecuteOrTerminal(query, lease.Value);
+ if (!CompleteItem(lease.Value, result)) break;
+ }
+ }
+ return true;
+ }
+ finally
+ {
+ try { if (lease.HasValue) { try { host.End(lease.Value); } catch(GuaSpatialException e) when(e.Code==GuaSpatialErrorCode.Stale) { } } }
+ finally { pumping = false; Array.Clear(casts, 0, casts.Length); Array.Clear(overlaps, 0, overlaps.Length); }
+ }
+ }
+
+ GuaSpatialHostQueryResult ExecuteOrTerminal(GuaSpatialRequest query, ulong lease)
+ {
+ try { return Execute(query,lease); }
+ catch(GuaSpatialException e) when(e.Code==GuaSpatialErrorCode.NotReady || e.Code==GuaSpatialErrorCode.Stale)
+ {
+ // Native has already recorded the truthful terminal reason.
+ // Only carry consumed correlation through completion cleanup.
+ return Unsupported(query);
+ }
+ }
+ bool CompleteItem(ulong lease, GuaSpatialHostQueryResult result)
+ {
+ using(var execution=GuaSpatialDocument.FromExecution(result))
+ {
+ for(int attempt=0;attempt<2;++attempt)
+ {
+ try { host.Complete(lease,execution); return true; }
+ catch(GuaSpatialException e) when(e.Code==GuaSpatialErrorCode.NotReady)
+ {
+ // Selective revocation may retain an in-flight item.
+ // One bounded retry releases it. If already released,
+ // NotReady is a no-op because no next item was taken.
+ }
+ catch(GuaSpatialException e) when(e.Code==GuaSpatialErrorCode.Stale) { return false; }
+ }
+ return true;
+ }
+ }
+
+ static float F(double value)
+ {
+ var converted = (float)value;
+ if (float.IsInfinity(converted) || float.IsNaN(converted) || Math.Abs(value) > 8192)
+ throw new ArgumentOutOfRangeException(nameof(value));
+ return converted;
+ }
+ static Vector3 V(GuaSpatialVector v) => new Vector3(F(v.X), F(v.Y), F(v.Z));
+ static GuaSpatialVector V(Vector3 v) => new GuaSpatialVector(v.x, v.y, v.z);
+ static Quaternion Orientation(GuaSpatialBasis basis)
+ {
+ // Either handedness is legal. Flipping one box axis describes the
+ // identical occupied box and gives Unity a proper rotation basis.
+ return Quaternion.LookRotation(V(basis.Z), V(basis.Y));
+ }
+ int Overlap(GuaSpatialShape shape, GuaUnitySpatialPolicy policy)
+ {
+ switch (shape.Type)
+ {
+ case "sphere": return scene.OverlapSphere(V(shape.Center), F(shape.Radius.Value), overlaps, policy.LayerMask, policy.Triggers);
+ case "capsule":
+ if (shape.PointA == shape.PointB) return scene.OverlapSphere(V(shape.PointA), F(shape.Radius.Value), overlaps, policy.LayerMask, policy.Triggers);
+ return scene.OverlapCapsule(V(shape.PointA), V(shape.PointB), F(shape.Radius.Value), overlaps, policy.LayerMask, policy.Triggers);
+ case "box": return scene.OverlapBox(V(shape.Center), V(shape.HalfExtents), overlaps, Orientation(shape.Basis), policy.LayerMask, policy.Triggers);
+ default: throw new ArgumentException("Unsupported shape");
+ }
+ }
+ int Cast(GuaSpatialShape shape, Vector3 direction, float distance, GuaUnitySpatialPolicy policy)
+ {
+ switch (shape.Type)
+ {
+ case "sphere": return scene.SphereCast(V(shape.Center), F(shape.Radius.Value), direction, casts, distance, policy.LayerMask, policy.Triggers);
+ case "capsule":
+ if (shape.PointA == shape.PointB) return scene.SphereCast(V(shape.PointA), F(shape.Radius.Value), direction, casts, distance, policy.LayerMask, policy.Triggers);
+ return scene.CapsuleCast(V(shape.PointA), V(shape.PointB), F(shape.Radius.Value), direction, casts, distance, policy.LayerMask, policy.Triggers);
+ case "box": return scene.BoxCast(V(shape.Center), V(shape.HalfExtents), direction, casts, Orientation(shape.Basis), distance, policy.LayerMask, policy.Triggers);
+ default: throw new ArgumentException("Unsupported shape");
+ }
+ }
+
+ GuaSpatialEngineBounds PreparedBounds(GuaSpatialRequest q)
+ {
+ var lo=new[]{double.PositiveInfinity,double.PositiveInfinity,double.PositiveInfinity};
+ var hi=new[]{double.NegativeInfinity,double.NegativeInfinity,double.NegativeInfinity};
+ Func original=v=>new[]{v.X,v.Y,v.Z};
+ Func converted=v=>new[]{(double)v.x,(double)v.y,(double)v.z};
+ var delta=q.Kind=="raycast"?V(q.Segment.To)-V(q.Segment.From):q.Kind=="sweep"?V(q.Delta):Vector3.zero;
+ var length=delta.magnitude;
+ if((q.Kind=="raycast"||(q.Kind=="sweep"&&(q.Delta.X!=0||q.Delta.Y!=0||q.Delta.Z!=0)))&&(length==0||float.IsInfinity(length))) throw new ArgumentException("Collapsed motion");
+ var travel=length==0?Vector3.zero:(delta/length).normalized*length;
+ for(int pass=0;pass<2;++pass)
+ {
+ bool engine=pass==1;
+ Func v=n=>engine?converted(V(n)):original(n);
+ double[] a,b;
+ if(q.Kind=="raycast") { a=v(q.Segment.From); b=engine?converted(V(q.Segment.From)+travel):v(q.Segment.To); }
+ else
+ {
+ var s=q.Shape; a=v(s.Type=="capsule"?s.PointA:s.Center); b=v(s.Type=="capsule"?s.PointB:s.Center);
+ if(engine && s.Type=="capsule" && (a[0]!=b[0] || a[1]!=b[1] || a[2]!=b[2]) && (double)(V(s.PointB)-V(s.PointA)).sqrMagnitude<1.1754943508222875e-38) throw new ArgumentException("Collapsed capsule axis");
+ var ext=new double[3];
+ if(s.Type=="box")
+ {
+ var h=v(s.HalfExtents); if(h[0]<=0||h[1]<=0||h[2]<=0) throw new ArgumentException("Collapsed extents");
+ var rotation=engine?Orientation(s.Basis):Quaternion.identity;
+ var x=engine?converted(rotation*Vector3.right):original(s.Basis.X);
+ var y=engine?converted(rotation*Vector3.up):original(s.Basis.Y);
+ var z=engine?converted(rotation*Vector3.forward):original(s.Basis.Z);
+ for(int i=0;i<3;++i) ext[i]=Math.Abs(x[i])*h[0]+Math.Abs(y[i])*h[1]+Math.Abs(z[i])*h[2];
+ }
+ else { var r=engine?F(s.Radius.Value):s.Radius.Value; if(r<=0) throw new ArgumentException("Collapsed radius"); for(int i=0;i<3;++i) ext[i]=r; }
+ var d=q.Kind=="sweep"?(engine?converted(travel):original(q.Delta)):new double[3];
+ for(int i=0;i<3;++i) { var min=Math.Min(a[i],b[i])-ext[i]; var max=Math.Max(a[i],b[i])+ext[i]; a[i]=min+Math.Min(0,d[i]); b[i]=max+Math.Max(0,d[i]); }
+ }
+ for(int i=0;i<3;++i) { lo[i]=Math.Min(lo[i],Math.Min(a[i],b[i])); hi[i]=Math.Max(hi[i],Math.Max(a[i],b[i])); }
+ }
+ double scale=1; for(int i=0;i<3;++i) scale=Math.Max(scale,Math.Max(Math.Abs(lo[i]),Math.Abs(hi[i])));
+ // Guard prepared parameter rounding; backend collision error remains unknown.
+ double guard=scale*64.0/8388608.0;
+ return new GuaSpatialEngineBounds {MinX=lo[0]-guard,MinY=lo[1]-guard,MinZ=lo[2]-guard,MaxX=hi[0]+guard,MaxY=hi[1]+guard,MaxZ=hi[2]+guard};
+ }
+ static GuaSpatialHit Anonymous()
+ {
+ return new GuaSpatialHit { Missing = new Dictionary
+ {
+ ["position"] = "not_observed", ["distance"] = "not_observed", ["normal"] = "not_observed",
+ ["collisionRef"] = "not_published", ["worldObjectId"] = "not_published"
+ }};
+ }
+ GuaSpatialHostQueryResult Execute(GuaSpatialRequest q, ulong lease)
+ {
+ var r = new GuaSpatialHostQueryResult { RequestId = q.RequestId, SessionEpoch = q.SessionEpoch,
+ QueryId = q.QueryId, SpaceId = q.SpaceId, SpaceEpoch = q.SpaceEpoch, Kind = q.Kind };
+ var policy = policies[q.QueryPolicyId];
+ // Unity has no per-query backface flag. Refuse mismatched globals,
+ // never silently inherit them or mutate game settings.
+ if (Physics.queriesHitBackfaces != policy.Backfaces)
+ { r.Status = "unsupported"; r.Error = new GuaSpatialFailure("unsupported_policy"); return r; }
+ try
+ {
+ var loaded = host.CheckEngineBounds(lease, PreparedBounds(q));
+ r.Status = "completed"; r.Coverage = loaded ? new GuaSpatialCoverage { State="complete", LoadedRegion=registration.LoadedRegion } : new GuaSpatialCoverage { State="unknown", Reason="engine_coverage_unverified" }; r.Truncated = false; r.Hits = Array.Empty();
+ int count;
+ if (q.Kind != "raycast")
+ {
+ count = Overlap(q.Shape, policy);
+ if (q.Kind == "sweep") r.InitialOverlap = count > 0 ? "detected" : "notDetected";
+ if (count > 0)
+ {
+ r.Outcome = q.Kind == "sweep" ? "initialOverlap" : "detected";
+ r.Truncated = count == overlaps.Length || count > (q.MaxHits ?? registration.Provider.Limits.MaxHitsPerQuery);
+ var hitCount = Math.Min(count, q.MaxHits ?? registration.Provider.Limits.MaxHitsPerQuery);
+ r.Hits = new GuaSpatialHit[hitCount];
+ for (int i = 0; i < hitCount; ++i) r.Hits[i] = Anonymous();
+ if (q.Kind == "sweep" && q.Delta.X == 0 && q.Delta.Y == 0 && q.Delta.Z == 0)
+ r.Motion = new GuaSpatialMotion { Type = "zeroLength", Distance = 0 };
+ return r;
+ }
+ if (q.Kind == "overlap") { r.Outcome = "notDetected"; return r; }
+ if (q.Delta.X == 0 && q.Delta.Y == 0 && q.Delta.Z == 0)
+ { r.Outcome = r.Coverage.State == "complete" ? "clear" : "indeterminate"; r.Motion = new GuaSpatialMotion { Type = "zeroLength", Distance = 0 }; return r; }
+ }
+ Vector3 delta = q.Kind == "raycast" ? V(q.Segment.To) - V(q.Segment.From) : V(q.Delta);
+ var distance = delta.magnitude;
+ if (distance == 0 || float.IsInfinity(distance)) throw new ArgumentOutOfRangeException(nameof(distance));
+ count = q.Kind == "raycast"
+ ? scene.Raycast(V(q.Segment.From), delta / distance, casts, distance, policy.LayerMask, policy.Triggers)
+ : Cast(q.Shape, delta / distance, distance, policy);
+ if (q.Kind == "raycast") { r.Nearest = count > 0 ? "returnedHits" : "none"; r.OriginInside = "unknown"; }
+ r.Outcome = count > 0 ? (q.Kind == "raycast" ? "hit" : "blocked") : (q.Kind == "raycast" ? "noHit" : r.Coverage.State == "complete" ? "clear" : "indeterminate");
+ Array.Sort(casts, 0, count, Comparer.Create((a, b) => a.distance.CompareTo(b.distance)));
+ var limit = q.MaxHits ?? registration.Provider.Limits.MaxHitsPerQuery;
+ r.Truncated = count == casts.Length || count > limit;
+ r.Hits = new GuaSpatialHit[Math.Min(count, limit)];
+ for (int i = 0; i < r.Hits.Length; ++i)
+ {
+ // Internal-start and mesh normals are not certified. Publish
+ // detection without inferring penetration or support.
+ var hit = Anonymous();
+ r.Hits[i] = hit;
+ }
+ return r;
+ }
+ catch (GuaSpatialException e) when (e.Code == GuaSpatialErrorCode.NotAuthorized) { return Unsupported(q); }
+ catch (ArgumentException)
+ {
+ return Unsupported(q);
+ }
+ }
+ static GuaSpatialHostQueryResult Unsupported(GuaSpatialRequest q) => new GuaSpatialHostQueryResult
+ { RequestId=q.RequestId,SessionEpoch=q.SessionEpoch,QueryId=q.QueryId,SpaceId=q.SpaceId,SpaceEpoch=q.SpaceEpoch,Kind=q.Kind,
+ Status="unsupported",Error=new GuaSpatialFailure(q.Kind=="raycast"?"unsupported_operation":"unsupported_shape") };
+
+ public void Dispose()
+ {
+ if (disposed) return;
+ CheckThread();
+ if (pumping) throw new InvalidOperationException("Cannot dispose a live physics boundary");
+ host.Unregister(Provider); disposed = true; Provider = 0;
+ }
+ }
+}
diff --git a/bindings/unity/Runtime/GuaUnitySpatial.cs.meta b/bindings/unity/Runtime/GuaUnitySpatial.cs.meta
new file mode 100644
index 00000000..fb395962
--- /dev/null
+++ b/bindings/unity/Runtime/GuaUnitySpatial.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c3661f2a932c4e24a8c3f299b4700b45
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/docs/spatial-engine-r1.md b/docs/spatial-engine-r1.md
new file mode 100644
index 00000000..0fae5ea8
--- /dev/null
+++ b/docs/spatial-engine-r1.md
@@ -0,0 +1,190 @@
+# Spatial-03 adapters and real geometry evidence
+
+`GuaUnitySpatial` reads its explicitly registered `PhysicsScene`;
+`GuaSpatialReader` reads its explicitly registered `World3D` direct state.
+Both are opt-in trusted host integrations over the native spatial-host-r1
+scheduler, with ordered take/complete and mandatory End. Both readers continue after an individual consumed item becomes
+terminal: they preserve the native reason, use at most one completion retry to
+release retained correlation, and take the next eligible item in the same lease.
+All-batch terminal states still stop and End; an already revoked lease needs no
+second reclamation. They expose no runtime
+transport capability. Existing strict spatial-r1 documents remain unchanged.
+Host-installed enabled Testing/Debug grants are required; Player and whole
+original shapes crossing a grant are rejected before dispatch.
+
+Unity Pump requires its registering main thread and a host-approved synchronized
+read interval held throughout one bounded batch. It never calls SyncTransforms,
+Simulate, or changes physics globals. The geometry fixture syncs once after each
+host mutation. Its Editor boundary omits tick and World snapshot. The runtime
+profile uses normal FixedUpdate callbacks and likewise omits invented physics
+ticks. Godot uses the main thread within `_physics_process` and records the
+actual engine physics callback counter. Distinct sample IDs identify held read
+intervals, not simultaneous samples across different calls or engines.
+
+## Geometry and authorization
+
+The readers implement rays, sphere/capsule/oriented-box overlaps and fixed
+orientation translation sweeps, including diagonal motion and either box basis
+handedness. Godot capsule height is endpoint distance plus twice radius; equal
+endpoints use a sphere. Box full size is twice halfExtents. Three exclusively
+owned reusable shape resources avoid editing live colliders. Unity uses its
+corresponding PhysicsScene methods and finite reusable arrays (33..1024 slots).
+Each sweep checks initial overlap first. Zero delta is an overlap-only query.
+Godot preserves cast_motion native brackets with source and unknown error;
+it never substitutes rest_info from a different target.
+
+Godot spatial document serialization explicitly enables full precision so the
+JSON writer cannot collapse tiny nonzero values before native validation.
+Binary64 requests are converted to binary32 engine parameters. Both readers
+compute a conservative union of original and prepared geometry, including the
+full motion, and add a parameter-encoding guard of 64 binary32 epsilons times
+max(1, maximum absolute bound). The additive trusted C ABI
+`gua_spatial_host_check_engine_bounds` requires this volume to contain the
+native outward-rounded original bounds, then checks current owner and policy
+regions before physics. It retains the volume for complete/poll revocation and
+coverage checks. Narrow grants are refused rather than expanded. This guard
+bounds parameter preparation; it does not certify collision-kernel accuracy.
+Godot rejects registrations claiming a representation other than binary32,
+matching the pinned build and its parameter-preparation rules.
+Coordinates/dimensions beyond 8192 units, nonfinite/collapsed parameters and
+nonzero margins remain unsupported. Backend absolute error remains unknown.
+Distinct capsule endpoints whose binary32 squared axis length is zero or
+subnormal (below 1.1754943508222875e-38) are refused before physics. Godot also
+checks the normalized axis remains unit length within 1e-6, preventing a scaled
+capsule transform. Real-engine regressions use axis lengths 1e-25 and 1e-22.
+Dimensions come from the request; inferred live-collider nonuniform scale is
+not supported. The small-endpoint capsule regression (y=1 and
+1.0000001192092896, radius=.25, owner min y=.74999998) must produce no geometry
+under its narrow grant even though the original volume is authorized.
+
+Only an explicit trusted loaded AABB can establish complete coverage, and it
+must contain the entire prepared union. Missing/outside coverage is unknown;
+an empty sweep then returns indeterminate. Overlap never proves penetration.
+Anonymous hits retain missing reasons for position, distance, normal and public
+identity, with relation unknown. No pointer, RID or instance ID is published.
+Rays claim nearest only within returned hits. Buffer saturation and extra
+results beyond maxHits remain truncated.
+
+## Policies and lifecycle
+
+Unity uses explicit layer masks and Ignore/Collide triggers. Self colliders must
+use excluded layers; arbitrary post-query predicates are unsupported. Unity has
+no per-query backface option: it verifies the selected policy against current
+Physics.queriesHitBackfaces and refuses mismatches without physics or changing
+settings. Godot uses explicit bodies/areas, masks, bounded host RID exclusions
+and ray hit_back_faces. Shape queries use host obstacle backface settings;
+hosts must install matching concave-shape settings as part of their registered
+policy. Per-query changes to those obstacle settings are unsupported.
+
+Dispose/unregister before scene destruction or origin changes and register a
+fresh spaceEpoch afterward. Unity rejects invalid PhysicsScenes at Pump.
+Automatic origin-shift detection is not supplied. A retained World3D reference
+does not replace the host lifecycle obligation.
+
+## Reproduction and measured combinations
+
+Run `scripts/run-spatial-engine-fixtures.ps1` with the pinned engine paths.
+Run again with `-GodotBackend 'Jolt Physics'` for Jolt. Optional GodotCppSource
+can reuse the pinned godot-cpp checkout offline. Fresh native and managed code
+is staged in isolated ignored artifacts projects. Previous evidence is deleted
+before execution; missing records or nonzero engine exits fail the runner.
+No Playtest dependency is used.
+
+`protocol/fixtures/spatial-engine-r1.json` fixes versions, geometry dimensions,
+expected occupancy, .001-unit tolerance, tick target and policies. Expectations
+are analytical, not generated from adapter output. The triangular wall has
+front face toward negative Z; the Godot and Unity constructors use their
+respective front-face winding. Inside-start rays and exact floor contact are
+explicitly backend-specific; neither may certify normals or penetration.
+
+The Windows runs cover 33 shared scenarios, a second moving-door state, and
+16-query batches: 67 result records per engine. An additional 34 lease-race records exercise
+post-Take owner revocation for every geometry scenario and deterministic
+post-Take deadline expiry once. The exact Execute/completion helpers used by
+Pump release the terminal item, then run the eligible query against the actual
+held engine state; its fixed outcome and same boundary sample are checked.
+Unity reflection is confined to the fixture, with no production physics override
+or test hook. The deadline fixture deliberately pauses the host for 120 ms after
+taking an item with a fixed 100 ms deadline; it does not simulate physics.
+They also assert independent
+world/scene isolation, anonymous walls, Player denial, whole-shape grant denial,
+prepared-geometry refusal, explicit trigger/self filters, saturation, unloaded
+coverage, unregister and stale replacement epochs. Door old/new states have
+distinct samples and Godot ticks. The measured inside-start ray returns hit in
+both Godot backends and noHit in Unity, while originInside remains unknown.
+Exact floor contact reports detected in all three, without claiming penetration
+or a reliable normal.
+
+| Combination | Evidence/support |
+| --- | --- |
+| Godot 4.7.stable.official.5b4e0cb0f / GodotPhysics3D / Windows x64 | Real primitive, general orientation/motion and static triangle front/back ray fixtures |
+| Same Godot patch / Jolt Physics / Windows x64 | Same fixed fixtures and actual callback profile |
+| Unity 6000.5.3f1 / PhysX / Windows x64 / Play Mode | Same fixed fixtures and runtime FixedUpdate profile |
+| Other patch versions/OS/backend combinations | Unverified; no support claim |
+| Static triangle overlap/sweep, arbitrary concave meshes and game-specific one-way logic | Unverified; no portable support claim; raw physics does not execute gameplay one-way callbacks |
+| Nonzero margin, inferred nonuniform collider scale, arbitrary Unity predicate self filtering | Unsupported |
+| Reliable normals, penetration depth, public collision/World IDs | Not supplied |
+
+Godot concave shapes are hollow static triangle collections, not solid-volume
+penetration certificates; see [official ConcavePolygonShape3D documentation](https://docs.godotengine.org/en/stable/classes/class_concavepolygonshape3d.html).
+Measured triangle ray behavior does not certify all mesh or one-way combinations.
+No completion of parent #130 is claimed.
+
+## CPU, queue and game impact
+
+Each engine profile runs 150 actual physics callbacks in each phase (baseline,
+one query, 16 queries), discards the first 30 as warmup and retains 120 samples:
+360 records per run. Every callback performs the same 2000 sine accumulations
+as game work. Records include total callback wall time, pump wall time,
+enqueue-to-pump wall time and actual callback interval. Phase main-thread CPU
+and elapsed time are measured across the whole retained interval, including
+engine work between callbacks. Windows GetThreadTimes has a 100 ns numeric
+representation but coarse scheduler accounting; short deltas may be zero or
+attributed at a later call. Do not interpret individual deltas as precise
+per-query CPU. QueryThreadCycleTime also records actual thread CPU cycles for
+each callback; cycles are never converted to time using a guessed frequency.
+Phase CPU totals include engine and editor work between callbacks; compare them
+alongside callback cycle counts and wall time, not as isolated adapter CPU.
+This is a synthetic host workload, not a performance guarantee for another game.
+
+The final full-precision serialization and capsule-underflow regression run
+passed 67 results plus 360 callback samples for each combination. Four native
+spatial suites and 102 managed spatial tests passed. The following callback
+wall times are microseconds; raw evidence/logs remain under ignored artifacts.
+
+| Engine/backend | Baseline median | Single median / p95 | Batch16 median / p95 |
+| --- | ---: | ---: | ---: |
+| GodotPhysics3D | 153.5 | 739.5 / 950 | 6130 / 7851 |
+| Jolt Physics | 144 | 759.5 / 1009 | 5904 / 6823 |
+| Unity PhysX | 18.2 | 597 / 869.6 | 5280.7 / 7223.7 |
+
+Godot single/batch p95 callback intervals reached about 29.9 ms with normal
+60 FPS render pacing; Unity reached about 16.7 ms. The configured 60 Hz target
+therefore does not establish uninterrupted 60 Hz delivery. Queue timing starts
+before enqueue serialization/validation and is an upper bound on core queue
+residence. Geometry Godot singletons deliberately wait for the next normal
+physics boundary. First-use/JIT and engine background work can change timings.
+
+Engine calls are synchronous and cannot be interrupted. The native host checks
+cooperative deadlines/work budgets between operations and discards late
+results; it cannot guarantee a maximum frame stall. Host limits bound queue,
+batch size, hits, retained results and dispatch work, not backend execution
+time. Budget checks, revocation, End and owner cleanup are covered by native
+and managed host suites; real engine fixtures preserve those boundaries.
+
+| Engine/backend | Mean callback CPU cycles: baseline / single / batch16 | Whole retained phase main-thread CPU ms: baseline / single / batch16 |
+| --- | ---: | ---: |
+| GodotPhysics3D | 643843 / 2762477 / 23655329 | 31.25 / 171.875 / 765.625 |
+| Jolt Physics | 595460 / 2856448 / 22622195 | 125 / 0 / 546.875 |
+| Unity PhysX | 71817 / 2437274 / 20747119 | 1968.75 / 1953.125 / 1968.75 |
+
+Each retained phase spans approximately 1.98 seconds. Unity batch-mode Editor
+work keeps the main thread busy between callbacks, so phase CPU totals do not
+isolate query costs. Jolt reports zero single-query phase CPU despite more callback work than the
+baseline; this exposes the accounting resolution limit of GetThreadTimes;
+callback CPU cycles still distinguish the workload. Baseline-subtracted median
+callback wall overhead is about 0.59 ms for one query and 5.98 ms for batch16
+in GodotPhysics3D; 0.62/5.76 ms in Jolt; 0.58/5.26 ms in Unity. These measure
+adapter, serialization, enqueue and result consumption within the host workload.
+For full primitive/triangle scenario timings, use the single/batch records,
+not only this empty-space profiling scenario.
diff --git a/examples/godot-gdscript/addons/gua/gua_spatial.gd b/examples/godot-gdscript/addons/gua/gua_spatial.gd
new file mode 100644
index 00000000..4931d662
--- /dev/null
+++ b/examples/godot-gdscript/addons/gua/gua_spatial.gd
@@ -0,0 +1,303 @@
+class_name GuaSpatialReader
+extends RefCounted
+## Trusted host-owned direct-space reader. Call pump only from the approved
+## _physics_process boundary. No live node/collider settings are modified.
+## Dispose before origin changes or World3D destruction; use a fresh spaceEpoch.
+
+var _host: GuaSpatialHost
+var _world: World3D
+var _registration: Dictionary
+var _policies: Dictionary
+var provider: int
+var _pumping := false
+var _sample := 0
+var _sphere := SphereShape3D.new()
+var _capsule := CapsuleShape3D.new()
+var _box := BoxShape3D.new()
+var _shape_query := PhysicsShapeQueryParameters3D.new()
+var _ray_query := PhysicsRayQueryParameters3D.new()
+
+func configure(host: GuaSpatialHost, world: World3D, registration: Dictionary, policies: Dictionary) -> int:
+ if provider != 0 or world == null or OS.get_thread_caller_id() != OS.get_main_thread_id():
+ return 1
+ # Freeze host dictionaries; masks, trigger flags and exclusions never come
+ # from a query. RID exclusions are applied by physics before enumeration.
+ _registration = registration.duplicate(true)
+ _policies = policies.duplicate(true)
+ if registration.provider.get("precision",{}).get("representation","") != "binary32" or registration.provider.engine.name != "Godot" or registration.provider.engine.version != Engine.get_version_info().string or registration.provider.engine.backend != ProjectSettings.get_setting("physics/3d/physics_engine") or registration.provider.engine.backend not in ["GodotPhysics3D","Jolt Physics"]:
+ return 5
+ for policy in registration.policies:
+ if not _policies.has(policy.id):
+ return 1
+ var filter: Dictionary = _policies[policy.id]
+ if not filter.has_all(["mask","bodies","areas","backfaces","margin","exclude"]):
+ return 1
+ if typeof(filter.mask) != TYPE_INT or filter.mask < 0 or filter.mask > 4294967295 or typeof(filter.bodies) != TYPE_BOOL or typeof(filter.areas) != TYPE_BOOL or typeof(filter.backfaces) != TYPE_BOOL:
+ return 1
+ if not filter.exclude is Array or filter.exclude.size() > 1024:
+ return 1
+ for excluded in filter.exclude:
+ if typeof(excluded) != TYPE_RID:
+ return 1
+ if filter.margin != 0.0:
+ return 5 # Nonzero margin could expand the authorized query volume.
+ _host = host
+ _world = world
+ var result := host.register_provider(JSON.stringify(_registration, "", true, true))
+ if result.status == 0:
+ provider = result.handle
+ return result.status
+
+func dispose() -> int:
+ if _pumping:
+ return 11
+ if provider == 0:
+ return 0
+ var result := _host.unregister_provider(provider)
+ provider = 0
+ _world = null
+ return result.status
+
+func pump() -> int:
+ if not Engine.is_in_physics_frame() or OS.get_thread_caller_id() != OS.get_main_thread_id() or _pumping or provider == 0:
+ return 11
+ _sample += 1
+ # Real physics callback counter; no World snapshot inferred from it.
+ var boundary := {"schemaVersion":"spatial-host-r1", "documentType":"boundary",
+ "physicsSampleId":"godot:%s:%s" % [provider, _sample], "tick":Engine.get_physics_frames()}
+ var begin := _host.begin(provider, JSON.stringify(boundary, "", true, true))
+ if begin.status != 0:
+ return begin.status
+ _pumping = true
+ var status := 0
+ var space := _world.direct_space_state
+ while true:
+ var taken := _host.take(begin.handle)
+ if taken.status == 10:
+ break
+ if taken.status != 0:
+ status = taken.status
+ break
+ var query: Dictionary = JSON.parse_string(taken.json)
+ var execution := _execute(space, query, begin.handle)
+ var completed := _complete_item(begin.handle, execution)
+ if completed == 11:
+ break
+ if completed != 0:
+ status = completed
+ break
+ # Mandatory cleanup even when native validation/deadlines reject a result.
+ var ended := _host.end(begin.handle)
+ _pumping = false
+ return status if status != 0 else (0 if ended.status == 11 else ended.status)
+
+func _complete_item(lease: int, execution: Dictionary) -> int:
+ var wire := JSON.stringify(execution,"",true,true)
+ for _attempt in range(2):
+ var completed := _host.complete(lease,wire)
+ if completed.status == 10:
+ # One retry releases retained terminal correlation. Already released
+ # items cannot consume another query because no next Take occurred.
+ continue
+ return completed.status
+ return 0
+
+static func _v(value: Dictionary) -> Vector3:
+ return Vector3(value.x, value.y, value.z)
+
+static func _exact(value: Dictionary) -> bool:
+ var v := _v(value)
+ return v.is_finite() and maxf(absf(v.x),maxf(absf(v.y),absf(v.z))) <= 8192.0
+
+static func _supported(q: Dictionary) -> bool:
+ if q.kind == "raycast":
+ return _exact(q.segment.from) and _exact(q.segment.to) and _v(q.segment.from) != _v(q.segment.to)
+ var s: Dictionary = q.shape
+ if q.kind == "sweep" and (not _exact(q.delta) or (_v(q.delta) == Vector3.ZERO and (q.delta.x != 0 or q.delta.y != 0 or q.delta.z != 0))):
+ return false
+ if s.type == "box":
+ return _exact(s.center) and _exact(s.halfExtents) and _v(s.halfExtents).x > 0 and _v(s.halfExtents).y > 0 and _v(s.halfExtents).z > 0
+ if not is_finite(s.radius) or s.radius > 8192 or Vector3(s.radius,0,0).x <= 0:
+ return false
+ if s.type == "sphere":
+ return _exact(s.center)
+ if not _exact(s.pointA) or not _exact(s.pointB):
+ return false
+ var a := _v(s.pointA)
+ var b := _v(s.pointB)
+ if a == b:
+ return true
+ var delta := b-a
+ # Squared lengths in the subnormal range corrupt float normalization even
+ # when nonzero. Refuse before constructing a scaled capsule transform.
+ if delta.length_squared() < 1.1754943508222875e-38:
+ return false
+ var axis := delta.normalized()
+ return axis.is_finite() and absf(axis.length_squared()-1.0) <= 0.000001
+
+func _prepared_bounds(q: Dictionary) -> Array:
+ var low := [INF,INF,INF]
+ var high := [-INF,-INF,-INF]
+ for encoded in range(2):
+ var a: Array
+ var b: Array
+ if q.kind == "raycast":
+ var from_v := _v(q.segment.from)
+ var to_v := _v(q.segment.to)
+ a = [from_v.x,from_v.y,from_v.z] if encoded else [q.segment.from.x,q.segment.from.y,q.segment.from.z]
+ b = [to_v.x,to_v.y,to_v.z] if encoded else [q.segment.to.x,q.segment.to.y,q.segment.to.z]
+ else:
+ var s: Dictionary = q.shape
+ var extent := [0.0,0.0,0.0]
+ if encoded:
+ var transform := _shape_query.transform
+ var center := transform.origin
+ var ends := Vector3.ZERO
+ if s.type == "capsule" and _shape_query.shape == _capsule:
+ ends = transform.basis.y * (_capsule.height * 0.5 - _capsule.radius)
+ var av := center - ends
+ var bv := center + ends
+ a = [av.x,av.y,av.z]
+ b = [bv.x,bv.y,bv.z]
+ if s.type == "box":
+ var h := _box.size * 0.5
+ for i in range(3):
+ extent[i] = absf(transform.basis.x[i])*h.x + absf(transform.basis.y[i])*h.y + absf(transform.basis.z[i])*h.z
+ else:
+ var radius: float = _sphere.radius if _shape_query.shape == _sphere else _capsule.radius
+ extent = [radius,radius,radius]
+ else:
+ var av: Dictionary = s.pointA if s.type == "capsule" else s.center
+ var bv: Dictionary = s.pointB if s.type == "capsule" else s.center
+ a = [av.x,av.y,av.z]
+ b = [bv.x,bv.y,bv.z]
+ if s.type == "box":
+ for i in range(3):
+ var axis: String = ["x","y","z"][i]
+ extent[i] = absf(s.basis.x[axis])*s.halfExtents.x + absf(s.basis.y[axis])*s.halfExtents.y + absf(s.basis.z[axis])*s.halfExtents.z
+ else:
+ extent = [s.radius,s.radius,s.radius]
+ var delta := [0.0,0.0,0.0]
+ if q.kind == "sweep":
+ var dv := _v(q.delta)
+ delta = [dv.x,dv.y,dv.z] if encoded else [q.delta.x,q.delta.y,q.delta.z]
+ for i in range(3):
+ var min_v: float = minf(a[i],b[i])-extent[i]+minf(0,delta[i])
+ var max_v: float = maxf(a[i],b[i])+extent[i]+maxf(0,delta[i])
+ a[i] = min_v
+ b[i] = max_v
+ for i in range(3):
+ low[i] = minf(low[i],minf(a[i],b[i]))
+ high[i] = maxf(high[i],maxf(a[i],b[i]))
+ var scale := 1.0
+ for i in range(3):
+ scale = maxf(scale,maxf(absf(low[i]),absf(high[i])))
+ # Guard parameter encoding, not collision-kernel numerical error.
+ var guard := scale * 64.0 / 8388608.0
+ return [low[0]-guard,low[1]-guard,low[2]-guard,high[0]+guard,high[1]+guard,high[2]+guard]
+
+func _prepare_shape(shape: Dictionary, policy: Dictionary) -> void:
+ var transform := Transform3D.IDENTITY
+ match shape.type:
+ "sphere":
+ _sphere.radius = shape.radius
+ _shape_query.shape = _sphere
+ transform.origin = _v(shape.center)
+ "capsule":
+ var a := _v(shape.pointA)
+ var b := _v(shape.pointB)
+ if a == b:
+ _sphere.radius = shape.radius
+ _shape_query.shape = _sphere
+ transform.origin = a
+ else:
+ _capsule.radius = shape.radius
+ _capsule.height = a.distance_to(b) + 2.0 * shape.radius
+ _shape_query.shape = _capsule
+ transform.origin = (a + b) * 0.5
+ var y := (b - a).normalized()
+ var helper := Vector3.RIGHT if abs(y.dot(Vector3.RIGHT)) < 0.9 else Vector3.FORWARD
+ var x := helper.cross(y).normalized()
+ transform.basis = Basis(x, y, x.cross(y))
+ "box":
+ _box.size = 2.0 * _v(shape.halfExtents)
+ _shape_query.shape = _box
+ transform.origin = _v(shape.center)
+ transform.basis = Basis(_v(shape.basis.x), _v(shape.basis.y), _v(shape.basis.z))
+ _shape_query.transform = transform
+ _shape_query.motion = Vector3.ZERO
+ _shape_query.margin = policy.margin
+ _shape_query.collision_mask = policy.mask
+ _shape_query.collide_with_bodies = policy.bodies
+ _shape_query.collide_with_areas = policy.areas
+ _shape_query.exclude = policy.exclude
+
+func _execute(space: PhysicsDirectSpaceState3D, q: Dictionary, lease: int) -> Dictionary:
+ var r := {"schemaVersion":"spatial-host-r1", "documentType":"execution",
+ "requestId":q.requestId, "sessionEpoch":q.sessionEpoch, "queryId":q.queryId,
+ "spaceId":q.spaceId, "spaceEpoch":q.spaceEpoch, "kind":q.kind,
+ "status":"completed", "coverage":{"state":"unknown", "reason":"engine_coverage_unverified"},
+ "truncated":false, "hits":[]}
+ var policy: Dictionary = _policies[q.queryPolicyId]
+ if not _supported(q):
+ for key in ["coverage", "truncated", "hits"]:
+ r.erase(key)
+ r.status = "unsupported"
+ r.error = {"code":"unsupported_operation" if q.kind == "raycast" else "unsupported_shape"}
+ return r
+ if q.kind != "raycast":
+ _prepare_shape(q.shape, policy)
+ var b := _prepared_bounds(q)
+ var checked := _host.check_engine_bounds(lease,b[0],b[1],b[2],b[3],b[4],b[5])
+ if checked.status != 0:
+ for key in ["coverage","truncated","hits"]:
+ r.erase(key)
+ r.status = "unsupported"
+ r.error = {"code":"unsupported_operation" if q.kind == "raycast" else "unsupported_shape"}
+ return r
+ r.coverage = {"state":"complete","loadedRegion":_registration.loadedRegion} if checked.loaded_complete else {"state":"unknown","reason":"outside_loaded_region"}
+ if q.kind == "raycast":
+ _ray_query.from = _v(q.segment.from)
+ _ray_query.to = _v(q.segment.to)
+ _ray_query.collision_mask = policy.mask
+ _ray_query.collide_with_bodies = policy.bodies
+ _ray_query.collide_with_areas = policy.areas
+ _ray_query.hit_back_faces = policy.backfaces
+ _ray_query.hit_from_inside = true
+ _ray_query.exclude = policy.exclude
+ var hit := space.intersect_ray(_ray_query)
+ r.outcome = "noHit" if hit.is_empty() else "hit"
+ r.nearest = "none" if hit.is_empty() else "returnedHits"
+ r.originInside = "unknown"
+ if not hit.is_empty():
+ r.hits.append(_anonymous())
+ return r
+ # One extra result allows truthful enumeration truncation within <=33 slots.
+ var hits := space.intersect_shape(_shape_query, int(q.maxHits) + 1)
+ if not hits.is_empty():
+ r.outcome = "initialOverlap" if q.kind == "sweep" else "detected"
+ r.truncated = hits.size() > int(q.maxHits)
+ for _i in range(mini(hits.size(), int(q.maxHits))):
+ r.hits.append(_anonymous())
+ else:
+ r.outcome = ("clear" if r.coverage.state == "complete" else "indeterminate") if q.kind == "sweep" else "notDetected"
+ if q.kind == "sweep":
+ r.initialOverlap = "detected" if not hits.is_empty() else "notDetected"
+ var delta := _v(q.delta)
+ if delta == Vector3.ZERO:
+ r.motion = {"type":"zeroLength", "distance":0}
+ elif hits.is_empty():
+ _shape_query.motion = delta
+ var fractions := space.cast_motion(_shape_query)
+ if fractions.size() != 2:
+ r.initialOverlap = "indeterminate"
+ else:
+ r.outcome = "blocked" if fractions[1] < 1.0 else ("clear" if r.coverage.state == "complete" else "indeterminate")
+ r.motion = {"type":"nativeBracket", "source":"Godot.cast_motion",
+ "safeFraction":fractions[0], "unsafeFraction":fractions[1],
+ "error":{"state":"unknown", "reason":"backend_error_unmeasured"}}
+ return r
+
+static func _anonymous() -> Dictionary:
+ return {"relation":"unknown", "missing":{"position":"not_observed", "distance":"not_observed",
+ "normal":"not_observed", "collisionRef":"not_published", "worldObjectId":"not_published"}}
diff --git a/examples/spatial-fixtures/godot/fixture.gd b/examples/spatial-fixtures/godot/fixture.gd
new file mode 100644
index 00000000..4fb64280
--- /dev/null
+++ b/examples/spatial-fixtures/godot/fixture.gd
@@ -0,0 +1,348 @@
+extends Node3D
+
+const Reader = preload("res://addons/gua/gua_spatial.gd")
+var fixture: Dictionary
+var host := GuaSpatialHost.new()
+var reader: GuaSpatialReader
+var viewport: SubViewport
+var index := 0
+var phase := 0
+var spatial_owner: int
+var queued_at: int
+var evidence: Array = []
+var lease_races: Array = []
+var profile_records: Array = []
+var profile_frame := 0
+var profile_last := 0
+var profile_query: Dictionary
+var profile_sink := 0.0
+var profile_phase_cpu := 0.0
+var profile_phase_wall := 0
+var door_sample: Dictionary
+var region := {"min":{"x":-20,"y":-20,"z":-20},"max":{"x":20,"y":20,"z":20}}
+
+func _ready() -> void:
+ fixture = JSON.parse_string(FileAccess.get_file_as_string("res://spatial-engine-r1.json"))
+ var version := Engine.get_version_info()
+ if not _check(version.major == 4 and version.minor == 7 and version.patch == 0 and version.status == "stable" and version.hash.begins_with("5b4e0cb0f"), "pinned Godot patch"):
+ set_physics_process(false)
+ return
+ _check(host.configure("fixture-godot", 2, 2, 4, 64, 2, 1000, 1000).status == 0, "host configure")
+ # A permanently populated separate World3D must not contaminate empty cases.
+ var other := SubViewport.new()
+ other.own_world_3d = true
+ add_child(other)
+ _obstacle(other, {"center":{"x":0,"y":0,"z":0},"size":{"x":10,"y":10,"z":10},"category":"solid"})
+
+func _check(condition: bool, label: String) -> bool:
+ if not condition:
+ push_error("SPATIAL FAIL: " + label)
+ get_tree().quit(1)
+ return condition
+
+func _obstacle(parent: Node, obstacle: Dictionary) -> void:
+ var body: CollisionObject3D = Area3D.new() if obstacle.category == "trigger" else StaticBody3D.new()
+ body.collision_layer = 2 if obstacle.category == "self" else 1
+ body.position = Vector3(obstacle.center.x, obstacle.center.y, obstacle.center.z)
+ var collision := CollisionShape3D.new()
+ if obstacle.get("mesh",false):
+ var mesh := ConcavePolygonShape3D.new()
+ mesh.set_faces(PackedVector3Array([Vector3(-2,-2,0),Vector3(2,-2,0),Vector3(0,2,0)]))
+ mesh.backface_collision = false
+ collision.shape = mesh
+ else:
+ var box := BoxShape3D.new()
+ box.size = Vector3(obstacle.size.x, obstacle.size.y, obstacle.size.z)
+ collision.shape = box
+ body.add_child(collision)
+ parent.add_child(body)
+
+func _registration(loaded: bool) -> Dictionary:
+ var basis := {"x":{"x":1,"y":0,"z":0},"y":{"x":0,"y":1,"z":0},"z":{"x":0,"y":0,"z":1}}
+ var registration := {"schemaVersion":"spatial-host-r1", "documentType":"registration",
+ "provider":{"schemaVersion":"spatial-r1", "documentType":"provider", "providerId":"godot-fixture",
+ "spaceId":"fixture", "spaceEpoch":index+1, "worldSpace":"world3d", "basis":basis, "up":basis.y,
+ "unit":{"label":"fixture-unit"}, "precision":{"representation":"binary32", "reason":"backend_error_unmeasured"},
+ "operations":["raycast","overlap","sweep"], "shapes":["sphere","capsule","box"],
+ "policies":["solid","triggers"], "consistencies":["bestEffort","samePhysicsSample"],
+ "engine":{"name":"Godot", "version":Engine.get_version_info().string, "backend":ProjectSettings.get_setting("physics/3d/physics_engine"), "backendVersion":"unknown"},
+ "limits":{"maxQueriesPerBatch":64,"maxHitsPerQuery":2,"maxDeadlineMs":1000}},
+ "policies":[{"id":"solid","revision":1,"region":region},{"id":"triggers","revision":1,"region":region}]}
+ if loaded:
+ registration.loadedRegion = region
+ return registration
+
+func _physics_process(_delta: float) -> void:
+ if index >= fixture.cases.size():
+ if not _profile_frame():
+ return
+ var output := {"configuration":fixture.configuration,"backend":ProjectSettings.get_setting("physics/3d/physics_engine"),"results":evidence,"profile":profile_records,"leaseRaces":lease_races,"physicsTick":Engine.get_physics_frames()}
+ var file := FileAccess.open("res://evidence.json", FileAccess.WRITE)
+ file.store_string(JSON.stringify(output, "\t", true, true))
+ print("SPATIAL PASS: ", evidence.size(), " real Godot cases")
+ get_tree().quit(0)
+ set_physics_process(false)
+ return
+ var case: Dictionary = fixture.cases[index]
+ if phase == 0:
+ case.query.spaceEpoch = index+1
+ viewport = SubViewport.new()
+ viewport.own_world_3d = true
+ add_child(viewport)
+ for obstacle in case.obstacles:
+ _obstacle(viewport, obstacle)
+ var misstated := _registration(case.loaded)
+ misstated.provider.precision.representation = "binary64"
+ var rejected_reader := Reader.new()
+ _check(rejected_reader.configure(host,viewport.find_world_3d(),misstated,{})==5,"misstated Godot precision refused")
+ reader = Reader.new()
+ var policies := {"solid":{"mask":1,"bodies":true,"areas":false,"backfaces":false,"margin":0.0,"exclude":[]},
+ "triggers":{"mask":1,"bodies":true,"areas":true,"backfaces":false,"margin":0.0,"exclude":[]}}
+ if not _check(reader.configure(host, viewport.find_world_3d(), _registration(case.loaded), policies) == 0, case.id + " registration"):
+ return
+ var grants := {"schemaVersion":"spatial-host-r1","documentType":"owner","sessionEpoch":1,
+ "profile":"Testing","enabled":true,"policies":["solid","triggers"],"region":region}
+ var opened := host.open_owner(JSON.stringify(grants, "", true, true))
+ if not _check(opened.status == 0, "spatial_owner"):
+ return
+ spatial_owner = opened.handle
+ var denied_query: Dictionary = case.query.duplicate(true)
+ denied_query.kind = "overlap"
+ denied_query.erase("segment")
+ denied_query.erase("delta")
+ denied_query.shape = {"type":"sphere","center":{"x":19.75,"y":0,"z":0},"radius":0.5}
+ var denied_batch := {"schemaVersion":"spatial-host-r1","documentType":"batch","batchId":9999,
+ "consistency":"samePhysicsSample","queries":[denied_query]}
+ _check(host.enqueue(spatial_owner, JSON.stringify(denied_batch, "", true, true)).status == 8, "whole-shape region denied")
+ grants.profile = "Player"
+ _check(host.set_owner(spatial_owner, JSON.stringify(grants, "", true, true)).status == 0, "player grants")
+ denied_batch.queries = [case.query]
+ _check(host.enqueue(spatial_owner, JSON.stringify(denied_batch, "", true, true)).status == 8, "Player denied before physics")
+ grants.profile = "Testing"
+ _check(host.set_owner(spatial_owner, JSON.stringify(grants, "", true, true)).status == 0, "testing restore")
+ var narrow_grants: Dictionary = grants.duplicate(true)
+ narrow_grants.region.min.y = 0.74999998
+ _check(host.set_owner(spatial_owner, JSON.stringify(narrow_grants, "", true, true)).status == 0, "derived geometry narrow grant")
+ var rounding_query: Dictionary = case.query.duplicate(true)
+ rounding_query.requestId = 10001
+ rounding_query.queryId = "capsule-derived-rounding"
+ rounding_query.kind = "overlap"
+ rounding_query.erase("segment")
+ rounding_query.erase("delta")
+ rounding_query.shape = {"type":"capsule","pointA":{"x":0,"y":1,"z":0},
+ "pointB":{"x":0,"y":1.0000001192092896,"z":0},"radius":0.25}
+ var rounding_batch := {"schemaVersion":"spatial-host-r1","documentType":"batch","batchId":10001,
+ "consistency":"samePhysicsSample","queries":[rounding_query]}
+ _check(host.enqueue(spatial_owner, JSON.stringify(rounding_batch, "", true, true)).status == 0, "original volume authorized")
+ _check(reader.pump() == 0, "derived geometry refusal pump")
+ var rounding_result := host.poll(spatial_owner, 10001)
+ var rounding_doc: Dictionary = JSON.parse_string(rounding_result.json)
+ _check(rounding_doc.items[0].state == "failed" and rounding_doc.items[0].reason == "unsupported_shape" and not rounding_doc.items[0].has("result"), "no expanded physics geometry")
+ _check(host.set_owner(spatial_owner, JSON.stringify(grants, "", true, true)).status == 0, "normal grants restored")
+ rounding_query.requestId = 10002
+ rounding_query.queryId = "capsule-axis-underflow"
+ rounding_query.shape.pointA = {"x":0,"y":0,"z":0}
+ rounding_query.shape.pointB = {"x":1e-25,"y":0,"z":0}
+ rounding_batch.batchId = 10002
+ _check(host.enqueue(spatial_owner,JSON.stringify(rounding_batch, "", true, true)).status==0,"underflow input valid")
+ _check(reader.pump()==0,"underflow refusal pump")
+ var underflow_result := host.poll(spatial_owner,10002)
+ var underflow_doc: Dictionary = JSON.parse_string(underflow_result.json)
+ _check(underflow_doc.items[0].state=="failed" and underflow_doc.items[0].reason=="unsupported_shape" and not underflow_doc.items[0].has("result"),"no singular capsule transform")
+ rounding_query.requestId = 10003
+ rounding_query.queryId = "capsule-axis-subnormal"
+ rounding_query.shape.pointB.x = 1e-22
+ rounding_batch.batchId = 10003
+ _check(host.enqueue(spatial_owner,JSON.stringify(rounding_batch,"",true,true)).status==0,"subnormal input valid")
+ _check(reader.pump()==0,"subnormal refusal pump")
+ var subnormal_result := host.poll(spatial_owner,10003)
+ var subnormal_doc: Dictionary = JSON.parse_string(subnormal_result.json)
+ _check(subnormal_doc.items[0].state=="failed" and subnormal_doc.items[0].reason=="unsupported_shape" and not subnormal_doc.items[0].has("result"),"no scaled capsule axis")
+ var batch := {"schemaVersion":"spatial-host-r1","documentType":"batch","batchId":index+1,
+ "consistency":"samePhysicsSample","queries":[case.query]}
+ queued_at = Time.get_ticks_usec()
+ _check(host.enqueue(spatial_owner, JSON.stringify(batch, "", true, true)).status == 0, case.id + " enqueue")
+ phase = 1
+ return
+ # Engine has advanced a normal physics boundary after host-created colliders.
+ var start := Time.get_ticks_usec()
+ if not _check(reader.pump() == 0, case.id + " pump"):
+ set_physics_process(false)
+ return
+ var elapsed := Time.get_ticks_usec() - start
+ var polled := host.poll(spatial_owner, index+1)
+ if not _check(polled.status == 0, case.id + " poll"):
+ return
+ var result: Dictionary = JSON.parse_string(polled.json)
+ var item: Dictionary = result.items[0]
+ if not _check(item.state == "completed", case.id + " completion " + JSON.stringify(item, "", true, true)):
+ return
+ var expected: String = case.doorOpenOutcome if phase == 2 else case.outcome
+ if expected != "engine-specific":
+ if not _check(item.result.outcome == expected, case.id + " expected " + expected + " got " + JSON.stringify(item, "", true, true)):
+ return
+ _check(item.result.truncated == case.truncated, case.id + " truncation")
+ if case.id == "inside-ray":
+ _check(item.result.originInside == "unknown", "inside ray uncertainty")
+ for hit in item.result.hits:
+ _check(hit.missing.has("normal") and hit.relation == "unknown", "no fabricated normal/penetration")
+ evidence.append({"case":case.id,"result":item.result,"pumpWallUs":elapsed,"queueWallUs":start-queued_at})
+ if case.id == "door-transition" and phase == 1:
+ door_sample = item.result.sample
+ viewport.get_child(0).position = Vector3(0, 0, 10)
+ case.query.requestId = 5000
+ case.query.queryId = "door-transition-open"
+ var batch := {"schemaVersion":"spatial-host-r1","documentType":"batch","batchId":index+1,
+ "consistency":"samePhysicsSample","queries":[case.query]}
+ queued_at = Time.get_ticks_usec()
+ _check(host.enqueue(spatial_owner, JSON.stringify(batch, "", true, true)).status == 0, "door open enqueue")
+ phase = 2
+ return
+ if phase == 2:
+ _check(item.result.sample.physicsSampleId != door_sample.physicsSampleId and item.result.sample.tick > door_sample.tick, "door state matches new sample/tick")
+ var queries: Array = []
+ for n in range(16):
+ var query: Dictionary = case.query.duplicate(true)
+ query.requestId = 6000+n
+ query.queryId = "batch:%s" % n
+ queries.append(query)
+ var batch := {"schemaVersion":"spatial-host-r1","documentType":"batch","batchId":6000,
+ "consistency":"samePhysicsSample","queries":queries}
+ var queue_start := Time.get_ticks_usec()
+ _check(host.enqueue(spatial_owner, JSON.stringify(batch, "", true, true)).status == 0, "batch enqueue")
+ var batch_start := Time.get_ticks_usec()
+ _check(reader.pump() == 0, "batch pump")
+ var batch_wall := Time.get_ticks_usec()-batch_start
+ var batch_result := host.poll(spatial_owner, 6000)
+ _check(batch_result.status == 0, "batch poll")
+ var batch_doc: Dictionary = JSON.parse_string(batch_result.json)
+ var sample_id: String = batch_doc.items[0].result.sample.physicsSampleId
+ for batch_item in batch_doc.items:
+ _check(batch_item.state == "completed" and batch_item.result.sample.physicsSampleId == sample_id, "one held physics sample")
+ _check(expected == "engine-specific" or batch_item.result.outcome == expected, "batch fixed expectation")
+ _check(batch_item.result.truncated == case.truncated, "batch truncation")
+ evidence.append({"case":case.id,"batchSize":16,"pumpWallUs":batch_wall,"queueWallUs":batch_start-queue_start,"result":batch_doc})
+ _lease_race(case,expected)
+ if index == 0:
+ _lease_race(case,expected,true)
+ _check(reader.dispose() == 0, "dispose")
+ var stale_batch := {"schemaVersion":"spatial-host-r1","documentType":"batch","batchId":9999,
+ "consistency":"samePhysicsSample","queries":[case.query]}
+ _check(host.enqueue(spatial_owner, JSON.stringify(stale_batch, "", true, true)).status != 0, "unregistered world rejected")
+ # Explicit host lifecycle: unregister before changing origin, then new epoch.
+ for obstacle in viewport.get_children():
+ obstacle.position.x += 100
+ var new_registration := _registration(false)
+ new_registration.provider.spaceEpoch = index+1001
+ var replacement := Reader.new()
+ _check(replacement.configure(host, viewport.find_world_3d(), new_registration, {"solid":{"mask":1,"bodies":true,"areas":false,"backfaces":false,"margin":0.0,"exclude":[]},"triggers":{"mask":1,"bodies":true,"areas":true,"backfaces":false,"margin":0.0,"exclude":[]}}) == 0, "new origin epoch registration")
+ _check(host.enqueue(spatial_owner, JSON.stringify(stale_batch, "", true, true)).status != 0, "old origin epoch rejected")
+ _check(replacement.dispose() == 0, "new epoch dispose")
+ _check(host.close_owner(spatial_owner).status == 0, "close")
+ viewport.queue_free()
+ phase = 0
+ index += 1
+
+# Actual normal physics callbacks, with identical deterministic game work in
+# baseline/single/batch phases. No simulation calls or inferred tick cadence.
+func _profile_frame() -> bool:
+ if profile_frame == 0:
+ viewport = SubViewport.new()
+ viewport.own_world_3d = true
+ add_child(viewport)
+ reader = Reader.new()
+ var policy := {"solid":{"mask":1,"bodies":true,"areas":false,"backfaces":false,"margin":0.0,"exclude":[]},"triggers":{"mask":1,"bodies":true,"areas":true,"backfaces":false,"margin":0.0,"exclude":[]}}
+ _check(reader.configure(host,viewport.find_world_3d(),_registration(true),policy)==0,"profile registration")
+ var opened := host.open_owner(JSON.stringify({"schemaVersion":"spatial-host-r1","documentType":"owner","sessionEpoch":1,"profile":"Testing","enabled":true,"policies":["solid","triggers"],"region":region}, "", true, true))
+ _check(opened.status==0,"profile owner")
+ spatial_owner = opened.handle
+ profile_query = fixture.cases[0].query.duplicate(true)
+ profile_query.spaceEpoch = index+1
+ var start := Time.get_ticks_usec()
+ var cpu: Dictionary = host.thread_cpu_time()
+ _check(cpu.status==0 and cpu.has("cycles"),"Windows thread CPU evidence")
+ if profile_frame%150 == 30:
+ profile_phase_cpu = cpu.microseconds
+ profile_phase_wall = start
+ var count: int = [0,1,16][mini(profile_frame/150,2)]
+ for n in range(2000):
+ profile_sink += sin(float(n)*0.01)*0.000001
+ var pump_wall := 0
+ var queue_wall := 0
+ if count > 0:
+ var queries: Array = []
+ for n in range(count):
+ var q := profile_query.duplicate(true)
+ q.requestId = 100000+profile_frame*16+n
+ q.queryId = "profile:%s:%s" % [profile_frame,n]
+ queries.append(q)
+ var batch := {"schemaVersion":"spatial-host-r1","documentType":"batch","batchId":100000+profile_frame,"consistency":"samePhysicsSample","queries":queries}
+ var queued := Time.get_ticks_usec()
+ _check(host.enqueue(spatial_owner,JSON.stringify(batch, "", true, true)).status==0,"profile enqueue")
+ var pump_start := Time.get_ticks_usec()
+ _check(reader.pump()==0,"profile pump")
+ pump_wall = Time.get_ticks_usec()-pump_start
+ queue_wall = pump_start-queued
+ var polled := host.poll(spatial_owner,100000+profile_frame)
+ _check(polled.status==0,"profile poll")
+ var result: Dictionary = JSON.parse_string(polled.json)
+ for item in result.items:
+ _check(item.state=="completed" and item.result.outcome=="clear","profile fixed geometry")
+ var end := Time.get_ticks_usec()
+ var cpu_end: Dictionary = host.thread_cpu_time()
+ if profile_frame%150 >= 30:
+ profile_records.append({"batchSize":count,"callbackWallUs":end-start,"threadCpuUs":cpu_end.microseconds-cpu.microseconds,"threadCpuCycles":cpu_end.cycles-cpu.cycles,"pumpWallUs":pump_wall,"queueWallUs":queue_wall,"callbackIntervalUs":start-profile_last,"tick":Engine.get_physics_frames(),"phaseMainThreadCpuUs":cpu_end.microseconds-profile_phase_cpu if profile_frame%150 == 149 else null,"phaseElapsedUs":end-profile_phase_wall if profile_frame%150 == 149 else null})
+ profile_last = start
+ profile_frame += 1
+ if profile_frame == 450:
+ _check(reader.dispose()==0,"profile dispose")
+ _check(host.close_owner(spatial_owner).status==0,"profile close")
+ viewport.queue_free()
+ return true
+ return false
+
+# Deterministic owner revocation after Take; invokes the same completion helper
+# as pump, then executes the next query against the actual held World3D state.
+func _lease_race(case: Dictionary, expected: String, deadline: bool = false) -> void:
+ var first: Dictionary = case.query.duplicate(true)
+ first.requestId = 11000
+ first.queryId = "race-revoked"
+ if deadline:
+ first.deadlineMs = 100
+ first.kind = "overlap"
+ first.erase("segment")
+ first.erase("delta")
+ first.shape = {"type":"sphere","center":{"x":9,"y":0,"z":0},"radius":0.25}
+ var second: Dictionary = case.query.duplicate(true)
+ second.requestId = 11001
+ second.queryId = "race-eligible"
+ var batch := {"schemaVersion":"spatial-host-r1","documentType":"batch","batchId":11000,"consistency":"samePhysicsSample","queries":[first,second]}
+ _check(host.enqueue(spatial_owner,JSON.stringify(batch,"",true,true)).status==0,"race enqueue")
+ var sample_id := "race:%s:%s" % [index,deadline]
+ var boundary := {"schemaVersion":"spatial-host-r1","documentType":"boundary","physicsSampleId":sample_id,"tick":Engine.get_physics_frames()}
+ var begun := host.begin(reader.provider,JSON.stringify(boundary,"",true,true))
+ _check(begun.status==0,"race begin")
+ var consumed := host.take(begun.handle)
+ _check(consumed.status==0 and JSON.parse_string(consumed.json).requestId==11000,"race first consumed before terminal change")
+ var grants := {"schemaVersion":"spatial-host-r1","documentType":"owner","sessionEpoch":1,"profile":"Testing","enabled":true,"policies":["solid","triggers"],"region":region.duplicate(true)}
+ grants.region.max.x = 8
+ if deadline:
+ OS.delay_msec(120) # Deterministic host stall after Take; no physics simulation.
+ else:
+ _check(host.set_owner(spatial_owner,JSON.stringify(grants,"",true,true)).status==0,"race selective revoke")
+ var execution := reader._execute(viewport.find_world_3d().direct_space_state,JSON.parse_string(consumed.json),begun.handle)
+ _check(reader._complete_item(begun.handle,execution)==0,"race correlation released")
+ var eligible := host.take(begun.handle)
+ _check(eligible.status==0,"race next eligible taken")
+ var actual := reader._execute(viewport.find_world_3d().direct_space_state,JSON.parse_string(eligible.json),begun.handle)
+ _check(reader._complete_item(begun.handle,actual)==0,"race actual physics completed")
+ var polled := host.poll(spatial_owner,11000)
+ _check(polled.status==0,"race poll")
+ var result: Dictionary = JSON.parse_string(polled.json)
+ _check(result.items[0].state=="failed" and result.items[0].reason==("deadline_exceeded" if deadline else "not_authorized") and not result.items[0].has("result"),"race revoked geometry withheld")
+ _check(result.items[1].state=="completed" and result.items[1].result.sample.physicsSampleId==sample_id,"race same held sample")
+ _check(expected=="engine-specific" or result.items[1].result.outcome==expected,"race independent geometry")
+ _check(host.end(begun.handle).status==0,"race mandatory end")
+ grants.region = region
+ _check(host.set_owner(spatial_owner,JSON.stringify(grants,"",true,true)).status==0,"race grants restored")
+ lease_races.append({"case":case.id,"deadline":deadline,"result":result})
diff --git a/examples/spatial-fixtures/godot/fixture.tscn b/examples/spatial-fixtures/godot/fixture.tscn
new file mode 100644
index 00000000..44a950fa
--- /dev/null
+++ b/examples/spatial-fixtures/godot/fixture.tscn
@@ -0,0 +1,4 @@
+[gd_scene load_steps=2 format=3]
+[ext_resource type="Script" path="res://fixture.gd" id="1"]
+[node name="Fixture" type="Node3D"]
+script = ExtResource("1")
diff --git a/examples/spatial-fixtures/godot/project.godot b/examples/spatial-fixtures/godot/project.godot
new file mode 100644
index 00000000..f10600ee
--- /dev/null
+++ b/examples/spatial-fixtures/godot/project.godot
@@ -0,0 +1,9 @@
+config_version=5
+[application]
+config/name="Gua Spatial Geometry Fixture"
+run/main_scene="res://fixture.tscn"
+[physics]
+3d/physics_engine="GodotPhysics3D"
+common/physics_ticks_per_second=60
+[rendering]
+renderer/rendering_method="gl_compatibility"
diff --git a/examples/spatial-fixtures/unity/SpatialFixture.cs b/examples/spatial-fixtures/unity/SpatialFixture.cs
new file mode 100644
index 00000000..0ba6caf3
--- /dev/null
+++ b/examples/spatial-fixtures/unity/SpatialFixture.cs
@@ -0,0 +1,270 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Reflection;
+using System.Text.Json;
+using Gua.Core;
+using Gua.Unity;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+
+public static class SpatialFixture
+{
+ static GuaSpatialVector V(double x, double y, double z) => new GuaSpatialVector(x,y,z);
+ static readonly GuaSpatialRegion Region = new GuaSpatialRegion(V(-20,-20,-20), V(20,20,20));
+ static Vector3 Vector(JsonElement v) => new Vector3(v.GetProperty("x").GetSingle(),v.GetProperty("y").GetSingle(),v.GetProperty("z").GetSingle());
+ static void Check(bool condition, string label) { if (!condition) throw new Exception("SPATIAL FAIL: " + label); }
+ static void Obstacle(Scene scene, JsonElement obstacle)
+ {
+ var go = new GameObject("anonymous-obstacle");
+ SceneManager.MoveGameObjectToScene(go,scene);
+ go.transform.position = Vector(obstacle.GetProperty("center"));
+ var category = obstacle.GetProperty("category").GetString();
+ go.layer = category == "self" ? 9 : 8;
+ if(obstacle.TryGetProperty("mesh",out var meshFlag) && meshFlag.GetBoolean())
+ {
+ var mesh=new Mesh {vertices=new[]{new Vector3(-2,-2,0),new Vector3(0,2,0),new Vector3(2,-2,0)},triangles=new[]{0,1,2}};
+ go.AddComponent().sharedMesh=mesh;
+ }
+ else { var collider = go.AddComponent(); collider.size=Vector(obstacle.GetProperty("size")); collider.isTrigger=category=="trigger"; }
+ }
+ static GuaSpatialRegistration Registration(bool loaded, long epoch)
+ {
+ var basis = new GuaSpatialBasis(V(1,0,0),V(0,1,0),V(0,0,1));
+ return new GuaSpatialRegistration { LoadedRegion = loaded ? Region : null,
+ Policies = new[] { new GuaSpatialHostPolicy("solid",1,Region),new GuaSpatialHostPolicy("triggers",1,Region) },
+ Provider = new GuaSpatialProvider { ProviderId="unity-fixture", SpaceId="fixture", SpaceEpoch=epoch,
+ Basis=basis,Up=basis.Y,Unit=new GuaSpatialUnit { Label="fixture-unit" },
+ Precision=new GuaSpatialPrecision { Representation="binary32",Reason="backend_error_unmeasured" },
+ Operations=new[]{"raycast","overlap","sweep"},Shapes=new[]{"sphere","capsule","box"},
+ Policies=new[]{"solid","triggers"},Consistencies=new[]{"bestEffort","samePhysicsSample"},
+ Engine=new GuaSpatialEngine("Unity",Application.unityVersion,"PhysX","unknown"),
+ Limits=new GuaSpatialLimits(64,2,1000) }};
+ }
+ public static void Run()
+ {
+ SessionState.SetBool("GuaSpatialFixturePending", true);
+ EditorApplication.EnterPlaymode();
+ }
+ [InitializeOnLoadMethod]
+ static void ResumeAfterReload() { EditorApplication.update += RunWhenPlaying; }
+ static void RunWhenPlaying()
+ {
+ if (!SessionState.GetBool("GuaSpatialFixturePending", false) || !EditorApplication.isPlaying || !Application.isPlaying) return;
+ SessionState.SetBool("GuaSpatialFixturePending", false);
+ EditorApplication.update -= RunWhenPlaying;
+ RunCore();
+ }
+ static void RunCore()
+ {
+ var exit = 1;
+ try
+ {
+ using (var fixture = JsonDocument.Parse(File.ReadAllText("spatial-engine-r1.json")))
+ using (var host = new GuaSpatialHost(new GuaSpatialHostOptions { MaxProviders=2,MaxOwners=2,MaxQueueDepth=4,
+ MaxQueriesPerBatch=64,MaxHitsPerQuery=2,QueryDeadlineMs=1000,MaxBatchWorkMs=1000 },"fixture-unity"))
+ {
+ Check(Application.unityVersion == fixture.RootElement.GetProperty("configuration").GetProperty("unity").GetString(),"pinned Unity patch");
+ // Global state is a host precondition; the reader never changes it.
+ Check(!Physics.queriesHitBackfaces,"fixture requires explicit backfaces=false host state");
+ var evidence = new List