Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

physics-test

A Cardinal game whose only job is to exercise world-engine/pkg/plugin/physics2d and report anything that does not behave the way Box2D does.

It runs headless. No NATS, no Redis, no Docker. It prints a pass/fail report and exits non-zero if any check failed.

go run ./shards/game            # run the suite
go run ./shards/game -v         # ...and print passing checks too
go run ./shards/game -digest    # ...and print a state hash for run-to-run comparison
go run ./shards/game -restore   # snapshot a world, rebuild it in a fresh one, compare
./scripts/run-hostile.sh        # run the crash-prone cases, each in its own process

go.mod has a replace pointing at /home/doglightning/world-engine, so it always tests the local checkout, not a published version. Results below are against Anthony/box2d-fixes — the pure-Go Box2D port in pkg/box2d, with no cgo and no third_party. The plugin owns its runtime per Plugin instance; queries, Reset and Engine are methods on it.

Current result

Main suite: 389 checks pass, 5 fail. Crash-restore check: 8 pass, 2 fail. Crash-prone cases: no fatal crashes.

Against the cgo bridge on main, this branch fixes four hard crashes

Case main (cgo) Anthony/box2d-fixes (pure Go)
destroy-during-contact FATAL use-after-free in the event drain passes; ContactEnd fires
short-chain (3 points) FATAL def->count >= 4 rejected cleanly
short-chain-loop (3 points) FATAL def->count >= 4 accepted and simulated
zero-extent-box FATAL area > FLT_EPSILON body goes NaN, world survives

destroy-during-contact was the worst finding on main — a projectile despawning after it hit a wall killed the shard. It is gone. The engine also poisons a world whose Step was unwound by a panic (box2d/world.go:555) so a later read fails loudly instead of returning a wrong answer.

zero-extent-box is the one that only half-improved: Validate still accepts zero half-extents, the engine still produces a (NaN, NaN) body from them, and the reconciler then rejects that body every tick forever. No crash, but the entity is permanently broken and the log fills up.

Everything else reproduces the C behaviour exactly

All 394 suite checks return identical results on both implementations, down to the values — the contact point is (0.0000, 0.9828) on each, the sleeping body sits at 44.000000 on each. Run-to-run digests match, so the port is deterministic.

That means these five still stand:

# What breaks Where
1 A body woken by physics comes back frozen after a restore internal/writeback.go (Awake is never mirrored back)
2 A manual body driven into a sleeping body raises no contact box2d/body.go SetBodyTransform
3 ContactEventPayload.Point is body-relative, not world-space internal/contact_listener.go (Manifold.Points[0].AnchorA)
4 Editing a capsule's end centers at runtime is silently ignored internal/shadow.go colliderShapeStructuralEqual
5 OverlapAABB is broad-phase only, but documents a narrow-phase test internal/query.go overlapCallback

1. Awake goes stale, and a restore acts on it. Transform2D and Velocity2D are written back from the engine every tick, so they are always current. PhysicsBody2D.Awake is push-only: the reconciler sends it to Box2D and nothing reads the real sleep state back. So a body a game put to sleep, which physics later woke, keeps Awake: false in its component forever.

A crash restore rebuilds every body from those components. In the check, a body falling at 38.8 m/s at snapshot time comes back asleep and frozen in mid-air, and ends the run 57 m from where the original did. Run it with go run ./shards/game -restore.

Everything else about the restore is clean — all 36 bodies round-trip through MarshalWire/UnmarshalWire with no field changed, flags and filters included, so the defaulting in PhysicsBody2D.UnmarshalJSON is doing its job. This is purely the ECS to Box2D rebuild acting on a component that had gone stale.

Curiously, -restore -restore-no-reset diverges by less: skipping the documented Plugin.Reset leaves the reconciler diffing instead of rebuilding, and Awake never shows as changed so the stale value is never pushed. That is not a fix. It only works while the restarted world's Init scene matches the snapshot entity for entity, which a real restore cannot rely on.

2. Manual bodies do not wake what they hit. BodyTypeManual is the documented kind for characters and enemies. It is repositioned with World.SetBodyTransform, which moves the broad-phase proxy and wakes nothing. Against an awake dynamic body the contact fires; against one that has settled and gone to sleep, nothing happens at all. The bodytypes scenario tests both, side by side.

3. Point is not a world point. It is the manifold's AnchorA, which is relative to body A's centre of mass. Callers must add body A's centre. Nothing in the plugin says so, and Normal beside it is world-space.

4. Capsule end centers are not in the structural diff. colliderShapeStructuralEqual compares Radius, HalfExtents, Vertices, ChainPoints and EdgeVertices, but not CapsuleCenter1/CapsuleCenter2. Changing a capsule's length at runtime does nothing and reports no error. Changing its radius works, so the failure is silent and partial.

5. OverlapAABB returns false positives. AABBOverlapHit is documented as a shape that overlaps "after narrow-phase test". overlapCallback appends every shape the broad phase reports. A plank turned 45° is reported 1.4 m away from its real geometry.

Also worth knowing, all pinned by passing checks:

  • The engine clamps linear speed to 400 m/s, and the plugin exposes no way to raise it.
  • The tick that rebuilds after Plugin.Reset reconciles but does not step. One tick of simulated time is lost.
  • Transform2D.Rotation is wrapped to [-π, π] on writeback.
  • Spawning a spinning body whose centre of mass is off its origin gives it linear velocity, even when Velocity2D.Linear is zero. That is Box2D behaviour, not a bug, but it surprises people.
  • The plugin root still does not re-export NewPhysicsBody2D. It only exists as physics2d/component.NewPhysicsBody2D.

