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 processgo.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.
Main suite: 389 checks pass, 5 fail. Crash-restore check: 8 pass, 2 fail. Crash-prone cases: no fatal 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.
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.Resetreconciles but does not step. One tick of simulated time is lost. Transform2D.Rotationis wrapped to[-π, π]on writeback.- Spawning a spinning body whose centre of mass is off its origin gives it linear
velocity, even when
Velocity2D.Linearis zero. That is Box2D behaviour, not a bug, but it surprises people. - The plugin root still does not re-export
NewPhysicsBody2D. It only exists asphysics2d/component.NewPhysicsBody2D.
| 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.
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.
- 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:
Setupruns oncardinal.Init, before the plugin, so the firstFullRebuildFromECSsees the bodies.EachTickruns onPreUpdate, before the physics pipeline. Drive gameplay-owned bodies here.Stepsrun onUpdate, after the pipeline, where post-step positions and this tick's contact events are both visible.
harness.InitECSreaches the unexportedecs.World.Initby reflection.cardinal.Worldonly calls it insideStartGame, which also wants NATS. This is the same shim the plugin's own integration tests use.
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.
go run ./shards/game -restore # the documented flow
go run ./shards/game -restore -restore-no-reset # ...without Plugin.Reset after FromProtoThis 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.
go run ./shards/game -serveThis 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.