A POST-PASS, not a sweep change. The sweep carries the 0.55 step-up,
CONTACT_SKIN and the terrain-cliff logic, and every existing contract was
written against it, so it is untouched. separate_movers runs after the whole
integration loop — which also makes the result independent of who stepped
first — and before rider pinning, which stays authoritative.
HORIZONTAL ONLY, resolving the least-penetration axis of x/z. Resolving
vertically is exactly how characters end up standing on each other's heads; an
overlapping pair is pushed apart on the ground plane and a stack unpicks
itself.
Three FIXED relaxation passes, deliberately not convergence-based: an
early-exit on "nothing moved" makes the result depend on iteration order, and
this has to be bit-reproducible. Broad phase is a uniform grid sized 2x the
widest half, with buckets as a sorted (cell_key, index) array rather than a
hash map — allocation-light AND ordered without a second sort. That replaced a
hash map of per-cell Vecs and took allocations from 617/tick to ~15.
Each shove is clamped by sweep_axis against the solid world. Without that, a
crowd pressed against a wall squeezes its outermost members straight through.
push_mass weights the split by the OTHER body's mass, so equals each give half
and a player at 4.0 shoulders through NPCs at 1.0. 0.0 — the Default — READS
AS 1.0, not as weightless: a literal zero would make every default-constructed
mover infinitely shovable and divide by zero when two met. Same discipline as
`hidden` over `visible`.
Projectiles are excluded, and that is CORRECTNESS not taste: collect_touches
reports a strike from the overlap itself, so separating projectiles would mean
a bullet could never touch anyone. Sensors, collide:false decor and attached
riders are skipped too.
50 packed movers 0.023 ms/tick
200 packed movers 0.123
12 villagers + 500 static 0.107
200 movers + 500 static 2.020
Packed crowds where everyone overlaps a neighbour — the honest worst case. The
200-among-500-statics figure is dominated by the per-shove static clamp; at the
realistic 12-50 NPCs it is 0.1-0.25 ms. The fix if 200+ becomes normal is
accumulating pushes and clamping once per mover per pass, deliberately not done
because it changes Gauss-Seidel to Jacobi and the numbers don't justify it.
THE GOLDEN HASHES DID NOT CHANGE, and that is genuine rather than lucky:
mover_scene's walkers start 1.7 apart with 0.4 halves and diverge, and its only
other mover is an attached rider, so no pair ever overlaps and the pass is
inert. Nine new tests prove separation works; the unchanged goldens prove it
does nothing where movers never meet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three complaints from looking at the running app, all one problem: the world
didn't behave like a place.
COLLIDERS COME FROM EACH PROP'S OWN PRIMITIVES, not its AABB. Kenney authors a
house as walls + roof + door frame and a tree as trunk + canopy, so
StaticModel::parts records per-primitive bounds during the existing vertex
bake (they were being merged away). collider_parts() drops boxes under 10% of
the model's span, merges near-coincident ones, caps at 8 — low-res by design.
Policy falls out of the decomposition: buildings/fences/rocks take every
qualifying box; trees keep only parts both narrow and low, so the trunk blocks
and you walk under branches; lamps and decals take none. A prop whose parts all
filter out gets a synthesised box (trees a narrow post), because silently
reverting to walk-through scenery is the bug being fixed — a real catch, since
that fallback first shipped for Solid only and colliders dropped 39 -> 20 when
single-mesh pines found no trunk.
`hidden` rather than `visible`, deliberately: Entity derives Default, so the
field defaulting to false must be the UNUSUAL case. A `visible` flag would make
every default-constructed entity invisible — the same trap as the zero-seed rng
and the zero-gravity bodies this codebase has already been bitten by twice.
Proven by test, not by eye: a walker stops at a house wall but passes through
its DOORWAY (this fails with a single AABB), a trunk blocks while its canopy
doesn't, hidden colliders still block. One test initially "failed" because 120
ticks at 4 u/s travels exactly 8 units — it was measuring the tick budget, not
the collider.
STATIC PROPS NOW CAST. rebuild_static_shadows only walked entities, and props
are ModelInstances whose colliders are hidden, so trees and houses cast
nothing. Placed models feed the same baked layer, caster points sampled from
the model mesh (extremes always kept, then strided to ~48 — a stride alone
misses roof ridges) so a pine's shadow tapers. Cached on (render_rev,
bake_generation, models_rev), merged into one geometry, one draw.
THE SCENE IS COMPOSED: five suburban houses set back from a road all FACING it
(uniform facing is the point — random yaw reads as debris), lamps on one verge,
benches on the other, a fence line, three separated tree stands rather than
uniform sprinkling, and the physics demo gathered into a builder's yard. Props
scale to a target height from their own bounds, since a fixed multiplier gives
a 12-unit bench beside a 2-unit house. Exhaust only emits above 3 u/s (a parked
car under its own smoke column read as a bug).
44 props, 39 colliders, 7 draw items, 15.8k triangles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A generated game handed box3d an infinite extent and took the whole process
down — found by the eval harness actually generating games, not by reading
code.
NaN was already absorbed (Rust's `max` returns the non-NaN operand), but
INFINITY survives it and poisons every plane normal to NaN. box3d's face query
then never beats its -f32::MAX starting separation, leaves max_face_index at
the -1 sentinel, and convex_manifold.rs casts that sentinel through u8 into
255 and uses it to index a 6-element array.
The port is FAITHFUL to upstream C here — convex_manifold.c does the same
(uint8_t)maxFaceIndex cast; C reads garbage where Rust panics — so box3d is
not the place to diverge. The boundary is: never hand the solver a value it
cannot reason about. sane_extent() clamps non-finite and out-of-range
dimensions (and density) to a workable range.
Regression test covers +inf, -inf, NaN, 0 and negative extents: none may panic
and all must leave a finite pose after 30 ticks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two bugs, one visible symptom — the Knight strolling through the crate stack.
1. Rigid bodies were absent from the mover sweep set and from raycast /
camera-boom queries. When box3d dynamics landed, movers kept sweeping only
against Static|Kinematic, so a character (and a bullet, and the chase
camera) passed straight through every crate. Rigid poses are read back from
box3d at the end of the previous tick, so at snapshot time a rigid is as
settled as a kinematic and belongs in exactly the same set.
2. Fixing (1) exposed a deeper one. Clamping left the two boxes EXACTLY flush
(|d| == sum of halves), where float error decides the next axis' overlap
test either way — and a "yes" sent the falling mover UP onto the crate,
straight through the documented 0.55 step-up limit. It then walked along
the crate top. CONTACT_SKIN (1e-3) makes resting contact stop a hair short
of flush, so contact is unambiguous instead of borderline. Verified: a
walker into a 1.0-tall crate now stops at its face (x 1.20, y 0.50) rather
than climbing to y 1.50.
mover_is_blocked_by_a_rigid_body pins the behaviour. The mover golden hash is
re-baselined once, deliberately — the skin shifts every clamped position by
1e-3, and the movement it now describes is correct rather than merely
different. The reason is recorded at the assertion so a future change to that
hash needs the same justification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
step_world cloned two things per tick purely to dodge a borrow: the whole
Terrain (heights AND colors) and every static/kinematic Entity at 208 bytes
each. Terrain dominated — a 257^2 field is 1.3 MB/tick, 79 MB/s of memcpy at
60 Hz, on a world containing seven entities.
- Terrain: copy -> borrow, splitting the struct borrow the way the bottom of
the same function already did
- Statics: 208-byte Entity -> 48-byte Solid. This one MUST stay a copy —
movers sweep against kinematic poses from BEFORE this tick's integration
and that ordering is load-bearing — but it only ever needed
id/kind/pos/half/vel
- owner_pose: skip building the table when nothing is attached (most worlds)
scene ms/tick B/tick
demo 0.002 -> 0.003 15,140 -> 4,796 (-68%)
racing-ish (129) 0.007 -> 0.002 362,316 -> 8,576 (-98%)
terrain 257 0.019 -> 0.001 1,323,964 -> 896 (-99.93%)
large (500 static) 0.063 -> 0.056 591,386 -> 82,382 (-86%)
stress (2000 static) 0.583 -> 0.457 2,353,936 -> 327,812 (-86%)
Result-neutrality proven, not assumed: new mover_golden.rs covers what
rigid_dynamics.rs doesn't reach (terrain cliffs/floors, sweeps, platform
carry, attach pin, projectile lifetimes, auto-face) and its golden hash is
identical before and after — verified by stashing only the source changes
and re-running, not by re-baselining. Also includes a test pinning the
pre-integration snapshot ordering, so a future "obvious" simplification that
reads live positions gets caught.
Leak check: 36,000 ticks (10 simulated minutes) of a busy world with
projectiles spawning and expiring — RSS flat at 3.8 MB, +0.4% drift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- libs/game/sim/dynamics.rs: box3d world inside GameWorld, body mirror
RECONCILED against the sorted entity list per tick (merge-walk), so
retain/rollback/reset are correct by construction. Statics + kinematics
mirrored (kinematics via target transforms so resting rigids inherit
platform velocity); smooth terrain as a box3d heightfield
- New BodyKind::Rigid + body:"rigid" with density/friction/restitution;
game.push = mass-scaled impulse; set_pos/set_vel detected via bit-exact
pose caches (no new dispatch arms). Sphere rigids roll on real spheres
- Entity.orient quat read back per tick; renderer builds quat instance
transforms for rigids (no shader change). Step order: mover sweep
verbatim -> reconcile -> world_step(dt, 4) -> readback; rigid-free
worlds skip the solver
- GameWorld stays Clone via box3d snapshot round-trip (bit-identical
continuation proven by test)
- Determinism: double-run equality + golden hash 0xa8a2baf71e4a564f
(aarch64, debug and release). Perf: 0.038 ms/tick for 100 movers +
50 rigids + 65x65 terrain (budget 2 ms)
- Tape probe BYTE_IDENTICAL; box3d crate untouched; arcade demo gains a
kicked crate stack (captures verified: settles upright, then topples
with rotated resting poses)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>