diff --git a/FS.GG.Game.slnx b/FS.GG.Game.slnx
index d5659746..f965063f 100644
--- a/FS.GG.Game.slnx
+++ b/FS.GG.Game.slnx
@@ -16,5 +16,6 @@
+
diff --git a/docs/roadmaps/game-area-portals.md b/docs/roadmaps/game-area-portals.md
index 573edbf5..f37abba6 100644
--- a/docs/roadmaps/game-area-portals.md
+++ b/docs/roadmaps/game-area-portals.md
@@ -112,6 +112,49 @@ assembly compiled. An initial test import failed `FS0892` and was corrected to q
module references before the passing run. Native merge readback, broader repository gates,
actual P2 transfer, publication and installed/consumer qualification remain open.
+## P2 local source qualification — 2026-10-04
+
+A local candidate joins the exact qualified P1 topology and Box2D runtime sources; it
+is not protected source acceptance. `AreaRuntime` owns active independent local worlds,
+validates routed commands before any step, steps every world once in stable area order,
+and collects every crossing proposal before staging or removing bodies. It preserves
+Runtime's supported circle/box descriptors, material, density, kind, sensor flag and
+current awake state. Other filters, damping, gravity scale and sleep configuration
+remain Runtime's fixed defaults. Joint-connected bodies refuse before mutation.
+Destination placement uses conservative enclosing-disc overlap with non-sensor bodies.
+Self-area proposals refuse duplicate staging in this initial slice.
+
+Known missing-area, capacity, blocked-placement and staging-validation refusals preserve
+source ownership. Source removal follows real destination creation; transfer hooks do
+not step. An engine or commit uncertainty stops the whole owner, retains its worlds,
+and publishes no successful tick. `InspectStopped` offers best-effort actual copied
+observations with unreadable worlds explicit as `None`; the last fully published
+snapshot remains separate. Hidden solver state and contacts are not restored.
+
+The actual `examples/Box2D.Portals` entry point ran a circle through the paired 90-degree
+portal into a second headless world: one body/entity and one traversal at tick 1, copied
+position `(9.9,5.15)`, velocity `(-1,4)` and angular velocity `2`. Both local worlds had
+one step; five recorded ticks repeated exactly in fresh worlds under Box2D.NET `3.1.654`
+and .NET `10.0.401`, 0.1-second ticks and four substeps. Pose and momentum checks use
+absolute `2e-5` tolerance, with angle compared modulo one turn. Source-only replay scope
+is the pinned local profile.
+
+Eleven focused `Box2D AreaRuntime` tests passed: the real scene, asleep descriptor
+recreation without stepping, missing/capacity/blocked/joint refusal, actual single-precision
+staging-validation refusal, injected commit interruption with cleanup of a real staged
+body and explicit stopped observations, exit re-arming after recorded clearance,
+interpolation discontinuity and global identity prevalidation. Injected interruption
+qualifies that failure path; it does not claim an observed native engine allocator fault.
+Retained snapshots are immutable, and transferred entities snap while ordinary entities
+continue interpolation.
+
+The failed compiler/name-resolution attempts and real angle-drift observations were
+retained privately. The engine's approximate rotation construction initially compounded
+error when recreating a body; staging now installs a direct sine/cosine transform before
+preserving awake state, and the real entry passed the original strict tolerance.
+Repository gates, generated adapter baselines and native merge readback remain pending.
+Publication, installed support, P3 product adoption and browser physics remain open.
+
## Later physics and workspace boundaries
A crate straddling an opening and colliding on both sides, or a rope/joint spanning
diff --git a/examples/Box2D.Portals/Box2D.Portals.fsproj b/examples/Box2D.Portals/Box2D.Portals.fsproj
new file mode 100644
index 00000000..cfef67d8
--- /dev/null
+++ b/examples/Box2D.Portals/Box2D.Portals.fsproj
@@ -0,0 +1,5 @@
+
+ Exefalse
+
+
+
diff --git a/examples/Box2D.Portals/Program.fs b/examples/Box2D.Portals/Program.fs
new file mode 100644
index 00000000..1b02a6a4
--- /dev/null
+++ b/examples/Box2D.Portals/Program.fs
@@ -0,0 +1,12 @@
+module Box2D.Portals.Program
+
+[]
+let main _ =
+ let observed = Scene.qualify ()
+ let event = observed.Head.Traversals.Head
+ printfn "PASS portal: tick=%d entity=%s source=%A destination=%A bodies=1 traversals=1 replay=exact"
+ event.Tick event.Entity event.Source event.Destination
+ printfn "pose=(%g,%g) velocity=(%g,%g) angularVelocity=%g"
+ event.DestinationPose.Position.X event.DestinationPose.Position.Y
+ event.DestinationPose.LinearVelocity.X event.DestinationPose.LinearVelocity.Y event.DestinationPose.AngularVelocity
+ 0
diff --git a/examples/Box2D.Portals/README.md b/examples/Box2D.Portals/README.md
new file mode 100644
index 00000000..0c838c7e
--- /dev/null
+++ b/examples/Box2D.Portals/README.md
@@ -0,0 +1,24 @@
+# Headless rigid portal example
+
+Run the real `Box2D.Portals` entry point with `dotnet run --project examples/Box2D.Portals`.
+It uses Box2D.NET 3.1.654 and .NET 10, one circle, two independent worlds and a paired
+90-degree portal. Both worlds step once before transfer. Source removal commits the
+same game entity to one destination body; transformed velocities are preserved and
+subsequent gravity follows the destination. The scene checks position/velocity and orientation modulo one turn within `2e-5`.
+Transfer staging uses a direct sine/cosine rotation to avoid accumulating the pinned
+engine's approximate rotation-construction error; angle extraction remains approximate.
+Five recorded ticks repeat exactly under
+this pinned profile. The program refuses to report success when its observations differ.
+
+The transfer slice supports Runtime's centred circle/box descriptors and fixed engine
+filter/damping/gravity-scale/sleep defaults. It copies current awake state and refuses
+joint-connected bodies. Destination placement conservatively tests enclosing-disc
+clearance against non-sensor bodies. Missing areas, capacity, blocked placement and
+invalid staging descriptors preserve source ownership; uncertain engine effects stop
+the owner. Portal-aware interpolation snaps moved entities; re-arming waits until the
+prior endpoint clears the body's radius plus declared hysteresis from the exit segment.
+Contacts in the destination start at its next ordinary step.
+
+This source example does not qualify public installed support, product adoption,
+browser physics, cross-world contacts/joints, hidden solver rollback or cross-platform
+replay. Execution evidence is recorded only after the actual bounded run.
diff --git a/examples/Box2D.Portals/Scene.fs b/examples/Box2D.Portals/Scene.fs
new file mode 100644
index 00000000..55e89089
--- /dev/null
+++ b/examples/Box2D.Portals/Scene.fs
@@ -0,0 +1,58 @@
+module Box2D.Portals.Scene
+
+open System
+open FS.GG.Game.Core
+open FS.GG.Game.Physics.Box2D
+
+let areaA = AreaTopology.AreaId "a"
+let areaB = AreaTopology.AreaId "b"
+let private get = function Ok value -> value | Error reason -> failwith reason
+let private point x y : Point = { X = x; Y = y }
+let mapping = AreaTopology.rigidMap (Math.PI / 2.) (point 10. 5.) |> get
+let sourceOpening : AreaTopology.Opening = { Start = point 0. -2.; Finish = point 0. 2. }
+let destinationOpening : AreaTopology.Opening =
+ { Start = AreaTopology.mapPoint mapping sourceOpening.Start; Finish = AreaTopology.mapPoint mapping sourceOpening.Finish }
+let forward : AreaTopology.Portal =
+ { Id = AreaTopology.PortalId "forward"; Source = areaA; Destination = areaB
+ SourceOpening = sourceOpening; DestinationOpening = destinationOpening
+ Direction = AreaTopology.PositiveToNegative; Mapping = mapping; Reverse = Some (AreaTopology.PortalId "back") }
+let back : AreaTopology.Portal =
+ { Id = AreaTopology.PortalId "back"; Source = areaB; Destination = areaA
+ SourceOpening = destinationOpening; DestinationOpening = sourceOpening
+ Direction = AreaTopology.NegativeToPositive; Mapping = AreaTopology.inverse mapping; Reverse = Some forward.Id }
+let graph = AreaTopology.create [areaA; areaB] [forward; back] |> get
+let settings =
+ [ { Area = areaA; Physics = { Physics.defaultSettings with Gravity = point 0. 0.; TickSeconds = 0.1 }; Capacity = 16 }
+ { Area = areaB; Physics = { Physics.defaultSettings with Gravity = point 0. -3.; TickSeconds = 0.1 }; Capacity = 16 } ]
+let moving =
+ { Physics.circle "traveller" 0.1 (point -0.25 0.) with
+ LinearVelocity = point 4. 1.; Rotation = 0.3; AngularVelocity = 2.
+ Density = 2.; Friction = 0.4; Restitution = 0.2 }
+let inputs =
+ [ [ { Area = areaA; Command = CreateBody moving } ]; []; []; []; [] ]
+let advance = function Advanced snapshot -> snapshot | Stopped(reason, _) -> failwith ("Stopped: " + reason)
+let run () =
+ use runtime = new AreaRuntime(graph, settings, 0.05)
+ inputs |> List.map (runtime.Step >> advance)
+let qualify () =
+ let observed = run ()
+ if observed <> run () then failwith "Pinned-profile portal replay differs."
+ let transferred = observed.Head
+ let event = match transferred.Traversals with [event] -> event | _ -> failwith "Expected one committed traversal."
+ if transferred.Ownership <> Map.ofList [moving.Entity, areaB] then failwith "Entity identity/ownership differs."
+ if transferred.Areas |> Map.toSeq |> Seq.sumBy (fun (_, snapshot) -> snapshot.Bodies.Count) <> 1 then
+ failwith "Expected exactly one live body after commit."
+ if transferred.Areas |> Map.exists (fun _ snapshot -> snapshot.Tick <> 1L) then failwith "A local world stepped more than once."
+ let sourceMotion : AreaTopology.Motion =
+ { Position = event.SourcePose.Position; Velocity = event.SourcePose.LinearVelocity
+ Orientation = event.SourcePose.Rotation; AngularVelocity = event.SourcePose.AngularVelocity }
+ let expected = AreaTopology.mapMotion mapping sourceMotion
+ let close a b = abs (a - b) <= 2e-5
+ let pose = event.DestinationPose
+ if not (close pose.Position.X expected.Position.X && close pose.Position.Y expected.Position.Y
+ && close pose.LinearVelocity.X expected.Velocity.X && close pose.LinearVelocity.Y expected.Velocity.Y
+ && close pose.AngularVelocity expected.AngularVelocity
+ && close (Math.IEEERemainder(pose.Rotation - expected.Orientation, 2. * Math.PI)) 0.) then
+ failwithf "Rotated pose/momentum differ. actual=%A expected=%A source=%A" pose expected event.SourcePose
+ if observed |> List.sumBy (fun snapshot -> snapshot.Traversals.Length) <> 1 then failwith "Unexpected repeated transfer."
+ observed
diff --git a/examples/Box2D.Portals/packages.lock.json b/examples/Box2D.Portals/packages.lock.json
new file mode 100644
index 00000000..ab8d8e25
--- /dev/null
+++ b/examples/Box2D.Portals/packages.lock.json
@@ -0,0 +1,33 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "FSharp.Core": {
+ "type": "Direct",
+ "requested": "[10.1.302, )",
+ "resolved": "10.1.302",
+ "contentHash": "bN7XMQrAQg1dyHScJ7FcsznERBkvm74LtIo1O6C5mUzgbDqSwNbDpb3+nBGaJAjDmtC/suK5Vx5pFysKY6Hm+Q=="
+ },
+ "fs.gg.game.core": {
+ "type": "Project",
+ "dependencies": {
+ "FSharp.Core": "[10.1.302, )"
+ }
+ },
+ "fs.gg.game.physics.box2d": {
+ "type": "Project",
+ "dependencies": {
+ "Box2D.NET": "[3.1.654, )",
+ "FS.GG.Game.Core": "[0.16.0, )",
+ "FSharp.Core": "[10.1.302, )"
+ }
+ },
+ "Box2D.NET": {
+ "type": "CentralTransitive",
+ "requested": "[3.1.654, )",
+ "resolved": "3.1.654",
+ "contentHash": "Sh43ZKml3BPnhGD9FzvHfBLcLR1LDUN1iMJnnX2dJJam15+DG2nLRezPlxjdXWfrskg0wEJpgdNVEkNnHx9wwA=="
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/readiness/surface-baselines/FS.GG.Game.Physics.Box2D.txt b/readiness/surface-baselines/FS.GG.Game.Physics.Box2D.txt
index db4f8f70..1627e164 100644
--- a/readiness/surface-baselines/FS.GG.Game.Physics.Box2D.txt
+++ b/readiness/surface-baselines/FS.GG.Game.Physics.Box2D.txt
@@ -1,3 +1,12 @@
+FS.GG.Game.Physics.Box2D.AreaCommand
+FS.GG.Game.Physics.Box2D.AreaPhysics
+FS.GG.Game.Physics.Box2D.AreaRuntime
+FS.GG.Game.Physics.Box2D.AreaSettings
+FS.GG.Game.Physics.Box2D.AreaSnapshot
+FS.GG.Game.Physics.Box2D.AreaStepResult
+FS.GG.Game.Physics.Box2D.AreaStepResult+Advanced
+FS.GG.Game.Physics.Box2D.AreaStepResult+Stopped
+FS.GG.Game.Physics.Box2D.AreaStepResult+Tags
FS.GG.Game.Physics.Box2D.BodyDescriptor
FS.GG.Game.Physics.Box2D.BodyKind
FS.GG.Game.Physics.Box2D.BodyKind+Tags
@@ -18,6 +27,7 @@ FS.GG.Game.Physics.Box2D.PhysicsEvent+SensorEntered
FS.GG.Game.Physics.Box2D.PhysicsEvent+SensorExited
FS.GG.Game.Physics.Box2D.PhysicsEvent+Tags
FS.GG.Game.Physics.Box2D.Pose
+FS.GG.Game.Physics.Box2D.RefusedTraversal
FS.GG.Game.Physics.Box2D.Runtime
FS.GG.Game.Physics.Box2D.Settings
FS.GG.Game.Physics.Box2D.Shape
@@ -25,3 +35,7 @@ FS.GG.Game.Physics.Box2D.Shape+Box
FS.GG.Game.Physics.Box2D.Shape+Circle
FS.GG.Game.Physics.Box2D.Shape+Tags
FS.GG.Game.Physics.Box2D.Snapshot
+FS.GG.Game.Physics.Box2D.TransferRefusal
+FS.GG.Game.Physics.Box2D.TransferRefusal+StagingRejected
+FS.GG.Game.Physics.Box2D.TransferRefusal+Tags
+FS.GG.Game.Physics.Box2D.Traversal
diff --git a/readiness/surface-baselines/members/FS.GG.Game.Physics.Box2D.txt b/readiness/surface-baselines/members/FS.GG.Game.Physics.Box2D.txt
index f5ca4140..5aabc072 100644
--- a/readiness/surface-baselines/members/FS.GG.Game.Physics.Box2D.txt
+++ b/readiness/surface-baselines/members/FS.GG.Game.Physics.Box2D.txt
@@ -1,3 +1,23 @@
+FS.GG.Game.Physics.Box2D.AreaCommand.Area : FS.GG.Game.Core.AreaTopology+AreaId [get]
+FS.GG.Game.Physics.Box2D.AreaCommand.Command : FS.GG.Game.Physics.Box2D.Command [get]
+FS.GG.Game.Physics.Box2D.AreaCommand.new(FS.GG.Game.Core.AreaTopology+AreaId, FS.GG.Game.Physics.Box2D.Command)
+FS.GG.Game.Physics.Box2D.AreaPhysics.interpolate(System.Double, FS.GG.Game.Physics.Box2D.AreaSnapshot, FS.GG.Game.Physics.Box2D.AreaSnapshot) : Microsoft.FSharp.Collections.FSharpMap>
+FS.GG.Game.Physics.Box2D.AreaRuntime.InspectStopped() : Microsoft.FSharp.Collections.FSharpMap>
+FS.GG.Game.Physics.Box2D.AreaRuntime.Snapshot() : FS.GG.Game.Physics.Box2D.AreaSnapshot
+FS.GG.Game.Physics.Box2D.AreaRuntime.Step(Microsoft.FSharp.Collections.FSharpList) : FS.GG.Game.Physics.Box2D.AreaStepResult
+FS.GG.Game.Physics.Box2D.AreaRuntime.new(FS.GG.Game.Core.AreaTopology+Graph, Microsoft.FSharp.Collections.FSharpList, System.Double)
+FS.GG.Game.Physics.Box2D.AreaSettings.Area : FS.GG.Game.Core.AreaTopology+AreaId [get]
+FS.GG.Game.Physics.Box2D.AreaSettings.Capacity : System.Int32 [get]
+FS.GG.Game.Physics.Box2D.AreaSettings.Physics : FS.GG.Game.Physics.Box2D.Settings [get]
+FS.GG.Game.Physics.Box2D.AreaSettings.new(FS.GG.Game.Core.AreaTopology+AreaId, FS.GG.Game.Physics.Box2D.Settings, System.Int32)
+FS.GG.Game.Physics.Box2D.AreaSnapshot.Areas : Microsoft.FSharp.Collections.FSharpMap [get]
+FS.GG.Game.Physics.Box2D.AreaSnapshot.Ownership : Microsoft.FSharp.Collections.FSharpMap [get]
+FS.GG.Game.Physics.Box2D.AreaSnapshot.Refused : Microsoft.FSharp.Collections.FSharpList [get]
+FS.GG.Game.Physics.Box2D.AreaSnapshot.Tick : System.Int64 [get]
+FS.GG.Game.Physics.Box2D.AreaSnapshot.Traversals : Microsoft.FSharp.Collections.FSharpList [get]
+FS.GG.Game.Physics.Box2D.AreaSnapshot.new(System.Int64, Microsoft.FSharp.Collections.FSharpMap, Microsoft.FSharp.Collections.FSharpMap, Microsoft.FSharp.Collections.FSharpList, Microsoft.FSharp.Collections.FSharpList)
+FS.GG.Game.Physics.Box2D.AreaStepResult+Tags.Advanced : System.Int32
+FS.GG.Game.Physics.Box2D.AreaStepResult+Tags.Stopped : System.Int32
FS.GG.Game.Physics.Box2D.BodyDescriptor.AngularVelocity : System.Double [get]
FS.GG.Game.Physics.Box2D.BodyDescriptor.Density : System.Double [get]
FS.GG.Game.Physics.Box2D.BodyDescriptor.Entity : System.String [get]
@@ -36,6 +56,10 @@ FS.GG.Game.Physics.Box2D.Pose.LinearVelocity : FS.GG.Game.Core.Point [get]
FS.GG.Game.Physics.Box2D.Pose.Position : FS.GG.Game.Core.Point [get]
FS.GG.Game.Physics.Box2D.Pose.Rotation : System.Double [get]
FS.GG.Game.Physics.Box2D.Pose.new(FS.GG.Game.Core.Point, System.Double, FS.GG.Game.Core.Point, System.Double)
+FS.GG.Game.Physics.Box2D.RefusedTraversal.Entity : System.String [get]
+FS.GG.Game.Physics.Box2D.RefusedTraversal.Portal : FS.GG.Game.Core.AreaTopology+PortalId [get]
+FS.GG.Game.Physics.Box2D.RefusedTraversal.Reason : FS.GG.Game.Physics.Box2D.TransferRefusal [get]
+FS.GG.Game.Physics.Box2D.RefusedTraversal.new(System.String, FS.GG.Game.Core.AreaTopology+PortalId, FS.GG.Game.Physics.Box2D.TransferRefusal)
FS.GG.Game.Physics.Box2D.Runtime.Settings : FS.GG.Game.Physics.Box2D.Settings [get]
FS.GG.Game.Physics.Box2D.Runtime.Snapshot() : FS.GG.Game.Physics.Box2D.Snapshot
FS.GG.Game.Physics.Box2D.Runtime.Step(Microsoft.FSharp.Collections.FSharpList) : FS.GG.Game.Physics.Box2D.Snapshot
@@ -50,3 +74,16 @@ FS.GG.Game.Physics.Box2D.Snapshot.Bodies : Microsoft.FSharp.Collections.FSharpMa
FS.GG.Game.Physics.Box2D.Snapshot.Events : Microsoft.FSharp.Collections.FSharpList [get]
FS.GG.Game.Physics.Box2D.Snapshot.Tick : System.Int64 [get]
FS.GG.Game.Physics.Box2D.Snapshot.new(System.Int64, Microsoft.FSharp.Collections.FSharpMap, Microsoft.FSharp.Collections.FSharpList)
+FS.GG.Game.Physics.Box2D.TransferRefusal+Tags.CapacityExceeded : System.Int32
+FS.GG.Game.Physics.Box2D.TransferRefusal+Tags.DestinationBlocked : System.Int32
+FS.GG.Game.Physics.Box2D.TransferRefusal+Tags.JointConnected : System.Int32
+FS.GG.Game.Physics.Box2D.TransferRefusal+Tags.MissingDestination : System.Int32
+FS.GG.Game.Physics.Box2D.TransferRefusal+Tags.StagingRejected : System.Int32
+FS.GG.Game.Physics.Box2D.Traversal.Destination : FS.GG.Game.Core.AreaTopology+AreaId [get]
+FS.GG.Game.Physics.Box2D.Traversal.DestinationPose : FS.GG.Game.Physics.Box2D.Pose [get]
+FS.GG.Game.Physics.Box2D.Traversal.Entity : System.String [get]
+FS.GG.Game.Physics.Box2D.Traversal.Portal : FS.GG.Game.Core.AreaTopology+PortalId [get]
+FS.GG.Game.Physics.Box2D.Traversal.Source : FS.GG.Game.Core.AreaTopology+AreaId [get]
+FS.GG.Game.Physics.Box2D.Traversal.SourcePose : FS.GG.Game.Physics.Box2D.Pose [get]
+FS.GG.Game.Physics.Box2D.Traversal.Tick : System.Int64 [get]
+FS.GG.Game.Physics.Box2D.Traversal.new(System.Int64, FS.GG.Game.Core.AreaTopology+PortalId, System.String, FS.GG.Game.Core.AreaTopology+AreaId, FS.GG.Game.Core.AreaTopology+AreaId, FS.GG.Game.Physics.Box2D.Pose, FS.GG.Game.Physics.Box2D.Pose)
diff --git a/src/Game.Physics.Box2D/AreaRuntime.fs b/src/Game.Physics.Box2D/AreaRuntime.fs
new file mode 100644
index 00000000..933f2b5d
--- /dev/null
+++ b/src/Game.Physics.Box2D/AreaRuntime.fs
@@ -0,0 +1,217 @@
+namespace FS.GG.Game.Physics.Box2D
+
+open System
+open FS.GG.Game.Core
+open FS.GG.Game.Physics.Box2D
+
+/// An independent active local world with a whole-body capacity, including static/sensor bodies.
+type AreaSettings = { Area: AreaTopology.AreaId; Physics: Settings; Capacity: int }
+/// A game command routed to exactly one active local world.
+type AreaCommand = { Area: AreaTopology.AreaId; Command: Command }
+/// Copied whole-body traversal, published only after source removal and ownership commit.
+type Traversal =
+ { Tick: int64; Portal: AreaTopology.PortalId; Entity: string
+ Source: AreaTopology.AreaId; Destination: AreaTopology.AreaId
+ SourcePose: Pose; DestinationPose: Pose }
+/// A known refusal preserves source ownership; uncertain engine mutation stops the whole owner.
+type TransferRefusal =
+ | MissingDestination | CapacityExceeded | DestinationBlocked | JointConnected | StagingRejected of reason: string
+/// A refused proposal for one entity during one tick.
+type RefusedTraversal = { Entity: string; Portal: AreaTopology.PortalId; Reason: TransferRefusal }
+/// Immutable copied observations after all local steps and transfers.
+type AreaSnapshot =
+ { Tick: int64; Areas: Map
+ Ownership: Map; Traversals: Traversal list; Refused: RefusedTraversal list }
+/// Explicit failure disposition. A stopped result is never successful acceptance and may contain
+/// staged duplicates; LastKnown is the last fully published immutable state, not a rollback promise.
+type AreaStepResult = Advanced of AreaSnapshot | Stopped of reason: string * lastKnown: AreaSnapshot
+
+/// Owns independent Runtime instances and whole-body transfer transactions on one simulation owner.
+[]
+type AreaRuntime(graph: AreaTopology.Graph, settings: AreaSettings list, hysteresis: float) =
+ let configs = settings |> List.map (fun s -> s.Area, s) |> Map.ofList
+ do
+ if configs.Count <> settings.Length || settings.IsEmpty then invalidArg "settings" "Active areas must be unique and nonempty."
+ if not (Double.IsFinite hysteresis) || hysteresis <= 0. then invalidArg "hysteresis" "Hysteresis must be finite and positive."
+ if settings |> List.exists (fun s -> s.Capacity < 1) then invalidArg "settings" "Capacity must be positive."
+ if settings |> List.exists (fun s -> s.Physics.TickSeconds <> settings.Head.Physics.TickSeconds) then
+ invalidArg "settings" "All active worlds must use the same fixed interval."
+ let worlds =
+ let mutable created = Map.empty
+ try
+ for KeyValue(area, config) in configs do created <- Map.add area (new Runtime(config.Physics)) created
+ created
+ with _ ->
+ for KeyValue(_, world) in created do (world :> IDisposable).Dispose()
+ reraise ()
+ let mutable disposed = false
+ let mutable stopped = false
+ let mutable commitFault: (unit -> unit) option = None
+ let mutable tick = 0L
+ let mutable blocked: Map = Map.empty
+ let snapshots () = worlds |> Map.map (fun _ world -> world.Snapshot())
+ let ownership areas =
+ areas |> Map.toList |> List.collect (fun (area, snapshot: Snapshot) -> snapshot.Bodies |> Map.toList |> List.map (fun (entity, _) -> entity, area)) |> Map.ofList
+ let initialAreas = snapshots ()
+ let mutable last = { Tick = 0L; Areas = initialAreas; Ownership = Map.empty; Traversals = []; Refused = [] }
+ let ensureActive () =
+ if disposed then raise (ObjectDisposedException(nameof AreaRuntime))
+ if stopped then invalidOp "Area simulation is stopped; dispose it."
+ let radius (descriptor: BodyDescriptor) = match descriptor.Shape with Circle r -> r | Box(w, h) -> sqrt (w * w + h * h)
+ let pose (descriptor: BodyDescriptor) : Pose =
+ { Position = descriptor.Position; Rotation = descriptor.Rotation
+ LinearVelocity = descriptor.LinearVelocity; AngularVelocity = descriptor.AngularVelocity }
+ let clear (point: Point) (opening: AreaTopology.Opening) clearance =
+ let dx, dy = opening.Finish.X - opening.Start.X, opening.Finish.Y - opening.Start.Y
+ let length2 = dx * dx + dy * dy
+ let along = max 0. (min 1. (((point.X - opening.Start.X) * dx + (point.Y - opening.Start.Y) * dy) / length2))
+ let x, y = opening.Start.X + along * dx, opening.Start.Y + along * dy
+ let px, py = point.X - x, point.Y - y
+ px * px + py * py > clearance * clearance
+ let placementAllowed destination descriptor =
+ let current = worlds[destination].Snapshot()
+ current.Bodies |> Map.forall (fun entity _ ->
+ let other, _, _ = worlds[destination].Describe entity
+ if other.IsSensor || descriptor.IsSensor then true
+ else
+ let x, y = other.Position.X - descriptor.Position.X, other.Position.Y - descriptor.Position.Y
+ let sum = radius descriptor + radius other
+ x * x + y * y >= sum * sum)
+ let stop reason =
+ stopped <- true
+ worlds |> Map.iter (fun _ world -> world.Stop())
+ Stopped(reason, last)
+
+ member _.Snapshot() = ensureActive (); last
+ member _.InspectStopped() =
+ if disposed then raise (ObjectDisposedException(nameof AreaRuntime))
+ if not stopped then invalidOp "Inspection is for a stopped owner."
+ worlds |> Map.map (fun _ world -> world.Inspect())
+ member internal _.CommitFault with set value = commitFault <- value
+ member internal _.SetAwake(area, entity, awake) = ensureActive (); worlds[area].SetAwake(entity, awake)
+ member internal _.BodyState(area, entity) = ensureActive (); worlds[area].Describe entity
+ member internal _.LocalTicks = worlds |> Map.map (fun _ world -> world.Snapshot().Tick)
+
+ member _.Step(commands: AreaCommand list) =
+ ensureActive ()
+ // Validate the entire global batch before stepping any local world.
+ let batches = worlds |> Map.map (fun area _ -> commands |> List.choose (fun c -> if c.Area = area then Some c.Command else None))
+ for command in commands do
+ if not (Map.containsKey command.Area worlds) then invalidArg "commands" "Unknown active area."
+ let mutable ids = last.Ownership |> Map.map (fun _ _ -> ())
+ let mutable removed = Set.empty
+ for command in commands do
+ match command.Command with
+ | CreateBody body ->
+ if Map.containsKey body.Entity ids || Set.contains body.Entity removed then invalidArg "commands" "Duplicate/reused global entity ID."
+ ids <- Map.add body.Entity () ids
+ | RemoveBody entity -> ids <- Map.remove entity ids; removed <- Set.add entity removed
+ | _ -> ()
+ worlds |> Map.iter (fun area world -> world.ValidateBatch batches[area])
+ for KeyValue(area, world) in worlds do
+ let mutable count = world.Snapshot().Bodies.Count
+ for command in batches[area] do
+ match command with
+ | CreateBody _ -> count <- count + 1
+ | RemoveBody _ -> count <- count - 1
+ | _ -> ()
+ if count > configs[area].Capacity then invalidArg "commands" "Area capacity exceeded."
+ try
+ let previous = snapshots ()
+ let stepped = worlds |> Map.map (fun area world -> world.Step batches[area])
+ tick <- tick + 1L
+ let mutable refusals = []
+ let mutable traversals = []
+ // Collect every proposal before any world is mutated. Stable area/entity ordering.
+ let candidates =
+ [ for KeyValue(area, snapshot) in stepped do
+ for KeyValue(entity, current) in snapshot.Bodies do
+ let descriptor, awake, jointed = worlds[area].Describe entity
+ let prior =
+ match Map.tryFind entity previous[area].Bodies with
+ | Some old -> Some old.Position
+ | None -> commands |> List.tryPick (fun c ->
+ match c.Command with
+ | CreateBody body when c.Area = area && body.Entity = entity -> Some body.Position
+ | _ -> None)
+ match Map.tryFind entity blocked with
+ | Some (_, opening, clearance) when prior |> Option.exists (fun position -> clear position opening clearance) -> blocked <- Map.remove entity blocked
+ | _ -> ()
+ let excluded = match Map.tryFind entity blocked with Some (id, _, _) -> Set.singleton id | None -> Set.empty
+ match prior with
+ | None -> ()
+ | Some position ->
+ let motion : AreaTopology.Motion =
+ { Position = current.Position; Velocity = current.LinearVelocity
+ Orientation = current.Rotation; AngularVelocity = current.AngularVelocity }
+ match AreaTopology.tryCrossing graph area excluded (radius descriptor) position motion with
+ | Some crossing -> yield entity, descriptor, awake, jointed, crossing
+ | None -> () ]
+ for entity, descriptor, awake, jointed, crossing in candidates do
+ let portal = crossing.Portal
+ let mapped = crossing.MappedMotion
+ let destination =
+ { descriptor with Position = mapped.Position; Rotation = mapped.Orientation
+ LinearVelocity = mapped.Velocity; AngularVelocity = mapped.AngularVelocity }
+ let refusal =
+ if jointed then Some JointConnected
+ elif not (Map.containsKey portal.Destination worlds) then Some MissingDestination
+ elif worlds[portal.Destination].Snapshot().Bodies.Count >= configs[portal.Destination].Capacity then Some CapacityExceeded
+ elif not (placementAllowed portal.Destination destination) then Some DestinationBlocked
+ else None
+ let mutable rejection = refusal
+ if rejection.IsNone then
+ try worlds[portal.Destination].ValidateBatch [CreateBody destination]
+ with :? ArgumentException as error -> rejection <- Some (StagingRejected error.Message)
+ match rejection with
+ | Some reason -> refusals <- { Entity = entity; Portal = portal.Id; Reason = reason } :: refusals
+ | None ->
+ let target, source = worlds[portal.Destination], worlds[portal.Source]
+ // Keep source until destination body/fixture/awake state exist. Never step staged bodies.
+ target.Stage(destination, awake)
+ try
+ commitFault |> Option.iter (fun fault -> fault ())
+ source.RemoveWithoutStep entity
+ with _ ->
+ try target.RemoveWithoutStep entity
+ with _ -> target.Stop()
+ reraise ()
+ let actual, actualAwake, _ = target.Describe entity
+ if actualAwake <> awake && descriptor.Kind = Dynamic then invalidOp "Destination awake state differs; simulation stopped."
+ let finalPose = pose actual
+ traversals <- { Tick = tick; Portal = portal.Id; Entity = entity
+ Source = portal.Source; Destination = portal.Destination
+ SourcePose = pose descriptor; DestinationPose = finalPose } :: traversals
+ match portal.Reverse with
+ | Some reverse -> blocked <- Map.add entity (reverse, portal.DestinationOpening, radius descriptor + hysteresis) blocked
+ | None -> blocked <- Map.remove entity blocked
+ let areas = snapshots () |> Map.map (fun area snapshot -> { snapshot with Events = stepped[area].Events })
+ let owners = ownership areas
+ if owners.Count <> (areas |> Map.toSeq |> Seq.sumBy (fun (_, snapshot) -> snapshot.Bodies.Count)) then
+ invalidOp "Entity ownership is duplicated; simulation stopped."
+ blocked <- blocked |> Map.filter (fun entity _ -> Map.containsKey entity owners)
+ last <- { Tick = tick; Areas = areas; Ownership = owners; Traversals = List.rev traversals; Refused = List.rev refusals }
+ Advanced last
+ with error -> stop error.Message
+
+ interface IDisposable with
+ member _.Dispose() =
+ if not disposed then
+ for KeyValue(_, world) in worlds do (world :> IDisposable).Dispose()
+ disposed <- true
+
+/// Portal-aware presentation snaps transferred entities and interpolates others normally.
+[]
+module AreaPhysics =
+ let interpolate alpha (previous: AreaSnapshot) (current: AreaSnapshot) =
+ if not (Double.IsFinite alpha) || alpha < 0. || alpha > 1. then invalidArg "alpha" "Alpha must be in [0,1]."
+ if current.Tick <> previous.Tick + 1L then invalidArg "current" "Snapshots must be consecutive."
+ let moved = current.Traversals |> List.map _.Entity |> Set.ofList
+ current.Areas |> Map.map (fun area snapshot ->
+ match Map.tryFind area previous.Areas with
+ | None -> snapshot.Bodies
+ | Some old ->
+ let ordinary = Physics.interpolate alpha old snapshot
+ snapshot.Bodies |> Map.map (fun entity pose ->
+ if Set.contains entity moved || Map.tryFind entity previous.Ownership <> Some area then pose
+ else ordinary[entity]))
diff --git a/src/Game.Physics.Box2D/AreaRuntime.fsi b/src/Game.Physics.Box2D/AreaRuntime.fsi
new file mode 100644
index 00000000..29a12af1
--- /dev/null
+++ b/src/Game.Physics.Box2D/AreaRuntime.fsi
@@ -0,0 +1,57 @@
+namespace FS.GG.Game.Physics.Box2D
+
+open System
+open FS.GG.Game.Core
+open FS.GG.Game.Physics.Box2D
+
+/// An independent active local world with a whole-body capacity, including static/sensor bodies.
+type AreaSettings = { Area: AreaTopology.AreaId; Physics: Settings; Capacity: int }
+/// A game command routed to exactly one active local world.
+type AreaCommand = { Area: AreaTopology.AreaId; Command: Command }
+/// Copied whole-body traversal, published only after source removal and ownership commit.
+type Traversal =
+ { Tick: int64; Portal: AreaTopology.PortalId; Entity: string
+ Source: AreaTopology.AreaId; Destination: AreaTopology.AreaId
+ SourcePose: Pose; DestinationPose: Pose }
+/// A known refusal preserves source ownership; uncertain engine mutation stops the whole owner.
+type TransferRefusal =
+ | MissingDestination | CapacityExceeded | DestinationBlocked | JointConnected | StagingRejected of reason: string
+/// A refused proposal for one entity during one tick.
+type RefusedTraversal = { Entity: string; Portal: AreaTopology.PortalId; Reason: TransferRefusal }
+/// Immutable copied observations after all local steps and transfers.
+type AreaSnapshot =
+ { Tick: int64; Areas: Map
+ Ownership: Map; Traversals: Traversal list; Refused: RefusedTraversal list }
+/// Explicit failure disposition. A stopped result is never successful acceptance and may contain
+/// staged duplicates; LastKnown is the last fully published immutable state, not a rollback promise.
+type AreaStepResult = Advanced of AreaSnapshot | Stopped of reason: string * lastKnown: AreaSnapshot
+
+/// Owns worlds on one simulation owner. Circle/box fixtures retain scale, density, material,
+/// kind and sensor setting; current pose, velocities and engine awake state are preserved.
+/// Filters, damping, gravity scale and sleep settings use the existing Runtime fixed defaults.
+/// Destination placement conservatively refuses overlap of enclosing discs (sensors excluded).
+/// Joint-connected bodies refuse. Self-area proposals refuse duplicate staging in this first slice.
+/// All worlds step once before collection/transfer; destination contacts begin on its next tick.
+/// Engine uncertainty stops this owner and retains its worlds; dispose explicitly after diagnosis.
+[]
+type AreaRuntime =
+ new: graph: AreaTopology.Graph * settings: AreaSettings list * hysteresis: float -> AreaRuntime
+ /// Last fully published immutable observation; unavailable after stop or disposal.
+ member Snapshot: unit -> AreaSnapshot
+ /// Best-effort copied actual state after stopping, without stepping. None retains an unreadable
+ /// world as unknown; no snapshot here certifies ownership or recovery.
+ member InspectStopped: unit -> Map
+ member internal CommitFault: (unit -> unit) option with set
+ /// Validate every routed batch first, step once per active area, propose once per entity,
+ /// then stage/commit without stepping. Ordinary refusals preserve source ownership.
+ member Step: commands: AreaCommand list -> AreaStepResult
+ member internal SetAwake: area: AreaTopology.AreaId * entity: string * awake: bool -> unit
+ member internal BodyState: area: AreaTopology.AreaId * entity: string -> BodyDescriptor * bool * bool
+ member internal LocalTicks: Map
+ interface IDisposable
+
+/// Portal-aware presentation snaps traversals and interpolates other entities normally.
+[]
+module AreaPhysics =
+ /// Validate alpha and consecutive ticks; return current-area pose maps.
+ val interpolate: alpha: float -> previous: AreaSnapshot -> current: AreaSnapshot -> Map>
diff --git a/src/Game.Physics.Box2D/FS.GG.Game.Physics.Box2D.fsproj b/src/Game.Physics.Box2D/FS.GG.Game.Physics.Box2D.fsproj
index a2c301c4..c48d06de 100644
--- a/src/Game.Physics.Box2D/FS.GG.Game.Physics.Box2D.fsproj
+++ b/src/Game.Physics.Box2D/FS.GG.Game.Physics.Box2D.fsproj
@@ -13,8 +13,11 @@
+
+
+
diff --git a/src/Game.Physics.Box2D/Runtime.fs b/src/Game.Physics.Box2D/Runtime.fs
index 8bb864fc..f2792fca 100644
--- a/src/Game.Physics.Box2D/Runtime.fs
+++ b/src/Game.Physics.Box2D/Runtime.fs
@@ -124,23 +124,30 @@ type Runtime(settings: Settings) =
bodyDef.linearVelocity <- vec descriptor.LinearVelocity
bodyDef.angularVelocity <- float32 descriptor.AngularVelocity
let id = B2Bodies.b2CreateBody(world, &bodyDef)
- let mutable shapeDef = B2Types.b2DefaultShapeDef()
- shapeDef.density <- float32 descriptor.Density
- shapeDef.material.friction <- float32 descriptor.Friction
- shapeDef.material.restitution <- float32 descriptor.Restitution
- shapeDef.isSensor <- descriptor.IsSensor
- shapeDef.enableSensorEvents <- true
- shapeDef.enableContactEvents <- true
- let shape =
- match descriptor.Shape with
- | Circle radius ->
- let geometry = B2Circle(B2Vec2(0.f, 0.f), float32 radius)
- B2Shapes.b2CreateCircleShape(id, &shapeDef, &geometry)
- | Box (width, height) ->
- let geometry = B2Geometries.b2MakeBox(float32 width, float32 height)
- B2Shapes.b2CreatePolygonShape(id, &shapeDef, &geometry)
- bodies <- Map.add descriptor.Entity (id, shape, descriptor) bodies
- shapes <- Map.add (key shape) descriptor.Entity shapes
+ try
+ let mutable shapeDef = B2Types.b2DefaultShapeDef()
+ shapeDef.density <- float32 descriptor.Density
+ shapeDef.material.friction <- float32 descriptor.Friction
+ shapeDef.material.restitution <- float32 descriptor.Restitution
+ shapeDef.isSensor <- descriptor.IsSensor
+ shapeDef.enableSensorEvents <- true
+ shapeDef.enableContactEvents <- true
+ let shape =
+ match descriptor.Shape with
+ | Circle radius ->
+ let geometry = B2Circle(B2Vec2(0.f, 0.f), float32 radius)
+ B2Shapes.b2CreateCircleShape(id, &shapeDef, &geometry)
+ | Box (width, height) ->
+ let geometry = B2Geometries.b2MakeBox(float32 width, float32 height)
+ B2Shapes.b2CreatePolygonShape(id, &shapeDef, &geometry)
+ bodies <- Map.add descriptor.Entity (id, shape, descriptor) bodies
+ shapes <- Map.add (key shape) descriptor.Entity shapes
+ with _ ->
+ // Retain the source during staging; clean this known partial allocation before propagating.
+ B2Bodies.b2DestroyBody id
+ bodies <- Map.remove descriptor.Entity bodies
+ shapes <- Map.filter (fun _ entity -> entity <> descriptor.Entity) shapes
+ reraise ()
| RemoveBody entity ->
let (id, _, _) = bodies[entity]
B2Bodies.b2DestroyBody id
@@ -189,6 +196,39 @@ type Runtime(settings: Settings) =
let e = contact.endEvents[i]
yield ContactEnded(pair e.shapeIdA e.shapeIdB) ] |> List.sort
+ member internal _.ValidateBatch(commands: FS.GG.Game.Physics.Box2D.Command list) = ensureActive (); validate commands
+ member internal _.IsStopped = stopped
+ member internal _.Inspect() = if disposed then None else try Some (capture []) with _ -> None
+ member internal _.Stop() = stopped <- true
+ member internal _.Describe(entity) =
+ ensureActive ()
+ let (id, _, descriptor) = bodies[entity]
+ let pose = (capture []).Bodies[entity]
+ let current = { descriptor with Position = pose.Position; Rotation = pose.Rotation
+ LinearVelocity = pose.LinearVelocity; AngularVelocity = pose.AngularVelocity }
+ current, B2Bodies.b2Body_IsAwake id,
+ (joints |> Map.exists (fun _ (_, joint) -> joint.EntityA = entity || joint.EntityB = entity))
+ member internal _.Stage(descriptor, awake) =
+ ensureActive ()
+ validate [CreateBody descriptor]
+ try
+ apply (CreateBody descriptor)
+ let (id, _, _) = bodies[descriptor.Entity]
+ // Staging must not accumulate b2MakeRot's approximate sine error on every traversal.
+ let rotation = B2Rot(float32 (cos descriptor.Rotation), float32 (sin descriptor.Rotation))
+ B2Bodies.b2Body_SetTransform(id, vec descriptor.Position, rotation)
+ B2Bodies.b2Body_SetAwake(id, awake)
+ with _ -> stopped <- true; reraise ()
+ member internal _.RemoveWithoutStep(entity) =
+ ensureActive ()
+ validate [RemoveBody entity]
+ try apply (RemoveBody entity)
+ with _ -> stopped <- true; reraise ()
+ member internal _.SetAwake(entity, awake) =
+ ensureActive ()
+ let (id, _, _) = bodies[entity]
+ B2Bodies.b2Body_SetAwake(id, awake)
+
member _.Settings = settings
member _.Snapshot() = ensureActive (); capture []
member _.Step(commands) =
diff --git a/src/Game.Physics.Box2D/Runtime.fsi b/src/Game.Physics.Box2D/Runtime.fsi
index 4701648c..0ff5dd02 100644
--- a/src/Game.Physics.Box2D/Runtime.fsi
+++ b/src/Game.Physics.Box2D/Runtime.fsi
@@ -10,6 +10,14 @@ type Runtime =
new: settings: Settings -> Runtime
/// The pinned fixed solver profile.
member Settings: Settings
+ member internal ValidateBatch: commands: Command list -> unit
+ member internal IsStopped: bool
+ member internal Inspect: unit -> Snapshot option
+ member internal Stop: unit -> unit
+ member internal Describe: entity: string -> BodyDescriptor * bool * bool
+ member internal Stage: descriptor: BodyDescriptor * awake: bool -> unit
+ member internal RemoveWithoutStep: entity: string -> unit
+ member internal SetAwake: entity: string * awake: bool -> unit
/// Copy the current poses without stepping; events are empty.
member Snapshot: unit -> Snapshot
/// Validate the full command batch, apply in order, step once and copy all observations.
diff --git a/tests/Game.Physics.Box2D.Tests/AreaRuntimeTests.fs b/tests/Game.Physics.Box2D.Tests/AreaRuntimeTests.fs
new file mode 100644
index 00000000..5d6c4934
--- /dev/null
+++ b/tests/Game.Physics.Box2D.Tests/AreaRuntimeTests.fs
@@ -0,0 +1,133 @@
+module Game.Physics.Box2D.Tests.AreaRuntimeTests
+
+open System
+open Expecto
+open FS.GG.Game.Core
+open FS.GG.Game.Physics.Box2D
+
+module Scene = Box2D.Portals.Scene
+let private p x y : Point = { X = x; Y = y }
+let private advance = Scene.advance
+let private command area cmd : AreaCommand = { Area = area; Command = cmd }
+let private configuration = Scene.settings |> List.map (fun s -> { s with Physics = { s.Physics with Gravity = p 0. 0. } })
+let private get = function Ok value -> value | Error reason -> failwith reason
+let private checkRefusal reason (snapshot: AreaSnapshot) =
+ Expect.equal snapshot.Refused.Head.Reason reason "declared refusal"
+ Expect.equal snapshot.Ownership.[Scene.moving.Entity] Scene.areaA "source ownership retained"
+ Expect.isEmpty snapshot.Traversals "no false success"
+let private near a b = Expect.isLessThan (abs (a - b)) 2e-5 "single precision conversion tolerance"
+
+[]
+let tests = testList "Box2D AreaRuntime" [
+ test "real rotated two-area scene preserves identity momentum once-step and exact replay" {
+ let observed = Scene.qualify ()
+ Expect.equal observed.Length 5 "actual headless scene"
+ }
+ test "whole supported descriptor and awake state survive non-stepping stage" {
+ use source = new Runtime(configuration.Head.Physics)
+ use destination = new Runtime(configuration.Head.Physics)
+ let body = { Scene.moving with LinearVelocity = p 0. 0.; AngularVelocity = 0.; Shape = Box(0.2, 0.3) }
+ source.Step [CreateBody body] |> ignore
+ source.SetAwake(body.Entity, false)
+ let descriptor, awake, jointed = source.Describe body.Entity
+ Expect.isFalse awake "engine source is asleep"
+ Expect.isFalse jointed "unjointed"
+ destination.Stage(descriptor, awake)
+ source.RemoveWithoutStep body.Entity
+ let actual, actualAwake, _ = destination.Describe body.Entity
+ Expect.equal { actual with Rotation = descriptor.Rotation } descriptor "shape/material/kind/sensor/pose/momentum preserved"
+ near (Math.IEEERemainder(actual.Rotation - descriptor.Rotation, 2. * Math.PI)) 0.
+ Expect.equal actualAwake awake "engine sleeping state preserved"
+ Expect.equal (source.Snapshot().Tick, destination.Snapshot().Tick) (1L, 0L) "mutation never steps"
+ Expect.isEmpty (source.Snapshot().Bodies) "source gone after commit"
+ }
+ test "missing destination preserves source after its ordinary step" {
+ use runtime = new AreaRuntime(Scene.graph, [configuration.Head], 0.05)
+ let snapshot = runtime.Step [command Scene.areaA (CreateBody Scene.moving)] |> advance
+ checkRefusal MissingDestination snapshot
+ }
+ test "capacity failure preserves source and resident" {
+ let configs = configuration |> List.map (fun s -> if s.Area = Scene.areaB then { s with Capacity = 1 } else s)
+ use runtime = new AreaRuntime(Scene.graph, configs, 0.05)
+ let resident = { Physics.circle "resident" 0.1 (p 30. 30.) with Kind = Static }
+ let snapshot = runtime.Step [command Scene.areaA (CreateBody Scene.moving); command Scene.areaB (CreateBody resident)] |> advance
+ checkRefusal CapacityExceeded snapshot
+ Expect.equal snapshot.Ownership.Count 2 "both entities retained"
+ }
+ test "blocked destination placement preserves source" {
+ use runtime = new AreaRuntime(Scene.graph, configuration, 0.05)
+ let resident = { Physics.circle "resident" 0.5 (p 9.9 5.15) with Kind = Static }
+ let snapshot = runtime.Step [command Scene.areaA (CreateBody Scene.moving); command Scene.areaB (CreateBody resident)] |> advance
+ checkRefusal DestinationBlocked snapshot
+ }
+ test "joint-connected crossing refuses without implicitly breaking joint" {
+ use runtime = new AreaRuntime(Scene.graph, configuration, 0.05)
+ let anchor = { Physics.circle "anchor" 0.1 (p -0.25 -1.) with Kind = Static }
+ let body = { Scene.moving with LinearVelocity = p 10. 0.; AngularVelocity = 0. }
+ let snapshot = runtime.Step [command Scene.areaA (CreateBody anchor); command Scene.areaA (CreateBody body)
+ command Scene.areaA (CreateDistanceJoint { Joint = "joint"; EntityA = anchor.Entity; EntityB = body.Entity; Length = 1. })] |> advance
+ checkRefusal JointConnected snapshot
+ let _, _, jointed = runtime.BodyState(Scene.areaA, body.Entity)
+ Expect.isTrue jointed "joint remains attached"
+ }
+ test "actual staging descriptor precision refusal preserves source without destination allocation" {
+ let mapping = AreaTopology.rigidMap 0. (p 1e39 0.) |> get
+ let portal = { Scene.forward with Mapping = mapping; Reverse = None
+ DestinationOpening = { Start = p 1e39 -2.; Finish = p 1e39 2. } }
+ let graph = AreaTopology.create [Scene.areaA; Scene.areaB] [portal] |> get
+ use runtime = new AreaRuntime(graph, configuration, 0.05)
+ let snapshot = runtime.Step [command Scene.areaA (CreateBody Scene.moving)] |> advance
+ match snapshot.Refused.Head.Reason with StagingRejected _ -> () | reason -> failtestf "Expected staging refusal, got %A" reason
+ Expect.equal snapshot.Ownership.[Scene.moving.Entity] Scene.areaA "source retained"
+ Expect.isEmpty snapshot.Areas.[Scene.areaB].Bodies "no staged allocation"
+ }
+ test "injected commit failure cleans real staged destination and stops with actual observations" {
+ use runtime = new AreaRuntime(Scene.graph, configuration, 0.05)
+ let body = { Scene.moving with Position = p -0.65 0. }
+ runtime.Step [command Scene.areaA (CreateBody body)] |> advance |> ignore
+ runtime.CommitFault <- Some (fun () -> failwith "injected commit interruption")
+ match runtime.Step [] with
+ | Advanced _ -> failtest "Uncertain transaction must not report success."
+ | Stopped(reason, lastKnown) ->
+ Expect.stringContains reason "injected" "fault provenance"
+ Expect.equal lastKnown.Tick 1L "last fully published tick retained"
+ let actual = runtime.InspectStopped()
+ Expect.isSome actual.[Scene.areaA] "source actual state readable"
+ Expect.isTrue (actual.[Scene.areaA].Value.Bodies.ContainsKey body.Entity) "real source preserved"
+ Expect.isEmpty actual.[Scene.areaB].Value.Bodies "real staged destination removed"
+ Expect.throws (fun () -> runtime.Step [] |> ignore) "stopped owner refuses further simulation"
+ }
+ test "exit hysteresis blocks immediate bounce then re-arms after recorded clearance" {
+ use runtime = new AreaRuntime(Scene.graph, configuration, 0.25)
+ let first = runtime.Step [command Scene.areaA (CreateBody Scene.moving)] |> advance
+ Expect.equal first.Traversals.Length 1 "first crossing"
+ let bounced = runtime.Step [command Scene.areaB (ApplyImpulse(Scene.moving.Entity, p 0. (-8. * Math.PI * 0.1 * 0.1 * Scene.moving.Density)))] |> advance
+ Expect.isEmpty bounced.Traversals "immediate reverse crossing excluded"
+ // Restore positive Y velocity; first clear below, then travel above the aperture, then return.
+ runtime.Step [command Scene.areaB (ApplyImpulse(Scene.moving.Entity, p 0. (8. * Math.PI * 0.1 * 0.1 * Scene.moving.Density)))] |> advance |> ignore
+ for _ in 1 .. 3 do runtime.Step [] |> advance |> ignore
+ runtime.Step [command Scene.areaB (ApplyImpulse(Scene.moving.Entity, p 0. (-8. * Math.PI * 0.1 * 0.1 * Scene.moving.Density)))] |> advance |> ignore
+ let returned = [for _ in 1 .. 5 -> runtime.Step [] |> advance] |> List.collect _.Traversals
+ Expect.equal returned.Length 1 "one reverse crossing after clearance"
+ Expect.equal returned.Head.Destination Scene.areaA "back in original area"
+ }
+ test "traversal interpolation snaps moved body while ordinary entities blend" {
+ use runtime = new AreaRuntime(Scene.graph, configuration, 0.05)
+ let body = { Scene.moving with Position = p -0.65 0. }
+ let other = { Physics.circle "ordinary" 0.1 (p -10. 0.) with LinearVelocity = p 1. 0. }
+ let previous = runtime.Step [command Scene.areaA (CreateBody body); command Scene.areaA (CreateBody other)] |> advance
+ let current = runtime.Step [] |> advance
+ let displayed = AreaPhysics.interpolate 0.5 previous current
+ Expect.equal displayed.[Scene.areaB].[body.Entity] current.Areas.[Scene.areaB].Bodies.[body.Entity] "cross-area snap"
+ let oldX, newX = previous.Areas.[Scene.areaA].Bodies.[other.Entity].Position.X, current.Areas.[Scene.areaA].Bodies.[other.Entity].Position.X
+ near displayed.[Scene.areaA].[other.Entity].Position.X ((oldX + newX) / 2.)
+ let retained = current
+ runtime.Step [] |> advance |> ignore
+ Expect.equal current retained "retained immutable state"
+ }
+ test "global command validation refuses duplicate identity before any world steps" {
+ use runtime = new AreaRuntime(Scene.graph, configuration, 0.05)
+ Expect.throws (fun () -> runtime.Step [command Scene.areaA (CreateBody Scene.moving); command Scene.areaB (CreateBody Scene.moving)] |> ignore) "global identity refusal"
+ Expect.equal runtime.LocalTicks (Map.ofList [Scene.areaA, 0L; Scene.areaB, 0L]) "all worlds unstepped"
+ }
+]
diff --git a/tests/Game.Physics.Box2D.Tests/Game.Physics.Box2D.Tests.fsproj b/tests/Game.Physics.Box2D.Tests/Game.Physics.Box2D.Tests.fsproj
index 1db11d1b..1a70ab11 100644
--- a/tests/Game.Physics.Box2D.Tests/Game.Physics.Box2D.Tests.fsproj
+++ b/tests/Game.Physics.Box2D.Tests/Game.Physics.Box2D.Tests.fsproj
@@ -2,6 +2,8 @@
Exefalsefalse
+
+