Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions FS.GG.Game.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@
</Folder>
<Folder Name="/examples/">
<Project Path="examples/Box2D.Headless/Box2D.Headless.fsproj" />
<Project Path="examples/Box2D.Portals/Box2D.Portals.fsproj" />
</Folder>
</Solution>
43 changes: 43 additions & 0 deletions docs/roadmaps/game-area-portals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions examples/Box2D.Portals/Box2D.Portals.fsproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><IsPackable>false</IsPackable></PropertyGroup>
<ItemGroup><Compile Include="Scene.fs" /><Compile Include="Program.fs" /></ItemGroup>
<ItemGroup><ProjectReference Include="../../src/Game.Physics.Box2D/FS.GG.Game.Physics.Box2D.fsproj" /></ItemGroup>
</Project>
12 changes: 12 additions & 0 deletions examples/Box2D.Portals/Program.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module Box2D.Portals.Program

[<EntryPoint>]
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
24 changes: 24 additions & 0 deletions examples/Box2D.Portals/README.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 58 additions & 0 deletions examples/Box2D.Portals/Scene.fs
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions examples/Box2D.Portals/packages.lock.json
Original file line number Diff line number Diff line change
@@ -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=="
}
}
}
}
14 changes: 14 additions & 0 deletions readiness/surface-baselines/FS.GG.Game.Physics.Box2D.txt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,10 +27,15 @@ 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
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
37 changes: 37 additions & 0 deletions readiness/surface-baselines/members/FS.GG.Game.Physics.Box2D.txt
Original file line number Diff line number Diff line change
@@ -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.Core.AreaTopology+AreaId, Microsoft.FSharp.Collections.FSharpMap<System.String, FS.GG.Game.Physics.Box2D.Pose>>
FS.GG.Game.Physics.Box2D.AreaRuntime.InspectStopped() : Microsoft.FSharp.Collections.FSharpMap<FS.GG.Game.Core.AreaTopology+AreaId, Microsoft.FSharp.Core.FSharpOption<FS.GG.Game.Physics.Box2D.Snapshot>>
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.AreaCommand>) : FS.GG.Game.Physics.Box2D.AreaStepResult
FS.GG.Game.Physics.Box2D.AreaRuntime.new(FS.GG.Game.Core.AreaTopology+Graph, Microsoft.FSharp.Collections.FSharpList<FS.GG.Game.Physics.Box2D.AreaSettings>, 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<FS.GG.Game.Core.AreaTopology+AreaId, FS.GG.Game.Physics.Box2D.Snapshot> [get]
FS.GG.Game.Physics.Box2D.AreaSnapshot.Ownership : Microsoft.FSharp.Collections.FSharpMap<System.String, FS.GG.Game.Core.AreaTopology+AreaId> [get]
FS.GG.Game.Physics.Box2D.AreaSnapshot.Refused : Microsoft.FSharp.Collections.FSharpList<FS.GG.Game.Physics.Box2D.RefusedTraversal> [get]
FS.GG.Game.Physics.Box2D.AreaSnapshot.Tick : System.Int64 [get]
FS.GG.Game.Physics.Box2D.AreaSnapshot.Traversals : Microsoft.FSharp.Collections.FSharpList<FS.GG.Game.Physics.Box2D.Traversal> [get]
FS.GG.Game.Physics.Box2D.AreaSnapshot.new(System.Int64, Microsoft.FSharp.Collections.FSharpMap<FS.GG.Game.Core.AreaTopology+AreaId, FS.GG.Game.Physics.Box2D.Snapshot>, Microsoft.FSharp.Collections.FSharpMap<System.String, FS.GG.Game.Core.AreaTopology+AreaId>, Microsoft.FSharp.Collections.FSharpList<FS.GG.Game.Physics.Box2D.Traversal>, Microsoft.FSharp.Collections.FSharpList<FS.GG.Game.Physics.Box2D.RefusedTraversal>)
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]
Expand Down Expand Up @@ -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.Command>) : FS.GG.Game.Physics.Box2D.Snapshot
Expand All @@ -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<FS.GG.Game.Physics.Box2D.PhysicsEvent> [get]
FS.GG.Game.Physics.Box2D.Snapshot.Tick : System.Int64 [get]
FS.GG.Game.Physics.Box2D.Snapshot.new(System.Int64, Microsoft.FSharp.Collections.FSharpMap<System.String, FS.GG.Game.Physics.Box2D.Pose>, Microsoft.FSharp.Collections.FSharpList<FS.GG.Game.Physics.Box2D.PhysicsEvent>)
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)
Loading
Loading