From 963595164c00ad0c273400f931ea7c0f644a1c24 Mon Sep 17 00:00:00 2001 From: link1345 Date: Thu, 1 Oct 2026 18:16:05 +0900 Subject: [PATCH 1/4] Prototype bounded Godot and Unity spatial readers with real geometry fixtures --- bindings/unity/Runtime/GuaUnitySpatial.cs | 283 +++ .../unity/Runtime/GuaUnitySpatial.cs.meta | 11 + docs/spatial-engine-r1.md | 131 ++ .../godot-gdscript/addons/gua/gua_spatial.gd | 257 +++ examples/spatial-fixtures/godot/fixture.gd | 202 +++ examples/spatial-fixtures/godot/fixture.tscn | 4 + examples/spatial-fixtures/godot/project.godot | 9 + .../spatial-fixtures/unity/SpatialFixture.cs | 202 +++ native/gua-godot/CMakeLists.txt | 1 + .../include/gua/godot/spatial_host.hpp | 32 + native/gua-godot/src/register_types.cpp | 2 + native/gua-godot/src/spatial_host.cpp | 75 + protocol/fixtures/spatial-engine-r1.json | 1515 +++++++++++++++++ scripts/run-spatial-engine-fixtures.ps1 | 63 + 14 files changed, 2787 insertions(+) create mode 100644 bindings/unity/Runtime/GuaUnitySpatial.cs create mode 100644 bindings/unity/Runtime/GuaUnitySpatial.cs.meta create mode 100644 docs/spatial-engine-r1.md create mode 100644 examples/godot-gdscript/addons/gua/gua_spatial.gd create mode 100644 examples/spatial-fixtures/godot/fixture.gd create mode 100644 examples/spatial-fixtures/godot/fixture.tscn create mode 100644 examples/spatial-fixtures/godot/project.godot create mode 100644 examples/spatial-fixtures/unity/SpatialFixture.cs create mode 100644 native/gua-godot/include/gua/godot/spatial_host.hpp create mode 100644 native/gua-godot/src/spatial_host.cpp create mode 100644 protocol/fixtures/spatial-engine-r1.json create mode 100644 scripts/run-spatial-engine-fixtures.ps1 diff --git a/bindings/unity/Runtime/GuaUnitySpatial.cs b/bindings/unity/Runtime/GuaUnitySpatial.cs new file mode 100644 index 00000000..3debae20 --- /dev/null +++ b/bindings/unity/Runtime/GuaUnitySpatial.cs @@ -0,0 +1,283 @@ +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 = Execute(query); + using (var execution = GuaSpatialDocument.FromExecution(result)) host.Complete(lease.Value, execution); + } + } + return true; + } + finally + { + try { if (lease.HasValue) host.End(lease.Value); } + finally { pumping = false; Array.Clear(casts, 0, casts.Length); Array.Clear(overlaps, 0, overlaps.Length); } + } + } + + static float F(double value) + { + var converted = (float)value; + if (float.IsInfinity(converted) || float.IsNaN(converted) || (double)converted != value || 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"); + } + } + + GuaSpatialCoverage Coverage(GuaSpatialRequest q) + { + var region = registration.LoadedRegion; + if (region != null && Contained(q, region)) + return new GuaSpatialCoverage { State = "complete", LoadedRegion = region }; + return new GuaSpatialCoverage { State = "unknown", Reason = "engine_coverage_unverified" }; + } + static bool Cardinal(GuaSpatialVector v) + => (v.X == 0 ? 0 : 1) + (v.Y == 0 ? 0 : 1) + (v.Z == 0 ? 0 : 1) <= 1; + static bool Contained(GuaSpatialRequest q, GuaSpatialRegion region) + { + var lo = q.Kind == "raycast" ? V(q.Segment.From) : q.Shape.Type == "capsule" ? Vector3.Min(V(q.Shape.PointA), V(q.Shape.PointB)) : V(q.Shape.Center); + var hi = q.Kind == "raycast" ? V(q.Segment.To) : q.Shape.Type == "capsule" ? Vector3.Max(V(q.Shape.PointA), V(q.Shape.PointB)) : lo; + var min = Vector3.Min(lo, hi); var max = Vector3.Max(lo, hi); + if (q.Kind != "raycast") + { + Vector3 extents; + if (q.Shape.Type == "box") + { + var b = q.Shape.Basis; var h = V(q.Shape.HalfExtents); + var x = V(b.X); var y = V(b.Y); var z = V(b.Z); + extents = new Vector3(Mathf.Abs(x.x)*h.x+Mathf.Abs(y.x)*h.y+Mathf.Abs(z.x)*h.z, + Mathf.Abs(x.y)*h.x+Mathf.Abs(y.y)*h.y+Mathf.Abs(z.y)*h.z, + Mathf.Abs(x.z)*h.x+Mathf.Abs(y.z)*h.y+Mathf.Abs(z.z)*h.z); + } + else extents = Vector3.one * F(q.Shape.Radius.Value); + min -= extents; max += extents; + if (q.Kind == "sweep") { var d = V(q.Delta); min = Vector3.Min(min, min + d); max = Vector3.Max(max, max + d); } + } + // Stay away from a rounded region boundary. Native host repeats the + // authoritative outward-rounded containment check at completion. + const double slack = 0.001; + return min.x > region.Min.X + slack && min.y > region.Min.Y + slack && min.z > region.Min.Z + slack + && max.x < region.Max.X - slack && max.y < region.Max.Y - slack && max.z < region.Max.Z - slack; + } + static bool ExactCardinal(GuaSpatialRequest q) + { + // An initial bounded subset avoids expanding an authorized binary64 + // shape by silently rounding it into engine floats. Arbitrary + // orientations/diagonal motion require an explicit precision contract. + if (q.Kind == "raycast") return Cardinal(new GuaSpatialVector(q.Segment.To.X-q.Segment.From.X, q.Segment.To.Y-q.Segment.From.Y, q.Segment.To.Z-q.Segment.From.Z)); + if (q.Kind == "sweep" && !Cardinal(q.Delta)) return false; + if (q.Shape.Type == "capsule" && !Cardinal(new GuaSpatialVector(q.Shape.PointB.X-q.Shape.PointA.X, q.Shape.PointB.Y-q.Shape.PointA.Y, q.Shape.PointB.Z-q.Shape.PointA.Z))) return false; + if (q.Shape.Type == "capsule") + { + var a=q.Shape.PointA; var b=q.Shape.PointB; + Func exact = n => Math.Abs(n)<=8192 && (double)(float)n==n; + if (!exact(a.X)||!exact(a.Y)||!exact(a.Z)||!exact(b.X)||!exact(b.Y)||!exact(b.Z)) return false; + var mid = new GuaSpatialVector((a.X+b.X)*0.5,(a.Y+b.Y)*0.5,(a.Z+b.Z)*0.5); + if (!exact(mid.X)||!exact(mid.Y)||!exact(mid.Z)) return false; + var engineMid=(V(a)+V(b))*0.5f; + if (engineMid.x!=mid.X || engineMid.y!=mid.Y || engineMid.z!=mid.Z) return false; + var length=Math.Abs(b.X-a.X)+Math.Abs(b.Y-a.Y)+Math.Abs(b.Z-a.Z); + if (Vector3.Distance(V(a),V(b))!=length || !exact(length+2*q.Shape.Radius.Value)) return false; + } + if (q.Shape.Type == "box") + { + var b = q.Shape.Basis; + return Cardinal(b.X) && Cardinal(b.Y) && Cardinal(b.Z) + && b.X.X*b.X.X+b.X.Y*b.X.Y+b.X.Z*b.X.Z == 1 + && b.Y.X*b.Y.X+b.Y.Y*b.Y.Y+b.Y.Z*b.Y.Z == 1 + && b.Z.X*b.Z.X+b.Z.Y*b.Z.Y+b.Z.Z*b.Z.Z == 1; + } + return true; + } + 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) + { + 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]; + if (!ExactCardinal(q)) + { r.Status = "unsupported"; r.Error = new GuaSpatialFailure(q.Kind == "raycast" ? "unsupported_operation" : "unsupported_shape"); return r; } + // 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 + { + r.Status = "completed"; r.Coverage = Coverage(q); 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 (ArgumentException) + { + return 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..ac68130b --- /dev/null +++ b/docs/spatial-engine-r1.md @@ -0,0 +1,131 @@ +# Spatial-03 engine prototype and real geometry evidence + +This branch is a bounded, opt-in engine prototype for #133, not completion of +the spatial feature. No runtime transport advertises spatial execution. Existing +UI, World, Input, Observe, Trace and strict legacy spatial-r1 APIs are unchanged. + +`GuaUnitySpatial` reads only its explicitly registered `PhysicsScene`. +`GuaSpatialReader` reads only its explicitly registered `World3D` direct state. +Both use the native spatial-host-r1 scheduler, ordered take/complete calls and +mandatory lease End. The host creates enabled Testing/Debug grants independently; +the reader cannot grant client authority. Player requests and entire shapes +crossing an owner region are rejected by the native host before physics. + +Unity requires the host to call Pump on the registering main thread in an +approved synchronized interval. It never calls SyncTransforms or Simulate. +The fixture calls SyncTransforms once after host obstacle creation/mutation, +then holds the single-threaded read interval while Pump executes one batch. +It omits tick and World snapshot because the Editor callback does not establish +either. Godot requires the main thread inside a physics frame; its fixture uses +`_physics_process`. It reports the actual engine physics callback counter, with +no inferred World snapshot. Sample IDs identify each held read interval, not a +claim that different intervals sharing a tick are simultaneous. + +Unity filters with host layer masks and explicit Ignore/Collide trigger flags. +Self colliders must occupy excluded layers; arbitrary post-query predicates +are unsupported because the host contract requires prefiltering. Unity lacks a +per-query backface flag: the reader checks that the explicitly selected host +backface value equals the current global value and refuses mismatches without +querying or changing global state. Godot uses explicit masks, bodies, +areas, backfaces and bounded host RID exclusions before detection. Shape margin +must be zero so it cannot enlarge the authorized volume. Three reusable Godot +shape resources belong exclusively to each reader; no live collider is edited. + +Overlap detection never establishes penetration or an exact contact normal. +The prototype publishes anonymous hits with explicit missing reasons for all +details. Godot sweep first performs overlap, then uses cast_motion fractions +with source and unknown backend error; it never substitutes rest_info from a +different collider. Capsule height is endpoint distance plus twice the radius, +and equal endpoints use a sphere. Box sizes are twice halfExtents. Unity uses +the corresponding overlap and cast APIs and a bounded reusable result array. +Full buffers remain truncated; available hits never establish policyRegion +nearest. No collision pointer/RID/instance ID becomes a public reference. + +The loaded AABB is a trusted host declaration, never inferred from empty World +metadata or empty queries. Readers conservatively classify containment with a +0.001-unit inward slack; the native host repeats its authoritative outward +containment check at publication. Outside/missing declarations remain unknown; +an otherwise empty sweep then returns indeterminate, not clear. The slack is +a conservative coverage classification rule, not measured collision precision. +Provider precision is binary32 with unknown absolute backend error. + +Exactly representable capsule endpoints are insufficient: derived midpoint, +endpoint distance and total height must also retain their engine representation. +The audit regression uses endpoints y=1 and y=1.0000001192092896, radius=0.25, +and an owner minimum y=0.74999998. The native host accepts the original volume, +whose minimum is 0.75, while a rounded Godot center/height would reach below +the grant. Both readers now reject this query before physics; the real-engine +fixtures assert a failed unsupported_shape item with no result geometry. + +Before destroying a scene or changing origin, the host must Dispose/unregister +the reader and register the replacement with a fresh spaceEpoch. Unity also +rejects invalid PhysicsScenes at Pump. Automatic origin-shift detection is not +provided. Retaining a Godot World3D reference does not substitute for this host +lifecycle obligation. + +## Reproduction and acceptance limits + +Run `scripts/run-spatial-engine-fixtures.ps1` with the pinned Godot and Unity +executables. Optional GodotCppSource points to the repository's pinned +godot-cpp checkout for offline builds. The script builds current native and +managed code, stages isolated projects under artifacts, then executes real +Godot and Unity Play Mode fixtures. No external Playtest dependency is used. +Engine versions, backend labels, OS, tick target, margin and tolerance are fixed +in `protocol/fixtures/spatial-engine-r1.json` before execution. Occupancy +expectations come from the fixed box geometry, not adapter output. Internal +start rays and exact boundary floor contact intentionally have backend-specific +detection, but must preserve unknown origin/normal/relation facts. + +The real Windows runs cover 20 shared scenarios plus the second state of one +moving door, 16-query batches, independent loaded worlds/scenes, Player denial, +whole-shape region denial, unregister and stale replacement epochs. Obstacles +have no semantic World registrations, so anonymous walls remain eligible. +Door transforms change only at approved host points; old/new results carry +different physics sample IDs (and different Godot physics ticks). + +| Combination | State | +| --- | --- | +| Godot 4.7 stable / GodotPhysics3D / Windows x64 / fixture primitive boxes | Real fixture evidence | +| Unity 6000.5.3f1 / PhysX / Windows x64 / Play Mode / fixture primitive boxes | Real fixture evidence | +| Exactly binary32-representable coordinates/dimensions, cardinal capsules and motion, signed cardinal box bases, coordinates/dimensions within 8192 units | Prototype subset | +| General diagonal motion, arbitrary box/capsule orientations, silently rounded binary64 geometry | Unsupported in this prototype | +| Godot nonzero margins; Unity mismatched global backfaces | Unsupported | +| Mesh obstacles, one-way collision, Jolt, nonuniform inferred collider scale, other OS/patch versions | Unverified; no support claim | +| Reliable normals, penetration depths, public collision/World IDs | Not supplied | +| General geometry precision/authorization design and full game-impact profiling | Remaining #133 work | + +Each fixture records pump wall time and enqueue-to-pump wall time for single +and 16-query batches. Unity also records coarse **whole-process** CPU deltas; +these include other Unity threads and may quantize to zero, so they are not +per-query CPU precision. Godot per-query CPU and baseline-subtracted gameplay +impact are not yet measured. A 60 Hz setting is not proof of sustained 60 fps. +One synchronous engine query cannot be interrupted; deadlines are cooperative. +Native host deadlines discard late evidence and stop remaining work; these +budgets do not make engine calls hard real-time. Do not close #133 or merge this +prototype as accepted until the remaining acceptance work is addressed. + +## Measured Windows checkpoint (2026-10-01) + +The current-source reproducible runner passed **41 records in each real +engine**. Four native spatial suites passed, followed by **101 managed +SpatialTests/SpatialHostTests** using GUA_NATIVE_DIR for the freshly built DLL. +The raw engine results/logs remain in ignored artifacts; generated output is +not committed. No fixture tolerance was relaxed after a failed geometry test. + +| Engine | Queries/batch | Timed records | Upper-median pump wall µs | Maximum pump wall µs | Upper-median enqueue-to-pump wall µs | +| --- | ---: | ---: | ---: | ---: | ---: | +| Godot 4.7 / GodotPhysics3D | 1 | 21 | 433 | 786 | 19628 | +| Godot 4.7 / GodotPhysics3D | 16 | 20 | 2963 | 4158 | 897 | +| Unity 6000.5.3f1 / PhysX | 1 | 20 | 340.7 | 4050.5 | 27.2 | +| Unity 6000.5.3f1 / PhysX | 16 | 20 | 3303.5 | 5137 | 256.9 | + +These are one local fixture run; the preliminary unsupported-geometry regression +now warms host serialization before the timed geometry queries. Unity's separately +recorded door-open state has no timing entry. Queue measurement begins before +enqueue authoring/validation and is an upper bound on core queue residence; +Godot singletons intentionally wait for the next normal physics boundary. +An earlier run before that warmup observed a 20071.4 µs Unity singleton maximum, +above a nominal 60 Hz interval. These are synchronous cooperative calls and +local timings, not a sustained frame-rate claim. +Read the remaining profiling and geometry limitations above before treating +this checkpoint as acceptance. 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..6330cdca --- /dev/null +++ b/examples/godot-gdscript/addons/gua/gua_spatial.gd @@ -0,0 +1,257 @@ +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.engine.name != "Godot" or registration.provider.engine.version != Engine.get_version_info().string or registration.provider.engine.backend != "GodotPhysics3D" or ProjectSettings.get_setting("physics/3d/physics_engine") != "GodotPhysics3D": + 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)) + 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)) + 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) + var completed := _host.complete(begin.handle, JSON.stringify(execution)) + if completed.status != 0: + status = completed.status + 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 ended.status + +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.x == value.x and v.y == value.y and v.z == value.z and v.is_finite() and maxf(absf(v.x), maxf(absf(v.y), absf(v.z))) <= 8192.0 + +static func _cardinal(v: Vector3) -> bool: + return int(v.x != 0) + int(v.y != 0) + int(v.z != 0) <= 1 + +static func _supported(q: Dictionary) -> bool: + if q.kind == "raycast": + return _exact(q.segment.from) and _exact(q.segment.to) and _cardinal(_v(q.segment.to) - _v(q.segment.from)) + var shape: Dictionary = q.shape + if q.kind == "sweep" and (not _exact(q.delta) or not _cardinal(_v(q.delta))): + return false + if shape.type == "box": + if not _exact(shape.center) or not _exact(shape.halfExtents): + return false + for axis in [shape.basis.x, shape.basis.y, shape.basis.z]: + if not _exact(axis) or not _cardinal(_v(axis)) or _v(axis).length_squared() != 1.0: + return false + return true + if Vector3(shape.radius, 0, 0).x != shape.radius or shape.radius > 8192.0: + return false + if shape.type == "sphere": + return _exact(shape.center) + if not _exact(shape.pointA) or not _exact(shape.pointB): + return false + var a := _v(shape.pointA) + var b := _v(shape.pointB) + if not _cardinal(b-a): + return false + if a == b: + return true + # Input representability does not imply representability of the derived + # engine center/height. Reject any expansion before touching physics. + var desired_center := {"x":(shape.pointA.x+shape.pointB.x)*0.5, + "y":(shape.pointA.y+shape.pointB.y)*0.5,"z":(shape.pointA.z+shape.pointB.z)*0.5} + if not _exact(desired_center) or (a+b)*0.5 != _v(desired_center): + return false + var length: float = absf(shape.pointB.x-shape.pointA.x) + absf(shape.pointB.y-shape.pointA.y) + absf(shape.pointB.z-shape.pointA.z) + var height: float = length + 2.0*shape.radius + return a.distance_to(b) == length and length > 0.0 and Vector3(height,0,0).x == height + +func _coverage(q: Dictionary) -> Dictionary: + if not _registration.has("loadedRegion"): + return {"state":"unknown", "reason":"loaded_region_unknown"} + var low: Vector3 + var high: Vector3 + if q.kind == "raycast": + low = _v(q.segment.from).min(_v(q.segment.to)) + high = _v(q.segment.from).max(_v(q.segment.to)) + else: + var s: Dictionary = q.shape + low = _v(s.pointA).min(_v(s.pointB)) if s.type == "capsule" else _v(s.center) + high = _v(s.pointA).max(_v(s.pointB)) if s.type == "capsule" else low + var extent: Vector3 = Vector3.ONE * float(s.radius) if s.type != "box" else _v(s.basis.x).abs() * s.halfExtents.x + _v(s.basis.y).abs() * s.halfExtents.y + _v(s.basis.z).abs() * s.halfExtents.z + low -= extent + high += extent + if q.kind == "sweep": + low = low.min(low + _v(q.delta)) + high = high.max(high + _v(q.delta)) + var region: Dictionary = _registration.loadedRegion + var min_bound := _v(region.min) + Vector3.ONE * 0.001 + var max_bound := _v(region.max) - Vector3.ONE * 0.001 + if low.x > min_bound.x and low.y > min_bound.y and low.z > min_bound.z and high.x < max_bound.x and high.y < max_bound.y and high.z < max_bound.z: + return {"state":"complete", "loadedRegion":region} + return {"state":"unknown", "reason":"outside_loaded_region"} + +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) -> 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 + r.coverage = _coverage(q) + 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 + _prepare_shape(q.shape, policy) + # 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..c75c9b15 --- /dev/null +++ b/examples/spatial-fixtures/godot/fixture.gd @@ -0,0 +1,202 @@ +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 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() + 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":"GodotPhysics3D", "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(): + var output := {"configuration":fixture.configuration,"results":evidence,"physicsTick":Engine.get_physics_frames()} + var file := FileAccess.open("res://evidence.json", FileAccess.WRITE) + file.store_string(JSON.stringify(output, "\t")) + 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) + 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)) + 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)).status == 8, "whole-shape region denied") + grants.profile = "Player" + _check(host.set_owner(spatial_owner, JSON.stringify(grants)).status == 0, "player grants") + denied_batch.queries = [case.query] + _check(host.enqueue(spatial_owner, JSON.stringify(denied_batch)).status == 8, "Player denied before physics") + grants.profile = "Testing" + _check(host.set_owner(spatial_owner, JSON.stringify(grants)).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)).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)).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)).status == 0, "normal grants restored") + 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)).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)): + 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)): + 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)).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)).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}) + _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)).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)).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 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..9196ac1a --- /dev/null +++ b/examples/spatial-fixtures/unity/SpatialFixture.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +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; + 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 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); + 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 = Process.GetCurrentProcess().TotalProcessorTime; + Check(adapter.Pump(),id+" pump"); + var wallUs = (Stopwatch.GetTimestamp()-start)*1000000.0/Stopwatch.Frequency; + var cpuUs = (Process.GetCurrentProcess().TotalProcessorTime-cpu).TotalMilliseconds*1000; + 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,processCpuUs=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 +#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 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..41a796a1 --- /dev/null +++ b/native/gua-godot/src/spatial_host.cpp @@ -0,0 +1,75 @@ +#include "gua/godot/spatial_host.hpp" +#include "gua/godot/copy_json.hpp" +#include +#include +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::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::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("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("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..4c0b5e68 --- /dev/null +++ b/protocol/fixtures/spatial-engine-r1.json @@ -0,0 +1,1515 @@ +{ + "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." + }, + "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" + } + } + ] +} diff --git a/scripts/run-spatial-engine-fixtures.ps1 b/scripts/run-spatial-engine-fixtures.ps1 new file mode 100644 index 00000000..3d24f0e5 --- /dev/null +++ b/scripts/run-spatial-engine-fixtures.ps1 @@ -0,0 +1,63 @@ +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 = '', + [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 + 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/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`"",'--quit-after','300','--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 41) { throw "Incomplete engine fixture evidence: $output" } + Write-Host "Real engine evidence: $output" + } +} finally { Pop-Location } From 666eeedaaeaa1e484977aca0c3d1072b58673678 Mon Sep 17 00:00:00 2001 From: link1345 Date: Thu, 1 Oct 2026 18:52:52 +0900 Subject: [PATCH 2/4] Complete general spatial geometry, retained engine bounds and real callback profiling --- .../dotnet/src/Gua.Core/GuaSpatialHost.cs | 18 + .../Gua.Selector.Tests/SpatialHostTests.cs | 21 + bindings/unity/Runtime/GuaUnitySpatial.cs | 114 ++- docs/spatial-engine-r1.md | 302 ++++---- .../godot-gdscript/addons/gua/gua_spatial.gd | 155 ++-- examples/spatial-fixtures/godot/fixture.gd | 125 +++- .../spatial-fixtures/unity/SpatialFixture.cs | 37 +- .../spatial-fixtures/unity/SpatialProfile.cs | 96 +++ native/gua-core/include/gua/spatial_host.h | 11 + native/gua-core/src/spatial.cpp | 1 + native/gua-core/src/spatial_host.inc | 35 +- native/gua-core/tests/spatial_host_tests.cpp | 21 + .../include/gua/godot/spatial_host.hpp | 2 + native/gua-godot/src/spatial_host.cpp | 28 + protocol/fixtures/spatial-engine-r1.json | 694 +++++++++++++++++- protocol/specs/spatial-host-r1.md | 13 + scripts/run-spatial-engine-fixtures.ps1 | 9 +- 17 files changed, 1381 insertions(+), 301 deletions(-) create mode 100644 examples/spatial-fixtures/unity/SpatialProfile.cs 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 index 3debae20..6250ed86 100644 --- a/bindings/unity/Runtime/GuaUnitySpatial.cs +++ b/bindings/unity/Runtime/GuaUnitySpatial.cs @@ -84,7 +84,7 @@ public bool Pump(long? tick = null) { if (request == null) break; var query = request.ReadRequest(); - var result = Execute(query); + var result = Execute(query, lease.Value); using (var execution = GuaSpatialDocument.FromExecution(result)) host.Complete(lease.Value, execution); } } @@ -100,7 +100,7 @@ public bool Pump(long? tick = null) static float F(double value) { var converted = (float)value; - if (float.IsInfinity(converted) || float.IsNaN(converted) || (double)converted != value || Math.Abs(value) > 8192) + if (float.IsInfinity(converted) || float.IsNaN(converted) || Math.Abs(value) > 8192) throw new ArgumentOutOfRangeException(nameof(value)); return converted; } @@ -137,70 +137,46 @@ int Cast(GuaSpatialShape shape, Vector3 direction, float distance, GuaUnitySpati } } - GuaSpatialCoverage Coverage(GuaSpatialRequest q) + GuaSpatialEngineBounds PreparedBounds(GuaSpatialRequest q) { - var region = registration.LoadedRegion; - if (region != null && Contained(q, region)) - return new GuaSpatialCoverage { State = "complete", LoadedRegion = region }; - return new GuaSpatialCoverage { State = "unknown", Reason = "engine_coverage_unverified" }; - } - static bool Cardinal(GuaSpatialVector v) - => (v.X == 0 ? 0 : 1) + (v.Y == 0 ? 0 : 1) + (v.Z == 0 ? 0 : 1) <= 1; - static bool Contained(GuaSpatialRequest q, GuaSpatialRegion region) - { - var lo = q.Kind == "raycast" ? V(q.Segment.From) : q.Shape.Type == "capsule" ? Vector3.Min(V(q.Shape.PointA), V(q.Shape.PointB)) : V(q.Shape.Center); - var hi = q.Kind == "raycast" ? V(q.Segment.To) : q.Shape.Type == "capsule" ? Vector3.Max(V(q.Shape.PointA), V(q.Shape.PointB)) : lo; - var min = Vector3.Min(lo, hi); var max = Vector3.Max(lo, hi); - if (q.Kind != "raycast") + 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) { - Vector3 extents; - if (q.Shape.Type == "box") + 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 b = q.Shape.Basis; var h = V(q.Shape.HalfExtents); - var x = V(b.X); var y = V(b.Y); var z = V(b.Z); - extents = new Vector3(Mathf.Abs(x.x)*h.x+Mathf.Abs(y.x)*h.y+Mathf.Abs(z.x)*h.z, - Mathf.Abs(x.y)*h.x+Mathf.Abs(y.y)*h.y+Mathf.Abs(z.y)*h.z, - Mathf.Abs(x.z)*h.x+Mathf.Abs(y.z)*h.y+Mathf.Abs(z.z)*h.z); + 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]) && (V(s.PointB)-V(s.PointA)).sqrMagnitude==0) 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]); } } - else extents = Vector3.one * F(q.Shape.Radius.Value); - min -= extents; max += extents; - if (q.Kind == "sweep") { var d = V(q.Delta); min = Vector3.Min(min, min + d); max = Vector3.Max(max, max + d); } + 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])); } } - // Stay away from a rounded region boundary. Native host repeats the - // authoritative outward-rounded containment check at completion. - const double slack = 0.001; - return min.x > region.Min.X + slack && min.y > region.Min.Y + slack && min.z > region.Min.Z + slack - && max.x < region.Max.X - slack && max.y < region.Max.Y - slack && max.z < region.Max.Z - slack; - } - static bool ExactCardinal(GuaSpatialRequest q) - { - // An initial bounded subset avoids expanding an authorized binary64 - // shape by silently rounding it into engine floats. Arbitrary - // orientations/diagonal motion require an explicit precision contract. - if (q.Kind == "raycast") return Cardinal(new GuaSpatialVector(q.Segment.To.X-q.Segment.From.X, q.Segment.To.Y-q.Segment.From.Y, q.Segment.To.Z-q.Segment.From.Z)); - if (q.Kind == "sweep" && !Cardinal(q.Delta)) return false; - if (q.Shape.Type == "capsule" && !Cardinal(new GuaSpatialVector(q.Shape.PointB.X-q.Shape.PointA.X, q.Shape.PointB.Y-q.Shape.PointA.Y, q.Shape.PointB.Z-q.Shape.PointA.Z))) return false; - if (q.Shape.Type == "capsule") - { - var a=q.Shape.PointA; var b=q.Shape.PointB; - Func exact = n => Math.Abs(n)<=8192 && (double)(float)n==n; - if (!exact(a.X)||!exact(a.Y)||!exact(a.Z)||!exact(b.X)||!exact(b.Y)||!exact(b.Z)) return false; - var mid = new GuaSpatialVector((a.X+b.X)*0.5,(a.Y+b.Y)*0.5,(a.Z+b.Z)*0.5); - if (!exact(mid.X)||!exact(mid.Y)||!exact(mid.Z)) return false; - var engineMid=(V(a)+V(b))*0.5f; - if (engineMid.x!=mid.X || engineMid.y!=mid.Y || engineMid.z!=mid.Z) return false; - var length=Math.Abs(b.X-a.X)+Math.Abs(b.Y-a.Y)+Math.Abs(b.Z-a.Z); - if (Vector3.Distance(V(a),V(b))!=length || !exact(length+2*q.Shape.Radius.Value)) return false; - } - if (q.Shape.Type == "box") - { - var b = q.Shape.Basis; - return Cardinal(b.X) && Cardinal(b.Y) && Cardinal(b.Z) - && b.X.X*b.X.X+b.X.Y*b.X.Y+b.X.Z*b.X.Z == 1 - && b.Y.X*b.Y.X+b.Y.Y*b.Y.Y+b.Y.Z*b.Y.Z == 1 - && b.Z.X*b.Z.X+b.Z.Y*b.Z.Y+b.Z.Z*b.Z.Z == 1; - } - return true; + 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() { @@ -210,20 +186,19 @@ static GuaSpatialHit Anonymous() ["collisionRef"] = "not_published", ["worldObjectId"] = "not_published" }}; } - GuaSpatialHostQueryResult Execute(GuaSpatialRequest q) + 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]; - if (!ExactCardinal(q)) - { r.Status = "unsupported"; r.Error = new GuaSpatialFailure(q.Kind == "raycast" ? "unsupported_operation" : "unsupported_shape"); return r; } // 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 { - r.Status = "completed"; r.Coverage = Coverage(q); r.Truncated = false; r.Hits = Array.Empty(); + 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") { @@ -265,13 +240,16 @@ GuaSpatialHostQueryResult Execute(GuaSpatialRequest q) } return r; } + catch (GuaSpatialException e) when (e.Code == GuaSpatialErrorCode.NotAuthorized) { return Unsupported(q); } catch (ArgumentException) { - return 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") }; + 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; diff --git a/docs/spatial-engine-r1.md b/docs/spatial-engine-r1.md index ac68130b..2d27f394 100644 --- a/docs/spatial-engine-r1.md +++ b/docs/spatial-engine-r1.md @@ -1,131 +1,173 @@ -# Spatial-03 engine prototype and real geometry evidence - -This branch is a bounded, opt-in engine prototype for #133, not completion of -the spatial feature. No runtime transport advertises spatial execution. Existing -UI, World, Input, Observe, Trace and strict legacy spatial-r1 APIs are unchanged. - -`GuaUnitySpatial` reads only its explicitly registered `PhysicsScene`. -`GuaSpatialReader` reads only its explicitly registered `World3D` direct state. -Both use the native spatial-host-r1 scheduler, ordered take/complete calls and -mandatory lease End. The host creates enabled Testing/Debug grants independently; -the reader cannot grant client authority. Player requests and entire shapes -crossing an owner region are rejected by the native host before physics. - -Unity requires the host to call Pump on the registering main thread in an -approved synchronized interval. It never calls SyncTransforms or Simulate. -The fixture calls SyncTransforms once after host obstacle creation/mutation, -then holds the single-threaded read interval while Pump executes one batch. -It omits tick and World snapshot because the Editor callback does not establish -either. Godot requires the main thread inside a physics frame; its fixture uses -`_physics_process`. It reports the actual engine physics callback counter, with -no inferred World snapshot. Sample IDs identify each held read interval, not a -claim that different intervals sharing a tick are simultaneous. - -Unity filters with host layer masks and explicit Ignore/Collide trigger flags. -Self colliders must occupy excluded layers; arbitrary post-query predicates -are unsupported because the host contract requires prefiltering. Unity lacks a -per-query backface flag: the reader checks that the explicitly selected host -backface value equals the current global value and refuses mismatches without -querying or changing global state. Godot uses explicit masks, bodies, -areas, backfaces and bounded host RID exclusions before detection. Shape margin -must be zero so it cannot enlarge the authorized volume. Three reusable Godot -shape resources belong exclusively to each reader; no live collider is edited. - -Overlap detection never establishes penetration or an exact contact normal. -The prototype publishes anonymous hits with explicit missing reasons for all -details. Godot sweep first performs overlap, then uses cast_motion fractions -with source and unknown backend error; it never substitutes rest_info from a -different collider. Capsule height is endpoint distance plus twice the radius, -and equal endpoints use a sphere. Box sizes are twice halfExtents. Unity uses -the corresponding overlap and cast APIs and a bounded reusable result array. -Full buffers remain truncated; available hits never establish policyRegion -nearest. No collision pointer/RID/instance ID becomes a public reference. - -The loaded AABB is a trusted host declaration, never inferred from empty World -metadata or empty queries. Readers conservatively classify containment with a -0.001-unit inward slack; the native host repeats its authoritative outward -containment check at publication. Outside/missing declarations remain unknown; -an otherwise empty sweep then returns indeterminate, not clear. The slack is -a conservative coverage classification rule, not measured collision precision. -Provider precision is binary32 with unknown absolute backend error. - -Exactly representable capsule endpoints are insufficient: derived midpoint, -endpoint distance and total height must also retain their engine representation. -The audit regression uses endpoints y=1 and y=1.0000001192092896, radius=0.25, -and an owner minimum y=0.74999998. The native host accepts the original volume, -whose minimum is 0.75, while a rounded Godot center/height would reach below -the grant. Both readers now reject this query before physics; the real-engine -fixtures assert a failed unsupported_shape item with no result geometry. - -Before destroying a scene or changing origin, the host must Dispose/unregister -the reader and register the replacement with a fresh spaceEpoch. Unity also -rejects invalid PhysicsScenes at Pump. Automatic origin-shift detection is not -provided. Retaining a Godot World3D reference does not substitute for this host -lifecycle obligation. - -## Reproduction and acceptance limits - -Run `scripts/run-spatial-engine-fixtures.ps1` with the pinned Godot and Unity -executables. Optional GodotCppSource points to the repository's pinned -godot-cpp checkout for offline builds. The script builds current native and -managed code, stages isolated projects under artifacts, then executes real -Godot and Unity Play Mode fixtures. No external Playtest dependency is used. -Engine versions, backend labels, OS, tick target, margin and tolerance are fixed -in `protocol/fixtures/spatial-engine-r1.json` before execution. Occupancy -expectations come from the fixed box geometry, not adapter output. Internal -start rays and exact boundary floor contact intentionally have backend-specific -detection, but must preserve unknown origin/normal/relation facts. - -The real Windows runs cover 20 shared scenarios plus the second state of one -moving door, 16-query batches, independent loaded worlds/scenes, Player denial, -whole-shape region denial, unregister and stale replacement epochs. Obstacles -have no semantic World registrations, so anonymous walls remain eligible. -Door transforms change only at approved host points; old/new results carry -different physics sample IDs (and different Godot physics ticks). - -| Combination | State | +# 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. 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. +Coordinates/dimensions beyond 8192 units, nonfinite/collapsed parameters and +nonzero margins remain unsupported. Backend absolute error remains unknown. +Distinct capsule endpoints whose binary32 axis length underflows to zero are +refused before a singular transform or engine capsule query can be prepared. +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. 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 / GodotPhysics3D / Windows x64 / fixture primitive boxes | Real fixture evidence | -| Unity 6000.5.3f1 / PhysX / Windows x64 / Play Mode / fixture primitive boxes | Real fixture evidence | -| Exactly binary32-representable coordinates/dimensions, cardinal capsules and motion, signed cardinal box bases, coordinates/dimensions within 8192 units | Prototype subset | -| General diagonal motion, arbitrary box/capsule orientations, silently rounded binary64 geometry | Unsupported in this prototype | -| Godot nonzero margins; Unity mismatched global backfaces | Unsupported | -| Mesh obstacles, one-way collision, Jolt, nonuniform inferred collider scale, other OS/patch versions | Unverified; no support claim | -| Reliable normals, penetration depths, public collision/World IDs | Not supplied | -| General geometry precision/authorization design and full game-impact profiling | Remaining #133 work | - -Each fixture records pump wall time and enqueue-to-pump wall time for single -and 16-query batches. Unity also records coarse **whole-process** CPU deltas; -these include other Unity threads and may quantize to zero, so they are not -per-query CPU precision. Godot per-query CPU and baseline-subtracted gameplay -impact are not yet measured. A 60 Hz setting is not proof of sustained 60 fps. -One synchronous engine query cannot be interrupted; deadlines are cooperative. -Native host deadlines discard late evidence and stop remaining work; these -budgets do not make engine calls hard real-time. Do not close #133 or merge this -prototype as accepted until the remaining acceptance work is addressed. - -## Measured Windows checkpoint (2026-10-01) - -The current-source reproducible runner passed **41 records in each real -engine**. Four native spatial suites passed, followed by **101 managed -SpatialTests/SpatialHostTests** using GUA_NATIVE_DIR for the freshly built DLL. -The raw engine results/logs remain in ignored artifacts; generated output is -not committed. No fixture tolerance was relaxed after a failed geometry test. - -| Engine | Queries/batch | Timed records | Upper-median pump wall µs | Maximum pump wall µs | Upper-median enqueue-to-pump wall µs | -| --- | ---: | ---: | ---: | ---: | ---: | -| Godot 4.7 / GodotPhysics3D | 1 | 21 | 433 | 786 | 19628 | -| Godot 4.7 / GodotPhysics3D | 16 | 20 | 2963 | 4158 | 897 | -| Unity 6000.5.3f1 / PhysX | 1 | 20 | 340.7 | 4050.5 | 27.2 | -| Unity 6000.5.3f1 / PhysX | 16 | 20 | 3303.5 | 5137 | 256.9 | - -These are one local fixture run; the preliminary unsupported-geometry regression -now warms host serialization before the timed geometry queries. Unity's separately -recorded door-open state has no timing entry. Queue measurement begins before -enqueue authoring/validation and is an upper bound on core queue residence; -Godot singletons intentionally wait for the next normal physics boundary. -An earlier run before that warmup observed a 20071.4 µs Unity singleton maximum, -above a nominal 60 Hz interval. These are synchronous cooperative calls and -local timings, not a sustained frame-rate claim. -Read the remaining profiling and geometry limitations above before treating -this checkpoint as acceptance. +| 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 | 187 | 953.5 / 1214 | 6700 / 7808 | +| Jolt Physics | 188 | 955.5 / 1184 | 6730.5 / 8056 | +| Unity PhysX | 28.1 | 814.5 / 1295.5 | 6714.9 / 8628.5 | + +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 | 649393 / 3675727 / 25416008 | 93.75 / 109.375 / 625 | +| Jolt Physics | 663140 / 3624212 / 25865469 | 46.875 / 46.875 / 531.25 | +| Unity PhysX | 95995 / 3437384 / 25254363 | 1953.125 / 1953.125 / 1953.125 | + +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 single and baseline phase times quantize equally; +callback CPU cycles still distinguish the workload. Baseline-subtracted median +callback wall overhead is about 0.77 ms for one query and 6.51 ms for batch16 +in GodotPhysics3D; 0.77/6.54 ms in Jolt; 0.79/6.69 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 index 6330cdca..82fbd438 100644 --- a/examples/godot-gdscript/addons/gua/gua_spatial.gd +++ b/examples/godot-gdscript/addons/gua/gua_spatial.gd @@ -24,7 +24,7 @@ func configure(host: GuaSpatialHost, world: World3D, registration: Dictionary, p # from a query. RID exclusions are applied by physics before enumeration. _registration = registration.duplicate(true) _policies = policies.duplicate(true) - if registration.provider.engine.name != "Godot" or registration.provider.engine.version != Engine.get_version_info().string or registration.provider.engine.backend != "GodotPhysics3D" or ProjectSettings.get_setting("physics/3d/physics_engine") != "GodotPhysics3D": + if 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): @@ -43,7 +43,7 @@ func configure(host: GuaSpatialHost, world: World3D, registration: Dictionary, p return 5 # Nonzero margin could expand the authorized query volume. _host = host _world = world - var result := host.register_provider(JSON.stringify(_registration)) + var result := host.register_provider(JSON.stringify(_registration, "", true, true)) if result.status == 0: provider = result.handle return result.status @@ -65,7 +65,7 @@ func pump() -> int: # 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)) + var begin := _host.begin(provider, JSON.stringify(boundary, "", true, true)) if begin.status != 0: return begin.status _pumping = true @@ -79,8 +79,8 @@ func pump() -> int: status = taken.status break var query: Dictionary = JSON.parse_string(taken.json) - var execution := _execute(space, query) - var completed := _host.complete(begin.handle, JSON.stringify(execution)) + var execution := _execute(space, query, begin.handle) + var completed := _host.complete(begin.handle, JSON.stringify(execution, "", true, true)) if completed.status != 0: status = completed.status break @@ -94,70 +94,86 @@ static func _v(value: Dictionary) -> Vector3: static func _exact(value: Dictionary) -> bool: var v := _v(value) - return v.x == value.x and v.y == value.y and v.z == value.z and v.is_finite() and maxf(absf(v.x), maxf(absf(v.y), absf(v.z))) <= 8192.0 - -static func _cardinal(v: Vector3) -> bool: - return int(v.x != 0) + int(v.y != 0) + int(v.z != 0) <= 1 + 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 _cardinal(_v(q.segment.to) - _v(q.segment.from)) - var shape: Dictionary = q.shape - if q.kind == "sweep" and (not _exact(q.delta) or not _cardinal(_v(q.delta))): - return false - if shape.type == "box": - if not _exact(shape.center) or not _exact(shape.halfExtents): - return false - for axis in [shape.basis.x, shape.basis.y, shape.basis.z]: - if not _exact(axis) or not _cardinal(_v(axis)) or _v(axis).length_squared() != 1.0: - return false - return true - if Vector3(shape.radius, 0, 0).x != shape.radius or shape.radius > 8192.0: - return false - if shape.type == "sphere": - return _exact(shape.center) - if not _exact(shape.pointA) or not _exact(shape.pointB): + 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 - var a := _v(shape.pointA) - var b := _v(shape.pointB) - if not _cardinal(b-a): + 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 a == b: - return true - # Input representability does not imply representability of the derived - # engine center/height. Reject any expansion before touching physics. - var desired_center := {"x":(shape.pointA.x+shape.pointB.x)*0.5, - "y":(shape.pointA.y+shape.pointB.y)*0.5,"z":(shape.pointA.z+shape.pointB.z)*0.5} - if not _exact(desired_center) or (a+b)*0.5 != _v(desired_center): + if s.type == "sphere": + return _exact(s.center) + if not _exact(s.pointA) or not _exact(s.pointB): return false - var length: float = absf(shape.pointB.x-shape.pointA.x) + absf(shape.pointB.y-shape.pointA.y) + absf(shape.pointB.z-shape.pointA.z) - var height: float = length + 2.0*shape.radius - return a.distance_to(b) == length and length > 0.0 and Vector3(height,0,0).x == height + var a := _v(s.pointA) + var b := _v(s.pointB) + return a == b or (a.distance_to(b) > 0.0 and (b-a).normalized().is_finite() and (b-a).normalized().length_squared() > 0.0) -func _coverage(q: Dictionary) -> Dictionary: - if not _registration.has("loadedRegion"): - return {"state":"unknown", "reason":"loaded_region_unknown"} - var low: Vector3 - var high: Vector3 - if q.kind == "raycast": - low = _v(q.segment.from).min(_v(q.segment.to)) - high = _v(q.segment.from).max(_v(q.segment.to)) - else: - var s: Dictionary = q.shape - low = _v(s.pointA).min(_v(s.pointB)) if s.type == "capsule" else _v(s.center) - high = _v(s.pointA).max(_v(s.pointB)) if s.type == "capsule" else low - var extent: Vector3 = Vector3.ONE * float(s.radius) if s.type != "box" else _v(s.basis.x).abs() * s.halfExtents.x + _v(s.basis.y).abs() * s.halfExtents.y + _v(s.basis.z).abs() * s.halfExtents.z - low -= extent - high += extent - if q.kind == "sweep": - low = low.min(low + _v(q.delta)) - high = high.max(high + _v(q.delta)) - var region: Dictionary = _registration.loadedRegion - var min_bound := _v(region.min) + Vector3.ONE * 0.001 - var max_bound := _v(region.max) - Vector3.ONE * 0.001 - if low.x > min_bound.x and low.y > min_bound.y and low.z > min_bound.z and high.x < max_bound.x and high.y < max_bound.y and high.z < max_bound.z: - return {"state":"complete", "loadedRegion":region} - return {"state":"unknown", "reason":"outside_loaded_region"} +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 @@ -195,7 +211,7 @@ func _prepare_shape(shape: Dictionary, policy: Dictionary) -> void: _shape_query.collide_with_areas = policy.areas _shape_query.exclude = policy.exclude -func _execute(space: PhysicsDirectSpaceState3D, q: Dictionary) -> Dictionary: +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, @@ -208,7 +224,17 @@ func _execute(space: PhysicsDirectSpaceState3D, q: Dictionary) -> Dictionary: r.status = "unsupported" r.error = {"code":"unsupported_operation" if q.kind == "raycast" else "unsupported_shape"} return r - r.coverage = _coverage(q) + 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) @@ -225,7 +251,6 @@ func _execute(space: PhysicsDirectSpaceState3D, q: Dictionary) -> Dictionary: if not hit.is_empty(): r.hits.append(_anonymous()) return r - _prepare_shape(q.shape, policy) # 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(): diff --git a/examples/spatial-fixtures/godot/fixture.gd b/examples/spatial-fixtures/godot/fixture.gd index c75c9b15..e368db19 100644 --- a/examples/spatial-fixtures/godot/fixture.gd +++ b/examples/spatial-fixtures/godot/fixture.gd @@ -10,6 +10,13 @@ var phase := 0 var spatial_owner: int var queued_at: int var evidence: 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}} @@ -37,9 +44,15 @@ func _obstacle(parent: Node, obstacle: Dictionary) -> void: 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() - var box := BoxShape3D.new() - box.size = Vector3(obstacle.size.x, obstacle.size.y, obstacle.size.z) - collision.shape = box + 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) @@ -51,7 +64,7 @@ func _registration(loaded: bool) -> Dictionary: "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":"GodotPhysics3D", "backendVersion":"unknown"}, + "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: @@ -60,9 +73,11 @@ func _registration(loaded: bool) -> Dictionary: func _physics_process(_delta: float) -> void: if index >= fixture.cases.size(): - var output := {"configuration":fixture.configuration,"results":evidence,"physicsTick":Engine.get_physics_frames()} + if not _profile_frame(): + return + var output := {"configuration":fixture.configuration,"backend":ProjectSettings.get_setting("physics/3d/physics_engine"),"results":evidence,"profile":profile_records,"physicsTick":Engine.get_physics_frames()} var file := FileAccess.open("res://evidence.json", FileAccess.WRITE) - file.store_string(JSON.stringify(output, "\t")) + 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) @@ -82,7 +97,7 @@ func _physics_process(_delta: float) -> void: 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)) + var opened := host.open_owner(JSON.stringify(grants, "", true, true)) if not _check(opened.status == 0, "spatial_owner"): return spatial_owner = opened.handle @@ -93,16 +108,16 @@ func _physics_process(_delta: float) -> void: 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)).status == 8, "whole-shape region denied") + _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)).status == 0, "player grants") + _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)).status == 8, "Player denied before physics") + _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)).status == 0, "testing restore") + _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)).status == 0, "derived geometry narrow grant") + _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" @@ -113,16 +128,26 @@ func _physics_process(_delta: float) -> void: "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)).status == 0, "original volume authorized") + _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)).status == 0, "normal grants restored") + _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") 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)).status == 0, case.id + " enqueue") + _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. @@ -136,11 +161,11 @@ func _physics_process(_delta: float) -> void: 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)): + 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)): + 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": @@ -156,7 +181,7 @@ func _physics_process(_delta: float) -> void: 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)).status == 0, "door open enqueue") + _check(host.enqueue(spatial_owner, JSON.stringify(batch, "", true, true)).status == 0, "door open enqueue") phase = 2 return if phase == 2: @@ -170,7 +195,7 @@ func _physics_process(_delta: float) -> void: 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)).status == 0, "batch enqueue") + _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 @@ -186,7 +211,7 @@ func _physics_process(_delta: float) -> void: _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)).status != 0, "unregistered world rejected") + _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 @@ -194,9 +219,67 @@ func _physics_process(_delta: float) -> void: 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)).status != 0, "old origin epoch rejected") + _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 diff --git a/examples/spatial-fixtures/unity/SpatialFixture.cs b/examples/spatial-fixtures/unity/SpatialFixture.cs index 9196ac1a..ef4181e6 100644 --- a/examples/spatial-fixtures/unity/SpatialFixture.cs +++ b/examples/spatial-fixtures/unity/SpatialFixture.cs @@ -23,8 +23,12 @@ static void Obstacle(Scene scene, JsonElement obstacle) go.transform.position = Vector(obstacle.GetProperty("center")); var category = obstacle.GetProperty("category").GetString(); go.layer = category == "self" ? 9 : 8; - var collider = go.AddComponent(); - collider.size = Vector(obstacle.GetProperty("size")); collider.isTrigger = category == "trigger"; + 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) { @@ -114,13 +118,18 @@ static void RunCore() 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");} 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 = Process.GetCurrentProcess().TotalProcessorTime; + var start = Stopwatch.GetTimestamp(); var cpu = SpatialProfile.CpuUs(); Check(adapter.Pump(),id+" pump"); var wallUs = (Stopwatch.GetTimestamp()-start)*1000000.0/Stopwatch.Frequency; - var cpuUs = (Process.GetCurrentProcess().TotalProcessorTime-cpu).TotalMilliseconds*1000; + var cpuUs = SpatialProfile.CpuUs()-cpu; using (var result = host.Poll(owner,(ulong)batchId)) { Check(result != null,id+" poll"); @@ -131,7 +140,7 @@ static void RunCore() 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,processCpuUs=cpuUs, + evidence.Add(new { id, result=result.ToJson(),pumpWallUs=wallUs,threadCpuUs=cpuUs, queueWallUs=(start-queued)*1000000.0/Stopwatch.Frequency }); if (id == "door-transition") { @@ -157,10 +166,10 @@ static void RunCore() using (var batch = GuaSpatialDocument.FromBatch(new GuaSpatialBatch {BatchId=6000,Consistency="samePhysicsSample",Queries=queries})) { var queued=Stopwatch.GetTimestamp(); host.Enqueue(owner,batch); - var start=Stopwatch.GetTimestamp(); var cpu=Process.GetCurrentProcess().TotalProcessorTime; + var start=Stopwatch.GetTimestamp(); var cpu=SpatialProfile.CpuUs(); Check(adapter.Pump(),"batch pump"); var wallUs=(Stopwatch.GetTimestamp()-start)*1000000.0/Stopwatch.Frequency; - var cpuUs=(Process.GetCurrentProcess().TotalProcessorTime-cpu).TotalMilliseconds*1000; + var cpuUs=SpatialProfile.CpuUs()-cpu; using (var result=host.Poll(owner,6000)) { var items=result.ReadBatchResult().Items; @@ -172,7 +181,7 @@ static void RunCore() Check(expected=="engine-specific" || item.Result.Outcome==expected,"batch fixed expectation"); Check(item.Result.Truncated==test.GetProperty("truncated").GetBoolean(),"batch truncation"); } - evidence.Add(new {id,batchSize=16,result=result.ToJson(),pumpWallUs=wallUs,processCpuUs=cpuUs,queueWallUs=(start-queued)*1000000.0/Stopwatch.Frequency}); + evidence.Add(new {id,batchSize=16,result=result.ToJson(),pumpWallUs=wallUs,threadCpuUs=cpuUs,queueWallUs=(start-queued)*1000000.0/Stopwatch.Frequency}); } } adapter.Dispose(); @@ -191,10 +200,18 @@ static void RunCore() SceneManager.UnloadSceneAsync(scene); } SceneManager.UnloadSceneAsync(other); - File.WriteAllText("evidence.json",JsonSerializer.Serialize(new {configuration=fixture.RootElement.GetProperty("configuration"),results=evidence})); + var configuration=fixture.RootElement.GetProperty("configuration").Clone(); + var profile=new GameObject("spatial-profile").AddComponent(); + 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})); + UnityEngine.Debug.Log("SPATIAL PROFILE PASS: "+records.Count+" real FixedUpdate samples"); + EditorApplication.Exit(0); + }; UnityEngine.Debug.Log("SPATIAL PASS: "+evidence.Count+" real Unity cases"); } - exit = 0; + return; // Runtime profile completes across normal FixedUpdate callbacks. } catch (Exception e) { UnityEngine.Debug.LogException(e); } EditorApplication.Exit(exit); 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/include/gua/godot/spatial_host.hpp b/native/gua-godot/include/gua/godot/spatial_host.hpp index d55cde5b..d895d2b1 100644 --- a/native/gua-godot/include/gua/godot/spatial_host.hpp +++ b/native/gua-godot/include/gua/godot/spatial_host.hpp @@ -23,6 +23,8 @@ class GuaSpatialHost : public RefCounted { 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); diff --git a/native/gua-godot/src/spatial_host.cpp b/native/gua-godot/src/spatial_host.cpp index 41a796a1..78ec2a06 100644 --- a/native/gua-godot/src/spatial_host.cpp +++ b/native/gua-godot/src/spatial_host.cpp @@ -2,6 +2,12 @@ #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; @@ -9,6 +15,20 @@ Dictionary GuaSpatialHost::reply(int status, uint64_t 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); @@ -45,6 +65,12 @@ Dictionary GuaSpatialHost::set_owner(uint64_t owner, const String& json) { retur 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); } @@ -59,6 +85,7 @@ static Dictionary copied(int status, gua_spatial_document_t* doc) { 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); @@ -66,6 +93,7 @@ void GuaSpatialHost::_bind_methods() { 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); diff --git a/protocol/fixtures/spatial-engine-r1.json b/protocol/fixtures/spatial-engine-r1.json index 4c0b5e68..b6572c24 100644 --- a/protocol/fixtures/spatial-engine-r1.json +++ b/protocol/fixtures/spatial-engine-r1.json @@ -8,7 +8,29 @@ "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." + "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": [ { @@ -1510,6 +1532,676 @@ "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 index 3d24f0e5..c9344f5d 100644 --- a/scripts/run-spatial-engine-fixtures.ps1 +++ b/scripts/run-spatial-engine-fixtures.ps1 @@ -3,6 +3,7 @@ param( [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' @@ -28,6 +29,8 @@ try { 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 @@ -36,6 +39,7 @@ try { # 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 @@ -52,12 +56,13 @@ try { 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`"",'--quit-after','300','--log-file',"`"$root/artifacts/godot-run.log`"") "$root/artifacts/godot-run.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 41) { throw "Incomplete engine fixture evidence: $output" } + 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" } Write-Host "Real engine evidence: $output" } } finally { Pop-Location } From 7ff2c758c75d6067f76d7642a5dea1fbf288ec41 Mon Sep 17 00:00:00 2001 From: link1345 Date: Thu, 1 Oct 2026 19:11:09 +0900 Subject: [PATCH 3/4] Reject subnormal capsule axes before engine normalization --- bindings/unity/Runtime/GuaUnitySpatial.cs | 2 +- docs/spatial-engine-r1.md | 25 +++++++++++-------- .../godot-gdscript/addons/gua/gua_spatial.gd | 10 +++++++- examples/spatial-fixtures/godot/fixture.gd | 9 +++++++ .../spatial-fixtures/unity/SpatialFixture.cs | 4 +++ 5 files changed, 37 insertions(+), 13 deletions(-) diff --git a/bindings/unity/Runtime/GuaUnitySpatial.cs b/bindings/unity/Runtime/GuaUnitySpatial.cs index 6250ed86..d560ce0d 100644 --- a/bindings/unity/Runtime/GuaUnitySpatial.cs +++ b/bindings/unity/Runtime/GuaUnitySpatial.cs @@ -156,7 +156,7 @@ GuaSpatialEngineBounds PreparedBounds(GuaSpatialRequest q) 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]) && (V(s.PointB)-V(s.PointA)).sqrMagnitude==0) throw new ArgumentException("Collapsed capsule axis"); + 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") { diff --git a/docs/spatial-engine-r1.md b/docs/spatial-engine-r1.md index 2d27f394..9dd577db 100644 --- a/docs/spatial-engine-r1.md +++ b/docs/spatial-engine-r1.md @@ -42,8 +42,10 @@ coverage checks. Narrow grants are refused rather than expanded. This guard bounds parameter preparation; it does not certify collision-kernel accuracy. Coordinates/dimensions beyond 8192 units, nonfinite/collapsed parameters and nonzero margins remain unsupported. Backend absolute error remains unknown. -Distinct capsule endpoints whose binary32 axis length underflows to zero are -refused before a singular transform or engine capsule query can be prepared. +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 @@ -138,9 +140,9 @@ wall times are microseconds; raw evidence/logs remain under ignored artifacts. | Engine/backend | Baseline median | Single median / p95 | Batch16 median / p95 | | --- | ---: | ---: | ---: | -| GodotPhysics3D | 187 | 953.5 / 1214 | 6700 / 7808 | -| Jolt Physics | 188 | 955.5 / 1184 | 6730.5 / 8056 | -| Unity PhysX | 28.1 | 814.5 / 1295.5 | 6714.9 / 8628.5 | +| GodotPhysics3D | 186.5 | 988.5 / 1316 | 6786 / 7975 | +| Jolt Physics | 186 | 970.5 / 1225 | 6841.5 / 7897 | +| Unity PhysX | 28.3 | 916.7 / 1139.3 | 6264.1 / 8198 | 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 @@ -158,16 +160,17 @@ 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 | 649393 / 3675727 / 25416008 | 93.75 / 109.375 / 625 | -| Jolt Physics | 663140 / 3624212 / 25865469 | 46.875 / 46.875 / 531.25 | -| Unity PhysX | 95995 / 3437384 / 25254363 | 1953.125 / 1953.125 / 1953.125 | +| GodotPhysics3D | 646125 / 3755560 / 25994769 | 93.75 / 31.25 / 1000 | +| Jolt Physics | 651599 / 3685939 / 25629250 | 62.5 / 62.5 / 546.875 | +| Unity PhysX | 97196 / 3507393 / 24183972 | 1937.5 / 1953.125 / 2000 | 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 single and baseline phase times quantize equally; +isolate query costs. GodotPhysics3D single reports less phase CPU than baseline despite more callback +work, and Jolt single and baseline phase times quantize equally; callback CPU cycles still distinguish the workload. Baseline-subtracted median -callback wall overhead is about 0.77 ms for one query and 6.51 ms for batch16 -in GodotPhysics3D; 0.77/6.54 ms in Jolt; 0.79/6.69 ms in Unity. These measure +callback wall overhead is about 0.80 ms for one query and 6.60 ms for batch16 +in GodotPhysics3D; 0.78/6.66 ms in Jolt; 0.89/6.24 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 index 82fbd438..9192c998 100644 --- a/examples/godot-gdscript/addons/gua/gua_spatial.gd +++ b/examples/godot-gdscript/addons/gua/gua_spatial.gd @@ -112,7 +112,15 @@ static func _supported(q: Dictionary) -> bool: return false var a := _v(s.pointA) var b := _v(s.pointB) - return a == b or (a.distance_to(b) > 0.0 and (b-a).normalized().is_finite() and (b-a).normalized().length_squared() > 0.0) + 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] diff --git a/examples/spatial-fixtures/godot/fixture.gd b/examples/spatial-fixtures/godot/fixture.gd index e368db19..a3b1366d 100644 --- a/examples/spatial-fixtures/godot/fixture.gd +++ b/examples/spatial-fixtures/godot/fixture.gd @@ -144,6 +144,15 @@ func _physics_process(_delta: float) -> void: 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() diff --git a/examples/spatial-fixtures/unity/SpatialFixture.cs b/examples/spatial-fixtures/unity/SpatialFixture.cs index ef4181e6..5f095a03 100644 --- a/examples/spatial-fixtures/unity/SpatialFixture.cs +++ b/examples/spatial-fixtures/unity/SpatialFixture.cs @@ -123,6 +123,10 @@ static void RunCore() 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); From c984df27472aa3e937a4150610661ea779b96d57 Mon Sep 17 00:00:00 2001 From: link1345 Date: Thu, 1 Oct 2026 19:40:13 +0900 Subject: [PATCH 4/4] Preserve per-item terminal lease continuation and Godot precision metadata --- bindings/unity/Runtime/GuaUnitySpatial.cs | 35 +++++++++++- docs/spatial-engine-r1.md | 38 +++++++++---- .../godot-gdscript/addons/gua/gua_spatial.gd | 23 ++++++-- examples/spatial-fixtures/godot/fixture.gd | 56 ++++++++++++++++++- .../spatial-fixtures/unity/SpatialFixture.cs | 49 +++++++++++++++- scripts/run-spatial-engine-fixtures.ps1 | 1 + 6 files changed, 180 insertions(+), 22 deletions(-) diff --git a/bindings/unity/Runtime/GuaUnitySpatial.cs b/bindings/unity/Runtime/GuaUnitySpatial.cs index d560ce0d..0d9ac22f 100644 --- a/bindings/unity/Runtime/GuaUnitySpatial.cs +++ b/bindings/unity/Runtime/GuaUnitySpatial.cs @@ -84,19 +84,48 @@ public bool Pump(long? tick = null) { if (request == null) break; var query = request.ReadRequest(); - var result = Execute(query, lease.Value); - using (var execution = GuaSpatialDocument.FromExecution(result)) host.Complete(lease.Value, execution); + var result = ExecuteOrTerminal(query, lease.Value); + if (!CompleteItem(lease.Value, result)) break; } } return true; } finally { - try { if (lease.HasValue) host.End(lease.Value); } + 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; diff --git a/docs/spatial-engine-r1.md b/docs/spatial-engine-r1.md index 9dd577db..0fae5ea8 100644 --- a/docs/spatial-engine-r1.md +++ b/docs/spatial-engine-r1.md @@ -3,7 +3,11 @@ `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. They expose no runtime +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. @@ -40,6 +44,8 @@ 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 @@ -92,7 +98,15 @@ 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. They also assert independent +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 @@ -140,9 +154,9 @@ wall times are microseconds; raw evidence/logs remain under ignored artifacts. | Engine/backend | Baseline median | Single median / p95 | Batch16 median / p95 | | --- | ---: | ---: | ---: | -| GodotPhysics3D | 186.5 | 988.5 / 1316 | 6786 / 7975 | -| Jolt Physics | 186 | 970.5 / 1225 | 6841.5 / 7897 | -| Unity PhysX | 28.3 | 916.7 / 1139.3 | 6264.1 / 8198 | +| 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 @@ -160,17 +174,17 @@ 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 | 646125 / 3755560 / 25994769 | 93.75 / 31.25 / 1000 | -| Jolt Physics | 651599 / 3685939 / 25629250 | 62.5 / 62.5 / 546.875 | -| Unity PhysX | 97196 / 3507393 / 24183972 | 1937.5 / 1953.125 / 2000 | +| 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. GodotPhysics3D single reports less phase CPU than baseline despite more callback -work, and Jolt single and baseline phase times quantize equally; +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.80 ms for one query and 6.60 ms for batch16 -in GodotPhysics3D; 0.78/6.66 ms in Jolt; 0.89/6.24 ms in Unity. These measure +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 index 9192c998..4931d662 100644 --- a/examples/godot-gdscript/addons/gua/gua_spatial.gd +++ b/examples/godot-gdscript/addons/gua/gua_spatial.gd @@ -24,7 +24,7 @@ func configure(host: GuaSpatialHost, world: World3D, registration: Dictionary, p # from a query. RID exclusions are applied by physics before enumeration. _registration = registration.duplicate(true) _policies = policies.duplicate(true) - if 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"]: + 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): @@ -80,14 +80,27 @@ func pump() -> int: break var query: Dictionary = JSON.parse_string(taken.json) var execution := _execute(space, query, begin.handle) - var completed := _host.complete(begin.handle, JSON.stringify(execution, "", true, true)) - if completed.status != 0: - status = completed.status + 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 ended.status + 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) diff --git a/examples/spatial-fixtures/godot/fixture.gd b/examples/spatial-fixtures/godot/fixture.gd index a3b1366d..4fb64280 100644 --- a/examples/spatial-fixtures/godot/fixture.gd +++ b/examples/spatial-fixtures/godot/fixture.gd @@ -10,6 +10,7 @@ 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 @@ -75,7 +76,7 @@ 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,"physicsTick":Engine.get_physics_frames()} + 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") @@ -90,6 +91,10 @@ func _physics_process(_delta: float) -> void: 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":[]}} @@ -217,6 +222,9 @@ func _physics_process(_delta: float) -> void: _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]} @@ -292,3 +300,49 @@ func _profile_frame() -> bool: 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/unity/SpatialFixture.cs b/examples/spatial-fixtures/unity/SpatialFixture.cs index 5f095a03..0ba6caf3 100644 --- a/examples/spatial-fixtures/unity/SpatialFixture.cs +++ b/examples/spatial-fixtures/unity/SpatialFixture.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Reflection; using System.Text.Json; using Gua.Core; using Gua.Unity; @@ -70,6 +71,7 @@ static void RunCore() // 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; @@ -188,6 +190,8 @@ static void RunCore() evidence.Add(new {id,batchSize=16,result=result.ToJson(),pumpWallUs=wallUs,threadCpuUs=cpuUs,queueWallUs=(start-queued)*1000000.0/Stopwatch.Frequency}); } } + LeaseRace(host,adapter,owner,grants,currentQuery,id=="door-transition"?test.GetProperty("doorOpenOutcome").GetString():test.GetProperty("outcome").GetString(),leaseRaces,id); + if(batchId==1) LeaseRace(host,adapter,owner,grants,currentQuery,test.GetProperty("outcome").GetString(),leaseRaces,id,true); adapter.Dispose(); foreach (var obstacle in scene.GetRootGameObjects()) obstacle.transform.position+=new Vector3(100,0,0); Physics.SyncTransforms(); @@ -209,7 +213,7 @@ static void RunCore() 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})); + 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); }; @@ -220,4 +224,47 @@ static void RunCore() 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/scripts/run-spatial-engine-fixtures.ps1 b/scripts/run-spatial-engine-fixtures.ps1 index c9344f5d..b8871431 100644 --- a/scripts/run-spatial-engine-fixtures.ps1 +++ b/scripts/run-spatial-engine-fixtures.ps1 @@ -63,6 +63,7 @@ try { $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 }