What is covered

Scenario Pins
defaults Constructor and JSON defaults, the zero-value trap, Validate
shapes All 7 ShapeTypes reach Box2D and collide by their geometry
bodytypes Static / dynamic / kinematic / manual, and the writeback rules
flags Active, Awake, SleepingAllowed, Bullet, FixedRotation, gravity scale, damping
material Friction mixes as sqrt(a*b), restitution as max(a,b), density becomes mass
filtering Category/mask, one-sided masks, group index, full 64-bit filter width
sensors Triggers vs contacts, compound slots, sleeping bodies, static visitors, disabled bodies
contacts Event entities, shape slots, normal, point, filters, Begin/End lifecycle
compound Child offsets and rotations, slot identity, combined centre of mass
queries Raycast, OverlapAABB, CircleSweep, plus every documented edge case
lifecycle Create, destroy, teleport, retype, resize, add/remove shapes, refilter, retune
stability 10-box stack, deep overlap recovery, 2 cm to 100 m shapes, 5 km from origin
reset Plugin.Reset rebuild: poses, velocities, no replayed events, queries

And a separate two-world check, -restore, because it needs a second world:

Check Pins
deserialize Every component field survives MarshalWire/UnmarshalWire exactly
stale flags No body's Awake contradicts its motion at snapshot time
rebuild The restored world builds a Box2D world and survives the tick
drift Both worlds simulate to the same place from the same state

Plus two always-on watchdogs:

  • Every body is checked for NaN/Inf every tick, and named if it goes bad.
  • The C-side world is checked for disappearing without a scenario asking.

Crash-prone cases

These terminate the process, so they run one at a time via -hostile:

Case Outcome today
destroy-during-contact passes — the shard survives and ContactEnd fires
zero-extent-box accepted, then the body goes (NaN, NaN)
short-chain rejected cleanly
short-chain-loop accepted and simulated
zero-radius-circle accepted, builds a degenerate fixture
negative-radius-circle rejected cleanly, error logged every tick
polygon-no-vertices rejected cleanly
polygon-two-vertices rejected cleanly
polygon-too-many-vertices rejected cleanly
degenerate-capsule rejected cleanly
chain-on-dynamic-body rejected cleanly

The common thread: ColliderShape.Validate only checks that numbers are finite. Anything that is finite but malformed by the engine's rules gets through. On the cgo bridge that meant four fatal assertions; the pure-Go engine survives all of them, but zero-extent-box still yields a permanently NaN body. A geometry check in Validate — non-zero extents, non-zero radius, chain point counts — would turn the remaining case into an ordinary error at the boundary.

Note that a rejected shape retries forever: the entity stays in ECS with no body, and ReconcileFromECS logs the same failure every tick for as long as it lives.

How it works

  • Each scenario gets its own lane, 300 m of world to itself. Scenario code is written in lane-local coordinates; the harness offsets them. Nothing in one scenario can collide with, or be found by a query belonging to, another.
  • A scenario has three hooks:
    • Setup runs on cardinal.Init, before the plugin, so the first FullRebuildFromECS sees the bodies.
    • EachTick runs on PreUpdate, before the physics pipeline. Drive gameplay-owned bodies here.
    • Steps run on Update, after the pipeline, where post-step positions and this tick's contact events are both visible.
  • harness.InitECS reaches the unexported ecs.World.Init by reflection. cardinal.World only calls it inside StartGame, which also wants NATS. This is the same shim the plugin's own integration tests use.

Adding a check

func MyThing() harness.Scenario {
	var s struct{ ball cardinal.EntityID }
	return harness.Scenario{
		Name: "mything",
		Setup: func(c *harness.Ctx) {
			s.ball = c.Spawn("ball", 0, 10, body(physics.BodyTypeDynamic, circle(0.5)))
		},
		Steps: []harness.Step{
			{Tick: 120, Do: func(c *harness.Ctx) {
				c.Near("the ball lands", c.Pos(s.ball).Y, 0.5, 0.15)
			}},
		},
	}
}

Add it to scenario.All(). It gets the next lane automatically.

Write assertions against an observable consequence, not against the component you just wrote. The component is exactly where a flag that never reached Box2D still looks correct.

Use c.Note(...) for a measurement worth reading but not worth a hard threshold. Notes never fail the run.

The crash-restore check

go run ./shards/game -restore                     # the documented flow
go run ./shards/game -restore -restore-no-reset   # ...without Plugin.Reset after FromProto

This is a different path from the reset scenario. reset calls Plugin.Reset on a live world and rebuilds from ECS still in memory. A restore round-trips every component through MarshalWire/UnmarshalWire first, which for PhysicsBody2D means its custom UnmarshalJSON — the code that decides whether an absent field means false or Box2D's default of true.

It reproduces Cardinal's real ordering: World.run calls world.Init() first, so the game's Init systems spawn a whole scene and the plugin builds a Box2D world from it, and only then does restore() call FromProto and throw that ECS state away. The physics world is left describing a scene that no longer exists.

The scene is built so that every body carries at least one value that differs from the default it would fall back to — no body has all three sleep flags true, none is left at GravityScale 1 alone, filters use bits above 32 and both signs of group index, and all seven shape types are present. A field that gets dropped or defaulted shows up as that field reverting, and the comparison names it.

Running as a real shard

go run ./shards/game -serve

This registers the same systems and calls world.StartGame(), so it needs the usual shard infrastructure. Checks stream to stdout as they fire; the summary prints when the shard is stopped.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages