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(); + var leaseRaces = new List(); + var other = SceneManager.CreateScene("spatial-other",new CreateSceneParameters(LocalPhysicsMode.Physics3D)); + var sentinel = new GameObject("other-scene-wall"); SceneManager.MoveGameObjectToScene(sentinel,other); + sentinel.layer=8; + sentinel.AddComponent().size = Vector3.one * 10; + long batchId = 0; + foreach (var test in fixture.RootElement.GetProperty("cases").EnumerateArray()) + { + ++batchId; + var id = test.GetProperty("id").GetString(); + var scene = SceneManager.CreateScene("spatial-"+id,new CreateSceneParameters(LocalPhysicsMode.Physics3D)); + foreach (var obstacle in test.GetProperty("obstacles").EnumerateArray()) Obstacle(scene,obstacle); + using (var adapter = new GuaUnitySpatial(host,scene.GetPhysicsScene(),Registration(test.GetProperty("loaded").GetBoolean(),batchId), + new[] { new GuaUnitySpatialPolicy("solid",1<<8,QueryTriggerInteraction.Ignore,false), + new GuaUnitySpatialPolicy("triggers",1<<8,QueryTriggerInteraction.Collide,false) })) + { + // Approved host boundary: sync once after setup, never per query. + Physics.SyncTransforms(); + using (var grants = GuaSpatialDocument.FromOwner(new GuaSpatialOwnerGrants {SessionEpoch=1,Profile="Testing",Enabled=true, + Policies=new[]{"solid","triggers"},Region=Region})) + using (var request = GuaSpatialDocument.FromJson(GuaSpatialDocumentType.Request,test.GetProperty("query").GetRawText())) + { + var owner = host.OpenOwner(grants); + var deniedQuery = request.ReadRequest(); deniedQuery.SpaceEpoch=batchId; deniedQuery.Kind="overlap"; deniedQuery.Segment=null; deniedQuery.Delta=null; + deniedQuery.Shape = new GuaSpatialShape {Type="sphere",Center=V(19.75,0,0),Radius=0.5}; + using (var denied = GuaSpatialDocument.FromBatch(new GuaSpatialBatch {BatchId=9999,Consistency="samePhysicsSample",Queries=new[]{deniedQuery}})) + { + try { host.Enqueue(owner,denied); throw new Exception("region unexpectedly accepted"); } + catch (GuaSpatialException e) { Check(e.Code==GuaSpatialErrorCode.NotAuthorized,"whole-shape region denied"); } + } + var player = grants.ReadOwner(); player.Profile="Player"; + using (var playerDoc = GuaSpatialDocument.FromOwner(player)) host.SetOwner(owner,playerDoc); + var currentQuery = request.ReadRequest(); currentQuery.SpaceEpoch=batchId; + using (var denied = GuaSpatialDocument.FromBatch(new GuaSpatialBatch {BatchId=9999,Consistency="samePhysicsSample",Queries=new[]{currentQuery}})) + { + try { host.Enqueue(owner,denied); throw new Exception("Player unexpectedly accepted"); } + catch (GuaSpatialException e) { Check(e.Code==GuaSpatialErrorCode.NotAuthorized,"Player denied before physics"); } + } + host.SetOwner(owner,grants); + var narrow=grants.ReadOwner(); narrow.Region=new GuaSpatialRegion(V(-20,0.74999998,-20),V(20,20,20)); + using (var narrowDoc=GuaSpatialDocument.FromOwner(narrow)) host.SetOwner(owner,narrowDoc); + var roundingQuery=request.ReadRequest(); roundingQuery.SpaceEpoch=batchId; roundingQuery.RequestId=10001; roundingQuery.QueryId="capsule-derived-rounding"; + roundingQuery.Kind="overlap"; roundingQuery.Segment=null; roundingQuery.Delta=null; + roundingQuery.Shape=new GuaSpatialShape {Type="capsule",PointA=V(0,1,0),PointB=V(0,1.0000001192092896,0),Radius=0.25}; + using (var rounding=GuaSpatialDocument.FromBatch(new GuaSpatialBatch {BatchId=10001,Consistency="samePhysicsSample",Queries=new[]{roundingQuery}})) host.Enqueue(owner,rounding); + Check(adapter.Pump(),"derived geometry refusal pump"); + using (var rounding=host.Poll(owner,10001)) + { var item=rounding.ReadBatchResult().Items[0]; Check(item.State=="failed" && item.Reason=="unsupported_shape" && item.Result==null,"no expanded physics geometry"); } + host.SetOwner(owner,grants); + roundingQuery.RequestId=10002;roundingQuery.QueryId="capsule-axis-underflow"; + roundingQuery.Shape.PointA=V(0,0,0);roundingQuery.Shape.PointB=V(1e-25,0,0); + using(var underflow=GuaSpatialDocument.FromBatch(new GuaSpatialBatch {BatchId=10002,Consistency="samePhysicsSample",Queries=new[]{roundingQuery}})) host.Enqueue(owner,underflow); + Check(adapter.Pump(),"underflow refusal pump"); + using(var underflow=host.Poll(owner,10002)) {var item=underflow.ReadBatchResult().Items[0];Check(item.State=="failed"&&item.Reason=="unsupported_shape"&&item.Result==null,"no collapsed capsule axis");} + roundingQuery.RequestId=10003;roundingQuery.QueryId="capsule-axis-subnormal";roundingQuery.Shape.PointB=V(1e-22,0,0); + using(var subnormal=GuaSpatialDocument.FromBatch(new GuaSpatialBatch {BatchId=10003,Consistency="samePhysicsSample",Queries=new[]{roundingQuery}})) host.Enqueue(owner,subnormal); + Check(adapter.Pump(),"subnormal refusal pump"); + using(var subnormal=host.Poll(owner,10003)) {var item=subnormal.ReadBatchResult().Items[0];Check(item.State=="failed"&&item.Reason=="unsupported_shape"&&item.Result==null,"no subnormal capsule axis");} + using (var batch = GuaSpatialDocument.FromBatch(new GuaSpatialBatch { BatchId=batchId,Consistency="samePhysicsSample",Queries=new[]{currentQuery} })) + { + var queued = Stopwatch.GetTimestamp(); host.Enqueue(owner,batch); + var start = Stopwatch.GetTimestamp(); var cpu = SpatialProfile.CpuUs(); + Check(adapter.Pump(),id+" pump"); + var wallUs = (Stopwatch.GetTimestamp()-start)*1000000.0/Stopwatch.Frequency; + var cpuUs = SpatialProfile.CpuUs()-cpu; + using (var result = host.Poll(owner,(ulong)batchId)) + { + Check(result != null,id+" poll"); + var item = result.ReadBatchResult().Items[0]; + Check(item.State == "completed",id+" completion "+result.ToJson()); + var expected = test.GetProperty("outcome").GetString(); + Check(expected == "engine-specific" || item.Result.Outcome == expected,id+" outcome "+result.ToJson()); + Check(item.Result.Truncated == test.GetProperty("truncated").GetBoolean(),id+" truncation"); + Check(item.Result.Sample.Tick == null,id+" no invented edit-mode physics tick"); + foreach (var hit in item.Result.Hits) Check(hit.Missing.ContainsKey("normal") && hit.Relation=="unknown",id+" unknown normal/relation"); + evidence.Add(new { id, result=result.ToJson(),pumpWallUs=wallUs,threadCpuUs=cpuUs, + queueWallUs=(start-queued)*1000000.0/Stopwatch.Frequency }); + if (id == "door-transition") + { + var oldSample = item.Result.Sample; + scene.GetRootGameObjects()[0].transform.position = new Vector3(0,0,10); + // A second approved boundary after host door mutation. + Physics.SyncTransforms(); + var openQuery = request.ReadRequest(); openQuery.SpaceEpoch=batchId; openQuery.RequestId=5000; openQuery.QueryId="door-transition-open"; + using (var openBatch = GuaSpatialDocument.FromBatch(new GuaSpatialBatch {BatchId=5000,Consistency="samePhysicsSample",Queries=new[]{openQuery}})) host.Enqueue(owner,openBatch); + Check(adapter.Pump(),"door open pump"); + using (var openResult = host.Poll(owner,5000)) + { + var openItem=openResult.ReadBatchResult().Items[0]; + Check(openItem.State=="completed" && openItem.Result.Outcome==test.GetProperty("doorOpenOutcome").GetString(),"door open physics state"); + Check(openItem.Result.Sample.PhysicsSampleId!=oldSample.PhysicsSampleId && openItem.Result.Sample.ObservedFromMs>=oldSample.ObservedToMs,"door new boundary sample"); + evidence.Add(new {id="door-transition-open",result=openResult.ToJson()}); + } + } + } + } + var queries = new GuaSpatialRequest[16]; + for (int n=0;n(); + profile.Completed=(records,error)=> + { + if(error!=null) {UnityEngine.Debug.LogException(error);EditorApplication.Exit(1);return;} + File.WriteAllText("evidence.json",JsonSerializer.Serialize(new {configuration,results=evidence,profile=records,leaseRaces})); + UnityEngine.Debug.Log("SPATIAL PROFILE PASS: "+records.Count+" real FixedUpdate samples"); + EditorApplication.Exit(0); + }; + UnityEngine.Debug.Log("SPATIAL PASS: "+evidence.Count+" real Unity cases"); + } + return; // Runtime profile completes across normal FixedUpdate callbacks. + } + catch (Exception e) { UnityEngine.Debug.LogException(e); } + EditorApplication.Exit(exit); + } + static void LeaseRace(GuaSpatialHost host,GuaUnitySpatial adapter,ulong owner,GuaSpatialDocument grants,GuaSpatialRequest query,string expected,List evidence,string id,bool deadline=false) + { + // Reflection is fixture-only access to the exact private pump helpers; + // no production test hook or physics override is installed. + var execute=typeof(GuaUnitySpatial).GetMethod("ExecuteOrTerminal",BindingFlags.Instance|BindingFlags.NonPublic); + var complete=typeof(GuaUnitySpatial).GetMethod("CompleteItem",BindingFlags.Instance|BindingFlags.NonPublic); + GuaSpatialRequest Copy() {using(var d=GuaSpatialDocument.FromRequest(query)) return d.ReadRequest();} + var first=Copy();first.RequestId=11000;first.QueryId="race-revoked";first.Kind="overlap";first.Segment=null;first.Delta=null;first.Shape=new GuaSpatialShape {Type="sphere",Center=V(9,0,0),Radius=.25};if(deadline) first.DeadlineMs=100; + var second=Copy();second.RequestId=11001;second.QueryId="race-eligible"; + using(var batch=GuaSpatialDocument.FromBatch(new GuaSpatialBatch {BatchId=11000,Consistency="samePhysicsSample",Queries=new[]{first,second}})) host.Enqueue(owner,batch); + var sample="race:"+id+":"+deadline; + using(var boundary=GuaSpatialDocument.FromBoundary(new GuaSpatialBoundary {PhysicsSampleId=sample})) + { + var lease=host.Begin(adapter.Provider,boundary).Value; + try + { + using(var consumed=host.Take(lease)) + { + Check(consumed!=null && consumed.ReadRequest().RequestId==11000,"race first consumed before terminal change"); + if(deadline) System.Threading.Thread.Sleep(120); + else {var narrow=grants.ReadOwner();narrow.Region=new GuaSpatialRegion(V(-20,-20,-20),V(8,20,20));using(var updated=GuaSpatialDocument.FromOwner(narrow)) host.SetOwner(owner,updated);} + var execution=(GuaSpatialHostQueryResult)execute.Invoke(adapter,new object[]{consumed.ReadRequest(),lease}); + Check((bool)complete.Invoke(adapter,new object[]{lease,execution}),"race correlation released"); + } + using(var eligible=host.Take(lease)) + { + Check(eligible!=null,"race next eligible taken"); + var execution=(GuaSpatialHostQueryResult)execute.Invoke(adapter,new object[]{eligible.ReadRequest(),lease}); + Check((bool)complete.Invoke(adapter,new object[]{lease,execution}),"race actual physics completed"); + } + using(var result=host.Poll(owner,11000)) + { + var items=result.ReadBatchResult().Items; + Check(items[0].State=="failed"&&items[0].Reason==(deadline?"deadline_exceeded":"not_authorized")&&items[0].Result==null,"race revoked geometry withheld"); + Check(items[1].State=="completed"&&items[1].Result.Sample.PhysicsSampleId==sample,"race same held sample"); + Check(expected=="engine-specific"||items[1].Result.Outcome==expected,"race independent geometry"); + evidence.Add(new {id,deadline,result=result.ToJson()}); + } + } + finally {host.End(lease);host.SetOwner(owner,grants);} + } + } + +} diff --git a/examples/spatial-fixtures/unity/SpatialProfile.cs b/examples/spatial-fixtures/unity/SpatialProfile.cs new file mode 100644 index 00000000..0563244e --- /dev/null +++ b/examples/spatial-fixtures/unity/SpatialProfile.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Gua.Core; +using Gua.Unity; +using UnityEngine; + +// Runtime component: samples real FixedUpdate callbacks, never Simulate. +public sealed class SpatialProfile : MonoBehaviour +{ + public Action,Exception> Completed; + GuaSpatialHost host; + GuaUnitySpatial reader; + ulong owner; + int frame; + long last; + double sink,phaseCpu; + long phaseWall; + float oldFixedDelta; + readonly List records = new List(); + [StructLayout(LayoutKind.Sequential)] struct FileTime { public uint Low,High; public ulong Value => ((ulong)High<<32)|Low; } + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread(); + [DllImport("kernel32.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] + static extern bool GetThreadTimes(IntPtr thread,out FileTime created,out FileTime exited,out FileTime kernel,out FileTime user); + [DllImport("kernel32.dll",SetLastError=true)] [return:MarshalAs(UnmanagedType.Bool)] + static extern bool QueryThreadCycleTime(IntPtr thread,out ulong cycles); + public static ulong CpuCycles() + { + if(!QueryThreadCycleTime(GetCurrentThread(),out var cycles)) throw new InvalidOperationException("Windows thread cycles unavailable"); + return cycles; + } + public static double CpuUs() + { + if(!GetThreadTimes(GetCurrentThread(),out var created,out var exited,out var kernel,out var user)) throw new InvalidOperationException("Windows thread CPU unavailable"); + return (kernel.Value+user.Value)/10.0; + } + static double Us(long ticks) => ticks*1000000.0/Stopwatch.Frequency; + static GuaSpatialVector V(double x,double y,double z) => new GuaSpatialVector(x,y,z); + void Start() + { + try + { + oldFixedDelta=Time.fixedDeltaTime; Time.fixedDeltaTime=1f/60f; + var region=new GuaSpatialRegion(V(-20,-20,-20),V(20,20,20)); + var basis=new GuaSpatialBasis(V(1,0,0),V(0,1,0),V(0,0,1)); + host=new GuaSpatialHost(new GuaSpatialHostOptions {MaxProviders=1,MaxOwners=1,MaxQueueDepth=1,MaxQueriesPerBatch=64,MaxHitsPerQuery=2,QueryDeadlineMs=1000,MaxBatchWorkMs=1000},"profile-unity"); + var registration=new GuaSpatialRegistration {LoadedRegion=region,Policies=new[]{new GuaSpatialHostPolicy("solid",1,region)},Provider=new GuaSpatialProvider + {ProviderId="profile",SpaceId="profile",SpaceEpoch=1,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"},Consistencies=new[]{"samePhysicsSample"},Engine=new GuaSpatialEngine("Unity",Application.unityVersion,"PhysX","unknown"),Limits=new GuaSpatialLimits(64,2,1000)}}; + reader=new GuaUnitySpatial(host,Physics.defaultPhysicsScene,registration,new[]{new GuaUnitySpatialPolicy("solid",1<<8,QueryTriggerInteraction.Ignore,false)}); + using(var grants=GuaSpatialDocument.FromOwner(new GuaSpatialOwnerGrants {SessionEpoch=1,Profile="Testing",Enabled=true,Policies=new[]{"solid"},Region=region})) owner=host.OpenOwner(grants); + Physics.SyncTransforms(); // One approved host setup boundary. + } + catch(Exception e) { Finish(e); } + } + void FixedUpdate() + { + if(host==null || reader==null) return; + try + { + var start=Stopwatch.GetTimestamp(); var cpu=CpuUs(); var cycles=CpuCycles(); + if(frame%150==30) {phaseCpu=cpu;phaseWall=start;} + int count=frame<150?0:frame<300?1:16; + for(int n=0;n<2000;++n) sink+=Math.Sin(n*.01)*.000001; + double pumpWall=0,queueWall=0; + if(count>0) + { + var queries=new GuaSpatialRequest[count]; + for(int n=0;n=30) records.Add(new {batchSize=count,callbackWallUs=Us(end-start),threadCpuUs=cpuEnd-cpu,threadCpuCycles=CpuCycles()-cycles,pumpWallUs=pumpWall,queueWallUs=queueWall,callbackIntervalUs=Us(start-last),fixedUpdateCallback=frame,phaseMainThreadCpuUs=frame%150==149?(double?)(cpuEnd-phaseCpu):null,phaseElapsedUs=frame%150==149?(double?)Us(end-phaseWall):null}); + last=start; ++frame; + if(frame==450) Finish(null); + } + catch(Exception e) { Finish(e); } + } + void Finish(Exception error) + { + enabled=false; + try { reader?.Dispose(); if(host!=null) {host.CloseOwner(owner);host.Dispose();} } + catch(Exception e) { if(error==null) error=e; } + host=null;reader=null; Time.fixedDeltaTime=oldFixedDelta; + Completed?.Invoke(records,error); + } +} diff --git a/native/gua-core/include/gua/spatial_host.h b/native/gua-core/include/gua/spatial_host.h index 359bc97f..e75d30ae 100644 --- a/native/gua-core/include/gua/spatial_host.h +++ b/native/gua-core/include/gua/spatial_host.h @@ -55,6 +55,17 @@ int gua_spatial_host_begin(gua_spatial_host_t*, uint64_t provider, const gua_spatial_document_t* boundary, uint64_t* lease, gua_spatial_error_t*); int gua_spatial_host_take(gua_spatial_host_t*, uint64_t lease, gua_spatial_document_t** request, gua_spatial_error_t*); +/* Trusted adapter-only conservative bound of the prepared engine query, + * including its entire translation. Must contain the original request bounds. + * Checked before physics and retained for revocation/publication checks. This + * does not authorize a new query or prove backend numerical error bounds. + * Call after take, at most once per item. loaded_complete is cleared on error. */ +typedef struct gua_spatial_engine_bounds_v1_t { + uint32_t struct_size; + double min_x, min_y, min_z, max_x, max_y, max_z; +} gua_spatial_engine_bounds_v1_t; +int gua_spatial_host_check_engine_bounds(gua_spatial_host_t*, uint64_t lease, + const gua_spatial_engine_bounds_v1_t*, uint32_t* loaded_complete, gua_spatial_error_t*); int gua_spatial_host_complete(gua_spatial_host_t*, uint64_t lease, const gua_spatial_document_t* result, gua_spatial_error_t*); int gua_spatial_host_end(gua_spatial_host_t*, uint64_t lease, gua_spatial_error_t*); diff --git a/native/gua-core/src/spatial.cpp b/native/gua-core/src/spatial.cpp index da720bd3..146b6e93 100644 --- a/native/gua-core/src/spatial.cpp +++ b/native/gua-core/src/spatial.cpp @@ -10,6 +10,7 @@ #include #include #include +#include using gua_value_detail::json; struct gua_spatial_document_t { int type; json value; std::string wire; }; diff --git a/native/gua-core/src/spatial_host.inc b/native/gua-core/src/spatial_host.inc index bcb5e145..4e259bff 100644 --- a/native/gua-core/src/spatial_host.inc +++ b/native/gua-core/src/spatial_host.inc @@ -121,6 +121,7 @@ struct host_batch { uint64_t owner=0,provider=0,id=0,lease=0,order=0; json input,boundary; std::vector items; std::vector dispatched; + std::vector> prepared; size_t next=0; bool inflight=false,done=false,cancelled=false,owner_closed=false; double accepted=0,started=0; uint64_t revision=0; size_t retained_bytes=512; @@ -147,13 +148,19 @@ bool owner_authorized(gua_spatial_host_t& h,const host_batch& b,const json& q) { auto o=h.owners.find(b.owner); if(o==h.owners.end()) return false; const auto& g=o->second.grants; - return boolean(g.at("enabled"))&&(text(g,"profile")=="Debug"||text(g,"profile")=="Testing")&&sequence(g.at("sessionEpoch"))==sequence(q.at("sessionEpoch"))&&contains(g.at("policies"),text(q,"queryPolicyId"))&&fits(query_bounds(q),aabb(g.at("region"))); + bounds volume=query_bounds(q); + const auto& qs=b.input.at("queries").items; + for(size_t i=0;isecond,text(q,"queryPolicyId")); if(!pol) return nullptr; - return fits(query_bounds(q),aabb(pol->at("region")))?pol:nullptr; + bounds volume=query_bounds(q); + const auto& qs=b.input.at("queries").items; + for(size_t i=0;iat("region")))?pol:nullptr; } json item(const json& q,const char* state,const char* reason) { return obj({{"requestId",q.at("requestId")},{"queryId",q.at("queryId")},{"state",str(state)},{"reason",str(reason)}}); @@ -281,7 +288,7 @@ int gua_spatial_host_enqueue(gua_spatial_host_t* h,uint64_t o,const gua_spatial_ require(h->batches.size()limits.max_queue_depth,GUA_SPATIAL_CAPACITY); for(const auto& q:qs) b.retained_bytes+=dump(item(q,"notExecuted","provider_unregistered")).size(); require(b.retained_bytes<=1024*1024,GUA_SPATIAL_CAPACITY); - b.accepted=now(*h); b.order=++h->order; b.items.resize(qs.size()); b.dispatched.resize(qs.size()); h->batches.emplace(std::make_pair(o,b.id),std::move(b)); + b.accepted=now(*h); b.order=++h->order; b.items.resize(qs.size()); b.dispatched.resize(qs.size()); b.prepared.resize(qs.size()); h->batches.emplace(std::make_pair(o,b.id),std::move(b)); }); } int gua_spatial_host_describe(gua_spatial_host_t* h,uint64_t o,uint64_t p,gua_spatial_document_t** out,gua_spatial_error_t* e) { @@ -330,6 +337,25 @@ int gua_spatial_host_take(gua_spatial_host_t* h,uint64_t lease,gua_spatial_docum auto owned=doc(1,q); b.revision=static_cast(sequence(pol->at("revision"))); b.inflight=true; b.dispatched[b.next]=true; *out=owned.release(); }); } +int gua_spatial_host_check_engine_bounds(gua_spatial_host_t* h,uint64_t lease,const gua_spatial_engine_bounds_v1_t* input,uint32_t* loaded,gua_spatial_error_t* e) { + if(loaded) *loaded=0; + return boundary(e,[&] { + host(h); require(input&&loaded&&input->struct_size==sizeof(*input)); + bounds volume{{input->min_x,input->min_y,input->min_z},{input->max_x,input->max_y,input->max_z}}; + for(size_t i=0;i<3;++i) require(std::isfinite(volume.lo[i])&&std::isfinite(volume.hi[i])&&volume.lo[i]<=volume.hi[i]); + std::lock_guard lock(h->mutex); auto& b=leased(*h,lease); + require(b.inflight&&!b.owner_closed&&b.items[b.next].type==json::null,GUA_SPATIAL_NOT_READY); + require(!b.prepared[b.next],GUA_SPATIAL_NOT_READY); + if(auto reason=stopped(*h,b)) { finish(*h,b,reason); fail(GUA_SPATIAL_NOT_READY); } + const auto& q=b.input.at("queries").items[b.next]; + require(fits(query_bounds(q),volume),GUA_SPATIAL_GEOMETRY); + auto pol=authorized(*h,b,q); require(pol,GUA_SPATIAL_NOT_AUTHORIZED); + require(fits(volume,aabb(pol->at("region")))&&fits(volume,aabb(h->owners.at(b.owner).grants.at("region"))),GUA_SPATIAL_NOT_AUTHORIZED); + b.prepared[b.next]=volume; + const auto& registration=h->providers.at(b.provider).registration; + *loaded=has(registration,"loadedRegion")&&fits(volume,aabb(registration.at("loadedRegion")))?1u:0u; + }); +} int gua_spatial_host_complete(gua_spatial_host_t* h,uint64_t lease,const gua_spatial_document_t* d,gua_spatial_error_t* e) { return boundary(e,[&] { host(h); document(d,10); std::lock_guard lock(h->mutex); auto& b=leased(*h,lease); require(b.inflight,GUA_SPATIAL_NOT_READY); @@ -349,7 +375,8 @@ int gua_spatial_host_complete(gua_spatial_host_t* h,uint64_t lease,const gua_spa } else { auto r=d->value; auto& registration=h->providers.at(b.provider).registration; if(text(r.at("coverage"),"state")=="complete") { - if(!has(registration,"loadedRegion")||!fits(query_bounds(q),aabb(registration.at("loadedRegion")))||!fits(aabb(r.at("coverage").at("loadedRegion")),aabb(registration.at("loadedRegion")))||!fits(query_bounds(q),aabb(r.at("coverage").at("loadedRegion")))) { finish(*h,b,"internal"); fail(GUA_SPATIAL_SEMANTICS); } + const auto volume=b.prepared[b.next].value_or(query_bounds(q)); + if(!has(registration,"loadedRegion")||!fits(volume,aabb(registration.at("loadedRegion")))||!fits(aabb(r.at("coverage").at("loadedRegion")),aabb(registration.at("loadedRegion")))||!fits(volume,aabb(r.at("coverage").at("loadedRegion")))) { finish(*h,b,"internal"); fail(GUA_SPATIAL_SEMANTICS); } } json s=obj({{"physicsSampleId",b.boundary.at("physicsSampleId")},{"clockId",str(h->clock)},{"observedFromMs",number(b.started)},{"observedToMs",number(now(*h))},{"queryPolicyRevision",integer(b.revision)}}); if(has(b.boundary,"tick")) s.fields["tick"]=b.boundary.at("tick"); diff --git a/native/gua-core/tests/spatial_host_tests.cpp b/native/gua-core/tests/spatial_host_tests.cpp index 150d833d..63f38dcb 100644 --- a/native/gua-core/tests/spatial_host_tests.cpp +++ b/native/gua-core/tests/spatial_host_tests.cpp @@ -54,6 +54,27 @@ struct Fixture { } }; int main() { + { + Fixture x; assert(x.enqueue()==0); auto l=x.begin(); auto q=x.take(l); + gua_spatial_engine_bounds_v1_t b{sizeof(b),-9,-9,-9,9,9,9}; uint32_t loaded=99; + auto malformed=b; malformed.struct_size=0; + assert(gua_spatial_host_check_engine_bounds(x.h,l,&malformed,&loaded,&x.e)==GUA_SPATIAL_INVALID&&loaded==0); + malformed=b; malformed.min_x=std::numeric_limits::quiet_NaN(); + assert(gua_spatial_host_check_engine_bounds(x.h,l,&malformed,&loaded,&x.e)==GUA_SPATIAL_INVALID&&loaded==0); + malformed=b; malformed.max_x=11; + assert(gua_spatial_host_check_engine_bounds(x.h,l,&malformed,&loaded,&x.e)==GUA_SPATIAL_NOT_AUTHORIZED&&loaded==0); + malformed=b; malformed.min_z=1; malformed.max_z=1; + assert(gua_spatial_host_check_engine_bounds(x.h,l,&malformed,&loaded,&x.e)==GUA_SPATIAL_GEOMETRY&&loaded==0); + assert(gua_spatial_host_check_engine_bounds(x.h,l,&b,&loaded,&x.e)==0&&loaded==1); + assert(gua_spatial_host_check_engine_bounds(x.h,l,&b,&loaded,&x.e)==GUA_SPATIAL_NOT_READY); + assert(x.complete(l,q)==0); auto other=x.take(l); assert(x.complete(l,other)==0); + // Original request still fits; the retained engine volume no longer does. + auto grants=x.owner; grants.fields.at("region").fields.at("max").fields["x"]=parse("8"); x.set_owner(grants); + auto result=x.poll(); assert(result.at("items").items[0].at("reason").text=="not_authorized"); + assert(!result.at("items").items[0].fields.contains("result")); + assert(result.at("items").items[1].at("state").text=="completed"); + assert(gua_spatial_host_end(x.h,l,&x.e)==0); + } { Fixture x; for(const auto& c:x.f.at("valid").items) { gua::SpatialDocument d(std::stoi(c.at("type").text),wire(c.at("json"))); gua::SpatialDocument again(d.type(),d.to_json()); assert(d.to_json()==again.to_json()); } diff --git a/native/gua-godot/CMakeLists.txt b/native/gua-godot/CMakeLists.txt index 33ad0880..1eb65ecf 100644 --- a/native/gua-godot/CMakeLists.txt +++ b/native/gua-godot/CMakeLists.txt @@ -30,6 +30,7 @@ FetchContent_MakeAvailable(godot-cpp) add_library(gua-godot SHARED src/gua_context.cpp + src/spatial_host.cpp src/register_types.cpp ) diff --git a/native/gua-godot/include/gua/godot/spatial_host.hpp b/native/gua-godot/include/gua/godot/spatial_host.hpp new file mode 100644 index 00000000..d895d2b1 --- /dev/null +++ b/native/gua-godot/include/gua/godot/spatial_host.hpp @@ -0,0 +1,34 @@ +#pragma once +#include "gua/spatial_host.h" +#include +#include +#include + +namespace godot { +// Trusted in-process host API. Deliberately absent from GuaContext/bridge/MCP. +class GuaSpatialHost : public RefCounted { + GDCLASS(GuaSpatialHost, RefCounted) + gua_spatial_host_t* host_ = nullptr; + static Dictionary reply(int status, uint64_t handle = 0); + Dictionary document_call(int operation, uint64_t handle, const String& json); +protected: + static void _bind_methods(); +public: + ~GuaSpatialHost(); + Dictionary configure(const String& clock_id, int providers, int owners, int queue, + int queries, int hits, double deadline_ms, double work_ms); + Dictionary register_provider(const String& json); + Dictionary open_owner(const String& json); + Dictionary set_owner(uint64_t owner, const String& json); + Dictionary enqueue(uint64_t owner, const String& json); + Dictionary begin(uint64_t provider, const String& json); + Dictionary complete(uint64_t lease, const String& json); + Dictionary check_engine_bounds(uint64_t lease, double min_x, double min_y, double min_z, double max_x, double max_y, double max_z); + Dictionary thread_cpu_time(); + Dictionary unregister_provider(uint64_t provider); + Dictionary close_owner(uint64_t owner); + Dictionary end(uint64_t lease); + Dictionary take(uint64_t lease); + Dictionary poll(uint64_t owner, uint64_t batch); +}; +} diff --git a/native/gua-godot/src/register_types.cpp b/native/gua-godot/src/register_types.cpp index ef6fea21..80e66035 100644 --- a/native/gua-godot/src/register_types.cpp +++ b/native/gua-godot/src/register_types.cpp @@ -1,6 +1,7 @@ #include "gua/godot/register_types.hpp" #include "gua/godot/gua_context.hpp" +#include "gua/godot/spatial_host.hpp" #include @@ -15,6 +16,7 @@ void initialize_gua_godot_module(ModuleInitializationLevel level) } GDREGISTER_CLASS(GuaContext); + GDREGISTER_CLASS(GuaSpatialHost); } void uninitialize_gua_godot_module(ModuleInitializationLevel level) diff --git a/native/gua-godot/src/spatial_host.cpp b/native/gua-godot/src/spatial_host.cpp new file mode 100644 index 00000000..78ec2a06 --- /dev/null +++ b/native/gua-godot/src/spatial_host.cpp @@ -0,0 +1,103 @@ +#include "gua/godot/spatial_host.hpp" +#include "gua/godot/copy_json.hpp" +#include +#include +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif +namespace godot { +Dictionary GuaSpatialHost::reply(int status, uint64_t handle) { + Dictionary result; result["status"] = status; + if (status == 0 && handle != 0 && handle <= static_cast(std::numeric_limits::max())) result["handle"] = static_cast(handle); + return result; +} +GuaSpatialHost::~GuaSpatialHost() { gua_spatial_host_destroy(host_); } +Dictionary GuaSpatialHost::thread_cpu_time() { + auto result=reply(GUA_SPATIAL_UNSUPPORTED); +#ifdef _WIN32 + FILETIME created{},exited{},kernel{},user{}; + if(GetThreadTimes(GetCurrentThread(),&created,&exited,&kernel,&user)) { + const uint64_t k=(uint64_t(kernel.dwHighDateTime)<<32)|kernel.dwLowDateTime; + const uint64_t u=(uint64_t(user.dwHighDateTime)<<32)|user.dwLowDateTime; + result["status"]=GUA_SPATIAL_OK; result["microseconds"]=double(k+u)/10.0; + ULONG64 cycles=0; + if(QueryThreadCycleTime(GetCurrentThread(),&cycles)&&cycles<=static_cast(std::numeric_limits::max())) result["cycles"]=static_cast(cycles); + } +#endif + return result; +} +Dictionary GuaSpatialHost::configure(const String& clock, int providers, int owners, int queue, + int queries, int hits, double deadline, double work) { + if (host_ || providers <= 0 || owners <= 0 || queue <= 0 || queries <= 0 || hits <= 0) return reply(GUA_SPATIAL_INVALID); + const gua_spatial_host_options_v1_t options{sizeof(options), static_cast(providers), static_cast(owners), + static_cast(queue), static_cast(queries), static_cast(hits), deadline, work}; + const auto utf8 = clock.utf8(); gua_spatial_error_t error{}; + return reply(gua_spatial_host_create(&options, {utf8.get_data(), static_cast(utf8.length())}, &host_, &error)); +} +Dictionary GuaSpatialHost::document_call(int operation, uint64_t handle, const String& json) { + if (!host_) return reply(GUA_SPATIAL_STALE); + int type = GUA_SPATIAL_EXECUTION; + if (operation == 0) type = GUA_SPATIAL_REGISTRATION; + if (operation == 1 || operation == 2) type = GUA_SPATIAL_OWNER; + if (operation == 3) type = GUA_SPATIAL_BATCH; + if (operation == 4) type = GUA_SPATIAL_BOUNDARY; + const gua_spatial_parse_options_v1_t options{sizeof(options), type}; + const auto utf8 = json.utf8(); gua_spatial_document_t* doc = nullptr; gua_spatial_error_t error{}; + int status = gua_spatial_from_json(&options, {utf8.get_data(), static_cast(utf8.length())}, &doc, &error); + if (status != 0) return reply(status); + uint64_t output = 0; + switch (operation) { + case 0: status = gua_spatial_host_register(host_, doc, &output, &error); break; + case 1: status = gua_spatial_host_open_owner(host_, doc, &output, &error); break; + case 2: status = gua_spatial_host_set_owner(host_, handle, doc, &error); break; + case 3: status = gua_spatial_host_enqueue(host_, handle, doc, &error); break; + case 4: status = gua_spatial_host_begin(host_, handle, doc, &output, &error); break; + default: status = gua_spatial_host_complete(host_, handle, doc, &error); break; + } + gua_spatial_destroy(doc); return reply(status, output); +} +Dictionary GuaSpatialHost::register_provider(const String& json) { return document_call(0, 0, json); } +Dictionary GuaSpatialHost::open_owner(const String& json) { return document_call(1, 0, json); } +Dictionary GuaSpatialHost::set_owner(uint64_t owner, const String& json) { return document_call(2, owner, json); } +Dictionary GuaSpatialHost::enqueue(uint64_t owner, const String& json) { return document_call(3, owner, json); } +Dictionary GuaSpatialHost::begin(uint64_t provider, const String& json) { return document_call(4, provider, json); } +Dictionary GuaSpatialHost::complete(uint64_t lease, const String& json) { return document_call(5, lease, json); } +Dictionary GuaSpatialHost::check_engine_bounds(uint64_t lease, double min_x, double min_y, double min_z, double max_x, double max_y, double max_z) { + const gua_spatial_engine_bounds_v1_t bounds{sizeof(bounds), min_x, min_y, min_z, max_x, max_y, max_z}; + gua_spatial_error_t error{}; uint32_t loaded=0; + auto result=reply(host_?gua_spatial_host_check_engine_bounds(host_,lease,&bounds,&loaded,&error):GUA_SPATIAL_STALE); + result["loaded_complete"]=loaded!=0; return result; +} +Dictionary GuaSpatialHost::unregister_provider(uint64_t p) { gua_spatial_error_t e{}; return reply(host_ ? gua_spatial_host_unregister(host_, p, &e) : GUA_SPATIAL_STALE); } +Dictionary GuaSpatialHost::close_owner(uint64_t o) { gua_spatial_error_t e{}; return reply(host_ ? gua_spatial_host_close_owner(host_, o, &e) : GUA_SPATIAL_STALE); } +Dictionary GuaSpatialHost::end(uint64_t l) { gua_spatial_error_t e{}; return reply(host_ ? gua_spatial_host_end(host_, l, &e) : GUA_SPATIAL_STALE); } +static Dictionary copied(int status, gua_spatial_document_t* doc) { + Dictionary result; result["status"] = status; + if (status == 0 && doc) { + const auto json = gua::godot_detail::copy_json_with_retry([&](char* buffer, int capacity) { return gua_spatial_copy_json(doc, buffer, capacity); }); + result["json"] = String::utf8(json.c_str()); + } + gua_spatial_destroy(doc); return result; +} +Dictionary GuaSpatialHost::take(uint64_t l) { gua_spatial_error_t e{}; gua_spatial_document_t* d = nullptr; const int s = host_ ? gua_spatial_host_take(host_, l, &d, &e) : GUA_SPATIAL_STALE; return copied(s, d); } +Dictionary GuaSpatialHost::poll(uint64_t o, uint64_t b) { gua_spatial_error_t e{}; gua_spatial_document_t* d = nullptr; const int s = host_ ? gua_spatial_host_poll(host_, o, b, &d, &e) : GUA_SPATIAL_STALE; return copied(s, d); } +void GuaSpatialHost::_bind_methods() { + ClassDB::bind_method(D_METHOD("thread_cpu_time"), &GuaSpatialHost::thread_cpu_time); + ClassDB::bind_method(D_METHOD("configure", "clock_id", "providers", "owners", "queue", "queries", "hits", "deadline_ms", "work_ms"), &GuaSpatialHost::configure); + ClassDB::bind_method(D_METHOD("register_provider", "json"), &GuaSpatialHost::register_provider); + ClassDB::bind_method(D_METHOD("open_owner", "json"), &GuaSpatialHost::open_owner); + ClassDB::bind_method(D_METHOD("set_owner", "owner", "json"), &GuaSpatialHost::set_owner); + ClassDB::bind_method(D_METHOD("enqueue", "owner", "json"), &GuaSpatialHost::enqueue); + ClassDB::bind_method(D_METHOD("begin", "provider", "json"), &GuaSpatialHost::begin); + ClassDB::bind_method(D_METHOD("complete", "lease", "json"), &GuaSpatialHost::complete); + ClassDB::bind_method(D_METHOD("check_engine_bounds", "lease", "min_x", "min_y", "min_z", "max_x", "max_y", "max_z"), &GuaSpatialHost::check_engine_bounds); + ClassDB::bind_method(D_METHOD("unregister_provider", "provider"), &GuaSpatialHost::unregister_provider); + ClassDB::bind_method(D_METHOD("close_owner", "owner"), &GuaSpatialHost::close_owner); + ClassDB::bind_method(D_METHOD("end", "lease"), &GuaSpatialHost::end); + ClassDB::bind_method(D_METHOD("take", "lease"), &GuaSpatialHost::take); + ClassDB::bind_method(D_METHOD("poll", "owner", "batch"), &GuaSpatialHost::poll); +} +} diff --git a/protocol/fixtures/spatial-engine-r1.json b/protocol/fixtures/spatial-engine-r1.json new file mode 100644 index 00000000..b6572c24 --- /dev/null +++ b/protocol/fixtures/spatial-engine-r1.json @@ -0,0 +1,2207 @@ +{ + "configuration": { + "godot": "4.7.stable.official.5b4e0cb0f", + "godotBackend": "GodotPhysics3D", + "unity": "6000.5.3f1", + "unityBackend": "PhysX", + "os": "Windows x64", + "tickHz": 60, + "geometryTolerance": 0.001, + "policyMargin": 0, + "expectations": "Fixed analytic occupancy for boxes; engine-specific internal-start ray and boundary contact do not certify normals or penetration.", + "godotBackends": [ + "GodotPhysics3D", + "Jolt Physics" + ], + "profiling": { + "framesPerPhase": 150, + "warmupFrames": 30, + "samplesPerPhase": 120, + "phases": [ + 0, + 1, + 16 + ], + "gameWork": "2000 sine accumulations per normal physics callback", + "cpuClock": "Windows GetThreadTimes; 100 ns representation, coarser scheduler accounting", + "godotMaxRenderFps": 60 + }, + "policies": { + "solid": "layer 1 Godot/layer 8 Unity; bodies; trigger ignore; backfaces false; margin 0", + "triggers": "same eligible layer; trigger include; backfaces false; margin 0", + "self": "layer 2 Godot/layer 9 Unity excluded before detection" + } + }, + "cases": [ + { + "id": "clear", + "obstacles": [], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 1, + "sessionEpoch": 1, + "queryId": "clear", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "sweep", + "shape": { + "type": "sphere", + "center": { + "x": 0, + "y": 0, + "z": -2 + }, + "radius": 0.25 + }, + "delta": { + "x": 0, + "y": 0, + "z": 4 + } + }, + "outcome": "clear", + "truncated": false, + "loaded": true + }, + { + "id": "blocked", + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 2, + "sessionEpoch": 1, + "queryId": "blocked", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "sweep", + "shape": { + "type": "sphere", + "center": { + "x": 0, + "y": 0, + "z": -2 + }, + "radius": 0.25 + }, + "delta": { + "x": 0, + "y": 0, + "z": 4 + } + }, + "outcome": "blocked", + "truncated": false, + "loaded": true + }, + { + "id": "initial", + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 3, + "sessionEpoch": 1, + "queryId": "initial", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "sweep", + "shape": { + "type": "sphere", + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "radius": 0.5 + }, + "delta": { + "x": 0, + "y": 0, + "z": 4 + } + }, + "outcome": "initialOverlap", + "truncated": false, + "loaded": true + }, + { + "id": "zero-clear", + "obstacles": [], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 4, + "sessionEpoch": 1, + "queryId": "zero-clear", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "sweep", + "shape": { + "type": "sphere", + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "radius": 0.5 + }, + "delta": { + "x": 0, + "y": 0, + "z": 0 + } + }, + "outcome": "clear", + "truncated": false, + "loaded": true + }, + { + "id": "zero-initial", + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 5, + "sessionEpoch": 1, + "queryId": "zero-initial", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "sweep", + "shape": { + "type": "sphere", + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "radius": 0.5 + }, + "delta": { + "x": 0, + "y": 0, + "z": 0 + } + }, + "outcome": "initialOverlap", + "truncated": false, + "loaded": true + }, + { + "id": "gap-ray", + "obstacles": [ + { + "center": { + "x": -1.25, + "y": 0, + "z": 0 + }, + "size": { + "x": 1.5, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 1.25, + "y": 0, + "z": 0 + }, + "size": { + "x": 1.5, + "y": 4, + "z": 0.5 + }, + "category": "solid" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 6, + "sessionEpoch": 1, + "queryId": "gap-ray", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "raycast", + "segment": { + "from": { + "x": 0, + "y": 0, + "z": -2 + }, + "to": { + "x": 0, + "y": 0, + "z": 2 + } + } + }, + "outcome": "noHit", + "truncated": false, + "loaded": true + }, + { + "id": "gap-capsule", + "obstacles": [ + { + "center": { + "x": -1.25, + "y": 0, + "z": 0 + }, + "size": { + "x": 1.5, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 1.25, + "y": 0, + "z": 0 + }, + "size": { + "x": 1.5, + "y": 4, + "z": 0.5 + }, + "category": "solid" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 7, + "sessionEpoch": 1, + "queryId": "gap-capsule", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "sweep", + "shape": { + "type": "capsule", + "pointA": { + "x": 0, + "y": -0.5, + "z": -2 + }, + "pointB": { + "x": 0, + "y": 0.5, + "z": -2 + }, + "radius": 0.75 + }, + "delta": { + "x": 0, + "y": 0, + "z": 4 + } + }, + "outcome": "blocked", + "truncated": false, + "loaded": true + }, + { + "id": "capsule-total-height", + "obstacles": [ + { + "center": { + "x": 0, + "y": 1.125, + "z": 0 + }, + "size": { + "x": 4, + "y": 0.5, + "z": 4 + }, + "category": "solid" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 8, + "sessionEpoch": 1, + "queryId": "capsule-total-height", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "overlap", + "shape": { + "type": "capsule", + "pointA": { + "x": 0, + "y": -0.5, + "z": 0 + }, + "pointB": { + "x": 0, + "y": 0.5, + "z": 0 + }, + "radius": 0.5 + } + }, + "outcome": "detected", + "truncated": false, + "loaded": true + }, + { + "id": "capsule-horizontal", + "obstacles": [ + { + "center": { + "x": 0, + "y": 1.125, + "z": 0 + }, + "size": { + "x": 4, + "y": 0.5, + "z": 4 + }, + "category": "solid" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 9, + "sessionEpoch": 1, + "queryId": "capsule-horizontal", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "overlap", + "shape": { + "type": "capsule", + "pointA": { + "x": -0.5, + "y": 0, + "z": 0 + }, + "pointB": { + "x": 0.5, + "y": 0, + "z": 0 + }, + "radius": 0.5 + } + }, + "outcome": "notDetected", + "truncated": false, + "loaded": true + }, + { + "id": "capsule-point", + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 10, + "sessionEpoch": 1, + "queryId": "capsule-point", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "overlap", + "shape": { + "type": "capsule", + "pointA": { + "x": 0, + "y": 0, + "z": 0 + }, + "pointB": { + "x": 0, + "y": 0, + "z": 0 + }, + "radius": 0.5 + } + }, + "outcome": "detected", + "truncated": false, + "loaded": true + }, + { + "id": "box-x", + "obstacles": [ + { + "center": { + "x": 1, + "y": 0, + "z": 0 + }, + "size": { + "x": 0.5, + "y": 4, + "z": 4 + }, + "category": "solid" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 11, + "sessionEpoch": 1, + "queryId": "box-x", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "overlap", + "shape": { + "type": "box", + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "halfExtents": { + "x": 1, + "y": 0.25, + "z": 0.25 + }, + "basis": { + "x": { + "x": 1, + "y": 0, + "z": 0 + }, + "y": { + "x": 0, + "y": 1, + "z": 0 + }, + "z": { + "x": 0, + "y": 0, + "z": 1 + } + } + } + }, + "outcome": "detected", + "truncated": false, + "loaded": true + }, + { + "id": "box-z", + "obstacles": [ + { + "center": { + "x": 1, + "y": 0, + "z": 0 + }, + "size": { + "x": 0.5, + "y": 4, + "z": 4 + }, + "category": "solid" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 12, + "sessionEpoch": 1, + "queryId": "box-z", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "overlap", + "shape": { + "type": "box", + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "halfExtents": { + "x": 1, + "y": 0.25, + "z": 0.25 + }, + "basis": { + "x": { + "x": 0, + "y": 0, + "z": -1 + }, + "y": { + "x": 0, + "y": 1, + "z": 0 + }, + "z": { + "x": 1, + "y": 0, + "z": 0 + } + } + } + }, + "outcome": "notDetected", + "truncated": false, + "loaded": true + }, + { + "id": "inside-ray", + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 13, + "sessionEpoch": 1, + "queryId": "inside-ray", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "raycast", + "segment": { + "from": { + "x": 0, + "y": 0, + "z": 0 + }, + "to": { + "x": 0, + "y": 0, + "z": 2 + } + } + }, + "outcome": "engine-specific", + "truncated": false, + "loaded": true + }, + { + "id": "self-behind-wall", + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": -1 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.25 + }, + "category": "self" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 14, + "sessionEpoch": 1, + "queryId": "self-behind-wall", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "raycast", + "segment": { + "from": { + "x": 0, + "y": 0, + "z": -2 + }, + "to": { + "x": 0, + "y": 0, + "z": 2 + } + } + }, + "outcome": "hit", + "truncated": false, + "loaded": true + }, + { + "id": "trigger-ignore", + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "trigger" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 15, + "sessionEpoch": 1, + "queryId": "trigger-ignore", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "raycast", + "segment": { + "from": { + "x": 0, + "y": 0, + "z": -2 + }, + "to": { + "x": 0, + "y": 0, + "z": 2 + } + } + }, + "outcome": "noHit", + "truncated": false, + "loaded": true + }, + { + "id": "trigger-include", + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "trigger" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 16, + "sessionEpoch": 1, + "queryId": "trigger-include", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "triggers", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "raycast", + "segment": { + "from": { + "x": 0, + "y": 0, + "z": -2 + }, + "to": { + "x": 0, + "y": 0, + "z": 2 + } + } + }, + "outcome": "hit", + "truncated": false, + "loaded": true + }, + { + "id": "saturation", + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + }, + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0.5 + }, + "category": "solid" + } + ], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 17, + "sessionEpoch": 1, + "queryId": "saturation", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "overlap", + "shape": { + "type": "sphere", + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "radius": 0.5 + } + }, + "outcome": "detected", + "truncated": true, + "loaded": true + }, + { + "id": "unloaded", + "obstacles": [], + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 18, + "sessionEpoch": 1, + "queryId": "unloaded", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "sweep", + "shape": { + "type": "sphere", + "center": { + "x": 0, + "y": 0, + "z": -2 + }, + "radius": 0.25 + }, + "delta": { + "x": 0, + "y": 0, + "z": 4 + } + }, + "outcome": "indeterminate", + "truncated": false, + "loaded": false + }, + { + "id": "floor-contact", + "truncated": false, + "loaded": true, + "obstacles": [ + { + "category": "solid", + "size": { + "z": 8, + "x": 8, + "y": 1 + }, + "center": { + "z": 0, + "x": 0, + "y": -1 + } + } + ], + "outcome": "engine-specific", + "query": { + "spaceEpoch": 1, + "deadlineMs": 1000, + "documentType": "request", + "maxHits": 2, + "queryId": "floor-contact", + "queryPolicyId": "solid", + "consistency": "samePhysicsSample", + "shape": { + "type": "sphere", + "radius": 0.5, + "center": { + "z": 0, + "x": 0, + "y": 0 + } + }, + "spaceId": "fixture", + "kind": "overlap", + "sessionEpoch": 1, + "requestId": 19, + "schemaVersion": "spatial-r1" + } + }, + { + "id": "door-transition", + "doorOpenOutcome": "noHit", + "loaded": true, + "obstacles": [ + { + "category": "solid", + "size": { + "z": 0.5, + "x": 4, + "y": 4 + }, + "center": { + "z": 0, + "x": 0, + "y": 0 + } + } + ], + "truncated": false, + "outcome": "hit", + "query": { + "spaceEpoch": 1, + "deadlineMs": 1000, + "documentType": "request", + "maxHits": 2, + "queryId": "door-transition", + "queryPolicyId": "solid", + "consistency": "samePhysicsSample", + "spaceId": "fixture", + "kind": "raycast", + "sessionEpoch": 1, + "segment": { + "to": { + "z": 2, + "x": 0, + "y": 0 + }, + "from": { + "z": -2, + "x": 0, + "y": 0 + } + }, + "requestId": 20, + "schemaVersion": "spatial-r1" + } + }, + { + "id": "diagonal-sphere-blocked", + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 21, + "sessionEpoch": 1, + "queryId": "diagonal-sphere-blocked", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "sweep", + "shape": { + "type": "sphere", + "center": { + "x": -2, + "y": -2, + "z": 0 + }, + "radius": 0.2 + }, + "delta": { + "x": 4, + "y": 4, + "z": 0 + } + }, + "outcome": "blocked", + "truncated": false, + "loaded": true, + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 0.5, + "y": 4, + "z": 4 + }, + "category": "solid" + } + ] + }, + { + "id": "diagonal-sphere-clear", + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 22, + "sessionEpoch": 1, + "queryId": "diagonal-sphere-clear", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "sweep", + "shape": { + "type": "sphere", + "center": { + "x": -2, + "y": -2, + "z": 0 + }, + "radius": 0.2 + }, + "delta": { + "x": 4, + "y": 4, + "z": 0 + } + }, + "outcome": "clear", + "truncated": false, + "loaded": true, + "obstacles": [] + }, + { + "id": "diagonal-capsule-blocked", + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 23, + "sessionEpoch": 1, + "queryId": "diagonal-capsule-blocked", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "sweep", + "shape": { + "type": "capsule", + "pointA": { + "x": -2.25, + "y": -2.25, + "z": 0 + }, + "pointB": { + "x": -1.75, + "y": -1.75, + "z": 0 + }, + "radius": 0.2 + }, + "delta": { + "x": 4, + "y": 4, + "z": 0 + } + }, + "outcome": "blocked", + "truncated": false, + "loaded": true, + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 0.5, + "y": 4, + "z": 4 + }, + "category": "solid" + } + ] + }, + { + "id": "diagonal-capsule-clear", + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 24, + "sessionEpoch": 1, + "queryId": "diagonal-capsule-clear", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "sweep", + "shape": { + "type": "capsule", + "pointA": { + "x": -2.25, + "y": -2.25, + "z": 0 + }, + "pointB": { + "x": -1.75, + "y": -1.75, + "z": 0 + }, + "radius": 0.2 + }, + "delta": { + "x": 4, + "y": 4, + "z": 0 + } + }, + "outcome": "clear", + "truncated": false, + "loaded": true, + "obstacles": [] + }, + { + "id": "diagonal-box-blocked", + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 25, + "sessionEpoch": 1, + "queryId": "diagonal-box-blocked", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "sweep", + "shape": { + "type": "box", + "center": { + "x": -2, + "y": -2, + "z": 0 + }, + "halfExtents": { + "x": 0.3, + "y": 0.1, + "z": 0.2 + }, + "basis": { + "x": { + "x": 0.7071067811865476, + "y": 0.7071067811865476, + "z": 0 + }, + "y": { + "x": -0.7071067811865476, + "y": 0.7071067811865476, + "z": 0 + }, + "z": { + "x": 0, + "y": 0, + "z": 1 + } + } + }, + "delta": { + "x": 4, + "y": 4, + "z": 0 + } + }, + "outcome": "blocked", + "truncated": false, + "loaded": true, + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 0.5, + "y": 4, + "z": 4 + }, + "category": "solid" + } + ] + }, + { + "id": "diagonal-box-clear", + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 26, + "sessionEpoch": 1, + "queryId": "diagonal-box-clear", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "sweep", + "shape": { + "type": "box", + "center": { + "x": -2, + "y": -2, + "z": 0 + }, + "halfExtents": { + "x": 0.3, + "y": 0.1, + "z": 0.2 + }, + "basis": { + "x": { + "x": 0.7071067811865476, + "y": 0.7071067811865476, + "z": 0 + }, + "y": { + "x": -0.7071067811865476, + "y": 0.7071067811865476, + "z": 0 + }, + "z": { + "x": 0, + "y": 0, + "z": 1 + } + } + }, + "delta": { + "x": 4, + "y": 4, + "z": 0 + } + }, + "outcome": "clear", + "truncated": false, + "loaded": true, + "obstacles": [] + }, + { + "id": "diagonal-ray", + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 27, + "sessionEpoch": 1, + "queryId": "diagonal-ray", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "raycast", + "segment": { + "from": { + "x": -2, + "y": -2, + "z": 0 + }, + "to": { + "x": 2, + "y": 2, + "z": 0 + } + } + }, + "outcome": "hit", + "truncated": false, + "loaded": true, + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 0.5, + "y": 4, + "z": 4 + }, + "category": "solid" + } + ] + }, + { + "id": "capsule-diagonal-height", + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 28, + "sessionEpoch": 1, + "queryId": "capsule-diagonal-height", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "overlap", + "shape": { + "type": "capsule", + "pointA": { + "x": -0.5, + "y": -0.5, + "z": 0 + }, + "pointB": { + "x": 0.5, + "y": 0.5, + "z": 0 + }, + "radius": 0.25 + } + }, + "outcome": "detected", + "truncated": false, + "loaded": true, + "obstacles": [ + { + "center": { + "x": 0.55, + "y": 0.55, + "z": 0 + }, + "size": { + "x": 0.2, + "y": 0.2, + "z": 0.2 + }, + "category": "solid" + } + ] + }, + { + "id": "box-45-occupied", + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 29, + "sessionEpoch": 1, + "queryId": "box-45-occupied", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "overlap", + "shape": { + "type": "box", + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "halfExtents": { + "x": 1, + "y": 0.1, + "z": 0.1 + }, + "basis": { + "x": { + "x": 0.7071067811865476, + "y": 0.7071067811865476, + "z": 0 + }, + "y": { + "x": -0.7071067811865476, + "y": 0.7071067811865476, + "z": 0 + }, + "z": { + "x": 0, + "y": 0, + "z": 1 + } + } + } + }, + "outcome": "detected", + "truncated": false, + "loaded": true, + "obstacles": [ + { + "center": { + "x": 0.65, + "y": 0.65, + "z": 0 + }, + "size": { + "x": 0.1, + "y": 0.1, + "z": 0.1 + }, + "category": "solid" + } + ] + }, + { + "id": "box-left-handed", + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 30, + "sessionEpoch": 1, + "queryId": "box-left-handed", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "overlap", + "shape": { + "type": "box", + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "halfExtents": { + "x": 1, + "y": 0.1, + "z": 0.1 + }, + "basis": { + "x": { + "x": -0.7071067811865476, + "y": -0.7071067811865476, + "z": 0 + }, + "y": { + "x": -0.7071067811865476, + "y": 0.7071067811865476, + "z": 0 + }, + "z": { + "x": 0, + "y": 0, + "z": 1 + } + } + } + }, + "outcome": "detected", + "truncated": false, + "loaded": true, + "obstacles": [ + { + "center": { + "x": 0.65, + "y": 0.65, + "z": 0 + }, + "size": { + "x": 0.1, + "y": 0.1, + "z": 0.1 + }, + "category": "solid" + } + ] + }, + { + "id": "rounded-coordinates", + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 31, + "sessionEpoch": 1, + "queryId": "rounded-coordinates", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "overlap", + "shape": { + "type": "sphere", + "center": { + "x": 0.1, + "y": 0.2, + "z": 0.3 + }, + "radius": 0.1 + } + }, + "outcome": "detected", + "truncated": false, + "loaded": true, + "obstacles": [ + { + "center": { + "x": 0.1, + "y": 0.2, + "z": 0.3 + }, + "size": { + "x": 0.1, + "y": 0.1, + "z": 0.1 + }, + "category": "solid" + } + ] + }, + { + "id": "mesh-front-ray", + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 32, + "sessionEpoch": 1, + "queryId": "mesh-front-ray", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "raycast", + "segment": { + "from": { + "x": 0, + "y": 0, + "z": -2 + }, + "to": { + "x": 0, + "y": 0, + "z": 2 + } + } + }, + "outcome": "hit", + "truncated": false, + "loaded": true, + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0 + }, + "category": "solid", + "mesh": true + } + ] + }, + { + "id": "mesh-back-ray", + "query": { + "schemaVersion": "spatial-r1", + "documentType": "request", + "requestId": 33, + "sessionEpoch": 1, + "queryId": "mesh-back-ray", + "spaceId": "fixture", + "spaceEpoch": 1, + "queryPolicyId": "solid", + "deadlineMs": 1000, + "consistency": "samePhysicsSample", + "maxHits": 2, + "kind": "raycast", + "segment": { + "from": { + "x": 0, + "y": 0, + "z": 2 + }, + "to": { + "x": 0, + "y": 0, + "z": -2 + } + } + }, + "outcome": "noHit", + "truncated": false, + "loaded": true, + "obstacles": [ + { + "center": { + "x": 0, + "y": 0, + "z": 0 + }, + "size": { + "x": 4, + "y": 4, + "z": 0 + }, + "category": "solid", + "mesh": true + } + ] + } + ] +} diff --git a/protocol/specs/spatial-host-r1.md b/protocol/specs/spatial-host-r1.md index 8f0df9f7..85fd1fd5 100644 --- a/protocol/specs/spatial-host-r1.md +++ b/protocol/specs/spatial-host-r1.md @@ -141,3 +141,16 @@ No bridge, MCP, Inspector, WebMCP, Trace or engine advertises spatial execution through this change. Existing r1 offline consumers and runtime UI/World/input paths are unaffected. #133 must supply real engine evidence; #134 must preserve this ownership and authorization contract across transport correlations. + +## Trusted prepared engine bounds + +The additive C ABI `gua_spatial_host_check_engine_bounds` accepts a finite, +ordered binary64 AABB after Take and before physics, at most once per item. +The trusted adapter must conservatively include the original query and the +actual prepared engine shape with its full translation. The host requires +containment of its outward-rounded original bounds and the current owner and +policy grants. Failure grants no additional authority. Accepted bounds remain +bounded per batch and are reauthorized at completion, owner-policy revocation +and public polling; complete coverage must contain them as well. Older adapters +that do not call this function retain the original r1 behavior. This host-only +API adds no wire document fields, transport exposure or backend precision claim. diff --git a/scripts/run-spatial-engine-fixtures.ps1 b/scripts/run-spatial-engine-fixtures.ps1 new file mode 100644 index 00000000..b8871431 --- /dev/null +++ b/scripts/run-spatial-engine-fixtures.ps1 @@ -0,0 +1,69 @@ +param( + [string]$GodotExecutable = $env:GODOT_EXECUTABLE, + [string]$UnityExecutable = 'C:\Program Files\Unity\Hub\Editor\6000.5.3f1\Editor\Unity.exe', + [string]$GeneratorInstance = 'C:/Program Files/Microsoft Visual Studio/18/Community,version=18.10.12217.157', + [string]$GodotCppSource = '', + [string]$GodotBackend = "GodotPhysics3D", + [int]$TimeoutSeconds = 180 +) +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +if (!(Test-Path -LiteralPath $GodotExecutable) -or !(Test-Path -LiteralPath $UnityExecutable)) { throw 'Both pinned real engine executables are required.' } +if ($TimeoutSeconds -le 0) { throw 'Timeout must be positive.' } +Push-Location $root +try { + $cmakeArgs = @('-S','.', '-B','build/spatial-godot','-G','Visual Studio 18 2026','-A','x64', + "-DCMAKE_GENERATOR_INSTANCE=$GeneratorInstance",'-DGUA_BUILD_GODOT=ON','-DGUA_BUILD_WS_BRIDGE=OFF', + '-DGUA_BUILD_EXAMPLES=OFF','-DGUA_GODOT_CONFIGURATION=Release') + if ($GodotCppSource) { $cmakeArgs += "-DFETCHCONTENT_SOURCE_DIR_GODOT-CPP=$GodotCppSource" } + & cmake @cmakeArgs + if ($LASTEXITCODE) { throw 'CMake configure failed.' } + & cmake --build build/spatial-godot --config Release --target gua gua-godot --parallel 4 + if ($LASTEXITCODE) { throw 'Native engine fixture build failed.' } + & dotnet build bindings/dotnet/src/Gua.Core/Gua.Core.csproj -c Release -f netstandard2.1 + if ($LASTEXITCODE) { throw 'Managed fixture build failed.' } + $godot = Join-Path $root 'artifacts/godot-spatial' + $unity = Join-Path $root 'artifacts/unity-spatial' + New-Item -ItemType Directory -Force "$godot/addons/gua/bin", "$unity/Assets/Editor", "$unity/Assets/Plugins", "$unity/Packages", "$unity/ProjectSettings" | Out-Null + foreach ($prior in "$godot/evidence.json", "$unity/evidence.json", "$root/artifacts/godot-run.log", "$root/artifacts/unity-spatial.log") { + if (Test-Path -LiteralPath $prior) { Remove-Item -LiteralPath $prior -Force } + } + Copy-Item examples/spatial-fixtures/godot/* $godot -Force + $project = Get-Content "$godot/project.godot" -Raw + $project.Replace('3d/physics_engine="GodotPhysics3D"', "3d/physics_engine=`"$GodotBackend`"") | Set-Content "$godot/project.godot" + Copy-Item protocol/fixtures/spatial-engine-r1.json $godot -Force + Copy-Item examples/godot-gdscript/addons/gua/gua_spatial.gd "$godot/addons/gua" -Force + Copy-Item examples/godot-gdscript/addons/gua/gua.gdextension "$godot/addons/gua" -Force + $library = 'examples/godot-gdscript/addons/gua/bin/gua_godot.windows.release.x86_64.dll' + Copy-Item $library "$godot/addons/gua/bin" -Force + # Headless editor import uses the debug descriptor, with the same release ABI. + Copy-Item $library "$godot/addons/gua/bin/gua_godot.windows.debug.x86_64.dll" -Force + Copy-Item bindings/unity/Runtime/GuaUnitySpatial.cs "$unity/Assets" -Force + Copy-Item examples/spatial-fixtures/unity/SpatialProfile.cs "$unity/Assets" -Force + Copy-Item examples/spatial-fixtures/unity/SpatialFixture.cs "$unity/Assets/Editor" -Force + Copy-Item protocol/fixtures/spatial-engine-r1.json $unity -Force + Copy-Item bindings/dotnet/src/Gua.Core/bin/Release/netstandard2.1/Gua.Core.dll "$unity/Assets/Plugins" -Force + Copy-Item build/spatial-godot/native/gua-core/Release/gua.dll "$unity/Assets/Plugins" -Force + & "$PSScriptRoot/copy-unity-managed-closure.ps1" -AssetsFile bindings/dotnet/src/Gua.Core/obj/project.assets.json -TargetFramework netstandard2.1 -Destination "$unity/Assets/Plugins" + '{"dependencies":{"com.unity.modules.physics":"1.0.0"}}' | Set-Content "$unity/Packages/manifest.json" + 'm_EditorVersion: 6000.5.3f1' | Set-Content "$unity/ProjectSettings/ProjectVersion.txt" + function Invoke-Engine([string]$Executable, [string[]]$Arguments, [string]$Log) { + $engineProcess = Start-Process -FilePath $Executable -ArgumentList $Arguments -WindowStyle Hidden -PassThru + if (!$engineProcess.WaitForExit($TimeoutSeconds * 1000)) { + & "$env:SystemRoot/System32/taskkill.exe" /PID $engineProcess.Id /T /F | Out-Null + throw "Engine timed out. See $Log" + } + if ($engineProcess.ExitCode -ne 0) { throw "Engine failed ($($engineProcess.ExitCode)). See $Log" } + } + Invoke-Engine $GodotExecutable @('--headless','--path',"`"$godot`"",'--editor','--import','--quit','--log-file',"`"$root/artifacts/godot-import.log`"") "$root/artifacts/godot-import.log" + Invoke-Engine $GodotExecutable @('--headless','--path',"`"$godot`"",'--max-fps','60','--quit-after','10000','--log-file',"`"$root/artifacts/godot-run.log`"") "$root/artifacts/godot-run.log" + Invoke-Engine $UnityExecutable @('-batchmode','-nographics','-projectPath',"`"$unity`"",'-executeMethod','SpatialFixture.Run','-logFile',"`"$root/artifacts/unity-spatial.log`"") "$root/artifacts/unity-spatial.log" + foreach ($output in "$godot/evidence.json", "$unity/evidence.json") { + if (!(Test-Path -LiteralPath $output)) { throw "Missing real-engine evidence: $output" } + $evidence = Get-Content -LiteralPath $output -Raw | ConvertFrom-Json + if ($evidence.results.Count -ne (2 * (Get-Content protocol/fixtures/spatial-engine-r1.json -Raw | ConvertFrom-Json).cases.Count + 1)) { throw "Incomplete engine fixture evidence: $output" } + if ($evidence.profile.Count -ne 360) { throw "Incomplete real physics-callback profile: $output" } + if ($evidence.leaseRaces.Count -ne ((Get-Content protocol/fixtures/spatial-engine-r1.json -Raw | ConvertFrom-Json).cases.Count + 1)) { throw "Missing selective lease race evidence: $output" } + Write-Host "Real engine evidence: $output" + } +} finally { Pop-Location }