diff --git a/CLAUDE.md b/CLAUDE.md index 97d6cf9..e3f60ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,10 @@ knowledge base — see § Quick links spec. | Field | Value | |---|---| | Phase | 1 (Etch ↔ ECS) | -| Current milestone | M1.1.11 — Forge 3D shapes: the infinite plane (half-space) — code-complete, PR open. M1.1.10 is CLOSED, squash-merged to `main` (commit `712e4b5`, tag `v0.11.10-queries-shapecast-overlap`). | -| Last released tag | `v0.11.10-queries-shapecast-overlap` (posted by Guy after merge) | -| Active branch | `phase-1/forge/plane-halfspace` (PR open, not merged) | -| Next planned milestone | M1.1.11.1 — core shapes: static MeshShape. The plan row that grouped Plane and MeshShape is SPLIT (`engine-phase-1-plan.md`): the mesh half carries a rigid-solver change (several contact constraints per body pair — `ContactConstraint` identity, the warm-start cache key, island constraint ordering), an active/internal-edge policy, per-triangle `raycastAll` results with the fourth ordering-key term §1.11.14 would then need, `ShapeStore` owned memory, back-face mode on the two query structs, and the third `ShapeClass` variant. M1.1.0–M1.1.10 CLOSED. | +| Current milestone | M1.1.12 — Forge 3D: the kinematic character controller — code-complete, PR open. M1.1.11.1 is CLOSED, squash-merged to `main` (commit `a4354df`, tag `v0.11.11-mesh-shape`). | +| Last released tag | `v0.11.11-mesh-shape` (posted by Guy after merge) | +| Active branch | `phase-1/forge/character-controller` (PR open, not merged) | +| Next planned milestone | M1.1.13 — sensors and triggers. Preceded by a corpus operation OUTSIDE any milestone: `engine-physics-forge.md` crossed 220 KB with §1 at 70 % of it and §1.11 alone at 75 KB, to be split four ways between M1.1.12's closure and M1.1.13's opening (`engine-audit-checklist.md` §5). M1.1.0–M1.1.11.1 CLOSED. | ## Tags @@ -69,6 +69,7 @@ knowledge base — see § Quick links spec. | `v0.11.10-queries-shapecast-overlap` | 2026-07-27 | M1.1.10 — Queries: shapecast, overlap, point query, closest point | Eleventh M1.1 sub-milestone — the second and last that interrogates the world, and the one that replaces the five `@panic` bodies M1.1.9 froze. Normative model authored for it: `engine-physics-forge.md` §1.11.10 to §1.11.14, appended to the §1.11 series so nothing renumbers, plus a corollary at the end of §1.11.8 and a corrected `physics_overlap_aabb` comment in §13. **A shape cast is not expressible over the ray kernels**: the cast of A along `d` against B is a raycast against the Minkowski difference of the two CORES inflated by `r_a + r_b`, so a sphere cast against a box is a ray against a ROUNDED box — the one shape `rayShape` rejects. New `pipeline/narrowphase/shapecast.zig` (490 lines): the van den Bergen configuration-space ray walk, `GJKClosestPoint::CastShape` lineage, reusing `Simplex(T)`'s Voronoi solver and `support.zig` but never the `gjk()` loop (`epa.zig` dependency precedent, RD-2 records that the frozen brief's two clauses could not both be read literally). `A ⊖ B` and not `B ⊖ A` is deliberate: it is the difference `minkowskiSupport` already computes, so the `Vertex{w, support_a, support_b}` semantics and the `Σ λ_i · support_b_i` witness reconstruction carry over, at the cost of one negation on the direction. Because the support map covers every BOUNDED convex, the kernel has no shape to reject and needs no error channel — the frozen signature carrying none is evidence for the design rather than a constraint on it. **The restart budget is per ADVANCE, not per call**, and the literal reading was measurably wrong: for a POINT core the Minkowski difference is a single point, so every sample is a legitimate duplicate and a once-per-call budget made a closed-form sphere cast of 7 exit `restart_exhausted` at `6.952526`; refreshed at each advance — the reference's own placement, set immediately after the `x` shift with the comment that a rebuild is allowed once after `x` changes — the same scene converges `6.214835 → 6.952526 → 6.999764 → 7.000001`. Termination is a seven-variant `CastExit` mirroring §1.11.11's table exactly, neither padded nor truncated, with the zero-direction exit an eighth variant explicitly labelled as domain and fired before the walk. The named ceiling exhausts into a HIT at the current parameter, never a miss: the parameter grows from zero and is at every step a LOWER BOUND of the true time of impact, so a truncated answer is a contact announced early — the safe failure direction for the character controller — and the reference's two non-convergence exits do the same. The reference has no ceiling at all, which M1.1.14 forbids. RD-1: §1.11.11 as authored said the parameter "reaches or exceeds" `max_distance` is a miss, which contradicts its own closed-interval line three rows above and the brief's own required test; STRICT exceedance is implemented and the KB is patched, the reference's `>=` and its half-open interval deliberately not followed. Guards at TRUE ZERO throughout: the "no longer approaching" test never reaches its division, a denormal denominator overflows the step to infinity hence the parameter past the bound hence a miss through the existing test, and the numerator is strictly positive at that branch so no NaN is reachable — the reference's `-1.0e-18f` is not reproduced, same substitution as §1.11.2 against `RayAABox.h`. `Aabb(T).inflate` in `foundation/math` and `Bvh(T).queryCast` + `Broadphase(T).queryCast`: the swept traversal is additive on `queryRay` in the strict sense — same collector contract, same near-first descent, same visit accounting, `rayInterval` untouched — the only difference being that the node's stored box is inflated by the extent before the slab test, which is the exact Minkowski sum of two AABBs. `queryRay` becomes `queryCast` at a ZERO extent and must stay bit-identical; the `-0.0 + 0.0 = +0.0` sign flip is argued at the re-expression site over all five downstream consumers. A single swept AABB fed to the overlap traversal was REJECTED: it loses the bound entirely and turns a sub-linear query linear in the corridor's proxies. The ray starts at the CENTRE of the cast shape's initial world AABB, not at its position — the two agree only because the three stored shapes have origin-centred local boxes, a property of those shapes and not of the model. **The frozen solver-side signatures of the five entries moved to `Real`**, with mirror types `CastQuery` / `CastHit` / `OverlapRequest` / `ClosestPointHit` and `[]BodyId` outputs; `overlapAabb` gained `store`, without which its exact kernel is unreachable. An entry typed `f32` inside the solver would narrow the time of impact and the contact point before leaving the kernel and widen them again at the interface tier — two conversions, one invisible, and the loss of exactly what `-Dphysics_f64` buys. `engine-tier-interfaces.md` §1 and `api/types.zig` are UNTOUCHED: they are the frozen surface, wrapped in one place at M1.1.15 for all eight entries at once. The M1.1.9 pin had recorded that one of the two halves would have to move here or at the freeze. Doing so removed the last references to the public types, so the change detector would have gone SILENT while staying green until the freeze five sub-milestones away; it now pins `api/types.zig` field by field, which a field rename was observed to break. `query.zig` becomes the `query/` package — `root.zig` façade, `ray.zig` moved textually unchanged (git reads it as a rename; every line verified present verbatim in the pre-move file bar the eight imports the split imposes), `cast.zig`, `overlap.zig`. RD-3 records that `root.zig` at 555 lines breaks the brief's own Note, the frozen allocation of the eight entries to the façade being what produces it; kept against the two conscious overages already merged next door at 981 and 938. **`BodyId` cannot order a query result** and this was measured, not argued: it is a slot index, so it encodes creation order. On `main`, at f32, two unit spheres at `(20, ±0.5, 0)` against a ray from the origin along `+X` both return `19.133974` — bit-identical, the squared perpendicular offset being `0.25` either side, closed form `20 − √0.75` — and swapping the two creation orders changes the ENTITY returned, by `raycast` as much as by `raycastAll` truncated to one slot. The key becomes `(distance, entity, BodyId)` across the family and `(entity, BodyId)` for the three overlaps, written once as `keyLess` so the two families cannot drift, with `BodyId` surviving only as the final tie-break between two bodies of the same entity — a residual named in §1.11.14 rather than hidden, and pinned. **This supersedes the M1.1.9 record of a tie-break on the smaller `BodyId`**, which was exact when written; §1.11.6 asserted the same superseded key and was reconciled mid-milestone, the contradiction having been found by grep and not by a test. `BodyManager` gains `entity()` — the column existed since M1.1.0 and had never been exposed — and four stale-safe adapters returning WORLD space through `BodyCastHit` / `BodyClosestPoint`: `raycastBody` returns the body's local frame but `gjkPair` already returns world, two of the three named precedents disagree, and the cast kernel's native frame is A's, which is no body's. Distinct types rather than a quiet reinterpretation. §1.11.12 and §1.11.13 held at first contact with code: `overlapAabb` tests the TIGHT world AABB and never the leaf's fat box, since otherwise a tuning constant would change a query's answer, and the rejection is observed on the traversal by a counting collector rather than deduced from box arithmetic; `overlapShape` introduces NO threshold, its predicate being that the GJK regime is not `separated`; `closestPoint` measures distance to the SOLID with membership tested upstream of any classification, and `.shallow` is NOT an interior but a real separation absorbed by the numeric margin — a counter-factual reading it as one fails exactly the test written for it, and the probe is placed in ULPs of the coordinate scale because the band is a few ULPs wide by construction. **The `.deep` band was a defect and the external review found it:** GJK classifies `.deep` at `dist <= conv_k · floatEps(T) · coordScale` on the CORE distance, so for a hard core a point genuinely outside the solid lands there, and the entry answered distance 0 at the QUERIED POINT — an interior answer for a point exact membership had just placed outside, and a hit even at `max_distance == 0`. The comment defending it asserted that `.deep` means the cores intersect, which `gjk.zig` itself contradicts three hundred lines away (`A false-deep on a true near-touch, cores actually disjoint`) while documenting that in `.deep` the closest points are UNSPECIFIED — they are the zero vector, so using them answers the world origin. A justification the callee explicitly disclaims is the costliest defect class there is: it survives review by resembling an argument. Fixed inside what `.deep` does specify: the terminal simplex, whose vertices carry `support_b`, re-solved for its barycentrics and recombined — the reconstruction `shapecast.zig` already performs on the same data — then mapped out of A's frame. `closest_a` needed no regime split at all: a point core IS the queried point everywhere, which shortened the formula rather than lengthening it, and the three regimes now share one projection. The band is `16 · floatEps(T) · coordScale` and `coordScale` is RELATIVE geometry (`\|pos_b − pos_a\| + coreExtent(a) + coreExtent(b)`, the probe's extent being zero), so it does NOT grow with distance from the origin: constant at `5.211e-6` at f32, `9.706e-15` at f64, or 43.7 ULP of unit. What grows is `ulp(coordinate)`, which bounds the defect's REACHABILITY — five representable points fall strictly inside it at 1 m, none at 100 m or beyond, the first float off the face already clearing it. Unreachable is not absent, and both the first probe written for it and the reviewer's own had the same blind spot: a step sized as a fraction of the coordinate is already coarser than the band at 100 m. Two independent probes bracket the frontier identically, 32 ULP defective and 64 ULP correct. Initial contact returns distance 0 and the witness on B from the same loop, no EPA; `position = cast.origin` is REFUTED and the test asserts the cast origin is demonstrably outside the hit body, without which it could not tell the two rules apart. The domain assertion of §1.11.11 was honoured on `max_distance` alone: `shapeCast` and `closestPoint` asserted the bound and the other three asserted nothing, and handle resolution ran FIRST, so a stale handle short-circuited validation entirely and a NaN pose reached the kernel unremarked at the first call carrying a live one. All five now assert origin, direction and rotation finite and the rotation UNIT before touching the store, through shared `assertFiniteVec` (NaN caught with the infinities, `@abs(NaN) < inf` being false) and `assertUnitRotation`. Not cosmetic: these rotations serve as inverses BY CONJUGATION, and a conjugate inverts only a unit quaternion — the same class of defect M1.1.9 corrected on `addBody`, where an f32-unit quaternion widened to f64 was off by `3.4e-8` and scaled a static collider's frame. **`overlapAabb` rejects an INVERTED query box explicitly at the entry**, returning zero without traversing: a component with `min > max` denotes the empty set on that axis, hence the empty region. The first arbitrage here was wrong and was let through on reasoning rather than measurement — an inverted box was called a well-defined query with an empty answer, and it is not: the overlap predicate is written for well-formed boxes and accepts any body enclosing both bounds, so against a `[−2, 2]³` body the box `min = (1,1,1)`, `max = (−1,−1,−1)` returns ONE body, `min = (9,9,9)`, `max = (−9,−9,−9)` returns zero, and an inversion on two axes only returns one — the answer follows the amplitude and the axes of the malformation. An assertion would not do: it holds in debug only and would leave the answer arbitrary where the engine runs, on an entry that returns a `u32` with no error channel. The test is strict `>`, a DEGENERATE box being a legal region — a point, a slice — and the non-strict counter-factual takes down the face-inclusive test written in E6 for an unrelated reason. §1.11.12 carries the rule. `overlapAabb` is the only entry taking caller bounds; the other four build their own box, and `closestPoint`'s well-formedness depended implicitly on `max_distance >= 0`, now stated where the box is built. Bench `bench/forge_3d_shapecast.zig` REPORTED, not gated, ReleaseFast over the same 10 000-body grid as the raycast bench: sphere 1300.8 ns, box 1344.7, capsule 1244.0, shape overlap 231.5, and a point cast at radius 0 against a raycast on the SAME rays — 1314.1 against 827.1, a 1.59× cost isolated to the GJK walk since the traversal is bit-identically `queryRay`, with an identical 0.89 hit rate confirming the two paths agree on what they touch. Leak check proven in BOTH directions: a deliberate 4 KiB leak fires with `safety` forced true and reports "no leaks" with the default, the default being not a weaker check but one that reports success unconditionally. Eleven inherited M1.1.5–M1.1.9 envelope quantities re-measured against `main` at `dd7fa1f` through a worktree, both precisions: ZERO movement, digit for digit. A language audit run with a byte-wise accent class reported a clean tree over files that demonstrably contain French and was redone authoritatively — zero French prose, twelve verbatim spec citations counted. 306/306 green at f32 AND `-Dphysics_f64=true`, debug AND ReleaseSafe. OUT (later, NOT debt): analytic swept fast paths (the M1.1.4 pattern, owing a geometric-equivalence proof against the generic path); the EPA deepest point at a zero time of impact (additive, a defaulted field with zero call sites, gated behind a setting in the reference too); a bounded nearest-neighbour descent for `closestPoint` (additive, `max_distance` already bounds the caller's set); Plane and MeshShape (M1.1.11) — the support-map kernel extends to any BOUNDED convex so ConvexHull (M1.1.19) is nearly free, but an infinite half-space has an UNBOUNDED support map and a non-convex mesh needs a per-triangle traversal, neither free nor here; the f32→`Real` widening of the public surface, one decision over `BodyDescriptor`, the interface pose, the query results and the ECS `Transform` together (M1.1.15); far-field conditioning, characterised and not fixed (§1.11.4 bis); CCD and speculative contacts, which a shape cast is not; `step()`/`PhysicsWorld`/`PhysicsModule` and the Tier 1 `physics_query` service with its Etch wrappers, which owes the entity-level deduplication the solver deliberately does not do (M1.1.15); character controller (M1.1.12); compounds and `subshape_id` beyond the constant 0 (M1.1.20); `forge_2d`. | | `v0.11.11-plane-halfspace` | 2026-07-30 | M1.1.11 — Forge 3D shapes: the infinite plane (half-space) | Twelfth M1.1 sub-milestone, and the plan row that grouped Plane with MeshShape is SPLIT — the mesh half becomes M1.1.11.1 because it carries a rigid-solver change (several contact constraints per body pair), an internal-edge policy, and `ShapeStore` owned memory, none of which a half-space needs. New normative spec, appended so nothing renumbers: `engine-physics-forge.md` §1.11.15 (half-space internal model) and §1.11.16 (`subshape_id` as an opaque path decoded by the root shape, root in the LOW bits per `SubShapeID.h`, zero bits for a shape without sub-shapes, so the frozen `0` default survives and no sentinel is needed). Amended: §1.11.1 point 3 (queries visit the unbounded lists too), §1.11.3 (three narrowphase categories; where the refusal lives), §1.11.4 (the back-face bound is MeshShape, not the end of Phase 1 — MeshShape is itself Phase 1), §1.11.7 (fourth signature principle: a probe shape can be refused and the refusal is typed), §1.11.12, §2 (shape table reconciled to the twelve frozen `ShapeType` variants; `RotatedTranslated` recorded as absorbed by `CollisionShape.offset` / `rotation_offset` and will never exist). `engine-phase-1-criteria.md` C1.1's shape list carried nine of twelve and now carries all twelve; `engine-tier-interfaces.md` 0.3 to 0.4; `engine-c-api.md` gains `WeldQueryStatus` and loses a stale `raycast_all` sort comment. THE NARROWPHASE GAINS A TAXONOMY ABOVE THE SUPPORT MAP. A half-space has an UNBOUNDED support map, so GJK, EPA and the M1.1.10 cast kernel do not apply to it; the category is chosen BEFORE a shape becomes a `SupportShape`, and that conversion stops being a total function of the store and becomes an asserted precondition of the convex arm. `ShapeClass` has TWO variants deliberately (the mesh is the third) and every switch on it is exhaustive with no `else`, so M1.1.11.1 is a compile error at each site that owes a decision. The plane's kernels are analytic and CHEAPER than GJK: `sep = n·supportCore_B(−n) − r_b − d`, one support call, closed form, no iteration, no threshold, and the SIGN OF `sep` IS the classification, so §3's three-band regime does not apply and must not be copied in. The `− r_b` term is the failure mode of the whole shape: `support` returns the support of the CORE, so a sphere whose centre lies exactly on the plane penetrates by its radius, and a box-only suite would pass with the term missing — both forms are computed in the same test and the radius-free one is asserted to the refuted answer. AN UNBOUNDED AABB DOES NOT DEGRADE THE BVH, IT DESTROYS IT, measured on the structure: the centre of an infinite box is NaN and that centre is the ray origin a shape cast derives from a box, the surface area is infinite so the SAH cost is infinite at every candidate and the best-cost-child descent degenerates, and the union propagates the infinity to the root after which every query visits every node. The bounded substitute box is REFUSED: the reference takes it (`PlaneShape.h`, default half-extent 1000 m) and its own class comment states that no collision is returned outside that box and that collision at its edge is inconsistent — a tuning constant that changes a query's answer, which §1.11.12 already refuses for the broadphase margin. Unbounded shapes therefore live OUTSIDE THE TREES in a per-layer flat list, and a half-space is never asked for a box: it is asked whether it overlaps one, which `Aabb(T).overlapsHalfSpace` answers exactly by the lowest corner along `n` read component by component, with no infinity and no constant (RD-1, tested against an enumeration of the eight corners on a box neither centred nor cubic, `distance` swept so each of the eight normal sign patterns sees both verdicts and the exact boundary). Slot indices are STABLE, retired slots are recycled LIFO, and ITERATION FOLLOWS THE INDEX — so after A, B, C, retiring A and inserting D iterates D, B, C. An earlier §1.11.15 sentence and four code sites called the list insertion-ordered; that was FALSE and is superseded: what M1.1.14 requires is that the order be a deterministic function of the operation sequence, which slot-stable LIFO satisfies exactly, and no observable result depends on it since queries sort by the §1.11.14 key and `computePairs` by the canonical pair key with adjacent dedup. The bound on list length is the PEAK of simultaneously live slots per layer, not the live count and not the total ever created; the dense ordered list that would give O(live) is recorded with its trigger and NOT built, the peak being measured at 1 in every scene in the repository because a half-space forces a static body. Pair generation runs in BOTH directions, and omitting either makes the other silently wrong: a bounded proxy entering the moved log is crossed with the unbounded lists, and inserting an unbounded shape confronts the existing leaves — by PRUNING on the corner predicate rather than enumerating (RD-3), which cannot lose a pair because a node box is FAT and contains its descendants, so a body whose tight box later reaches the half-space must first escape its fat box and re-enter the moved log. `addBody` rejects a non-static body carrying a half-space by `error.ShapeMustBeStatic` — named on the INVARIANT so M1.1.11.1 reuses it for the mesh — ordered BEFORE any computation derived from a local AABB, which `computeSleepRadius` performs with no branch on body type. `local_aabb` and `unit_inertia` are NaN rather than `undefined`, and the reason is measured: with `undefined`, `computeSleepRadius(plane)` returned 5.2510e-13 at f32 and 6.4444e-104 at f64, finite and plausible and unnoticeable, and `std.debug.assert` is compiled OUT of ReleaseFast, the mode the benches run in. THE DATED UNREACHABILITY OF `error.UnsupportedShape` IS CLOSED BY MOVING THE REFUSAL, NOT THE DATE: the rounded-box latch inside `rayShape` becomes an asserted precondition and the error leaves the ray path entirely (32 lines of mechanism deleted across four files), while the two entries that take a caller-supplied shape handle gain an error channel that separates three outcomes a single `null` conflated ON `main` — a stale handle, an inadmissible probe, and a real miss. The six handle-free entries stay total and that absence is pinned by a named predicate rather than by one type equality. At an INITIAL OVERLAP the cast returns `−direction`, not `n`: all four kernels now agree, returning `n` broke outright the invariant `shapecast.zig` documents as the reason for its own fallback, and the outgoing-cast test did not exist because the suite only swept inward, where the old value satisfied the invariant by accident. Descriptor domain, asserted at creation: `normal` already unit, `distance` FINITE — a NaN distance produced two contradictory silent behaviours, measured, reporting contact for a sphere 1000 m outside while making the same shape invisible to the broadphase. Contact path: the supporting face in direction `−n` gives up to four core vertices, NO CLIPPING runs because a half-space is unbounded, and the returned position is the midpoint of the convex surface point and its projection so the position solver reconstructs both anchors without a special case; `feature_id` uses a FOURTH class tag `0xC000`, free on both halves, so disjointness from the four existing producer pairs is structural and asserted by mask rather than enumerated. A box dropped on a plane rests on four contacts with centre_y 0.495073940 at f32 and 0.495074006 at f64, penetration 0.004926056 and 0.004925994, just UNDER the slop where M1.1.7 RD-1 measured a box on a box just above — not a divergence, and the plane's `sep` is a dot product against a stored unit normal with no clipping behind it, so nothing pushes it either way. FAR FIELD, and §1.11.4 bis splits differently here: the contact normal is the STORED `n` returned verbatim, so length AND orientation are exact at any range and assert as bit equality, and the whole residue moves into `signedDistance` whose error grows like `floatEps(T)·abs(p)`. And a true-zero guard's exactness is FRAME-LOCAL and does not compose — a ray parallel to the boundary in WORLD against a rotated plane body arrives with a transported dot of exactly `−floatEps(Real)`, so the kernel correctly reports a crossing at 8.3886120e7 m at f32 and 4.5035996e16 m at f64; what rejects such a ray is the entry's finite `max_distance`, which §1.11.4 already requires, and NOT an epsilon the kernel would invent. Benches: both raycast and shapecast measured on their existing scenes and on the same scenes with one plane, in the same process back to back, and EVERY mode shows BOTH SIGNS across runs, so the cost of one half-space in a per-layer list is below this bench's noise floor and its sign is not stable — reported as such, no envelope registered for a quantity below the noise. The eleven inherited M1.1.5 to M1.1.10 envelope quantities re-measured against `main` at `712e4b5` through a worktree with the same probe compiled in both trees, both precisions: ZERO movement, digit for digit, corroborated by nine inherited test files being byte-identical to the tag. Leak check proven both ways: with `safety` forced true a deliberate 4 KiB leak printed LEAK DETECTED, and with the default the SAME leak printed no leaks. 356 forge tests green at f32 AND `-Dphysics_f64=true`, debug AND ReleaseSafe. Seven recorded deviations (RD-1 `overlapsHalfSpace` in foundation math, RD-2 `LocalHit` and `CastHit` moved to `support.zig` so the class-dispatching adapter returns ONE type, RD-3 pruning, RD-4 a sixth `BodyManager` adapter, RD-5 the harness, RD-6 the benches, RD-7 `broadphase.zig` 981 to 1224) plus B1, a delivery blocker whose root cause was a search narrowed to one directory reported as a negative. Out: everything MeshShape drags in, a back-face field with no consumer, the fourth ordering-key term, `ShapeStore` owned memory, the ECS authoring surface for plane and mesh (deferred together, the mesh variant needing an asset handle that does not exist before M1.6), the 2D symmetry (`PhysicsModule2D` freezes at M1.8.11), a bit-packed `SubShapeID` creator, and the M1.1.15 group. External review by Codex over four rounds; every finding verified against source before acting, and four sections authored by Claude.ai were refuted — by the code or by that review, none by re-reading. | | `v0.11.11-mesh-shape` | 2026-08-02 | M1.1.11.1 — Forge 3D shapes: the static triangle mesh | Thirteenth M1.1 sub-milestone, second half of the split M1.1.11 row, and the TWELFTH AND LAST shape of the C1.1 list. NOT split, and the size rule was MEASURED rather than invoked: §2.2 targets 500–2000 delivered lines including tests, and the four preceding sub-milestones delivered 3237, 3968, 5095 and 4607 Zig lines — every one 1.6× to 2.5× over, every one merged. New normative spec, appended so nothing renumbers: `engine-physics-forge.md` §1.11.17. Amended: §1.11.4 (the back-face bound is MeshShape, and its largest-component null-direction guard holds for a SUPPLIED vector but not for a COMPUTED one), §1.11.3 (the SOUP has no support map, an isolated TRIANGLE does), §1.11.7 (three principles became four at M1.1.11 without the count following), §1.11.16 (MeshShape is the first shape carrying sub-shapes). `engine-tier-interfaces.md` 0.4 to 0.6. A MESH IS A SURFACE AND NOT A SOLID, categorically and not by setting: membership is false everywhere, `pointQuery` never returns a body carrying one, `closestPoint` measures to the surface and is never zero by interiority. The reference's `sCollidePointUsingRayCast` hit-count parity is REFUSED — it presumes a CLOSED mesh, nothing validates closure, and on an open mesh the answer then depends on an arbitrary ray direction. THE TRIANGLE ENTERS AS A FOURTH `Core` VARIANT AND NOT AS A FAMILY OF KERNELS: a triangle is a BOUNDED convex whose support map is the max of three dot products, the only property GJK, EPA, the manifold generator and the M1.1.10 cast kernel require, so those four serve the mesh UNCHANGED and only the ray kernel gains an analytic arm. ELEVEN exhaustive switches on `Core` broke, not the six a grep predicted: the compiler is the authority on that count and a grep is not. THE `ShapeClass` NET ADVERTISED AT M1.1.11 HAD FIVE HOLES, all closed here: `addBody`, `bodyAabb`, `worldAabb` and `closestPointBody` deciding by `if` or by class assert rather than by `switch` — the last FALLING THROUGH to `supportShape`, a panic in Debug and ReleaseSafe and undefined behaviour in ReleaseFast — plus `gjkPair`, which handed any shape to `supportShape` and which a HALF-SPACE breaks identically, so that fifth hole predates the mesh. `fast_paths.zig` carried the one `else` on a `Core` switch and `.segment × .triangle` passed through it without a decision: right answer, never chosen. THE SHAPE STORE GAINS OWNED MEMORY and `createShape` becomes TRANSACTIONAL — build, `errdefer`, the two `ensureUnusedCapacity` as the only remaining fallible steps, then an infallible commit — with `MeshData.init` validating ENTIRELY BEFORE its first allocation, so a typed refusal allocates nothing. `deinit` walks LIVE SLOTS and not columns. The frozen `destroyShape: fn (*Impl, ShapeId) void` is untouched: that is the MODULE's signature and the module does not exist before M1.1.15. No sanitiser, because removal RENUMBERS and that number IS the `subshape_id`. THE UNIT-NORMAL INVARIANT TOOK ELEVEN ROUNDS, AND THE ENGINE DEFECT WAS FOUND IN ROUND FOUR: everything after it was a defect in the measuring apparatus or in a contract, and that — not the arithmetic — is what made the line long. Each of the first fixes traded one end of the float range for the other. `faceCross` on vertices at `1e10` gives `1e20` whose square overflows f32, so the length reads `inf` and the division answers the ZERO VECTOR — and the comment above `faceNormal` defended the code by arguing that refusing exactly-degenerate triangles keeps `normalize` from answering NaN, which was true and about a different failure, the "justification the callee disclaims" class this repository already named its costliest. `Vec.normalizeScaled` closed that half, the M1.1.9 ray-direction technique the mesh path had not inherited; the CROSS ITSELF overflows one step earlier, after which `normalizeScaled` divides infinity by its own infinite largest component and answers NaN, worse because it propagates. Scaling the EDGES would not have closed it either, `±0.9 · floatMax` giving an infinite edge at BOTH precisions, so this was never an f32 defect. One common power of two over the THREE VERTICES closed the overflows and INTRODUCED A FALSE DEGENERATE at mixed scales, sending a small leg below the subnormal floor so `MeshData.init` accused valid data — silent, and looking like a diagnosis. Per-edge factors improved it; a COMPLETENESS ARGUMENT DRAFTED FOR THEM WAS REFUTED BY MEASUREMENT BEFORE IT WAS WRITTEN, which is why it appears nowhere. Then per-LANE repair, a cross being three INDEPENDENT 2×2 determinants and a lane that overflows having no reason to take the others with it, which was the single largest step of the float series. THE NUMERIC SERIES OF FALSE-REFUSAL RATES ACROSS THOSE FORMS IS DELIBERATELY NOT RECORDED: it was measured through an apparatus that was itself corrected twice mid-flight, a NaN-IGNORING maximum reduction and a transposed `c`/`d`, and publishing rates obtained from a faulty instrument would contradict the very discipline this milestone establishes. What stands is the structural argument, which depends on no measurement: NO ARRANGEMENT OF POWERS OF TWO CLOSES THE CLASS, because where a reduction is required against overflow it must scale DOWN, and scaling down is precisely what loses a component expressible only at the input magnitude. And one current figure: the two float forms kept as witnesses still refuse 17.4% of valid triangles at f32 and 20.3% at f64 under ADVERSARIAL sampling, uniform over the whole exponent range and dominated by absurd spreads — a real mesh lives within a few orders of magnitude, so it is a STRESS METRIC AND NOT A FIELD EXPECTATION, and it carries no normative weight now that the verdict is exact. WHAT CLOSES THE CLASS IS AN EXACT INTEGER TIER, and the decisive insight is that it does not serve to REFUSE but to SERVE — a normal needs only a direction and a direction is scale-free. Each component is an integer mantissa times a power of two, so each determinant is exact in `i1024` at f32 and `i8192` at f64 (the worst case retains all eight terms), with SHIFTS AND ADDITIONS ONLY, wide division and wide int-to-float being nonexistent libcalls at those widths — which is how the constraint surfaced. Two defects inside it, both found by measurement: a term may be dropped only against the ACCUMULATED SUM and never against the dominant term, since retained terms can almost entirely cancel, and the output must NOT preserve true magnitude, the exact cross of a subnormal triangle being toward `2⁻²⁹⁴` and unrepresentable at f32. The short-circuit was then REMOVED outright: the width was already sized for eight terms so it bought nothing but an occasion to err on a path that must be exact and not fast — and removing it took the residual count from 1 to 3, meaning it had been MASKING two. THE ENGINE DEFECT WAS THE DISPATCH, NOT THE ARITHMETIC: `isDegenerate` consulted the TIERED float cross, which returns the first tier producing a non-zero, and a float cross over three proportional points is a rounding residue that reads as a perfectly valid direction — `dir = (0, 0.5, 0)`, the very output shape of tier 2, is what betrayed it. This would have shipped. VERDICT AND DIRECTION ARE NOW SEPARATE QUESTIONS AT THE API and that separation is the real result: the verdict is exact and belongs to `init`, the direction is tiered float and belongs to the runtime on geometry `init` has already admitted. AND THE SAME CONFUSION WAS FOUND AGAIN IN THE TEST THAT WAS SUPPOSED TO CATCH IT: the randomised property derived its verdict from `shippedDirection`, the tiered path, while `shippedZero` — which calls the exact `triangleIsFlat` that `MeshData.init` actually consults — sat thirty lines above carrying the comment explaining why measuring the tiered form is wrong. Three residual "false accepts" at f32 were therefore a measurement of the FLOAT path against the exact oracle, an expected disagreement and the very dispatch defect already fixed in production; one line changed and the count went to ZERO at f32 and stayed 0 at f64. The dominance counters and the direction metric now read `no_direction` and never the verdict, so the CAUSE of that confusion is removed and not only its effect. Before that line was found, two probes had eliminated the engine — an eight-term differential showing the disputed lane's terms symmetric in pairs and its sum exactly zero, and a bit-exact round-trip of `decompose` across subnormals from `−110` to `−150` — which is what left the apparatus as the only possible suspect; they bounded the search rather than finding the target. THREE CONSECUTIVE ROUNDS THEN FOUND THE SAME CLASS OF DEFECT — A GUARANTEE MEASURED INSTEAD OF ASSERTED — and fixing instances one at a time guaranteed a fourth, so the class was SWEPT instead: false ACCEPT was pinned first, false REFUSE was merely counted under a permissive dominance check until `expectEqual(truth.zero, shipped_zero)` was required on every draw (two exact integer arithmetics computing one determinant must AGREE, so the correct form is an equality and not a one-sided bound), and the DIRECTION path's totality was counted too until `expect(!no_direction)` was required per case — that one guarding a production `orelse unreachable`, since a `.degenerate` on a non-flat triangle would have `init` admit it and `faceNormal` fire. The sweep rule is now explicit and auditable in one pass: EVERY quantity describing the SHIPPED form is a per-case assertion, and a counter survives only for the two historical FLOAT forms, which are allowed to fail and exist for non-vacuity and the dominance narrative. Two false metrics were DELETED rather than converted, because a guarantee has no counter; a length guard that could no longer be false was removed, because a guard that cannot fail is not a guard; and the dominance ladder dropped to TWO rungs, the shipped form being unlistable beside forms permitted to fail without implying it might fail too. A PARALLEL SWEEP OF THE DOCUMENTATION found the mirror-image motif — corrected text added without deleting what it replaced — in three places: `vec.zig` carried BOTH contracts one line apart, the corrected asymmetry and the superseded "never the area is zero" with the variant docs repeating the wrong one; `mesh.isDegenerate` stated "three of 932 reached the store", a pre-rewiring measurement readable as current; and `math.zig`'s re-export had kept "decided exactly" after `vec.zig` was narrowed — a contract narrowed at one site and not at its re-export being a contract not narrowed. THE GUARANTEE IS UNRESERVED AND EVERY HALF IS ASSERTED: classification is TOTAL and EXACT, every finite triangle having a direction or being exactly flat, so no triangle is ever mislabelled; FALSE REFUSAL IS ZERO, by construction and asserted per case; FALSE ACCEPT IS ZERO, the production verdict and an independently written integer oracle agreeing without exception at both precisions, with a NON-VACUITY control — the float forms still accept eight degenerates between them at f32, so the family bites and the agreement is not the agreement of two silences; DIRECTION TOTALITY IS ASSERTED per case; and every admitted triangle gets a normal unit to `unit_k` ULP, exact `1` being reachable only on an axis-aligned cross. There is NO "not representable" error variant: it would have no reachable cause, and an error no caller can provoke is an assertion — the repository has removed a dead public variant once already for that reason. `triangleCross` IS NOT A CLASSIFIER AND ITS CONTRACT IS ASYMMETRIC, which is the real content and not a nuance: `.degenerate` is reached only AFTER the integer tier, so it IS a reliable flatness verdict and the area is exactly zero; `.direction` comes from the first float tier forming a finite non-zero vector and therefore does NOT prove non-flatness. The asymmetry is stated ONCE, and the documentation names `triangleIsFlat` as the classifier at both sites where the contract is announced. It was deliberately NOT made to consult the exact tier: that would put integer arithmetic on the ray kernel's hot path and collapse the verdict/direction separation §1.11.17 makes normative. §1.11.17's original unqualified promise to serve every non-zero area was the AUTHOR'S OWN DEFECT and the root cause of the whole line: an absolute guarantee written over the entire float exponent range, then treated as load-bearing, producing five rounds over triangles whose coordinates span 300 orders of magnitude — a domain no asset will ever occupy. NINE OF THE LAST TWELVE FINDINGS WERE DEFECTS IN THE MEASURING APPARATUS OR IN A CONTRACT, NOT IN THE ENGINE: a property measuring the tiered path instead of the exact verdict while the correct helper sat thirty lines away; three guarantees counted instead of asserted; a contract narrowed at one site and not at its re-export; two superseded doc formulations surviving beside their corrections; a scratch copy of `exactLane` instead of the shipped code; two vacuous test families (a collinear family built by float interpolation ROUNDS and produced zero true degenerates out of four thousand; one built by exact integer multiple makes every component `a·b − b·a`, exactly zero in float too, so no form could false-accept and the assertion proved nothing); a stale dump read as current; a probe drawing one unit per vertex so the total-cancellation family was absent by construction; and a best-of-three unable to resolve a sub-5% timing question that INTERLEAVED runs settled. Each repair revealed the next apparatus defect. A transposed `c`/`d` in `laneUnlessOverflow` was caught by the existing collinear pins, which earned their place. THE STANDING LESSONS ARE THREE: audit the WIRING and not the result, since every prescription issued on a reported figure — the short-circuit as cause, the subnormal hypothesis, two probe repairs already in place — was refuted by measurement rather than by argument; when two implementations of one exact arithmetic disagree, ask first which of them was ever checked; and when the same class of defect appears twice, SWEEP THE CLASS instead of fixing the instance, because fixing the instance guarantees the next round. The power of two remains load-bearing twice and is ASSERTED not argued: it rewrites only the exponent field, so exactly collinear points stay exactly collinear and the true-zero guard keeps its verdict where an arbitrary divisor would round; and because `normalizeScaled` divides by a component of its own input, any common factor CANCELS, so the normal is bit-identical whatever exponents are chosen, swept over 49 combinations. `Vec.scalePow2` applies its factor in TWO HALVES because the exponent can leave range when the result does not: reducing `3.4e38` needs `2⁻¹²⁸`, lifting a subnormal needs `2¹⁴⁸`. The shared forms live in `foundation` (`math.triangleCross`, `foundation/math/exact.zig`), the only possible home since `pipeline/` cannot import `mesh.zig`, and `exact.zig` will serve beyond meshes — which is why the descriptor domain was NOT bounded to hide the residual, a bound that would have masked an apparatus defect behind a fifth pre-freeze surface change. Tiers 2 and 3 sit behind tier 1's test so the current path gains nothing: raycast best-of-three `760.0 / 1706.0 / 3138.5` ns against the previous form's interleaved `756.3 / 1654.9 / 3002.0`, single-tree and therefore confirming the absence of broad movement and nothing more. Per-edge repair had cost a stable `+4.7% / +3.8% / +4.4%` by INTERLEAVED runs against the conditional form's `+0.5% / +1.2% / +0.7%`, and the `+11%` first reported for per-edge was an INTER-SESSION ARTEFACT — the reasoning held and only the amplitude moved. THE MESH CANDIDATE SET WAS NOT CONSERVATIVE against the GJK margin: `overlapShapeBody` bounded it by the probe's box while the convex arm calls GJK with no filter, so a triangle separated by less than `16 · floatEps(T) · coordScale` was culled before the kernel saw it and the entry answered `false` where the same probe against a convex answers `true` — §1.11.12's predicate being that the GJK regime is not `separated`, and nothing else. Closed by inflating with the NORMATIVE margin itself, `contact_margin_conv_k` and `contactMargin` hoisted out of `gjk.zig`'s locals and re-exported so no second epsilon exists, with `MeshData.maxVertexMagnitude` giving the mesh side in O(1); a local duplicate of `contactMargin` in `fast_paths.zig`, harmless while both were private and a drift risk once one was public, went in the same pass. `worldAabb` IS TIGHT OVER THE TRANSPORTED VERTICES, a recorded deviation to the contrary refused and withdrawn: the three primitives are each tight deliberately, and the cost argument rested on a per-proxy-update path that DOES NOT EXIST for a shape that forces a static body. Tight over the STORED vertex set, unreferenced vertices included; the mesh is also the one shape whose local box is not origin-centred, so the centre is transported too. Then MEASURED at 16 000 triangles: 72.8 µs against 11.5 ns, three orders, the cheapest entry of the family having become the most expensive — so the per-body box CACHED AT `addBody` landed in the same milestone, NaN in every non-mesh row so a faulty read is loud, and with NO invalidation logic. What replaces invalidation is POISONING: `setPosition`/`setRotation` reset it on any non-dynamic body and the arm falls back to the O(V) pass, correct and merely slower — so correctness rests on a fallback and not on a promise about M1.1.15, and the branch is guarded by body type so the solver's hot path pays nothing. The ray kernel is Möller–Trumbore in its signed-determinant form where THE DETERMINANT IS THE ORIENTATION (`det = −d·n`) and where there is exactly ONE DIVISION, at the end, against `\|det\|` and never a reciprocal, since for a denormal determinant `1/det` overflows and `0 · inf` is a NaN that passes both barycentric bounds. It ALSO returned NaN as a distance on extreme inputs, found by measurement: `det` at `1e40` is infinite, `u` and `v` reach infinity and PASS their bounds since `inf > inf` is false, and `t_num` then multiplies infinity by an exact zero — the NaN the file's own comment credited the `\|det\|` form with preventing, arriving by the other edge. Reducing unconditionally never produced a NaN but was SLOWER AND LESS ACCURATE, a very negative exponent pushing small origin components into the subnormals where the mantissa truncates, returning `9.99979261261345e19` where the origin scale returns `1.0000000200408773e20` for a true `1e20`; reducing by the triangle alone left NaN on twelve rows. What ships is the origin scale first with a reduced retry on a structural signal, which is why `Attempt` reports `.degenerate` and `.unrepresentable` APART from `.miss`: conflating either with a miss is what let the NaN out. One residual is measured and asserted rather than hidden — an ordinary-magnitude origin against a triangle whose legs are `4 · floatTrueMin` is a MISS at f32 in every form, and the test asserts the miss at f32 AND the exact distance at f64, the contrast proving a precision limit rather than a design one, with the failure direction safe throughout: a miss, never a NaN and never a false hit. §1.11.4 bis already records that the information is not in the inputs. The boundary is INCLUDED on all three edges, so a ray through a shared edge hits both triangles and the selection above breaks the tie on the SMALLEST TRIANGLE INDEX, never on traversal order. The static acceleration structure is NOT the broadphase `Bvh`: fixed set, no insertion, no removal, no fat margin, no rotation rebalancing, binned SAH over three axes × 12 bins into a FLAT array, an exact `2T − 1` reservation so no growth can fail mid-build, and a MEDIAN fallback when every centroid coincides. `Aabb(T).rayInterval` and `Aabb(T).inflate` are reused VERBATIM and `traverseRay` IS `traverseCast` at zero extent. TRAVERSAL IS BY EXPLICIT FIXED-DEPTH STACK AND NEVER BY RECURSION, the `Bvh`'s recursion being safe only because its rotations bound its height: `max_tree_depth = 64` held BY CONSTRUCTION, the builder forcing a leaf there past which a leaf simply holds more triangles — costing traversal time and changing no answer — then asserted, with every push checked against a stack of `h + 2`. Adjacency and the active-edge flags are built AT CREATION in the same transaction, not where they are consumed: building them later would reopen the OOM transaction and change `MeshData`'s owned set after it had been tested. Pairing is by SORTING `(lo, hi, triangle, edge)` keys and pairing adjacent runs, no hashed container; a run of one (open boundary) or of three and more (non-manifold) is ACTIVE. Convexity is `(n₁ × n₂) · edge_direction > 0` with the sign DERIVED in the comment from a concrete pair rather than guessed, the parallel branch at TRUE ZERO splitting on the sign of `n₁·n₂`, and the near-antiparallel residual NAMED rather than papered over — closing it would take the reference's second named constant at `cos(179°)`, which Weld does not take. THE ACTIVE-EDGE THRESHOLD LANDED ON THE DESCRIPTOR as `active_edge_cos_threshold: f32`, default `cos(5°)`: a NAMED PHYSICAL parameter of the class of `restitution_threshold` and `penetration_slop`, which §1.11.2's `k · floatEps(T) · coordScale` discipline does NOT govern. Declared in `mesh.zig` and NOT in `solver_config.zig` — flags are baked at creation, so a solver field would be read after the decision it governs, and `mesh.zig` importing the rigid branch would invert the dependency — and reaching the descriptor because otherwise "configurable" was FALSE, the only path to `MeshData.init` being `createShape`, and after M1.1.15 the field could never be added. THE SECOND ARGUMENT FOR THAT TYPING WAS REFUTED BY MEASUREMENT: the geometric term is itself build-dependent by 2.3e-8, the same order as the 2.2e-8 between the two renderings of the constant, and no `f32` value falls strictly inside that band at all, the `f32` ULP near 1 being 5.96e-8, so the old typing could never flip a verdict BY ITSELF. The field stands on the window argument alone and the `f32` typing on hygiene; flags at the threshold therefore remain precision-dependent, inherent to any threshold and not a defect. BACK FACES LANDED on `RaycastQuery`, `ShapeCastQuery` and `OverlapQuery`, in the last window there was. A back-face hit returns a FLIPPED normal: §1.11.4 declares `normal · direction <= 0` on all hits and the `−direction` choice at distance zero draws its justification from it, so the reference's unflipped normal would puncture it — assumed divergence, and nothing is lost since the caller asked for the mode and the real side stays reachable through `subshape_id`. THE OVERLAP PREDICATE AS AUTHORED CARRIED A SIGN ERROR in both §1.11.17 and the brief, which wrote `n · support_probe(n) − r_probe < n · v₀`: the radius EXTENDS the probe toward the front so it is ADDED, that formula seeking the MAXIMUM of `n · x` while §1.11.15's seeks the minimum, and flipping the support direction without flipping the radius term is the whole error. The spec's own next sentence decided it and a unit sphere centred on the plane is the discriminating case. `back_face_mode` ON `OverlapQuery` IS NEARLY INERT, MEASURED AND NOT ARGUED: a triangle lies IN its plane, so a probe entirely behind cannot touch it and GJK already reports `separated`, while any probe that does touch reaches the plane and therefore straddles — leaving a band of a few ULPs where a core just behind is `.shallow`. Kept anyway, and not for symmetry: `overlapShape` returning only bodies is a Weld choice and not a fatality, the reference carries `mBackFaceMode` on `CollideShapeSettings` because its equivalent returns points and normals, and after M1.1.15 the field could never be added. The inertness is written on the field. `subshape_id` WAS FILLED ON NO FAMILY AT ALL before this milestone; `LocalHit`, `BodyCastHit` and `BodyClosestPoint` gained it, without which `ShapeCastHit` and `ClosestPointResult` would have kept their defaults in silence. A mesh returns ONE HIT PER BODY, decided in `raycastBody` so the three collectors are untouched: §1.11.14's key does not discriminate two triangles of one body, so two hits would be neither ordered nor invariant. Contacts: `collidePairOrdered` becomes `collidePairEachOrdered`, nine arms each owing its decision, mesh × convex delivering SEVERAL MANIFOLDS through a collector — and `collidePair` IS that entry with a one-slot collector, so the 3×3 has one implementation and not two, its precondition asserted at its head. The back-face cull compares the manifold normal oriented MESH TO CONVEX against the outward normal, strictly: that is the orientation resolution borrows, so a disagreeing contact is one whose resolution would drive the body through the surface. A sphere at the CENTRE OF A CLOSED CUBE returns ZERO manifolds, exiting every face from behind, which is what single-sided means. The contact cache's second key term, unused at 0 since M1.1.6, is FILLED with the triangle index; the test's decisive property is not that it warm-starts but that every stored key is pairwise distinct WHILE at least one `feature_id` recurs under two different `subshape_id`, so the collision the term prevents is live. `lessByPairKey` COMPARED ONLY `pair_key` while `std.mem.sort` is `std.sort.block`, UNSTABLE — so with several constraints per pair the order was neither the traversal's nor a contract but the sort's internals, voiding M1.1.8's written guarantee that contiguity never rests on sort stability, on the order-sensitive path of a Sequential Impulses solver. Closed with TOTAL keys at both sort sites, `(pair_key, subshape_id)` and `(rank, pair_key, subshape_id)`, both comparators exposed and totality asserted rather than inferred from sorted output. The wake now FOLLOWS `prepare`, forced by the collector holding a `*const BodyManager`, and the equivalence is PROVABLE: `prepare` reads motion, pose, both velocities, friction and restitution, `wakeBody` writes `flags.sleeping`, `sleep_time` and the two `sleep_ref_*` columns, disjoint sets. PERMUTATION INVARIANCE OF A SIMULATION IS PHYSICAL AND NOT BIT-EXACT, and the milestone's own requirement was mis-posed: SI resolves in pair-key sort order, keys derive from `BodyId`, `BodyId` from creation order. Measured over 300 ticks, Δy = 1.34e-4 m and IDENTICAL at f32 and f64, which shows the cause is discrete and not float noise; bound 1 mm, seven times the measurement, stated as a physical claim. Bit-exact invariance holds for the QUERIES, where §1.11.14's key manufactures it. THE BRIEF'S SLIDER COULD NOT SHOW THE ARTEFACT: a BOX across a flat seam produces ZERO edge contacts, maximum tilt 6e-8, because a box lying flat touches face to face so the support plane IS the face. The artefact belongs to a probe whose nearest feature can be the EDGE — a SPHERE whose centre has crossed the seam projects OUTSIDE the triangle behind it, which answers from its seam edge, 8 edge contacts and a real tilt of 4.2e-3 at 5 cm. A second measurement settled the rig: the default `linear_damping` of 0.05 alone costs `5 × (1 − 0.05/60)⁶⁰ = 4.756049` m/s over sixty ticks and matched the first probe's loss digit for digit, so the slider is frictionless and undamped and the retained velocity is catching and nothing else. Slider and counter-factual live in ONE test over geometry identical vertex for vertex, only the index topology differing: paired seams flat, inactive, corrected, 5.000001 m/s; unpaired seams open, active, uncorrected, 4.647478 — the second failing the first's bound in the same test. The code counter-factual is recorded: making `internalEdgeNormal` return null takes down FOUR tests. THE COMPLEMENT IS WHAT REFUSES BLIND SMOOTHING, slider and counter-factual alone passing an implementation that corrected everything: a 30° fold stays active at 0.769745 m/s, and on one 2° geometry the descriptor's threshold alone moves the verdict both ways, `cos 5°` inactive at 4.969233 against `cos 0.5°` active at 4.833944. At manifold grain the back triangle returns `(0.40614, 0.91382, 0)` unpaired and exactly `(0, 1, 0)` paired while the face contact stays `+Y` in both, so the correction is TARGETED; and a CONCAVE seam stays inactive against a threshold tight enough to activate a convex fold of the same 10°, so the angle cannot be the explanation. A frictionless slider retaining 5.000001 m/s of 5 is a 2e-7 relative GAIN, negligible here and the signature of NGS energy injection if it grows — recorded for the next milestone without action. Closing benches: twelve ReleaseFast runs, branch against `main` at `03157b7` through a worktree, both precisions — the six anti-DCE checksums IDENTICAL to the last digit, so the fourth `Core` variant, the hoisted margin and the new asserts changed NO answer on any pre-existing path; timings move in BOTH directions, extremes `−7.0%` to `+5.4%`, so NO envelope is registered for a quantity whose sign is not stable. A drift in `bench/results/forge_narrowphase.md` was ATTRIBUTED rather than guessed by replaying that bench across the four commits that touched the narrowphase since: `6e9ad44` still returns the committed `1356124.4934110916` and `7e63912`, the M1.1.3-HF EPA hotfix, returns today's `1356124.4937987747`. The three result files are RESTORED and not refreshed: each is the record of the milestone that wrote it, and the attribution is recorded here so the next reader does not repeat the bisect. Three tooling defects of one class, all self-reported: a `zig build … \| tail && suite` chain reports `tail`'s status, so a red build was pushed under a green self-report; an unquoted `$flags` in zsh passed two options as one argument, which fails LOUDLY so every gate that reported a green fourth corner really exercised it; and best-of-three could not resolve a sub-5% timing question. The standing practice is now: capture `$?` before any filtering, keep the FULL log on failure — the first script kept only the Build Summary line and hid the one line that explained everything — and interleave when comparing forms. RD-7 accounts for thirteen files changed outside the frozen scope list, `math.zig` joining for the two re-exports, each carrying its reason in place; three listed files were untouched and none needed touching. 419 forge tests green at f32 AND `-Dphysics_f64=true`, Debug AND ReleaseSafe; zero `@panic` remaining in the module, counted. External review by Codex across eleven closing rounds: seventeen findings raised, fifteen verified and fixed, one refused with its reason, one proposed fix declined on cost (a widened accumulator closes only f32, `f64` needing software-emulated `f128` evaluated per candidate triangle on a hot path); two diagnoses corrected in verification (the `gjkPair` hole is not mesh-specific and predates the milestone; the constraint order rests on an unstable sort rather than on traversal order); and five normative or framing items were the author's own — the overlap predicate's sign, `active_edge_cos_threshold` missing from the frozen descriptor, §1.11.17's unqualified promise to serve every non-zero area, an agreement required in one direction only, and a superseded contract formulation left standing beside its correction. Out (later, NOT debt): HeightField, joining the `.triangle_soup` category at M1.1.20 with an IMPLICIT structure; per-triangle material and user data, this milestone giving §1.11.7's accessor its first real argument while the table stays §4; quantised triangle storage; the fourth term on §1.11.14's ordering key, deferred a second time with the reason now written down; a bit-packed `SubShapeID` creator, which waits for compounds since a mesh is root; runtime deformation; the ECS authoring surface, needing an asset handle that does not exist before M1.6; `step()`/`PhysicsWorld`/`PhysicsModule`/ECS `Transform` sync and the `f32` to `Real` widening of the public surface as one grouped decision (M1.1.15); far-field conditioning, characterised and not fixed; the 2D symmetry; character controller (M1.1.12), sensors and triggers (M1.1.13), cross-platform determinism (M1.1.14), joints (M1.1.16–18), ConvexHull (M1.1.19), Compound (M1.1.20). | +| `v0.11.12-character-controller` | 2026-08-05 | M1.1.12 — Forge 3D: the kinematic character controller | Fourteenth M1.1 sub-milestone, and the FIRST that is neither a shape nor a solver pass. **THE LAST DEFECT CLOSED WAS ONE THIS BRIEF HAD ALREADY DECIDED TO SHIP OPEN, AND THAT DECISION WAS WRONG** — recorded because the reasoning is the lesson, not the fix. A character whose base sits EXACTLY tangent to a surface served no horizontal motion at all, permanently: the sweep reports a contact at distance zero, `paddedAdvance` returns zero, the slide returns a horizontal motion projected on a horizontal plane unchanged, and all four iterations are consumed with the remainder DROPPED. Found while BUILDING A PROBE for something else, measured at seven heights, and then judged acceptable to defer on a reachability argument — "unreachable from play, only authoring reaches it" — that ignored the public DEFAULT of the field: `position: Vec3 = Vec3.zero` over a floor at `y = 0` IS the failing configuration, in a descriptor the same reasoning had authored. A precondition the field's own default violates is a bug with an apology attached. External review reopened it, and the trace then made the defect WORSE than the deferral had assumed: `depenetrate` MANUFACTURED the state rather than merely failing to leave it, resolving a character 0.05 m inside the floor to a base of exactly `0.000000000` and freezing it — so any interpenetration at all, from a spawn, a teleport, a resize or a platform, ended frozen. Closed in `depenetrate`, which now resolves to `padding` of CLEARANCE and not to touching, `engine-physics-forge.md` §1.12.6 making the stand-off an obligation of the controller that nothing had established (`paddedAdvance` cannot, having nothing to subtract from at a zero advance). The branch cannot over-fire by CONSTRUCTION rather than by a threshold: a manifold exists only within the contact margin, so a capsule already standing off is `.separated` and invisible to the query — traced, 0.005 and 0.02 produce no contact at all. Two candidate fixes had been costed and one was refuted by this trace: the per-call EXCLUSION through `sweepNearest` was unnecessary, the manifold path seeing a tangent contact perfectly well since `gjk.zig` classifies exact tangency `.shallow` and `collideOrdered` answers null only on `.separated`. Two forms were refuted by measurement and are recorded so they are not retried: not counting the iteration leaves a bit-identical state, hence an infinite loop; serving the remainder TUNNELS, measured through a wall whose face stands at 0.8. Three currently-green expectations moved and each was re-derived rather than bumped — including one whose clean value had itself been a consequence of the freeze, a tangent base making `stepDown`'s padded advance clamp to zero. A FOURTH round then closed the same freeze on the `padding = 0` path, which the fix had left intact because `penetration + 0` at a tangency of `−0.0` moves nothing: the depenetration target gains a NUMERICAL FLOOR `standoff_floor_k · floatEps(Real) · coordScale` with `k = 64` strictly above `contact_margin_conv_k`'s 16, since the point is to leave the contact margin and not to sit on its edge — and that constant is the one place in this module where §1.11.2's tolerance discipline DOES govern, the opposite of `padding`, `max_slope` and `predictive_contact_distance`. Zero stays in the domain: removing it would have masked the defect instead of closing it. §1.12.6 is narrowed to match what the controller actually guarantees — `padding` is what a SWEEP reserves, the POSE invariant being only that the capsule is never left inside the contact margin — so an authored pose closer than `padding` but clear of the margin is deliberately NOT normalised. Then the FLOOR ITSELF was scoped: `@max(padding, floor)` let it OVERRIDE a requested padding — `64 · floatEps(f32)` crosses the 0.02 default at 2 632 m and reaches 3.8 cm at 5 km, so a caller asking for 2 cm silently got 3.8 and two identical scenes translated apart stopped at different distances — and it now serves only a caller who asked for no stand-off at all. **AND THE WHOLE APPROACH WAS WRONG FOR FOUR ROUNDS**: each fix closed one ARRIVAL PATH to exact tangency — the depenetration's stand-off, then the same at all five advance sites, then its scoping — and another path appeared every time, because arrival is a float-resolution phenomenon with as many paths as one likes (a 500 m collider reseats the capsule whatever the floor; a `padding` of `floatMin` adds a value that changes no bit). What is FINITE is the CONSEQUENCE, and it lives in one place: a zero advance against a surface that does not oppose the travel direction obstructs nothing, yet the slide leaves the motion unchanged and the budget burns with the remainder dropped. That body is now set aside for ONE retry that does not spend the budget — `SweepCollector` gaining a second exclusion it turned out to already have the shape for, 48 lines, no epsilon (exact-zero advance, sign of a dot product), bounded by construction at one slot. The round-2 estimate that this needed threading through five call sites was made WITHOUT READING `SweepCollector`, which already carried an exclusion — the same defect of prescribing from an imagined mechanism that cost the four rounds. A squeezed character now walks and a corridor now serves motion along itself, both having pinned the stall as correct. The `padding < radius` guard added in that series was REMOVED again, its freeze motive being the declared stand-off honoured and its traversal motive refuted across fourteen configurations — `2 ·` and `3.3 · radius` against a 0.1 m wall at seven entry depths straddling its mid-plane, the capsule exiting on the side it entered from every time. **A REMAINING f32 ENVELOPE IS DECLARED AND NOT FIXED**, with its real cause rather than "large collider": at a 1 km box `slideNormal` returns NULL, so the slide takes its documented "stop rather than guess a direction" exit — serving it would mean inventing a direction the narrowphase declines to supply. Measured boundary at f32, a scale and not a distance since `gjk.zig`'s coordScale is relative: 100 m half-extent serves every call, 300 m stalls on the third, 500 m and above from the second, and there is NO stall at 50 km from the origin; f64 serves 2 km. **EIGHT green assertions in this milestone proved nothing**, the eighth found by EXTERNAL REVIEW and not by the probe table — whose blind spot is now named: it probes the mechanisms one thought to disable, so it cannot catch a test that exercises no mechanism at all. The counter-measure is a MUTATION probe rather than a disabling one — restore the previous implementation and require the test to fail — which is how the scoped floor is now pinned, `@max` breaking a test where it previously broke none. Normative spec authored for it: `engine-physics-forge.md` §1.12, appended so nothing renumbers, plus §9 rewritten as the calling surface, §1.8.5's W4 gaining its first named producer, and §1.12.6's slope constraint on the slide added mid-milestone. `engine-tier-interfaces.md` 0.6 → 0.8. `engine-movement.md` loses its ground raycast entirely and its crouch mutation; `engine-gameplay-systems.md` §18 loses a triple duplicate declaration of a domain it does not own. THE CONTROLLER IS VIRTUAL AND CARRIES NO SIMULATED BODY — contested and settled on the reference, whose `CharacterVirtual` has no rigid body and is not tracked by `PhysicsSystem`. BUT IT CARRIES A BROADPHASE PRESENCE, an *inner body*: mandatory on `PhysicsModule`, optional per character, defaulting to ON, which inverts the reference's default because the failure mode of default-off is a character nobody can query, found late. The argument is internal to the frozen surface and mentions no demo: `CharacterDescriptor` has carried `collision_layer` since its original version, and in Weld's query family the object layer is HOW an object declares itself visible to other callers' queries (§1.11.5) — so either the character has a presence, or that field has no observable effect. An earlier justification derived from `engine-phase-1-criteria.md` C1.8 was RETRACTED and superseded (RD-3): the C1.x criteria MEASURE whether the engine arrived somewhere and are not design inputs, and an argument that evaporates when the demo changes was never the argument. Six entries added to the frozen surface in the last window there was — `destroyCharacter`, `resizeCharacter`, `setCharacterPosition`, `getCharacterInnerBody`, `setAngularVelocity`, `moveKinematic` — plus an error channel on `moveCharacter`, plus `BodyDescriptor.can_sleep` (a spec debt open since M1.1.8), plus `setBodyTransform` declared a TELEPORTATION deriving no velocity. `PackedId.dead` reserves the all-ones no-handle bit pattern (RD-1): `ground_body`'s default of `0` was a live handle to slot 0 generation 0, so NO bit pattern of that field meant absence and the field was unreadable without consulting a sibling — a coupling the C ABI cannot express, `engine-c-api.md` having neither `struct_size` nor a minor version. THE DISCRIMINANT FOR AN ERROR CHANNEL IS WHETHER AN ENTRY RETURNS A VALUE, not whether it writes: `createCharacter`/`moveCharacter`/`resizeCharacter`/`getCharacterInnerBody` return, so a dead handle has no honest answer; `destroyCharacter`/`setCharacterPosition` return nothing, so a no-op IS an answer. `setCharacterPosition` was made fallible mid-milestone and reverted on that rule. Position is the BASE of the capsule and never the centre of its shape, the offset living in exactly one named place — the reference PARAMETERISES that anchor through `mShapeOffset` and Weld FIXES it. Ground determination is a BOUNDED DOWNWARD SWEEP and not manifolds at the current pose: `collideOrdered` returns null on a separated pair and a resting character stands `padding` ABOVE its floor, so a manifold-only reading answers `.in_air` for a standing character. At distance zero the sweep normal is `−direction` and unusable on a slope, so the fallback is the seventh body adapter, `collideShapeBody`, which is why gate B delivered two entries. `max_slope` is stored as a COSINE computed once, an `acos` per contact per frame being exactly what M1.1.14 must make reproducible. THE SLIDE HAD TO BE CONSTRAINED BY SLOPE and §1.12 did not say so: measured, a character climbed any face up to 90°−ε by walking into it, 0.583 m of rise in one call against a 50° face under a 45° limit, with the verdict correctly saying `.on_steep_ground` throughout — the engine telling the truth while the pose climbed. The rule caps the projected motion's up component at `max(up_before, 0)` and NOT at `up_before`: capping at `up_before` drives INTO the plane on an inclined face (measured `dy = 0.00000`, the character pinned to the cliff) and annuls the physically correct answer, a body sliding down a 50° slope descending more slowly than in free fall. DEPENETRATION PUSHES OUT AND NEVER THROUGH: it reverts to the entry pose the moment a contact is found whose plane the BASE has crossed since entry. Without it the exit side of an unresolvable squeeze was the PARITY of `max_depenetration_iterations` — at 3 and 5 the base landed 0.800000 below the ground plane and nothing in the suite moved, so an odd count would have shipped in silence. On the BASE and not the centre, measured: the centre of a 1.8 m capsule is still 0.10 m above a plane its feet have passed 0.80 m below, so a centre test does not fire at all. A narrow corridor was examined as a second instance and MEASURED not to be one: its two constraints are symmetric about the entry pose, so the oscillation stays bounded inside — the tunnelling mode needs a constraint at EXACTLY ZERO penetration at entry. Self-exclusion is UNILATERAL, which gives character-versus-character collision for free where the reference needs `CharacterVsCharacterCollision`; unobservable at gate C and asserted there only through the one well-defined property that does not depend on a normal the narrowphase documents as undefined — the ground is never the character's own presence — and breaking FOUR tests by gate D. `resizeCharacter` is atomic, feet-anchored, preserves the `BodyId`, and separates three outcomes where a bare `bool` would conflate a caller fault, an OCCUPIED target volume (a legitimate gameplay answer) and success. `syncPresence` was reordered so the single fallible call precedes every mutation. An interim form published the UNION of the old and new boxes and was WRONG twice: `Bvh.update` returns without refitting when its stored fat box already contains the new tight one, so a teleport's leaf covered the whole trajectory permanently and no later call shrank it; and the failure mode the union guarded does not exist, `Broadphase.update` already reserving its moved-log slot before touching the node. Found by external review, on both counts. THREE ROUNDS OF EXTERNAL REVIEW AFTER THE INTERNAL GATES CLOSED, nine findings, every one verified against source before being acted on and every one confirmed — symptom and diagnosis. Their apparatus: the push applied before a publication that can still fail, so a retry double-applies it; the broadphase proxy outliving the character, whose own comment counted three released resources where there are four; the manifold fallback feeding `ground_velocity` the penetration MIDPOINT instead of the body's surface point, on a path no test reached because the rotating-platform case goes through the sweep; `step_height` unvalidated where every other stored physical parameter is, absent from the brief's own enumeration three times over; `setShape` accepting any non-dynamic shape swap while maintaining only two of its four consequences; in the second round, the deferral of the push having DOUBLED the force ceiling, since the entries were applied one `addImpulse` each with the cap per entry — a ceiling one can exceed by being touched twice is not a ceiling, closed by summing per body and capping the sum once, whose test reads the same number at both precisions where the slack-ceiling one does not; and in the THIRD round, the reopened tangency defect above plus a comment left describing the pre-coalescing worst case. NINE OF THIS MILESTONE'S OWN FINDINGS WERE IN THE MEASURING APPARATUS: three tests that asserted nothing at gate E alone, a proxy-freshness test that passed with the proxy update REMOVED — because a broadphase box is only a CONSERVATIVE FILTER, so a stale fat box the ray still crosses yields the correct distance and the query is right for the wrong reason, what a stale proxy loses being a candidate the tree no longer offers — a bit-exact comparison passing the same literal `1.8` at two precisions and therefore comparing two different inputs, a tie-break test whose insertion order made both rules agree, a closed form DERIVED at 1.95 and MEASURED at 0.688, three harness defects of one class (an exit code taken from a trailing `echo`, a regex broken by an apostrophe, four probes reporting failure on compile errors), and two bench rows whose accepted/refused counters caught a cost measured against an empty tree and then a refusal timed under the name of a success. The standing formulation is CC's: *an assertion that exercises a path does not thereby test the mechanism that path uses*, and every one was found by DISABLING the mechanism, never by rereading the test. A tolerance class was added for it: a quantity that entered through the `f32` public surface and is compared at solver precision needs an `f32`-grade tolerance in BOTH builds — the discriminant is the quantity's ORIGIN, not the representability of its literal — now normative in §1.11.2. Reference lineage verified on source at `jrouwe/JoltPhysics@master`, including the v5.6.0 bug fix reproduced as a test: stair walking against a wall low enough to arm it and high enough to fail it made the character exit FURTHER than it asked, measured here at 0.37 where 0.02 is correct, closed by requiring a positive drop. The padding on the step's FORWARD sweep is load-bearing and its counterfactual was refuted by measurement: not 0.02 m of setback but 1.24 m of legitimate travel never served, an unpadded advance leaving the capsule flush so the landing sweep reports the WALL at distance zero and the whole step is refused. The eleven inherited M1.1.5 to M1.1.10 envelope quantities re-measured against `main` at `a4354df` through a worktree with the SAME probe compiled in both trees, both precisions: ZERO movement, digit for digit, corroborated by all seventeen inherited forge test files being byte-identical to the tag, `solver_test.zig` included. The NGS energy-injection watch is answered by measurement and the answer is arithmetic: `5.0000005` at f32 is EXACTLY one ULP above the launch speed, and f64 retains exactly `5`. Bench REPORTED, not gated, five paths INTERLEAVED across eight reps: plane 212.0 ns, stairs 2235.5, wall 1764.5, mesh floor 7979.0, `resizeCharacter` 203.0, the worst still leaving 2089 calls per 16.67 ms frame. Green at f32 AND `-Dphysics_f64=true`, Debug AND ReleaseSafe. Out (later, NOT debt): the Etch surface of the controller, its service and wrapper names being deferred to M1.1.15 with the rest of the physics service — `physics_move_character`, `physics_resize_character` and `physics_set_character_position` are marked PROVISIONAL in `engine-movement.md` so they do not become canonical by inertia; the ECS `VirtualCharacter` component, needing the `Transform` sync of M1.1.15; `moveKinematic`'s body, deriving a velocity from a target pose belonging with the tick cycle, and with NOTHING to stub before `src/interfaces/` exists; a landing-clearance test for the one unguarded step mode, whose failure direction is measured and bounded instead; the crease branch of the slope cap, NOT DISTINGUISHED by any of five scenes and labelled as such rather than as proven inert; `CharacterMoveResult2D.collisions` and the whole 2D character symmetry (M1.8.x); sensors and triggers (M1.1.13), cross-platform determinism (M1.1.14), the M1.1.15 group. | ### Hotfixes (untagged) @@ -140,7 +141,11 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **Tier 0 IPC — bounded receive, unowned (opened at M1.1.9)**: `engine-zig-conventions.md` §13 line 897 requires an internal timeout ≤ 5 s with clean resource teardown for any test awaiting an external resource. `connection.recvFrame` has neither a non-blocking variant nor a deadline (`src/core/ipc/connection.zig:123` and `:157` are the only receive entries), and the IPC test targets are built by a loop that does not wire `test_watchdog` (only the `test_specs` loop does, `build.zig:618`), so a hang there never stalls the sibling IPC cases but never lets `zig build test` complete either. Closing §13 for real needs a bounded receive primitive in Tier 0 IPC. Owned by whoever next opens that surface; not a physics milestone. - **M1.1.15 owns three M1.1.8 leftovers**: the wake fixpoint's ROUND COUNT is unpinned (the E4 fix that removed a redundant round per resting tick changed no result, so no test could have caught it — build telemetry belongs with the orchestrator); `build`'s per-tick deferred-index buffer is the one allocation on the build path and moves to the orchestrator's scratch (`build` owns no state, so it cannot reuse it); and the production W4 wiring — removal of a body, teleport of a static/kinematic — wakes the sleepers retained in a pair with it, proven at harness level at M1.1.8, unwired until `PhysicsWorld` exists. - **Windows bench job budget (`bench.yml` `timeout-minutes: 10`)**: marginal and WILL recur. Closed by EXPERIMENT at M1.1.11.1, not by argument: the job was cancelled at 9m28 on `bench-ecs-smoke (windows-2025)`, passed on rerun at 7m34 on the SAME commit, and `build-and-test (windows-2025, ReleaseSafe)` — which compiles the whole forge suite including the `i1024`/`i8192` tiers — passed in 42m7, so the code is not implicated. The runner is intrinsically at the edge: `build-and-test (windows-2025, Debug)` takes 9m19 for comparable work against a 10-minute budget that includes checkout, Zig setup and cache restore. Two options, neither taken here: raise the budget, or drop the Windows bench from the PR matrix. Whoever hits the next cancellation should read this entry before suspecting their change. -- **NGS energy injection watch (since M1.1.11.1)**: a FRICTIONLESS, undamped slider on a flat mesh seam retains 5.000001 m/s of 5 over sixty ticks — a 2e-7 relative GAIN. Negligible at this scale and no action taken, but a contact solver that adds energy is a stability seed, and this is the signature. Re-read the figure at M1.1.12 (character controller) and M1.1.14 (cross-platform determinism); if it grows with tick count or contact count, it is NGS and not noise. +- **NGS energy injection watch (since M1.1.11.1)**: a FRICTIONLESS, undamped slider on a flat mesh seam retains 5.000001 m/s of 5 over sixty ticks — a 2e-7 relative GAIN. Negligible at this scale and no action taken, but a contact solver that adds energy is a stability seed, and this is the signature. RE-READ AT M1.1.12 AND THE ANSWER IS ARITHMETIC, NOT NGS: the retained speed is `5.0000005` at f32 — `nextafter(5)` is `5.000000476837158`, so the gain is EXACTLY ONE ULP — and exactly `5` at f64, where the gain is zero. A solver adding energy would add it at both precisions. Unchanged digit for digit between the M1.1.12 branch and `main`. Still worth re-reading at M1.1.14 (cross-platform determinism), where an ULP is the unit of the question. +- **Tooling facts have no owner (opened at M1.1.12)**: `engine-development-workflow.md` carries NO tooling-facts section, so these facts propagate by manual recopy from brief to brief with nobody accountable — which is how one gets dropped. Three were added this milestone, all self-reported, and one of them was a harness violating a fact the brief it was written against already listed. Give the workflow doc the section, and have briefs cite it instead of copying it. Not a physics milestone. +- **Frozen pose setters are `void`, and pose writes are about to become allocation-fallible (opened at M1.1.12)**: `setBodyTransform`, `setLinearVelocity`, `setAngularVelocity` and `setCharacterPosition` are all `void` in the frozen interface. The character store already owns a broadphase proxy, so its writes go through `Broadphase.update`, which RESERVES and can fail. When M1.1.15 wires bodies into the broadphase, every pose setter faces the same thing. Two ways out — a reservation seam making `update` infallible, or error channels on the setters — and the decision belongs at M1.1.15, which IS the freeze, so this is not a post-freeze problem. Deciding it now would foreclose the better option. +- **Should setters be fallible at all (opened at M1.1.12)**: the discriminant used this milestone is whether an entry RETURNS a value. It is uniform across the repo today. The question of whether a write that did not happen should be reportable spans the whole Tier 0 surface and belongs with the interface tier at M1.1.15, not inside a module milestone. +- **`engine-physics-forge.md` decomposition (opened at M1.1.12)**: 220 KB, §1 at 70 %, §1.11 alone at 75 KB and growing 10–26 KB per sub-milestone because §1.11 is an ACCUMULATOR — every M1.1.x sub-milestone appends its internal model there, and HeightField is already announced for the same treatment. Four-file split arbitrated — constitution, solver, queries, shapes. To be executed BETWEEN this milestone's closure and M1.1.13's opening, never inside a milestone. Surface to retarget measured: 35 `§N` references from 23 files, 22 of them onto §1.x. Open question of the operation itself: does §1.11.17 keep its number in a file whose top level is no longer §1.11 — preserving the 22 references at the cost of an odd numbering — or does one renumber and retarget them. `spec-changelog.md` carries the migration. ## Non-negotiable rules @@ -327,4 +332,4 @@ line, and never on a `tail`. --- -Last updated: 2026-08-02 +Last updated: 2026-08-05 diff --git a/bench/forge_3d_character.zig b/bench/forge_3d_character.zig new file mode 100644 index 0000000..1be6ab7 --- /dev/null +++ b/bench/forge_3d_character.zig @@ -0,0 +1,387 @@ +//! forge_3d kinematic character controller throughput bench (M1.1.12). +//! +//! Five rows, one per path the controller has: `moveCharacter` on a flat half-space, on a flight of +//! stairs, against a wall, on a tessellated triangle mesh, and `resizeCharacter`. Each row is a +//! DIFFERENT code path and not a different scale of the same one — the plane row runs the ground +//! sweep and nothing else, the stairs row arms the three step sweeps, the wall row arms the slide, +//! the mesh row puts the ground sweep and the contact fallback on a `.triangle_soup` shape, and the +//! resize row is the only one that allocates. +//! +//! **RUNS ARE INTERLEAVED, not best-of-N-per-mode.** Every rep runs all five modes in sequence and +//! the best rep per mode is kept, so a thermal ramp or a scheduling burst lands on all five rather +//! than on whichever happened to be measured while it passed. Best-of-three per mode cannot resolve +//! a gap under about 5 %, which is the size of the gaps between these rows. +//! +//! Each mode oscillates its character by a fixed displacement, alternating sign, so it stays inside +//! its scene for the whole batch. That is deliberate rather than convenient: a character walking off +//! the end of a flight of stairs would spend most of the batch in free flight and the row would +//! measure the plane path under the stairs' name. +//! +//! **Reported, not gated.** No numeric envelope is pre-registered — no baseline for this path has +//! ever been measured, and registering a bound before measuring it is the failure mode recorded at +//! M1.1.8. The controller's guarantees are carried by its acceptance suite, not by a figure here. +//! +//! ReleaseFast for the absolute ns (a Debug or ReleaseSafe run stays useful for relative +//! comparisons). Writes `bench/results/forge_3d_character.md`. + +const std = @import("std"); +const builtin = @import("builtin"); +const forge = @import("forge_3d"); +const api = @import("weld_forge"); + +const Real = forge.Real; +const Vec3r = forge.Vec3r; +const BodyManager = forge.BodyManager; +const ShapeStore = forge.ShapeStore; +const Broadphase = forge.Broadphase; +const CharacterStore = forge.CharacterStore; +/// The public `f32` vector the frozen mesh descriptor takes — named once so the bench does not +/// spell the path twice. +const MeshVec3 = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; + +/// Moves timed per mode per rep. +const n_moves = 2_000; +/// Interleaved reps; the best per mode is reported. +const n_reps = 8; +/// One tick at the fixed 60 Hz timestep. +const dt: Real = 1.0 / 60.0; +/// Oscillation amplitude, well under the 0.1 m broadphase fat margin on the way out and back so the +/// row is not dominated by proxy re-fits. +const stride: Real = 0.02; + +// --- Monotonic clock (mirrors `bench/forge_3d_raycast.zig`: clock_gettime on POSIX, QPC on +// Windows — `std.time.Timer` is avoided for the same cross-platform reason). --- + +const timespec_t = extern struct { tv_sec: i64, tv_nsec: i64 }; +const CLOCK_MONOTONIC: i32 = if (builtin.os.tag == .linux) 1 else 6; +extern "c" fn clock_gettime(clk_id: i32, tp: *timespec_t) c_int; +extern "kernel32" fn QueryPerformanceCounter(out: *i64) callconv(.winapi) i32; +extern "kernel32" fn QueryPerformanceFrequency(out: *i64) callconv(.winapi) i32; + +var qpc_freq_cached: i64 = 0; +fn qpcFreq() i64 { + if (qpc_freq_cached == 0) _ = QueryPerformanceFrequency(&qpc_freq_cached); + return qpc_freq_cached; +} + +fn nowNs() i64 { + return switch (builtin.os.tag) { + .windows => blk: { + var counter: i64 = 0; + _ = QueryPerformanceCounter(&counter); + const freq = qpcFreq(); + const sec_part: i64 = @divFloor(counter, freq); + const rem: i64 = counter - sec_part * freq; + break :blk sec_part * std.time.ns_per_s + @divFloor(rem * std.time.ns_per_s, freq); + }, + else => blk: { + var ts = timespec_t{ .tv_sec = 0, .tv_nsec = 0 }; + _ = clock_gettime(CLOCK_MONOTONIC, &ts); + break :blk ts.tv_sec * std.time.ns_per_s + ts.tv_nsec; + }, + }; +} + +fn av3(x: f32, y: f32, z: f32) @TypeOf(@as(api.BodyDescriptor, undefined).position) { + return .{ .data = .{ x, y, z } }; +} + +fn vr(x: Real, y: Real, z: Real) Vec3r { + return Vec3r.fromArray(.{ x, y, z }); +} + +/// NON-VACUITY counters for the resize row: a bench row that timed a REFUSAL under the name of a +/// success would be the same defect class as a test that exercises a path without testing it. +var resize_accepted: u64 = 0; +var resize_refused: u64 = 0; + +const Mode = enum { plane, stairs, wall, mesh, resize }; + +fn modeName(m: Mode) []const u8 { + return switch (m) { + .plane => "moveCharacter / plane", + .stairs => "moveCharacter / stairs", + .wall => "moveCharacter / wall", + .mesh => "moveCharacter / mesh floor", + .resize => "resizeCharacter", + }; +} + +const Scene = struct { + store: ShapeStore = .{}, + bm: BodyManager = .{}, + bp: Broadphase, + chars: CharacterStore = .{}, + character: api.CharacterId = undefined, + /// Owned mesh arrays, freed with the scene — `createShape` copies them, but the builder's + /// buffers are the bench's. + verts: []MeshVec3 = &.{}, + tris: []u32 = &.{}, + + fn deinit(self: *Scene, gpa: std.mem.Allocator) void { + self.chars.deinit(gpa); + self.store.deinit(gpa); + self.bm.deinit(gpa); + self.bp.deinit(gpa); + if (self.verts.len != 0) gpa.free(self.verts); + if (self.tris.len != 0) gpa.free(self.tris); + } +}; + +/// A ground half-space plus whatever the mode needs, plus one character with its broadphase +/// presence registered — which the controller needs for self-exclusion to have anything to exclude. +fn buildScene(gpa: std.mem.Allocator, mode: Mode) !Scene { + var scene = Scene{ .bp = Broadphase.init(.{}) }; + errdefer scene.deinit(gpa); + + // The floor. Every mode but `.mesh` stands on a half-space, which lives outside the trees. + if (mode == .mesh) { + // A 32 x 32 quad grid of unit cells centred on the origin: 2 048 triangles, paired seams, + // flat. Flat and paired is the interesting case — the active-edge pass then snaps the + // seam-derived normals, which is the work the row is meant to include. + const side = 33; + const verts = try gpa.alloc(MeshVec3, side * side); + const tris = try gpa.alloc(u32, 32 * 32 * 6); + for (0..side) |iz| { + for (0..side) |ix| { + const fx: f32 = @as(f32, @floatFromInt(ix)) - 16; + const fz: f32 = @as(f32, @floatFromInt(iz)) - 16; + verts[iz * side + ix] = .{ .data = .{ fx, 0, fz } }; + } + } + var w: usize = 0; + for (0..32) |iz| { + for (0..32) |ix| { + const a: u32 = @intCast(iz * side + ix); + const b: u32 = @intCast(iz * side + ix + 1); + const c: u32 = @intCast((iz + 1) * side + ix); + const d: u32 = @intCast((iz + 1) * side + ix + 1); + // Wound so the face normals point +Y. + tris[w + 0] = a; + tris[w + 1] = c; + tris[w + 2] = b; + tris[w + 3] = b; + tris[w + 4] = c; + tris[w + 5] = d; + w += 6; + } + } + scene.verts = verts; + scene.tris = tris; + const shape = try scene.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = verts, .indices = tris } }); + const id = try scene.bm.addBody(gpa, &scene.store, .{ + .entity = .{ .index = 0, .generation = 0 }, + .body_type = .static, + .shape = shape, + }); + _ = try scene.bp.insert(gpa, .static, scene.bm.bodyAabb(&scene.store, id).?, id); + } else { + const plane = try scene.store.createShape(gpa, .{ .plane = .{ .normal = av3(0, 1, 0), .distance = 0 } }); + const id = try scene.bm.addBody(gpa, &scene.store, .{ + .entity = .{ .index = 0, .generation = 0 }, + .body_type = .static, + .shape = plane, + }); + _ = try scene.bp.insertUnbounded(gpa, .static, .{ .normal = Vec3r.unit_y, .distance = 0 }, id); + } + + switch (mode) { + // Four 0.2 m risers ahead of the character, each inside the default 0.3 m `step_height`, so + // walking into the first one arms the climb every time. + // + .stairs => for (0..4) |i| { + const h: f32 = 0.2 * @as(f32, @floatFromInt(i + 1)); + const shape = try scene.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, h / 2, 2) } }); + const id = try scene.bm.addBody(gpa, &scene.store, .{ + .entity = .{ .index = @intCast(10 + i), .generation = 0 }, + .body_type = .static, + .shape = shape, + .position = av3(0.6 + @as(f32, @floatFromInt(i)), h / 2, 0), + }); + _ = try scene.bp.insert(gpa, .static, scene.bm.bodyAabb(&scene.store, id).?, id); + }, + // **The resize row needs its OWN scenery and two versions of this bench got it wrong.** A + // resize's cost is dominated by the occupancy query over the target volume, so what that + // query traverses IS the measurement. On a bare half-space it traverses nothing and the row + // read 40.5 ns — a number whose name promised what a resize costs and whose value was what it + // costs against an empty tree. Sharing the stairs' scenery then made every resize REFUSED, + // because the first riser overlaps the capsule, so the row timed a refusal under the name of a + // success. Both were caught by the accepted/refused counters, not by re-reading the code. + // + // What is built instead: one box whose tight box starts 0.05 m past the capsule's surface, so + // the 0.1 m fat margin makes it a CANDIDATE the narrowphase must actually test and reject, + // plus three further out for tree depth. Growth is vertical, so the gap survives it. + .resize => { + for (0..4) |i| { + const cx: f32 = 0.75 + 1.2 * @as(f32, @floatFromInt(i)); + const shape = try scene.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.4, 0.4, 0.4) } }); + const id = try scene.bm.addBody(gpa, &scene.store, .{ + .entity = .{ .index = @intCast(20 + i), .generation = 0 }, + .body_type = .static, + .shape = shape, + .position = av3(cx, 0.9, 0), + }); + _ = try scene.bp.insert(gpa, .static, scene.bm.bodyAabb(&scene.store, id).?, id); + } + }, + // A wall too tall to climb, so the move slides along it instead. + .wall => { + const shape = try scene.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 2, 4) } }); + const id = try scene.bm.addBody(gpa, &scene.store, .{ + .entity = .{ .index = 10, .generation = 0 }, + .body_type = .static, + .shape = shape, + .position = av3(0.85, 2, 0), + }); + _ = try scene.bp.insert(gpa, .static, scene.bm.bodyAabb(&scene.store, id).?, id); + }, + else => {}, + } + + var desc = api.CharacterDescriptor{ .entity = .{ .index = 100, .generation = 0 } }; + desc.position = av3(0, 0.02, 0); + scene.character = try scene.chars.createCharacter(gpa, &scene.store, &scene.bm, desc); + if (try scene.chars.getCharacterInnerBody(scene.character)) |presence| { + const proxy = try scene.bp.insert( + gpa, + .dynamic, + scene.bm.bodyAabb(&scene.store, presence).?, + presence, + ); + scene.chars.setPresenceProxy(scene.character, proxy); + } + return scene; +} + +/// One timed batch. Returns nanoseconds for the whole batch; the caller divides. +fn runBatch(gpa: std.mem.Allocator, scene: *Scene, mode: Mode, checksum: *f64) !i64 { + const t0 = nowNs(); + var i: u32 = 0; + while (i < n_moves) : (i += 1) { + const sign: Real = if (i % 2 == 0) 1 else -1; + switch (mode) { + .resize => { + // Two heights that both FIT, so the row measures the succeeding path — build, the + // occupancy query, the commit and the destroy — and not an early refusal. + const h: f32 = if (i % 2 == 0) 1.6 else 1.8; + const ok = try scene.chars.resizeCharacter(gpa, &scene.bp, &scene.bm, &scene.store, scene.character, 0.3, h); + if (ok) { + checksum.* += h; + resize_accepted += 1; + } else resize_refused += 1; + }, + else => { + const r = try scene.chars.moveCharacter( + gpa, + &scene.bp, + &scene.bm, + &scene.store, + scene.character, + vr(stride * sign, -stride, 0), + dt, + ); + checksum.* += r.position.toArray()[0] + r.position.toArray()[1]; + }, + } + } + return nowNs() - t0; +} + +pub fn main() !void { + // `safety` FORCED true: its default is `std.debug.runtime_safety`, false in ReleaseFast, which + // is the mode this bench runs in — a default-configured checker reports "no leaks" + // unconditionally there, proven in both directions at M1.1.10 and M1.1.11. + var debug_allocator: std.heap.DebugAllocator(.{ .safety = true }) = .init; + // Declared FIRST so it runs LAST — after the scenes' own `defer`, which is the only order in + // which the leak verdict sees a torn-down world. + defer _ = debug_allocator.deinit(); + const gpa = debug_allocator.allocator(); + + const modes = [_]Mode{ .plane, .stairs, .wall, .mesh, .resize }; + var best: [modes.len]i64 = @splat(std.math.maxInt(i64)); + var checksum: f64 = 0; + + // The scenes are built ONCE and reused across reps, so construction never lands inside a timing + // window and the mesh row is not really a mesh-build row. + var scenes: [modes.len]Scene = undefined; + for (modes, 0..) |m, i| scenes[i] = try buildScene(gpa, m); + defer for (&scenes) |*s| s.deinit(gpa); + + // One untimed warm-up pass per mode: the first call of a fresh character grows its layer's moved + // log, which is the one allocation on the pose path, and a first rep carrying it would report + // an allocation cost every later rep does not pay. + for (modes, 0..) |m, i| _ = try runBatch(gpa, &scenes[i], m, &checksum); + + var rep: u32 = 0; + while (rep < n_reps) : (rep += 1) { + // INTERLEAVED: all five modes inside the rep, in a fixed order. + for (modes, 0..) |m, i| { + const ns = try runBatch(gpa, &scenes[i], m, &checksum); + if (ns < best[i]) best[i] = ns; + } + } + + const frame_ns: f64 = @as(f64, std.time.ns_per_s) / 60.0; + var per_call: [modes.len]f64 = undefined; + for (0..modes.len) |i| per_call[i] = @as(f64, @floatFromInt(best[i])) / @as(f64, n_moves); + + std.debug.print("\nforge_3d character controller bench ({s}, {d} calls x {d} interleaved reps, best rep)\n", .{ @tagName(builtin.mode), n_moves, n_reps }); + std.debug.print(" {s:<28} {s:>12} {s:>14} {s:>20}\n", .{ "mode", "ns/call", "calls/s", "calls/frame @60Hz" }); + for (modes, 0..) |m, i| { + std.debug.print(" {s:<28} {d:>9.1} ns {d:>13.0} {d:>20.0}\n", .{ + modeName(m), per_call[i], 1.0e9 / per_call[i], frame_ns / per_call[i], + }); + } + std.debug.print(" (reported, not gated; checksum {d:.3}; resizes accepted {d} refused {d})\n", .{ checksum, resize_accepted, resize_refused }); + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(gpa); + try buf.print(gpa, + \\# forge_3d kinematic character controller bench + \\ + \\- Build mode: {s} + \\- {d} calls per mode per rep, {d} INTERLEAVED reps, best rep reported + \\- Anti-DCE checksum: {d:.3} + \\ + \\| mode | ns/call | calls/s | calls per 16.67 ms frame | + \\|---|---|---|---| + \\ + , .{ @tagName(builtin.mode), n_moves, n_reps, checksum }); + for (modes, 0..) |m, i| { + try buf.print(gpa, "| {s} | {d:.1} | {d:.0} | {d:.0} |\n", .{ + modeName(m), per_call[i], 1.0e9 / per_call[i], frame_ns / per_call[i], + }); + } + try buf.appendSlice(gpa, + \\ + \\**Reported, not gated.** No envelope is pre-registered: this is the first measurement of + \\this path, and registering a bound before measuring its baseline is the failure mode + \\recorded at M1.1.8. + \\ + \\Runs are INTERLEAVED — every rep runs all five modes in sequence and the best rep per mode + \\is kept — so a thermal ramp or a scheduling burst lands on all five rather than on + \\whichever happened to be measured while it passed. Best-of-N per mode cannot resolve a gap + \\under about 5 %, which is the size of the gaps between these rows. + \\ + \\The five rows are five different code paths, not five scales of one: the plane row runs the + \\ground sweep alone, the stairs row arms the climb's three sweeps, the wall row arms the + \\slide, the mesh row puts the ground sweep and the contact fallback on a `.triangle_soup` + \\shape, and `resizeCharacter` is the only row that allocates. + \\ + ); + + const bytes = buf.items; + const path: [:0]const u8 = "bench/results/forge_3d_character.md"; + const fp = fopen(path.ptr, "w"); + if (fp == null) return error.WriteReportFailed; + _ = fwrite(bytes.ptr, 1, bytes.len, fp.?); + _ = fclose(fp.?); + + // The scenes are torn down by the `defer` above, so the leak verdict is read after it — which + // is why this is the last statement and not a `defer` of its own. + std.debug.print(" allocator: checked with safety forced true\n", .{}); +} + +extern "c" fn fopen(path: [*:0]const u8, mode: [*:0]const u8) ?*anyopaque; +extern "c" fn fwrite(ptr: [*]const u8, size: usize, n: usize, stream: *anyopaque) usize; +extern "c" fn fclose(stream: *anyopaque) c_int; diff --git a/bench/results/forge_3d_character.md b/bench/results/forge_3d_character.md new file mode 100644 index 0000000..874c650 --- /dev/null +++ b/bench/results/forge_3d_character.md @@ -0,0 +1,27 @@ +# forge_3d kinematic character controller bench + +- Build mode: ReleaseFast +- 2000 calls per mode per rep, 8 INTERLEAVED reps, best rep reported +- Anti-DCE checksum: 31917.672 + +| mode | ns/call | calls/s | calls per 16.67 ms frame | +|---|---|---|---| +| moveCharacter / plane | 212.0 | 4716981 | 78616 | +| moveCharacter / stairs | 2235.5 | 447327 | 7455 | +| moveCharacter / wall | 1764.5 | 566733 | 9446 | +| moveCharacter / mesh floor | 7979.0 | 125329 | 2089 | +| resizeCharacter | 203.0 | 4926108 | 82102 | + +**Reported, not gated.** No envelope is pre-registered: this is the first measurement of +this path, and registering a bound before measuring its baseline is the failure mode +recorded at M1.1.8. + +Runs are INTERLEAVED — every rep runs all five modes in sequence and the best rep per mode +is kept — so a thermal ramp or a scheduling burst lands on all five rather than on +whichever happened to be measured while it passed. Best-of-N per mode cannot resolve a gap +under about 5 %, which is the size of the gaps between these rows. + +The five rows are five different code paths, not five scales of one: the plane row runs the +ground sweep alone, the stairs row arms the climb's three sweeps, the wall row arms the +slide, the mesh row puts the ground sweep and the contact fallback on a `.triangle_soup` +shape, and `resizeCharacter` is the only row that allocates. diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md new file mode 100644 index 0000000..5432f3b --- /dev/null +++ b/briefs/M1.1.12-character-controller.md @@ -0,0 +1,2347 @@ +# M1.1.12 — Forge 3D: the kinematic character controller + +> **Status:** CLOSED +> **Phase:** 1 +> **Branch:** `phase-1/forge/character-controller` +> **Planned tag:** `v0.11.12-character-controller` +> **Base:** `main` at `a4354df` (`v0.11.11-mesh-shape`) +> **Dependencies:** M1.1.0 (shape store, `BodyManager`, `PackedId`), M1.1.1 (broadphase BVH, pair generation), M1.1.2 (support maps, GJK), M1.1.3 (EPA, manifold), M1.1.6 (contact cache), M1.1.8 (islands, sleep, wake causes W1–W4), M1.1.9 (query family frozen, ray traversal, `Attempt`), M1.1.10 (the eight entries, `queryCast`, the ordering key), M1.1.11 (`ShapeClass`, half-space), M1.1.11.1 (mesh, `collidePairEach`, transactional `createShape`, active edges) +> **Opened:** 2026-08-04 + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +Fourteenth core sub-milestone of the M1.1 rigid arc, and the first that is **not** a shape or a solver pass. `engine-phase-1-plan.md`, M1.1.12 row: « Character controller kinematic (moveCharacter, step height, max slope, grounded) », testable as « Capsule sur sol, monte marches, glisse pente, bloquée par mur ». + +The normative model was authored for this milestone and is `engine-physics-forge.md` **§1.12**, appended to §1 so that nothing renumbers. §9 was rewritten as the calling surface. `engine-tier-interfaces.md` is at **version 0.7** and carries the whole frozen surface. Read §1.12 before anything else. + +**This milestone spends the last of a window that does not reopen.** `PhysicsModule` freezes at M1.1.15, three sub-milestones out, and nothing about the controller will be reopened at the sensors milestone or the determinism milestone. The frozen surface was therefore settled first, before any scope discussion, and it grew by six entries — `destroyCharacter`, `resizeCharacter`, `setCharacterPosition`, `getCharacterInnerBody`, `setAngularVelocity`, `moveKinematic` — plus an error channel on `moveCharacter`, a refounded descriptor and result, and the `GroundState` enum. The mechanism that makes this final rather than merely awkward is that `engine-c-api.md` carries **no `struct_size` and no minor version** (registered open signal, `engine-audit-checklist.md` §5): after the freeze, adding one defaulted field to a descriptor is an ABI break for every Tier 3 plugin, not a source-compatible addition. + +**This milestone is not split, and the size rule was measured rather than invoked.** `engine-development-workflow.md` §2.2 targets 500–2000 delivered lines. Delivered Zig lines, measured: M1.1.8 3237, M1.1.9 3968, M1.1.10 5095, M1.1.11 4607, M1.1.11.1 **8827**. Every one exceeds the target, all were reviewed gate by gate and merged. This milestone is estimated at **6000–9000**, between M1.1.10 and M1.1.11.1. That estimate is offered as a range and not as a promise: M1.1.11.1's brief estimated 3400–3900 and delivered 8827, a 2.3× overrun, and the failure was in the estimate and not in the milestone. There is no M1.1.12.1 and none is to be proposed. If the size becomes alarming mid-execution, measure against these five numbers before invoking §2.2. + +Five things are settled here that are larger than one controller. + +First, **the controller is virtual and carries no simulated body, and this was contested and resolved on the reference.** §1.5's phrasing supports both readings — « kinematic-based » suggests a body, « équivalent `CharacterVirtual` Jolt » suggests none. The reference decides it: `CharacterVirtual` has no rigid body, is not tracked by `PhysicsSystem`, and an *inner body* is an **optional** addition through `CharacterVirtualSettings::mInnerBodyShape`. The controller does not participate in any solver pass: no inverse mass, no inertia tensor, no contact constraint, no island membership. + +Second, **the inner body is nonetheless mandatory here, and that is the one place this milestone diverges from the reference by default.** A controller with no broadphase proxy is invisible to every query, and `engine-phase-1-criteria.md` C1.8's *Scope minimum* refuses that twice: « attaque joueur infligeant des dégâts via fenêtre d'AnimNotify + **raycast/overlap** », and « caméra de suivi avec **collision anti-mur** » on a player whose motion is explicitly the physical character controller. The second is the decisive one and it concerns the player, whose controller use is stated: a sweep leaving the player must exclude the player, `PhysicsQueryFilter.exclude` takes `BodyId` only, hence `getCharacterInnerBody`. The reference exposes `GetInnerBodyID()` for that exact reason. The capability is mandatory on `PhysicsModule` and optional per character through `CharacterDescriptor.inner_body`, whose default is `true` — inverted from the reference, because the failure mode of a default-off is a character nobody can shoot, discovered late. + +Third, **the M1.1.11.1 cache pattern does not transfer, and that is the result of examining it rather than a way of avoiding it.** `Body.world_aabb` is the only cache of its kind and it is populated **only** for a mesh — `switch (shape.class())`, the `.triangle_soup` arm — NaN for convex and for half-space. A controller's presence carries a capsule, hence `.convex`, hence a cache that is NaN from creation; and `poisonCachedBox` early-returns on `.dynamic` only, so a kinematic body enters it and rewrites six NaNs over NaNs on every pose write, which is inert. Above all a mesh forces a **static** body, so no changing pose can ever meet that cache. The question that remains is the inverse one: does this milestone introduce a cache of its own? **No** — nothing expensive is pose-invariant for a character, a capsule's support shape being two scalars. This is written down so the next sub-milestone does not reopen it. + +Fourth, **the safe failure direction of this module was already written upstream, at M1.1.10.** `shapecast.zig`'s header states that exhausting the iteration budget returns a contact **announced early, never missed**, « the safe failure direction for the character controller of M1.1.12 ». This milestone consumes that decision instead of retaking it. In the same register, `Attempt(T)` in `triangle.zig` reports `.degenerate` and `.unrepresentable` **apart** from `.miss`: a character that cannot move and a character that does not move are two different answers, and conflating them is the class of false negative this whole module refuses. + +Fifth, **verdict and direction are separate questions, and the verdict is ternary.** A ground state is a verdict; a normal is a direction; they share no path. And a boolean cannot carry `on_steep_ground`, which is precisely the state `engine-movement.md` transitions on — a boolean would force the consumer to re-derive the angle from the normal, hence to recompute `max_slope` **outside the engine that holds it**, hence to be able to disagree with it. + +## Scope + +- **`src/modules/forge/api/types.zig`** — `CharacterId`, `CharacterDescriptor`, `GroundState`, `CharacterMoveResult`, transcribed from `engine-tier-interfaces.md` §1 at version 0.7 **field for field, name for name, default for default**. None of these four symbols exists on `main`; `grep -r Character src/` returns nothing. `CharacterId` follows the established `PackedId` layout `index:24 | generation:8`, like `BodyId` and `ShapeId` — the generation is what makes §1.12's typed error on a stale handle implementable at all, and without it that contract is unenforceable rather than merely unenforced. +- **The four stale prose sites in `rigid/island_manager.zig`.** Lines 36, 81, 160 and 339 describe the resolution order as `(rank, pair_key)` while `lessByCompositeKey` at 434 compares `(rank, pair_key, subshape_id)` and the `subshape_id` field documents itself as « the third term of the composite key ». Left by M1.1.11.1's closing finding F4, which added the term without deleting the four texts it replaced. Four occurrences is a **class, not an instance**: sweep it, and add no new text without deleting the text it supersedes. +- **`forge_3d/character.zig`** (create) — the character store and the move algorithm. Slot allocator with stable slots and LIFO recycling, the `ShapeStore` pattern verbatim. `createCharacter` is **transactional**: the capsule shape, the store slot and the inner body are all reserved before any slot is mutated, so an OOM never leaves a half-built character live. `destroyCharacter` releases all three and is a no-op on a stale handle, like `removeBody`. +- **Descriptor domain, refused by typed error and never sanitised.** Rejected at creation: `collision_layer` outside `[0, 32)` by `error.InvalidCollisionLayer`, the same error and the same reason as `addBody` — the mask is 32 bits and a character declared beyond it would be invisible to every query with no diagnostic; a non-finite or non-positive `radius` or `height`; a non-finite `max_slope`, `padding`, `mass` or `max_push_force`. `max_slope` is **not** clamped into `[0, π/2]`: a value outside it is a domain error, and silently clamping would make a caller's mistake look like a modelling choice. +- **The position of a character is its BASE, never the centre of its capsule.** `CharacterMoveResult.position` is written into `Transform.position` by gameplay, and every probe starts there. A body's pose is the **centre** of its shape, the capsule being symmetric about the origin, so the inner body's pose is the character position plus half the height along up. This offset exists in exactly one place in the code and is named; computed twice, it will diverge once. +- **`max_slope` is stored as a cosine, computed once at creation.** The test is `n · up >= cos_max_slope`. Not a micro-optimisation: an `acos` per contact per frame is precisely what M1.1.14 must make reproducible, `engine-phase-1-plan.md` naming internal trigonometric functions among its determinism hazards, and storing the cosine moves the single trigonometric call to creation time. +- **`max_slope`, `padding` and `predictive_contact_distance` are named PHYSICAL parameters**, the class of `restitution_threshold`, `penetration_slop` and `active_edge_cos_threshold`: they select a modelling behaviour, not a numerical tolerance, and §1.11.2's `k · floatEps(T) · coordScale` discipline does **not** govern them. Say so at each declaration, because a reviewer applying that rule to them will call it a violation and be wrong. +- **A seventh body-level adapter, `collideShapeBody`** on `BodyManager` — a shape at a pose against a body, returning manifolds, several for a mesh, on the shape of `collidePairEach`. This is the adapter a virtual controller needs and it is the same cloth as the six that exist (`raycastBody`, `castShapeBody`, `containsPointBody`, `closestPointBody`, `collidePair`, `collidePairEach`). It is internal: no interface entry is added for it. +- **Depenetration goes through the MANIFOLD and never through the sweep.** At distance zero a cast returns `−direction` (§1.11.4) — the direction travelled from, not the surface normal. That choice is correct and preserves `normal · direction <= 0` on every hit; it is simply **unusable for sliding**. A controller starting interpenetrated therefore queries the manifold, which carries real points, a real normal and a real depth. +- **Filtering is two questions with no shared path.** `collision_layer` is what the character **is**, read by others through the mask of *their* queries; `layer_mask` is what the character **sees**, read by itself in its own sweeps. The object-layer matrix of §3 does not govern a controller — it filters simulation **pairs**, which a sweep is not (§1.11.1) — and it is in any case not implemented in `forge_3d`: only the 4×4 **broad** layer matrix exists (`default_layer_pairs`). +- **Self-exclusion is unilateral.** A controller's sweeps and manifolds exclude **its own** presence and no other. Other characters' presences are seen as any body would be, which gives character-versus-character collision for free — the reference needs a whole interface (`CharacterVsCharacterCollision`) because its `CharacterVirtual` instances do not see each other. With C1.8's wave of 5–10 enemies in one room, this is the difference between enemies that interpenetrate and enemies that do not. +- **The ground verdict is ternary and its five quantities each answer a different question.** `ground_state`, `ground_normal`, `ground_entity`, `ground_body`, `ground_velocity`. On `.in_air`: `.in_air`, `Vec3.up`, null, 0, zero. `ground_normal` is **never** poisoned — three documents read that field inside a `@replicated` component and a NaN would cross the rollback. +- **`ground_velocity` is the velocity AT THE CONTACT POINT**, hence `v + ω × r` and not the support's linear velocity: without the rotational term a character standing at the rim of a rotating platform drifts. +- **`setAngularVelocity`** — implemented. It closes a gap dating from M1.1.0: the 2D interface carries `setAngularVelocity2D`, the reference carries both, and 3D carried only the linear one, so `ω` was authorable by **no caller at all** and `ground_velocity`'s rotational term had no source. It is a setter on an existing column. +- **`moveKinematic`** — frozen signature, **typed stub** naming M1.1.15. Deriving a velocity from a target pose belongs with the tick cycle and the wake composition, which arrive with `PhysicsWorld`; implementing it here would bring the platform-motion path into a controller milestone with no consumer to exercise it. The pattern is authorised by name in C1.1 (« `createJoint` peut être un stub typé ») and was executed at M1.1.9, which froze five entries with `@panic` bodies naming M1.1.10. +- **`setBodyTransform` gains the word teleportation in its documentation** and the statement that it derives no velocity. No signature change. Without it, a kinematic platform moved through it reports zero velocity and `ground_velocity` lies for the only interesting case. +- **`resizeCharacter` is atomic and anchored at the feet**, and it **preserves the inner body's `BodyId`** — a resize is not a re-creation, and an exclusion memorised by the caller survives. Three outcomes and a bare `bool` would conflate them: typed error for the caller's fault (stale handle, non-finite or non-positive dimensions), `false` for an **occupied** target volume, which is a legitimate gameplay answer and not an error, `true` for success. Same split as `shapeCast`. +- **`setCharacterPosition` moves without sweeping and without resolving**: it may leave the character interpenetrated, and that is the contract. It invalidates the reported ground verdict, which returns to `.in_air`. +- **Pushing dynamic bodies, with real semantics and a real test.** `mass` and `max_push_force` govern the impulse applied to the dynamic body met; the character receives **nothing** in return, being kinematic — the push is unilateral by construction. A `max_push_force` of zero disables it with no special case. This is not an inert field: the test harness composes the pipeline, so the impulse's effect is observable. +- **`moveCharacter`, `setCharacterPosition` and every successful `resizeCharacter` wake the bodies they touch, under W4 and not under a new cause.** §1.8.5's W4 already covers the teleportation of a kinematic body, which is exactly what a presence pose write is. **W3 cannot cover it**: it tests a non-member's velocity at true zero, and a presence moved by pose write has velocity columns that stay exactly zero while it crosses the scene. The controller is the engine's first real producer of W4. The push is W1, an external mutation, already covered. §1.12.10 states all three. +- **The public surface is `f32`**, consistent with `BodyDescriptor`, interface poses, query results and the ECS `Transform`; internal orchestration is at the solver scalar. Widening is one grouped decision at M1.1.15 and never one member of the set alone (§1.11.8). + +## Out of scope + +- **The Etch surface of the controller.** `engine-movement.md` §9 and §10 name `physics_move_character`, `physics_resize_character` and `physics_set_character_position` and mark all three **provisional**: the Tier 1 physics service and its Etch wrappers are deferred to M1.1.15 by M1.1.10's own OUT list. The controller is not part of `physics_query`, not being a query. Add no service and no wrapper here. +- **The ECS authoring surface.** No `VirtualCharacter` component is registered in the ECS. `engine-physics-forge.md` §9 specifies it; wiring it needs the `Transform` sync of M1.1.15. Deferred with the plane and mesh variants, deliberately. +- **`step()` / `PhysicsWorld` / `PhysicsModule` instantiation / ECS `Transform` sync / the public `f32` wrapper / widening `Real`** (M1.1.15, one grouped decision). Nothing in production inserts a body into the broadphase today; this milestone proves its behaviour through the test harness and must **say so** rather than imply a wiring that does not exist. +- **`moveKinematic`'s body** — M1.1.15, as above. The signature ships; the derivation does not. +- **`CharacterMoveResult2D.collisions`** survives the removal of its 3D counterpart. The 2D symmetry is recorded for M1.8.11, where `PhysicsModule2D` freezes. Do not touch `forge_2d`. +- **A `BodyType → BroadphaseLayer` mapping.** It does not exist on `main` — the layer is an insertion argument, and `body_manager.zig:1195` dates the wiring to M1.1.15. The test harness inserts its own proxies, as the query suites do. +- **The `MovementState` duplication** in `engine-gameplay-systems.md` was resolved on the KB side by removing the duplicate declaration, not by merging field lists. Nothing to do here. +- **The decomposition of `engine-physics-forge.md`** — the file crossed 220 KB, §1 is 70 % of it and §1.11 alone is 75 KB. Four-file split arbitrated, scheduled **between this milestone's closure and M1.1.13's opening**, never inside a milestone. Recorded in `engine-audit-checklist.md` §5. +- **A `predictive_contact_distance` left inert.** The field ships because the reference documents that a value of zero gets the character stuck, sliding direction no longer being computable. It is the one field of this descriptor the algorithm has not yet justified. **Gate D consumes it or deletes it** — both remain inside the window; leaving it inert does not. +- Sensors and triggers (M1.1.13), cross-platform determinism (M1.1.14), joints (M1.1.16–18), Cylinder/TaperedCylinder/ConvexHull (M1.1.19), Compound/MutableCompound/Empty (M1.1.20), HeightField (M1.1.20), vehicle (M1.1.21), the deformable branch (M1.1.22–23). +- Crouch, sprint, coyote time, jump buffering and every other kinematic tunable: `engine-movement.md`'s domain, not the engine's. +- `src/c-api/`, editor overlays, the Tier 1 service. + +## Specs to read first + +1. `engine-physics-forge.md` — **§1.12 first, in full.** Then §9 (calling surface, rewritten). Then §1.11 in full, with attention to §1.11.2 (the epsilon discipline and what escapes it), §1.11.3, §1.11.4 (the `normal · direction <= 0` invariant and the `−direction` choice at distance zero), §1.11.4 bis, §1.11.5 (filtering, the `[0, 32)` bound), §1.11.7 (four principles, and the error/absence split), §1.11.8 (precision boundary), §1.11.11, §1.11.12, §1.11.15, §1.11.17. Then §1.5 (scope), §1.7, **§1.8.4 and §1.8.5 (the four wake causes, W3's blindness, W4)**, §2, §3, §17. +2. `engine-tier-interfaces.md` — §1 at **version 0.7**, in full: the six new entries and their doc comments, `moveCharacter`'s error channel, `CharacterDescriptor`, `GroundState`, `CharacterMoveResult`, `BodyDescriptor.can_sleep`, `setBodyTransform`'s teleportation note, and §12's counting convention. +3. `engine-phase-1-criteria.md` — **C1.8's *Scope minimum* in full** (it is what makes the inner body mandatory), then C1.1. +4. `engine-phase-1-plan.md` — the M1.1.12 row, then M1.1.13, M1.1.14, M1.1.15. +5. `engine-movement.md` — §2 (the components and `GroundState`, owner declaration), §4 Walking (platform velocity inheritance), §5 (the transitions that read the verdict), §9 (crouch on `resizeCharacter`), **§10 (the controller's consumer, and the one-tick latency)**, §11 (`teleport`). +6. `engine-zig-conventions.md` — naming, unmanaged-first allocation, the `root.zig` convention, the comptime interface-check pattern, the file-length guideline. +7. `engine-coordinate-system.md` — the Y-up convention, which is why no rotation field exists. + +## Specs read + +*Checked by Claude Code at Étape 2, with a real `YYYY-MM-DD HH:MM` timestamp each.* + +- [x] `engine-physics-forge.md` — 2026-08-04 06:47 (§1.12 first, in full; then §9; then §1.11 entire — §1.11.1 to §1.11.17; then §1.5, §1.7 with §1.7.1/§1.7.2, §1.8 with §1.8.1 to §1.8.9, §2, §3, §13, §16, §17) +- [x] `engine-tier-interfaces.md` — 2026-08-04 06:52 (§0 Principes; §1 `PhysicsModule` in full at version 0.7 — the 27 `assertFn`, the six new entries, `CharacterDescriptor`, `GroundState`, `CharacterMoveResult`, `BodyDescriptor.can_sleep`, `setBodyTransform`'s teleportation note; §12 and its counting convention) +- [x] `engine-phase-1-criteria.md` — 2026-08-04 06:32 (C1.8 *Scope minimum* in full, then C1.1 and its Statut block, C1.0, the non-regression list) +- [x] `engine-phase-1-plan.md` — 2026-08-04 06:55 (M1.1 header and the whole core-comptime table — M1.1.11, M1.1.11.1, M1.1.12, M1.1.13, M1.1.14, M1.1.15 — plus the additive-completion table and the C1.0 freeze line) +- [x] `engine-movement.md` — 2026-08-04 06:29 (§1 to §15 in full: §2 components and the owner declaration of `GroundState`, §4 Walking and platform velocity inheritance, §5 transitions reading the verdict, §9 crouch on `resizeCharacter`, §10 the controller's consumer and the one-tick latency, §11 `teleport`) +- [x] `engine-zig-conventions.md` — 2026-08-04 06:58 (§1 to §19 in full: naming, unmanaged-first allocation, the `root.zig` convention, the comptime interface-check pattern §16, the ≤ 500-line file guideline §19, the two lazy-analysis guards §13) +- [x] `engine-coordinate-system.md` — 2026-08-04 06:31 (§1 to §8 in full: Y-up right-handed, 1 unit = 1 metre, `Vec3.up`, §4.3 `TransformSystem` and its kinematic-parent case) + +Also read, attached but not on the list above: `engine-audit-checklist.md` — 2026-08-04 06:30 (§0 to §5, including the 2026-08-04 journal entry that carries this milestone's opened signals — the four `island_manager.zig` prose sites, `predictive_contact_distance`, and the 2D symmetry consigned for M1.8.11). + +## Files to create or modify + +- `src/modules/forge/api/types.zig` — modify — `CharacterId`, `CharacterDescriptor`, `GroundState`, `CharacterMoveResult`; `setBodyTransform`'s documentation. +- `src/modules/forge/forge_3d/character.zig` — create — the character store, the move algorithm, ground determination, resize, push, the inner-body lifecycle. If the import graph forces a different placement or a split, that is a **Recorded deviation**, not a silent move. +- `src/modules/forge/forge_3d/body_manager.zig` — modify — `collideShapeBody`, `setAngularVelocity` if absent from the public path, the inner-body pose write and its broadphase proxy update. +- `src/modules/forge/forge_3d/rigid/island_manager.zig` — modify — the four stale prose sites. +- `src/modules/forge/forge_3d/root.zig` — modify — re-exports, and the comptime pin for the new suite. +- `src/modules/forge/forge_3d/tests/character_test.zig` — create — the controller acceptance suite. +- `src/modules/forge/forge_3d/tests/body_manager_test.zig` — modify — `collideShapeBody`, `setAngularVelocity`. +- `src/modules/forge/forge_3d/tests/raycast_test.zig`, `.../shapecast_test.zig`, `.../overlap_test.zig` — modify — the inner body is findable, and self-exclusion. +- `src/modules/forge/api/types.zig` test block — modify — the frozen-signature pins extended field by field (**extended, never weakened**). +- `build.zig` — modify only if a new test file needs registering in `test-forge-3d`. +- `bench/` — modify — see Benchmarks. +- `CLAUDE.md` — modify — the Tags table, the `Current state` block, the open decisions **and the file-footer date**, §3.4 requires all four; the patch content is supplied by Claude.ai, **inside the PR**. + +## Acceptance criteria + +### Tests + +RED-first on every pinnable behaviour. Every expectation is a **closed form computed in the comment above it**, never a value read back from the implementation. Every suite runs at `f32` and `f64` × Debug and ReleaseSafe. Entry state: **419 tests** in `test-forge-3d`, green at `a4354df`. + +**At the first gate that writes a property, produce the wiring table.** For every quantity the suite measures: its name, its nature — **delivered guarantee** or **historical metric** — and its treatment. A guarantee is an assertion **per case**; a counter belongs only to a shape that is allowed to fail. Nine of M1.1.11.1's last twelve findings were in the measuring apparatus and not in the code, so the apparatus is audited before its results are read. + +**No absolute guarantee over a numerical domain.** « Every non-degenerate configuration is served » cost five closure rounds at M1.1.11.1 on its own. A guarantee is written over a **bounded domain** or as a **failure direction**. A two-directional guarantee is asserted in both directions or in neither. + +**`tests/character_test.zig`** (create) — the four claims of the plan row, and the frozen surface: + +- Capsule at rest on a plane: `.grounded`, normal `+Y`, `ground_body` the plane's, `ground_velocity` zero, position unchanged to within `padding`. +- Capsule on a slope below `max_slope`: `.grounded`, normal the slope's. Above it: `.on_steep_ground`, normal the slope's, `ground_entity` **non-null** — the field is null only on `.in_air`. +- Capsule over the void: `.in_air`, normal exactly `Vec3.up`, `ground_entity` null, `ground_velocity` zero. Assert `Vec3.up` and **not** a NaN: the field is read inside a `@replicated` component. +- Step climb at `step_height − ε` succeeds; at `step_height + ε` the character is blocked and the sweep's remaining displacement is consumed against the riser. The two directions of that guarantee are both asserted. +- Wall block: displacement into a wall leaves the tangential component and cancels the normal one, `normal · displacement_out` at true zero to within the documented tolerance. +- Slide on a crease: two contact planes, the direction along `n₁ × n₂` through `foundation/math/exact.zig`'s `triangleCrossDirection(T, zero, n₁, n₂)`, which returns `null` on **exactly** parallel normals — that null is a different answer from « no crease » and is asserted as such. +- Start interpenetrated: depenetration by manifold puts the character outside within one call, and the sweep's `−direction` normal is **not** what drove it. Pin this by a case where the two differ measurably. +- `ground_velocity` on a rotating platform: `setAngularVelocity` on a kinematic body, a character on its rim, `v + ω × r` in closed form. This case is the reason `setAngularVelocity` ships and it is the assertion that makes the field non-inert. +- Domain rejections: `collision_layer` at 32 and at 255 → `error.InvalidCollisionLayer`; non-finite and non-positive `radius`, `height`; non-finite `max_slope`, `padding`, `mass`, `max_push_force`. `max_slope` at `π` is a **rejection**, not a clamp. +- Stale handle: `moveCharacter`, `resizeCharacter`, `getCharacterInnerBody` each return their typed error after `destroyCharacter`; `setCharacterPosition` and `destroyCharacter` are no-ops. Exercise the generation wrap deliberately. +- Inner body: `getCharacterInnerBody` returns a `BodyId`; a raycast from outside finds the character's entity; a raycast from the character excluding that `BodyId` does **not**. `inner_body: false` → `getCharacterInnerBody` returns `null`, and no raycast finds it. +- Presence pose freshness: after a `moveCharacter`, a raycast finds the entity at the **new** pose and never at the old one. Same after `setCharacterPosition` and after a successful `resizeCharacter`. +- Two characters: each sees the other's presence, neither sees its own. +- Resize: shrinking always succeeds; growing into a free volume succeeds and the base does not move; growing under a low ceiling returns `false` and changes **nothing**; the `BodyId` is identical across all three. +- Push: a dynamic box in the path receives an impulse consistent with `mass` and `max_push_force`; the character's own position is unaffected by the box's reaction; `max_push_force = 0` moves the box not at all. +- Transactional creation: an allocator failing at each successive allocation leaves no live slot, no orphan shape and no orphan body. `std.testing.FailingAllocator`, one case per allocation site. +- Frozen surface pins: every field of `CharacterDescriptor` and `CharacterMoveResult` by name and default, and the three `GroundState` values in order. + +**Existing suites** — extended, never weakened. `body_manager_test.zig`: `collideShapeBody` against convex, half-space and mesh, several manifolds for the mesh. `raycast_test.zig` / `shapecast_test.zig` / `overlap_test.zig`: a presence is an ordinary body to each of the eight entries. + +### Benchmarks + +Reported, not gated. `moveCharacter` on a flat plane, on a stair flight, against a wall, and on a mesh floor; `resizeCharacter`. Interleave runs rather than best-of-three — best-of-three does not decide a gap under 5 %. + +## Gate sequence + +Seven gates, push-early, STOP/GO on real pushed diffs. One fenced block per verdict. + +- **A — the frozen surface, and the stale-prose class.** `api/types.zig`'s four symbols with their pinning tests; `island_manager.zig`'s four prose sites; `setBodyTransform`'s documentation. No algorithm. This is the irreversible part and it is reviewed alone, before a line of algorithm exists. +- **B — the seventh adapter and the store.** `collideShapeBody`; the character store, slots, transactional creation with rollback, `destroyCharacter`, domain rejections, the inner-body lifecycle, `getCharacterInnerBody`. +- **C — ground determination alone.** The probe, the stored cosine, the ternary verdict, the five `ground_*` quantities, `setAngularVelocity`. Testable with no motion at all: a capsule placed on a plane, on a slope, over the void, on a rotating platform. **The wiring table is due at this gate.** +- **D — the move.** Sweep, slide, depenetration by manifold, wall block. No stepping yet. `predictive_contact_distance` is consumed or deleted here. +- **E — step height.** Climb and descend. +- **F — resize, push, teleportation.** `resizeCharacter` atomic and feet-anchored with its three outcomes; the push measured through the harness; `setCharacterPosition` and the invalidated verdict. +- **G — closure.** Bench reported, inherited envelope quantities re-measured, `moveKinematic`'s typed stub, `CLAUDE.md` §3.4 patch **inside the PR**, PR opened, then every check verified one by one **at the end** — the total is only legible then. + +## Conventions + +- **Branch:** `phase-1/forge/character-controller` +- **Final tag:** `v0.11.12-character-controller` — patch 12 for sub-milestone 12, the normal bijection. M1.1.11.1 shared patch 11 with M1.1.11 by slug because both were halves of one split plan row; that was the exception and it ends here. `vMAJOR.MINOR.PATCH-slug` per `engine-development-workflow.md` §2.3. +- **PR title:** `Phase 1 / Forge: kinematic character controller` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`). Squash trailer: `Closes M1.1.12.` then `Brief: briefs/M1.1.12-character-controller.md`. No `Co-Authored-By` exists anywhere in this history and none is added. +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) + +## Notes + +**The inner body is a duplication with a synchronisation contract, and that is the likeliest way this milestone ships a silent bug.** The character's pose lives twice: in the store record and in the body. The record is authoritative, the body is its mirror, and three entries write both — `moveCharacter`, `setCharacterPosition`, `resizeCharacter`. Miss the broadphase proxy update on any one of them and queries answer at the previous pose, which no test on a stationary character will find. That is why *presence pose freshness* is its own acceptance case on all three paths and not a single case on the move. + +**The base-versus-centre offset is the second.** A character's position is its base; a body's pose is its shape's centre. The offset is half the height along up, it belongs in exactly one named place, and computed a second time it will disagree with the first. `engine-movement.md`'s old ground raycast implied the base and `BodyDescriptor` means the centre, which is how a half-height discrepancy hides in plain sight. + +**Do not derive a slope from an angle anywhere.** The stored cosine is the only form. An `acos` appearing in the move path is both a determinism hazard for M1.1.14 and a re-derivation of a threshold the engine already holds. + +**Sweep the class, not the instance.** When the same defect appears twice, the instance fix guarantees the next round. `island_manager.zig`'s four prose sites are in this brief for that reason and not because prose matters more than code. A corrected text is added by **deleting** the one it replaces. + +**Reference lineage, verified on source at `jrouwe/JoltPhysics@master`.** `Jolt/Physics/Character/CharacterVirtual.h`: `CharacterVirtualSettings` carries `mMass` 70.0, `mMaxStrength` 100.0, `mCharacterPadding` 0.02, `mPredictiveContactDistance` 0.1 — whose comment states that a value of 0 will most likely get the character stuck, sliding direction no longer being computable — `mInnerBodyShape` (null by default), `mInnerBodyLayer`, `mInnerBodyIDOverride`; the class exposes `GetInnerBodyID`, `SetInnerBodyShape`, `UpdateInnerBodyTransform`, `GetInnerBodyPosition`, and `mShapeOffset` with `GetCenterOfMassPosition` distinct from `GetPosition`; the class comment states the character is not tracked by `PhysicsSystem` and that the inner body is optional. `Jolt/Physics/Body/BodyInterface.h`: `MoveKinematic(BodyID, RVec3 targetPosition, Quat targetRotation, float deltaTime)` and `SetAngularVelocity` both exist. Weld keeps the bodyless controller, the base-anchored position, the kinematic auto-excluded inner body, and the four parameter defaults. Weld **diverges** on: `inner_body` defaulting to `true`; no separate inner-body layer, `collision_layer` serving, our own `collision_layer` / `layer_mask` split doing that work; no `inner_body_shape`, the presence carrying the controller's own capsule; the anchor **fixed** where the reference parameterises it through `mShapeOffset`; no `mInnerBodyIDOverride`, the stable-slot LIFO allocator already making `BodyId` a deterministic function of the creation sequence. Verify any further claim about the reference **on the reference**, never on a comment in this repository. + +**`git checkout ` destroys unstaged edits, and a counter-factual is where that bites.** +Established at gate A, at the price of redoing roughly a third of that gate's writing: reverting an +injected break with `git checkout src/modules/forge/api/types.zig` restored the file from the index +and took every uncommitted edit with it, without a prompt. That is git's documented behaviour and +not a local incident. The practice is therefore: **copy the file to the scratchpad before injecting a +break, and restore from that copy** — or commit first and revert against the commit. It is also a +reason to keep counter-factuals late in a gate rather than mid-writing. + +**Tooling facts established at M1.1.11 and M1.1.11.1, not to be rediagnosed.** The leak checker's `safety` flag is false by default in ReleaseFast. The pre-commit hook lints only staged files. The build runner's `failed command:` line appears on tests that pass while writing to stderr, and is not a failure. A pipeline's status is its **last** command's: capture `$?` before any filtering and keep the **complete** log on failure. `zsh` does not word-split an unquoted flags variable. Best-of-three does not decide a gap under 5 %: interleave the runs. On a `pull_request` event, `paths-ignore` filters evaluate over **every** file in the PR and not over the last commit. `bench.yml` carries `timeout-minutes: 10`, marginal on the Windows runner, and a cancellation with no output there is a budget overrun rather than a hang — rerun before suspecting the code. + +**File length.** `character.zig` is a new file and will be large. The 500-line review guideline is a guideline and an overage here is expected, but it is declared in a Recorded deviation with its shape, not absorbed in silence. `body_manager.zig` is already a conscious overage; if it grows further, say so. + +--- + +# LIVING SECTION + +*Filled by Claude Code during execution. Recorded deviations, blockers, closing notes, execution log.* + +## Recorded deviations + +### RD-1 — `CharacterMoveResult.ground_body`'s default becomes `PackedId.dead`, not `0` + +**FROZEN SECTION change, acted through a Claude.ai round-trip at gate A.** +`engine-tier-interfaces.md` moves **0.7 → 0.8**; `engine-physics-forge.md` §1.12.5's table +follows. Claude.ai's verdict names it a defect of its own spec, surfaced by this gate's diff. + +§1 v0.7 froze `ground_body: BodyId = 0`. The gate-A pin exposed that `0` is not a sentinel — +`PackedId.pack(0, 0)` is `0`, a valid handle to slot 0 generation 0 — and the round-trip established +the consequence, which is structural rather than scenario-dependent: **with `0` as the default, no +bit configuration of the field means absence**. The field is then unreadable without consulting a +neighbouring one, and that coupling is invisible at the C ABI level — `engine-c-api.md` carrying +neither `struct_size` nor a minor version, a Tier 3 caller reading `ground_body` alone has no way to +learn it was supposed to read `ground_state` first, and no future version can teach it. + +An earlier draft of this entry argued the same conclusion from a scene — the first body created +being typically the ground — and that illustration is **WITHDRAWN**, in code comments and here, +because it derives an engine rule from a hypothetical creation order. Same fault as the one RD-3 +records. The bit-configuration argument depends on no scene and is the only one written. + +Three things change, and one of them is a deletion: + +- `PackedId` gains `pub const dead: u32 = pack(maxInt(u24), maxInt(u8))` — the all-ones + reservation, on the exact form of `EntityId.dead` and with the same collision argument + (`index = maxInt(u24)` needs 16.7 M live slots). **One** constant for the three handles, since + they share the packing; a per-type sentinel would be three values to keep equal. +- `ground_body: BodyId = PackedId.dead`. +- The two texts describing the old state — `ground_body`'s doc comment and the type-level note + explaining "why `0` is a default and not a sentinel" — are **deleted**, not amended. They + describe a state that no longer exists, and a corrected text is added by removing the one it + replaces. + +Why it could not wait: `engine-c-api.md` carries no `struct_size` and no minor version +(registered open signal, `engine-audit-checklist.md` §5), so after the M1.1.15 freeze a +descriptor field's default is frozen into the ABI for every Tier 3 plugin. + +**Class swept, and it is of a single instance.** +`grep ': BodyId = \|: ShapeId = \|: JointId = \|: CharacterId = '` over the frozen surface returns +`ground_body` alone — the only other hit is a test local, not a field default. No further search, +and no per-type constant. + +### RD-2 — the frozen `## Notes` section gained one paragraph + +Authorised explicitly in the same gate-A round-trip that carries RD-1: the `git checkout` tooling +defect is to be recorded in § Notes "so the next milestone does not rediscover it at the same +price". Recorded here so that a FROZEN SECTION edit is traced rather than silent. + +### RD-3 — the Context's justification for the inner body is superseded + +*Text supplied by Claude.ai at the gate-A addendum, copied verbatim.* + +**RD-n — the Context's justification for the inner body is superseded.** Context +item 2, the `inner_body` scope bullet, and the `Specs to read first` line naming +C1.8 as « what makes the inner body mandatory » all derive a normative engine rule +from a validation gate. `engine-phase-1-criteria.md` C1.x MEASURE whether the engine +arrived somewhere; they are not design inputs, which are the domain and the ARCH-nnn +invariants. The argument that holds is internal to the frozen surface and mentions no +demo: `CharacterDescriptor` has carried `collision_layer` since its original version, +and in Weld's query family the object layer is the mechanism by which an object +declares itself VISIBLE to other callers' queries (§1.11.5 — the layer tested is the +touched shape's, and the caller declares by mask what it wants to see). So either the +character carries a broadphase presence, or `collision_layer` is a field with no +observable effect. The frozen surface has promised query visibility since it was +written; this milestone makes the promise true rather than inventing it. The coherent +alternative — no presence, and `collision_layer` removed from the descriptor — was +rejected on cost and not on principle: without a presence every consumer maintains its +own « where are the characters » structure and every query becomes two, forever, in a +frozen surface. Timing is settled by the deferral rule alone: neither a descriptor +field nor an interface entry can land after the M1.1.15 freeze. No implementation line +changes; only the reason does. C1.8 keeps one legitimate role, as a scheduling fact — +someone meets this hole in Phase 1 and not in Phase 3 — and that role belongs in a +milestone artifact, never in a durable spec. + +**Realisation note.** No implementation line changes, as the entry states, and nothing was removed +from the frozen Context, Scope or Specs-to-read-first — those keep their C1.8 derivation and this +entry supersedes it in writing, which is the only mechanism available for a FROZEN SECTION. What +changed in the delivered code is the `inner_body` doc comment, which cited C1.8 as the reason: it now +carries the frozen-surface argument, `collision_layer` being a field with no observable effect on a +character no query can see. The C1.8 sentence is deleted there rather than kept beside its +replacement. + +## Blockers encountered + +*(none yet)* + +### RD-4 — `destroyCharacter` gains a `*Broadphase` parameter + +The frozen surface has no `destroyCharacter` at all before this milestone — it is one of the six +entries added here — so this is a change to a signature frozen ten days ago rather than one frozen at +M1.1.0, and it is recorded for that reason and not because the interface tier constrains it. + +The proxy is the character's FOURTH resource and it was being leaked: `Broadphase.remove` is the only +way to release it, it takes the broadphase, and the entry had no reference to one. `remove` is +infallible, so the entry stays `void` and its frozen return type is untouched. The alternative — a +store that remembers its broadphase — was refused: the two pose paths and the resize already take one +per call, and a stored pointer would be the only long-lived reference to a Tier-0 structure anywhere +in this module. + +### RD-5 — `BodyManager.setShape` is restricted to a convex on both sides + +Added at gate B for `resizeCharacter` with a body-type assert only, and its doc comment described a +general shape swap. It maintained two of that swap's four consequences: a mesh carries a CACHED +`world_aabb` a mesh-to-mesh swap would leave stale, and a half-space lives outside the broadphase +trees so a convex-to-half-space swap leaves a leaf for a shape with no box. Restricted rather than +extended, because the only need is capsule to capsule on a kinematic presence and a later relaxation +is purely additive — and because the restriction makes both inconsistencies unreachable by +construction instead of handled. The comment now says what the function does NOT do. + +## Execution log + +### Étape 0 — spec request + +The seven specs of § Specs to read first were **already attached** to the opening message, alongside +`engine-audit-checklist.md`. The blockquote request of Étape 0 was therefore not sent — it would have +asked for documents already in hand. No other action preceded the ingestion. + +### Étape 1 — setup + +Branch `phase-1/forge/character-controller` created from `main` at `a4354df` (tag +`v0.11.11-mesh-shape`), working tree clean at branch point. Brief copied **verbatim** to +`briefs/M1.1.12-character-controller.md`, byte-identity verified by `diff -q` before the commit. +Commit `docs(brief): add M1.1.12 milestone brief`. + +### Étape 2 — ingestion + +Seven documents read in the order of § Specs to read first, in full, with `engine-physics-forge.md` +§1.12 read first as the brief demands. Timestamps ticked in § Specs read. `Status:` PLANNED → ACTIVE. +No production line written before this point. + +### Gate A — the frozen surface, and the stale-prose class + +**A.1 — the frozen surface.** `CharacterId`, `CharacterDescriptor`, `GroundState` and +`CharacterMoveResult` transcribed into `src/modules/forge/api/types.zig` from +`engine-tier-interfaces.md` §1 at version 0.7, field for field, name for name, default for default, +and in the spec's own declaration order (the character block sits between `BodyDescriptor` and +`PhysicsQueryFilter`, exactly where §1 puts it). The brief's claim that none of the four exists on +`main` was VERIFIED and not assumed: `grep -rn Character src/` returns one hit, an unrelated Etch +parser fixture string `"BaseCharacter"`. + +`CharacterId` carries the established `PackedId` layout `index:24 | generation:8`. No new packing +helper: the same `PackedId.pack`/`unpack` serves all three handles. + +**Two absences confirmed against the repo before transcribing, rather than after.** `EntityId.dead` +exists (`src/core/ecs/entity.zig:37`) and is the null form `ground_entity` takes outside +`.grounded`/`.on_steep_ground`. `foundation.math.Vec3` carries `unit_y` and **no `up`** — the module +names its basis vectors by axis, while `engine-coordinate-system.md` §2 names this one semantically. +`ground_normal`'s default is therefore spelled `Vec3.unit_y`, which is bit-identically the spec's +`Vec3.up` under the engine's Y-up convention, `(0, 1, 0)`. This is a spelling and not a value: the +pin asserts `lengthSq() == 1` exactly, so the field's non-poisoned contract is stated in its +strongest available form. Adding `up`/`down`/`forward`/`back` to `foundation/math/vec.zig` would be +the alternative and it is OUT of this gate's file list — flagged for Guy rather than taken. + +**Where `setBodyTransform`'s teleportation note landed, and why there.** The declaration does not +exist anywhere in the repo — `grep -rn setBodyTransform src/ tests/ bench/` is empty, because +`src/interfaces/PhysicsModule.zig` is deferred to M1.1.15 with `ModuleContext` (M1.1.0 scope +boundary). There is therefore no doc comment to extend. The note landed as a `// ---` section block +in `api/types.zig`, on the exact precedent of the `// --- Queries (the complete family, frozen +before the interface freeze) ---` block that file already carries, placed next to the frozen types +the three entries traffic in and covering `setBodyTransform`, `moveKinematic` and +`setAngularVelocity` together — they are one argument, not three. It moves with the declarations +when that file lands, which is what this file's own header already promises. A realization inside +the designated file, in the class of M1.1.1's `insert(gpa, …)` realizing a schematic +`insert(aabb, user_data)`; recorded here rather than as a deviation, and named in the gate signal +so Guy can rule otherwise. + +**One file touched outside § Files to create or modify: `src/modules/forge/api/root.zig`.** +Justification: that file re-exports the api surface **symbol by symbol**, and its own header states +that `forge_3d` and any Tier-3 backend depend on it. Without four re-export lines the frozen surface +is unreachable from the solver — a frozen surface no consumer can name is not a surface, and gates B +onward reach these types as `api.CharacterDescriptor`. The same edit also had to correct the +`PackedId` re-export doc, which said "shared by `BodyId`/`ShapeId`" and became stale the moment +`CharacterId` joined: a **fifth instance of the very prose class A.2 sweeps**, created by this +gate's own change, and deleted-and-replaced rather than left beside its correction. + +**Two findings about the frozen surface. One of them changed it.** + +1. `CharacterMoveResult.ground_body`'s default of `0` is **not a sentinel** — and the round-trip + established that this is a live defect and not a documentation gap. See **RD-1**: the default + becomes `PackedId.dead`, `engine-tier-interfaces.md` goes to 0.8, and the two texts that + described the old state are deleted. The reasoning that holds is structural and mentions no + scene: with `0` as the default, no bit configuration of the field means absence, so the field is + readable only in company of `ground_state` — a coupling the C ABI cannot express. +2. `BodyId`, `ShapeId` and `CharacterId` are **all `u32`, hence not distinct types**. The compiler + cannot stop a `BodyId` being passed where a `CharacterId` is wanted. That is the frozen + boundary's own choice (§1 declares all of them `u32`) and it is NOT reopened — confirmed at the + round-trip; what the generation buys is stale-slot detection, never handle confusion. Pinned by + `expectEqual(BodyId, CharacterId)` so the absence of type safety is asserted rather than left + tacit. + +**A.2 — the stale-prose class, swept.** The four sites the brief names were confirmed at +`island_manager.zig:36`, `:81`, `:160` and `:339`, each describing the resolution order as +`(rank, pair_key)` while `lessByCompositeKey` at `:434` compares the triplet and the `subshape_id` +field documents itself as "the third term of the composite key". All four now carry +`(rank, pair_key, subshape_id)`, and in every case the superseded text was **deleted**, never left +beside its replacement. The file-header site gained the reason the third term exists — a mesh pair +contributes one constraint per contacting triangle, all sharing `rank` and `pair_key`, so on those +two alone the order would be `std.sort.block`'s unstable tie-handling — and names +`lessByCompositeKey` as the authority, which is what makes the header's own promise checkable +instead of merely repeated. + +**The sweep was run over the whole repo and not over the four named lines**, since the brief's own +instruction is that four occurrences are a class. `grep -rn "rank, pair_key)"` excluding lines that +also name `subshape` now returns **nothing** in `src/`. Three hits survive outside it and are +**deliberately not retro-patched**: + +- `CLAUDE.md:67` — the `v0.11.8-islands-sleep` tag row. +- `briefs/M1.1.8-islands-sleep.md:30` and `:154` — a closed, frozen brief. + +Both are records of a state at a date, and the doctrine is explicit on exactly this point: the +`engine-audit-checklist.md` journal closes its 2026-08-04 entry with "**Non retro-patché, +délibérément** — un journal enregistre un état à une date", and M1.1.9 declined to patch the M0.7 +brief's acceptance line for the same reason. `forge_3d/rigid/root.zig` already carried the triplet +from M1.1.11.1 and was never stale. + +Nothing else in `island_manager.zig` was touched: no code, no signature, no test. + +**Non-vacuity of the pins, MEASURED in both directions.** "13 tests pass" says nothing about whether +a pin bites, so each mechanism was broken deliberately and the failure observed. Five breaks across +three mechanisms, in two runs: + +| Break | Mechanism | Observed | +|---|---|---| +| `radius` default `0.3` → `0.31` | default assert | `expected 0.3, found 0.31` | +| a value inserted mid-`GroundState` | enum order assert | `expected 1, found 2` | +| a value APPENDED to `GroundState` | enum count assert | `expected 3, found 4` | +| a field APPENDED to `CharacterDescriptor` | struct count assert | `expected 13, found 14` | +| a field APPENDED to `CharacterMoveResult` | struct count assert | `expected 6, found 7` | + +The three APPEND cases are the point of the count asserts and the reason they are not decoration: an +appended field or value leaves **every** by-name and default assert passing, so a by-name reference — +which catches a rename or a removal as a compile error — cannot see it at all. The first run proved +this method's own trap rather than the pin: with the `radius` break present, the descriptor test +stopped at its first failing assertion and the count assert never executed, so the count pin looked +proven when it was not. It was re-run with append-only breaks, each isolated, and each fired **on the +count assert itself**. + +**Test collection, checked rather than trusted.** `zig build test-forge-3d` reported `419/419`, +which is bit-for-bit the entry count at `a4354df` — the signature of the silent skip +(`engine-zig-conventions.md` §13). Diagnosed rather than assumed: the new pins live in `weld_forge`, +whose inline tests are collected by `forge_api_tests` (`build.zig:307`), which belongs to +`zig build test` and **not** to `test-forge-3d`. Under `zig build test` the `forge_api` step reports +`13 pass (13 total)` — 9 test blocks in `types.zig` plus 4 in `components.zig` — and it is the one +step whose compile line reads `success` rather than `cached`, which identifies it positively rather +than by position in the tree. No `build.zig` change: registering a `test-forge-api` step is not +this gate's scope, and the target already exists. + +**One tooling defect, self-reported.** Reverting the first counter-factual with +`git checkout src/modules/forge/api/types.zig` **destroyed every uncommitted A.1 edit in that file** +— `git checkout` restores from the index, and the work was unstaged. It was reapplied in full and +the delivered content is unchanged, but roughly a third of the gate's writing was redone for nothing. +Standing practice, adopted and used for the second counter-factual: copy the file to the scratchpad +before injecting a break and restore from that copy; never `git checkout` a file whose only copy of +the work is the working tree. + +**On the wiring table.** The brief makes it due at gate C, and gate A produces no measured quantity: +every assertion added here is a per-case guarantee and there is not one counter, so the table's own +rule — a guarantee is asserted per case, a counter belongs only to a shape allowed to fail — is +satisfied vacuously rather than by arrangement. + +**Explicitly not in this gate**, per the brief: `character.zig`, `collideShapeBody`, +`setAngularVelocity`'s implementation, the inner body, any movement logic, any ground determination. +`forge_3d/root.zig` is untouched — its re-exports and the comptime pin belong to the gate that +creates the suite. + +**Validation.** Six corners, each a LITERAL command rather than a flags variable, exit code captured +BEFORE any filtering and the full log kept on failure (the three tooling facts the brief lists): + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 | 0 | 4/4 steps, 419/419 | +| `test-forge-3d`, Debug, f64 | 0 | 4/4 steps, 419/419 | +| `test-forge-3d`, ReleaseSafe, f32 | 0 | 4/4 steps, 419/419 | +| `test-forge-3d`, ReleaseSafe, f64 | 0 | 4/4 steps, 419/419 | +| `zig build test`, Debug | 0 | 264/264 steps, 1590/1607 (17 skipped) | +| `zig build test`, ReleaseSafe | 0 | 264/264 steps, 1590/1607 (17 skipped) | + +`zig fmt --check src/ bench/ tests/` clean; tree-wide `zig build lint` exit 0 — run on the tree and +not through the pre-commit hook, which lints staged files only. + +`test-forge-3d` stays at 419 in all four corners and that is the CORRECT number, not a stall: the +four new pins are collected by the `forge_api` target, and the `-Dphysics_f64` flag governs +`forge_3d` alone — the api surface is `f32` by construction (§1.11.8), so no f64 leg of it exists to +run. Confirmed at the round-trip against the build graph and the CI workflow, not merely reasoned: +`forge_api_tests` depends only on `test_step` and `test-forge-3d` only on `forge_3d_tests_run`, and +`.github/workflows/ci.yml:228` runs `zig build test -Doptimize=${{ matrix.mode }}`, so the pins run +in CI in Debug **and** ReleaseSafe. Nothing is moved. + +The `island_manager.zig` diff is comment-only, verified mechanically by filtering the diff down to +its non-comment lines, which are none. + +### Gate A — STOP round-trip: `ground_body`'s default + +One STOP, on the first of the two findings above, and it is recorded as **RD-1** rather than +absorbed: `ground_body: BodyId = 0` becomes `PackedId.dead`. The full reasoning, the deletions and +the class sweep live in that entry; what belongs here is what was run. + +`PackedId.dead` is one constant for the three handles, declared in `PackedId` immediately after its +two fields — the placement `EntityId` uses for its own `dead`, so the two read as the same idiom at +the two tiers. The class was verified to be a single instance before the fix and not after: +`grep ': BodyId = \|: ShapeId = \|: JointId = \|: CharacterId = '` over the frozen surface returned +`ground_body` and one test local, nothing else. After the fix, +`grep ': BodyId = 0\|: ShapeId = 0\|: CharacterId = 0'` over `src/modules/forge/` returns nothing. + +**The regression pin was proven, and its two halves are not equally proven.** Injecting the exact +regression — the default put back to `0` — fails the test with `expected 4294967295, found 0`. That +is the FIRST half firing (`expectEqual(PackedId.dead, r.ground_body)`), since it comes first. The +second half, `ground_body != 0`, is therefore **not independently exercised** by this +counter-factual: it is a redundant guard that would still catch a return to `0` if the sentinel +constant itself were corrupted to `0` in the same change. Stated rather than claimed as measured, +because the whole point of the previous non-vacuity round was that an earlier assertion +short-circuits the ones behind it. + +Also done at this round-trip, per its three rulings: + +- The `// --- Body pose and velocity entries ---` block **stays where it is** — the brief's line + asked to extend a doc comment on a declaration that does not exist anywhere in the repo — and + gains one line naming **M1.1.15 as its destination**, to be MOVED onto the three declarations in + `src/interfaces/PhysicsModule.zig` and not duplicated there. Two copies of a contract are two + things that can disagree, which is the block's own subject. +- `Vec3.unit_y` **stays**, and no direction constant is added to `foundation/math/vec.zig`: + `up`/`down`/`forward` are names of a coordinate CONVENTION, and `foundation/math` has no business + knowing the engine's. The two spellings are two languages — `engine-coordinate-system.md`'s table + is Etch, `vec.zig` names basis vectors by axis in Zig — and the comment explaining both is kept + verbatim. The spec side is patched by Claude.ai. +- `api/root.zig` is **accepted**, the brief's file list having been incomplete: a new public type + requires its re-export. Its `PackedId` doc line now also names `PackedId.dead`, without which a + consumer reaching the surface through `root.zig` could not find the sentinel. + +**Validation after the fix**, same six literal corners: + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 419/419 · 419/419 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 419/419 · 419/419 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1591/1608 (17 skipped) ×2 | + +The `forge_api` step reads `14 pass (14 total)`, up one from the 13 of the first gate-A run — the +new `PackedId.dead` test — and the suite total moves 1607 → 1608 accordingly, which is the arithmetic +that shows the added test is collected rather than assumed to be. `zig fmt --check src/ bench/ tests/` +clean; tree-wide `zig build lint` exit 0. + +### Gate A — addendum: two justifications withdrawn, no behaviour changed + +The addendum carries no instruction change and no implementation change. What it corrects is **why** +two things are the way they are, and in both cases the faulty reason had the same shape: an engine +rule derived from something outside the engine — a validation gate in one case, a hypothetical scene +in the other. Both replacements were made by DELETING the superseded text. + +**RD-3 — the inner body.** Recorded verbatim as supplied. The frozen Context, Scope and +`Specs to read first` keep their C1.8 derivation and are NOT re-patched, RD-3 superseding them in +writing, which is the only mechanism a FROZEN SECTION allows. One delivered line did change: the +`inner_body` doc comment cited C1.8 as the reason and now carries the argument internal to the +descriptor — `collision_layer` is how an object declares itself visible to other callers' queries +(§1.11.5), so either the character carries a presence or that field has no observable effect. +`grep -rn "C1\.8" src/modules/forge/api/` now returns nothing. + +**The `PackedId.dead` motive.** The illustration used at the first round-trip — a scene in which the +first body created is typically the ground — is **withdrawn from every comment**, and the durable +motive replaces it in two places: the `ground_body` doc comment and the regression-pin comment in +the test. It also cost the two brief passages that had repeated it. What is written now depends on no +scene: `PackedId.pack(0, 0)` is `0`, so with `0` as the default no bit configuration of the field +means absence; the field is then readable only in company of `ground_state`, and that coupling is +invisible at the C ABI level, where `engine-c-api.md` has neither `struct_size` nor a minor version to +ever express it. + +Worth recording as the pattern rather than as two incidents: **a justification is a durable artifact +and inherits the discipline of the thing it justifies.** Both faulty reasons would have compiled, +passed every test, and survived review by resembling arguments — the class this repository has +already named its costliest. Neither was found by a test; both were found by someone reading the +direction of the derivation. + +**One correction to my own gate-A signal, since it went the other way.** I wrote that Claude.ai's +arena argument was "plus fort que mon constat" and copied it over an argument I already had. It was +the weaker of the two, and the addendum's ruling is that saying so was my job. Precisely: my original +observation — no value of `ground_body` means absence, so `ground_state` is the discriminator — was +the correct PREMISE but concluded "document the coupling"; the C ABI clause is what turns it into +"remove the coupling", and that clause was not mine. What I conceded was the middle step, and the +delivered text is the synthesis of the two ends without it. + +### Gate B — the seventh adapter and the character store + +**B.1 — `collideShapeBody`.** A caller-supplied convex probe at a pose against a body, calling +`collector.add(subshape_id, manifold)` for every manifold the pair produces. `castShapeBody`'s shape +without the direction, and a 1×3 dispatch rather than `collidePairEach`'s 3×3: the probe is a bounded +convex by its type, so only the BODY's category is switched on — exhaustively, no `else`. + +Normal orientation is **probe → body** in all three arms. The half-space arm computes body→probe, +§1.11.15's formulas being stated with the plane as A, and negates; the mesh arm passes +`mesh_is_a = false` so `collideConvexMesh` hands `collideOrdered` its arguments in that order and +needs no mirroring. + +**Only the general "Each" form**, with no convenience sibling asserting the body carries no +sub-shape. Such a wrapper is purely additive, so the deferral rule lets it wait for a caller that +wants it — and the controller is not that caller: a mesh floor is exactly the case it cannot afford +to lose. + +**No `back_face_mode` parameter, and that is a decision.** Contact generation culls back faces +unconditionally, the mode being a solver-internal setting and not part of the frozen surface +(§1.11.17). Reusing `collideConvexMesh` rather than walking the mesh here is what makes the entry +inherit both that cull and the internal-edge correction M1.1.11.1 proved on its slider — the two +behaviours a controller most needs and would have had to re-derive. + +**B.2 — the character store**, `forge_3d/character.zig`. The `ShapeStore` pattern verbatim: stable +slots, LIFO recycling, a generation on the handle. A plain column and not a `MultiArrayList`, because +a character is addressed by handle and never swept — which is what distinguishes it from +`BodyManager`, whose SoA exists for the per-tick integrator pass. + +`createCharacter` is transactional across THREE stores: validate first, allocating nothing; then the +capsule under an `errdefer` that destroys it; then the presence under an `errdefer` that removes it; +then the slot and column, the last fallible steps, after which the commit cannot fail. So there is no +window in which a live slot exists without its capsule or its presence. + +**The base↔centre offset lives in one named place, `baseToCentre`,** and it is generic over the +scalar rather than duplicated per precision — the presence's `BodyDescriptor` is `f32` (§1.12.11) +while every later pose write is at `Real`. That is safe and not merely convenient: halving is exact +in binary floating point and widening is exact, so the two calls return the same number. Asserted as +BIT equality rather than approximately, because the whole point of one definition is that there is +not a second value. + +**The presence is NOT inserted into the broadphase**, and that is the state of the tree and not an +oversight: no `BodyType → BroadphaseLayer` wiring exists — the layer is an insertion argument — and +it arrives with `PhysicsWorld` at M1.1.15. The suite inserts its own proxy through a local helper +over the published `harness.World`, as the query suites do, which is also why `solver_test.zig` is +untouched. + +**Two domain conditions beyond the brief's list, both reported rather than absorbed.** + +1. **`height >= 2 · radius`**, refused by `error.InvalidDimensions`. Not in the brief's enumeration, + but it follows from the capsule construction: the cylinder half-height is `height/2 − radius`, so + a shorter height describes no capsule at all. Refused and not clamped to a sphere, on the same + argument as `max_slope`. MEASURED to be the sole detector — see the counter-factuals below. +2. **`max_slope` is range-checked to `[0, π/2]`**, which the Scope bullet requires ("a value outside + it is a domain error") though the frozen enumeration lists only non-finiteness for that field. + +And one the brief lists that is deliberately NOT implemented: negative `padding`, `mass` or +`max_push_force` are currently ACCEPTED, only non-finiteness being refused, because that is exactly +what the frozen list says. None of the three has a consumer before gates D and F. Flagged for Guy +rather than silently widened. + +`predictive_contact_distance` is deliberately unvalidated: it is the one field no code consumes, and +gate D either consumes it — and then owns its domain — or deletes it, in which case a check written +now would be a check to delete. + +**Two getters added to `BodyManager`**, `bodyType` and `shapeOf`. Both are one-line stale-safe reads +of columns that have existed since M1.1.0, and both exist because §1.12.2 states normative properties +of the presence that were otherwise UNASSERTABLE: that it is `.kinematic` — `motionProperties` cannot +tell a kinematic from a static, both carrying an inverse mass of exactly zero — and that it carries +the controller's OWN capsule and never a second shape, whose direct form is handle equality. Same +argument that exposed `entity()` at M1.1.10: a normative property nothing can assert is one that +regresses silently. + +**Non-vacuity, measured in three places.** + +| Probe | Result | +|---|---| +| Allocation sites in `createCharacter` | **6** — measured by the sweep, and exactly the structural prediction: two per store (slot metadata + column) across the shape store, the body manager and this one | +| `height >= 2 · radius` guard removed | `expected error.InvalidDimensions, found 0` — the call SUCCEEDS and builds a capsule with a negative cylinder half-height. Sole detector. | +| `max_slope` range guard removed | `expected error.InvalidSlope, found 0` — the call SUCCEEDS and stores `cos(π) = −1`, i.e. "every surface is walkable, ceilings included". Sole detector. | + +The transactional test is a SWEEP over `fail_index` rather than one hand-written case per site, so +the count is measured instead of predicted and a site added later is covered without editing the +test. It asserts the three stores empty at every failing index, and carries its own non-vacuity +bound: at least one failure must have occurred, and the loop must have terminated by SUCCEEDING +rather than by exhausting its bound. + +Both counter-factuals were run ISOLATED, one guard at a time — the first attempt removed both and +only the earlier case fired, which is the same short-circuit trap the gate-A round already paid for +once. The lesson transferred rather than being relearned. + +**A discriminator was added to the mesh test after writing it.** Asserting "2 manifolds" alone does +not distinguish "one per contacting triangle" from "one per traversed triangle": a probe over the +INTERIOR of a single triangle must report exactly one, and its identity must be that triangle's. The +positions are argued in the comment — the point (0.5, −0.5) in xz is 0.707 m from the shared +diagonal while the sphere's footprint at the floor plane has radius √(0.5² − 0.4²) = 0.3, so the +second triangle is out of reach by a wide and computed margin. + +**Explicitly not in this gate**: `moveCharacter` and any sweep, slide or depenetration logic; the +ground verdict and its five `ground_*` quantities — none of whose state is even declared, so a field +arrives with the code that fills it; `setAngularVelocity`'s interface path; `resizeCharacter`; the +push; `setCharacterPosition`; `moveKinematic`. + +**No wiring table at this gate**, per the addendum's resolution of the brief's own tension: the table +carries MEASURED quantities and their nature, and gate B produces none — every assertion here is +pass/fail per case. Gate C is the first to produce normals, velocities and a threshold. + +#### What the f64 corner found, and it was the apparatus again + +Six tests failed at `-Dphysics_f64=true` while passing at `f32`, and the cause was **one tolerance +class error of mine**, not a production defect. `CharacterDescriptor` is `f32` and stays so until the +grouped widening of M1.1.15, so a field authored `0.3` is stored as `f32(0.3)`, which widened is +`0.30000001192…`. Asserting that against the decimal literal `0.3` at the solver tolerance — `1e-12` +in an `f64` build — asserts a precision the descriptor CANNOT carry. At `f32` the two tolerances +coincide at `1e-5`, so the whole class was invisible in that leg. + +Fixed by a second named tolerance, `api_tol`, `f32`-grade in BOTH builds, applied to the twenty +assertions whose quantity ARRIVED THROUGH THE PUBLIC SURFACE. The discriminator is the quantity's +ORIGIN and not whether a literal happens to be representable, which is why the manifold normals and +penetrations of the adapter tests keep `tol`: those the solver computes. No assertion is weakened at +`f32`, where the two values are equal, and none is vacuous at `f64` — the smallest margin any of them +must resolve is the `0.2` between `cos(π/3)` and `cos(π/4)`, four orders above the bound. + +**And the six reported were not the whole set.** A test stops at its first failing assertion, so the +five later assertions of the first failing test were never reached — including a presence-pose +comparison that would have failed for the same reason. The class was therefore fixed by enumerating +every descriptor-origin assertion in the file, not by repairing the six the log named. **Third +appearance of this same trap in this milestone** (the gate-A count pins, the two isolated domain +guards, and now this), and it is the reason each counter-factual here was run one guard at a time. + +**One assertion was not a tolerance problem but MIS-POSED, and only the `f64` leg could show it.** The +`baseToCentre` bit-equality check passed the same decimal literal `1.8` to the `f32` and the `Real` +form. But `f32(1.8)` and `f64(1.8)` are different numbers, so it compared two different INPUTS and +proved nothing about the offset — while passing at `f32`, where they are the same number by +construction. It now takes the height from one `f32` variable and widens it, which is the claim +actually being made. + +#### A defect in my own validation harness, self-reported + +The six-corner script reported `SCRIPT_EXIT=0` on the very run where two corners were RED. Its exit +code was its last `echo`'s, so the failure existed only inside the log file — precisely the "a +pipeline's status is its last command's" fact the brief lists as established, reproduced in the +harness built to enforce it. Fixed: the script counts failing corners and exits non-zero, and its +final line now says `all corners green` or names the count. Had I read only the notification's exit +code, I would have signalled a green gate over a red `f64` leg. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 434/434 · 434/434 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 434/434 · 434/434 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1606/1623 (17 skipped) ×2 | + +**Domain guards, extended at the gate-B STOP.** Four more, and the rule that decides which +conditions earn one is the one I had already used for `height >= 2·radius` and then stopped applying: +a value the code ACCEPTS and that produces silently wrong geometry or dynamics is a domain error, and +refusing it costs one comparison. Non-finiteness is not the whole class. `padding < 0` inflates the +capsule inward so the character sinks `|padding|` into every surface; `mass <= 0` duplicates +`max_push_force = 0`, the documented disabler, and negative inverts the impulse so the character +pulls; `max_push_force < 0` is meaningless. And `predictive_contact_distance` is guarded even though +nothing consumed it at gate B, because it is **stored** at solver precision — a caller's NaN would +sit in the record indistinguishable from the DELIBERATE poison NaN this repository writes on purpose, +and that ambiguity is what cost M1.1.11.1 rounds, not the NaN. + +**Thirteen guards**, counted mechanically as `if` statements in `validateDescriptor`. Each of the four +new ones was counter-checked ISOLATED, and all four report `found 0` — the call SUCCEEDS without the +guard, so each is the sole detector of its condition. + +`test-forge-3d` moves 419 → **434**, which is exactly the **fifteen** test blocks this suite adds, +counted mechanically — so every one is collected rather than assumed to be. The mesh discriminator is +a block inside an existing test rather than a sixteenth, which is why the delta is fifteen and not +sixteen. The full suite moves 1608 → 1623, the same delta. `zig fmt --check src/ bench/ tests/` clean; +tree-wide `zig build lint` exit 0. The wrapper now exits non-zero on any red corner and its last line +reads `all corners green`. + +## Closing notes + +*(none yet)* + +### Gate C — ground determination, and the wiring table + +#### C.0 — the wiring table + +Delivered before the results, as the gate requires. Every quantity the suite measures, its NATURE, +and its treatment. The rule: **a guarantee is an assertion PER CASE; a counter belongs only to a +shape that is allowed to fail.** Nine of M1.1.11.1's last twelve findings were in the measuring +apparatus rather than the code, and two more were found in this milestone's own apparatus, so the +apparatus is declared before its output is read. + +| Quantity | Nature | Treatment | +|---|---|---| +| `ground_state` per scene | delivered guarantee | asserted per case against a hand-computed verdict, in eight scenes | +| `ground_normal` | delivered guarantee | asserted per case, component-wise, against the plane's own stored normal — exact by §1.11.15 | +| `ground_normal` is never poisoned | delivered guarantee | `lengthSq() == 1` EXACTLY on `.in_air`, not a tolerance | +| `ground_entity` / `ground_body` non-null off `.in_air` | delivered guarantee | asserted per case, including on `.on_steep_ground` | +| `ground_entity` / `ground_body` sentinels on `.in_air` | delivered guarantee | `EntityId.dead` and `PackedId.dead` by equality | +| `ground_velocity` | delivered guarantee | asserted against the closed form `v + ω × r`, plus a magnitude floor so a zero rotational term cannot hide in the tolerance | +| Winner selection (flattest) | delivered guarantee | asserted per case, with a discriminator scene proving the loser was in range | +| Tie-break `(BodyId, subshape_id)` | delivered guarantee | asserted under BOTH traversal orders | +| Sweep band `padding + predictive_contact_distance` | delivered guarantee | asserted as a closed form AND in both directions on a real scene | +| Manifold fallback at distance zero | delivered guarantee | asserted by the normal being the slope's and NOT `+up` | +| Stale handle | delivered guarantee | `expectError` per entry | +| Allocation sites in `createCharacter` | **historical metric** | 6, measured by sweep; the sweep asserts the INVARIANT per failing index and only the count is a metric | +| Domain guards in `validateDescriptor` | **historical metric** | 13, counted mechanically; each guard's effect is asserted per case | +| Test blocks in the suite | **historical metric** | 24, counted mechanically and cross-checked against the suite delta | +| Self-exclusion | **NOT measurable at this gate** | declared as such below rather than counted or claimed | + +No counter in this suite is load-bearing: every row marked "delivered guarantee" is an assertion that +fails the build. The three metrics describe the apparatus, not the engine. + +#### C.1 — the mechanism, and the trap was real + +**The verdict cannot come from manifolds at the current pose.** Verified on the source before writing +a line: `collideOrdered` returns null on a separated pair, and a character at rest is `padding` ABOVE +its floor — so a manifold-only probe answers `.in_air` for a character plainly standing up. + +The ground is found by a bounded DOWNWARD SWEEP of the capsule via `castShapeBody`, which returns the +outward normal of the surface it met (§1.11.11) — already the orientation `ground_normal` means, with +no sign work. At distance ZERO the cast's normal is `−direction`, i.e. exactly `+up`, and that value is +not wrong but UNUSABLE: on a slope it answers "perfectly horizontal" and every slope test would pass. +So the normal comes from `collideShapeBody` at the current pose instead. **That is why gate B +delivered two entries and not one.** + +#### C.2 — the sign, in one named place + +`collideShapeBody` returns probe → body: for a character on a floor it points from the capsule DOWN. +`ground_normal` points from the surface TOWARD the character. Hence ONE negation, in `ManifoldSink`, +and the test that pins it is on a 30° SLOPE and not on a flat floor — a flat floor makes an inverted +sign visible, a slope makes it diagnostic, because the wrong answer there is `+up` and differs from +the right one by 0.5 in x. + +#### C.3 — selection and verdict + +Candidate = a contact whose normal has a **strictly** positive component on up; winner = the largest +such component, i.e. the FLATTEST and not the nearest, so a character straddling an edge stands on the +walkable face; exact ties by the smaller `(BodyId, subshape_id)`; verdict `.grounded` if the winner +passes `normal · up >= cos_max_slope`, `.on_steep_ground` if a candidate exists and none passes, +`.in_air` if none exists. + +Two consequences written down because they are choices and not accidents. A perfectly VERTICAL wall is +never ground, whatever `max_slope` says: `cos(π/2)` is zero to float noise, so admitting `align_up == 0` +would make "can I stand on this wall" depend on that noise. And the collector's bound NEVER tightens, +unlike the query family's `closest` — a nearer steeper contact must not prune a flatter one behind it, +which is the whole point of "flattest wins". + +#### C.6 — the sweep bound, and it settles gate D's question + +`padding + predictive_contact_distance`, which makes this field's **first consumer** the ground probe. +The two terms are the two reasons the ground is not at distance zero when a character rests on it, and +their sum is the band in which "the ground I am standing on" is meaningful. The bound must be SMALL and +that is what makes "flattest wins" well posed: within 12 cm at the defaults every candidate is +genuinely underfoot, where over metres a distant flat floor would outrank the steep slope actually +under the character. The field is not deleted, and it is no longer inert. + +#### C.5 — `setAngularVelocity` + +**Already present and already `pub`** — `BodyManager` has carried it since M1.1.8, and gate A recorded +its interface entry in the frozen-surface block. So nothing was implemented here, and saying otherwise +would be claiming work that does not exist. What this gate adds is its first real CONSUMER: the +rotating-platform test, without which the rotational term of `ground_velocity` would have no +authorable source and the field would be inert. + +#### Non-vacuity: five probes, and TWO of my assertions proved nothing + +Each mechanism was disabled alone and the failing test observed. + +| Mechanism disabled | Result | +|---|---| +| The one sign negation | `at distance zero the manifold fallback answers, and NOT with up` fails | +| Flattest-wins inverted | `straddling a walkable and a steep face stands on the FLATTER one` fails | +| The manifold fallback | `at distance zero…` fails | +| The exact tie-break | **NOTHING failed** — my test was not discriminating | +| Self-exclusion | **NOTHING failed** — and no test can make it fail at this gate | + +**The tie-break test was mis-constructed.** I reversed the broadphase insertion order to make the +traversal offer the larger `BodyId` first — but without the tie-break the code keeps the LAST candidate +offered, so the reversed order yields the smaller `BodyId` anyway and both rules agree. It is the +FORWARD order that discriminates. Rewritten to sweep BOTH orders, and the counter-factual now fails +where it must. + +**Self-exclusion is required, implemented, and NOT observable at this gate — measured, not assumed.** +Removing the exclusion breaks no test, including the five that pin `ground_body` to a real support and +the one on a 60° slope. The reason is structural: what exclusion removes is a contact between the probe +and a body BIT-IDENTICAL to it at the same pose, whose normal §3 declares geometrically UNDEFINED, and +empirically that normal never qualifies as ground. An earlier version of the over-the-void test claimed +its `.in_air` verdict proved exclusion; that claim is **withdrawn in place**. What remains is a cheap +structural assertion — the support is never the character's own presence — plus the statement that the +mechanism becomes observable at gate D, where the same contact would block motion outright. Asserting +it here would mean asserting on a value the narrowphase documents as undefined. + +Both findings are the same class as the gate-B ones and as M1.1.11.1's closing round: **an assertion +that looks like it proves something, and does not.** Four instances in this milestone now, all found by +disabling the mechanism rather than by reading the test. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 443/443 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 443/443 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1615/1632 (17 skipped) ×2 | + +434 → **443**, the nine test blocks gate C adds, and the suite's own block count is **24** — matching +the 419 → 443 delta exactly, counted mechanically on both sides. `zig fmt --check src/ bench/ tests/` +clean; tree-wide `zig build lint` exit 0; the wrapper reports `all corners green`. + +The `f64` leg was green on the FIRST run this time, which is the gate-B tolerance lesson holding: every +new assertion on a descriptor-origin quantity was written against `api_tol` from the start. + +**Out of gate C**, per the brief: the displacement, sliding, depenetration, step height, +`resizeCharacter`, the push, `setCharacterPosition`, `moveKinematic`. + +### Gate D — the move + +#### D.1 — what `moveCharacter` takes and returns + +A DISPLACEMENT in metres, and `dt` reaches no term of this gate — the derived terms are the support +velocity, which the ground probe reads from the support's own columns, and the push impulse of gate +F. It is accepted so the signature is the frozen one and those terms have their input the day they +land. `MoveResult` carries the resolved BASE position and the ground verdict at the new pose, with +**no remaining displacement and no counter**: a caller that wants to know whether it was blocked +compares what it asked for against what it got, which is a caller-side derivation and exactly why +`collisions` was deleted from the frozen surface. + +The algorithm: depenetrate through the manifold, deepest first, bounded; then sweep and slide, +bounded — advance to `padding` short of the first contact, then constrain the rest against the +accumulated planes, one plane projecting and two sliding along their crease; then publish the record, +mirror the pose onto the presence and its proxy, wake what was touched, and re-probe the ground. + +Both ceilings are NAMED at 4 and exhausting either stops the character SHORT of where it asked to go, +never further — §1.11.11's safe failure direction, and the reason the assertion on it is an +inequality rather than an equality. + +#### D.2 / D.3 / D.4 — the three constrained cases + +The distance-zero trap is met a second time and answered the same way: **depenetration goes through +the MANIFOLD and never through the sweep**, whose `−direction` at distance zero is the direction +travelled from rather than the surface. The obligatory test where the two answers differ measurably +is a 30° slope entered 2 cm deep: the manifold pushes along `(0.5, 0.866, 0)` so the correction has +`Δx = 0.01`, where a sweep-driven push would have moved purely in Y. The x component IS the +discriminator and is asserted as one. + +The crease uses `math.triangleCrossDirection`, re-exported from `exact.zig` for this second consumer +— the tiered float `triangleCross` cannot serve, its `.direction` being the first float tier that +forms a non-zero vector, so on exactly parallel normals it returns a rounding residue that reads as a +valid crease. That is the confusion §1.11.17 records as having nearly shipped. Its `null` is a THIRD +answer and is asserted separately from "no edge", in both the parallel and the anti-parallel case, +with a genuine pair proving the null is a real discrimination and not a function that always returns +null. + +The wall test asserts the tangential component against a closed form as well as the normal one at +zero, because cancelling both would pass the first assertion and be wrong. + +#### A defect of mine, found by the crease test and fixed by DELETING code + +The crease slide did not happen: `y` came out at the value reached BEFORE the second contact. The +cause was a check I had written after the crease projection — "is the motion still driving into +either plane" — which is **mathematically vacuous**: the crease axis is the CROSS PRODUCT of the two +normals, hence perpendicular to both by construction, so that dot product is exactly zero in +mathematics and float noise in practice. Measured: the box contact normals carry components a few +ULPs off their exact axis, one of them made the dot marginally negative, and a strict `< 0` on that +noise stopped a legitimate edge slide dead. + +Removed rather than given a tolerance. The corner case is already the third-plane branch, which is a +STRUCTURAL condition and not a numerical one — and a guard that can only fire on noise is not a +guard. Found by instrumenting the loop after two rounds of reasoning had failed to locate it, which +is the standing lesson: audit the wiring, not the result. + +#### My closed forms assumed axis-aligned motion, and the oblique case is the one that matters + +Two expectations were wrong on the first run: `1.68` where the answer is `1.6810263`. The stand-off is +subtracted along the DIRECTION OF TRAVEL, so its projection onto an axis is `padding · d_axis` and not +`padding` — for `d = (3,0,1)/√10` that is `0.02 · 0.9486833`. §1.11.4 bis makes an oblique case +mandatory precisely because an axis-aligned suite sees none of this, and my first expectation was the +axis-aligned answer. + +The tangential components, by contrast, come out EXACTLY at their asked-for value, and the algebra +says why: the padding cancels between the distance travelled before contact and the distance +remaining after it. So `z = 1` and `y = 1` are exact rather than approximate, and are asserted as +such. + +And the second wrong expectation was hidden behind the first by assertion short-circuiting — the +**fifth** instance in this milestone. + +#### D.5 — self-exclusion, now observable and observed + +At gate C, removing it broke nothing and I said so. Here it breaks **four** tests, including the +unobstructed move: without it the first sweep meets the character's own presence at distance zero, the +advance clamps to `max(0, 0 − padding) = 0`, and the character cannot move a millimetre. The +prediction gate C recorded is confirmed at the gate that was named for it. + +#### D.6 — and my freshness test did not catch a stale proxy + +**MEASURED: with the proxy update removed, all three of my freshness assertions still passed.** The +reason is structural and worth writing down, because it makes a whole class of freshness test +worthless: the broadphase box is only a CONSERVATIVE FILTER, and the exact answer comes from the +body's pose, which the same write path updates. A stale fat box that the ray still crosses therefore +yields the correct distance anyway — the query is right for the wrong reason. + +What a stale proxy actually loses is a candidate the tree no longer offers at all. So the added +assertion approaches the NEW position from a direction the OLD box does not intersect — from −Z at +x = 5, where the stale box sits around x = 0 with a 0.1 m fat margin — and without the update the +presence is never offered and the ray misses. The optional is checked before unwrapping so the +failure reads as an expectation rather than a panic. + +Sixth instance of the class. The pattern across all six is now unmistakable: **an assertion that +exercises a path does not thereby test the mechanism the path happens to use.** + +#### D.7 — the wake, and where its boundary is + +`moveCharacter` wakes the bodies it TOUCHED, accumulated during the move and woken afterwards because +the collectors hold a `*const BodyManager` — the ordering M1.1.11.1 was forced into for the same +reason. The capacity of that list is EXACT rather than a guess: one slot per iteration of each bounded +phase, so no phase can overflow it, and the overflow guard is an assert rather than a silent clamp +because a dropped wake is the defect W4 exists to prevent. + +W4 and not W3, and the test says why: a presence moved by pose write keeps velocity columns of exactly +zero while it crosses the scene, so W3's true-zero test never sees it move. Testable here without the +M1.1.15 tick loop, using `sleep.putToSleep` directly — the same transition step 11 applies — rather +than running thirty ticks, since the tick count is not what the test is about. + +**The boundary, stated rather than blurred:** what this entry owes is the bodies it touched. The wider +W4 — waking sleepers merely RETAINED in a candidate pair with the presence, without being touched this +call — belongs to the orchestrator that owns the retained set, at M1.1.15. Claiming the full W4 here +would be claiming a set this code cannot see. + +#### D.8 — `predictive_contact_distance` + +The move does NOT consume it: the sweep's distance is the remaining displacement and nothing else. Its +only consumer remains the ground probe's band. Asserted rather than asserted-about — two characters +differing only in that field stop at the same place against the same wall. + +#### Non-vacuity: six probes, one of which exposed a worthless test + +| Mechanism disabled | Result | +|---|---| +| Self-exclusion | **4 tests** fail, including the unobstructed move | +| The plane slide | wall and crease tests fail | +| The crease slide | crease test fails | +| Depenetration | the interpenetrated-start test fails | +| The wake | the sleeping-body test fails | +| The broadphase proxy update | **NOTHING failed** — test rewritten, then it fails | + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 452/452 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 452/452 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1624/1641 (17 skipped) ×2 | + +443 → **452**, the nine test blocks gate D adds. `zig fmt --check src/ bench/ tests/` clean; tree-wide +`zig build lint` exit 0; the wrapper reports `all corners green`. The `f64` leg was green on the first +run, as at gate C. + +One file touched outside the brief's list: **`src/foundation/math/math.zig`**, one re-export line for +`triangleCrossDirection`. Justified by the first-consumer rule and by the M1.1.11.1 precedent, which +added two re-exports there for exactly this reason (RD-7): `exact.zig` exposes it, `foundation.math` +did not, and the tiered float alternative is unusable for a crease. + +**Out of gate D**, per the brief: step height and descent (gate E); `resizeCharacter`, the push, +`setCharacterPosition` (gate F); `moveKinematic` (gate G). + +### Gate E — step height, and a guard of mine that had to be removed + +#### E.0 — the dead guard, removed with its proof + +`slideAlongCrease`'s `if (len_sq == 0) return null` after the exact tier's `orelse` is provably +unreachable, and the proof was VERIFIED in `exact.zig`'s implementation rather than taken from its +doc: `triangleCrossDirection` brings its three exact determinants onto one common power of two chosen +so the DOMINANT lane keeps a full mantissa, and for that lane `shift = bitLen − keep < bitLen`, so it +is never dropped by the below-scale skip and never rounds to zero. A non-null return therefore always +has a component in `[0.5, 1)` and `lengthSq()` lies in `[0.25, 3)`. Removed, with the argument written +where the guard was — a guard that cannot fire told the reader the exact tier may return a zero +vector, which that tier documents as impossible. + +The latent `.miss` / `.unrepresentable` distinction is left unbuilt, as instructed. + +#### E.1 / E.2 / E.3 — what was delivered + +The climb attempts lift, forward, land, once per call, and `padding` enters the geometry twice: the +lift needed is exactly `step_height` because the padding CANCELS between the two resting +configurations, and it reappears in each of the three sweeps as the stand-off. Both directions +asserted — 0.25 m climbed to a base of exactly `h + padding = 0.27`, 0.35 m blocked at `0.68` with the +height unchanged. + +Descent sticks to the floor under two conditions. `entered_grounded` is the one the gate specifies. +The second is mine and it is MEASURED: `engine-movement.md`'s default `jump_velocity` is 8 m/s, so a +jump's first tick rises `8/60 = 0.133 m`, well inside the 0.3 m `step_height`, and on that tick the +character is still grounded — so without a "did not ask to go up" guard, floor-sticking cancels every +jump on its first frame. + +#### E.4 — the reference's v5.6.0 note, verified on the source + +Fetched from `jrouwe/JoltPhysics` `Docs/ReleaseNotes.md` before being relied on: the line is in +**v5.6.0** under Bug Fixes, verbatim as quoted. My implementation reproduced the bug class, traced: +lift 0.3, forward 0.169, down-sweep finding the obstacle 0.0106 below so `drop` clamps to zero — the +character lands WEDGED against the obstacle's top edge at the lifted height, standing on nothing, and +the following slide rides it up that edge's tilted normal onto a step it was never allowed to climb. +Measured 0.37 where 0.02 is correct. Closed by requiring `drop > 0`. + +The assertion is a DIFFERENTIAL — the same scene at `step_height` 0 and 0.3 must stop at the same x — +because a bound on "no further than requested" would not catch it: 0.849 against a request of 1.5 +violates no such bound. + +#### A guard of mine that was WRONG, and the measurement that says so + +I also wrote a second acceptance condition — the landing must be HIGHER than the start — against the +other squeeze mode: rise, advance where the capsule is narrower, drop back. **It had to be removed, +and the reason is not that it was unexercised but that it BREAKS A LEGITIMATE CASE.** Measured on a +0.2 m kerb with flat ground either side: + +| | x reached | y reached | +|---|---|---| +| with the condition | 0.912 | **0.495** | +| without it | 2.000 | 0.020 | + +With it, the character fails to step over a kerb well inside its `step_height` AND ends half a metre +in the air, having ratcheted up the kerb's edge. Without it, the whole 2 m is served at the original +height. Stepping over a kerb onto LEVEL ground is indistinguishable from the squeeze mode by a height +comparison, and the legitimate case is the common one. + +So the squeeze-onto-level-ground mode is now **unguarded, and that is recorded rather than papered +over**: telling it from a legitimate step-over needs a test that the landed pose is CLEAR of the +obstacle that blocked it, which is a different mechanism from comparing heights. Named for whoever +ports the reference's stair-walking in full. The kerb case is now a test, and the reverse probe — +re-adding the condition — breaks it, which is how the removal is pinned rather than merely explained. + +#### Three of my tests asserted nothing, and all three were found by disabling the mechanism + +1. **The step's landing slope test.** My first scene put a 70° ramp ahead of the character and + asserted it did not end up standing on it. Removing the check broke nothing — because the height + gain came from the SLIDE, not the climb, and because at 70° the face rises 0.75 m over the 0.11 m a + lift affords, so the step can never land on it at all. Rebuilt at 50°, where the reach is 0.25 m + for a 0.30 m rise, and as a differential on `max_slope` over identical geometry. +2. **The step-down entry condition.** Both halves of my parameterised test shared one geometry, and + the airborne half started 2 m up — 0.4 m above the lower floor after its move, outside the 0.3 m + sweep, where nothing could be stuck whatever the condition said. Split into two scenes, the + airborne one now 0.2 m above a floor: outside the 0.12 m ground band so it reads `.in_air`, inside + the 0.3 m sweep so the condition is the only thing deciding. The entry state is now asserted in the + fixture rather than assumed. +3. **The climb condition**, above — unexercised, and then found to be wrong. + +**A REPORTED FINDING THAT IS NOT THIS GATE'S TO FIX.** The SLIDE lets a character gain height on a +slope steeper than its limit: pushed into a 50° ramp under a 45° limit it rises 0.583 m in one call, +and the same scene with the limit at 80° — which makes the ramp walkable and suppresses the climb +attempt entirely — gives 0.579 m. The verdict is correct throughout (`.on_steep_ground`, normal +`cos50 = 0.6427876`), so the engine is not lying; the POSITION climbs. This is E.2's "ratchet up a +steep ramp" concern arriving through the slide rather than the step, nothing in §1.12 settles whether +sliding should itself be slope-constrained, and the numbers are pinned in the suite so the next +milestone meets them. + +#### E.5 — what accumulates + +`max_touched` is re-accounted sweep by sweep: `max_depenetration_iterations` pushes, +`max_slide_iterations` slide sweeps, the THREE sweeps of the single step attempt, and the one +step-down sweep. Exact, not estimated, and the assert stays an assert. + +#### Non-vacuity: six probes, all biting + +| Mechanism disabled | Result | +|---|---| +| The climb attempt | the two-direction test AND the kerb test fail | +| The landing slope test | the 50° ramp differential fails | +| `drop > 0` | the two-direction test fails | +| `entered_grounded` | the descent test fails | +| Floor-sticking entirely | the descent test fails | +| **Re-adding** the removed climb condition | the kerb test fails | + +The last row is a REVERSE probe: the removal is pinned by showing that putting the condition back +breaks a case, which is the only way to hold a deletion in place. + +#### One transient, self-reported + +One `zig build test-forge-3d` run died with `signal KILL` and NO test named. The same binary run +directly reported `All 459 tests passed`, and the next build-runner invocation was green on the same +sources. Reported as a one-off with what was checked, not diagnosed: it named no test, it did not +reproduce, and inventing a cause for it would be worse than recording it. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 459/459 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 459/459 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1631/1648 (17 skipped) ×2 | + +452 → **459**, the seven test blocks gate E adds — one of them replacing the non-discriminating ramp +test rather than adding to it. `zig fmt --check src/ bench/ tests/` clean; tree-wide `zig build lint` +exit 0; the wrapper reports `all corners green`. + +**Out of gate E**, per the brief: `resizeCharacter`, the push, `setCharacterPosition` (gate F); +`moveKinematic` (gate G). + +### Gate F — resize, push, teleport, and two riders + +#### F.0 — rider 1: the slide is slope-constrained (§1.12.6) + +The rule as given: the projected motion's component on `up` is capped at the component the motion had +BEFORE projection, when the contact plane's normal fails the slope test. A cap, not a cancellation, +which is what makes the four cases right at once. + +Before the rule, a character climbed any face up to 90°−ε by walking into it — **0.583 m of rise in one +call** against a 50° face under a 45° limit, with the verdict correctly reporting `.on_steep_ground` +throughout: the engine was telling the truth while the pose climbed. + +**The rule's case 3 does not hold as stated for a steep but SLOPED face, and this was computed before +the test was written.** Against a 50° normal `(−0.766, 0.643, 0)`, a pure gravity step `(0, −1, 0)` +projects to an up component of `−0.587`, which is GREATER than the `−1` it came from — so the cap DOES +fire, and what it does is restore the requested rate. The prediction that a descent is "already below, +hence untouched" holds only for a VERTICAL face, where `after == before` exactly and the projection is +the identity on that axis. The rule was applied exactly as given; only the account of why case 3 comes +out right is corrected. The observable outcome is the one the rule intends. + +**The cap had to be extended to the CREASE, and that was measured, not anticipated.** §1.12.6 governs +the slide, and an edge between two planes of which at least one is unwalkable is as much a way to gain +height as a plane. Against a 50° ramp built as a rotated box the character contacts both its top face +and a side feature, and rose **0.0186 m per call** through `slideAlongCrease` while the single-plane cap +held its own path at exactly zero. Capping only the plane would have left the rule true of one branch +and false of the other. The probe table shows the two branches are not redundant: disabling the plane +cap gives a rise of 0.3367, disabling the crease cap gives 0.0386, and each alone fails the test. + +#### F.0bis — rider 2: the unguarded squeeze mode, MEASURED + +No clearance guard was built. What was measured is what happens without one. + +A capsule needing 1.8 m of headroom under a ceiling offering 1.0 m — and separately 1.7 m, so a small +deficit as well as a large one — is **PINNED**: the depenetration alternates between the two surfaces and +lands the capsule flush on the floor at base exactly 0 (the 0.02 stand-off spent), and the horizontal +request is consumed entirely by the four slide iterations against the ceiling's downward normal without +ever being served. Base `(0, 0, 0)` on call one and **bit-identical on call two**, so there is no +per-call bias to accumulate. A commanded DESCENT behaves the same: it does not sink. With enough +headroom the same scene serves the full 1 m per call, so the ceiling is inert once the capsule fits. + +**The failure direction is safe only because the iteration count is EVEN, and nothing said so.** +`max_depenetration_iterations` alternates between the two surfaces, so the side the character ends on is +the count's parity. Measured by changing the constant: at 3 and at 5 the base lands at **−0.800000** — +the full depth of the squeeze, on the far side of the ground plane — and at 4 and at 8 it lands at 0. At +all four values the ONLY test that moved was the one pinning the constants themselves, so an odd count +would have shipped silently. Pinned with a parity assertion carrying the measurement, which is a record +and not a guard: it builds no mechanism and turns an accident into something a change has to answer for. + +#### F.1 / F.2 / F.3 / F.4 / F.5 — what was delivered + +`resizeCharacter` is atomic and feet-anchored: the new capsule is built, the target volume tested with +the character's OWN presence excluded (it still carries the old capsule, which the new one overlaps by +construction), and only then is anything committed. A refusal changes NOTHING — position, radius, +height, presence `BodyId` and the live shape count all asserted unchanged, in both directions. The +presence's shape is swapped BEFORE the proxy sync so the broadphase box reflects the new SIZE and not +only the new centre; the probe table shows those are two distinct mechanisms, each with its own failing +test. + +The push is measured against a closed form and asserted UNILATERAL from the same scene run twice, +differing only in `max_push_force`: wanted impulse `70 · 6 = 420 N·s`, ceiling `100/60 = 1.667 N·s`, so +the unit-mass box leaves at exactly `100/60` m/s, while `max_push_force = 0` gives not a millimetre per +second. The character's stop is bit-identical in both runs, which is what separates a push from a +collision. + +`setCharacterPosition` teleports without resolving, may leave the character interpenetrated by contract, +and invalidates the reported verdict to `.in_air` — the safe direction, one tick of gravity rather than a +character believed to be standing where it no longer is. + +**One change beyond the gate, and its reason.** The teleport was a silent no-op on a dead handle, on the +`removeBody` precedent. That is the wrong precedent: a destroy is idempotent, so ignoring a dead handle +is its natural answer, whereas a teleport that goes nowhere is a write the caller has to know did not +happen — and it was the ONLY one of the store's five entries answering the same question a different +way. Now `error.StaleCharacter`, like the rest. The signature was already `!void`, so no call site moved. + +Freshness is written the same way on all three write paths, per the gate: each ray approaches the new +pose from a direction the OLD fat box does not intersect, because a stale box the ray still crosses +yields the correct distance anyway — the exact answer comes from the body's pose. The resize adds the +SIZE half: a ray above the old capsule's top and below the new one's finds the character only after the +grow. Both wakes are asserted on their success paths. + +#### The reference's second padding defect, verified rather than carried + +The v5.6.0 note — "Fixed `CharacterVirtual` sometimes not fully taking character padding into account +when moving through the environment" — was checked against the step path, which adds three sweeps and is +where the reference lost it. All three go through the single `paddedAdvance`, so the stand-off is +structural. But two of the three had NO test exercising their padding, which is exactly the defect's +shape, and the probe table settled which: + +- **LAND** — already pinned (a 0.25 m step gives base 0.27, not 0.25). Disabling breaks two tests. +- **FORWARD** — was unpinned. Now pinned at 1.93 in a scene where the climb lands on a plateau and the + forward sweep is bounded by a wall standing on it: `2.25 − 0.3 − 0.02`. +- **LIFT** — **unobservable, and confirmed by probe rather than left as an argument.** Dropping the + padding from the lift alone breaks nothing at either precision. The lift and the landing drop both + subtract it and `drop > 0` is required, so the two subtractions cancel exactly in the final pose + whatever the geometry. It is observable only as a change of VERDICT — a climb the raw lift clears and + the padded lift does not — which is a discrete boundary, not a stand-off. Recorded, not asserted + vacuously. + +**The FORWARD counterfactual was DERIVED as a lost stand-off and the measurement refuted that.** The +prediction was 1.95, flush against the wall. Measured: **0.688**. The unpadded advance leaves the capsule +exactly flush, so the landing sweep — which runs downward from there — reports the WALL at distance zero +instead of the plateau below, `drop` clamps to zero, and the whole climb is refused by condition 5; the +character then slides to a stop against the step's riser. The cost of losing the padding on that one +sweep is not 0.02 m of stand-off, it is **1.24 m of a legitimate move never served** — a better account +of why the reference shipped a fix for it than the one predicted. + +#### Probe table — one mechanism disabled at a time + +| Probe | Exit | What fails | +|---|---|---| +| `cap-at-before-plane` | 1 | gravity on a steep face | +| `cap-at-before-crease` | **0** | **nothing — undiscriminated, see below** | +| `no-slope-cap-plane` | 1 | four cases (rise 0.3367 where 0.02 is right) | +| `no-slope-cap-crease` | 1 | four cases (rise 0.0386 — a different path) | +| `no-depenetration-invariant` | 1 | the squeeze | +| `no-step-lift-padding` | **0** | **nothing — the cancellation, confirmed** | +| `no-step-forward-padding` | 1 | the FORWARD stand-off (0.688 where 1.93 is right) | +| `no-step-land-padding` | 1 | the step climb, and the kerb step-over | +| `no-resize-occupancy` | 1 | growing under a low ceiling | +| `no-resize-rollback` | 1 | the live shape count (NOT a leak — `deinit` frees live slots) | +| `no-resize-presence-shape` | 1 | freshness / the SIZE half | +| `no-resize-presence-sync` | 1 | atomicity, and freshness | +| `no-push` | 1 | the unilateral push | +| `no-teleport-verdict-invalidation` | 1 | the teleport | +| `teleport-stale-error` | 1 | the teleport — the no-op is pinned in both directions | +| `no-resize-wake` | 1 | both wakes | +| `no-teleport-wake` | 1 | both wakes | +| `reversed-fallible-order` | **0** | **nothing — see the ordering section** | + +Fifteen of eighteen mechanisms are load-bearing for at least one assertion. THREE are not, each measured +and each documented at the assertion that cannot make it, and the three are NOT of equal standing — +which is the point of listing them apart: + +- the step LIFT's padding is **provably** unobservable: it cancels against the landing drop in every + geometry, `drop > 0` being required; +- the fallible-step ORDERING rests on a structural argument because its triggering allocation cannot be + made to fail from a test; +- the crease's `max(before, 0)` bound is **merely undiscriminated** — five scenes were built for it and + none separated the two bounds. That is the weakest of the three and is written as such where it lives. + +#### Two tooling notes + +The probe script's failing-test extraction returned EMPTY for `no-step-forward-padding` while the exit +code was 1: the test name contains an apostrophe, which closed my `[^']*` capture early. The exit code +was the authority and the log carried the number; the name extraction is a convenience and was read as +one. It recurred once more on the rebuilt table before the class was fixed — the character class was +replaced by a non-greedy `.+?` anchored on the literal `' failed:`, which cannot stop early — and the +same rebuild produced four probes reporting exit 1 with no test named at all, which were UNUSED-VARIABLE +COMPILE ERRORS and not findings. The reporter now distinguishes the three outcomes explicitly (a named +failure, a compile error, nothing fails), because a probe that does not build proves nothing and read +identically to one that does. + +`$?` read through a pipe reported `SCRIPT_EXIT=0` while the wrapper's own last line said +`MATRIX: 2 corner(s) RED`. Same class as the standing note, third occurrence in this milestone: the +wrapper's printed verdict is what to read, and the final matrix was run without a pipe. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 470/470 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 470/470 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1642/1659 (17 skipped) ×2 | + +459 → **470**, the eleven test blocks gate F adds. `zig fmt --check src/ bench/ tests/` clean; tree-wide +`zig build lint` exit 0; the wrapper reports `all corners green`. + +**Out of gate F**, per the brief: `moveKinematic`, the bench and the closure artifacts (gate G). + +### Gate F — three corrections after the verdict + +#### 1 — the cap's bound was `before` and had to be `max(before, 0)` + +Verified before applying, on the ramp Guy's own four-cases test uses. Normal `(−0.766, 0.643, 0)`, +motion `(0, −1, 0)`: `slid = (−0.4925, −0.5866, 0)`, `after = −0.5866 > before = −1`, so the cap bit and +the output `(−0.4925, −1, 0)` has a dot of `−0.2657` with the normal — it drives INTO the plane. And the +projection was the physically right answer: a body sliding down a 50° slope descends more slowly than in +free fall because the surface carries part of the motion, and capping at `before` annulled exactly that. + +**The measured symptom is worse than the account that produced it.** At CONTACT the old bound does not +merely restore the requested rate — it removes the motion entirely: on a 50° face under pure gravity, +`dy = 0.00000`, the character frozen on the cliff. The full measurement, at four clearances: + +| surface clearance | `dy` with `max(before,0)` | `dy` with `before` | +|---|---|---| +| 0.00 (touching) | −0.17605 | **0.00000** | +| 0.02 | −0.18064 | −0.06012 | +| 0.05 | −0.19992 | −0.09615 | +| 0.15 | −0.26420 | −0.24382 | + +And at exact contact the correct bound reproduces the closed form to five digits: `dy = −d·sin²50°` and +`dx = −d·sin50°·cos50°`, measured `−0.17605` and `−0.14772` against `−0.176046` and `−0.147748`. + +**The missing test is added, and the FIRST derivation of its closed form was wrong.** The scene is a 60° +face, so the trigonometry closes — `cos = 1/2`, `sin = √3/2`, hence `sin² = 3/4` and `sin·cos = √3/4` — +with the capsule's surface starting `c0 = 0.12` m clear and a pure downward request of `0.5` m. The +padding comes off the TRAVEL, not off the normal, which the first derivation got wrong: contact is at +`c0/cos = 0.24`, the padded advance is `0.24 − 0.02 = 0.22`, the remaining `0.28` projects, and +`dy = −0.22 − 0.28·(3/4) = −0.43` with `dx = −0.28·(√3/4) = −0.1212436`. Both match the measurement to +six digits — and it was the SAME class of error corrected once already at gate D, where the padding's +axis projection was mis-derived the same way. + +The same correction went into `slideAlongCrease`, and on that branch it is UNDISCRIMINATED: five scenes +were built for it and none separated the two bounds. Written as such where it lives, and listed apart +from the two exceptions that carry proofs. + +One consequence stated so nobody takes the wrong property from the cap: it is NOT a guarantee that the +output never points into the plane. When the caller asks to RISE the cap preserves that rise by +construction, and the result can still have a component into the surface, which the sweep and the +depenetration answer. The guarantee is that the ENGINE does not climb on the character's behalf. + +#### 2 — the parity became an invariant, and the corridor prediction is refuted + +The invariant now carried by `depenetrate`: + +> The depenetration NEVER moves the character to the far side of a contact plane it was on the good +> side of at the ENTRY of the call. It pushes OUT, never THROUGH. + +Enforced by reverting to the entry pose the moment a contact is found whose plane the BASE has crossed +since entry. The base and not the centre, measured: the centre of a 1.8 m capsule is still 0.10 m above +a plane its feet have passed 0.80 m below, so a centre-based test does not fire at all. The manifold +point is the MIDPOINT of the two surface points, so the body's surface is half the penetration further +along the normal — that half-penetration term is what makes the side test exact. Both comparisons at +TRUE ZERO: the question is which side of a plane a point is on, not how far. + +Failure direction, now sayable: in an unresolvable squeeze the character keeps the pose it came in with, +with a residual overlap, and does not tunnel. **Verified parity-free by measurement** — the squeeze test +passes at iteration counts of 3, 4, 5 and 8, where the only tests that move at those values are the ones +pinning the constants themselves. The parity assertion stays as the RECORD of the original measurement +and carries no correction. + +**The narrow corridor is NOT a second instance, and that was measured rather than taken.** At widths of +0.40, 0.50 and 0.58 m against a 0.60 m capsule, and at counts of 3, 4 and 5, the character oscillates +between exactly `±(radius − width/2)` and NEVER leaves the doorway — 0.1, 0.05, 0.01 for the three +widths, closed form, identical at every count because the alternation is per CALL and not per iteration. +The two walls are symmetric about the entry pose so the alternation stays bounded; the ceiling tunnels +because the floor is NOT a contact at entry, which makes the first push large and unopposed. The +doorway test is added anyway, as the gate asked: it is what a game actually walks into, and it is what a +future change to `depenetrate` has to keep true. A detail it also pins: the +Z request is never served +either, the two wall normals being ANTIPARALLEL so their crease is exactly parallel and +`slideAlongCrease` returns its documented third answer. + +#### 3 — `setCharacterPosition` returns to a no-op, on the right discriminant + +The discriminant is whether the entry RETURNS A VALUE, not writing versus destroying: +`createCharacter`, `moveCharacter`, `resizeCharacter` and `getCharacterInnerBody` return something, so a +dead handle has no honest answer and they carry a channel; `destroyCharacter` and this entry return +nothing, so the no-op IS a coherent answer. My argument — a write the caller must know did not happen — +proves too much: it holds identically for `setBodyTransform`, `setLinearVelocity` and +`setAngularVelocity`, all `void` in the frozen surface, so carried to its end it makes every setter +fallible, which is a decision about the whole Tier 0 surface. Recorded for M1.1.15, where the interface +layer is built and the question covers all of it at once. The no-op is now pinned in BOTH directions — +re-adding the error channel breaks the teleport test. + +The residual `!` on the signature is disjoint from that question and is NOT a semantic refusal: it is +`Broadphase.update`'s reservation. Making the entry truly `void` needs a reservation seam in the +broadphase, which this milestone does not own; the gap is in the frozen surface and is recorded on the +entry alongside the setter-fallibility convention. + +#### Validation after the corrections + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 472/472 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 472/472 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1644/1661 (17 skipped) ×2 | + +470 → **472**: the gravity-on-a-steep-face test and the narrow-doorway test. `zig fmt --check` clean, +tree-wide `zig build lint` exit 0, the wrapper reports `all corners green`. + +### Gate G — closure + +#### G.1 — `moveKinematic`: nothing to do, and that is the answer + +The brief asked for a typed stub. There is nothing to stub: `src/interfaces/` does not exist, +`PhysicsModule` has no file in the repository, and it arrives at M1.1.15 — the same situation as +`setBodyTransform` at gate A. The signature is frozen in `engine-tier-interfaces.md` 0.8, which IS +where the freeze lives. A solver-level `moveKinematic` with no consumer, added in a closure gate, +would be exactly the scope creep the gate structure exists to prevent. Recorded, not implemented. + +#### G.2 — closure measurements + +**The eleven inherited envelope quantities**, re-measured against `main` at `a4354df` through a git +worktree, the SAME probe file compiled in both trees, at both precisions. **ZERO movement, digit for +digit, on all eleven, in both legs:** + +| # | quantity | f32 | f64 | +|---|---|---|---| +| E01 | free-flight `y` after 60 ticks | −4.901912 | −4.901912601958768 | +| E02 | free-flight `v_y` | −9.564696 | −9.564699949538094 | +| E03 | resting box centre `y` | 0.99499947 | 0.9949999999999991 | +| E04 | resting penetration | 0.0050005913 | 0.005000000000000893 | +| E05 | velocity iterations run at rest | 2 | 3 | +| E06 | position iterations run at rest | 3 | 3 | +| E07 | NGS `min_separation` | −0.0050005913 | −0.005000000000000893 | +| E08 | resting lateral creep over the second half | 0 | 0 | +| E09 | far-field hit distance at 10 km | 9999 | 9999 | +| E10 | far-field normal norm error | 0 | 0 | +| E11 | symmetric-tie distance / entity | 19.133974 / 3 | 19.133974596215563 / 3 | + +Nine of the eleven reproduce M1.1.11's recorded table to the digit. Two do not, and the reason is +stated rather than smoothed: the probe is this milestone's, not M1.1.11's, so E04 and E08 measure the +same physical quantity through a scene that differs in detail — E04 at a different tick and E08 over +a different window. That does not weaken the comparison the protocol actually asks for, which is +branch against `main` on ONE probe, and it would be dishonest to present a re-derived scene as a +reproduction of theirs. + +Corroborating structural evidence, stronger here than at M1.1.11: **all seventeen inherited forge +test files are BYTE-IDENTICAL to the tag**, `solver_test.zig` included — this milestone did not touch +the harness at all, where M1.1.11 did. + +**The NGS energy-injection watch, which `CLAUDE.md` names this milestone to re-read.** Answered by +measurement, and the answer is arithmetic rather than NGS: the frictionless undamped slider retains +`5.0000005` m/s at f32 — and `nextafter(5)` at f32 is `5.000000476837158`, so the gain is **exactly +one ULP** — while at f64 it retains exactly `5`, a gain of zero. A solver that adds energy adds it at +both precisions; one that rounds does not. Identical digit for digit between this branch and `main` +in both legs, so this milestone's neighbouring manifolds moved nothing. Recorded in `CLAUDE.md` in +place of the pending instruction, and still worth re-reading at M1.1.14 where an ULP is the unit of +the question. + +#### G.3 — bench, reported and not gated + +Five rows, one per path, INTERLEAVED across eight reps. ReleaseFast, 2 000 calls per mode per rep: + +| mode | ns/call | calls per 16.67 ms frame | +|---|---|---| +| `moveCharacter` / plane | 212.0 | 78 616 | +| `moveCharacter` / stairs | 2 235.5 | 7 455 | +| `moveCharacter` / wall | 1 764.5 | 9 446 | +| `moveCharacter` / mesh floor | 7 979.0 | 2 089 | +| `resizeCharacter` | 203.0 | 82 102 | + +No envelope registered: no baseline for this path has ever been measured, and registering a bound +before measuring it is the failure mode recorded at M1.1.8. + +**The resize row's accepted/refused counters caught two defects of my own, in the bench.** On a bare +half-space the occupancy query traverses nothing and the row read 40.5 ns — a figure whose NAME +promised what a resize costs and whose VALUE was what it costs against an empty tree. Sharing the +stairs' scenery then made all 18 000 resizes REFUSED, because the first riser overlaps the capsule, +so the row timed a refusal under the name of a success. Neither was found by re-reading the code; +both were found because the row counts what it accepted. Same class as the six apparatus findings of +the earlier gates, and the tenth of this milestone. + +Leak check proven in BOTH directions: with `safety` forced true a deliberate 4 KiB leak printed +`memory address … leaked`; with the default, the SAME leak printed nothing. + +#### G.4 — the `CLAUDE.md` patch + +All five items of `engine-development-workflow.md` §3.4. The `Current state` block was two milestones +stale and is replaced integrally; the tag row is added; four open decisions are added and none +removed; the footer is `2026-08-05`. + +The fifth item, **Hypotheses validated by spikes**, is CONDITIONAL and was checked rather than +skipped: the table holds S0 through S6, all of them Phase −1 and Phase 0 spikes, and this milestone +validates no spike hypothesis. Nothing to add, and that is the finding. + +**One edit beyond the four entries listed.** The NGS-watch entry ended with "Re-read the figure at +M1.1.12", an instruction this milestone has now carried out; leaving it would have left a pending +instruction standing next to its own answer, which is the "corrected text added without deleting what +it replaces" motif this repository has already swept once. The entry keeps its subject and its +M1.1.14 half; only the M1.1.12 clause becomes the measurement. + +#### Consignations, each with its owner + +| # | Consignation | Owner | +|---|---|---| +| 1 | Tooling facts have no owner — `engine-development-workflow.md` carries no such section, so they propagate by manual recopy | the workflow document; NOT a physics milestone | +| 2 | Frozen pose setters are `void` while pose writes are becoming allocation-fallible | M1.1.15, which IS the freeze | +| 3 | Whether setters should be fallible at all — spans the whole Tier 0 surface | M1.1.15, the interface tier | +| 4 | `engine-physics-forge.md` four-way decomposition, 220 KB with §1.11 an accumulator | between this closure and M1.1.13's opening, never inside a milestone | +| 5 | The one unguarded step mode (squeeze onto level ground) — failure direction measured and bounded instead of guarded | whoever ports the reference's stair walking in full | +| 6 | The crease branch of the slope cap, NOT DISTINGUISHED by any of five scenes — labelled undistinguished, not proven inert | open; a sixth scene that separates it, or a proof that none can | +| 7 | The latent `bp.update` use-after-free hazard: ordering fixed, the triggering allocation not makeable to fail from a test | M1.1.15, with consignation 2 | +| 8 | One transient `signal KILL` naming no test, not reproducible, the same binary run directly reporting all tests passed | none; reported, not diagnosed | + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 472/472 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 472/472 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1644/1661 (17 skipped) ×2 | +| `bench-forge-character`, ReleaseFast | 0 | five rows, leak check proven both ways | + +No behaviour line was added at gate G. `zig fmt --check src/ bench/ tests/` clean; tree-wide +`zig build lint` exit 0. + +### Gate G closing round — six findings from external review, and a seventh found while probing + +Four of the six are defects in code that six gates of internal review had passed. Two are omissions in +the brief's own instructions. All six were verified against source before being acted on, and all six +are confirmed — symptom and diagnosis. + +#### P1-1 — the push survived a publication that can still fail + +`pushBody` mutated and woke inside the slide loop, and `syncPresenceTo` can still fail after it. A +failure returns an error with the record deliberately intact, exactly as its comment promises — and +with bodies already pushed, so the caller's retry pushed a second time. + +The pushes are now ACCUMULATED and applied after `syncPresenceTo`, beside the `wakeBody` loop, which +is post-publication for this exact reason. `PendingPushes` bounds itself by `max_touched`: a push can +only target a body the move touched, and only the dynamic ones that yield, so the set is a subset of +one already bounded slot for slot — exact capacity, asserted, no clamp, no new fallible step. + +One consequence of deferring is written at the planner rather than left to be discovered: two slide +iterations hitting the SAME body now both plan against the velocity it had at the start of the call. +The per-entry ceiling is unchanged, so the worst case is the same `n · max_push_force · dt`; only the +actual sum can come out slightly larger. A planner cannot read a velocity that has not been written. + +#### P1-2 — the union fattened the proxy permanently, and guarded nothing + +Confirmed on both halves, and the second half is one neither the review nor I had checked. + +`Bvh.update` returns `false` WITHOUT refitting as soon as the stored fat box already contains the new +tight one. So after a teleport the leaf covered the whole trajectory and no later call ever shrank it — +permanent false positives in queries and in pair generation along the path, from a form whose own +comment claimed "the next call refits it". + +And the union's MOTIVE falls too: `Broadphase.update` RESERVES before mutating — +`try self.moved[li].ensureUnusedCapacity(gpa, 1)` precedes `self.trees[li].update(...)`, which is the +refit. On a failed reservation neither the node's box nor the moved log has moved, so that entry is +ALREADY atomic and already satisfies the reserve-then-mutate invariant it was being wrapped in. + +`new_box` alone is published. The doc paragraph justifying the union is DELETED, not amended; what +replaces it keeps the two real consequences that were measured — the freed shape under the record in +`resizeCharacter`, the lost pairs on the two pose paths — and attributes the atomicity to +`Broadphase.update`, which carries it. The containment short-circuit goes back to being what it is: the +fat margin's normal regime. + +#### P1-3 — the proxy outlived the character + +`destroyCharacter` released the body, the shape and the slot, and never `presence_proxy`. Its own +comment counted "all three of its resources" where there are FOUR — a count the code contradicted. +`Broadphase.remove` is infallible, so the entry stays `void` and gains a `*Broadphase` (RD-4). + +The test asserts REACHABILITY and not a node count, which is the point: a leaked leaf keeps its last +box and every query over that region keeps visiting it, against a freed body handle whose slot will be +recycled. A count of nodes would not have said the leaf was still reachable. The generation-wrap +concern the review raised is real but 256 recycles away, and is deliberately left out of the argument. + +#### P2-1 — `ground_velocity` was read at the penetration midpoint + +The manifold point is the MIDPOINT of the two surface points, and `ground_velocity` is read at the +point the character stands on. For a capsule sunk `p` into a floor the midpoint is at `−p/2` while the +body's surface is at `0`, so the lever arm was short by half the penetration. Corrected by +`deepest.position + outward · penetration/2`, the same reconstruction `prepare` performs on the same +field. + +And the second half of that finding counts as much: the existing rotating-platform test has the +character separated by `padding`, so it goes through the SWEEP and never reaches this point. The new +case puts the character in FRANK interpenetration, and its platform spins about **+Z** — about +Y the +point's height cancels out of the cross product entirely and the fix would be unobservable. + +#### P2-2 — `step_height` was never validated + +Stored at solver precision, consumed as a sweep distance by `tryStepUp`'s lift and `stepDown`'s probe, +and the one stored physical parameter with no guard. It was absent from the brief's original +enumeration, absent from the four guards added at gate F, and I did not notice it while re-reading my +own thirteen. Now finite and `>= 0`, zero being the disabler the suite already exercises. + +#### P2-3 — `setShape` promised more than it kept + +Restricted to a convex on both sides (RD-5). Deferral applied in the right direction: the only need is +capsule to capsule, a relaxation is purely additive, and the restriction makes both inconsistencies +unreachable rather than handled. + +#### A seventh finding, found while building the P1-1 probe + +**A base of EXACTLY zero serves no horizontal motion at all.** The capsule is exactly tangent to +`{ y <= 0 }`, so the horizontal sweep reports an initial contact at distance zero on the face-inclusive +convention, the padded advance clamps to zero, and the plane's `+Y` normal opposes nothing — the slide +returns the motion unchanged because `into >= 0`. The next iteration finds the same contact. All four +iterations are consumed with no progress and the remainder is DROPPED. + +MEASURED at six starting heights: **only exactly zero fails**; 0.005, 0.01, 0.019, 0.02 and 0.05 all +serve the whole metre. So the trigger is exact tangency, not "below `padding`" — which is what a guess +would have said. Not reachable from ordinary play, since a move leaves the character resting at +`padding` above its floor; reachable from AUTHORING, "put the character on the ground" being a natural +thing to write. PINNED at its measured behaviour and NOT fixed: gate G adds no behaviour line. + +#### Probe table — five mechanisms, one disabled at a time + +| Probe | Exit | Failing tests | Compile errors | +|---|---|---|---| +| `push-in-loop` | 1 | 1 | 0 | +| `publish-union` | 1 | 1 | 0 | +| `leak-proxy` | 1 | 1 | 0 | +| `midpoint-velocity` | 1 | 1 | 0 | +| `no-step-height-guard` | 1 | 1 | 0 | + +Two of these probes were WRONG on their first run and both errors are the ones this milestone has been +collecting. `leak-proxy` deleted the only use of `bp` and reported exit 1 on a COMPILE error — a probe +that does not compile proves nothing while reading exactly like one that does, which is why the table +now carries a compile-error column. And `push-in-loop` reported exit 0, no test failing: the test it +was meant to break used the default 70 kg mass against a `100/60` ceiling that binds on every +iteration, so both forms answer `2 · 100/60` and the assertion could not tell them apart. Rebuilt with +unit masses and a slack ceiling, where the two differ by a factor of two. + +`setShape`'s restriction has NO probe and that is stated rather than padded: it is a debug assert whose +only caller is `resizeCharacter`, capsule to capsule by construction, so no test in the suite can fire +it. It is a tripwire for a future caller, not a tested behaviour. + +#### What could NOT be proven, and how far the attempt went + +The injected-failure probe the gate asked for on the push path is not constructible. `syncPresenceTo`'s +only fallible call is `Broadphase.update`, whose reservation grows an `ArrayListUnmanaged`; a list +grows through `remap` first and falls back to `alloc` only when that returns null, so the injection +needs BOTH `fail_index` and `resize_fail_index` — which is what gate F was missing when it recorded +this failure as un-injectable. With both, the growth is reachable: MEASURED, it lands on the 34th +append of a pure teleport sequence. + +But an append happens only when a proxy leaves its fat box, and **a move that pushes is a move that +barely advances**, so it never grows the log. FIVE constructions were measured — the box adjacent, the +box eight metres away, a near-tangential slide along a long crate, and two warm-up sweeps of up to +sixty rounds — and none landed the growth on a call that also pushed. The parity is the reason: with +two appends per round the boundaries at 34, 66 and 130 are all even and the move always falls on an +odd index. + +So the invariant is pinned on its reachable half — a failed publication changes no velocity anywhere, +asserted with non-vacuity — and the ORDERING that protects the push rests on the structural argument +in `PendingPushes`. Same label as gate F used, and for the same measured reason. + +One more thing that could not be pinned as intended: the accumulator's behavioural signature depends +on hitting the same body twice, and that count is PRECISION-DEPENDENT — two contacts at f32, one at +f64. A head-on wall's slide cancels the whole motion, so the second contact exists only because an f32 +residue leaves a sliver of `remaining` behind. It is float noise, not a designed path, and asserting +`20` at both precisions was pinning noise — which is what made the f64 matrix legs red on the first +attempt. The expectation is per-precision and the invariant is stated as the multiple. + +#### The closure artifacts are HELD + +`CLAUDE.md` is restored to its state at `a4354df` and the brief's `Status` is back to `ACTIVE`: both +wait until these six are pushed and verified. The corrected text for the Tags row is recorded here so +it is not lost between now and then — the sentence about `syncPresence` publishing the union is +replaced by the account of its two errors, and the apparatus-findings list gains the five defects of +this round. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 479/479 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 479/479 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1651/1668 (17 skipped) ×2 | + +472 → **479**, the seven test blocks this round adds. `zig fmt --check src/ bench/ tests/` clean; +tree-wide `zig build lint` exit 0; the wrapper reports `all corners green`. + +### Gate G, second closing round + +#### The push ceiling was doubled by the deferral, measured and not closed by the first round + +`PendingPushes.add` appended without coalescing and `apply` called `addImpulse` once per entry, each +entry capped at `max_push_force · dt`. So two contacts on one body in one call delivered TWICE the +declared ceiling, three would have delivered three times, and nothing bounded the factor. The first +round WROTE that consequence at the planner — "the per-entry ceiling is unchanged" — which was exact +and insufficient: that sentence IS the defect. `max_push_force` is documented in newtons, and a +ceiling one can exceed by being touched twice is not a ceiling. + +COALESCED BY BODY: the impulses destined for one `BodyId` are summed, the SUM is capped once, and one +impulse per body is applied. Three things close together — the ceiling means what it says, the +atomicity is untouched since nothing leaves before the publication, and the answer stops depending on +how many contacts the sweep happened to make against one body, which is an iteration detail and not a +physical quantity. + +The scan in `add` is linear over at most `max_touched` entries, which is twelve; a hash container on a +path M1.1.14 must make reproducible would be the wrong trade even at scale. + +**The new test is the stronger one, and its number is precision-INDEPENDENT where the old one's is +not.** Under a binding ceiling of `5 · 1` the box leaves at exactly 5 m/s at BOTH precisions, where +the slack-ceiling test reads 20 at f32 and 10 at f64 — because under a binding ceiling the contact +count no longer reaches the answer. That independence is the second half of what coalescing bought, +and it is asserted rather than described. + +#### The seventh finding — mechanism CONFIRMED by trace, and the fix STOPPED at the gate's own rule + +Traced, and it is exactly as read: + +``` +it=0 centre=(0.000000,0.900000) rem=(1.000000,0.000000) hit_dist=0.000000 + normal=(0.000000,1.000000) centre and rem UNCHANGED planes=1 +it=1 … identical … it=2 … identical … it=3 … identical +final x=0.000000 +``` + +`hit_dist` is exactly zero every iteration, the resolved normal is the surface's `+Y` (the sweep's own +normal at distance zero being `−direction` and unusable), `paddedAdvance` returns +`max(0, 0 − padding) = 0`, and the slide returns a horizontal motion projected on a horizontal plane +unchanged. Neither `centre` nor `remaining` moves. Four iterations, then the remainder is dropped. + +**The observable formulation is right and its safe implementation is NOT a few lines, so this stops +here per the gate's own instruction.** Not counting the iteration alone is an infinite loop — the state +is bit-identical each round — so the fix has to make progress, and the only progress available without +a new mechanism is to serve the remainder. **MEASURED that this would tunnel**: with a wall whose −X +face stands at 0.8, the trace shows the sweep still reports ONLY the plane at distance zero, because it +returns the NEAREST hit and nothing is nearer than zero. Serving the remainder would carry the +character from 0 to 1, through that wall. + +So a safe fix needs the sweep to be able to report the SECOND-nearest hit — a per-call exclusion of the +non-obstructing body, threaded through `sweepNearest`. That is a signature change on the module's +hottest helper plus its five call sites (the slide sweep, the step's lift, forward and land, and the +step-down probe), and the loop's own bookkeeping to carry the set. Reported rather than widened. + +The seven-height pin stays as delivered and it already carries both directions: `0` at its measured +behaviour, and `0.005` through `0.05` at theirs. When the fix lands, the first expectation flips to a +served metre and the other five must not move — which is the "the guard does not over-fire" half whose +absence let the gate-F slope bound through. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 480/480 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 480/480 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1652/1669 (17 skipped) ×2 | + +479 → **480**, the force-ceiling block. Closure artifacts still HELD. + +### Closure — the artifacts, and what this milestone ships open + +#### The mitigation, which is not a fix + +`CharacterDescriptor.position` carries a documented PRECONDITION: a base placed exactly tangent to a +surface is a degenerate input, the controller's resting pose being `padding` above its floor. One +paragraph, and the trap stops being silent — which is the only half of the problem a closure gate can +close honestly. + +#### `CLAUDE.md`, the five items of `engine-development-workflow.md` §3.4 + +1. **`Current state`** — replaced integrally; it was two milestones stale, still announcing M1.1.11 + code-complete and `v0.11.10` as the last tag. +2. **The tag row** — added, and it SAYS that the milestone ships a known unfixed functional defect. A + tag row describing a milestone without one would be false, and this repository pays that kind of + falsehood two milestones later. +3. **Open decisions** — FIVE added, none removed: tooling facts having no owner, the frozen `void` pose + setters against allocation-fallible pose writes, whether setters should be fallible at all, the + exact-tangency defect with its owner and its two candidate and two refuted forms, and the + `engine-physics-forge.md` decomposition. +4. **Hypotheses validated by spikes** — the CONDITIONAL item, checked rather than skipped: the table + holds S0 through S6, all of them Phase −1 and Phase 0 spikes, and this milestone validates no spike + hypothesis. Nothing to add, and that is the finding. +5. **Footer** — `Last updated: 2026-08-05`. + +The NGS-watch entry's M1.1.12 clause becomes the measurement it asked for; its M1.1.14 half stands. + +#### Consignations, each with its owner + +| # | Consignation | Owner | +|---|---|---| +| 1 | Tooling facts have no owner — `engine-development-workflow.md` carries no such section, so they propagate by manual recopy | the workflow document; NOT a physics milestone | +| 2 | Frozen pose setters are `void` while pose writes are becoming allocation-fallible | M1.1.15, which IS the freeze | +| 3 | Whether setters should be fallible at all — spans the whole Tier 0 surface | M1.1.15, the interface tier | +| 4 | ~~A character based exactly tangent to a surface never moves~~ — **CLOSED in the third correction round**, not deferred | closed here | +| 5 | `engine-physics-forge.md` four-way decomposition, 220 KB with §1.11 an accumulator | between this closure and M1.1.13's opening, never inside a milestone | +| 6 | The one unguarded step mode (squeeze onto level ground) — failure direction measured and bounded instead of guarded | whoever ports the reference's stair walking in full | +| 7 | The crease branch of the slope cap, NOT DISTINGUISHED by any of five scenes — labelled undistinguished, not proven inert | open; a sixth scene that separates it, or a proof that none can | +| 8 | The latent `bp.update` use-after-free hazard: ordering fixed, the triggering allocation not makeable to fail from a test | M1.1.15, with consignation 2 | +| 9 | One transient `signal KILL` naming no test, not reproducible, the same binary run directly reporting all tests passed | none; reported, not diagnosed | + +#### Consignation 4 was going to ship open, and that decision was WRONG — superseded + +The three reasons written here for deferring it are left in the record below, struck, because the +reasoning is the lesson. All three rested on "not reachable from play, only authoring reaches it", and +that argument **ignored the public default of the field**: `position: Vec3 = Vec3.zero` over a floor at +`y = 0` IS the failing configuration. A precondition the field's own default violates is a bug with an +apology attached, and the mitigation instruction was withdrawn with the deferral. + +~~It is not reachable from play. It is pinned at seven heights with both directions. The deferral forces +no refactoring.~~ The second is still true and is why the fix was cheap to land. The first is false. The +third was true of the fix that was costed and irrelevant to the one that shipped. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 480/480 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 480/480 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1652/1669 (17 skipped) ×2 | +| `bench-forge-character`, ReleaseFast | 0 | five rows interleaved, leak check proven both ways | + +`zig fmt --check src/ bench/ tests/` clean; tree-wide `zig build lint` exit 0; the wrapper reports +`all corners green`. Zero French in code or brief prose, audited in Python. + +### Gate G, third closing round — the tangency defect CLOSED + +#### The push ceiling's stale comment (P3) + +`plannedPush`'s doc still stated a worst case of `n · max_push_force · dt` and a sum that could come out +"slightly larger". Coalescing made that false. Deleted and replaced by what `PendingPushes.apply` does: +one impulse per body, the magnitude of the SUM capped once. Classified P3 by the review, and rightly on +priority — but a comment describing an earlier version of the code is the exact class this milestone has +spent its length sweeping. + +#### The deferral was wrong, and the reason is worth more than the fix + +The decision to ship the tangency defect open rested on "not reachable from play, only authoring reaches +it". That argument **ignored the public DEFAULT of the field it was reasoning about**: +`api/types.zig` — `position: Vec3 = Vec3.zero`. Over a floor at `y = 0` the default IS the failing +configuration, in a descriptor the same reasoning had authored. One `grep` would have shown it. + +And the spec already forbade the state: §1.12.6 says the `padding` keeps the capsule clear of surfaces. +That is an obligation of the controller, not a description of a normal regime, so failing to establish +it is a spec violation and not a degenerate input. `setCharacterPosition` reopens the state at runtime +from an authored pose, which removes the last leg. + +#### The trace made it worse than the deferral assumed, and narrower to fix + +Two things came out of the trace that no argument had produced. + +**`depenetrate` was MANUFACTURING the frozen state.** A character starting 0.05 m inside the floor was +resolved to a base of exactly `0.000000000` — tangency, the failing configuration — and then froze. So +the reachability was never authoring alone: any interpenetration at all, from a spawn, a teleport, a +resize or a platform pushing the character in, ended frozen. The defect was one push away from every +overlapping start, not one authoring mistake away. + +**The exclusion through `sweepNearest` was unnecessary**, and the previous round's cost estimate was +built on an unverified assumption — that the manifold path cannot see a tangent contact. It sees it +perfectly well: `gjk.zig` classifies exact tangency `.shallow`, not `.separated`, and +`collideOrderedGeneric` returns null only on `.separated`. TRACED: at base 0 the overlap query reports +the plane with `pen = −0.000000000` and normal `+Y`, four rounds running. So the fix lives entirely in +`depenetrate` — no exclusion, no `sweepNearest`, no `paddedAdvance`, no set carried through the loop. + +#### What shipped, and why it cannot over-fire + +`depenetrate` resolves to `padding` of CLEARANCE instead of to touching: one added parameter, one term +in one expression. The branch is bounded **by construction and not by a threshold** — a manifold exists +only where the separation is at most the contact margin, so a capsule already standing off is +`.separated` and invisible to the query. TRACED at four heights: 0.005 and 0.02 produce no contact at +all, so nothing lifts them. + +#### Three green expectations moved, and each was re-derived rather than bumped + +- **the interpenetration test** — the push is now `overlap + padding` along the slope normal, and + floor-sticking then re-seats the character, which the old expectation did not have to account for + **because the old behaviour was the defect**: with the base left exactly tangent, `stepDown`'s + down-sweep found the surface at distance zero and its padded advance clamped to zero. The old clean + `Δy = 0.0173205` was itself a consequence of the freeze. New closed form + `0.04 · cos 30° − (padding / cos 30° − padding) = 0.0315470`, and said so at the assertion, because a + reader comparing the two would otherwise read it as a regression. +- **the doorway** — the offset moves by exactly `padding`, and the assertion that MATTERS is unaffected: + `0.12 < 0.20`, `0.07 < 0.25`, `0.03 < 0.29`, still inside at all three widths. Checked before the + number was touched. +- **the tangency pin** — flipped, and the comment describing it as pinned-not-fixed is DELETED rather + than amended, since it described a state that no longer exists. + +The pin now asserts BOTH directions over seven heights, and the seventh earns its place by MEASUREMENT +rather than symmetry: at 0.2 the character enters NOT grounded — the entry probe is bounded by +`padding + predictive_contact_distance` — so floor-sticking is skipped and it stays at 0.2 reporting +`.in_air`. My first expectation for it was wrong (0.02, by a formula that only covered grounded cases), +and it is the case that proves the new branch does not touch a character in contact with nothing. + +#### Probe + +| Probe | Exit | Failing tests | Compile errors | +|---|---|---|---| +| `no-standoff` (push to touching, as before) | 1 | 3 | 0 | + +#### Artifacts, corrected in the direction the fix allows + +The precondition on `CharacterDescriptor.position` is WITHDRAWN — the default no longer violates +anything, and what replaces it records that an earlier version had documented the default itself as a +degenerate input. The exact-tangency open decision is REMOVED from `CLAUDE.md`: it has no object. The +tag row no longer says "delivered open"; it says the defect was found while building a probe, measured, +wrongly deferred on a reachability argument that ignored the field's default, and closed after external +review reopened it. Two correction rounds became three. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 480/480 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 480/480 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1652/1669 (17 skipped) ×2 | + +### Gate G, fourth closing round — the floor, and a limit found while probing it + +#### P1 — the fix depended on a strictly positive `padding`, and zero is legal + +Confirmed line for line: `penetration + padding` with `padding = 0` and a tangency of `−0.0` moves the +capsule by nothing, so the freeze closed in the third round was intact on that path. And zero is in the +domain because gate F put it there. + +**Zero is NOT removed from the domain.** It is a setting with a meaning — no physical margin — and +removing it would have masked the defect instead of closing it, which is precisely how the freeze got +here. + +The target gains a NUMERICAL FLOOR: `target = max(padding, standoff_floor_k · floatEps(Real) · +coordScale)`, with `standoff_floor_k = 64` strictly above `contact_margin_conv_k`'s 16 — the floor +exists to put the capsule OUT of the contact margin, not on its edge. + +**The class distinction falls on the other side from every other constant in this module, and it is +written at the declaration.** This floor IS a numerical tolerance, so §1.11.2 governs it and the +`k · floatEps(T) · coordScale` discipline is its own form — unlike `padding`, `max_slope` and +`predictive_contact_distance`, which are named physical parameters that discipline deliberately does not +reach. Worth saying explicitly, because this module spends its length saying the opposite. + +Consequence, assumed and written: with `padding = 0` a resting character stands `standoff_floor` above +its floor rather than exactly on it. That is correct — zero means no PHYSICAL margin, not "stand inside +the numerical band where the classification decides the verdict from one frame to the next". + +#### P2 — the normative contract narrows, and no inflated probe is built + +The `depenetrate` comment that read as an accepted limit — "a capsule already standing off, even by +0.005, is invisible to this query" — is rewritten as the CONTRACT it now is: + +- `padding` is what a SWEEP RESERVES: a move leaves the capsule `padding` clear of what it touched. That + is all `paddedAdvance` establishes and all the reference promises, `mCharacterPadding` being a + parameter of `CastShape`/`CollideShape` and the v5.6.0 note speaking of moving THROUGH the + environment. +- the POSE invariant is narrower: the controller never leaves the capsule inside the contact margin, + where the GJK band decides the verdict from frame to frame. Numerical floor, §1.11.2. +- an authored pose closer than `padding` but clear of the margin is NOT normalised, and must not be — + normalising it would be a pose write nobody asked for. + +Under that contract the 0.005 / 0.01 / 0.019 rows are CORRECT rather than tolerated, and the reason is +cited at the assertion so a reviewer does not reopen it. + +#### A separate limit, found while probing the floor and measured NOT to be its residual + +**At a 1 km collider the move stalls from the second call at f32**, the character reading `.in_air` while +resting 0.02 above the box face. My first version of that test asserted it as a zero-padding residual of +the floor. **Both halves of that framing were refuted by measurement**: it happens at the DEFAULT +`padding = 0.02` as much as at zero, and with the floor REMOVED — the exact state of the third round's +push — the numbers are identical to the last digit. So it is neither caused by the floor nor a property +of `padding`. + +What it is: a large-collider precision limit at f32, the class §1.11.4 bis already characterises and +deliberately does not fix. The box's top face at zero is known only to `ulp(1000) = 6.1e-5` and the +pair's contact margin is `16 · floatEps(f32) · 2733.6 = 5.2e-3`. At f64 the same scene serves every +call, which is what identifies the cause as precision and not geometry. + +Pinned with two legs that make it a SCALE statement rather than a box statement: the 5 m box serves +every call at both precisions and both paddings, and the f64 leg of the 1 km case serves too. + +The arithmetic of the alternative was computed before it was declined: a floor at `gjk.zig`'s symmetric +PAIR scale would clear that margin and cost `64 · floatEps(f32) · 2733.6 = 20.9 mm` of levitation in +that regime. That is a physically visible offset, and it would not have fixed the stall anyway — the +stall is at the default padding, where the 0.02 target already dominates the margin. + +#### Two of my own errors this round, both caught by measurement + +The 1 km test's original framing, above. And its default-padding leg put a SECOND character in the first +one's world: self-exclusion is unilateral by design, so character two collided with character one's +presence and the leg read 0.38 m on a call it expected to serve in full. A separate world, and the reason +is written at the site. + +#### Probe + +| Probe | Exit | Failing tests | Compile errors | +|---|---|---|---| +| `no-standoff-floor` (target back to bare `padding`) | 1 | 2 | 0 | + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 482/482 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 482/482 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1654/1671 (17 skipped) ×2 | + +### Gate G, fifth closing round — the `padding` upper bound, and the domain table + +#### The guard, and the part of its motive that measurement refuted + +`padding` had no upper bound. `paddedAdvance` returns `max(0, d − padding)`, so a large `padding` +zeroes every advance, and the same `padding` enters the depenetration target so it also displaces a +character created near a wall by that much. `padding >= radius` is now refused at creation AND at the +resize — the relation is a domain of the PAIR, so shrinking the radius under a legal padding violates it +while neither field is individually out of range. + +**But the freeze half of the motive did not survive the table.** At `padding = 0.29999` against a 0.2 m +step the character stops short and does not climb — and that is the DECLARED STAND-OFF BEING HONOURED, +not a defect: a character demanding 0.3 m of clearance cannot approach a riser closer than 0.3 m, nor fit +onto a 0.2 m step with 0.3 m of clearance above it. So the guard stands on the geometric motive alone — +a stand-off shell thicker than the radius it surrounds is incoherent, the reference carrying 0.02 against +0.3 — and the claim that it closes a freeze is withdrawn. + +#### The domain table found ONE bound that froze, and closing it took a CLASS sweep + +`padding = 0` with any DOWNWARD component in the displacement froze the character: 0.2 m served on the +first call, 0.0001 on every one after. + +**Three diagnoses, and the first two were prescribed from reasoning and refuted by measurement.** First +the depenetration — already floored, and it did not help. Then floor-sticking, which does re-seat to exact +tangency at `padding = 0`: fixed, and the horizontal cases closed while the downward one did not move. +Only then traced, and the real mechanism is neither: `hit = 0.200997`, `adv = 0.200997`, and the advance +runs along the DIAGONAL, so its own `−Y` component lands the capsule exactly tangent INSIDE the slide +loop — after which three iterations find distance zero, advance nothing, and 0.3 m of the request is +dropped. + +So the fix is the class and not the instance: **every `paddedAdvance` call site owes the stand-off**, and +there are five — the slide, the step's lift, forward and land, and the step-down probe. All five take +`standoffTarget` now. That is this milestone's own standing lesson applied for the fifth time: when the +same defect appears twice, sweep the class, because fixing the instance guarantees the next round. + +Honest about the coverage: of the four new mechanisms, three are pinned by a failing test when removed +and the `stepDown` site is NOT — the slide site's floor already prevents the tangency that site would +recreate, so it is covered by the contract and not by an assertion. Stated rather than implied. + +#### The table itself + +Every row measured on one scene — a ground half-space, a 0.2 m step at `x >= 1.5`, four calls of +`(0.5, −0.05, 0)` — and now an assertion rather than a report. + +| Bound | served per call | final base | verdict | +|---|---|---|---| +| defaults (reference) | 0.5 0.5 0.5 0.5 | 0.20199 | grounded, climbs the step | +| `padding = 0` | 0.5 0.5 0.5 0.5 | 0.2 | **closed this round** | +| `padding = radius − ε` | 0.5 0.5 0 0 | 0.02 | stand-off honoured, not a defect | +| `padding = radius` | — | — | REJECTED, `InvalidPadding` | +| `max_push_force = 0` | 0.5 0.5 0.5 0.5 | 0.20199 | inert, no dynamic body here | +| `step_height = 0` | 0.5 0.5 0.1979 0 | 0.00199 | blocked by the riser, correct | +| `predictive_contact_distance = 0` | 0.5 0.5 0.5 0.5 | 0.20199 | inert — the row that says so | +| `mass = floatMin` | 0.5 0.5 0.5 0.5 | 0.20199 | read only by the push | +| `max_slope = 0` | 0.5 0.5 0.5 0.5 | 0.20199 | `cos 0 = 1` and the floor is exactly `+Y`, so `1 >= 1` holds | +| `max_slope = π/2` | 0.5 0.5 0.1979 0.0399 | 0.06572 | WALKS UP the vertical riser — the bound's consequence | +| `collision_layer = 31` | 0.5 0.5 0.5 0.5 | 0.20199 | the top index is as usable as the bottom | +| `height = 2·radius` | 0.5 0.5 0.5 0.5 | 0.20199 | capsule degenerate to a SPHERE, still served | + +Every row ends GROUNDED: no legal bound loses the floor. After the sweep, **no bound freezes and none +produces silently wrong geometry** — the two rows that deviate from the reference are the declared +stand-off and the declared slope limit doing exactly what they say. + +**This table is the part of the review that should never have depended on attention**, and it is the +finding that outlasts the guard: no gate of this milestone enumerated the legal bounds and asked what the +code does at each. Gate F decided which values to REJECT and never asked the other question, and both +ends of `padding` fell into that hole. + +#### Probe + +| Probe | Exit | Failing tests | Compile errors | +|---|---|---|---| +| `no-padding-upper-bound` | 1 | 1 | 0 | +| `no-resize-padding-recheck` | 1 | 1 | 0 | +| `slide-bare-padding` | 1 | 1 | 0 | +| `stepdown-bare-padding` | **0** | **0** | 0 | + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 484/484 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 484/484 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1656/1673 (17 skipped) ×2 | + +### Gate G, sixth closing round — the floor's scope, a guard measured away, and a spec I do not own + +#### P1 — the floor must not replace a padding the caller asked for + +`@max(padding, floor)` let the floor OVERRIDE a requested `padding`: `64 · floatEps(f32) = 7.6e-6`, so it +crossed the 0.02 default at 2 632 m and reached 3.8 cm at 5 km. A caller asking for 2 cm silently got +3.8, and two identical scenes translated apart stopped at different distances — a supported region +answering wrongly without saying so. + +Now `if (padding > 0) padding else floor`. A non-zero `padding` is honoured EXACTLY at every scale and is +invariant under translation; the floor serves only a caller who asked for no stand-off at all. + +**And the predicted stall regime was WRONG, corrected by measurement.** The prediction was that the +freeze returns where `padding` falls under the contact margin, at a `coordScale` beyond about 10 km. +Measured against a half-space floor at 1 km, 5 km, 8 km, 10 km, 11 km, 12 km, 15 km, 20 km and 50 km: +**the whole metre, every call, at both precisions, with no stall at any distance.** The reason is that +`gjk.zig`'s coordScale is RELATIVE — `|Δpos| + coreExtent(a) + coreExtent(b)`, the symmetry M1.1.2/P1c +fixed — so it does not grow with distance from the origin at all. What grows it is the OTHER BODY'S SIZE. + +So the boundary is a COLLIDER SIZE and not a distance, and it was swept: at f32 a 100 m half-extent +collider serves every call, 300 m stalls on the third, and 500 m and above stall from the second. At f64, +2 km still serves. Pinned at 100 m and 500 m with the f64 leg, which makes it a precision regime and puts +`-Dphysics_f64` where §1.11.4 bis already puts it. This subsumes the 1 km row as one point of a curve. + +#### P2 — the contract must change, and it is not mine to change + +The `depenetrate` comment asserted a narrowed contract that `engine-physics-forge.md` §1.12.6 does not yet +carry, and an implementation comment cannot redefine a normative document. Rewritten to record the actual +state: **the code is ahead of the spec here**, the divergence is real until the patch lands, and the +comment says so instead of arguing it away. No code change on this point. + +#### P3 — the `padding < radius` guard, MEASURED, and REMOVED + +Two motives, neither survived. The freeze motive was already conceded: a large `padding` stops the +character short of an obstacle, which is the declared stand-off being honoured. The remaining one was +explicitly labelled unmeasured — that a push larger than the shape might carry the capsule THROUGH thin +geometry, the push-out invariant guarding only planes the base was on the right side of at entry. + +Measured across FOURTEEN configurations: `padding` at `2 · radius` and at `3.3 · radius`, against a 0.1 m +wall, at seven entry depths straddling its mid-plane — `−0.04, −0.02, −0.005, 0, +0.005, +0.02, +0.04`. +**The capsule exits on the side it entered from every single time.** A larger push goes further out, never +through: the invariant reverts to the entry pose the moment it finds a plane the base has crossed, and +that is independent of how large the push is. + +So the guard rejected valid descriptors and valid resizes on no basis. **Removed at both doors, with its +test**, the `padding` field's domain restored to `[0, ∞)`, and the removal left as a comment because a +guard removed by measurement is worth more to the next reader than its silent absence. The domain table +gains a `padding = 2 · radius` row in its place — legal, and stopping short exactly as `r − ε` does. + +#### Probe + +Superseded: `no-padding-upper-bound` and `no-resize-padding-recheck` no longer exist to probe. The two +that remain are `slide-bare-padding` (exit 1, one test) and `stepdown-bare-padding` (exit 0, covered by +the contract and not by an assertion — stated in the previous round and unchanged). + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 484/484 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 484/484 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1656/1673 (17 skipped) ×2 | + +### Gate G, seventh closing round — the consequence, not the paths + +#### The fix, and what the trace chose over what I would have prescribed + +The property: a contact that does not oppose the remaining motion must not consume the budget, and the +tangential motion must be served to the next real obstacle. + +**Traced before choosing, and the trace overturned the round-2 cost estimate.** That estimate — a +per-call exclusion threaded through five `sweepNearest` call sites — was made without reading +`SweepCollector`, which already carries an `exclude: ?BodyId`. The mechanism existed; only a second slot +was missing. And the decisive fact was measured rather than argued: with the resting floor set aside and +self-exclusion KEPT, the sweep returns `null` and the whole remaining metre is free. My first probe of +that question was worthless — it passed the contact body as `exclude`, which clobbered the +self-exclusion, so it reported the character's own presence at distance zero and told me nothing. + +What shipped: `SweepCollector` gains `exclude_also`, `sweepNearest` gains the parameter and the four +non-slide call sites pass `null`, and the slide loop sets a non-opposing zero-advance contact aside for +ONE retry that does not spend the budget. **48 lines**, under the ceiling. Bounded by construction — one +slot, so one free retry per call, and a second non-opposing body spends its iteration normally. No +epsilon: the advance test is exact zero and the opposition test is the sign of a dot product. + +Discarded, and why: lifting the capsule out of tangency at the point of consequence — the reactive form +of every previous round — cannot be made robust, because at `padding = floatMin` the addition changes no +bit and at a 500 m collider the float resolution reseats it whatever the lift. Exclusion does not depend +on float magnitudes at all, which is what makes it the consequence-level fix rather than a sixth path. + +#### Three green tests flipped, all three of them pins of a stall + +- **the squeeze** — a character under a low ceiling now WALKS. The ceiling's downward normal does not + oppose a horizontal motion, so it obstructed nothing and yet consumed all four iterations. The + assertion the test exists for is untouched and still holds: never driven through the floor. +- **the doorway** — the +Z request is now SERVED. Both wall normals are antiparallel and neither opposes + +Z. The ejection invariant was checked BEFORE the number was touched: still inside, at all three widths. +- **the 1 km collider** — expected to fall with the rest, and it did NOT. + +#### The 1 km stall is a FOURTH mechanism, and the instruction for it cannot be met + +Traced: `slideNormal` returns NULL at that scale — `hit = 0.000000000`, `adv = 0.000000000`, +`normal = false` — so the loop takes its documented "no usable normal: stop rather than guess a +direction" exit, zeroes the remainder and serves nothing. It never reaches the non-opposing test, which +is why setting a contact aside cannot help it. + +So the instruction that this test stop pinning a stall and expect the metre **cannot be carried out**: +serving it would mean inventing a direction the narrowphase declines to supply, which the loop refuses by +design and which is the right refusal. The test keeps its stall expectation and gains the real cause in +place of the vague large-collider one, with the f64 leg that puts it in the §1.11.4 bis class. Reported +rather than forced. + +`padding = floatMin` — the path the `padding > 0` branch opens — is added at both precisions on the 5 m +scene, where it now serves every call. + +#### The vacuous invariance test, and the eighth of its kind + +Codex is right and the probe agrees: restoring `@max(padding, floor)` broke NOTHING. Starting at +`y = padding` with a horizontal motion and asserting `x` alone never exercises `standoffTarget` at all. + +Rewritten to walk into a WALL and assert the final CLEARANCE. At 5 km the floor is +`64 · floatEps(f32) · 5000.9 = 3.81e-2`, so `@max` stops the character 3.8 cm short where it asked for +2 — an 1.8 cm gap, three orders above the tolerance. The `@max` mutant now fails. + +**Eighth green assertion in this milestone that proved nothing, and the FIRST found by external review +rather than by the probe table.** The table's own blind spot is now visible: it probes the mechanisms I +thought to disable, so it cannot catch a test that exercises no mechanism at all. + +#### Probe + +| Probe | Exit | Failing | Compile errors | +|---|---|---|---| +| `no-free-retry` | 1 | 3 | 0 | +| `retry-ignores-opposition` | 1 | 2 | 0 | +| `standoff-back-to-max` | 1 | 1 | 0 | +| `retry-consumes-budget` | 1 | 0 | **1** | + +The last is INVALID and is listed rather than dropped: removing `budget += 1` leaves the variable never +mutated, which is a compile error and not a test result. The mechanism it aimed at is covered by +`no-free-retry`. + +#### Point 3 — the spec divergence + +Awaiting the patched `engine-physics-forge.md` and `engine-tier-interfaces.md` 0.8. When they land the +site comment acknowledging the divergence is DELETED and replaced by a reference to §1.12.6, and the +0.005 / 0.01 / 0.019 rows become correct by the spec rather than by a comment. No code change until then. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 484/484 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 484/484 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1656/1673 (17 skipped) ×2 | + +--- + +## Closure + +### Item 1 — the spec narrowing is NOT delivered, verified rather than assumed + +`~/Downloads/M1.1.12-character-controller/engine-physics-forge.md` carries an mtime of **2026-08-04 +04:55**, the milestone-opening set, and its §1.12.6 still reads *« Le `padding` tient la capsule à +distance des surfaces »* — the exact sentence the narrowing replaces. `handle_dead` appears in neither +file. So the site comment acknowledging the divergence STAYS, the 0.005 / 0.01 / 0.019 rows remain correct +by measurement rather than by the document, and no code moved on this point. Checked, not presumed. + +### Item 2 — `CLAUDE.md`, the five items of §3.4 + +`Current state` replaced integrally; the tag row added and then extended with this series; five open +decisions added and none removed; footer `2026-08-05`. The CONDITIONAL item, checked rather than skipped: +the spikes table holds S0 through S6, all Phase −1 and Phase 0, and this milestone validates no spike +hypothesis — nothing to add, and that is the finding. The NGS-watch entry carries its measurement: the +retained speed is one ULP above the launch speed at f32 and exactly the launch speed at f64, so the gain +is arithmetic and not NGS. + +### The probe table's blind spot, and the counter-measure — as a METHOD + +The probe table disables a mechanism and requires a test to fail. It is structurally unable to catch a +test that exercises NO mechanism: the eighth vacuous assertion of this milestone was found by external +review, not by the table, because the table only asks about mechanisms someone thought to disable. + +**The counter-measure is a MUTATION probe rather than a disabling one: restore the PREVIOUS +IMPLEMENTATION and require the test to fail.** That is what pins the scoped stand-off — `@max` broke +nothing before, and breaks a test now. The two are complementary and neither subsumes the other: a +disabling probe asks "is this mechanism load-bearing", a mutation probe asks "would the older, plausible +form be caught". Written here as a method for the next milestone, not as an anecdote about this one. + +### Consignations, each with its owner + +| # | Consignation | Owner | +|---|---|---| +| 1 | Tooling facts have no owner — `engine-development-workflow.md` carries no such section | the workflow document; NOT a physics milestone | +| 2 | Frozen pose setters are `void` while pose writes are becoming allocation-fallible | M1.1.15, which IS the freeze | +| 3 | Whether setters should be fallible at all — spans the whole Tier 0 surface | M1.1.15, the interface tier | +| 4 | The f32 COLLIDER-EXTENT envelope: at a 1 km box `slideNormal` returns null and the slide stops rather than guess. Boundary measured at 100 m / 300 m / 500 m half-extent; f64 clean to 2 km | a spec ADDITION to §1.11.4 bis, which characterises the far field by distance only and carries no extent axis — Guy's, and it closes the finding without a code change | +| 5 | The §1.12.6 narrowing — `padding` as a sweep reserve, not a pose invariant — undelivered at closure | Guy; the site comment records the divergence until it lands | +| 6 | `engine-physics-forge.md` four-way decomposition, 220 KB with §1.11 an accumulator | between this closure and M1.1.13's opening | +| 7 | The one unguarded step mode (squeeze onto level ground) | whoever ports the reference's stair walking in full | +| 8 | The crease branch of the slope cap, NOT DISTINGUISHED by any of five scenes | open; a sixth scene that separates it, or a proof that none can | +| 9 | The latent `bp.update` use-after-free hazard: ordering fixed, the trigger not makeable to fail from a test | M1.1.15, with consignation 2 | +| 10 | One transient `signal KILL` naming no test, not reproducible | none; reported, not diagnosed | + +### The domain table, and what it becomes + +Every descriptor parameter at its legal bounds, measured on one scene and ASSERTED — including the two +PAIR relations, `height >= 2 · radius` at its limiting value (a capsule degenerate to a sphere, served) +and `padding` against `radius` (no longer a bound: the guard was measured away, and `2 · radius` is a +legal row that stops short exactly as `r − ε` does). + +**It is an EXIGENCE for the next milestone that declares a descriptor**, and the reason is that no gate of +this one enumerated the legal bounds and asked what the code does at each. Gate F decided which values to +REJECT and never asked the other question; both ends of `padding` fell into that hole, and the lower end +cost four rounds. A domain table is cheap at the gate that declares the fields and expensive afterwards. + +### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 484/484 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 484/484 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1656/1673 (17 skipped) ×2 | +| `bench-forge-character`, ReleaseFast | 0 | five rows interleaved, leak check proven both ways | + +`zig fmt --check src/ bench/ tests/` clean; tree-wide `zig build lint` exit 0; zero French in code, +audited in Python. Eleven inherited envelope quantities at zero movement against `main`, both precisions, +and all seventeen inherited forge test files byte-identical to the tag. diff --git a/build.zig b/build.zig index 12521bc..74293b0 100644 --- a/build.zig +++ b/build.zig @@ -1015,6 +1015,34 @@ pub fn build(b: *std.Build) void { ); forge_mesh_bench_step.dependOn(&forge_mesh_bench_run.step); + // -------------------------------- M1.1.12 forge character controller bench -- + // + // `moveCharacter` on a plane / stairs / a wall / a triangle mesh, plus + // `resizeCharacter`, INTERLEAVED across reps. Writes + // `bench/results/forge_3d_character.md`. REPORTED, not gated — no envelope is + // pre-registered (bench header). + const forge_char_bench_module = b.createModule(.{ + .root_source_file = b.path("bench/forge_3d_character.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + forge_char_bench_module.addImport("forge_3d", forge_3d_module); + forge_char_bench_module.addImport("weld_forge", forge_api_module); + const forge_char_bench_exe = b.addExecutable(.{ + .name = "forge-character-bench", + .root_module = forge_char_bench_module, + }); + b.installArtifact(forge_char_bench_exe); + const forge_char_bench_run = b.addRunArtifact(forge_char_bench_exe); + forge_char_bench_run.step.dependOn(b.getInstallStep()); + if (b.args) |args| forge_char_bench_run.addArgs(args); + const forge_char_bench_step = b.step( + "bench-forge-character", + "Run the M1.1.12 forge character controller bench (five paths interleaved, writes bench/results/forge_3d_character.md)", + ); + forge_char_bench_step.dependOn(&forge_char_bench_run.step); + // -------------------------------------- M1.0.5 scene loader bench -------- // // `loadFromBytes` on a ~10k-entity image synthesized in-bench via the diff --git a/src/foundation/math/math.zig b/src/foundation/math/math.zig index 293b201..7506b7f 100644 --- a/src/foundation/math/math.zig +++ b/src/foundation/math/math.zig @@ -33,6 +33,17 @@ pub const CrossOutcome = vec.CrossOutcome; /// Whether a triangle is EXACTLY flat, decided in integer arithmetic. THE classifier: callers that /// must not admit a flat triangle ask this, never `triangleCross`. pub const triangleIsFlat = @import("exact.zig").triangleIsFlat; +/// A vector PARALLEL to the exact area vector of three points, or `null` when that vector is +/// EXACTLY zero — the exact tier `triangleIsFlat` is written on, exposed for callers that need the +/// DIRECTION together with that exact null. +/// +/// Re-exported at M1.1.12 for its second consumer: the character controller's edge slide, whose +/// direction is `n₁ × n₂` and whose `null` must mean "the two normals are exactly parallel" and +/// nothing weaker. The tiered `triangleCross` cannot serve there — its `.direction` comes from the +/// first float tier that forms a non-zero vector, so on exactly parallel normals it returns a +/// rounding residue that reads as a valid crease. That is the very confusion §1.11.17 records as +/// having nearly shipped. +pub const triangleCrossDirection = @import("exact.zig").triangleCrossDirection; /// The power-of-two exponent that reduces three points below unit magnitude. pub const pow2ReductionExponent = vec.pow2ReductionExponent; diff --git a/src/modules/forge/api/root.zig b/src/modules/forge/api/root.zig index da02b47..0dcdd51 100644 --- a/src/modules/forge/api/root.zig +++ b/src/modules/forge/api/root.zig @@ -31,7 +31,11 @@ pub const EntityId = types.EntityId; pub const BodyId = types.BodyId; /// Opaque collision-shape handle (`u32`, same packing as `BodyId`). pub const ShapeId = types.ShapeId; -/// The `index:24 | generation:8` packing shared by `BodyId`/`ShapeId`. +/// Opaque character-controller handle (`u32`, same packing as `BodyId`). +pub const CharacterId = types.CharacterId; +/// The `index:24 | generation:8` packing shared by `BodyId`, `ShapeId` and +/// `CharacterId`, and the `PackedId.dead` all-ones reservation those three use for +/// "no handle". pub const PackedId = types.PackedId; /// Simulation class of a body (static / kinematic / dynamic). pub const BodyType = types.BodyType; @@ -44,6 +48,18 @@ pub const BodyDescriptor = types.BodyDescriptor; /// Physics pose (position + rotation, no scale). pub const Transform = types.Transform; +// --- Character controller (`engine-physics-forge.md` §1.12) --- + +/// Everything needed to create one character controller. A controller is VIRTUAL — +/// it takes part in no solver pass, and its pose is written by `moveCharacter` alone. +pub const CharacterDescriptor = types.CharacterDescriptor; +/// The TERNARY ground verdict. Zig mirror of the Etch enum owned by +/// `engine-movement.md` §2 — same order, same values. +pub const GroundState = types.GroundState; +/// Result of one `moveCharacter`: the resolved BASE position plus the five ground +/// quantities, of which `ground_state` is the discriminator. +pub const CharacterMoveResult = types.CharacterMoveResult; + // --- Queries (the complete frozen family, `engine-tier-interfaces.md` §1) --- /// Number of object layers a query mask can address; `addBody` rejects a body diff --git a/src/modules/forge/api/types.zig b/src/modules/forge/api/types.zig index bf1f911..0b6ab4d 100644 --- a/src/modules/forge/api/types.zig +++ b/src/modules/forge/api/types.zig @@ -31,15 +31,33 @@ pub const BodyId = u32; /// generation:8` packing as `BodyId`. pub const ShapeId = u32; -/// The `index:24 | generation:8` bit layout shared by `BodyId` and `ShapeId` -/// (index in the low 24 bits, generation in the high 8). Stale-handle -/// detection for the free-list slot reuse in `ShapeStore`/`BodyManager` (E3). +/// Opaque character-controller handle (M1.1.12, `engine-physics-forge.md` §1.12) — +/// same `u32` boundary and `index:24 | generation:8` packing as `BodyId`. +/// +/// The GENERATION is not decoration here: §1.12 requires a typed error on a stale +/// handle from `moveCharacter`, `resizeCharacter` and `getCharacterInnerBody`, and +/// a bare slot index cannot tell a recycled slot from the handle that used to own +/// it. Without the generation that contract would be unenforceable rather than +/// merely unenforced. +pub const CharacterId = u32; + +/// The `index:24 | generation:8` bit layout shared by `BodyId`, `ShapeId` and +/// `CharacterId` (index in the low 24 bits, generation in the high 8). +/// Stale-handle detection for the free-list slot reuse in +/// `ShapeStore`/`BodyManager` (M1.1.0 E3), and for the character store from +/// M1.1.12. pub const PackedId = packed struct(u32) { /// Slot index into the owning pool (low 24 bits). index: u24, /// Generation tag bumped on slot reuse (high 8 bits). generation: u8, + /// Bit pattern reserved for "no handle", shared by `BodyId`, `ShapeId` and + /// `CharacterId`. Never produced by a slot allocator — `index = maxInt(u24)` + /// would require 16.7 M live slots, well past any milestone target. Same + /// reservation `EntityId.dead` makes on the ECS side, and the same reason. + pub const dead: u32 = pack(std.math.maxInt(u24), std.math.maxInt(u8)); + /// Pack an index + generation into the `u32` handle. pub fn pack(index: u24, generation: u8) u32 { return @bitCast(PackedId{ .index = index, .generation = generation }); @@ -248,6 +266,270 @@ pub const BodyDescriptor = struct { can_sleep: bool = true, }; +// --- Body pose and velocity entries — semantics frozen here --- +// +// `PhysicsModule`'s function declarations live in `src/interfaces/PhysicsModule.zig`, +// which does not exist yet: it lands at M1.1.15 with `ModuleContext`, and this file is +// the day-1 mirror of `engine-tier-interfaces.md` §1 until then (see the file header). +// So the SEMANTICS of three body entries are recorded here, next to the frozen types +// they traffic in, and they move with the declarations when that file lands. +// +// DESTINATION: M1.1.15 MOVES this block onto those three declarations in +// `src/interfaces/PhysicsModule.zig`. It is not duplicated there — two copies of a +// contract are two things that can disagree, which is the whole subject of the block. +// +// - `setBodyTransform(id, position, rotation)` is a TELEPORTATION. It writes the pose +// and derives NO velocity: a kinematic body moved through it keeps velocity columns +// of exactly zero. That is not an oversight to be repaired — it is the same split the +// reference draws between `SetPositionAndRotation` and `MoveKinematic`. +// +// The consequence is load-bearing for the character controller and it is why this +// note exists: `CharacterMoveResult.ground_velocity` is measured AT THE CONTACT +// POINT, so it reads the support's `v + ω × r`. A platform teleported through this +// entry therefore reports a ground velocity of ZERO while visibly moving +// (`engine-physics-forge.md` §1.12.5). The fix is to drive such a platform with +// `moveKinematic`, never to make this entry guess a velocity from two poses it was +// not given a `dt` for. +// +// - `moveKinematic(id, target_position, target_rotation, dt)` is what DERIVES both +// velocities from a target pose over a `dt`, on the shape of +// `BodyInterface::MoveKinematic`. Its signature freezes at M1.1.12; its body is a +// typed stub until M1.1.15, deriving a velocity belonging to the tick cycle and the +// wake composition, which arrive with `PhysicsWorld`. Same pattern C1.1 authorises by +// name for `createJoint` and M1.1.9 already executed on five query entries. +// +// - `setAngularVelocity(id, ω)` closes a gap dating from M1.1.0: `PhysicsModule2D` +// carries `setAngularVelocity2D` and the reference carries both, while 3D carried only +// the linear setter — so `ω` was authorable by NO caller at all, and the rotational +// term of `ground_velocity` had no source. `BodyManager` has had the column setter +// since M1.1.8; what was missing is the interface entry. +// +// Write intent, unchanged from §1.8.4: a pose or velocity WRITE is non-activating (it is +// the solver's own path), while an external mutation — force, torque, impulse — wakes. The +// interface tier composes wake + write for every setter it exposes to gameplay, and a +// character presence moved by pose write is wake cause W4, never W3 (§1.12.10). + +/// Everything needed to create one character controller +/// (`engine-physics-forge.md` §1.12). A controller is VIRTUAL: it takes part in no +/// solver pass — no inverse mass, no inertia tensor, no contact constraint, no island +/// membership — and its pose is written by `moveCharacter` and by that entry alone. +/// +/// There is NO rotation field, and the absence is argued rather than suffered: the +/// capsule is symmetric about Y, the engine's up is Y +/// (`engine-coordinate-system.md`), so no orientation changes a collision answer. That +/// validity is CONDITIONAL on the presence carrying the same capsule — were an +/// arbitrary presence shape ever admitted, rotation would become necessary again, +/// which is one more reason not to admit one (§1.12.3). +/// +/// Absent for reasons recorded rather than forgotten: `max_speed` is kinematics and +/// belongs to `MovementConfig` (`engine-movement.md`) — `moveCharacter` takes a +/// displacement already computed; and `friction` has no meaning on a body that never +/// reaches the contact solver, ground braking being `MovementConfig.ground_friction`. +pub const CharacterDescriptor = struct { + /// Owning ECS entity. + entity: EntityId, + + /// Position of the capsule's BASE, NEVER its centre (§1.12.3). A body's pose is the + /// centre of its shape — the capsule being symmetric about the origin — so the + /// presence sits at this position plus half the height along up. That offset exists in + /// exactly ONE named place in the solver: computed twice, it will diverge once. + /// + /// The anchor is FIXED where the reference PARAMETERISES it through `mShapeOffset` — + /// a deliberate divergence, this descriptor carrying only `radius` and `height`, hence + /// a capsule and nothing else. + /// + /// The DEFAULT is `Vec3.zero`, and a base placed exactly tangent to a surface — which that default + /// is, over a floor at `y = 0` — is served: `depenetrate` establishes the `padding` stand-off + /// §1.12.6 requires. An earlier version documented that configuration as a degenerate input the + /// caller had to avoid, which was a bug with an apology attached: a precondition the field's own + /// default violates is not a precondition. + position: Vec3 = Vec3.zero, + + /// Capsule radius (metres). + radius: f32 = 0.3, + /// Total capsule height, base to top (metres). + height: f32 = 1.8, + /// Tallest riser the controller climbs rather than being blocked by (metres). + step_height: f32 = 0.3, + + /// Steepest walkable slope, in RADIANS. A named PHYSICAL parameter of the class of + /// `restitution_threshold`, `penetration_slop` and `active_edge_cos_threshold`: it + /// selects a modelling behaviour, not a numerical tolerance, so §1.11.2's + /// `k · floatEps(T) · coordScale` discipline DOES NOT GOVERN IT. A reviewer applying + /// that rule here will be wrong. + /// + /// RADIANS here and DEGREES in the Etch components, the conversion belonging to the + /// `@unit(.degrees)` annotation — the divergence is written down so that nobody + /// "corrects" either side. The solver stores its COSINE, computed once at creation, + /// and tests `n · up >= cos_max_slope`: an `acos` per contact per frame is exactly + /// what M1.1.14 would have to make reproducible, `engine-phase-1-plan.md` naming + /// internal trigonometric functions among its determinism hazards (§1.12.5). + /// + /// A value outside `[0, π/2]` is a DOMAIN ERROR and is never clamped: silently + /// clamping would make a caller's mistake look like a modelling choice. + max_slope: f32 = 0.785, // ~45° + + /// Distance the capsule is held off surfaces (metres). Same class as `max_slope` — a + /// physical parameter, not a tolerance, and §1.11.2 governs it no more than the + /// other. Without a margin the capsule sits flush and GJK's own contact-margin band + /// then decides the verdict from one frame to the next. The reference's + /// `mCharacterPadding` value. + /// + /// Domain `[0, ∞)`, and the lower end is the one that took work. ZERO is legal and means no + /// PHYSICAL margin; the solver then holds the capsule off by a numerical floor so the + /// classification band cannot decide the verdict from one frame to the next, and that floor + /// applies ONLY at zero — a `padding` the caller asked for is honoured exactly, at every + /// scale, invariant under translation. + /// + /// There is deliberately NO upper bound. A large value stops the character further from + /// obstacles, which is what a large stand-off means, and it was measured NOT to push the + /// capsule through thin geometry: at `2 ·` and `3.3 · radius`, against a 0.1 m wall, at seven + /// entry depths straddling its mid-plane, it exits on the side it entered from every time. + padding: f32 = 0.02, + + /// How far OUTSIDE the shape to sweep for contacts not yet touching (metres). The + /// reference documents that a value of zero most likely gets the character stuck, the + /// sliding direction no longer being computable (`mPredictiveContactDistance`). + /// + /// THE ONE FIELD OF THIS DESCRIPTOR THE ALGORITHM HAS NOT YET JUSTIFIED. It ships + /// because the pre-freeze window closes at M1.1.15 and the only known production + /// realisation of this algorithm declares it load-bearing. The gate that writes + /// sliding CONSUMES it or DELETES it — both stay inside the window; leaving it inert + /// does not. + predictive_contact_distance: f32 = 0.1, + + /// What the character IS, read by others through the mask of THEIR queries. + /// + /// Bounded to `[0, collision_layer_count)`: `createCharacter` rejects anything beyond + /// with `error.InvalidCollisionLayer`, the same error and the same reason as `addBody` + /// (§1.11.5) — the mask is 32 bits, and a character declared past it would be + /// invisible to every query with no diagnostic at all. + /// + /// It serves as the PRESENCE layer too, with no dedicated field: the reference carries + /// a separate `mInnerBodyLayer` because it does not split the character's layer, and + /// our split with `layer_mask` already does that work (§1.12.2). + collision_layer: u8 = 0, + + /// What the character SEES, read by itself alone in its own sweeps. Two questions + /// with no shared path (§1.12.4). The object-layer matrix of + /// `engine-physics-forge.md` §3 does not govern a controller: it filters simulation + /// PAIRS, which a sweep is not (§1.11.1). + layer_mask: u32 = 0xFFFFFFFF, + + /// Mass serving the push impulse (kg). The character receives NOTHING in return: it + /// is kinematic, so the push is unilateral by construction (§1.12.9). The reference's + /// `mMass` value. + mass: f32 = 70.0, + + /// Ceiling on the push force (N). Zero disables pushing with no special case. The + /// reference's `mMaxStrength` value. + max_push_force: f32 = 100.0, + + /// Whether the character carries a broadphase PRESENCE — a kinematic body holding ITS + /// OWN capsule (§1.12.2). Without one it is invisible to every query, and the argument + /// for that being unacceptable is internal to this descriptor rather than borrowed from + /// any scene: `collision_layer` above is the mechanism by which an object declares + /// itself VISIBLE to other callers' queries (§1.11.5 — the layer tested is the touched + /// shape's, and the caller declares by mask what it wants to see). So either the + /// character carries a presence, or `collision_layer` is a field with no observable + /// effect. This surface has promised query visibility since it was written. + /// + /// There is NO `inner_body_shape` field: two shapes that can diverge are a + /// synchronisation contract with no counterpart, and `resizeCharacter` would have to + /// resize both. That absence is also what keeps the missing rotation field valid. + /// + /// DEFAULT `true`, a deliberate divergence from the reference, which defaults to + /// `nullptr`: the failure mode of a default-off is a character nobody can shoot, + /// discovered late. + inner_body: bool = true, +}; + +/// The ground verdict, TERNARY (`engine-physics-forge.md` §1.12.5). Zig mirror of the +/// Etch enum declared in `engine-movement.md` §2 — same order, same values, the same +/// mirror contract `BodyType` keeps with the `body_type` field of the `RigidBody` +/// component. +/// +/// A boolean would force the consumer to re-derive the angle from the normal, hence to +/// recompute `max_slope` OUTSIDE the engine that holds it, hence to be able to disagree +/// with it. It also could not carry `on_steep_ground`, which is precisely the state +/// `engine-movement.md` §5 transitions on. +pub const GroundState = enum(u8) { + /// On ground whose slope is walkable. + grounded, + /// Bearing on a slope too steep to walk. + on_steep_ground, + /// No ground. THE DEFAULT EVERYWHERE, and it is a failure DIRECTION: a `.grounded` + /// default on an unknown verdict means gravity not applied, hence a character that + /// floats — a symptom that does not correct itself; `.in_air` means one tick of + /// gravity, hence a sub-millimetre sink that does. Same reasoning as the + /// contact-announced-early of §1.11.11. + in_air, +}; + +/// Result of one `moveCharacter`. +/// +/// The former `collisions: u8` field is DELETED: a counter with no named consumer in the +/// corpus, saturating at 255, and a counter belongs only to a shape that is allowed to +/// fail. `CharacterMoveResult2D` carries the same field and SURVIVES this removal — the +/// 2D symmetry is consigned for M1.8.11, where `PhysicsModule2D` freezes, on the M1.1.11 +/// precedent of listing a 2D symmetry in OUT with its freeze date rather than touching 2D +/// from a 3D milestone. +/// +/// **`ground_state` is the discriminator, and the two support handles carry an explicit +/// "no support" value rather than relying on it.** `ground_entity` is `EntityId.dead` and +/// `ground_body` is `PackedId.dead` outside `.grounded` / `.on_steep_ground`, so a caller +/// that reads one without consulting the verdict gets an unmistakably absent handle +/// instead of a plausible wrong answer. `ground_normal` is the one field carrying a +/// meaningful value in every state, and that is deliberate (see its own doc). +pub const CharacterMoveResult = struct { + /// Position of the BASE after resolution (§1.12.3) — what gameplay writes into + /// `Transform.position`, and where every probe of the caller starts from. + position: Vec3, + + /// The VERDICT. A direction does not mix into it. + ground_state: GroundState = .in_air, + + /// The DIRECTION. `Vec3.up` on `.in_air` — spelled `Vec3.unit_y` here, `foundation` + /// math naming its basis vectors by axis while `engine-coordinate-system.md` names + /// this one semantically; under the engine's Y-up convention the two are the same + /// value, `(0, 1, 0)`. + /// + /// NEVER a poisoned value, and this is the one `ground_*` field that holds in every + /// state: three documents read it inside a `@replicated` component + /// (`engine-movement.md`, `engine-animation-kinesis.md`, + /// `engine-gameplay-systems.md`) and a NaN here would cross the rollback. The + /// poisoning discipline of §1.11.17 does not extend to this struct. + ground_normal: Vec3 = Vec3.unit_y, + + /// Entity of the support. `EntityId.dead` outside `.grounded` / `.on_steep_ground`. + /// Required by the moving-platform velocity inheritance of `engine-movement.md` §4. + /// + /// NON-NULL on `.on_steep_ground` as much as on `.grounded`: a steep slope is still a + /// support, and the field is null only on `.in_air` (§1.12.5). + ground_entity: EntityId, + + /// Body of the support — what the caller interrogates it through without searching for + /// it. `PackedId.dead` outside `.grounded` / `.on_steep_ground`, in step with + /// `ground_entity`. + /// + /// The default is the SENTINEL and not `0`, and the reason is structural: + /// `PackedId.pack(0, 0)` is `0`, so with `0` as the default NO bit configuration of this + /// field would mean absence. The field would be unreadable without consulting a + /// neighbouring one — and that coupling is invisible at the C ABI level. `engine-c-api.md` + /// carries neither `struct_size` nor a minor version, so a Tier 3 caller reading + /// `ground_body` alone has no way to learn it was supposed to read `ground_state` first, + /// and no future version can teach it. + ground_body: BodyId = PackedId.dead, + + /// Velocity AT THE CONTACT POINT, hence `v + ω × r` and not the support's linear + /// velocity: without the rotational term a character standing at the rim of a + /// rotating platform drifts. Zero on `.in_air`. + /// + /// This field is the reason `setAngularVelocity` and `moveKinematic` ship at all — + /// without them `ω` has no authorable source (see the body-entry block above). + ground_velocity: Vec3 = Vec3.zero, +}; + // --- Queries (the complete family, frozen before the interface freeze) --- // // Mirrors `engine-tier-interfaces.md` §1 verbatim. The family is settled IN FULL @@ -479,6 +761,23 @@ test "BodyId pack/unpack round-trip" { try testing.expectEqual(@as(u32, 0), PackedId.pack(0, 0)); } +test "PackedId.dead is the all-ones no-handle reservation" { + // All ones in both fields, so the whole `u32` is `0xFFFFFFFF`. Reserved rather than + // merely unlikely: a slot allocator would have to reach 16.7 M live slots to produce + // this index, which is the same argument `EntityId.dead` makes with 4 G on the ECS side. + try testing.expectEqual(@as(u32, 0xFFFFFFFF), PackedId.dead); + try testing.expectEqual(@as(u24, std.math.maxInt(u24)), PackedId.unpack(PackedId.dead).index); + try testing.expectEqual(@as(u8, std.math.maxInt(u8)), PackedId.unpack(PackedId.dead).generation); + + // ONE constant for the three handles, because they share the packing. A per-type + // sentinel would be three values to keep equal, hence one to get wrong. + const as_body: BodyId = PackedId.dead; + const as_shape: ShapeId = PackedId.dead; + const as_character: CharacterId = PackedId.dead; + try testing.expectEqual(as_body, as_shape); + try testing.expectEqual(as_body, as_character); +} + test "ShapeDescriptor payload defaults" { const s = ShapeDescriptor{ .sphere = .{} }; try testing.expectEqual(@as(f32, 0.5), s.sphere.radius); @@ -598,3 +897,144 @@ test "the frozen query family mirrors engine-tier-interfaces.md §1" { try testing.expectEqual(f32, @TypeOf(ray.max_distance)); try testing.expectEqual(f32, @TypeOf(closest.distance)); } + +test "CharacterId carries the PackedId layout, so a stale handle is detectable" { + // `u32` at the interface boundary, `index:24 | generation:8` inside — the same + // packing as `BodyId` and `ShapeId`, reached through the same one helper. + try testing.expectEqual(u32, CharacterId); + + const id: CharacterId = PackedId.pack(7, 3); + try testing.expectEqual(@as(u24, 7), PackedId.unpack(id).index); + try testing.expectEqual(@as(u8, 3), PackedId.unpack(id).generation); + + // THE property §1.12 needs from this handle: a recycled slot and the handle that used + // to own it differ, so `moveCharacter` can answer with a typed error instead of + // silently moving whoever took the slot over. Without the generation the two are the + // same integer and that contract is unenforceable, not merely unenforced. + try testing.expect(PackedId.pack(7, 3) != PackedId.pack(7, 4)); + + // And the limit of that, stated rather than discovered: all three handles are `u32`, + // so they are NOT distinct types and the compiler cannot stop a `BodyId` being passed + // where a `CharacterId` is wanted. That is the frozen boundary's own choice + // (`engine-tier-interfaces.md` §1 declares all of them `u32`); the generation guards + // slot recycling, never handle confusion. + try testing.expectEqual(BodyId, CharacterId); +} + +test "GroundState is the ternary verdict, u8-backed, in engine-movement.md's order" { + // The Etch enum in `engine-movement.md` §2 is the owner declaration and this is its + // Zig mirror: same order, same values. Four documents read this verdict, so a + // reordering here is a silent change of meaning in all of them. + try testing.expectEqual(@as(u8, 0), @intFromEnum(GroundState.grounded)); + try testing.expectEqual(@as(u8, 1), @intFromEnum(GroundState.on_steep_ground)); + try testing.expectEqual(@as(u8, 2), @intFromEnum(GroundState.in_air)); + try testing.expectEqual(u8, @typeInfo(GroundState).@"enum".tag_type); + + // THREE values, and the count is asserted because §1.12.8 REFUSES a fourth: inventing + // an `unknown` to report an invalidated verdict would cost more than the safe failure + // direction earns, `.in_air` already being that direction. Counter-factual: appending a + // fourth value leaves the three asserts above passing and fails HERE (measured). + try testing.expectEqual(@as(usize, 3), @typeInfo(GroundState).@"enum".fields.len); +} + +test "CharacterDescriptor mirrors engine-tier-interfaces.md §1 field for field" { + // Field NAMES and defaults are the contract. Referencing each field by name makes a + // rename or a removal a COMPILE error; the field-COUNT assert is what makes an + // ADDITION visible, which no by-name reference can catch. Both directions matter here + // and not merely in principle: after the M1.1.15 freeze, `engine-c-api.md` carrying no + // `struct_size` and no minor version, adding one defaulted field to this descriptor is + // an ABI break for every Tier 3 plugin rather than a source-compatible addition. + const d = CharacterDescriptor{ .entity = EntityId.dead }; + + try testing.expect(d.position.eql(Vec3.zero)); + try testing.expectEqual(@as(f32, 0.3), d.radius); + try testing.expectEqual(@as(f32, 1.8), d.height); + try testing.expectEqual(@as(f32, 0.3), d.step_height); + try testing.expectEqual(@as(f32, 0.785), d.max_slope); + try testing.expectEqual(@as(f32, 0.02), d.padding); + try testing.expectEqual(@as(f32, 0.1), d.predictive_contact_distance); + try testing.expectEqual(@as(u8, 0), d.collision_layer); + try testing.expectEqual(@as(u32, 0xFFFFFFFF), d.layer_mask); + try testing.expectEqual(@as(f32, 70.0), d.mass); + try testing.expectEqual(@as(f32, 100.0), d.max_push_force); + try testing.expectEqual(true, d.inner_body); + + // `entity` carries NO default, and that is transcribed rather than improved: a + // controller belonging to no entity is not a thing this descriptor should be able to + // express by omission. + try testing.expectEqual(EntityId, @TypeOf(d.entity)); + + // Counter-factual measured: appending one field leaves every assert above passing and + // fails HERE, which is the only reason this line is not decoration. + try testing.expectEqual(@as(usize, 13), @typeInfo(CharacterDescriptor).@"struct".fields.len); + + // Four absences, each argued in the spec and each pinned so that a later milestone + // re-adding one has to delete the argument first rather than quietly outvote it: + // rotation (§1.12.3 — the capsule is symmetric about the engine's Y up), + // `inner_body_shape` (§1.12.2 — two shapes that can diverge), and `max_speed` / + // `friction` (§9 — kinematics and a solver coefficient a virtual controller never + // reaches). + try testing.expect(!@hasField(CharacterDescriptor, "rotation")); + try testing.expect(!@hasField(CharacterDescriptor, "inner_body_shape")); + try testing.expect(!@hasField(CharacterDescriptor, "max_speed")); + try testing.expect(!@hasField(CharacterDescriptor, "friction")); + + // The public surface is f32 (§1.11.8, §1.12.11) — pinned, because widening it is ONE + // decision over `BodyDescriptor`, the interface pose, the query results and the ECS + // `Transform` together, at M1.1.15, and never over one member of that set alone. + try testing.expectEqual(f32, @TypeOf(d.radius)); + try testing.expectEqual(f32, @TypeOf(d.height)); + try testing.expectEqual(f32, @TypeOf(d.max_slope)); + try testing.expectEqual(f32, @TypeOf(d.padding)); + try testing.expectEqual(math.Vec3, @TypeOf(d.position)); + + // `collision_layer` is bounded by the SAME constant `addBody` reads, not by a second + // copy of 32 (§1.12.4): the mask is 32 bits, and a character declared past it would be + // invisible to every query with no diagnostic. + try testing.expect(d.collision_layer < collision_layer_count); + try testing.expectEqual(@as(u8, 32), collision_layer_count); +} + +test "CharacterMoveResult mirrors engine-tier-interfaces.md §1 field for field" { + const r = CharacterMoveResult{ .position = Vec3.zero, .ground_entity = EntityId.dead }; + + // The verdict, and its default is the safe failure direction in every state (§1.12.5). + try testing.expectEqual(GroundState.in_air, r.ground_state); + + // The direction, and the ONE `ground_*` field that holds a meaningful value even on + // `.in_air`. Asserted EXACTLY unit rather than approximately: three documents read it + // inside a `@replicated` component and a NaN would cross the rollback, so `lengthSq` + // being exactly 1 is the strongest available statement that it is never poisoned — + // `Vec3.unit_y` is `engine-coordinate-system.md`'s `Vec3.up` under Y-up, the same + // value `(0, 1, 0)`. + try testing.expect(r.ground_normal.eql(Vec3.unit_y)); + try testing.expectEqual(@as(f32, 1), r.ground_normal.lengthSq()); + + // The support handle's default is the SENTINEL, asserted in BOTH directions: equal to + // `PackedId.dead`, and DIFFERENT FROM 0. The second half is the one that catches a + // regression to the old default, which was `0` — a valid handle to slot 0 generation 0, + // hence a field with no bit configuration meaning absence, readable only in company of + // `ground_state` and silently so across the C ABI. `engine-c-api.md` carries no + // `struct_size` and no minor version, so after the M1.1.15 freeze that default would + // have been frozen into the ABI. + try testing.expectEqual(@as(BodyId, PackedId.dead), r.ground_body); + try testing.expect(r.ground_body != 0); + try testing.expect(r.ground_velocity.eql(Vec3.zero)); + + // `position` and `ground_entity` carry no default — transcribed as frozen. + try testing.expectEqual(math.Vec3, @TypeOf(r.position)); + try testing.expectEqual(EntityId, @TypeOf(r.ground_entity)); + + // Counter-factual measured, same shape as the descriptor's. + try testing.expectEqual(@as(usize, 6), @typeInfo(CharacterMoveResult).@"struct".fields.len); + + // `collisions: u8` is DELETED and its absence is pinned: an aggregated counter with no + // named consumer in the corpus, saturating at 255. `CharacterMoveResult2D` still + // carries it, and that asymmetry is consigned for M1.8.11 rather than resolved from a + // 3D milestone. + try testing.expect(!@hasField(CharacterMoveResult, "collisions")); + + // f32 boundary, same single decision as the descriptor above. + try testing.expectEqual(math.Vec3, @TypeOf(r.ground_normal)); + try testing.expectEqual(math.Vec3, @TypeOf(r.ground_velocity)); +} diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index e095065..2f80c1f 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -313,6 +313,70 @@ pub const BodyManager = struct { return self.bodies.items(.collision_layer)[idx]; } + /// Safe getter: the body's simulation class, or null if `id` is stale/invalid. + /// + /// The column has existed since M1.1.0 and was never exposed. What needs it is + /// M1.1.12: §1.12.2 states normatively that a character's presence is a KINEMATIC body, + /// and `motionProperties` cannot answer that — a static and a kinematic body both carry + /// an inverse mass of exactly zero. A normative property nothing can assert is one that + /// regresses silently, which is the same argument that exposed `entity` at M1.1.10. + pub fn bodyType(self: *const BodyManager, id: BodyId) ?api.BodyType { + const idx = self.alloc.validate(id) orelse return null; + return self.bodies.items(.body_type)[idx]; + } + + /// Safe getter: the handle of the shape this body carries, or null if `id` is + /// stale/invalid. + /// + /// Named `shapeOf` and not `shape` because it returns a `ShapeId` and not a `Shape`, and + /// the two live one `store.get` apart. Exposed for the same reason as `bodyType` above: + /// §1.12.2 states that a character's presence carries the CONTROLLER'S OWN capsule and + /// never a second shape, and handle equality is the direct form of that statement. + pub fn shapeOf(self: *const BodyManager, id: BodyId) ?api.ShapeId { + const idx = self.alloc.validate(id) orelse return null; + return self.bodies.items(.shape)[idx]; + } + + /// Replace the shape a body carries, KEEPING its `BodyId`. Stale-safe no-op. + /// + /// **Restricted to a NON-DYNAMIC body, asserted.** `computeMotion` returns an inverse mass and + /// an inverse inertia of exactly zero for anything but `.dynamic`, INDEPENDENT of the shape, so + /// a swap leaves `motion` correct with nothing to recompute. On a dynamic body the same swap + /// would silently keep an inertia tensor belonging to the old geometry, so that case is refused + /// rather than half-handled. + /// + /// Added at M1.1.12 for `resizeCharacter`, which MUST keep the presence's handle (§1.12.2) — a + /// resize is not a re-creation, and an exclusion the caller memorised survives it. + /// Destroy-and-add is the only alternative and it changes the `BodyId`. + /// + /// **AND restricted to a CONVEX on BOTH sides, asserted.** The body-type assert alone was not + /// enough, and the two other categories break a different consequence each: a mesh carries a + /// CACHED `world_aabb` computed at `addBody`, which a swap between two meshes would leave holding + /// the old geometry's box; and a half-space lives OUTSIDE the broadphase trees (§1.11.15), so + /// swapping a convex for one leaves a leaf in a tree for a shape that has no box at all. Both + /// fall away by construction under the restriction — a convex's cached box is NaN and never read, + /// and two bounded shapes keep the proxy in the tree where it belongs. + /// + /// The restriction is not a gap: the only need is capsule → capsule on a kinematic presence, and + /// widening it later is purely additive. Saying what this does NOT do is the point — the previous + /// comment described a general shape swap and the code maintained two of its four consequences. + /// + /// NON-ACTIVATING, like the other write paths of §1.8.4: the wake the caller owes is composed by + /// the caller from what the new volume touches. + pub fn setShape(self: *BodyManager, store: *const ShapeStore, id: BodyId, shape_id: api.ShapeId) void { + const idx = self.alloc.validate(id) orelse return; + std.debug.assert(self.bodies.items(.body_type)[idx] != .dynamic); + const shape = store.get(shape_id) orelse return; + std.debug.assert(shape.class() == .convex); + if (store.get(self.bodies.items(.shape)[idx])) |old| { + std.debug.assert(old.class() == .convex); + } + self.bodies.items(.shape)[idx] = shape_id; + // The sleep radius is geometry-derived, so it is recomputed even though a non-dynamic body's + // is never read — a stale derived value is worse than a redundant assignment. + self.bodies.items(.sleep_radius)[idx] = body_mod.computeSleepRadius(shape); + } + /// Safe getter: the ECS entity owning this body, or null if `id` is /// stale/invalid. /// @@ -1102,6 +1166,102 @@ pub const BodyManager = struct { return single.manifold; } + /// Full narrowphase between a caller-supplied convex `probe` posed at + /// (`probe_position`, `probe_rotation`) and body `id`, calling + /// `collector.add(subshape_id, manifold)` for EVERY manifold the pair produces. Nothing + /// is called when the two are separated, or when the handle — or its shape — is + /// stale/invalid. + /// + /// **The seventh body-level adapter, and the one a VIRTUAL controller needs.** A + /// controller owns no body, so `collidePairEach` — which resolves both sides from + /// handles — cannot serve it at all. This is `castShapeBody`'s shape without the + /// direction: shape plus pose against a body, dispatched on the BODY's category alone, + /// the probe being a bounded convex by its type. + /// + /// **Only the general "Each" form exists.** There is deliberately no convenience + /// sibling asserting the body carries no sub-shape, the way `collidePair` wraps + /// `collidePairEach`: the controller is this entry's only consumer and it needs the + /// general form, a mesh floor being exactly the case it cannot afford to lose. Such a + /// wrapper is purely additive — zero call sites to touch — so the deferral rule lets it + /// wait for a caller that wants it. + /// + /// **Normal orientation: probe → body.** The probe is A and the body is B, in all three + /// arms. The half-space arm computes body→probe, §1.11.15's formulas being stated with + /// the plane as A, and negates; the mesh arm passes `mesh_is_a = false` so the shared + /// helper hands `collideOrdered` its arguments in that same order and needs no mirroring. + /// + /// **No `back_face_mode` parameter, and that is a decision rather than an omission.** + /// Contact generation culls back faces unconditionally: the mode is a solver-internal + /// setting and not part of the frozen surface (§1.11.17), because a contact generated on + /// the back of a wall pushes the body through it. The mesh arm therefore inherits both + /// the cull and the internal-edge correction from `collideConvexMesh` — which is the + /// whole reason this entry reuses that helper instead of walking the mesh itself. + /// + /// Internal: no interface entry corresponds to it, and it knows nothing about + /// characters. Self-exclusion belongs to the controller — the broadphase will offer a + /// character its own presence among the candidates of its own sweeps, and filtering that + /// out is the controller's business, not this adapter's. + pub fn collideShapeBody( + self: *const BodyManager, + store: *const ShapeStore, + id: BodyId, + probe: narrowphase.SupportShape(Real), + probe_position: Vec3r, + probe_rotation: Quatr, + collector: anytype, + ) void { + const idx = self.alloc.validate(id) orelse return; + const shape = store.get(self.bodies.items(.shape)[idx]) orelse return; + const body_position = self.bodies.items(.position)[idx]; + const body_rotation = self.bodies.items(.rotation)[idx]; + + // Exhaustive on the body's class, no `else` — a fourth category is a compile error + // here and owes its own decision (§1.11.15, §1.11.17). + switch (shape.class()) { + .convex => { + const m = narrowphase.collideOrdered( + Real, + probe, + probe_position, + probe_rotation, + shape_mod.supportShape(shape), + body_position, + body_rotation, + ) orelse return; + collector.add(0, m); + }, + .half_space => { + var m = narrowphase.collidePlane( + Real, + shape_mod.halfSpace(shape), + body_position, + body_rotation, + narrowphase.RelativePose(Real).init( + body_position, + body_rotation, + probe_position, + probe_rotation, + ), + probe, + ) orelse return; + m.normal = m.normal.neg(); // computed body→probe; the caller asked probe→body + collector.add(0, m); + }, + // SEVERAL manifolds, one per contacting triangle — the one shape change a mesh + // imposes, and the reason this entry has no single-manifold form. + .triangle_soup => self.collideConvexMesh( + probe, + probe_position, + probe_rotation, + shape.mesh.?, + body_position, + body_rotation, + false, // the mesh is B, so the normal comes out probe→body already + collector, + ), + } + } + /// `collidePairEach` for a fixed (already-canonical body-id) order — validates both /// handles/shapes then runs the manifold pipeline in THIS order. Calls `collideOrdered` /// (not `collide`): `collide` would re-canonicalize by pose, so the `feature_id` diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig new file mode 100644 index 0000000..789bed3 --- /dev/null +++ b/src/modules/forge/forge_3d/character.zig @@ -0,0 +1,1946 @@ +//! `forge_3d/character.zig` — the kinematic character controller's store +//! (`engine-physics-forge.md` §1.12). +//! +//! **A controller is VIRTUAL and carries no simulated body.** It takes part in no solver +//! pass — no inverse mass, no inertia tensor, no contact constraint, no island membership — +//! and no impulse is ever applied to it. It appears in no step of the normative per-tick +//! cycle (§1.7), at the same title as a query (§1.11.1): the gameplay calls it, at whatever +//! frequency the gameplay chooses. +//! +//! What it DOES carry is a **presence**: a `.kinematic` body holding the controller's own +//! capsule, so the controller is visible to queries (§1.12.2). Without one, `collision_layer` +//! — the mechanism by which an object declares itself visible to other callers' queries +//! (§1.11.5) — would be a field with no observable effect. The presence is optional per +//! character through `CharacterDescriptor.inner_body`, whose default is `true`. +//! +//! **This milestone delivers the STORE and nothing that moves.** `moveCharacter`, the ground +//! verdict and its five quantities, `resizeCharacter`, the push and `setCharacterPosition` +//! belong to later gates of M1.1.12; none of their state is declared here, so that a field +//! arrives with the code that fills it. +//! +//! **No cache of any kind, and that is written down so the next sub-milestone does not +//! reopen it** (§1.12.8). The M1.1.11.1 `Body.world_aabb` pattern does not transfer: +//! nothing expensive is pose-invariant for a character, a capsule's support shape being two +//! scalars. + +const std = @import("std"); +const api = @import("weld_forge"); +const config = @import("config.zig"); +const shape_mod = @import("shape.zig"); +const body_manager_mod = @import("body_manager.zig"); +const broadphase_mod = @import("pipeline/broadphase.zig"); +const narrowphase = @import("pipeline/narrowphase/root.zig"); +const IdAllocator = @import("slot_alloc.zig").IdAllocator; +const math = @import("foundation").math; + +const Real = config.Real; +const Vec3r = config.Vec3r; +const Quatr = config.Quatr; +const ShapeStore = shape_mod.ShapeStore; +const BodyManager = body_manager_mod.BodyManager; +const Broadphase = broadphase_mod.Broadphase(Real); +const Ray = broadphase_mod.Ray(Real); +const SupportShape = narrowphase.SupportShape(Real); +const ContactManifold = narrowphase.ContactManifold(Real); +const BodyId = api.BodyId; +const ShapeId = api.ShapeId; +const CharacterId = api.CharacterId; +const CharacterDescriptor = api.CharacterDescriptor; +const EntityId = api.EntityId; +const GroundState = api.GroundState; + +/// The engine's up axis. `+Y` (`engine-coordinate-system.md`), which is also why +/// `CharacterDescriptor` carries no rotation field — the capsule is symmetric about it. +/// +/// Named once here rather than written as a literal at each of the five sites that need it, +/// so "up" is one thing and not five agreeing by luck. +const up: Vec3r = Vec3r.unit_y; + +/// Every way a `CharacterDescriptor` can be malformed, plus the stale handle — each refused +/// by its own typed error and NEVER sanitised. +/// +/// The reason no value is ever clamped into range: silently clamping would make a caller's +/// mistake look like a modelling choice, and the caller would have no diagnostic at all. +/// `max_slope` is the case that makes this concrete — a value of `π` is a REJECTION, not a +/// clamp to `π/2`. +/// +/// One error per malformation class, on the `MeshError` precedent (§1.11.17): a caller +/// fixing a bug wants to know which field, and a single `error.InvalidDescriptor` would +/// send it reading all of them. +pub const CharacterError = error{ + /// A length is out of domain: `radius` or `height` non-finite or not strictly positive, + /// `height` less than twice `radius` (which asks for a negative cylinder half-height), or + /// `predictive_contact_distance` non-finite or negative. + InvalidDimensions, + /// `max_slope` is non-finite or outside `[0, π/2]`. + InvalidSlope, + /// `padding` is non-finite or negative. + InvalidPadding, + /// `mass` is non-finite or not strictly positive, or `max_push_force` is non-finite or + /// negative. + InvalidPushParameters, + /// `collision_layer` is outside `[0, collision_layer_count)`. + InvalidCollisionLayer, + /// The handle is stale: its slot was freed, or its generation does not match. + StaleCharacter, +}; + +/// One stored controller. Authored parameters at solver precision, plus the two handles the +/// store owns the lifetime of. +/// +/// **`position` is the BASE of the capsule, never its centre** (§1.12.3). This is not an +/// implementation detail: `CharacterMoveResult.position` is written into `Transform.position` +/// by gameplay and every probe of the caller starts there, while a body's pose is the CENTRE +/// of its shape — the capsule being symmetric about the origin. The offset between the two is +/// `baseToCentre`, and it exists in exactly that one named place. +/// +/// **`max_slope` is stored as its COSINE**, computed once at creation. Not a +/// micro-optimisation: an `acos` per contact per frame is precisely what M1.1.14 would have +/// to make reproducible, `engine-phase-1-plan.md` naming internal trigonometric functions +/// among its determinism hazards, and storing the cosine moves the single trigonometric call +/// to creation time (§1.12.5). +pub const Character = struct { + /// Owning ECS entity — the identity queries report, and the one the presence carries too. + entity: EntityId, + /// World-space BASE of the capsule (metres). + position: Vec3r, + /// Capsule radius (metres). + radius: Real, + /// Total capsule height, base to top (metres). + height: Real, + /// Tallest riser the controller climbs rather than being blocked by (metres). Consumed + /// at gate E. + step_height: Real, + /// `cos(max_slope)`. The ground test is `n · up >= cos_max_slope`, so a LARGER cosine is + /// a STRICTER slope limit — worth knowing before comparing two of these. + cos_max_slope: Real, + /// Distance the capsule is held off surfaces (metres). A named PHYSICAL parameter of the + /// class of `restitution_threshold` and `penetration_slop`, not a numerical tolerance: + /// §1.11.2's `k · floatEps(T) · coordScale` discipline does NOT govern it. + padding: Real, + /// How far outside the shape to sweep for contacts not yet touching (metres). Same + /// parameter class as `padding`. Its FIRST consumer is the ground probe of gate C, which + /// bounds its downward sweep at `padding + predictive_contact_distance`. + predictive_contact_distance: Real, + /// What the character IS, read by others through the mask of THEIR queries. Also the + /// presence's layer, with no dedicated field (§1.12.2). + collision_layer: u8, + /// What the character SEES, read by itself alone in its own sweeps (§1.12.4). Consumed + /// from gate C. + layer_mask: u32, + /// Mass serving the push impulse (kg). Consumed at gate F. + mass: Real, + /// Ceiling on the push force (N); zero disables pushing. Consumed at gate F. + max_push_force: Real, + /// The capsule in the `ShapeStore`, owned by this character for its whole life. + shape: ShapeId, + /// The presence, or null when the descriptor asked for none. Its `BodyId` is STABLE and + /// stays so across a resize (gate F): a resize is not a re-creation, and an exclusion + /// the caller memorised survives it. + inner_body: ?BodyId, + /// The ground verdict LAST REPORTED by a `moveCharacter` (§1.12.8). + /// + /// `setCharacterPosition` INVALIDATES it back to `.in_air`, because a teleport moves the + /// character somewhere the previous verdict describes nothing about — and `.in_air` is the safe + /// failure direction, one tick of gravity rather than a floating character. The poisoning + /// discipline of §1.11.17 does not apply: `GroundState` has three values that four documents + /// share, and inventing a fourth to mean "unknown" would cost more than the safe direction + /// earns. + reported_ground: GroundState = .in_air, + /// The presence's broadphase proxy, once its owner has registered it through + /// `setPresenceProxy`. + /// + /// `createCharacter` cannot insert the proxy itself: the broad layer is an INSERTION ARGUMENT + /// and no `BodyType → BroadphaseLayer` wiring exists — that arrives with `PhysicsWorld` at + /// M1.1.15. So whoever owns the broadphase inserts, then hands the handle back here, and from + /// then on every pose write keeps it fresh. Null means "nobody registered one", and a pose + /// write then updates the body and not the tree. + presence_proxy: ?Broadphase.Proxy = null, +}; + +/// The offset from a character's BASE to the CENTRE of its capsule — half the height along +/// up, `up` being `+Y` (`engine-coordinate-system.md`). +/// +/// **THE one named place this offset exists.** Computed a second time somewhere else, it will +/// disagree with this one exactly once, and the symptom is a character standing half its +/// height into the floor or above it. `engine-movement.md`'s former ground raycast implied +/// the base while `BodyDescriptor` means the centre, which is how a half-height discrepancy +/// hides in plain sight. +/// +/// Generic over the scalar because both precisions need it: the `BodyDescriptor` the presence +/// is created from is `f32` (§1.12.11), while every later pose write is at `Real`. That is +/// safe rather than merely convenient — halving is exact in binary floating point, and +/// widening is exact, so `widen(h_f32 * 0.5)` and `widen(h_f32) * 0.5` are the same number. +pub fn baseToCentre(comptime T: type, height: T) math.Vec(3, T) { + return math.Vec(3, T).unit_y.scale(height * 0.5); +} + +/// The cylinder half-height of a capsule of total `height` and `radius`: the capsule spans +/// `2 · half_height + 2 · radius` along Y, so `half_height = height/2 − radius`. +/// +/// Its non-negativity is why `height >= 2 · radius` is a domain condition and not a +/// suggestion — a smaller height describes no capsule at all. +pub fn capsuleHalfHeight(comptime T: type, radius: T, height: T) T { + return height * 0.5 - radius; +} + +/// Reject a malformed descriptor, allocating nothing and mutating nothing. +/// +/// Called FIRST by `createCharacter`, before its first allocation, on the `MeshData.init` +/// precedent: a typed refusal must not have allocated. The order of the checks is readable +/// rather than normative — unlike `addBody`'s, whose ordering is load-bearing because the +/// body literal derives quantities from the local AABB. +/// +/// **THIRTEEN guards, and the rule that decides which conditions earn one**: a value the code +/// would ACCEPT and that produces silently wrong geometry or dynamics is a domain error, and +/// refusing it costs one comparison. Non-finiteness is not the whole class — a finite value in +/// the wrong half of the line does the same damage without being detectable downstream. +fn validateDescriptor(desc: CharacterDescriptor) CharacterError!void { + if (!std.math.isFinite(desc.radius) or desc.radius <= 0) return error.InvalidDimensions; + if (!std.math.isFinite(desc.height) or desc.height <= 0) return error.InvalidDimensions; + // A capsule shorter than its own diameter is not a capsule. Refused rather than clamped + // to a sphere, which would silently deliver geometry the caller did not ask for. + if (desc.height < 2 * desc.radius) return error.InvalidDimensions; + + // `[0, π/2]`, and `π` is the case that has to fail: a slope limit beyond vertical is + // meaningless, and clamping it would read as a modelling choice. + if (!std.math.isFinite(desc.max_slope)) return error.InvalidSlope; + if (desc.max_slope < 0 or desc.max_slope > std.math.pi / 2.0) return error.InvalidSlope; + + if (!std.math.isFinite(desc.padding)) return error.InvalidPadding; + // A NEGATIVE padding inflates the capsule INWARD: the character sinks `|padding|` into + // every surface it stands on, and nothing anywhere reports it. Zero is legal — no margin. + if (desc.padding < 0) return error.InvalidPadding; + // **AND NO UPPER BOUND, which was ordered, written, then MEASURED AWAY.** A `padding >= radius` + // guard shipped for one round on two motives and neither survived. The freeze motive fell first: + // a large `padding` does stop the character short of an obstacle, but that IS the declared + // stand-off being honoured — a character demanding 0.3 m of clearance cannot approach a riser + // closer than 0.3 m. What remained was that a push larger than the shape might carry the capsule + // THROUGH thin geometry. Measured across fourteen configurations — `padding` at `2 · radius` and + // at `3.3 · radius`, against a 0.1 m wall, at seven entry depths straddling its mid-plane — and + // the capsule exits on the side it entered from EVERY time: the push-out invariant reverts to the + // entry pose the moment it finds a plane the base has crossed, so a larger push goes further out + // and never through. + // + // So the guard rejected valid descriptors and valid resizes on no basis, and it is gone. Left as a + // comment because a guard removed by measurement is worth more to the next reader than its absence. + + if (!std.math.isFinite(desc.mass)) return error.InvalidPushParameters; + // Zero would DUPLICATE `max_push_force = 0`, which is the documented way to disable + // pushing — two ways to express one thing is the duplication class refused elsewhere in + // this module. Negative INVERTS the impulse: the character pulls instead of pushing. + if (desc.mass <= 0) return error.InvalidPushParameters; + + if (!std.math.isFinite(desc.max_push_force)) return error.InvalidPushParameters; + // A negative force ceiling has no meaning; zero is the disabler. + if (desc.max_push_force < 0) return error.InvalidPushParameters; + + // `step_height` is a SWEEP DISTANCE — `tryStepUp`'s lift and `stepDown`'s probe both pass it + // straight to `sweepNearest` — and every other stored physical parameter here is guarded, so its + // absence was an omission and not a decision. A NaN reaches the cast kernel's own domain assert, + // which holds in Debug only; a negative value would ask for a sweep backwards. Zero is legal and + // is the disabler the suite already exercises: no climb, no floor-sticking. + if (!std.math.isFinite(desc.step_height) or desc.step_height < 0) return error.InvalidDimensions; + + // Guarded even though no algorithm consumes it yet, because it is STORED at solver + // precision: a NaN entered by the caller would live in the store, indistinguishable from + // the DELIBERATE poison NaN this repository writes on purpose into fields that have no + // meaning for a shape. That ambiguity is what cost M1.1.11.1 several rounds — not the NaN + // itself. If gate D deletes the field, this guard leaves with it: one line. + if (!std.math.isFinite(desc.predictive_contact_distance) or + desc.predictive_contact_distance < 0) return error.InvalidDimensions; + + // A TYPED error and not an assert, for the same reason `addBody` uses one: the query + // mask is 32 bits, so a character declared past that domain would be invisible to every + // query with no diagnostic at all (§1.11.5, §1.12.4). + if (desc.collision_layer >= api.collision_layer_count) return error.InvalidCollisionLayer; +} + +/// The ground verdict and its four companion quantities, at SOLVER precision — the internal +/// mirror of `CharacterMoveResult`'s five `ground_*` fields (§1.12.5). Narrowing it to the +/// public `f32` form is the interface tier's business at M1.1.15; nothing here does it. +/// +/// **Every default is the `.in_air` answer**, so that state is the struct's zero value rather +/// than something the code has to remember to write. `.in_air` is the safe failure direction: +/// a `.grounded` default on an unknown verdict means gravity not applied, hence a character +/// that floats, a symptom that does not correct itself. +/// +/// `normal` is `up` on `.in_air` and NEVER a poisoned value — three documents read that field +/// inside a `@replicated` component and a NaN would cross the rollback. The two support +/// handles carry their explicit "no handle" sentinels instead. +pub const GroundInfo = struct { + /// The VERDICT. A direction does not mix into it. + state: GroundState = .in_air, + /// Outward normal of the winning surface, pointing FROM the surface TOWARD the character. + normal: Vec3r = up, + /// Entity of the support. NON-NULL on `.on_steep_ground` as much as on `.grounded` — a + /// steep slope is still a support — and `EntityId.dead` only on `.in_air`. + entity: EntityId = EntityId.dead, + /// Body of the support, `PackedId.dead` on `.in_air`. + body: BodyId = api.PackedId.dead, + /// Velocity AT THE CONTACT POINT, `v + ω × r`. Zero on `.in_air`. + velocity: Vec3r = Vec3r.zero, +}; + +/// One surface that qualified as ground, before the winner is chosen. +const Candidate = struct { + body: BodyId, + subshape_id: u32, + /// Outward, surface → character. + normal: Vec3r, + /// World contact point, from which the lever arm of `ground_velocity` is built. + point: Vec3r, + /// `normal · up`, the selection key. Cached because it decides both the winner and the + /// verdict, and recomputing it is how the two would come to disagree. + align_up: Real, +}; + +/// Gathers the ground candidates of one downward sweep and keeps the winner. +/// +/// **The bound NEVER tightens**, unlike the query family's `closest` collector: the winner is +/// the FLATTEST surface within the band and not the nearest one, so a nearer steeper contact +/// must not prune a flatter one behind it. That is what lets a character straddling an edge +/// stand on the walkable face (§1.12.5). +const GroundCollector = struct { + bm: *const BodyManager, + store: *const ShapeStore, + /// The character's capsule, as a support shape. + probe: SupportShape, + /// World CENTRE of that capsule — the pose the sweep starts from, base plus the offset. + centre: Vec3r, + /// How far down to look. Covers `padding` by construction; see `groundSweepDistance`. + max_sweep: Real, + /// What the character SEES (§1.12.4). + layer_mask: u32, + /// The character's OWN presence, excluded from its own sweeps. Self-exclusion is + /// UNILATERAL: no other character's presence is excluded, which is what gives + /// character-versus-character collision for free (§1.12.2). + exclude: ?BodyId, + best: ?Candidate = null, + /// Whether any contact at all faced upward — the difference between `.on_steep_ground` + /// and `.in_air` once no candidate passes the slope test. + any_candidate: bool = false, + + pub fn add(self: *GroundCollector, user_data: u32) void { + const body: BodyId = user_data; + if (self.exclude) |own| { + if (own == body) return; + } + // The layer getter answers staleness too: a freed handle has no layer. + const layer = self.bm.collisionLayer(body) orelse return; + if ((@as(u32, 1) << @intCast(layer)) & self.layer_mask == 0) return; + + const hit = self.bm.castShapeBody( + self.store, + body, + self.probe, + self.centre, + Quatr.identity, + up.neg(), + self.max_sweep, + .ignore, + ) orelse return; + + // **THE TWO PATHS, and the whole reason gate B delivered two entries.** + // + // A sweep that TRAVELLED returns the outward normal of the surface it met + // (§1.11.11), which is exactly what `ground_normal` means — no sign work at all. + // + // At distance ZERO the capsule already overlaps, and the cast's normal is + // `−direction`, i.e. `+up` (§1.11.4). That value is not wrong — it preserves + // `normal · direction <= 0` — it is UNUSABLE: on a slope it would answer "perfectly + // horizontal" and a slope test against it would always pass. So the normal comes from + // the manifold at the current pose instead, which carries a real surface. + if (hit.distance > 0) { + self.consider(body, hit.subshape_id, hit.normal, hit.position); + return; + } + var sink = ManifoldSink{ .ground = self, .body = body }; + self.bm.collideShapeBody(self.store, body, self.probe, self.centre, Quatr.identity, &sink); + } + + /// Offer one surface to the selection. + fn consider(self: *GroundCollector, body: BodyId, subshape_id: u32, normal: Vec3r, point: Vec3r) void { + const align_up = normal.dot(up); + // A candidate is a contact whose normal has a STRICTLY positive component on up. A + // perfectly vertical wall is therefore never ground, whatever `max_slope` says — which + // is deliberate: `cos(π/2)` is zero to float noise, so admitting `align_up == 0` would + // make "can I stand on this wall" depend on that noise. + if (align_up <= 0) return; + self.any_candidate = true; + + if (self.best) |b| { + // FLATTEST wins, not nearest. + if (align_up < b.align_up) return; + // Exact tie: the smaller `(BodyId, subshape_id)`, which is §1.11.14's total order. + // Without it the answer would follow the traversal order, hence the tree's shape. + if (align_up == b.align_up) { + if (body > b.body) return; + if (body == b.body and subshape_id >= b.subshape_id) return; + } + } + self.best = .{ + .body = body, + .subshape_id = subshape_id, + .normal = normal, + .point = point, + .align_up = align_up, + }; + } + + /// Never tightens — see the type doc. + pub fn maxDistance(self: *const GroundCollector) Real { + return self.max_sweep; + } + + /// Never stops early: the flattest surface is only known once the walk is done. + pub fn shouldStop(_: *const GroundCollector) bool { + return false; + } +}; + +/// Feeds every manifold of one body into the ground selection. +/// +/// **THE one sign negation of this module.** `collideShapeBody` returns the normal +/// probe → body: for a character on a floor it points from the capsule DOWN toward the floor. +/// `ground_normal` is what the caller reads to know which way is up the slope, so it points +/// from the surface TOWARD the character. Hence one negation, in this one named place — the +/// class of error that cost M1.1.11.1 a spec correction on its own overlap predicate. +const ManifoldSink = struct { + ground: *GroundCollector, + body: BodyId, + + pub fn add(self: *ManifoldSink, subshape_id: u32, manifold: ContactManifold) void { + // The DEEPEST point represents the manifold, which is the convention §3 already uses + // when it maps a manifold onto the single `contact_point` of a gameplay event. + var deepest = manifold.points[0]; + for (manifold.points[1..manifold.count]) |p| { + if (p.penetration > deepest.penetration) deepest = p; + } + // The SAME single negation the ground probe makes: `collideShapeBody` returns probe → body, + // and every consumer here wants surface → character. + const outward = manifold.normal.neg(); + // **THE MANIFOLD POINT IS THE MIDPOINT OF THE TWO SURFACE POINTS, NOT THE GROUND'S SURFACE**, + // and `ground_velocity` is read at the point the CHARACTER stands on. For a capsule sunk `p` + // into a floor the midpoint sits at `−p/2` while the body's surface is at `0`, so the lever + // arm was short by half the penetration and a rotating platform reported a velocity taken + // half a penetration inside itself. Moved out along the outward normal by `p/2`, which is + // the same reconstruction `prepare` performs on the same field (§1.7.2). + const surface = deepest.position.add(outward.scale(deepest.penetration / 2)); + self.ground.consider(self.body, subshape_id, outward, surface); + } +}; + +/// How far down the ground probe looks: `padding + predictive_contact_distance`. +/// +/// **This is `predictive_contact_distance`'s FIRST consumer**, which settles in advance the +/// question the brief left to gate D. The two terms are the two reasons the ground is not at +/// distance zero when a character rests on it: `padding` is how far the capsule is held OFF +/// surfaces, so a resting character is at least that far above its floor; and +/// `predictive_contact_distance` is, by its own definition, how far outside the shape to look +/// for contacts not yet touching. Their sum is exactly the band in which "the ground I am +/// standing on" is a meaningful question. +/// +/// The bound has to be SMALL, and that is what makes "flattest wins" well posed rather than +/// absurd: within 12 cm at the defaults, every candidate is genuinely underfoot, so preferring +/// the flatter of two is choosing a face of the ground. Over metres it would let a distant +/// flat floor outrank the steep slope actually under the character. +pub fn groundSweepDistance(c: Character) Real { + return c.padding + c.predictive_contact_distance; +} + +// --- The move (M1.1.12 gate D) --- + +/// How many times the slide loop may sweep before giving up. NAMED and mandatory: M1.1.14 forbids +/// an unbounded loop on a path that must be reproducible, and the reference's own controller +/// carries the same kind of ceiling. +/// +/// Exhausting it stops the character SHORT of where it asked to go — it never moves further. That +/// is the safe failure direction, the same one §1.11.11 chose for the cast kernel: a character that +/// does not finish its step is a visible stutter, a character that finishes it through a wall is a +/// hole in the world. +pub const max_slide_iterations: u32 = 4; + +/// The multiplier of the DEPENETRATION STAND-OFF FLOOR, and it must exceed +/// `narrowphase.contact_margin_conv_k` (16) rather than merely equal it: the floor exists to put the +/// capsule OUT of the contact margin, not to place it on the margin's edge. +/// +/// **THIS ONE IS A NUMERICAL TOLERANCE AND §1.11.2 GOVERNS IT — the opposite of every other constant +/// in this file, and worth saying because this module spends its length saying the opposite.** +/// `padding`, `max_slope` and `predictive_contact_distance` are named PHYSICAL parameters that select a +/// modelling behaviour, and the `k · floatEps(T) · coordScale` discipline deliberately does not reach +/// them. This floor selects nothing: it absorbs float noise, and it is written in that discipline's own +/// form for that reason. +const standoff_floor_k: Real = 64; + +/// How many times depenetration may push before giving up. Same ceiling discipline, same safe +/// failure direction: a character left slightly overlapping is corrected next call, a character +/// pushed by an unbounded loop is a hang. +pub const max_depenetration_iterations: u32 = 4; + +/// Bodies whose wake this call owes. The bound is EXACT rather than a guess, and it is accounted +/// sweep by sweep: `max_depenetration_iterations` pushes, `max_slide_iterations` slide sweeps, the +/// THREE sweeps of the single step-up attempt (lift, forward, land), and the one step-down sweep. +/// A step is attempted at most once per call, which is what keeps this a small constant. +const max_touched = max_depenetration_iterations + max_slide_iterations + 3 + 1; + +/// The bodies one move touched, accumulated rather than woken on the spot. +/// +/// The collectors hold a `*const BodyManager` — `castShapeBody` and `collideShapeBody` both take +/// one — so nothing inside them can wake anything. The same ordering M1.1.11.1 was forced into when +/// its wake moved after `prepare`, and for the same reason. +const TouchedBodies = struct { + items: [max_touched]BodyId = @splat(0), + len: u32 = 0, + + fn add(self: *TouchedBodies, body: BodyId) void { + // The capacity is EXACT, one slot per iteration of each bounded phase, so this cannot + // overflow. Asserted rather than silently clamped: a DROPPED wake is precisely the defect + // W4 exists to prevent, so it must be loud and not absorbed. + std.debug.assert(self.len < max_touched); + if (self.len >= max_touched) return; + self.items[self.len] = body; + self.len += 1; + } + + fn slice(self: *const TouchedBodies) []const BodyId { + return self.items[0..self.len]; + } +}; + +/// The pushes one move owes, accumulated rather than applied on the spot. +/// +/// **Applied AFTER the publication, for the reason the wake already is.** `syncPresenceTo` can still +/// fail after the slide loop has run, and a failure returns an error with the record deliberately +/// intact — so a push applied inside the loop survived a call that reported having done nothing, and +/// the caller's retry applied it a second time. Held here and drained beside the `wakeBody` loop, +/// which sits post-publication for exactly that reason. +/// +/// The bound is `max_touched` and it is EXACT, not a guess: a push can only target a body the move +/// TOUCHED, and only the dynamic ones that yield, so this set is a subset of one already bounded +/// slot for slot. Asserted rather than clamped — a dropped push is a silent divergence between what +/// the character resolved against and what the world did. +/// +/// **COALESCED BY BODY, and the first version was not — which broke the force ceiling.** `add` used +/// to append unconditionally and `apply` called `addImpulse` once per entry, each entry capped at +/// `max_push_force · dt`. So two contacts on one body in one call delivered TWICE the declared +/// ceiling, three would deliver three times, and nothing bounded the factor. A ceiling one can exceed +/// by being touched twice is not a ceiling — `max_push_force` is documented in newtons, and the +/// per-entry cap being "unchanged" WAS the defect rather than a mitigation of it. +/// +/// Summing per body and capping the sum ONCE closes three things at once: the ceiling means what it +/// says, the atomicity is untouched since nothing leaves before the publication, and the answer stops +/// depending on how many contacts the sweep happened to make against one body — an iteration detail, +/// not a physical quantity. +const PendingPushes = struct { + const Entry = struct { body: BodyId, impulse: Vec3r }; + + items: [max_touched]Entry = @splat(.{ .body = 0, .impulse = Vec3r.zero }), + len: u32 = 0, + + /// Accumulate onto `body`'s entry if it already has one. The scan is linear over at most + /// `max_touched` entries, which is twelve — a hash container on a path M1.1.14 must make + /// reproducible would be the wrong trade even if the set were large. + fn add(self: *PendingPushes, body: BodyId, impulse: Vec3r) void { + for (self.items[0..self.len]) |*e| { + if (e.body == body) { + e.impulse = e.impulse.add(impulse); + return; + } + } + std.debug.assert(self.len < max_touched); + if (self.len >= max_touched) return; + self.items[self.len] = .{ .body = body, .impulse = impulse }; + self.len += 1; + } + + /// One impulse per body, its MAGNITUDE capped at `max_impulse = max_push_force · dt`. + /// + /// The division is reached only when `mag_sq > max_impulse²`, which forces `mag_sq > 0` since the + /// right-hand side is non-negative — so the true-zero guard is structural here and no epsilon is + /// invented for it. + fn apply(self: *const PendingPushes, bm: *BodyManager, max_impulse: Real) void { + for (self.items[0..self.len]) |e| { + var impulse = e.impulse; + const mag_sq = impulse.lengthSq(); + if (mag_sq > max_impulse * max_impulse) { + impulse = impulse.scale(max_impulse / @sqrt(mag_sq)); + } + // ACTIVATING by contract (§1.8.4) — an external mutation, and W1 rather than W4. + bm.addImpulse(e.body, impulse); + } + } +}; + +/// What one `moveCharacter` returns, at solver precision. +/// +/// **No remaining displacement and no collision counter.** A caller that wants to know whether it +/// was blocked compares the position it asked for against the one it got — a caller-side +/// derivation, not an engine quantity, and precisely why the former `collisions` field was deleted +/// from the frozen `CharacterMoveResult`. +pub const MoveResult = struct { + /// The resolved BASE position (§1.12.3) — what gameplay writes into `Transform.position`. + position: Vec3r, + /// The ground verdict at that NEW pose, by the same probe gate C delivered. + ground: GroundInfo, +}; + +/// One contact the move must react to: where it is and which way the surface faces. +const Contact = struct { + body: BodyId, + /// Outward, surface → character — the same orientation `GroundInfo.normal` carries. + normal: Vec3r, + /// Overlap along `normal`, for the depenetration push. Zero for a swept contact. + penetration: Real = 0, + /// The manifold point, which is the MIDPOINT of the two surface points — so the body's own + /// surface lies half the penetration further along `normal`. Zero for a swept contact, which + /// has no manifold; only the depenetration reads it. + position: Vec3r = Vec3r.zero, +}; + +/// Nearest swept contact over every candidate body, with the character's own filter. +/// +/// Tightens its bound TO each accepted distance, like the query family's `closest` — here that IS +/// correct, unlike the ground probe's: a nearer surface genuinely stops the motion sooner, so +/// pruning what is behind it loses nothing. +const SweepCollector = struct { + bm: *const BodyManager, + store: *const ShapeStore, + probe: SupportShape, + origin: Vec3r, + direction: Vec3r, + bound: Real, + layer_mask: u32, + exclude: ?BodyId, + /// A SECOND body to ignore, used by the slide loop for a contact it has established does not + /// oppose the motion. See `sweepNearest`. + exclude_also: ?BodyId = null, + best: ?struct { + body: BodyId, + subshape_id: u32, + distance: Real, + normal: Vec3r, + } = null, + + pub fn add(self: *SweepCollector, user_data: u32) void { + const body: BodyId = user_data; + if (self.exclude) |own| { + if (own == body) return; + } + if (self.exclude_also) |other| { + if (other == body) return; + } + const layer = self.bm.collisionLayer(body) orelse return; + if ((@as(u32, 1) << @intCast(layer)) & self.layer_mask == 0) return; + + const hit = self.bm.castShapeBody( + self.store, + body, + self.probe, + self.origin, + Quatr.identity, + self.direction, + self.bound, + .ignore, + ) orelse return; + + if (self.best) |b| { + if (hit.distance > b.distance) return; + // The same total order the ground selection uses, for the same reason: without it the + // answer would follow the traversal, hence the tree's shape. + if (hit.distance == b.distance) { + if (body > b.body) return; + if (body == b.body and hit.subshape_id >= b.subshape_id) return; + } + } + self.best = .{ + .body = body, + .subshape_id = hit.subshape_id, + .distance = hit.distance, + .normal = hit.normal, + }; + // Tightened TO, not below, so an equal distance still reaches the tie-break. + self.bound = hit.distance; + } + + pub fn maxDistance(self: *const SweepCollector) Real { + return self.bound; + } + + pub fn shouldStop(_: *const SweepCollector) bool { + return false; + } +}; + +/// The DEEPEST manifold of one body against the probe — the depenetration push direction, and the +/// slide normal when a sweep reports distance zero. +const DeepestManifold = struct { + best: ?Contact = null, + body: BodyId, + + pub fn add(self: *DeepestManifold, subshape_id: u32, manifold: ContactManifold) void { + _ = subshape_id; + var deepest = manifold.points[0]; + for (manifold.points[1..manifold.count]) |p| { + if (p.penetration > deepest.penetration) deepest = p; + } + if (self.best) |b| { + if (deepest.penetration <= b.penetration) return; + } + // The SAME single negation the ground probe makes: `collideShapeBody` returns probe → body, + // and every consumer here wants surface → character. + self.best = .{ + .body = self.body, + .normal = manifold.normal.neg(), + .penetration = deepest.penetration, + .position = deepest.position, + }; + } +}; + +/// The deepest overlap over every candidate body — what depenetration pushes out of first. +const WorstOverlap = struct { + bm: *const BodyManager, + store: *const ShapeStore, + probe: SupportShape, + centre: Vec3r, + layer_mask: u32, + exclude: ?BodyId, + best: ?Contact = null, + + pub fn add(self: *WorstOverlap, user_data: u32) void { + const body: BodyId = user_data; + if (self.exclude) |own| { + if (own == body) return; + } + const layer = self.bm.collisionLayer(body) orelse return; + if ((@as(u32, 1) << @intCast(layer)) & self.layer_mask == 0) return; + + var one = DeepestManifold{ .body = body }; + self.bm.collideShapeBody(self.store, body, self.probe, self.centre, Quatr.identity, &one); + const c = one.best orelse return; + if (self.best) |b| { + if (c.penetration < b.penetration) return; + // Exact tie on depth: the smaller `BodyId`, so the push is not a function of the + // traversal order. + if (c.penetration == b.penetration and body >= b.body) return; + } + self.best = c; + } +}; + +/// The real outward normal at a swept contact. +/// +/// **The distance-zero trap, met a second time.** A sweep that TRAVELLED returns the surface's own +/// outward normal (§1.11.11), already the orientation every consumer here wants. At distance ZERO +/// it returns `−direction` — the direction travelled FROM, not the surface — which preserves +/// `normal · direction <= 0` and is nonetheless useless for sliding: on a slope it would answer +/// "perfectly horizontal". So the normal comes from the MANIFOLD instead, exactly as the ground +/// probe does, and null when even that finds nothing. +fn slideNormal( + bm: *const BodyManager, + store: *const ShapeStore, + probe: SupportShape, + centre: Vec3r, + body: BodyId, + distance: Real, + swept_normal: Vec3r, +) ?Vec3r { + if (distance > 0) return swept_normal; + var deepest = DeepestManifold{ .body = body }; + bm.collideShapeBody(store, body, probe, centre, Quatr.identity, &deepest); + const c = deepest.best orelse return null; + return c.normal; +} + +/// Remove the component of `motion` that goes INTO the surface whose outward normal is `normal`. +/// +/// The normal points from the surface toward the character, so moving into it is a NEGATIVE dot +/// product; subtracting that component zeroes it exactly and leaves the tangential part untouched. +/// Zeroing both would pass a "the normal component is zero" test and be wrong, which is why the +/// wall test asserts the tangential part against a closed form as well. +fn slideAlongPlane(motion: Vec3r, normal: Vec3r, cos_max_slope: Real) Vec3r { + const into = motion.dot(normal); + if (into >= 0) return motion; // already leaving the surface + const slid = motion.sub(normal.scale(into)); + + // **THE SLOPE CONSTRAINT (§1.12.6).** A contact plane whose normal FAILS the slope test may not + // be used to GAIN height: the up component of the projected motion is capped at the up component + // of the motion BEFORE projection. Without it a character climbs any face up to 90°−ε simply by + // walking into it — measured before the rule existed: 0.583 m of rise in one call against a 50° + // face under a 45° limit, and the verdict said `.on_steep_ground` throughout, so the engine was + // telling the truth while the pose climbed. + // + // A CAP and not a cancellation, which is what makes the four cases right at once: a walkable + // plane is untouched; a caller that ASKS to rise keeps its rise, because the cap is on the + // increase; and a motion already leaving the surface never reaches here at all. + // + // **THE BOUND IS `max(before, 0)` AND NOT `before`, and the difference is a defect the first + // form shipped.** Capping at `before` PENETRATES the plane on a sloped face, computed on a 50° + // ramp with normal `(−0.766, 0.643, 0)` and a pure gravity step `(0, −1, 0)`: the projection is + // `(−0.4925, −0.5866, 0)`, so `after = −0.5866` exceeds `before = −1`, the cap bit, and the + // output `(−0.4925, −1, 0)` had a dot of `−0.2657` with the normal — driving INTO the surface. + // And the projection was the physically right answer: a body sliding down a 50° slope descends + // more slowly than in free fall, the surface carrying part of the motion, and capping at + // `before` annulled exactly that. Bounding at zero instead makes the rule what it is meant to + // be — the ENGINE does not climb on the character's behalf — while leaving every descent the + // geometry produces untouched. + if (normal.dot(up) >= cos_max_slope) return slid; + const cap = @max(motion.dot(up), 0); + const after = slid.dot(up); + if (after <= cap) return slid; + return slid.sub(up.scale(after - cap)); +} + +/// Constrain `motion` against a crease formed by two contact planes. +/// +/// Returns null when the two normals are EXACTLY parallel — which is a THIRD answer and not an +/// absence of edge. Two exactly parallel normals and a single contact plane are different +/// situations, and conflating them is the false-negative class this module refuses; it is the +/// `Attempt` lesson of §1.11.17 applied to a crease. The exact tier is what makes that null +/// trustworthy: `math.triangleCross`'s float `.direction` would return a rounding residue here and +/// read as a valid crease. +fn slideAlongCrease(motion: Vec3r, n0: Vec3r, n1: Vec3r, cos_max_slope: Real) ?Vec3r { + const edge = math.triangleCrossDirection(Real, Vec3r.zero, n0, n1) orelse return null; + // The magnitude carries no meaning by contract — only the direction — so it is normalised here + // and never used as a length. + // + // **No zero-length guard, and its absence is PROVEN rather than assumed.** A non-null return + // always has a component in `[0.5, 1)`: the function brings its three exact determinants onto + // one common power of two chosen so the DOMINANT lane keeps a full mantissa, and for that lane + // `shift = bitLen − keep < bitLen`, so it is never dropped by the below-scale skip and never + // rounds to zero. Verified in `exact.zig`'s implementation, not inferred from its doc. So + // `lengthSq()` lies in `[0.25, 3)` and a guard on it could not fire — and a guard that cannot + // fire tells the reader the exact tier may return a zero vector, which that tier documents as + // impossible. + var axis = edge.scale(1 / @sqrt(edge.lengthSq())); + // Oriented to agree with the motion, so the character slides ALONG the edge in the direction + // it was already going and not backwards along it. + const along = motion.dot(axis); + if (along < 0) axis = axis.neg(); + const slid = axis.scale(@abs(along)); + + // **THE SLOPE CONSTRAINT APPLIES TO A CREASE TOO, and it has to.** §1.12.6 governs the slide, and + // an edge between two planes of which at least one is unwalkable is as much a way to gain height + // as the plane itself — measured: against a 50° ramp built as a ROTATED BOX, the character + // contacts both its top face and a side feature, giving two normals whose crease has an upward + // component, and it rose 0.0186 m per call through that path while the single-plane cap held its + // own path at exactly zero. Capping only the plane would have left the rule true of one branch + // and false of the other. + // + // The bound is `max(before, 0)`, exactly as on the plane branch and for the same measured + // reason: capping at `before` cancels a descent the geometry produced and drives the motion into + // the surface. + // + // **On THIS branch that bound is UNDISCRIMINATED by the suite, and the honest statement is + // weaker than the plane's.** Capping at `before` here breaks nothing, at either precision — and + // unlike the step lift's padding, which is provably unobservable because it cancels against the + // landing drop, this one is merely a case no scene built for it produced. Five were tried: a pure + // descent at the foot of a ramp never reaches the crease at all, the first plane being the + // walkable ground whose earlier branch cancels the downward component; and four mixed + // descend-and-push motions against a rotated box gave outcomes identical under both bounds to + // six decimals. So it stands on consistency with the plane branch, which is a reason and not a + // proof. + if (n0.dot(up) >= cos_max_slope and n1.dot(up) >= cos_max_slope) return slid; + const cap = @max(motion.dot(up), 0); + const after = slid.dot(up); + if (after <= cap) return slid; + return slid.sub(up.scale(after - cap)); +} + +/// Wakes every broadphase CANDIDATE of a volume, without consulting the narrowphase. +/// +/// A SUPERSET of what is actually touched, and that is the safe direction for a wake: W4 exists so a +/// sleeper is never MISSED, and waking one that turns out not to be in contact costs it a tick of +/// simulation and nothing else. Being a superset also removes the capacity question entirely — +/// nothing is accumulated, so there is no bound to overflow, which is what lets the two pose-writing +/// entries handle an unbounded number of overlaps where the move's exact-capacity list could not. +/// +/// It can hold a MUTABLE `BodyManager` precisely because it calls no adapter: `castShapeBody` and +/// `collideShapeBody` both take a `*const`, which is what forces the move to accumulate instead. +const CandidateWaker = struct { + bm: *BodyManager, + exclude: ?BodyId, + + pub fn add(self: *CandidateWaker, user_data: u32) void { + const body: BodyId = user_data; + if (self.exclude) |own| { + if (own == body) return; + } + self.bm.wakeBody(body); + } +}; + +/// One swept contact, resolved over every candidate body. +const SweepHit = struct { + body: BodyId, + subshape_id: u32, + distance: Real, + /// As the CAST reports it — the surface's outward normal when the sweep travelled, and + /// `−direction` at distance zero. Pass it through `slideNormal` before using it. + normal: Vec3r, +}; + +/// Nearest contact of the capsule swept from `origin` along `direction` for at most `distance`. +/// +/// Factored out because four callers need exactly this — the slide loop and the step's three +/// sweeps — and a second copy is how two of them would come to disagree about the filter. +fn sweepNearest( + bp: *const Broadphase, + bm: *const BodyManager, + store: *const ShapeStore, + record: shape_mod.Shape, + probe: SupportShape, + origin: Vec3r, + direction: Vec3r, + distance: Real, + layer_mask: u32, + exclude: ?BodyId, + /// A second body to ignore. Only the slide loop passes one — for a contact it has ESTABLISHED + /// does not oppose the motion — and every other caller passes `null`, so their contract is + /// unchanged in meaning. + exclude_also: ?BodyId, +) ?SweepHit { + var collector = SweepCollector{ + .bm = bm, + .store = store, + .probe = probe, + .origin = origin, + .direction = direction, + .bound = distance, + .layer_mask = layer_mask, + .exclude = exclude, + .exclude_also = exclude_also, + }; + const box = body_manager_mod.worldAabb(record, origin, Quatr.identity); + _ = bp.queryCast(Ray.init(box.center(), direction), box.halfExtents(), &collector); + const best = collector.best orelse return null; + return .{ + .body = best.body, + .subshape_id = best.subshape_id, + .distance = best.distance, + .normal = best.normal, + }; +} + +/// How far the capsule may actually travel toward a contact: up to `padding` short of it, and the +/// whole distance when nothing is in the way. Clamped at zero so a contact already inside the +/// margin never pushes the character backwards. +fn paddedAdvance(hit: ?SweepHit, distance: Real, padding: Real) Real { + const h = hit orelse return distance; + return @max(0, h.distance - padding); +} + +/// What a successful step-up produced: where the capsule ended up, and how much of the horizontal +/// request it consumed getting there. +const StepUp = struct { + centre: Vec3r, + advance: Real, +}; + +/// Try to climb an obstacle instead of stopping at it: lift, advance, land, and accept only if what +/// was landed on is WALKABLE. +/// +/// **Where `padding` enters the geometry, and it is not only the stopping distance.** A character +/// resting on the ground has its base `padding` above it, and after the climb it must be `padding` +/// above the step's top — so the rise between the two resting configurations is exactly the step's +/// height, and the lift to attempt is exactly `step_height` with no padding term. The padding +/// reappears in each of the three sweeps, which each stop short of what they meet. +/// +/// **A STEP IS NOT A SLOPE, and step 4 is the whole reason this returns an optional.** Without that +/// check a character climbs an 80° ramp in increments the size of a stair, each increment +/// individually legitimate, and the slope limit it holds becomes unenforceable. That is a case in +/// the suite and not a remark. +/// +/// Returns null — and moves NOTHING, so no lift survives a failed attempt — when there is no +/// headroom, when the obstacle is taller than the lift, when nothing is under the far side (a ledge, +/// not a step), or when the landing is too steep to stand on. +fn tryStepUp( + bp: *const Broadphase, + bm: *const BodyManager, + store: *const ShapeStore, + record: shape_mod.Shape, + probe: SupportShape, + centre: Vec3r, + direction: Vec3r, + remaining_distance: Real, + c: Character, + touched: *TouchedBodies, +) ?StepUp { + // 1 — lift. + const up_hit = sweepNearest(bp, bm, store, record, probe, centre, up, c.step_height, c.layer_mask, c.inner_body, null); + if (up_hit) |h| touched.add(h.body); + const lift = paddedAdvance(up_hit, c.step_height, standoffTarget(c.padding, probe, centre)); + if (lift <= 0) return null; + const lifted = centre.add(up.scale(lift)); + + // 2 — forward, from the lifted pose. An advance of zero means the obstacle reaches above the + // lift, which is exactly the `step_height + ε` case: the climb must fail and the caller slides. + const fwd_hit = sweepNearest(bp, bm, store, record, probe, lifted, direction, remaining_distance, c.layer_mask, c.inner_body, null); + if (fwd_hit) |h| touched.add(h.body); + const forward_advance = paddedAdvance(fwd_hit, remaining_distance, standoffTarget(c.padding, probe, lifted)); + if (forward_advance <= 0) return null; + const forward = lifted.add(direction.scale(forward_advance)); + + // 3 — land. The drop budget is the lift plus one more step height, so a step DOWN on the far + // side is still caught; finding nothing means there is no floor over there at all. + const down_hit = sweepNearest(bp, bm, store, record, probe, forward, up.neg(), lift + c.step_height, c.layer_mask, c.inner_body, null) orelse return null; + touched.add(down_hit.body); + const drop = paddedAdvance(down_hit, lift + c.step_height, standoffTarget(c.padding, probe, forward)); + const landed = forward.sub(up.scale(drop)); + + // 4 — the landing must be walkable. + const normal = slideNormal(bm, store, probe, landed, down_hit.body, down_hit.distance, down_hit.normal) orelse return null; + if (normal.dot(up) < c.cos_max_slope) return null; + + // 5 — **THE CAPSULE MUST HAVE COME DOWN ONTO A SURFACE, and this is the reference's v5.6.0 bug + // class.** MEASURED on an obstacle of `step_height + ε`: lift 0.3, forward 0.169, and a + // down-sweep finding the obstacle only 0.0106 below, so `drop = max(0, 0.0106 − padding) = 0` — + // the character lands WEDGED against the obstacle's top edge at the lifted height, standing on + // nothing. The following slide then rides it up that edge's tilted normal onto a step it was + // never allowed to climb: 0.37 where 0.02 is correct. + // + // **A second condition — "the landing must be HIGHER than the start" — was written here and then + // REMOVED, measured in both directions.** Its purpose was the other squeeze mode: rise, advance + // at a pose where the capsule's cross-section is narrower, and drop back to where you began. But + // that is INDISTINGUISHABLE from stepping over a kerb onto level ground, which is legitimate and + // which it forbade — measured on a 0.2 m kerb with flat ground either side: with the condition + // the character stopped at x = 0.912 and ended 0.495 m in the AIR, having ratcheted up the + // kerb's edge; without it, x = 2.0 at its original height, which is the whole requested move + // served correctly. + // + // So the squeeze-onto-level-ground mode is NOT guarded, and that is recorded rather than + // papered over: telling it from a legitimate step-over needs a test that the landed pose is + // clear of the obstacle it was blocked by, which is a different mechanism from a height + // comparison. Named for whoever ports the reference's stair-walking in full. + if (drop <= 0) return null; + + return .{ .centre = landed, .advance = forward_advance }; +} + +/// Stick to the floor when walking off a ledge, if it is within `step_height`. +/// +/// **The engine cannot guess INTENTION, so it uses the one fact it has**: the ground state at the +/// START of the move. A character that entered `.grounded` and walked off an edge is descending a +/// step; a character that entered `.in_air` is falling and must not be dragged down. That is the +/// reference's own condition for its floor-sticking. +/// +/// A second condition is added and it is MEASURED, not stylistic: the move must not have asked to +/// go UP. `engine-movement.md`'s default `jump_velocity` is 8 m/s, so a jump's first tick rises +/// `8/60 = 0.133 m` — well inside the 0.3 m default `step_height`. On the entering tick the +/// character is still grounded, so without this guard floor-sticking cancels every jump on its +/// first frame, which is a behaviour nobody would attribute to a step-down feature. +/// +/// A landing too steep to stand on is not a floor to stick to, so it is left alone. +fn stepDown( + bp: *const Broadphase, + bm: *const BodyManager, + store: *const ShapeStore, + record: shape_mod.Shape, + probe: SupportShape, + centre: Vec3r, + c: Character, + touched: *TouchedBodies, +) Vec3r { + const hit = sweepNearest(bp, bm, store, record, probe, centre, up.neg(), c.step_height, c.layer_mask, c.inner_body, null) orelse return centre; + const normal = slideNormal(bm, store, probe, centre, hit.body, hit.distance, hit.normal) orelse return centre; + if (normal.dot(up) < c.cos_max_slope) return centre; + touched.add(hit.body); + // The SAME stand-off the depenetration establishes, not a bare `c.padding`: at `padding = 0` the + // bare value descends the full distance to the floor and re-seats the capsule exactly tangent, + // which froze the next call. See `standoffTarget`. + return centre.sub(up.scale(paddedAdvance(hit, c.step_height, standoffTarget(c.padding, probe, centre)))); +} + +/// Push a DYNAMIC body the character walked into, and take nothing in return. +/// +/// **Unilateral by construction** (§1.12.9): the character is kinematic, so no impulse is ever +/// applied to it and its own resolution is untouched — it stops `padding` short of the body whether +/// the body yields or not. That is what distinguishes a push from an elastic collision, and it is +/// asserted as a differential rather than described. +/// +/// The impulse is what it would take to bring the body up to the character's own speed along the push +/// direction, clamped to `max_push_force · dt` — a force ceiling times a time IS an impulse, which is +/// what makes `max_push_force` a force in newtons rather than a fudge factor. `max_push_force = 0` +/// disables pushing with no special case, and so does a body already moving away faster than the +/// character. +/// +/// `dt` is one of the two DERIVED terms §1.12.1 reserves it for; the character is never integrated +/// with it. +/// The impulse this contact owes the body, or null if it owes none. PLANS, never applies — +/// `PendingPushes.apply` is the only writer, and it runs after the publication. +/// +/// If two slide iterations hit the SAME body, both plan against the velocity it had at the start of +/// the call — re-reading a velocity that has not been written yet is not available to a planner. +/// `PendingPushes` then SUMS the plans for one body and caps the magnitude of that sum ONCE, so the +/// applied total is at most `max_push_force · dt` however many contacts a body took. An earlier +/// paragraph here stated a worst case of `n · max_push_force · dt` and a sum that could come out +/// "slightly larger"; coalescing made that false, and it is deleted rather than qualified. +fn plannedPush( + bm: *const BodyManager, + body: BodyId, + normal: Vec3r, + character_velocity: Vec3r, + c: Character, + dt: Real, +) ?Vec3r { + if (c.max_push_force <= 0 or dt <= 0) return null; + if (bm.bodyType(body) != .dynamic) return null; + // `normal` runs surface → character, so the character pushes along its negation. + const direction = normal.neg(); + const body_velocity = bm.linearVelocity(body) orelse return null; + const closing = character_velocity.dot(direction) - body_velocity.dot(direction); + if (closing <= 0) return null; // the body is already leaving at least as fast + const impulse = @min(c.mass * closing, c.max_push_force * dt); + return direction.scale(impulse); +} + +/// Push the capsule out of everything it overlaps, deepest first, and record whose wake that owes. +/// +/// Bounded by `max_depenetration_iterations`. **Depenetration goes through the MANIFOLD and never +/// through the sweep** (§1.12.6): at distance zero a cast returns `−direction`, the direction +/// travelled from rather than the surface, which is correct for the cast's own invariant and +/// unusable for pushing out. +/// Push the character out of whatever it overlaps, deepest first, at most +/// `max_depenetration_iterations` times. +/// +/// **IT PUSHES OUT, NEVER THROUGH (§1.12.6).** The pass resolves one body at a time, so in a squeeze +/// it alternates between two opposing surfaces and where it stops is the ITERATION COUNT'S PARITY — +/// MEASURED: with a capsule needing 1.8 m of headroom under a ceiling offering 1.0 m, a count of 3 +/// or 5 leaves the base at −0.800000, the full depth of the squeeze and on the far side of the ground +/// plane, while 4 or 8 leaves it at 0. A guarantee cannot rest on the parity of a constant, and an +/// assertion on that parity protects the constant while saying nothing about the algorithm. So the +/// pass carries an invariant instead: +/// +/// The depenetration NEVER moves the character to the far side of a contact plane it was on the +/// good side of at the ENTRY of the call. +/// +/// Written that way the failure direction of an unresolvable squeeze is sayable, which is what the +/// brief requires of a guarantee: the character keeps the pose it came in with, and a residual +/// overlap, and does not tunnel. Enforced by reverting to the entry pose the moment a contact is +/// found whose plane the BASE has crossed since entry — resolving further would be tunnelling and +/// not depenetration. The base and not the centre: it is the reference point gameplay writes +/// (§1.12.3), and it is what "under the floor" means; the centre of a 1.8 m capsule is still 0.10 m +/// above a plane its feet have passed 0.80 m below, so a centre-based test does not fire at all. +/// +/// The side test does NOT fire for a character that entered ALREADY on the wrong side — which is the +/// ordinary case of resting a few millimetres sunk into the ground — so no legitimate resolution is +/// refused. And it cannot refuse one by accident either: pushing out of a floor raises the base and +/// pushing out of a wall moves it sideways, so no push a resolvable overlap needs can cross a plane +/// the character was above. +/// +/// A narrow door was examined as a second instance and is NOT one, measured rather than assumed: at +/// widths of 0.40, 0.50 and 0.58 m against a 0.60 m capsule, and at counts of 3, 4 and 5, the +/// character oscillates between ±(radius − half-width) and NEVER leaves the doorway. The two walls +/// are symmetric about its entry pose, so the alternation stays bounded; the ceiling case tunnels +/// because the floor is NOT a contact at entry, which makes the first push large and unopposed. +/// The coordinate scale the stand-off floor is measured in: the capsule's own extent plus its distance +/// from the origin, which is where float noise at this pose actually lives. +/// +/// It is NOT `gjk.zig`'s symmetric pair scale, which also carries the other body's core extent — that +/// quantity is not reachable from here, the other side being a half-space or a per-triangle support +/// shape as often as a convex. The consequence is bounded and MEASURED rather than argued: see the +/// large-collider case in the acceptance suite. +fn coordScale(probe: SupportShape, centre: Vec3r) Real { + return @sqrt(centre.lengthSq()) + narrowphase.coreExtent(Real, probe) + probe.radius; +} + +/// The stand-off this controller leaves between the capsule and a surface it resolves against: +/// `padding` when the caller asked for one, and the numerical floor otherwise. +/// +/// **BOTH the depenetration and the floor-sticking descent use it, and using it in only one place was +/// a defect found by enumerating the domain's bounds.** With `padding = 0`, `stepDown` passed a bare +/// zero to `paddedAdvance` and therefore descended the FULL distance to the floor, re-seating the +/// capsule exactly tangent — so the next call with any downward component in its displacement met a +/// contact at distance zero and froze, undoing what the depenetration had just established. Measured: +/// 0.2 m served on the first call and 0.0001 on every one after. +fn standoffTarget(padding: Real, probe: SupportShape, centre: Vec3r) Real { + // **NOT `@max`, and the difference is a silent lie against a loud failure.** `@max` let the floor + // OVERRIDE a padding the caller asked for: `64 · floatEps(f32) = 7.6e-6`, so the floor crosses the + // 0.02 default at 2 632 m and reaches 3.8 cm at 5 km. A caller asking for 2 cm silently got 3.8, and + // two identical scenes translated apart stopped at different distances — a supported region + // answering wrongly without saying so. + // + // The floor exists to serve a caller who asked for NO stand-off, not to overwrite one who asked for + // some. So a non-zero `padding` is honoured EXACTLY, at every scale, invariant under translation. + // + // The freeze regime returns where `padding` itself falls under the contact margin, which at the 0.02 + // default is a `coordScale` beyond about 10 km — inside the region §1.11.4 bis already declares f64. + // And that failure is LOUD: the character does not move. A loud failure in a region declared + // unsupported beats a silently wrong answer in a supported one, which is the direction of failure + // this whole milestone has been choosing. + if (padding > 0) return padding; + return standoff_floor_k * std.math.floatEps(Real) * coordScale(probe, centre); +} + +fn depenetrate( + bp: *const Broadphase, + bm: *const BodyManager, + store: *const ShapeStore, + record: shape_mod.Shape, + probe: SupportShape, + start: Vec3r, + base_start: Vec3r, + layer_mask: u32, + exclude: ?BodyId, + padding: Real, + touched: *TouchedBodies, +) Vec3r { + var centre = start; + var i: u32 = 0; + while (i < max_depenetration_iterations) : (i += 1) { + var worst = WorstOverlap{ + .bm = bm, + .store = store, + .probe = probe, + .centre = centre, + .layer_mask = layer_mask, + .exclude = exclude, + }; + _ = bp.queryAabb(body_manager_mod.worldAabb(record, centre, Quatr.identity), &worst); + const c = worst.best orelse break; + touched.add(c.body); + + // THE INVARIANT. The manifold point is the MIDPOINT of the two surface points, so the body's + // surface is half the penetration further along the outward normal. `s` is the base's signed + // clearance from that surface, and the accumulated push changes it by exactly its projection + // on the normal — so the entry value is the only thing this needs to carry. Both comparisons + // are at TRUE ZERO: no tolerance can be right here, the question being which side of a plane + // a point is on and not how far. + const surface = c.position.add(c.normal.scale(c.penetration / 2)); + const s_entry = base_start.sub(surface).dot(c.normal); + const s_now = s_entry + centre.sub(start).dot(c.normal); + if (s_entry >= 0 and s_now < 0) return start; + + // **OUT TO `padding` OF CLEARANCE, NOT TO TOUCHING — and pushing to touching FROZE a character + // permanently.** §1.12.6 makes the stand-off an obligation of the controller, and nothing + // established it: `paddedAdvance` cannot, having nothing to subtract it from when the advance + // is zero. + // + // A capsule left exactly tangent serves NO horizontal motion at all. The sweep reports a + // contact at distance zero, the padded advance clamps to zero, and the slide returns a + // horizontal motion projected on a horizontal plane unchanged — all four slide iterations are + // consumed with no progress and the remainder is dropped. TRACED: `pen = −0.000000000`, normal + // `+Y`, four identical rounds, final `x = 0`. + // + // And this pass MANUFACTURED that state rather than merely failing to leave it: a character + // starting 0.05 m inside the floor was resolved to a base of exactly `0.000000000` and then + // froze. The reachability is therefore not authoring alone — any interpenetration at all, from + // a spawn, a teleport, a resize or a platform pushing the character in, ended frozen. + // + // A capsule already standing off is not moved: a manifold exists only within the contact margin, + // so anything clear of it is `.separated` and invisible to this query. TRACED: 0.005 and 0.02 + // produce no contact at all, and 0.005 already serves a whole metre. + // + // **THE CODE IS AHEAD OF THE SPEC HERE, and that is recorded rather than argued away.** + // `engine-physics-forge.md` §1.12.6 still reads as a POSE invariant — the `padding` keeps the + // capsule clear of surfaces — which this behaviour does not satisfy for an authored pose closer + // than `padding`. A narrowing is being delivered upstream: `padding` becomes what a SWEEP + // reserves, matching the reference, where `mCharacterPadding` is a parameter of + // `CastShape`/`CollideShape` and normalises no authored pose. Until that patch lands the + // divergence is real, and an implementation comment cannot close it — only the document can. + // **THE TARGET HAS A NUMERICAL FLOOR, and without it `padding = 0` reproduced the freeze + // exactly.** Zero is a legal value and a meaningful one — no PHYSICAL margin — and it stays in + // the domain: removing it would have masked this defect instead of closing it, which is how the + // freeze got here in the first place. But `penetration + 0` at a tangency of `−0.0` moves the + // capsule by nothing, the sweep finds the same zero-distance contact, and the four iterations + // burn again. + // + // So the target is `max(padding, standoff_floor)`. With `padding = 0` a resting character stands + // `standoff_floor` above its floor rather than exactly on it, and that is correct rather than a + // compromise: `padding = 0` means "no physical margin", not "stand inside the numerical band + // where the GJK classification decides the verdict from one frame to the next". + const target = standoffTarget(padding, probe, centre); + centre = centre.add(c.normal.scale(c.penetration + target)); + } + return centre; +} + +/// Generational store of character controllers. +/// +/// The `ShapeStore` pattern verbatim: stable slots, LIFO recycling, a generation on the +/// handle. A plain column rather than a `MultiArrayList` because a character is addressed by +/// handle and never swept — which is what distinguishes it from `BodyManager`, whose SoA +/// exists for the per-tick integrator pass. +/// +/// The generation is what makes §1.12's typed error on a stale handle implementable at all: +/// a bare slot index cannot tell a recycled slot from the handle that used to own it. +pub const CharacterStore = struct { + alloc: IdAllocator = .{}, + characters: std.ArrayListUnmanaged(Character) = .empty, + + /// Release this store's own storage. + /// + /// It frees no shape and no body: those are owned by the `ShapeStore` and the + /// `BodyManager`, whose own `deinit` releases them. A live character at teardown + /// therefore leaks nothing — but its presence outlives it until that `BodyManager` is + /// torn down, which is why `destroyCharacter` exists and why a test that checks for + /// orphans checks the other two stores' counts. + pub fn deinit(self: *CharacterStore, gpa: std.mem.Allocator) void { + self.alloc.deinit(gpa); + self.characters.deinit(gpa); + self.* = undefined; + } + + /// Number of live characters. + pub fn count(self: *const CharacterStore) u32 { + return self.alloc.live_count; + } + + /// Create a controller, returning its handle. + /// + /// **TRANSACTIONAL.** Three resources are acquired here — the capsule in `store`, the + /// presence in `bm` when `desc.inner_body` is set, and this store's own slot — and a + /// failure at any of them leaves NO live slot, NO orphan shape and NO orphan body. The + /// discipline is the `createShape` one applied across three stores: validate first + /// (allocating nothing), then acquire each resource under an `errdefer` that releases + /// it, and leave the two infallible commits last. + /// + /// The slot and the column are reserved LAST because they are the only remaining + /// fallible steps once the two external resources are held; after them, + /// `allocateAssumeCapacity` and the column write cannot fail, so there is no window in + /// which a live slot exists without its shape or its presence. + /// + /// The presence is a `.kinematic` body carrying the controller's OWN capsule — the very + /// `ShapeId` this call created, never a second shape (§1.12.2). Its layer is + /// `collision_layer`. It is NOT inserted into the broadphase here: no + /// `BodyType → BroadphaseLayer` wiring exists, the layer being an insertion argument, and + /// that wiring arrives with `PhysicsWorld` at M1.1.15 — the same reason the query suites + /// insert their own proxies. + pub fn createCharacter( + self: *CharacterStore, + gpa: std.mem.Allocator, + store: *ShapeStore, + bm: *BodyManager, + desc: CharacterDescriptor, + ) !CharacterId { + try validateDescriptor(desc); + + const shape_id = try store.createShape(gpa, .{ .capsule = .{ + .radius = desc.radius, + .half_height = capsuleHalfHeight(f32, desc.radius, desc.height), + } }); + errdefer store.destroyShape(gpa, shape_id); + + const presence: ?BodyId = if (desc.inner_body) try bm.addBody(gpa, store, .{ + .entity = desc.entity, + // KINEMATIC: the controller's pose is written by `moveCharacter` and by that + // entry alone, so the presence must never be integrated or solved. + .body_type = .kinematic, + .shape = shape_id, + // The body's pose is the CENTRE of its shape; the descriptor gives the BASE. + .position = desc.position.add(baseToCentre(f32, desc.height)), + // No rotation field on the descriptor, and the absence is argued: the capsule is + // symmetric about Y and the engine's up is Y, so no orientation changes a + // collision answer (§1.12.3). + .rotation = math.Quatf.identity, + .collision_layer = desc.collision_layer, + // `can_sleep` is left at its default and is INERT here: only dynamic bodies are + // island members, and the sleep window sweep skips a non-dynamic body before it + // touches it (§1.8.1, §1.8.3). + }) else null; + errdefer if (presence) |b| bm.removeBody(b); + + try self.alloc.ensureUnusedCapacity(gpa, 1); + try self.characters.ensureUnusedCapacity(gpa, 1); + + // Infallible from here. + const record = Character{ + .entity = desc.entity, + .position = convVec3(desc.position), + .radius = desc.radius, + .height = desc.height, + .step_height = desc.step_height, + // The SINGLE trigonometric call of this module's whole life. Taken at `Real` on + // the widened angle rather than in `f32` and widened after, so its accuracy is + // bounded only by the angle the caller authored. + .cos_max_slope = @cos(@as(Real, desc.max_slope)), + .padding = desc.padding, + .predictive_contact_distance = desc.predictive_contact_distance, + .collision_layer = desc.collision_layer, + .layer_mask = desc.layer_mask, + .mass = desc.mass, + .max_push_force = desc.max_push_force, + .shape = shape_id, + .inner_body = presence, + }; + const a = self.alloc.allocateAssumeCapacity(); + if (a.is_new) { + self.characters.appendAssumeCapacity(record); + } else { + self.characters.items[a.index] = record; + } + return a.id; + } + + /// Destroy a controller, releasing all FOUR of its resources: the broadphase proxy, the presence + /// body, the capsule in `store`, and the handle slot. No-op on a stale/invalid handle, like + /// `removeBody` — and in particular it releases NOTHING there, which is what keeps a double + /// destroy from double-freeing the capsule. + pub fn destroyCharacter( + self: *CharacterStore, + gpa: std.mem.Allocator, + bp: *Broadphase, + store: *ShapeStore, + bm: *BodyManager, + id: CharacterId, + ) void { + const idx = self.alloc.validate(id) orelse return; + const record = self.characters.items[idx]; + // **FOUR resources, and an earlier version released three.** The broadphase proxy was the one + // left behind, and its own doc comment counted three — a number the code contradicted. A + // leaked proxy is not merely untidy: the leaf stays in its tree with the last box the + // presence had, so every query along that region still visits it and pair generation still + // offers it, against a body handle that has been freed and whose slot will be recycled. + // + // Removed FIRST, and the order is not arbitrary: `Broadphase.remove` is INFALLIBLE, so this + // entry stays `void` and there is no partial-teardown state to reason about. + if (record.presence_proxy) |proxy| bp.remove(proxy); + if (record.inner_body) |b| bm.removeBody(b); + store.destroyShape(gpa, record.shape); + _ = self.alloc.free(id); + } + + /// The presence's `BodyId`, without which no caller can exclude itself from its own + /// sweeps — the anti-wall sweep of a follow camera starts from the player, and + /// `PhysicsQueryFilter.exclude` takes `BodyId` alone (§1.12.2). + /// + /// THREE outcomes, and none of them is confusable with another: a typed error on a stale + /// handle, `null` for a character created without a presence, and the handle otherwise. + /// Returning `null` for the stale case would conflate a dead handle with a live + /// presence-less character, which is the false-negative class this module refuses. + pub fn getCharacterInnerBody(self: *const CharacterStore, id: CharacterId) CharacterError!?BodyId { + const idx = self.alloc.validate(id) orelse return error.StaleCharacter; + return self.characters.items[idx].inner_body; + } + + /// Safe getter — the stored record, or null if `id` is stale/invalid. + pub fn get(self: *const CharacterStore, id: CharacterId) ?Character { + const idx = self.alloc.validate(id) orelse return null; + return self.characters.items[idx]; + } + + /// Register the broadphase proxy of a character's presence, so every later pose write keeps + /// the tree fresh as well as the body. No-op on a stale handle. + /// + /// A seam and not a design preference: the broad LAYER is an insertion argument and the + /// `BodyType → BroadphaseLayer` wiring arrives with `PhysicsWorld` at M1.1.15, so the store + /// cannot choose where the proxy goes. Whoever does the insertion — the orchestrator later, the + /// test harness now — hands the handle back through here. + pub fn setPresenceProxy(self: *CharacterStore, id: CharacterId, proxy: Broadphase.Proxy) void { + const idx = self.alloc.validate(id) orelse return; + self.characters.items[idx].presence_proxy = proxy; + } + + /// Move character `id` by `displacement` metres, resolving collisions, and return its new BASE + /// position together with the ground verdict at that new pose. + /// + /// **A DISPLACEMENT, not a velocity** (§1.12.1). The kinematics belong to the caller + /// (`engine-movement.md`) and the geometry to the engine, and `dt` serves only the DERIVED + /// terms — the support velocity, and the push impulse at gate F — never to integrate the + /// character. It is accepted here so the signature is the frozen one and the derived terms have + /// their input the day they land. + /// + /// The algorithm, in the order it runs: + /// + /// 1. **Depenetrate** through the manifold, deepest overlap first, bounded. + /// 2. **Sweep and slide**, bounded: sweep along what remains, advance to `padding` short of + /// the first contact, then constrain the rest against the accumulated contact planes — + /// one plane projects, two slide along their crease, and anything more stops. + /// 3. **Publish**: write the record, mirror the pose onto the presence and its broadphase + /// proxy, wake what was touched, and recompute the ground verdict at the new pose. + /// + /// Both loops are bounded by NAMED ceilings and exhausting either stops the character SHORT of + /// where it asked to go, never further — the safe failure direction (§1.11.11). + /// + /// Errors: `error.StaleCharacter` on a dead handle, and whatever the broadphase proxy update + /// allocates. + pub fn moveCharacter( + self: *CharacterStore, + gpa: std.mem.Allocator, + bp: *Broadphase, + bm: *BodyManager, + store: *const ShapeStore, + id: CharacterId, + displacement: Vec3r, + dt: Real, + ) !MoveResult { + const idx = self.alloc.validate(id) orelse return error.StaleCharacter; + const c = self.characters.items[idx]; + const record = store.get(c.shape) orelse unreachable; + const probe = shape_mod.supportShape(record); + + var touched = TouchedBodies{}; + var pushes = PendingPushes{}; + // The character's own speed, DERIVED from the displacement and `dt` — the caller owns the + // kinematics, so this is the only place the engine reconstructs a velocity, and it exists + // solely to size the push impulse (§1.12.1). + const character_velocity = if (dt > 0) displacement.scale(1 / dt) else Vec3r.zero; + + // 0 — the ground state BEFORE anything moves, which is the only thing the engine knows + // about the caller's intention (see `stepDown`). Read here and not after, because after the + // move it is a different question. + const entered_grounded = (try self.groundOf(bp, bm, store, id)).state == .grounded; + + // 1 — depenetration. + var centre = depenetrate( + bp, + bm, + store, + record, + probe, + c.position.add(baseToCentre(Real, c.height)), + c.position, + c.layer_mask, + c.inner_body, + c.padding, + &touched, + ); + + // 2 — sweep and slide. + var remaining = displacement; + var planes: [2]Vec3r = @splat(Vec3r.zero); + var plane_count: u32 = 0; + var step_attempted = false; + // **A CONTACT THAT DOES NOT OPPOSE THE MOTION MUST NOT CONSUME THE BUDGET.** + // + // This is the CONSEQUENCE the last four rounds kept approaching by its arrival paths. Each of + // them closed one way of ending up exactly tangent — the depenetration's stand-off, then the + // same stand-off at all five advance sites, then scoping it to `padding == 0` — and each time + // another path appeared, because arrival at tangency is a float-resolution phenomenon and there + // are as many paths as one likes: a 500 m collider whose resolution reseats the capsule + // whatever the floor, a `padding` of `floatMin` whose addition changes no bit. What is FINITE + // is what tangency then does, and it does it in exactly one place — here. + // + // A zero advance against a surface that does not oppose the travel direction is not an + // obstruction: the slide leaves the motion unchanged, the next iteration finds the same + // contact, and the budget burns with the remainder dropped. So that body is set aside for ONE + // retry that does not spend the budget, and the sweep then reports the next REAL obstacle. + // TRACED: with the resting floor set aside and self-exclusion kept, the sweep returns null and + // the whole remaining metre is free. + // + // Bounded by construction: a single slot, so at most one free retry per call, and a second + // non-opposing body spends its iteration normally. No epsilon anywhere — the advance test is + // exact zero and the opposition test is the sign of a dot product. + var ignored: ?BodyId = null; + var budget: u32 = max_slide_iterations; + var iteration: u32 = 0; + while (iteration < budget) : (iteration += 1) { + const len_sq = remaining.lengthSq(); + // True zero, not an epsilon: a displacement of exactly nothing is done, and any + // representable non-zero displacement is a real request to be served. + if (len_sq == 0) break; + const distance = @sqrt(len_sq); + const direction = remaining.scale(1 / distance); + + const maybe_hit = sweepNearest(bp, bm, store, record, probe, centre, direction, distance, c.layer_mask, c.inner_body, ignored); + const hit = maybe_hit orelse { + // Nothing in the way: the whole remaining displacement is served. + centre = centre.add(remaining); + remaining = Vec3r.zero; + break; + }; + touched.add(hit.body); + + // Advance to the STAND-OFF short of the surface, clamped at zero so a contact already + // inside the margin does not push the character backwards. + // + // **`standoffTarget` and not a bare `c.padding`, and this is the site that made it a CLASS + // rather than an instance.** With `padding = 0` a DIAGONAL advance stops exactly at contact, + // so the capsule lands exactly tangent INSIDE this loop, and every later iteration then + // finds distance zero and advances nothing — the freeze, re-manufactured per call from the + // stand-off the depenetration had just established. Traced: `hit = 0.000000000`, + // `adv = 0.000000000`, three dead iterations, 0.3 m of the request dropped. + // + // Fixing the depenetration and the descent alone left this path open, which is the third + // instance of one class in this file: EVERY `paddedAdvance` call site owes the stand-off, + // so the target belongs to all five and not to whichever one a probe happened to catch. + const advance = paddedAdvance(hit, distance, standoffTarget(c.padding, probe, centre)); + centre = centre.add(direction.scale(advance)); + remaining = remaining.sub(direction.scale(advance)); + + const normal = slideNormal(bm, store, probe, centre, hit.body, hit.distance, hit.normal) orelse { + // No usable normal: stop rather than guess a direction. Short, never further. + remaining = Vec3r.zero; + break; + }; + + // The non-opposing zero-advance contact: set it aside and retry for free. `>= 0` is the + // exact test — a surface the motion runs ALONG (dot exactly zero) obstructs nothing, and + // one it runs away from even less. + if (advance == 0 and ignored == null and normal.dot(direction) >= 0) { + ignored = hit.body; + budget += 1; + continue; + } + + // PLAN the push on what was hit, if it is dynamic and yields. Planned here and applied + // after the publication (see `PendingPushes`); the position in the loop is readability + // alone, since the push is unilateral and cannot change the character's own resolution. + if (plannedPush(bm, hit.body, normal, character_velocity, c, dt)) |impulse| { + pushes.add(hit.body, impulse); + } + + // **CLIMB BEFORE SLIDING**, once per call. Attempted only against a surface too steep + // to walk on: something walkable is ground to stand on, not an obstacle to step over. + // On failure NOTHING has moved — `tryStepUp` returns the landing pose or nothing at all + // — so no lift survives a failed attempt, which is the reference's v5.6.0 bug class. + if (!step_attempted and normal.dot(up) < c.cos_max_slope) { + step_attempted = true; + const len = remaining.lengthSq(); + if (len > 0) { + if (tryStepUp(bp, bm, store, record, probe, centre, direction, @sqrt(len), c, &touched)) |stepped| { + centre = stepped.centre; + remaining = remaining.sub(direction.scale(stepped.advance)); + // No plane is recorded: the character went OVER the obstacle, not along it, + // so it is not a wall to slide on. + continue; + } + } + } + + // Record the plane. A normal parallel to one already held replaces it rather than + // filling the second slot, so a re-contact with the same wall does not read as a crease. + if (plane_count == 1 and math.triangleCrossDirection(Real, Vec3r.zero, planes[0], normal) == null) { + planes[0] = normal; + } else if (plane_count < 2) { + planes[plane_count] = normal; + plane_count += 1; + } else { + // A third distinct plane is a corner: nothing left to slide along. + remaining = Vec3r.zero; + break; + } + + if (plane_count == 1) { + remaining = slideAlongPlane(remaining, planes[0], c.cos_max_slope); + } else { + remaining = slideAlongCrease(remaining, planes[0], planes[1], c.cos_max_slope) orelse { + // EXACTLY parallel normals — a third answer, not an absent edge. There is no + // crease to slide along, so the motion stops. + remaining = Vec3r.zero; + break; + }; + // NO "still driving into a plane" check here, and its absence is a decision. + // The crease axis is the CROSS PRODUCT of the two normals, so it is perpendicular + // to both of them by construction and the motion projected onto it has a + // mathematically ZERO dot with each. Testing that dot against zero would be + // testing the SIGN OF FLOAT NOISE — measured: the box contact normals carry + // components a few ULPs off their exact axis, and a strict `< 0` on that noise + // stopped a legitimate edge slide dead. The corner case is already the third-plane + // branch above, which is a structural condition rather than a numerical one. + } + } + + // 3 — stick to the floor on the way DOWN, under the two conditions `stepDown` argues: the + // character entered grounded, and it did not ask to go up. + if (entered_grounded and displacement.dot(up) <= 0) { + centre = stepDown(bp, bm, store, record, probe, centre, c, &touched); + } + + // 4 — publish. The record is AUTHORITATIVE and the presence mirrors it; the two are written + // in one place so they cannot drift (see the Notes on this being the likeliest silent bug). + const new_base = centre.sub(baseToCentre(Real, c.height)); + // The presence FIRST, because its proxy update is the one step that can fail: on OOM the + // record must be left exactly as it was and the call retryable (see `syncPresenceTo`). + try self.syncPresenceTo(gpa, bp, bm, store, idx, new_base, c.shape, c.height); + self.characters.items[idx].position = new_base; + + // The wake this call owes. W4 and not W3: a presence moved by POSE WRITE keeps velocity + // columns of exactly zero while it crosses the scene, so W3's true-zero velocity test never + // sees it move (§1.12.10). Woken here rather than inside the collectors, which hold a + // `*const BodyManager`. + for (touched.slice()) |body| bm.wakeBody(body); + + // The pushes this call owes, drained here and not in the loop: everything above this line can + // still fail, and a failure must leave the world untouched so the retry is not a second push. + // One impulse per body, the per-body SUM capped once — see `PendingPushes`. + pushes.apply(bm, c.max_push_force * dt); + + const ground = try self.groundOf(bp, bm, store, id); + // Recorded so `setCharacterPosition` has something to INVALIDATE (§1.12.8). + self.characters.items[idx].reported_ground = ground.state; + return .{ .position = new_base, .ground = ground }; + } + + /// Resize a character, ATOMICALLY and ANCHORED AT THE FEET: the base does not move, the volume + /// grows or shrinks upward, and the controller and its presence change together or not at all. + /// + /// **The presence's `BodyId` is KEPT** (§1.12.2): a resize is not a re-creation, so an exclusion + /// the caller memorised survives it. That is what `BodyManager.setShape` exists for. + /// + /// THREE outcomes, which a bare `bool` would conflate — the same split as `shapeCast` (§1.11.7): + /// a typed ERROR for the caller's fault (stale handle, or the SAME domain bounds + /// `createCharacter` applies, `height >= 2 · radius` included); `false` for a target volume that + /// is OCCUPIED, which is a legitimate gameplay answer and not an error; `true` for success. + /// + /// A refusal changes NOTHING — not the pose, not the dimensions, not the presence — and the new + /// capsule it had to build to ask the question is destroyed on the way out. + /// + /// Shrinking always succeeds: the target volume is contained in the current one. Growing under a + /// low ceiling returns `false`. + pub fn resizeCharacter( + self: *CharacterStore, + gpa: std.mem.Allocator, + bp: *Broadphase, + bm: *BodyManager, + store: *ShapeStore, + id: CharacterId, + radius: f32, + height: f32, + ) !bool { + const idx = self.alloc.validate(id) orelse return error.StaleCharacter; + // The same three length bounds `validateDescriptor` applies, and for the same reasons — a + // resize is a second door onto the same domain, so it cannot be a laxer one. + if (!std.math.isFinite(radius) or radius <= 0) return error.InvalidDimensions; + if (!std.math.isFinite(height) or height <= 0) return error.InvalidDimensions; + if (height < 2 * radius) return error.InvalidDimensions; + + const c = self.characters.items[idx]; + const new_shape = try store.createShape(gpa, .{ .capsule = .{ + .radius = radius, + .half_height = capsuleHalfHeight(f32, radius, height), + } }); + errdefer store.destroyShape(gpa, new_shape); + const new_record = store.get(new_shape) orelse unreachable; + const new_probe = shape_mod.supportShape(new_record); + // ANCHORED AT THE FEET: the base is unchanged, so the new centre is derived from the NEW + // height through the one named offset. + const new_centre = c.position.add(baseToCentre(Real, height)); + + // Is the target volume free? The character's OWN presence is excluded — it still carries the + // old capsule, which the new one overlaps by construction, so including it would refuse + // every resize. + var probe_overlap = WorstOverlap{ + .bm = bm, + .store = store, + .probe = new_probe, + .centre = new_centre, + .layer_mask = c.layer_mask, + .exclude = c.inner_body, + }; + _ = bp.queryAabb(body_manager_mod.worldAabb(new_record, new_centre, Quatr.identity), &probe_overlap); + if (probe_overlap.best != null) { + store.destroyShape(gpa, new_shape); + return false; + } + + // The presence FIRST, with the NEW shape and height passed explicitly — so the proxy box + // reflects the new SIZE and not only the new centre, and so the one fallible step of the + // whole commit happens while the character is still entirely unchanged. A failure here + // reaches the `errdefer` above with nothing to undo but the new capsule itself. + try self.syncPresenceTo(gpa, bp, bm, store, idx, c.position, new_shape, height); + + // Infallible commit. + const old_shape = c.shape; + self.characters.items[idx].radius = radius; + self.characters.items[idx].height = height; + self.characters.items[idx].shape = new_shape; + if (c.inner_body) |body| bm.setShape(store, body, new_shape); + store.destroyShape(gpa, old_shape); + + // W4: whatever the NEW volume reaches. A superset, which is the safe direction. + var waker = CandidateWaker{ .bm = bm, .exclude = c.inner_body }; + _ = bp.queryAabb(body_manager_mod.worldAabb(new_record, new_centre, Quatr.identity), &waker); + return true; + } + + /// Teleport a character: move it WITHOUT sweeping and without resolving (§1.12.8). + /// + /// It may leave the character interpenetrated, and that is the contract rather than a limitation + /// — the caller asked to be somewhere, not to be moved toward somewhere. It INVALIDATES the + /// reported ground verdict, which returns to `.in_air`. + /// + /// **NO-OP on a stale handle, and the discriminant is whether the entry RETURNS A VALUE.** + /// `createCharacter`, `moveCharacter`, `resizeCharacter` and `getCharacterInnerBody` all return + /// something, so a dead handle has no honest answer and they carry an error channel; + /// `destroyCharacter` and this entry return nothing, so the no-op IS a coherent answer. Without + /// exception across the repository, and it is why `moveCharacter` has a channel and this does not. + /// + /// A version of this shipped `error.StaleCharacter` here, argued from "a write the caller has to + /// know did not happen". That argument proves too much: it holds identically for + /// `setBodyTransform`, `setLinearVelocity` and `setAngularVelocity`, all `void` in the frozen + /// surface, so applied to its end it makes every setter fallible — a decision about the whole + /// Tier 0 surface and not about one entry. Whether setters should be fallible is recorded for + /// M1.1.15, where the interface layer is built and the question covers all of it at once. + /// + /// **The frozen signature is `void` and this one is `!void`, and the residual `!` is NOT a + /// semantic refusal — it is the broadphase's allocation.** Keeping the proxy fresh is part of this + /// entry's contract (§1.12.2), `Broadphase.update` reserves a slot on its layer's moved log, and a + /// `void` entry has nowhere to put that failure; making it truly `void` needs a reservation seam + /// in the broadphase, which this milestone does not own. So the gap is in the frozen surface + /// rather than a liberty taken here, it is disjoint from the stale-handle question settled above, + /// and §1.11.7 forbids the easy answer of converting an error into an absent result. Recorded so + /// the freeze meets it knowingly, alongside the setter-fallibility convention. + pub fn setCharacterPosition( + self: *CharacterStore, + gpa: std.mem.Allocator, + bp: *Broadphase, + bm: *BodyManager, + store: *const ShapeStore, + id: CharacterId, + position: Vec3r, + ) !void { + const idx = self.alloc.validate(id) orelse return; + const c = self.characters.items[idx]; + // The presence FIRST — same reason as the other two write paths. + try self.syncPresenceTo(gpa, bp, bm, store, idx, position, c.shape, c.height); + self.characters.items[idx].position = position; + self.characters.items[idx].reported_ground = .in_air; + + const record = store.get(c.shape) orelse unreachable; + var waker = CandidateWaker{ .bm = bm, .exclude = c.inner_body }; + _ = bp.queryAabb( + body_manager_mod.worldAabb(record, position.add(baseToCentre(Real, c.height)), Quatr.identity), + &waker, + ); + } + + /// The verdict the last `moveCharacter` reported, or null on a stale handle. `.in_air` after a + /// `setCharacterPosition`, which invalidates it. + pub fn reportedGround(self: *const CharacterStore, id: CharacterId) ?GroundState { + const idx = self.alloc.validate(id) orelse return null; + return self.characters.items[idx].reported_ground; + } + + /// Mirror a TARGET pose and size onto the presence — the body AND its broadphase proxy — for a + /// target the caller has not yet written into the record. + /// + /// **The one place that mirror is written.** The pose lives twice, in the record and in the + /// body, and three entries write both; miss the proxy on any one of them and queries answer at + /// the previous pose, which no test on a stationary character would find. So the three entries + /// call this, and the freshness test is per write path rather than once on the move. + /// + /// **It takes the target rather than reading the record, so that the ONE FALLIBLE STEP RUNS + /// BEFORE ANY MUTATION.** `Broadphase.update` allocates, and an earlier version called it AFTER + /// the commit. Two distinct consequences, both real: + /// + /// - In `resizeCharacter` the record and the presence body already pointed at the new shape, + /// which the `errdefer` then destroyed — the character was left holding a freed shape, a + /// use-after-free on its next move. + /// - On the two pose paths the record and the body had moved while the proxy had not, so the + /// stored box no longer contained the body and pairs were silently lost. + /// + /// The box published is the NEW one alone. An interim form published the UNION of the old and the + /// new, and it was wrong twice. `Bvh.update` returns WITHOUT refitting as soon as its stored fat + /// box already contains the new tight one, so a teleport's leaf covered the whole trajectory and + /// no later call ever shrank it — permanent false positives in queries and in pair generation all + /// along the path, from a form whose own comment claimed the next call would refit it. And the + /// failure mode the union was guarding does not exist: `Broadphase.update` RESERVES its moved-log + /// slot before touching the node, so on OOM neither the node's box nor the log has moved. That + /// entry is already atomic and already satisfies the reserve-then-mutate invariant + /// (M1.1.1-HF1 D3/D4) this one leans on. + /// + /// The containment short-circuit is therefore not a hazard but the fat margin's normal regime: a + /// small advance does not refit, and does not need to, because the stored box still contains the + /// body. + fn syncPresenceTo( + self: *const CharacterStore, + gpa: std.mem.Allocator, + bp: *Broadphase, + bm: *BodyManager, + store: *const ShapeStore, + idx: u24, + base: Vec3r, + shape: api.ShapeId, + height: Real, + ) !void { + const c = self.characters.items[idx]; + const body = c.inner_body orelse return; + const record = store.get(shape) orelse unreachable; + const centre = base.add(baseToCentre(Real, height)); + if (c.presence_proxy) |proxy| { + try bp.update(gpa, proxy, body_manager_mod.worldAabb(record, centre, Quatr.identity)); + } + // Infallible from here. NON-ACTIVATING by contract (§1.8.4) — this is the controller's own + // write path, and the wake it owes is composed by the caller from the bodies it TOUCHED. + bm.setPosition(body, centre); + } + + /// The ground verdict for character `id` at its CURRENT pose — the controller is the + /// engine's single source of the ground (§1.12.5), and no system re-derives one by a + /// parallel raycast. + /// + /// **The verdict cannot come from manifolds at the current pose, and that is the trap of + /// this mechanism.** `collideOrdered` returns null on a separated pair, and a character at + /// rest is `padding` ABOVE its floor — so a manifold-only probe reports `.in_air` for a + /// character plainly standing up. The ground is found by a bounded DOWNWARD SWEEP of the + /// capsule instead, and the manifold is the fallback for the one case a sweep cannot answer + /// (see `GroundCollector.add`). + /// + /// Selection: candidates are contacts whose outward normal has a strictly positive + /// component on up; the winner is the FLATTEST of them, ties broken by the smaller + /// `(BodyId, subshape_id)`. Verdict: the winner passing `normal · up >= cos_max_slope` is + /// `.grounded`; a candidate existing but not passing is `.on_steep_ground`; no candidate at + /// all is `.in_air`. + /// + /// Errors: `error.StaleCharacter` on a dead handle. A live character whose capsule has + /// somehow left the store is a programming error rather than a caller's, and is asserted. + pub fn groundOf( + self: *const CharacterStore, + bp: *const Broadphase, + bm: *const BodyManager, + store: *const ShapeStore, + id: CharacterId, + ) CharacterError!GroundInfo { + const idx = self.alloc.validate(id) orelse return error.StaleCharacter; + const c = self.characters.items[idx]; + // The store owns this capsule for the character's whole life, so its absence is an + // internal invariant violation and not something a caller can provoke. + const record = store.get(c.shape) orelse unreachable; + + const centre = c.position.add(baseToCentre(Real, c.height)); + var collector = GroundCollector{ + .bm = bm, + .store = store, + .probe = shape_mod.supportShape(record), + .centre = centre, + .max_sweep = groundSweepDistance(c), + .layer_mask = c.layer_mask, + .exclude = c.inner_body, + }; + // The swept traversal of §1.11.10, in the form the query family already uses: nodes + // inflated by the probe's half-extents, and the ray starting at the CENTRE of the + // probe's world box. For a capsule that centre IS `centre`, the local box being + // origin-centred — but it is read from the box rather than assumed, because that + // equality is a property of this shape and not of the model. + const box = body_manager_mod.worldAabb(record, centre, Quatr.identity); + _ = bp.queryCast(Ray.init(box.center(), up.neg()), box.halfExtents(), &collector); + + const winner = collector.best orelse return .{}; + return .{ + .state = if (winner.align_up >= c.cos_max_slope) .grounded else .on_steep_ground, + .normal = winner.normal, + .entity = bm.entity(winner.body) orelse EntityId.dead, + .body = winner.body, + .velocity = contactPointVelocity(bm, winner.body, winner.point), + }; + } +}; + +/// Velocity of the support AT the contact point: `v + ω × r`, with `r` running from the +/// support's centre of mass to that point. +/// +/// **Without the rotational term a character standing at the rim of a rotating platform +/// drifts** (§1.12.5) — the platform's linear velocity is zero there while the surface under +/// the character is plainly moving. The centre of mass is the body's stored pose: every shape +/// the store builds is centred on its own origin. +/// +/// Zero for a body whose handle went stale between the traversal and here, which cannot happen +/// within one call and is answered rather than asserted because zero is the correct velocity +/// of a support that is not there. +fn contactPointVelocity(bm: *const BodyManager, body: BodyId, point: Vec3r) Vec3r { + const linear = bm.linearVelocity(body) orelse return Vec3r.zero; + const angular = bm.angularVelocity(body) orelse return Vec3r.zero; + const centre_of_mass = bm.position(body) orelse return Vec3r.zero; + return linear.add(angular.cross(point.sub(centre_of_mass))); +} + +/// Widen a descriptor `f32` `Vec3` to solver precision. The public surface is `f32` +/// (§1.11.8, §1.12.11) and widening it is one grouped decision at M1.1.15; this is the +/// abstraction point, so that decision touches the conversions and no call site. +fn convVec3(v: math.Vec3) Vec3r { + if (Real == f32) return v; + const a = v.toArray(); + return Vec3r.fromArray(.{ a[0], a[1], a[2] }); +} diff --git a/src/modules/forge/forge_3d/rigid/island_manager.zig b/src/modules/forge/forge_3d/rigid/island_manager.zig index 5b02599..e8835cc 100644 --- a/src/modules/forge/forge_3d/rigid/island_manager.zig +++ b/src/modules/forge/forge_3d/rigid/island_manager.zig @@ -33,10 +33,16 @@ //! Determinism by construction (M1.1.14). No hash container anywhere: the reverse //! `BodyId` → dense-index direction is a scratch array indexed by slot, members are //! sorted ascending by `BodyId`, ranks are assigned by walking members in that -//! order, and constraints are ordered by the COMPOSITE key `(rank, pair_key)` — so -//! the result never depends on the sort algorithm's stability. Rank, membership and -//! constraint order are a pure function of two inputs: the awake dynamic set, and -//! the pair-key-sorted constraint array. +//! order, and constraints are ordered by the COMPOSITE key +//! `(rank, pair_key, subshape_id)` — so the result never depends on the sort +//! algorithm's stability. Rank, membership and constraint order are a pure function +//! of two inputs: the awake dynamic set, and the pair-key-sorted constraint array. +//! +//! That key is a TRIPLET and not a pair, and the third term is what makes the +//! sentence above true: since M1.1.11.1 a mesh pair contributes one constraint per +//! contacting triangle, all sharing `rank` and `pair_key`, and on those two alone +//! their relative order would be `std.sort.block`'s internal tie-handling — which is +//! UNSTABLE. `lessByCompositeKey` is the authority. const std = @import("std"); const api = @import("weld_forge"); @@ -78,8 +84,8 @@ const Member = struct { slot: u32, }; -/// The composite sort key of one constraint — `(rank, pair_key)` — plus where the -/// constraint currently sits, so the array can be permuted into key order. +/// The composite sort key of one constraint — `(rank, pair_key, subshape_id)` — plus +/// where the constraint currently sits, so the array can be permuted into key order. pub const ConstraintKey = struct { rank: u32, pair_key: u64, @@ -157,9 +163,9 @@ pub const IslandManager = struct { /// wakes. /// /// `constraints` must be the array `contact_constraint.build` produced, sorted - /// ascending by pair key; it is permuted in place into `(rank, pair_key)` order. - /// Both accumulated impulses and every other field ride along untouched — only - /// the ORDER changes. + /// ascending by pair key; it is permuted in place into + /// `(rank, pair_key, subshape_id)` order. Both accumulated impulses and every + /// other field ride along untouched — only the ORDER changes. /// /// On the two wake causes this step owns: W3 is applied here as a real wake, so /// a group on a moving support restarts its window every tick and cannot @@ -336,9 +342,9 @@ pub const IslandManager = struct { } } - /// Reorder `constraints` into `(rank, pair_key)` order and record each island's - /// index range. The composite key is explicit, so the ordering never leans on - /// the sort algorithm being stable. + /// Reorder `constraints` into `(rank, pair_key, subshape_id)` order and record + /// each island's index range. The composite key is explicit AND total, so the + /// ordering never leans on the sort algorithm being stable. fn orderConstraints( self: *IslandManager, gpa: std.mem.Allocator, diff --git a/src/modules/forge/forge_3d/root.zig b/src/modules/forge/forge_3d/root.zig index 349607a..e21d9e1 100644 --- a/src/modules/forge/forge_3d/root.zig +++ b/src/modules/forge/forge_3d/root.zig @@ -41,6 +41,9 @@ const sleep_mod = @import("pipeline/sleep.zig"); // broadphase ray traversal + the exact kernels). Re-exported as the `query` // namespace below; the comptime pin analyses its acceptance tests. const query_mod = @import("query/root.zig"); +// M1.1.12 — the kinematic character controller's store. Re-exported below; the +// comptime pin analyses its acceptance suite. +const character_mod = @import("character.zig"); // --- Solver scalar + math aliases --- @@ -219,6 +222,42 @@ pub fn raySupportsShape(support_shape: SupportShape) bool { /// `(bp, bm, store)`. pub const query = query_mod; +// --- Character controller (M1.1.12) --- + +/// Generational store of character controllers (`engine-physics-forge.md` §1.12). A +/// controller is VIRTUAL: it takes part in no solver pass, and its pose is written by +/// `moveCharacter` alone. Bound to `Real` through the module's own `config.zig`. +pub const CharacterStore = character_mod.CharacterStore; +/// One stored controller — authored parameters at solver precision, plus the capsule and +/// the optional presence the store owns the lifetime of. `position` is the capsule's BASE. +pub const Character = character_mod.Character; +/// Every way a `CharacterDescriptor` can be malformed, plus the stale handle — each its own +/// typed error, and never sanitised. +pub const CharacterError = character_mod.CharacterError; +/// The ground verdict and its four companion quantities at solver precision — the internal +/// mirror of `CharacterMoveResult`'s five `ground_*` fields (§1.12.5). Every default is the +/// `.in_air` answer, so the safe failure direction is the struct's zero value. +pub const GroundInfo = character_mod.GroundInfo; +/// How far down the ground probe looks: `padding + predictive_contact_distance`, the band in +/// which "the ground I am standing on" is a meaningful question. +pub const groundSweepDistance = character_mod.groundSweepDistance; +/// What one `moveCharacter` returns: the resolved BASE position plus the ground verdict at that +/// new pose. No remaining displacement and no collision counter — a caller that wants to know +/// whether it was blocked compares what it asked for against what it got. +pub const CharacterMoveResult = character_mod.MoveResult; +/// The slide loop's iteration ceiling. Exhausting it stops the character SHORT, never further. +pub const max_slide_iterations = character_mod.max_slide_iterations; +/// The depenetration loop's iteration ceiling, same discipline and same failure direction. +pub const max_depenetration_iterations = character_mod.max_depenetration_iterations; +/// The CYLINDER half-height of a capsule of a given total height and radius. Re-exported for the +/// same reason as `baseToCentre` below: it is the one named place that conversion exists, and +/// `resizeCharacter` shares it with creation so a resize cannot derive it differently. +pub const capsuleHalfHeight = character_mod.capsuleHalfHeight; +/// The offset from a character's BASE to the CENTRE of its capsule — half the height along +/// `+Y`. Re-exported because it is THE one named place that offset exists, and a consumer +/// deriving it a second time is the defect the single definition prevents. +pub const baseToCentre = character_mod.baseToCentre; + // --- Islands (branch-neutral partition core) --- /// The island partition core: a union-find over opaque element indices, shared by @@ -264,6 +303,7 @@ comptime { _ = island_mod; _ = sleep_mod; _ = query_mod; + _ = character_mod; _ = @import("tests/body_manager_test.zig"); _ = @import("tests/integration_test.zig"); _ = @import("tests/broadphase_test.zig"); @@ -281,4 +321,5 @@ comptime { _ = @import("tests/overlap_test.zig"); _ = @import("tests/plane_test.zig"); _ = @import("tests/mesh_test.zig"); + _ = @import("tests/character_test.zig"); } diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig new file mode 100644 index 0000000..32cfbe1 --- /dev/null +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -0,0 +1,3243 @@ +//! Acceptance suite for the kinematic character controller (M1.1.12). +//! +//! Grows gate by gate. Gate B covers the seventh body-level adapter +//! (`BodyManager.collideShapeBody`) and the character STORE: creation, destruction, the +//! domain rejections, transactional rollback, the three outcomes of +//! `getCharacterInnerBody`, and the presence's visibility to queries. Nothing that moves — +//! `moveCharacter`, the ground verdict, resize and the push have their own gates. +//! +//! Every expectation is a CLOSED FORM computed by hand in the comment above it, never a +//! value read back from the implementation. Where a transcendental is unavoidable the angle +//! is chosen so its cosine IS a closed form: `cos(π/3) = 1/2`, `cos(π/4) = √2/2`. + +const std = @import("std"); +const config = @import("../config.zig"); +const shape_mod = @import("../shape.zig"); +const bm_mod = @import("../body_manager.zig"); +const character_mod = @import("../character.zig"); +const narrowphase = @import("../pipeline/narrowphase/root.zig"); +const query = @import("../query/root.zig"); +const api = @import("weld_forge"); +const foundation = @import("foundation"); +const harness = @import("solver_test.zig"); +const sleep_mod = @import("../pipeline/sleep.zig"); + +const Real = config.Real; +const Vec3r = config.Vec3r; +const Quatr = config.Quatr; +const BodyManager = bm_mod.BodyManager; +const ShapeStore = shape_mod.ShapeStore; +const CharacterStore = character_mod.CharacterStore; +const CharacterError = character_mod.CharacterError; +const math = foundation.math; +const ApiVec3 = math.Vec3; +const SupportShapeR = narrowphase.SupportShape(Real); +const Bp = @import("../pipeline/broadphase.zig").Broadphase(Real); +const testing = std.testing; + +/// Absolute tolerance for a quantity computed at SOLVER precision — float noise at the +/// unit-to-ten scale these tests work at, not geometric slack. +const tol: Real = if (Real == f32) 1e-5 else 1e-12; + +/// Absolute tolerance for a quantity that arrived through the PUBLIC `f32` surface and was +/// then widened. It is `f32`-grade in BOTH builds, and deliberately so. +/// +/// `CharacterDescriptor` is `f32` and stays `f32` until the grouped widening decision of +/// M1.1.15 (§1.11.8, §1.12.11), so a field authored as `0.3` is stored as `f32(0.3)` — which +/// widened is `0.30000001192…`, not `0.3`. Asserting such a value against a decimal literal +/// at `tol` would be asserting a precision the descriptor CANNOT carry, and it is what made +/// six of these tests fail at `-Dphysics_f64=true` while passing at `f32`, where the two +/// tolerances happen to coincide. +/// +/// The bound is sized on `floatEps(f32)` times the largest magnitude here (about 10 m), which +/// is `1.2e-6`, with a factor of ten of headroom. Anything the solver itself computes keeps +/// `tol`: the distinction is the QUANTITY'S ORIGIN, not whether a literal is representable. +const api_tol: Real = 1e-5; + +fn v(x: Real, y: Real, z: Real) Vec3r { + return Vec3r.fromArray(.{ x, y, z }); +} + +fn av(x: f32, y: f32, z: f32) ApiVec3 { + return ApiVec3.fromArray(.{ x, y, z }); +} + +fn ent(index: u32) api.EntityId { + return .{ .index = index, .generation = 0 }; +} + +/// A descriptor whose every field is the frozen default, with only the entity supplied — +/// so a test that overrides one field is visibly testing that field. +fn baseDescriptor() api.CharacterDescriptor { + return .{ .entity = ent(1) }; +} + +// --------------------------------------------------------------------------- +// B.1 — the seventh adapter: `collideShapeBody` +// --------------------------------------------------------------------------- + +/// Every manifold `collideShapeBody` offers, tagged with its sub-shape. Same shape as +/// `mesh_test.zig`'s tally: the collector contract is `add(subshape_id, manifold)`. +const Tally = struct { + ids: [32]u32 = @splat(0), + normals: [32]Vec3r = @splat(Vec3r.zero), + penetrations: [32]Real = @splat(0), + count: u32 = 0, + + pub fn add(self: *Tally, subshape_id: u32, manifold: narrowphase.ContactManifold(Real)) void { + self.ids[self.count] = subshape_id; + self.normals[self.count] = manifold.normal; + self.penetrations[self.count] = manifold.points[0].penetration; + self.count += 1; + } + + fn has(self: *const Tally, subshape_id: u32) bool { + for (self.ids[0..self.count]) |id| { + if (id == subshape_id) return true; + } + return false; + } +}; + +test "collideShapeBody answers against a convex body, normal probe to body" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + + // A 1 m half-extent box centred at the origin. Its +X face is at x = 1. + const box = try store.createShape(gpa, .{ .box = .{ .half_extents = ApiVec3.splat(1) } }); + const body = try bm.addBody(gpa, &store, .{ + .entity = ent(7), + .body_type = .static, + .shape = box, + .position = av(0, 0, 0), + }); + + // A unit sphere probe centred at x = 1.5: its surface reaches x = 0.5, so it overlaps + // the box's +X face by exactly 1 + 1 − 1.5 = 0.5 m. The probe is A and the body is B, + // so the normal runs probe → body, i.e. −X. + const probe = SupportShapeR{ .core = .point, .radius = 1 }; + var tally = Tally{}; + bm.collideShapeBody(&store, body, probe, v(1.5, 0, 0), Quatr.identity, &tally); + + try testing.expectEqual(@as(u32, 1), tally.count); + try testing.expect(tally.normals[0].approxEql(v(-1, 0, 0), tol)); + try testing.expectApproxEqAbs(@as(Real, 0.5), tally.penetrations[0], tol); + // A shape with no sub-shape tags `0`, and that value is not read (§1.11.16). + try testing.expectEqual(@as(u32, 0), tally.ids[0]); +} + +test "collideShapeBody answers against a half-space body" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + + // The ground half-space `{ y <= 0 }`, which forces a static body (§1.11.15). + const plane = try store.createShape(gpa, .{ .plane = .{ .normal = av(0, 1, 0), .distance = 0 } }); + const body = try bm.addBody(gpa, &store, .{ + .entity = ent(3), + .body_type = .static, + .shape = plane, + .position = av(0, 0, 0), + }); + + // A unit sphere probe centred at y = 0.75 dips to y = −0.25, so it penetrates the solid + // by 0.25 m. Normal probe → body is −Y: the plane's outward normal is +Y and the entry + // negates the body→probe form §1.11.15's formulas are stated in. + const probe = SupportShapeR{ .core = .point, .radius = 1 }; + var tally = Tally{}; + bm.collideShapeBody(&store, body, probe, v(0, 0.75, 0), Quatr.identity, &tally); + + try testing.expectEqual(@as(u32, 1), tally.count); + try testing.expect(tally.normals[0].approxEql(v(0, -1, 0), tol)); + try testing.expectApproxEqAbs(@as(Real, 0.25), tally.penetrations[0], tol); +} + +test "collideShapeBody returns SEVERAL manifolds against a mesh, one per contacting triangle" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + + // A flat quad at y = 0 spanning x, z in [−1, 1], as TWO triangles sharing the diagonal + // from (−1, −1) to (1, 1). Winding is counter-clockwise seen from the front face, so the + // outward normal is +Y for both — verified by hand: + // tri 0 = v0, v1, v2 : (v1−v0) × (v2−v0) = (2,0,2) × (2,0,0) = (0, 4, 0) → +Y + // tri 1 = v0, v3, v1 : (v3−v0) × (v1−v0) = (0,0,2) × (2,0,2) = (0, 4, 0) → +Y + const verts = [_]ApiVec3{ + av(-1, 0, -1), // v0 — on the shared diagonal + av(1, 0, 1), // v1 — on the shared diagonal + av(1, 0, -1), // v2 + av(-1, 0, 1), // v3 + }; + const idx = [_]u32{ 0, 1, 2, 0, 3, 1 }; + const mesh = try store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &idx } }); + const body = try bm.addBody(gpa, &store, .{ + .entity = ent(9), + .body_type = .static, + .shape = mesh, + .position = av(0, 0, 0), + }); + + // A radius-0.5 sphere centred at (0, 0.4, 0) dips to y = −0.1. The point (0, 0) lies ON + // the shared diagonal, hence inside BOTH triangles (the boundary is included), so the + // closest point of each to the probe centre is (0, 0, 0) at distance 0.4 < 0.5 — both + // overlap, and the entry must report both. + // + // ABOVE the plane on purpose: contact generation culls back faces unconditionally, so a + // probe centred below y = 0 would be culled on both triangles and report nothing. That + // is the correct behaviour and it is asserted separately below. + const probe = SupportShapeR{ .core = .point, .radius = 0.5 }; + var tally = Tally{}; + bm.collideShapeBody(&store, body, probe, v(0, 0.4, 0), Quatr.identity, &tally); + + try testing.expectEqual(@as(u32, 2), tally.count); + try testing.expect(tally.has(0)); + try testing.expect(tally.has(1)); + // Both normals run probe → body, i.e. downward onto the floor, and the internal-edge + // correction (the diagonal is paired and FLAT, so inactive) pins them to exactly −Y. + for (tally.normals[0..tally.count]) |n| { + try testing.expect(n.approxEql(v(0, -1, 0), tol)); + } + + // THE DISCRIMINATOR, without which "2" could be an artefact of the entry offering every + // triangle it traverses rather than every triangle it CONTACTS. A probe over the interior + // of ONE triangle must report exactly one manifold, and its identity must be that + // triangle's. (0.5, 0.5) in xz lies strictly on the +x side of the diagonal x = z, so it + // is interior to triangle 0 — corners (−1,−1), (1,1), (1,−1) — and 0.5 m of clearance + // from the diagonal is far more than the radius-0.5 sphere's 0.1 m of dip can reach. + { + var one = Tally{}; + bm.collideShapeBody(&store, body, probe, v(0.5, 0.4, -0.5), Quatr.identity, &one); + try testing.expectEqual(@as(u32, 1), one.count); + try testing.expectEqual(@as(u32, 0), one.ids[0]); + } +} + +test "collideShapeBody culls a mesh back face, and answers nothing on a stale handle or a separated probe" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + + const verts = [_]ApiVec3{ av(-1, 0, -1), av(1, 0, 1), av(1, 0, -1), av(-1, 0, 1) }; + const idx = [_]u32{ 0, 1, 2, 0, 3, 1 }; + const mesh = try store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &idx } }); + const mesh_body = try bm.addBody(gpa, &store, .{ + .entity = ent(9), + .body_type = .static, + .shape = mesh, + .position = av(0, 0, 0), + }); + const probe = SupportShapeR{ .core = .point, .radius = 0.5 }; + + // BELOW the surface: the probe reaches the plane, so GJK finds an overlap, but the + // contact normal opposes the outward normal and every triangle is culled. Zero + // manifolds — a contact generated on the back of a wall would push the body through it. + { + var tally = Tally{}; + bm.collideShapeBody(&store, mesh_body, probe, v(0, -0.4, 0), Quatr.identity, &tally); + try testing.expectEqual(@as(u32, 0), tally.count); + } + + // Separated: 10 m above the floor, nothing is offered at all. + { + var tally = Tally{}; + bm.collideShapeBody(&store, mesh_body, probe, v(0, 10, 0), Quatr.identity, &tally); + try testing.expectEqual(@as(u32, 0), tally.count); + } + + // A stale handle: the collector is never called. Distinct from "separated" only in + // cause, but the adapter must not touch a freed slot to find that out. + { + const box = try store.createShape(gpa, .{ .box = .{ .half_extents = ApiVec3.splat(1) } }); + const doomed = try bm.addBody(gpa, &store, .{ + .entity = ent(4), + .body_type = .static, + .shape = box, + .position = av(0, 0, 0), + }); + bm.removeBody(doomed); + var tally = Tally{}; + bm.collideShapeBody(&store, doomed, probe, Vec3r.zero, Quatr.identity, &tally); + try testing.expectEqual(@as(u32, 0), tally.count); + } +} + +// --------------------------------------------------------------------------- +// B.2 — the character store +// --------------------------------------------------------------------------- + +test "createCharacter stores the descriptor and poses the presence at the capsule CENTRE" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // Defaults except the base position and the slope: radius 0.3, height 1.8, so the + // capsule's cylinder half-height is 1.8/2 − 0.3 = 0.6 and the capsule spans 1.8 m. + // `max_slope = π/3` is chosen because its cosine is EXACTLY 1/2 — a closed form, where + // the default 0.785 rad would force reading a transcendental back. + var desc = baseDescriptor(); + desc.position = av(2, 5, -3); + desc.max_slope = std.math.pi / 3.0; + const id = try chars.createCharacter(gpa, &store, &bm, desc); + + try testing.expectEqual(@as(u32, 1), chars.count()); + const c = chars.get(id).?; + try testing.expect(c.position.approxEql(v(2, 5, -3), tol)); + try testing.expectApproxEqAbs(@as(Real, 0.3), c.radius, api_tol); + try testing.expectApproxEqAbs(@as(Real, 1.8), c.height, api_tol); + try testing.expectApproxEqAbs(@as(Real, 0.3), c.step_height, api_tol); + try testing.expectApproxEqAbs(@as(Real, 0.5), c.cos_max_slope, api_tol); + try testing.expectApproxEqAbs(@as(Real, 0.02), c.padding, api_tol); + try testing.expectApproxEqAbs(@as(Real, 0.1), c.predictive_contact_distance, api_tol); + try testing.expectEqual(@as(u8, 0), c.collision_layer); + try testing.expectEqual(@as(u32, 0xFFFFFFFF), c.layer_mask); + try testing.expectApproxEqAbs(@as(Real, 70), c.mass, api_tol); + try testing.expectApproxEqAbs(@as(Real, 100), c.max_push_force, api_tol); + try testing.expectEqual(ent(1), c.entity); + + // The capsule the store built, read back through the shape it owns. + const capsule = store.get(c.shape).?; + try testing.expectEqual(api.ShapeType.capsule, capsule.shape_type); + try testing.expectApproxEqAbs(@as(Real, 0.3), capsule.radius, api_tol); + try testing.expectApproxEqAbs(@as(Real, 0.6), capsule.half_height, api_tol); + + // THE OFFSET. The character's position is its BASE at y = 5; the presence is a body, so + // its pose is the CENTRE of the capsule, half the height higher: y = 5 + 0.9 = 5.9. X + // and Z are untouched — the offset is along up alone. + const presence = (try chars.getCharacterInnerBody(id)).?; + try testing.expect(bm.position(presence).?.approxEql(v(2, 5.9, -3), api_tol)); + try testing.expectEqual(api.BodyType.kinematic, bm.bodyType(presence).?); + // The presence carries the character's own capsule, never a second shape (§1.12.2). + try testing.expectEqual(c.shape, bm.shapeOf(presence).?); + // Its entity is the character's, so a query that finds it names the character. + try testing.expectEqual(ent(1), bm.entity(presence).?); + // Its layer is `collision_layer`, with no dedicated field (§1.12.2). + try testing.expectEqual(@as(u8, 0), bm.collisionLayer(presence).?); +} + +test "the stored cosine is the single trigonometric call, and a larger cosine is a stricter limit" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // cos(π/4) = √2/2 ≈ 0.7071067811865476, and cos(π/3) = 1/2 — both closed forms. + var steep = baseDescriptor(); + steep.max_slope = std.math.pi / 4.0; + const a = try chars.createCharacter(gpa, &store, &bm, steep); + + var shallow = baseDescriptor(); + shallow.max_slope = std.math.pi / 3.0; + const b = try chars.createCharacter(gpa, &store, &bm, shallow); + + const root2: Real = @sqrt(@as(Real, 2)); + try testing.expectApproxEqAbs(root2 / 2, chars.get(a).?.cos_max_slope, api_tol); + try testing.expectApproxEqAbs(@as(Real, 0.5), chars.get(b).?.cos_max_slope, api_tol); + + // The direction of the comparison, pinned because it is easy to invert: the test is + // `n · up >= cos_max_slope`, so the SMALLER angle stores the LARGER cosine and admits + // fewer slopes. + try testing.expect(chars.get(a).?.cos_max_slope > chars.get(b).?.cos_max_slope); + + // A slope limit of zero admits only an exactly flat floor: cos(0) = 1. + var flat = baseDescriptor(); + flat.max_slope = 0; + const c = try chars.createCharacter(gpa, &store, &bm, flat); + try testing.expectApproxEqAbs(@as(Real, 1), chars.get(c).?.cos_max_slope, api_tol); +} + +test "destroyCharacter releases all four resources and is a no-op on a stale handle" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + var bp = Bp.init(.{}); + defer bp.deinit(gpa); + + const id = try chars.createCharacter(gpa, &store, &bm, baseDescriptor()); + const presence = (try chars.getCharacterInnerBody(id)).?; + try testing.expectEqual(@as(u32, 1), chars.count()); + try testing.expectEqual(@as(u32, 1), store.count()); + try testing.expectEqual(@as(u32, 1), bm.count()); + + chars.destroyCharacter(gpa, &bp, &store, &bm, id); + + // All three of THESE counts back to zero: the character, its capsule and its presence. The + // FOURTH resource is the broadphase proxy, which this test's character never had — the test + // below builds one that does, because a count of nodes would not say the leaf was reachable. + try testing.expectEqual(@as(u32, 0), chars.count()); + try testing.expectEqual(@as(u32, 0), store.count()); + try testing.expectEqual(@as(u32, 0), bm.count()); + try testing.expect(!bm.isValid(presence)); + try testing.expectEqual(@as(?character_mod.Character, null), chars.get(id)); + + // A SECOND destroy releases nothing — which is what keeps a double destroy from + // double-freeing the capsule. It must not fault, and the counts must not go negative + // or wrap. + chars.destroyCharacter(gpa, &bp, &store, &bm, id); + try testing.expectEqual(@as(u32, 0), chars.count()); + try testing.expectEqual(@as(u32, 0), store.count()); + try testing.expectEqual(@as(u32, 0), bm.count()); +} + +test "a recycled slot yields a different handle, so a stale one stays detectable" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + var bp = Bp.init(.{}); + defer bp.deinit(gpa); + + const first = try chars.createCharacter(gpa, &store, &bm, baseDescriptor()); + chars.destroyCharacter(gpa, &bp, &store, &bm, first); + const second = try chars.createCharacter(gpa, &store, &bm, baseDescriptor()); + + // LIFO recycling puts the second character in the freed slot, so the two handles share + // an INDEX and differ only in GENERATION — which is the whole reason the generation is + // on the handle. Without it the stale `first` would silently address `second`. + try testing.expectEqual( + api.PackedId.unpack(first).index, + api.PackedId.unpack(second).index, + ); + try testing.expect(first != second); + try testing.expectError(error.StaleCharacter, chars.getCharacterInnerBody(first)); + try testing.expect((try chars.getCharacterInnerBody(second)) != null); +} + +test "getCharacterInnerBody has three outcomes and never conflates two of them" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + var bp = Bp.init(.{}); + defer bp.deinit(gpa); + + // 1 — a live character WITH a presence: the handle. + const with = try chars.createCharacter(gpa, &store, &bm, baseDescriptor()); + const presence = (try chars.getCharacterInnerBody(with)).?; + try testing.expect(bm.isValid(presence)); + + // 2 — a live character WITHOUT one: `null`, and no body was created for it. + var none = baseDescriptor(); + none.inner_body = false; + const without = try chars.createCharacter(gpa, &store, &bm, none); + try testing.expectEqual(@as(?api.BodyId, null), try chars.getCharacterInnerBody(without)); + // Still exactly ONE body in the manager — the first character's. + try testing.expectEqual(@as(u32, 1), bm.count()); + // But two capsules: a presence-less character still owns its shape, which the move + // algorithm sweeps with. + try testing.expectEqual(@as(u32, 2), store.count()); + + // 3 — a stale handle: a typed ERROR, not `null`. Conflating it with outcome 2 would + // make a dead handle indistinguishable from a live presence-less character. + chars.destroyCharacter(gpa, &bp, &store, &bm, with); + try testing.expectError(error.StaleCharacter, chars.getCharacterInnerBody(with)); +} + +test "the descriptor domain is refused by typed error and never sanitised" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + var bp = Bp.init(.{}); + defer bp.deinit(gpa); + + const nan = std.math.nan(f32); + const inf = std.math.inf(f32); + + const Case = struct { + expected: CharacterError, + mutate: *const fn (*api.CharacterDescriptor) void, + }; + const cases = [_]Case{ + // radius: non-finite, and non-positive on both sides of zero. + .{ .expected = error.InvalidDimensions, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.radius = nan; + } + }.f }, + .{ .expected = error.InvalidDimensions, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.radius = inf; + } + }.f }, + .{ .expected = error.InvalidDimensions, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.radius = 0; + } + }.f }, + .{ .expected = error.InvalidDimensions, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.radius = -0.3; + } + }.f }, + // height: same three shapes. + .{ .expected = error.InvalidDimensions, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.height = nan; + } + }.f }, + .{ .expected = error.InvalidDimensions, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.height = 0; + } + }.f }, + .{ .expected = error.InvalidDimensions, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.height = -1.8; + } + }.f }, + // A capsule shorter than its own diameter: height 0.4 against radius 0.3 would ask + // for a cylinder half-height of −0.1. Refused, not clamped to a sphere. + .{ .expected = error.InvalidDimensions, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.height = 0.4; + } + }.f }, + // max_slope: non-finite, negative, and BEYOND VERTICAL. `π` is the case that has to + // fail rather than clamp to `π/2` — clamping would read as a modelling choice. + .{ .expected = error.InvalidSlope, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.max_slope = nan; + } + }.f }, + .{ .expected = error.InvalidSlope, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.max_slope = -0.1; + } + }.f }, + .{ .expected = error.InvalidSlope, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.max_slope = std.math.pi; + } + }.f }, + // padding: non-finite, and NEGATIVE — which inflates the capsule inward, so the + // character sinks `|padding|` into every surface with nothing reporting it. + .{ .expected = error.InvalidPadding, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.padding = nan; + } + }.f }, + .{ .expected = error.InvalidPadding, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.padding = -0.01; + } + }.f }, + // mass: non-finite, ZERO — which would duplicate `max_push_force = 0`, the documented + // disabler — and NEGATIVE, which inverts the impulse so the character pulls. + .{ .expected = error.InvalidPushParameters, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.mass = inf; + } + }.f }, + .{ .expected = error.InvalidPushParameters, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.mass = 0; + } + }.f }, + .{ .expected = error.InvalidPushParameters, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.mass = -70; + } + }.f }, + // max_push_force: non-finite and negative. Zero is the disabler and stays legal. + .{ .expected = error.InvalidPushParameters, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.max_push_force = nan; + } + }.f }, + .{ .expected = error.InvalidPushParameters, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.max_push_force = -1; + } + }.f }, + // predictive_contact_distance: guarded because it is STORED, so a caller's NaN would + // sit in the record indistinguishable from this repository's DELIBERATE poison NaN. + .{ .expected = error.InvalidDimensions, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.predictive_contact_distance = nan; + } + }.f }, + .{ .expected = error.InvalidDimensions, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.predictive_contact_distance = -0.1; + } + }.f }, + // collision_layer: the mask is 32 bits, so 32 and 255 are both invisible-to-every- + // query and both refused — the same error and the same reason as `addBody`. + .{ .expected = error.InvalidCollisionLayer, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.collision_layer = 32; + } + }.f }, + .{ .expected = error.InvalidCollisionLayer, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.collision_layer = 255; + } + }.f }, + }; + + for (cases) |case| { + var desc = baseDescriptor(); + case.mutate(&desc); + try testing.expectError(case.expected, chars.createCharacter(gpa, &store, &bm, desc)); + // NOTHING was created by a refusal — no character, no capsule, no presence. That is + // what "validate before the first allocation" buys, and it is asserted after every + // case rather than once at the end, so a leak cannot be masked by a later success. + try testing.expectEqual(@as(u32, 0), chars.count()); + try testing.expectEqual(@as(u32, 0), store.count()); + try testing.expectEqual(@as(u32, 0), bm.count()); + } + + // The legal boundaries of the same fields, so the rejections above are not a blanket + // refusal — which is what a domain test asserts second and what makes the first half + // meaningful. `max_slope` exactly at `π/2` is admissible (a vertical wall counts as + // walkable); `max_push_force` of zero disables pushing with no special case, and is the + // ONLY way to do so now that `mass = 0` is refused; `padding` of zero is no margin; + // `predictive_contact_distance` of zero is legal domain even though the reference + // documents it as a value that gets the character stuck — a bad tuning value is not a + // malformed one; and a capsule whose height is exactly twice its radius is a sphere. + { + var ok = baseDescriptor(); + ok.max_slope = std.math.pi / 2.0; + ok.max_push_force = 0; + ok.padding = 0; + ok.predictive_contact_distance = 0; + ok.height = 0.6; // exactly 2 × 0.3 → cylinder half-height 0 + ok.collision_layer = 31; // the last legal layer + const id = try chars.createCharacter(gpa, &store, &bm, ok); + try testing.expectApproxEqAbs(@as(Real, 0), store.get(chars.get(id).?.shape).?.half_height, api_tol); + // cos(π/2) = 0 exactly in mathematics; in floating point it is the tiny residue of + // the argument reduction, so the assertion is on the tolerance and not on equality. + try testing.expectApproxEqAbs(@as(Real, 0), chars.get(id).?.cos_max_slope, api_tol); + chars.destroyCharacter(gpa, &bp, &store, &bm, id); + } +} + +test "createCharacter is transactional: no allocation failure leaves a live slot or an orphan" { + // The sweep runs `fail_index` upward until the call succeeds. At every failing index the + // three stores must be EMPTY: no live character, no orphan capsule, no orphan presence. + // + // A sweep rather than one hand-written case per allocation site: the number of sites is + // then MEASURED instead of predicted, and a site added later is covered without the test + // being edited. The count it observes is reported in the brief. + var failing_indices: u32 = 0; + var fail_index: usize = 0; + while (fail_index < 64) : (fail_index += 1) { + var failing = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = fail_index }); + const gpa = failing.allocator(); + + var store: ShapeStore = .{}; + var bm: BodyManager = .{}; + var chars: CharacterStore = .{}; + + const result = chars.createCharacter(gpa, &store, &bm, baseDescriptor()); + + if (result) |_| { + // The first index at which the whole call gets through: every earlier one failed, + // so `failing_indices` is the number of allocations this call actually performs. + chars.deinit(gpa); + store.deinit(gpa); + bm.deinit(gpa); + break; + } else |err| { + try testing.expectEqual(error.OutOfMemory, err); + failing_indices += 1; + try testing.expectEqual(@as(u32, 0), chars.count()); + try testing.expectEqual(@as(u32, 0), store.count()); + try testing.expectEqual(@as(u32, 0), bm.count()); + chars.deinit(gpa); + store.deinit(gpa); + bm.deinit(gpa); + } + } + + // NON-VACUITY: the sweep has to have exercised at least one failure, otherwise it + // asserted nothing at all. Four allocations are structurally required — the shape store's + // slot metadata and its column, then the body manager's two — plus this store's own two, + // so the count cannot be zero and cannot be one. + try testing.expect(failing_indices >= 2); + // And it must have TERMINATED by succeeding, not by exhausting the loop bound. + try testing.expect(failing_indices < 64); +} + +// --------------------------------------------------------------------------- +// The presence is an ordinary body to a query +// --------------------------------------------------------------------------- + +/// Create a character in `world` and insert its presence's broadphase proxy, which +/// `createCharacter` deliberately does not do: no `BodyType → BroadphaseLayer` wiring exists +/// — the layer is an insertion argument — and it arrives with `PhysicsWorld` at M1.1.15. +/// +/// The proxy is inserted here rather than through `harness.World.addBody`, which would need +/// the descriptor the store built internally. It is not registered in `world.bodies`, whose +/// only consumer is the W4 wake sweep, and nothing in this gate solves. +fn addCharacter( + gpa: std.mem.Allocator, + world: *harness.World, + chars: *CharacterStore, + desc: api.CharacterDescriptor, +) !api.CharacterId { + const id = try chars.createCharacter(gpa, &world.store, &world.bm, desc); + if (try chars.getCharacterInnerBody(id)) |presence| { + _ = try world.bp.insert( + gpa, + .dynamic, // a kinematic body shares the dynamic layer, per the harness's own map + world.bm.bodyAabb(&world.store, presence).?, + presence, + ); + } + return id; +} + +test "a ray from outside finds the presence, and excluding its BodyId hides it" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // Base at the origin, defaults: radius 0.3, height 1.8. The capsule therefore spans + // y ∈ [0, 1.8] with its cylinder wall at radius 0.3 about the Y axis between y = 0.3 and + // y = 1.5. + var desc = baseDescriptor(); + desc.entity = ent(42); + const id = try addCharacter(gpa, &world, &chars, desc); + const presence = (try chars.getCharacterInnerBody(id)).?; + + // A ray from (−10, 0.9, 0) along +X strikes the cylinder wall at x = −0.3, so the + // distance is exactly 10 − 0.3 = 9.7 and the outward normal there is −X. + const q = query.RayQuery{ .origin = v(-10, 0.9, 0), .direction = v(1, 0, 0), .max_distance = 100 }; + const hit = (query.raycast(&world.bp, &world.bm, &world.store, q)).?; + try testing.expectEqual(presence, hit.body); + // The ENTITY is the character's, which is what makes the character shootable: a caller + // resolves damage against an entity, never against a `BodyId`. + try testing.expectEqual(ent(42), hit.entity); + try testing.expectApproxEqAbs(@as(Real, 9.7), hit.distance, api_tol); + try testing.expect(hit.normal.approxEql(v(-1, 0, 0), tol)); + + // The SAME ray, excluding the presence: nothing. This is the anti-wall sweep of a follow + // camera and the character's own probes — `PhysicsQueryFilter.exclude` takes `BodyId` + // alone, which is why `getCharacterInnerBody` has to exist (§1.12.2). + const excluded = query.RayQuery{ + .origin = v(-10, 0.9, 0), + .direction = v(1, 0, 0), + .max_distance = 100, + .filter = .{ .exclude = &.{presence} }, + }; + try testing.expectEqual(@as(?query.RayHit, null), query.raycast(&world.bp, &world.bm, &world.store, excluded)); +} + +test "inner_body false leaves the character invisible to every query" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + var desc = baseDescriptor(); + desc.inner_body = false; + const id = try addCharacter(gpa, &world, &chars, desc); + + try testing.expectEqual(@as(?api.BodyId, null), try chars.getCharacterInnerBody(id)); + + // The same ray that hit the presence above finds nothing at all — which is exactly the + // failure mode `inner_body`'s default of `true` exists to avoid: a character nobody can + // shoot, discovered late. + const q = query.RayQuery{ .origin = v(-10, 0.9, 0), .direction = v(1, 0, 0), .max_distance = 100 }; + try testing.expectEqual(@as(?query.RayHit, null), query.raycast(&world.bp, &world.bm, &world.store, q)); + // Nor does an overlap, which walks the trees rather than a ray. + var out: [4]api.BodyId = undefined; + const probe = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 5 } }); + const n = try query.overlapShape(&world.bp, &world.bm, &world.store, .{ + .shape = probe, + .position = v(0, 0.9, 0), + }, &out); + try testing.expectEqual(@as(u32, 0), n); +} + +test "two characters each see the other's presence and neither is hidden by the other" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // Two characters 4 m apart on X, so their capsules (radius 0.3) are nowhere near + // touching. Self-exclusion is the CONTROLLER's business, not the store's — the store + // hands out the handles and each caller excludes its own (§1.12.2). + var left = baseDescriptor(); + left.entity = ent(1); + left.position = av(-2, 0, 0); + const a = try addCharacter(gpa, &world, &chars, left); + + var right = baseDescriptor(); + right.entity = ent(2); + right.position = av(2, 0, 0); + const b = try addCharacter(gpa, &world, &chars, right); + + const pa = (try chars.getCharacterInnerBody(a)).?; + const pb = (try chars.getCharacterInnerBody(b)).?; + try testing.expect(pa != pb); + + // A ray cast from A's own position along +X, EXCLUDING A: it must find B. The wall at + // x = 2 − 0.3 = 1.7 is 3.7 m from x = −2. + const q = query.RayQuery{ + .origin = v(-2, 0.9, 0), + .direction = v(1, 0, 0), + .max_distance = 100, + .filter = .{ .exclude = &.{pa} }, + }; + const hit = (query.raycast(&world.bp, &world.bm, &world.store, q)).?; + try testing.expectEqual(pb, hit.body); + try testing.expectEqual(ent(2), hit.entity); + try testing.expectApproxEqAbs(@as(Real, 3.7), hit.distance, api_tol); + + // And symmetrically from B, excluding B, back along −X. + const back = query.RayQuery{ + .origin = v(2, 0.9, 0), + .direction = v(-1, 0, 0), + .max_distance = 100, + .filter = .{ .exclude = &.{pb} }, + }; + const hit_back = (query.raycast(&world.bp, &world.bm, &world.store, back)).?; + try testing.expectEqual(pa, hit_back.body); + try testing.expectApproxEqAbs(@as(Real, 3.7), hit_back.distance, api_tol); +} + +test "baseToCentre is the one offset, and it agrees at both precisions" { + // Half the height along +Y, and nothing on X or Z. + const off = character_mod.baseToCentre(Real, 1.8); + try testing.expect(off.approxEql(v(0, 0.9, 0), api_tol)); + + // The `f32` form the presence's descriptor is built from and the `Real` form every later + // pose write uses must agree BIT for bit on the SAME height: halving is exact in binary + // floating point and widening is exact, so `widen(h · 0.5) == widen(h) · 0.5`. If that ever + // stopped holding, the base↔centre offset would exist at two values. + // + // The height is taken from ONE `f32` variable and widened, not written as the same decimal + // literal at two precisions — `f32(1.8)` and `f64(1.8)` are DIFFERENT NUMBERS, so a literal + // on each side would compare two different inputs and prove nothing about the offset. The + // first form of this assertion made exactly that mistake and the `f64` leg caught it. + const h_f32: f32 = 1.8; + const h_real: Real = h_f32; + const from_f32: Real = character_mod.baseToCentre(f32, h_f32).toArray()[1]; + const from_real = character_mod.baseToCentre(Real, h_real).toArray()[1]; + try testing.expectEqual(from_real, from_f32); +} + +// --------------------------------------------------------------------------- +// C — ground determination. No motion: a character is PLACED, and asked. +// --------------------------------------------------------------------------- +// +// The mechanism is a bounded DOWNWARD SWEEP and not a manifold at the current pose, and the +// scene layout of every test below depends on knowing why: `collideOrdered` returns null on a +// separated pair, and a character at rest is `padding` ABOVE its floor, so a manifold-only +// probe would report `.in_air` for a character plainly standing up. Most tests here therefore +// place the capsule `padding` above its surface — the resting configuration — and one places +// it OVERLAPPING, to exercise the manifold fallback the sweep cannot answer. + +/// A static half-space body, whose normal `groundOf` must return VERBATIM (§1.11.15). +fn addPlane(gpa: std.mem.Allocator, world: *harness.World, normal: ApiVec3, distance: f32, entity_index: u32) !api.BodyId { + const shape = try world.store.createShape(gpa, .{ .plane = .{ .normal = normal, .distance = distance } }); + return world.addBody(gpa, .{ + .entity = ent(entity_index), + .body_type = .static, + .shape = shape, + .position = av(0, 0, 0), + }); +} + +/// The unit normal of a plane tilted `deg` away from horizontal, in the XY plane: its up +/// component is `cos(deg)`, so `deg` IS the slope angle the `max_slope` test compares against. +fn slopeNormal(deg: f32) ApiVec3 { + const rad = deg * std.math.pi / 180.0; + return av(@sin(rad), @cos(rad), 0); +} + +test "a capsule resting on a plane is grounded, with the plane's own normal" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + const floor = try addPlane(gpa, &world, av(0, 1, 0), 0, 11); + + // Base at y = padding = 0.02, the resting configuration. The capsule's lower core endpoint + // is then at y = 0.02 + 0.3 = 0.32, so its separation from `{y <= 0}` is 0.32 − 0.3 = 0.02 + // — inside the 0.02 + 0.1 = 0.12 m sweep band, and STRICTLY POSITIVE, so the sweep path + // answers and the manifold fallback is not taken. + var desc = baseDescriptor(); + desc.entity = ent(50); + desc.position = av(0, 0.02, 0); + const id = try addCharacter(gpa, &world, &chars, desc); + + const g = try chars.groundOf(&world.bp, &world.bm, &world.store, id); + try testing.expectEqual(api.GroundState.grounded, g.state); + try testing.expect(g.normal.approxEql(v(0, 1, 0), tol)); + try testing.expectEqual(floor, g.body); + try testing.expectEqual(ent(11), g.entity); + // A static support has no velocity of its own, and the rotational term of a zero ω is zero. + try testing.expect(g.velocity.approxEql(Vec3r.zero, tol)); +} + +test "a slope below max_slope is grounded and above it is steep, both with the slope's normal" { + const gpa = testing.allocator; + + // The default `max_slope` is 0.785 rad ≈ 44.977°, so 30° is walkable and 60° is not, with + // more than 14° of margin on either side — the verdict cannot turn on the f32 rendering of + // the angle. Their up components are cos(30°) = √3/2 and cos(60°) = 1/2. + const cases = [_]struct { deg: f32, distance: f32, expected: api.GroundState }{ + // For a plane of normal n and a capsule core endpoint P, the separation is + // n·P − radius − distance. Base at y = 0 puts the lower endpoint at (0, 0.3, 0), so + // 30°: n·P = cos30 · 0.3 = 0.2598 → distance = 0.2598 − 0.3 − 0.05 = −0.0902 + // 60°: n·P = cos60 · 0.3 = 0.15 → distance = 0.15 − 0.3 − 0.05 = −0.2 + // both leaving a separation of 0.05 m, inside the 0.12 m band and strictly positive. + .{ .deg = 30, .distance = -0.0902, .expected = .grounded }, + .{ .deg = 60, .distance = -0.2, .expected = .on_steep_ground }, + }; + + for (cases) |case| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + const n = slopeNormal(case.deg); + const slope = try addPlane(gpa, &world, n, case.distance, 12); + + var desc = baseDescriptor(); + desc.position = av(0, 0, 0); + const id = try addCharacter(gpa, &world, &chars, desc); + + const g = try chars.groundOf(&world.bp, &world.bm, &world.store, id); + try testing.expectEqual(case.expected, g.state); + // The normal is the SLOPE's in both verdicts — a steep slope is still a real surface, + // and the verdict is the only thing that differs. + try testing.expect(g.normal.approxEql(v(n.toArray()[0], n.toArray()[1], 0), api_tol)); + try testing.expectEqual(slope, g.body); + // NON-NULL on `.on_steep_ground` as much as on `.grounded`: a steep slope is a support, + // and only `.in_air` has no support at all (§1.12.5). + try testing.expectEqual(ent(12), g.entity); + try testing.expect(g.entity.index != api.EntityId.dead.index); + try testing.expect(g.body != api.PackedId.dead); + } +} + +test "a capsule over the void is in_air on all five quantities" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // NOTHING in the scene but the character — whose own presence IS in the broadphase, and is + // the only thing its downward sweep can find. + // + // **This does NOT prove self-exclusion, and an earlier version of this comment claimed it + // did.** MEASURED: with the exclusion removed, this test and the four others that pin + // `ground_body` to a real support all still pass. The reason is that what exclusion removes + // is a contact between the probe and a body BIT-IDENTICAL to it at the same pose, whose + // normal §3 declares geometrically UNDEFINED — and empirically that normal never qualifies + // as ground. So the mechanism is required by §1.12.2 and implemented, but it is not + // observable at this gate; it becomes observable at gate D, where the same contact would + // block motion outright. Asserting it here would mean asserting on a value the narrowphase + // documents as undefined. + var desc = baseDescriptor(); + desc.position = av(0, 5, 0); + const id = try addCharacter(gpa, &world, &chars, desc); + try testing.expect((try chars.getCharacterInnerBody(id)) != null); + + const g = try chars.groundOf(&world.bp, &world.bm, &world.store, id); + // All five quantities at their `.in_air` values, asserted one by one rather than by + // comparing the struct: `normal` is EXACTLY up and never a poisoned value, three documents + // reading it inside a `@replicated` component. + try testing.expectEqual(api.GroundState.in_air, g.state); + try testing.expect(g.normal.eql(Vec3r.unit_y)); + try testing.expectEqual(@as(Real, 1), g.normal.lengthSq()); + try testing.expectEqual(api.EntityId.dead, g.entity); + try testing.expectEqual(@as(api.BodyId, api.PackedId.dead), g.body); + try testing.expect(g.velocity.eql(Vec3r.zero)); + // The one thing that IS assertable about self-exclusion here: whatever the verdict, the + // support is never the character's own presence. Cheap, and it would catch a future change + // that made the coincident self-contact start qualifying. + try testing.expect(g.body != (try chars.getCharacterInnerBody(id)).?); +} + +test "straddling a walkable and a steep face stands on the FLATTER one" { + const gpa = testing.allocator; + + // Two planes both within the sweep band. Base at y = 0.02: + // floor n = (0, 1, 0), distance 0 → separation 0.32 − 0.3 = 0.02 + // steep n = (sin60, cos60, 0) → n·P = 0.5 · 0.32 = 0.16, + // distance = 0.16 − 0.3 − 0.05 = −0.19 + // The floor's up component is 1 and the steep one's is 0.5, so the FLATTER must win even + // though both qualify as contacts and the steep one is nearer in sweep distance. + const steep = slopeNormal(60); + + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + const floor = try addPlane(gpa, &world, av(0, 1, 0), 0, 20); + _ = try addPlane(gpa, &world, steep, -0.19, 21); + + var desc = baseDescriptor(); + desc.position = av(0, 0.02, 0); + const id = try addCharacter(gpa, &world, &chars, desc); + + const g = try chars.groundOf(&world.bp, &world.bm, &world.store, id); + try testing.expectEqual(api.GroundState.grounded, g.state); + try testing.expect(g.normal.approxEql(v(0, 1, 0), tol)); + try testing.expectEqual(floor, g.body); + try testing.expectEqual(ent(20), g.entity); + } + + // THE DISCRIMINATOR. Remove the floor and keep the steep plane at the same distance: the + // verdict must change to `.on_steep_ground` on the steep normal. Without this the test + // above would pass just as well if the steep plane were out of range and never a candidate + // at all — it would assert "the floor wins" against no competition. + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + const only_steep = try addPlane(gpa, &world, steep, -0.19, 21); + + var desc = baseDescriptor(); + desc.position = av(0, 0.02, 0); + const id = try addCharacter(gpa, &world, &chars, desc); + + const g = try chars.groundOf(&world.bp, &world.bm, &world.store, id); + try testing.expectEqual(api.GroundState.on_steep_ground, g.state); + try testing.expect(g.normal.approxEql(v(steep.toArray()[0], steep.toArray()[1], 0), api_tol)); + try testing.expectEqual(only_steep, g.body); + } +} + +test "at distance zero the manifold fallback answers, and NOT with up" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // A 30° slope the capsule OVERLAPS rather than rests above. Base at y = 0 puts the lower + // core endpoint at (0, 0.3, 0), so n·P = cos30 · 0.3 = 0.2598 and a distance of + // 0.2598 − 0.3 + 0.02 = −0.0202 leaves a separation of −0.02 m: the capsule is 2 cm inside + // the solid, so the downward cast reports distance ZERO and its own normal is `−direction`, + // i.e. exactly `+up`. + const n = slopeNormal(30); + _ = try addPlane(gpa, &world, n, -0.0202, 30); + + var desc = baseDescriptor(); + desc.position = av(0, 0, 0); + const id = try addCharacter(gpa, &world, &chars, desc); + + const g = try chars.groundOf(&world.bp, &world.bm, &world.store, id); + + // THE POINT OF THIS TEST is the second assertion. The first would pass on either path. + const expected = v(n.toArray()[0], n.toArray()[1], 0); + try testing.expect(g.normal.approxEql(expected, api_tol)); + // `+up` is what the SWEEP would have returned at distance zero, and it is a lie on a + // slope — it would answer "perfectly horizontal" and make every slope test pass. cos 30° + // differs from 1 by 0.134 and the x component from 0 by 0.5, so the two answers are not + // near each other: this asserts the fallback was taken, not merely that a normal came back. + try testing.expect(!g.normal.approxEql(v(0, 1, 0), 0.1)); + try testing.expectApproxEqAbs(@as(Real, 0.5), g.normal.toArray()[0], api_tol); + // 30° is walkable, so the verdict rides on the real normal and not on the sweep's. + try testing.expectEqual(api.GroundState.grounded, g.state); +} + +test "ground_velocity is the velocity AT THE CONTACT POINT of a rotating platform" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // A kinematic slab 4 m across and 1 m thick centred on the origin, so its top face is at + // y = 0.5. Kinematic because a controller's support is exactly the moving-platform case, + // and because a dynamic body would need the solver to hold it up. + const slab = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av(2, 0.5, 2) } }); + const platform = try world.addBody(gpa, .{ + .entity = ent(60), + .body_type = .kinematic, + .shape = slab, + .position = av(0, 0, 0), + }); + + // Spin about +Y at 2 rad/s. This is the entry that makes the field testable at all: without + // it ω has no authorable source and the rotational term would be permanently zero. + world.bm.setAngularVelocity(platform, v(0, 2, 0)); + + // The character stands on the rim at x = 1.5, `padding` above the top face. + var desc = baseDescriptor(); + desc.position = av(1.5, 0.52, 0); + const id = try addCharacter(gpa, &world, &chars, desc); + + const g = try chars.groundOf(&world.bp, &world.bm, &world.store, id); + try testing.expectEqual(api.GroundState.grounded, g.state); + try testing.expectEqual(platform, g.body); + + // CLOSED FORM. The contact point is the witness on the body, i.e. (1.5, 0.5, 0) on the top + // face; the centre of mass is the body's pose, the origin; so r = (1.5, 0.5, 0) and + // ω × r = (0,2,0) × (1.5,0.5,0) + // = (2·0 − 0·0.5, 0·1.5 − 0·0, 0·0.5 − 2·1.5) + // = (0, 0, −3) + // with the platform's linear velocity zero. The tolerance is `api_tol` scaled by two, + // because an error δ in the contact point's x becomes 2δ in the z component. + try testing.expect(g.velocity.approxEql(v(0, 0, -3), 2 * api_tol)); + + // NON-VACUITY of the rotational term: without it the answer would be the platform's linear + // velocity, which is zero here. The measured value is 3 m/s, so the term is not a rounding + // residue that a loose tolerance could hide. + try testing.expect(g.velocity.length() > 2.9); +} + +test "an exact tie is broken by the smaller BodyId under BOTH traversal orders" { + const gpa = testing.allocator; + + // TWO IDENTICAL floors as two separate bodies, so both offer an up component of EXACTLY 1 + // — a plane returns its stored normal verbatim, so the tie is exact and not near-exact. + // + // **BOTH traversal orders are exercised, and only one of them discriminates.** The + // unbounded list iterates by slot index, so the insertion order IS the traversal order. + // Without the tie-break the code keeps the LAST candidate offered, so: + // forward [first, second] → no tie-break would answer `second`; the rule answers `first` + // reversed [second, first] → no tie-break would answer `first` too, same as the rule + // The forward case is therefore the one that pins the rule, and the reversed one shows the + // answer does not depend on the order. A first version of this test ran ONLY the reversed + // order and pinned nothing — measured: removing the tie-break broke no test at all. + for ([_]bool{ false, true }) |reversed| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + const shape_a = try world.store.createShape(gpa, .{ .plane = .{ .normal = av(0, 1, 0), .distance = 0 } }); + const shape_b = try world.store.createShape(gpa, .{ .plane = .{ .normal = av(0, 1, 0), .distance = 0 } }); + const first = try world.bm.addBody(gpa, &world.store, .{ + .entity = ent(70), + .body_type = .static, + .shape = shape_a, + .position = av(0, 0, 0), + }); + const second = try world.bm.addBody(gpa, &world.store, .{ + .entity = ent(71), + .body_type = .static, + .shape = shape_b, + .position = av(0, 0, 0), + }); + // A slot index encodes creation order, so this is the premise the tie-break rests on. + try testing.expect(first < second); + + const order = if (reversed) [_]api.BodyId{ second, first } else [_]api.BodyId{ first, second }; + for (order) |body| { + const shape = world.store.get(world.bm.shapeOf(body).?).?; + const plane_world = shape_mod.halfSpace(shape).transformed( + world.bm.rotation(body).?, + world.bm.position(body).?, + ); + _ = try world.bp.insertUnbounded(gpa, .static, .{ + .normal = plane_world.normal, + .distance = plane_world.distance, + }, body); + } + + var desc = baseDescriptor(); + desc.position = av(0, 0.02, 0); + const id = try addCharacter(gpa, &world, &chars, desc); + + const g = try chars.groundOf(&world.bp, &world.bm, &world.store, id); + try testing.expectEqual(api.GroundState.grounded, g.state); + // The SMALLER `BodyId` wins in BOTH orders. + try testing.expectEqual(first, g.body); + try testing.expectEqual(ent(70), g.entity); + } +} + +test "groundOf reports a stale handle as a typed error" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 80); + const id = try addCharacter(gpa, &world, &chars, baseDescriptor()); + // Live first, so the error below is about the handle and not about the scene. + _ = try chars.groundOf(&world.bp, &world.bm, &world.store, id); + + chars.destroyCharacter(gpa, &world.bp, &world.store, &world.bm, id); + try testing.expectError( + error.StaleCharacter, + chars.groundOf(&world.bp, &world.bm, &world.store, id), + ); +} + +test "the sweep band is padding plus predictive_contact_distance" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // The band is a closed form of two descriptor fields, and this is the assertion that names + // `predictive_contact_distance`'s first consumer: 0.02 + 0.1 = 0.12 at the defaults. + const id = try addCharacter(gpa, &world, &chars, baseDescriptor()); + const c = chars.get(id).?; + try testing.expectApproxEqAbs(@as(Real, 0.12), character_mod.groundSweepDistance(c), api_tol); + + // And it BITES in both directions on a real scene. A floor 0.10 m below the capsule's lower + // core endpoint is inside the band and answers; the same floor 0.20 m below is outside it + // and the character is `.in_air`. Base at y = b puts that endpoint at y = b + 0.3, so a + // plane `{y <= 0}` sits `b + 0.3 − 0.3 = b` below it: b IS the separation. + for ([_]struct { base: f32, expected: api.GroundState }{ + .{ .base = 0.10, .expected = .grounded }, + .{ .base = 0.20, .expected = .in_air }, + }) |case| { + var scene = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer scene.deinit(gpa); + var store: CharacterStore = .{}; + defer store.deinit(gpa); + + _ = try addPlane(gpa, &scene, av(0, 1, 0), 0, 90); + var desc = baseDescriptor(); + desc.position = av(0, case.base, 0); + const who = try addCharacter(gpa, &scene, &store, desc); + + const g = try store.groundOf(&scene.bp, &scene.bm, &scene.store, who); + try testing.expectEqual(case.expected, g.state); + } +} + +// --------------------------------------------------------------------------- +// D — the move. No step height. +// --------------------------------------------------------------------------- + +/// A character plus its presence's broadphase proxy REGISTERED with the store, so pose writes +/// keep the tree fresh. `addCharacter` above deliberately does not register it — the gate-B +/// tests had no pose write to keep fresh — and the difference is what the freshness test rides on. +fn addMover( + gpa: std.mem.Allocator, + world: *harness.World, + chars: *CharacterStore, + desc: api.CharacterDescriptor, +) !api.CharacterId { + const id = try chars.createCharacter(gpa, &world.store, &world.bm, desc); + if (try chars.getCharacterInnerBody(id)) |presence| { + const proxy = try world.bp.insert( + gpa, + .dynamic, + world.bm.bodyAabb(&world.store, presence).?, + presence, + ); + chars.setPresenceProxy(id, proxy); + } + return id; +} + +/// A static box body of the given half-extents at the given centre. +fn addBox(gpa: std.mem.Allocator, world: *harness.World, half: ApiVec3, centre: ApiVec3, entity_index: u32) !api.BodyId { + const shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = half } }); + return world.addBody(gpa, .{ + .entity = ent(entity_index), + .body_type = .static, + .shape = shape, + .position = centre, + }); +} + +test "an unobstructed move serves the whole displacement" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + var desc = baseDescriptor(); + desc.position = av(0, 5, 0); + const id = try addMover(gpa, &world, &chars, desc); + + // Nothing in the scene: the base moves by exactly the displacement asked for, and the verdict + // at the new pose is `.in_air`. + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(2, 0, -3), 1.0 / 60.0); + try testing.expect(r.position.approxEql(v(2, 5, -3), tol)); + try testing.expectEqual(api.GroundState.in_air, r.ground.state); + // The record is authoritative and now agrees with the result. + try testing.expect(chars.get(id).?.position.approxEql(v(2, 5, -3), tol)); +} + +test "a move into a wall keeps the tangential component and cancels the normal one" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // A wall occupying x >= 2: half-extents (1, 5, 5) centred at x = 3, so its −X face is at x = 2. + _ = try addBox(gpa, &world, av(1, 5, 5), av(3, 0, 0), 100); + + var desc = baseDescriptor(); + desc.position = av(0, 0, 0); + const id = try addMover(gpa, &world, &chars, desc); + + // Asked for (3, 0, 1) — into the wall, plus a metre along +Z the wall does not oppose. + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(3, 0, 1), 1.0 / 60.0); + + // **THE MOTION IS OBLIQUE, AND THAT CHANGES THE CLOSED FORM.** The capsule's radius is 0.3, so + // its surface reaches the wall when the centre is at x = 1.7; but the character travels along + // `d = (3,0,1)/√10`, and the sweep stops `padding` short ALONG d — so the shortfall projected + // onto x is `padding · dₓ` and not `padding`: + // + // x = 1.7 − padding · 3/√10 = 1.7 − 0.02 · 0.9486833 = 1.6810263 + // + // A first version of this test wrote 1.68, which is the axis-aligned answer, and an oblique + // sweep is precisely the case §1.11.4 bis makes mandatory. + const root10: Real = @sqrt(@as(Real, 10)); + try testing.expectApproxEqAbs(1.7 - 0.02 * (3.0 / root10), r.position.toArray()[0], api_tol); + + // The TANGENTIAL component is SERVED IN FULL, and asserting it is what separates "slid along + // the wall" from "stopped dead" — cancelling both would pass the assertion above and be wrong. + // + // Exactly 1, and the padding cancels out of it algebraically: the z travelled before the + // contact is `dz·(t_hit − padding)` and what remains after the slide is `dz·(|D| − t_hit + + // padding)`, whose sum is `dz·|D| = 1`. + try testing.expectApproxEqAbs(@as(Real, 1), r.position.toArray()[2], api_tol); + try testing.expectApproxEqAbs(@as(Real, 0), r.position.toArray()[1], api_tol); +} + +test "a move into a crease slides along the edge, and exactly parallel normals are a THIRD answer" { + const gpa = testing.allocator; + + // Two walls meeting at a vertical edge: one at x >= 2 (outward normal −X) and one at z >= 2 + // (outward normal −Z). Their crease is `(−1,0,0) × (0,0,−1)` — parallel to ±Y — so a character + // driven diagonally into the corner slides VERTICALLY along the edge and nowhere else. + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + _ = try addBox(gpa, &world, av(1, 5, 5), av(3, 0, 0), 110); + _ = try addBox(gpa, &world, av(5, 5, 1), av(0, 0, 3), 111); + + var desc = baseDescriptor(); + desc.position = av(0, 0, 0); + const id = try addMover(gpa, &world, &chars, desc); + + // Driven into both walls AND upward. The two horizontal components are cancelled by the + // two planes and the +Y component survives along the crease. + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(3, 1, 3), 1.0 / 60.0); + + // Same oblique correction as the wall test, now along `d = (3,1,3)/√19`: both horizontal + // components stop at `1.7 − padding · 3/√19 = 1.7 − 0.02 · 0.6882472 = 1.6862350`. The two + // walls are hit at the SAME sweep distance — `dₓ == d_z` — so the tie-break picks the + // smaller `BodyId` for the first plane and the second is met on the next iteration, where + // its own hit already sits inside the padding margin and the advance clamps to zero. + const root19: Real = @sqrt(@as(Real, 19)); + const stop = 1.7 - 0.02 * (3.0 / root19); + try testing.expectApproxEqAbs(stop, r.position.toArray()[0], api_tol); + try testing.expectApproxEqAbs(stop, r.position.toArray()[2], api_tol); + + // And the vertical component is served IN FULL along the crease — exactly 1, by the same + // cancellation the wall test's tangential component enjoys. If the second plane had stopped + // the motion instead of yielding an edge, this would be the 0.562 reached before the second + // contact, so the assertion discriminates between "slid along the edge" and "stopped in the + // corner" rather than merely being non-zero. + try testing.expectApproxEqAbs(@as(Real, 1), r.position.toArray()[1], api_tol); + } + + // THE THIRD ANSWER, asserted separately from "no edge". Two exactly parallel normals have NO + // crease, and `triangleCrossDirection` says so with an exact `null` rather than a rounding + // residue that reads as a valid direction. The primitive is asserted directly, because the + // move's own path replaces a parallel re-contact instead of treating it as a second plane — + // so the two situations must be distinguishable at the primitive, and they are. + { + const n = v(0, 1, 0); + // Exactly parallel: no edge exists. + try testing.expectEqual( + @as(?Vec3r, null), + foundation.math.triangleCrossDirection(Real, Vec3r.zero, n, n), + ); + // Exactly ANTI-parallel: also no edge, and also an exact null rather than a small vector. + try testing.expectEqual( + @as(?Vec3r, null), + foundation.math.triangleCrossDirection(Real, Vec3r.zero, n, n.neg()), + ); + // A genuine pair of distinct normals DOES yield an edge, so the null above is a real + // discrimination and not a function that always returns null. + const edge = foundation.math.triangleCrossDirection(Real, Vec3r.zero, v(-1, 0, 0), v(0, 0, -1)); + try testing.expect(edge != null); + // Parallel to ±Y: the x and z components are exactly zero. + try testing.expectEqual(@as(Real, 0), edge.?.toArray()[0]); + try testing.expectEqual(@as(Real, 0), edge.?.toArray()[2]); + try testing.expect(edge.?.toArray()[1] != 0); + } +} + +test "a move that starts interpenetrated is depenetrated by the MANIFOLD, not by the sweep" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // A 30° slope as a half-space, positioned so the capsule starts 2 cm INSIDE the solid — the + // same construction as the gate-C fallback test, and for the same reason. + const n = slopeNormal(30); + _ = try addPlane(gpa, &world, n, -0.0202, 120); + + var desc = baseDescriptor(); + desc.position = av(0, 0, 0); + const id = try addMover(gpa, &world, &chars, desc); + + // Asked for nothing at all, so the ONLY thing that can move the character is depenetration. + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, Vec3r.zero, 1.0 / 60.0); + + // **THE TWO ANSWERS DIFFER MEASURABLY, which is what makes this test discriminate.** The + // manifold pushes along the SLOPE's normal (0.5, 0.866, 0), so the correction has a non-zero X + // component; the sweep's `−direction` at distance zero would have pushed along `+up`, i.e. + // purely in Y. + // + // The push is the 2 cm overlap PLUS the `padding` stand-off — depenetration resolves to `padding` + // of clearance and not to touching, because a capsule left exactly tangent serves no horizontal + // motion at all. So along the slope normal the distance is `0.02 + 0.02 = 0.04`: + // Δx = 0.04 · 0.5 = 0.02 + // Δy = 0.04 · cos 30° = 0.0346410 + // + // **AND THEN FLOOR-STICKING RE-SEATS IT, which the previous expectation did not have to account + // for — because the previous behaviour was the defect.** With the base left exactly tangent, the + // down-sweep of `stepDown` found the surface at distance zero and its padded advance clamped to + // zero, so it moved nothing and Δy was a pure normal push. Now the base stands `padding` clear + // along the NORMAL, which is `padding / cos 30° = 0.0230940` clear VERTICALLY, and the down-sweep + // advances the excess: + // Δy = 0.0346410 − (0.0230940 − 0.02) = 0.0315470 + // + // The old clean `0.0173205` was itself a consequence of the freeze. Worth saying, because a + // reader comparing the two would otherwise read this as a regression. + try testing.expectApproxEqAbs(@as(Real, 0.02), r.position.toArray()[0], api_tol); + try testing.expectApproxEqAbs(@as(Real, 0.0315470), r.position.toArray()[1], api_tol); + // The X component is the discriminator: a sweep-driven push would leave it at exactly zero. + try testing.expect(@abs(r.position.toArray()[0]) > 0.005); + // And the character is no longer inside: the verdict is a real one on the slope. + try testing.expectEqual(api.GroundState.grounded, r.ground.state); +} + +test "self-exclusion is what lets a character move at all" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // Nothing in the scene but the character and its own presence, which sits exactly where the + // probe does. Without self-exclusion the very first sweep hits it at distance zero, the + // advance is `max(0, 0 − padding) = 0`, and the character cannot move a millimetre. At gate C + // the mechanism was NOT observable — measured — and here it is, which is why the assertion + // lives at this gate. + var desc = baseDescriptor(); + desc.position = av(0, 5, 0); + const id = try addMover(gpa, &world, &chars, desc); + + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0); + try testing.expectApproxEqAbs(@as(Real, 1), r.position.toArray()[0], api_tol); +} + +test "after a move a ray finds the entity at the NEW pose and never at the old one" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + var desc = baseDescriptor(); + desc.entity = ent(130); + desc.position = av(0, 0, 0); + const id = try addMover(gpa, &world, &chars, desc); + const presence = (try chars.getCharacterInnerBody(id)).?; + + // Before: the capsule's cylinder wall is at x = ±0.3 about the Y axis, so a ray from + // (−10, 0.9, 0) along +X hits at 10 − 0.3 = 9.7. + const before = query.RayQuery{ .origin = v(-10, 0.9, 0), .direction = v(1, 0, 0), .max_distance = 100 }; + try testing.expectApproxEqAbs( + @as(Real, 9.7), + (query.raycast(&world.bp, &world.bm, &world.store, before)).?.distance, + api_tol, + ); + + _ = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(5, 0, 0), 1.0 / 60.0); + + // After: the capsule is at x = 5, so the same ray hits at 15 − 0.3 = 14.7. Both halves matter — + // the NEW distance, and the fact that nothing answers at the OLD one any more, which is what + // catches a stale broadphase proxy. A body-only update would leave the tree at the old box and + // the ray would still find something near 9.7. + const hit = (query.raycast(&world.bp, &world.bm, &world.store, before)).?; + try testing.expectEqual(presence, hit.body); + try testing.expectEqual(ent(130), hit.entity); + try testing.expectApproxEqAbs(@as(Real, 14.7), hit.distance, api_tol); + + // And a ray aimed at where the character USED to be finds nothing: bounded just short of the + // new position so a hit there cannot be the new pose answering. + const at_old = query.RayQuery{ .origin = v(-10, 0.9, 0), .direction = v(1, 0, 0), .max_distance = 12 }; + try testing.expectEqual( + @as(?query.RayHit, null), + query.raycast(&world.bp, &world.bm, &world.store, at_old), + ); + + // **THE ASSERTION THAT ACTUALLY CATCHES A STALE BROADPHASE PROXY, and none of the three above + // does.** MEASURED: with the proxy update removed, every assertion so far still passes. The + // reason is structural — the broadphase box is only a CONSERVATIVE FILTER, and the exact answer + // comes from the body's pose, which the same write path updates. A stale fat box that the ray + // still crosses therefore yields the correct distance anyway. + // + // What a stale proxy loses is a candidate the tree no longer offers at all. So the ray has to + // approach the NEW position from a direction the OLD box does not intersect: from −Z at x = 5, + // where the stale box sits around x = 0 with a 0.1 m fat margin and is nowhere near. Without + // the update the presence is never offered and this MISSES. + const across = query.RayQuery{ .origin = v(5, 0.9, -10), .direction = v(0, 0, 1), .max_distance = 100 }; + const side = query.raycast(&world.bp, &world.bm, &world.store, across); + // Checked before unwrapping so a stale proxy reads as a failed expectation rather than as a + // panic on a null optional — the failure mode is "the tree no longer offers the candidate", + // which deserves to say so. + try testing.expect(side != null); + const side_hit = side.?; + try testing.expectEqual(presence, side_hit.body); + try testing.expectApproxEqAbs(@as(Real, 9.7), side_hit.distance, api_tol); +} + +test "moveCharacter wakes a sleeping body it touches" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // A dynamic box the character will walk into, put to sleep DIRECTLY rather than by running + // thirty ticks: the tick count is not what this test is about, and `sleep.putToSleep` is the + // same transition step 11 of the cycle applies. + const box_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av(0.5, 0.5, 0.5) } }); + const sleeper = try world.addBody(gpa, .{ + .entity = ent(140), + .body_type = .dynamic, + .shape = box_shape, + .position = av(2, 0, 0), + }); + sleep_mod.putToSleep(&world.bm, sleeper); + try testing.expectEqual(true, world.bm.isSleeping(sleeper).?); + + var desc = baseDescriptor(); + desc.position = av(0, 0, 0); + const id = try addMover(gpa, &world, &chars, desc); + + // Walk into it. The box's −X face is at x = 1.5, the capsule's radius is 0.3, so contact is + // made and the sweep reports it. + _ = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(3, 0, 0), 1.0 / 60.0); + + // WAKE CAUSE W4, and not W3: a presence moved by pose write keeps velocity columns of exactly + // zero while it crosses the scene, so W3's true-zero velocity test never sees it move + // (§1.12.10). What this entry owes is the bodies it TOUCHED; the wider W4 — waking sleepers + // merely RETAINED in a pair with the presence — belongs to the orchestrator that owns the + // retained set, at M1.1.15. + try testing.expectEqual(false, world.bm.isSleeping(sleeper).?); +} + +test "the move consumes no predictive_contact_distance, and the ceilings stop short" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // The sweep's distance is the REMAINING DISPLACEMENT and nothing else, so two characters + // differing only in `predictive_contact_distance` reach the same place against the same wall. + // That is the answer to gate D's question about the field: the ground probe is still its only + // consumer, and the move does not read it. + _ = try addBox(gpa, &world, av(1, 5, 5), av(3, 0, 0), 150); + + var lean = baseDescriptor(); + lean.entity = ent(151); + lean.position = av(0, 0, 0); + lean.predictive_contact_distance = 0; + const a = try addMover(gpa, &world, &chars, lean); + + var generous = baseDescriptor(); + generous.entity = ent(152); + generous.position = av(0, 0, 3); + generous.predictive_contact_distance = 0.5; + const b = try addMover(gpa, &world, &chars, generous); + + const ra = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, a, v(3, 0, 0), 1.0 / 60.0); + const rb = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, b, v(3, 0, 0), 1.0 / 60.0); + // Both stop at 2 − 0.3 − 0.02 = 1.68, the wall face minus the radius minus the padding. + try testing.expectApproxEqAbs(@as(Real, 1.68), ra.position.toArray()[0], api_tol); + try testing.expectApproxEqAbs(ra.position.toArray()[0], rb.position.toArray()[0], api_tol); + + // The two ceilings are NAMED and their failure direction is SHORT: a character never ends up + // further along than it asked for. Pinned as an inequality on the served distance, which holds + // whatever the scene does to the loop. + try testing.expect(ra.position.toArray()[0] <= 3); + try testing.expectEqual(@as(u32, 4), character_mod.max_slide_iterations); + try testing.expectEqual(@as(u32, 4), character_mod.max_depenetration_iterations); +} + +test "moveCharacter reports a stale handle as a typed error" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + const id = try addMover(gpa, &world, &chars, baseDescriptor()); + _ = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, Vec3r.zero, 1.0 / 60.0); + chars.destroyCharacter(gpa, &world.bp, &world.store, &world.bm, id); + try testing.expectError( + error.StaleCharacter, + chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0), + ); +} + +// --------------------------------------------------------------------------- +// E — step height: climbing and descending. +// --------------------------------------------------------------------------- + +/// A ground plane plus a step of height `h` whose front face is at x = 1, spanning x ∈ [1, 3]. +/// Returns the character, placed at x = 0 and `padding` above the ground so it enters `.grounded`. +fn stepScene( + gpa: std.mem.Allocator, + world: *harness.World, + chars: *CharacterStore, + h: f32, + step_height: f32, +) !api.CharacterId { + _ = try addPlane(gpa, world, av(0, 1, 0), 0, 200); + _ = try addBox(gpa, world, av(1, h / 2, 1), av(2, h / 2, 0), 201); + var desc = baseDescriptor(); + desc.entity = ent(202); + desc.position = av(0, 0.02, 0); + desc.step_height = step_height; + return addMover(gpa, world, chars, desc); +} + +test "a step below step_height is climbed and a step above it blocks — both directions" { + const gpa = testing.allocator; + + // CLIMBED: a 0.25 m step against the default 0.3 m `step_height`. + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + const id = try stepScene(gpa, &world, &chars, 0.25, 0.3); + + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1.5, 0, 0), 1.0 / 60.0); + + // The character ends RESTING on the step top: `padding` above y = 0.25, so base = 0.27. + // The lift is exactly `step_height` — the padding cancels between the two resting + // configurations — so the base rises to 0.32, the down-sweep finds the top 0.07 below and + // stops `padding` short of it, landing at 0.32 − 0.05 = 0.27. + try testing.expectApproxEqAbs(@as(Real, 0.27), r.position.toArray()[1], api_tol); + // And it is PAST the riser at x = 1, which a blocked character never is. + try testing.expect(r.position.toArray()[0] > 1); + // Standing on the step, not falling off it. + try testing.expectEqual(api.GroundState.grounded, r.ground.state); + } + + // BLOCKED: a 0.35 m step against the same 0.3 m `step_height`. Both directions asserted, and + // this half is the one that would pass vacuously if the climb simply never fired. + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + const id = try stepScene(gpa, &world, &chars, 0.35, 0.3); + + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1.5, 0, 0), 1.0 / 60.0); + + // The height is UNCHANGED: no lift survives a failed attempt. + try testing.expectApproxEqAbs(@as(Real, 0.02), r.position.toArray()[1], api_tol); + // And the normal component is cancelled — it stops short of the riser at x = 1. The capsule + // is widest 0.3 m from its axis, so the slide stops at 1 − 0.3 − padding = 0.68. + try testing.expectApproxEqAbs(@as(Real, 0.68), r.position.toArray()[0], api_tol); + try testing.expect(r.position.toArray()[0] < 1); + } +} + +test "a step is NOT a slope: a climb onto something too steep to stand on is refused" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // A ramp at 70° from horizontal, well past the default 45° limit, whose surface passes through + // the region the character would step onto. If the landing surface were not slope-tested, the + // character would climb it in `step_height` increments — each increment individually legitimate + // — and the slope limit the engine holds would be unenforceable. That is the case, not a remark. + const n = slopeNormal(70); + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 210); + // Positioned so the ramp's surface sits just ahead of the character: with the lower core + // endpoint at (0, 0.32, 0), `n·P = cos70 · 0.32 = 0.10944`, and a distance of + // 0.10944 − 0.3 − 0.4 = −0.59056 leaves it 0.4 m ahead along the normal. + _ = try addPlane(gpa, &world, n, -0.59056, 211); + + var desc = baseDescriptor(); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1.5, 0, 0), 1.0 / 60.0); + + // It never ends up standing on the ramp: the verdict is not `.grounded` on a 70° face, and the + // height has not been ratcheted upward by a climb that should not have been accepted. + try testing.expect(r.position.toArray()[1] < 0.1); + if (r.ground.state == .grounded) { + // If it is grounded at all it is on the FLOOR, whose normal is +Y — never on the ramp. + try testing.expect(r.ground.normal.approxEql(v(0, 1, 0), api_tol)); + } +} + +test "descent sticks to the floor only when the character ENTERED grounded" { + const gpa = testing.allocator; + + // TWO SCENES and not one parameterised pair, because the two halves need different geometry and + // a first version shared one — measured, and the shared form could not fail. Both halves ask + // "does the floor-sticking fire", so BOTH must put a floor inside the 0.3 m sweep's reach; the + // shared version left the airborne character 0.4 m above the lower floor, out of reach, where + // nothing could be stuck whatever the condition said. + + // (a) ENTERED GROUNDED — walking off a ledge onto a floor 0.2 m below, inside `step_height`. + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // Upper floor top at y = 0 spanning x ∈ [−2, 0]; lower floor top at y = −0.2 beyond it. + _ = try addBox(gpa, &world, av(1, 0.5, 1), av(-1, -0.5, 0), 220); + _ = try addBox(gpa, &world, av(2, 0.5, 1), av(2, -0.7, 0), 221); + + var desc = baseDescriptor(); + desc.position = av(-0.5, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1.5, 0, 0), 1.0 / 60.0); + // Landed on the lower floor, `padding` above it: −0.2 + 0.02 = −0.18. + try testing.expectApproxEqAbs(@as(Real, -0.18), r.position.toArray()[1], api_tol); + try testing.expectEqual(api.GroundState.grounded, r.ground.state); + } + + // (b) ENTERED IN THE AIR, with the floor WELL INSIDE reach so the entry condition is the only + // thing that can decide. One flat floor, and the character starts 0.2 m above it: the ground + // band is `padding + predictive_contact_distance = 0.12`, so 0.2 reads `.in_air`, while + // 0.2 < `step_height` = 0.3 puts that same floor inside the step-down sweep. + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 222); + + var desc = baseDescriptor(); + desc.position = av(0, 0.2, 0); + const id = try addMover(gpa, &world, &chars, desc); + // Verified in the fixture rather than assumed: the premise of this half is that the entry + // state really is `.in_air`. + try testing.expectEqual(api.GroundState.in_air, (try chars.groundOf(&world.bp, &world.bm, &world.store, id)).state); + + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1.5, 0, 0), 1.0 / 60.0); + // Height UNCHANGED: a falling character is not pulled down, even with the floor in reach. + // Without the entry condition it would be stuck to 0.02 — which is what makes this the + // discriminating half. + try testing.expectApproxEqAbs(@as(Real, 0.2), r.position.toArray()[1], api_tol); + try testing.expectEqual(api.GroundState.in_air, r.ground.state); + } +} + +test "an intermediate wall buys no horizontal progress — the reference's v5.6.0 bug class" { + const gpa = testing.allocator; + + // The case the reference names: a wall LOW ENOUGH to arm stair walking and HIGH ENOUGH that the + // climb cannot complete. Quoted from `jrouwe/JoltPhysics` release notes v5.6.0, Bug Fixes, + // verified on the source: "Fixed `CharacterVirtual` speeding up beyond requested speed when + // sliding along a wall that was low enough to trigger stair walking yet high enough to not step + // up completely." + // + // THE DIFFERENTIAL IS THE ASSERTION. The same scene is run twice, differing only in + // `step_height`: at 0 no climb is possible at all, at 0.3 the attempt fires and fails. If the + // failed attempt bought any horizontal progress, the two would end at different x — which is + // exactly the speed-up the reference fixed. A bound on "no further than requested" would NOT + // catch it: 0.849 against a request of 1.5 violates no such bound. + const requested = v(1.5, 0, 0); + + // TWO intermediate heights, because the two halves of the acceptance condition reject two + // DIFFERENT failures and one height exercises only one of them — measured, after a first version + // of this test used 0.35 alone and left the second half unexercised: + // + // 0.35 m — the lifted capsule ends WEDGED against the obstacle's top edge, `drop` clamping to + // zero. Rejected by the `drop > 0` half. + // 0.45 m — the lifted capsule's cross-section is narrower there (half-width 0.247 against + // 0.3 unlifted), so it SQUEEZES 0.053 m closer, and the down-sweep then finds the + // ground with `drop = 0.3 > 0` and lands it back where it started. Rejected by the + // climbed-higher half alone. + for ([_]f32{ 0.35, 0.45 }) |h| { + var reached: [2]Real = @splat(0); + for ([_]f32{ 0, 0.3 }, 0..) |step_height, i| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + const id = try stepScene(gpa, &world, &chars, h, step_height); + + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, requested, 1.0 / 60.0); + reached[i] = r.position.toArray()[0]; + // No lift survives either way. + try testing.expectApproxEqAbs(@as(Real, 0.02), r.position.toArray()[1], api_tol); + } + + // The same stopping place: the failed climb bought nothing at all. + try testing.expectApproxEqAbs(reached[0], reached[1], api_tol); + + // And the invariant the reference's bug violated, on the HORIZONTAL component — a legitimate + // climb adds vertical displacement, which is what climbing IS, so the bound belongs on the + // horizontal part and not on the norm of the whole. + try testing.expect(reached[1] <= @sqrt(requested.lengthSq()) + api_tol); + } +} + +test "the slide is CONSTRAINED by the slope: four cases" { + const gpa = testing.allocator; + + // §1.12.6's rule, added at gate F: when the slide projects onto a plane whose normal FAILS the + // slope test, the projected motion's up component is CAPPED at the pre-projection one. Before it, + // a character climbed any face up to 90°−ε by walking into it — measured at 0.583 m of rise in + // one call against a 50° face under a 45° limit, with the verdict correctly saying + // `.on_steep_ground` the whole time, so the engine told the truth while the pose climbed. + // + // A cap and not a cancellation, which is what makes all four cases right at once. + + // A ramp of `deg` degrees, as a box rotated about +Z so its top-face normal is + // `(−sin deg, cos deg, 0)`, with its top face passing through `(0.5, 0, 0)`. + const ramp = struct { + fn add(g: std.mem.Allocator, w: *harness.World, deg: f32) !void { + _ = try addPlane(g, w, av(0, 1, 0), 0, 250); + const rad = deg * std.math.pi / 180.0; + const shape = try w.store.createShape(g, .{ .box = .{ .half_extents = av(2, 0.5, 2) } }); + const n = av(-@sin(rad), @cos(rad), 0); + _ = try w.addBody(g, .{ + .entity = ent(251), + .body_type = .static, + .shape = shape, + .position = av(0.5 + 0.5 * @sin(rad), -0.5 * @cos(rad), 0), + .rotation = math.Quatf.fromAxisAngle(av(0, 0, 1), rad), + }); + _ = n; + } + }; + + // CASE 1 — a WALKABLE ramp is untouched by the rule, and the character climbs it. + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + try ramp.add(gpa, &world, 30); + var desc = baseDescriptor(); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0); + // 30° is inside the 45° limit, so the rule does not apply and the rise is real. + try testing.expect(r.position.toArray()[1] > 0.2); + try testing.expectEqual(api.GroundState.grounded, r.ground.state); + } + + // CASE 2 — a STEEP face walked into HORIZONTALLY gains no height at all. This is the case the + // rule exists for. + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + try ramp.add(gpa, &world, 50); + var desc = baseDescriptor(); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0); + // Height UNCHANGED, and the character is standing on the FLOOR (normal +Y) rather than on + // the cliff it was pushed into. + try testing.expectApproxEqAbs(@as(Real, 0.02), r.position.toArray()[1], api_tol); + try testing.expect(r.ground.normal.approxEql(v(0, 1, 0), api_tol)); + // And it is blocked short of the 1 m it asked for — lateral slide or blocking, per the rule. + try testing.expect(r.position.toArray()[0] < 1); + } + + // CASE 3 — a DOWNWARD motion against the same steep face still descends, and the cap does not + // trap the character against the cliff. + // + // **The rule's stated case 3 does not hold for a steep but SLOPED face, and this was computed + // before the test was written.** Against a 50° normal `(−0.766, 0.643, 0)`, a pure gravity step + // `(0,−1,0)` projects to an up component of `−0.587`, which is GREATER than the `−1` it came + // from, so the cap fires and restores the requested rate. The prediction that the projection is + // "already below, hence untouched" holds only for a VERTICAL face, where `after == before` + // exactly. Both were checked numerically. + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + try ramp.add(gpa, &world, 50); + var desc = baseDescriptor(); + desc.position = av(0, 0.5, 0); + const id = try addMover(gpa, &world, &chars, desc); + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(0.3, -0.3, 0), 1.0 / 60.0); + // It DESCENDS — the cap never turns a downward motion into an upward one, which is the half + // that says the guard does not over-fire. + try testing.expect(r.position.toArray()[1] < 0.5); + } + + // CASE 4 — when the CALLER asks to rise, the cap allows it: the pre-projection up component is + // positive, and the cap is on the INCREASE. The engine does not fight its caller. + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + try ramp.add(gpa, &world, 50); + var desc = baseDescriptor(); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + // Diagonally INTO the steep face and upward, so the motion really is projected — a purely + // upward step would leave the surface and never reach the cap at all. + const asked: Real = 0.4; + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(0.3, asked, 0), 1.0 / 60.0); + // The requested rise is SERVED, and the second half is what makes this discriminating: it is + // not merely non-zero, it is the whole amount asked for. + try testing.expectApproxEqAbs(0.02 + asked, r.position.toArray()[1], api_tol); + } +} + +test "the touched-body capacity accounts for the step's sweeps exactly" { + // The bound is a sum of iteration ceilings and not a guess: the depenetration iterations, the + // slide iterations, the three sweeps of the single step attempt, and the one step-down sweep. + // Asserted so a later gate that adds a sweep has to revisit the arithmetic rather than + // discovering the assert at runtime. + try testing.expectEqual(@as(u32, 4), character_mod.max_depenetration_iterations); + try testing.expectEqual(@as(u32, 4), character_mod.max_slide_iterations); +} + +test "a low kerb with level ground either side is stepped OVER, and the move is served in full" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 240); + // A THIN kerb 0.2 m high at x ∈ [1, 1.1], flat ground on both sides. Well inside the 0.3 m + // `step_height`, and the landing is at the SAME height the character started from — which is + // what makes this the discriminating case for the acceptance condition. + _ = try addBox(gpa, &world, av(0.05, 0.1, 2), av(1.05, 0.1, 0), 241); + + var desc = baseDescriptor(); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(2, 0, 0), 1.0 / 60.0); + + // The whole 2 m is served and the height is unchanged: over the kerb and down the other side. + try testing.expectApproxEqAbs(@as(Real, 2), r.position.toArray()[0], api_tol); + try testing.expectApproxEqAbs(@as(Real, 0.02), r.position.toArray()[1], api_tol); + try testing.expectEqual(api.GroundState.grounded, r.ground.state); + + // MEASURED counter-factual, and it is why a "the landing must be higher than the start" + // condition was removed from `tryStepUp`: with it, this move ends at x = 0.912 and y = 0.495 — + // blocked by a 0.2 m kerb AND half a metre in the air, having ratcheted up its edge. +} + +// --------------------------------------------------------------------------- +// F — resize, push, teleport. +// --------------------------------------------------------------------------- + +test "resizeCharacter is atomic, anchored at the feet, and keeps the presence BodyId" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 300); + var desc = baseDescriptor(); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + const presence = (try chars.getCharacterInnerBody(id)).?; + + // SHRINK — always succeeds, the target volume being contained in the current one. + try testing.expectEqual(true, try chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, 0.3, 1.0)); + { + const c = chars.get(id).?; + try testing.expectApproxEqAbs(@as(Real, 1.0), c.height, api_tol); + // ANCHORED AT THE FEET: the base is exactly where it was. + try testing.expectApproxEqAbs(@as(Real, 0.02), c.position.toArray()[1], api_tol); + // The presence's pose follows the NEW half-height: 0.02 + 0.5 = 0.52. + try testing.expectApproxEqAbs(@as(Real, 0.52), world.bm.position(presence).?.toArray()[1], api_tol); + // And the capsule the store built has the new cylinder half-height, 1.0/2 − 0.3 = 0.2. + try testing.expectApproxEqAbs(@as(Real, 0.2), world.store.get(c.shape).?.half_height, api_tol); + } + // THE HANDLE IS THE SAME. A resize is not a re-creation, so an exclusion the caller memorised + // survives it — which is the whole reason `BodyManager.setShape` exists. + try testing.expectEqual(presence, (try chars.getCharacterInnerBody(id)).?); + + // GROW back into a free volume — succeeds, base still unmoved. + try testing.expectEqual(true, try chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, 0.3, 1.8)); + try testing.expectApproxEqAbs(@as(Real, 0.02), chars.get(id).?.position.toArray()[1], api_tol); + try testing.expectEqual(presence, (try chars.getCharacterInnerBody(id)).?); + // Exactly ONE capsule is live besides the plane's shape: the old one was destroyed, not leaked. + try testing.expectEqual(@as(u32, 2), world.store.count()); +} + +test "growing under a low ceiling returns false and changes NOTHING" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 310); + // A ceiling slab whose underside is at y = 1.2, so a 1.0 m character fits and a 1.8 m one does + // not. + _ = try addBox(gpa, &world, av(2, 0.5, 2), av(0, 1.7, 0), 311); + + var desc = baseDescriptor(); + desc.position = av(0, 0.02, 0); + desc.height = 1.0; + const id = try addMover(gpa, &world, &chars, desc); + const presence = (try chars.getCharacterInnerBody(id)).?; + const before = chars.get(id).?; + const shape_before = before.shape; + + // `false` and not an error: a blocked stand-up is a legitimate gameplay answer, and the caller + // will try again next tick. Same split as `shapeCast` — an error channel for an inadmissible + // input, an absent value for a real refusal. + try testing.expectEqual(false, try chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, 0.3, 1.8)); + + // NOTHING changed — asserted on all three, not just on the return value. + const after = chars.get(id).?; + try testing.expectApproxEqAbs(before.height, after.height, api_tol); + try testing.expectApproxEqAbs(before.radius, after.radius, api_tol); + try testing.expect(after.position.approxEql(before.position, api_tol)); + try testing.expectEqual(shape_before, after.shape); + try testing.expectEqual(presence, (try chars.getCharacterInnerBody(id)).?); + try testing.expectEqual(shape_before, world.bm.shapeOf(presence).?); + // And the capsule built to ask the question was destroyed: THREE shapes live — the plane's, the + // ceiling box's and the character's — exactly as before the call. Three and not two: `addBox` + // creates a shape of its own, which a first version of this count forgot. + try testing.expectEqual(@as(u32, 3), world.store.count()); + + // Shrinking in the same scene still succeeds, which is what shows the `false` above was about + // the ceiling and not about resizing being broken. + try testing.expectEqual(true, try chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, 0.3, 0.9)); +} + +test "resizeCharacter refuses the same domain as createCharacter, by typed error" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + const id = try addMover(gpa, &world, &chars, baseDescriptor()); + const nan = std.math.nan(f32); + const inf = std.math.inf(f32); + + // A resize is a SECOND DOOR onto the same domain, so it cannot be a laxer one — including + // `height >= 2 · radius`, which is the bound a caller is most likely to trip while crouching. + try testing.expectError(error.InvalidDimensions, chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, nan, 1.8)); + try testing.expectError(error.InvalidDimensions, chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, 0, 1.8)); + try testing.expectError(error.InvalidDimensions, chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, -0.3, 1.8)); + try testing.expectError(error.InvalidDimensions, chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, 0.3, inf)); + try testing.expectError(error.InvalidDimensions, chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, 0.3, 0)); + try testing.expectError(error.InvalidDimensions, chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, 0.3, 0.4)); + // Nothing was built by any refusal: one capsule live, the character's own. + try testing.expectEqual(@as(u32, 1), world.store.count()); + + // And a stale handle is the caller's fault too. + chars.destroyCharacter(gpa, &world.bp, &world.store, &world.bm, id); + try testing.expectError(error.StaleCharacter, chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, 0.3, 1.8)); +} + +test "the push is unilateral: the box moves, the character does not react to it" { + const gpa = testing.allocator; + + // Same scene twice, differing ONLY in `max_push_force`. The character's own resolution must be + // identical in both — which is what separates a push from an elastic collision — while the box + // moves in one and not the other. + var box_speed: [2]Real = @splat(0); + var char_x: [2]Real = @splat(0); + + for ([_]f32{ 0, 100 }, 0..) |max_push, i| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // A unit-mass dynamic box whose −X face is at x = 0.35, so the capsule (radius 0.3) contacts + // it when its centre reaches x = 0.05 — inside the 0.1 m step below. + const box_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av(0.5, 0.5, 0.5) } }); + const box = try world.addBody(gpa, .{ + .entity = ent(320), + .body_type = .dynamic, + .shape = box_shape, + .position = av(0.85, 0, 0), + .mass = 1, + }); + + var desc = baseDescriptor(); + desc.position = av(0, 0, 0); + desc.max_push_force = max_push; + const id = try addMover(gpa, &world, &chars, desc); + + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(0.1, 0, 0), 1.0 / 60.0); + box_speed[i] = world.bm.linearVelocity(box).?.toArray()[0]; + char_x[i] = r.position.toArray()[0]; + } + + // CLOSED FORM. The character's speed is `0.1 / (1/60) = 6 m/s`, the box is at rest, so the + // impulse wanted is `mass · closing = 70 · 6 = 420 N·s` and the ceiling is + // `max_push_force · dt = 100/60 = 1.6667 N·s` — the ceiling wins. The box has unit mass, so its + // speed becomes exactly that impulse. + try testing.expectApproxEqAbs(@as(Real, 100.0 / 60.0), box_speed[1], api_tol); + // `max_push_force = 0` disables pushing with no special case: not a millimetre per second. + try testing.expectEqual(@as(Real, 0), box_speed[0]); + + // UNILATERAL: the character stops in exactly the same place whether or not the box yielded. This + // is the half that distinguishes a push from a collision, and it would fail if any impulse were + // applied back to the character or if its stop depended on the box's response. + try testing.expectApproxEqAbs(char_x[0], char_x[1], api_tol); +} + +test "setCharacterPosition teleports without resolving and invalidates the reported verdict" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 330); + var desc = baseDescriptor(); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + + // A move first, so there IS a reported verdict to invalidate. + const moved = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, Vec3r.zero, 1.0 / 60.0); + try testing.expectEqual(api.GroundState.grounded, moved.ground.state); + try testing.expectEqual(api.GroundState.grounded, chars.reportedGround(id).?); + + // Teleport INTO the floor — 0.5 m under it. It resolves nothing, so the character stays there: + // that is the contract and not a limitation, the caller having asked to BE somewhere. + try chars.setCharacterPosition(gpa, &world.bp, &world.bm, &world.store, id, v(3, -0.5, 0)); + try testing.expect(chars.get(id).?.position.approxEql(v(3, -0.5, 0), tol)); + // The reported verdict is INVALIDATED to `.in_air`, the safe failure direction: one tick of + // gravity rather than a character believed to be standing where it no longer is. + try testing.expectEqual(api.GroundState.in_air, chars.reportedGround(id).?); + + // NO-OP on a stale handle, and the discriminant is whether the entry RETURNS A VALUE: the four + // entries that return something have no honest answer for a dead handle and carry an error + // channel, while `destroyCharacter` and this one return nothing, so the no-op IS the answer. + chars.destroyCharacter(gpa, &world.bp, &world.store, &world.bm, id); + try chars.setCharacterPosition(gpa, &world.bp, &world.bm, &world.store, id, v(9, 9, 9)); + try testing.expectEqual(@as(?character_mod.Character, null), chars.get(id)); +} + +test "presence freshness on the second and third write paths, and resize reflects the SIZE" { + const gpa = testing.allocator; + + // Both assertions are written the same way as the move's, and for the reason measured at gate D: + // a stale fat box the ray still crosses yields the CORRECT distance anyway, because the exact + // answer comes from the body's pose. So each ray approaches the new pose from a direction the + // OLD box does not intersect. + + // (a) setCharacterPosition. + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + var desc = baseDescriptor(); + desc.entity = ent(340); + desc.position = av(0, 0, 0); + const id = try addMover(gpa, &world, &chars, desc); + const presence = (try chars.getCharacterInnerBody(id)).?; + + try chars.setCharacterPosition(gpa, &world.bp, &world.bm, &world.store, id, v(5, 0, 0)); + + // From −Z at x = 5: the old box sits around x = 0 with a 0.1 m fat margin and is nowhere near + // this ray, so without the proxy update the presence is never offered and this misses. + const across = query.RayQuery{ .origin = v(5, 0.9, -10), .direction = v(0, 0, 1), .max_distance = 100 }; + const hit = query.raycast(&world.bp, &world.bm, &world.store, across); + try testing.expect(hit != null); + try testing.expectEqual(presence, hit.?.body); + try testing.expectApproxEqAbs(@as(Real, 9.7), hit.?.distance, api_tol); + } + + // (b) resizeCharacter — the same pose-freshness question, plus the one only a resize has. + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + var desc = baseDescriptor(); + desc.entity = ent(341); + desc.position = av(0, 0, 0); + desc.height = 1.0; + const id = try addMover(gpa, &world, &chars, desc); + const presence = (try chars.getCharacterInnerBody(id)).?; + + // A ray at y = 1.5 passes ABOVE a 1.0 m capsule and finds nothing. + const high = query.RayQuery{ .origin = v(-10, 1.5, 0), .direction = v(1, 0, 0), .max_distance = 100 }; + try testing.expectEqual(@as(?query.RayHit, null), query.raycast(&world.bp, &world.bm, &world.store, high)); + + try testing.expectEqual(true, try chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, 0.3, 1.8)); + + // THE HALF ONLY A RESIZE HAS: the same ray must now HIT, because the capsule reaches y = 1.8. + // Nothing but a broadphase box reflecting the new SIZE makes that true — a box updated to the + // new pose but the old extents leaves this ray unoffered, and no pose-only assertion can tell + // the difference. + const grown = query.raycast(&world.bp, &world.bm, &world.store, high); + try testing.expect(grown != null); + try testing.expectEqual(presence, grown.?.body); + // The capsule's cylinder spans y ∈ [0.3, 1.5] at radius 0.3, so at y = 1.5 the ray grazes the + // top of the cylinder and the wall is at x = −0.3: distance 9.7. + try testing.expectApproxEqAbs(@as(Real, 9.7), grown.?.distance, api_tol); + } +} + +test "resize and teleport both wake what their new volume reaches" { + const gpa = testing.allocator; + + for ([_]enum { resize, teleport }{ .resize, .teleport }) |which| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 350); + // A sleeping dynamic box the new volume REACHES without overlapping it, so the resize + // SUCCEEDS and the wake is asserted on the success path. Half-extents 0.4 centred at + // x = 0.75, so its tight box starts at 0.35 while the capsule's surface stops at 0.3 — a + // 0.05 m gap, closed by the broadphase's 0.1 m fat margin, which is exactly the superset the + // waker is documented to be. + const box_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av(0.4, 0.4, 0.4) } }); + const sleeper = try world.addBody(gpa, .{ + .entity = ent(351), + .body_type = .dynamic, + .shape = box_shape, + .position = if (which == .resize) av(0.75, 0.9, 0) else av(5, 0.9, 0), + .mass = 1, + }); + sleep_mod.putToSleep(&world.bm, sleeper); + try testing.expectEqual(true, world.bm.isSleeping(sleeper).?); + + var desc = baseDescriptor(); + desc.position = av(0, 0.02, 0); + desc.height = 1.0; + const id = try addMover(gpa, &world, &chars, desc); + + switch (which) { + // A SUCCESSFUL grow whose new volume reaches the sleeper without overlapping it. + .resize => { + try testing.expectEqual(true, try chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, 0.3, 1.8)); + try testing.expectEqual(false, world.bm.isSleeping(sleeper).?); + }, + // A teleport ONTO the box wakes it: the new pose reaches it, and a presence moved by pose + // write has velocity columns of exactly zero, so W3 could never see it (§1.12.10). + .teleport => { + try chars.setCharacterPosition(gpa, &world.bp, &world.bm, &world.store, id, v(5, 0.02, 0)); + try testing.expectEqual(false, world.bm.isSleeping(sleeper).?); + }, + } + } +} + +// --------------------------------------------------------------------------- +// F.0bis — the unguarded squeeze mode, MEASURED, and the step path's padding +// --------------------------------------------------------------------------- + +test "a character squeezed under a low ceiling WALKS, and is never driven through the floor" { + const gpa = testing.allocator; + + // **This test pinned a stall and the stall is gone.** It used to assert that a squeezed character + // was PINNED at base zero, serving nothing. That was the tangency consequence: the ceiling's + // downward normal does not oppose a horizontal motion, so the contact obstructed nothing and yet + // consumed all four slide iterations. The loop now sets such a contact aside for one free retry, and + // the character walks. + // + // What the test still protects is the half that mattered: it is never driven THROUGH the floor. + for ([_]f32{ 1.0, 1.7 }) |clear| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 340); + _ = try addBox(gpa, &world, av(4, 0.5, 4), av(0, clear + 0.5, 0), 341); + var desc = baseDescriptor(); + desc.entity = ent(342); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + + var previous: Real = 0; + var k: u32 = 0; + while (k < 2) : (k += 1) { + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0); + const p = r.position.toArray(); + // The whole metre, both calls: a character squeezed VERTICALLY with clear horizontal space + // has no reason not to walk. + try testing.expectApproxEqAbs(previous + 1, p[0], api_tol); + previous = p[0]; + // Resting on the floor at `padding`, and NEVER below it — the assertion this test exists for. + try testing.expectApproxEqAbs(@as(Real, 0.02), p[1], api_tol); + try testing.expect(p[1] >= -api_tol); + } + } +} + +test "the depenetration's iteration count is even — a record, no longer a correction" { + // **THE HISTORY, KEPT BECAUSE IT IS WHY THE INVARIANT EXISTS.** Before the §1.12.6 depenetration + // invariant, `max_depenetration_iterations` alternating between the two surfaces of an + // unresolvable squeeze meant the side the character ended on was the count's PARITY: measured by + // changing the constant, at 3 and at 5 the base landed at −0.800000 — the full depth of the + // squeeze, on the far side of the ground plane — and at 4 and at 8 it landed at 0. Nothing else + // in the suite moved at any of the four values, so an odd count would have shipped silently. + // + // A guarantee cannot rest on the parity of a constant, and this assertion protected the constant + // while saying nothing about the algorithm. The invariant replaced it as the load-bearing + // statement, and the outcome no longer depends on the count at all — verified at 3, 4, 5 and 8. + // This line stays as the RECORD of that measurement and carries no correction. + try testing.expectEqual(@as(u32, 0), character_mod.max_depenetration_iterations % 2); +} + +test "the step's FORWARD sweep keeps the padding stand-off, which the lift's cannot show" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // A 0.25 m step spanning x ∈ [1, 3], and a wall standing ON it whose −X face is at x = 2.25. + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 360); + _ = try addBox(gpa, &world, av(1, 0.125, 1), av(2, 0.125, 0), 361); + _ = try addBox(gpa, &world, av(0.25, 1, 1), av(2.5, 1.25, 0), 362); + var desc = baseDescriptor(); + desc.entity = ent(363); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(3, 0, 0), 1.0 / 60.0); + const p = r.position.toArray(); + + // The step's forward sweep runs from the LIFTED pose, where the capsule clears the step top and + // the only thing ahead is the wall. Its advance is therefore `padding`-limited, and the pose it + // lands at survives to the end of the call: the next slide iteration finds the wall exactly + // `padding` away and advances by `max(0, 0.02 − 0.02) = 0`. + // + // So the answer is the wall's face minus the capsule radius minus the padding: + // 2.25 − 0.3 − 0.02 = 1.93. + // + // **The counterfactual was DERIVED as a lost stand-off and the MEASUREMENT refuted that.** With + // the padding dropped from the step's forward advance the character ends at 0.688, not at the + // predicted 1.95: the unpadded advance leaves the capsule EXACTLY flush against the wall, so the + // landing sweep — which runs downward from there — reports the WALL at distance zero instead of + // the plateau below, `drop` clamps to zero, and the whole climb is refused by condition 5. The + // character then slides to a stop against the step's riser. So the cost of losing the padding on + // this one sweep is not 0.02 m of stand-off, it is 1.24 m of a legitimate move never served — + // which is a far better account of why the reference shipped a fix for it. + try testing.expectApproxEqAbs(@as(Real, 1.93), p[0], api_tol); + // Resting on the step: 0.25 + padding. + try testing.expectApproxEqAbs(@as(Real, 0.27), p[1], api_tol); + try testing.expectEqual(api.GroundState.grounded, r.ground.state); + + // The LIFT's padding is deliberately NOT asserted, and the reason is that it CANNOT be: the lift + // and the landing drop both subtract it, and `drop > 0` is required, so the two subtractions + // cancel exactly in the final pose whatever the geometry. **CONFIRMED by probe rather than left + // as an argument**: dropping the padding from the lift alone breaks NOTHING in this suite, at + // either precision, while dropping it from the forward sweep breaks this test and from the + // landing sweep breaks two others. It is observable only as a change of VERDICT — a climb the raw + // lift would clear and the padded lift would not — which is a discrete boundary and not a + // stand-off. Recorded, and not asserted vacuously. +} + +test "on OOM a resize leaves the character UNCHANGED and is retryable" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 370); + var desc = baseDescriptor(); + desc.entity = ent(371); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + + const before = chars.get(id).?; + const shapes_before = world.store.count(); + const presence_before = (try chars.getCharacterInnerBody(id)).?; + + // `resizeCharacter`'s only interceptable allocation is the new capsule, at index 0. + var fa = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + try testing.expectError( + error.OutOfMemory, + chars.resizeCharacter(fa.allocator(), &world.bp, &world.bm, &world.store, id, 0.25, 1.2), + ); + + // UNCHANGED field by field, INCLUDING the shape handle and the live shape count. + const after = chars.get(id).?; + try testing.expect(after.position.eql(before.position)); + try testing.expectEqual(before.radius, after.radius); + try testing.expectEqual(before.height, after.height); + try testing.expectEqual(before.shape, after.shape); + try testing.expectEqual(shapes_before, world.store.count()); + try testing.expectEqual(presence_before, (try chars.getCharacterInnerBody(id)).?); + + // RETRYABLE: the same call with a working allocator succeeds and takes effect. + try testing.expectEqual(true, try chars.resizeCharacter(gpa, &world.bp, &world.bm, &world.store, id, 0.25, 1.2)); + try testing.expectApproxEqAbs(@as(Real, 1.2), chars.get(id).?.height, api_tol); + + // **THE OTHER FALLIBLE STEP OF THESE THREE ENTRIES COULD NOT BE MADE TO FAIL FROM A TEST, AND + // THAT IS RECORDED RATHER THAN GLOSSED.** `Broadphase.update` reserves a slot on its layer's + // moved log, so it can allocate — and `syncPresenceTo`'s ordering exists for exactly that. But + // `std.testing.FailingAllocator` at index 0 does not intercept it: MEASURED, a `moveCharacter` + // of 5 m on a freshly created character reports zero allocations seen, and 40 teleports of 5 m + // each — every one re-fitting the proxy well beyond the 0.1 m fat margin — report exactly one, + // because the list's growth goes through a resize the counter does not index. + // + // So the ordering in `syncPresenceTo` rests on a STRUCTURAL argument — the one fallible call + // precedes every mutation, and the box it publishes is the union of the old and new, so a failure + // leaves a proxy that still contains the body — and NOT on a pinned counterfactual. Consequently + // the use-after-free that ordering forecloses is a latent hazard and is NOT claimed as a + // demonstrated defect: what was demonstrated is that the allocation exists and that this test + // cannot make it fail. +} + +test "gravity on a steep face slides DOWN it, and the cap does not cancel the descent" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // **THE DIRECTION "THE GUARD DOES NOT OVER-FIRE", AND ITS ABSENCE IS WHAT LET A DEFECTIVE BOUND + // SHIP.** §1.12.6's cap was first written as "capped at the component BEFORE projection". On a + // sloped face that PENETRATES the plane: on a 50° normal `(−0.766, 0.643, 0)` a pure gravity + // step `(0, −1, 0)` projects to `(−0.4925, −0.5866, 0)`, whose up component `−0.5866` exceeds the + // `−1` it came from, so the cap bit and the output `(−0.4925, −1, 0)` had a dot of `−0.2657` + // with the normal — driving INTO the surface. And the projection was the physically correct + // answer: a body sliding down a 50° slope descends more slowly than in free fall because the + // surface carries part of the motion. The bound is `max(before, 0)`. + // + // A 60° face, so the trigonometry is closed form: `cos 60° = 1/2`, `sin 60° = √3/2`, hence + // `sin² = 3/4` and `sin·cos = √3/4`. Built as a box rotated about +Z, top-face normal + // `(−sin, cos, 0)`, the face passing through `(0.5, 0, 0)`. + const rad: f32 = 60.0 * std.math.pi / 180.0; + const shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av(3, 0.5, 3) } }); + _ = try world.addBody(gpa, .{ + .entity = ent(380), + .body_type = .static, + .shape = shape, + .position = av(0.5 + 0.5 * @sin(rad), -0.5 * @cos(rad), 0), + .rotation = math.Quatf.fromAxisAngle(av(0, 0, 1), rad), + }); + + // A capsule's closest approach to a plane comes from its bottom AXIS endpoint, not from its base, + // so the offset placing the SURFACE `c0` clear of the face is `radius·(1 − cos) + c0`. With + // `radius = 0.3` and `c0 = 0.12` that is `0.15 + 0.12 = 0.27`. + var desc = baseDescriptor(); + desc.entity = ent(381); + desc.position = av(0.5 - 0.27 * @sin(rad), 0.27 * @cos(rad), 0); + const id = try addMover(gpa, &world, &chars, desc); + const start = chars.get(id).?.position; + + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(0, -0.5, 0), 1.0 / 60.0); + const moved = r.position.sub(start).toArray(); + + // CLOSED FORM. Travelling along −Y closes the clearance at the rate `cos 60° = 1/2`, so contact + // is at `c0 / cos = 0.24`, and the padded advance is `0.24 − padding = 0.22` — the padding coming + // off the TRAVEL and not off the normal, the distinction that cost a first derivation here. The + // remaining `0.5 − 0.22 = 0.28` is projected onto the face and, `before` being negative, is left + // intact by the cap: + // + // dy = −0.22 − 0.28·(3/4) = −0.43 + // dx = −0.28·(√3/4) = −0.1212436 + const rem: Real = 0.28; + try testing.expectApproxEqAbs(@as(Real, -0.43), moved[1], api_tol); + try testing.expectApproxEqAbs(-rem * @sqrt(@as(Real, 3.0)) / 4.0, moved[0], api_tol); + try testing.expectEqual(api.GroundState.on_steep_ground, r.ground.state); + + // **MEASURED against the counterfactual**, which is what makes this discriminating rather than + // merely true: with the bound at `before` the same scene gives `dy = −0.281645` and + // `dx = −0.026694` — a third of the descent lost and the lateral slide cut by 4.5×. And at exact + // contact the old bound is worse still than the account that produced it: on a 50° face under + // pure gravity the character does not move AT ALL, `dy = 0.00000`, frozen on the cliff. + // + // The cap's purpose is that the ENGINE does not climb on the character's behalf. It is NOT a + // guarantee that the output never points into the plane: when the CALLER asks to rise, the cap + // preserves that rise by construction and the result can still have a component into the + // surface, which the next sweep and the depenetration answer. Stated so no reader takes the + // first property for the second. +} + +test "a doorway narrower than the character NEVER ejects it, whatever the iteration count" { + const gpa = testing.allocator; + + // The ordinary form of the same unresolvable mode as the low ceiling — two opposing contacts — + // and it is NOT a second instance of the tunnelling, which was measured rather than assumed. The + // two walls are symmetric about the entry pose, so the alternation stays bounded; the ceiling + // tunnels because the floor is not a contact at entry, which makes the first push large and + // unopposed. Kept as a pin all the same: this is what a future change to `depenetrate` has to + // keep true, and the doorway is the case a game actually walks into. + // + // Three widths against a 0.60 m capsule, so a small deficit as well as a large one. + for ([_]f32{ 0.4, 0.5, 0.58 }) |width| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 390); + _ = try addBox(gpa, &world, av(1, 2, 2), av(-1 - width / 2, 2, 0), 391); + _ = try addBox(gpa, &world, av(1, 2, 2), av(1 + width / 2, 2, 0), 392); + var desc = baseDescriptor(); + desc.entity = ent(393); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + + // CLOSED FORM. The capsule overlaps each wall by `radius − width/2`, and the depenetration + // resolves the deeper one — at entry they are equal, the tie broken on the smaller `BodyId`. + // The resolved offset is that overlap PLUS the `padding` stand-off, since depenetration + // resolves to `padding` of clearance and not to touching: `0.12`, `0.07`, `0.03` for the three + // widths, alternating side from call to call. + // + // The EJECTION invariant below is what matters and it is unaffected: `0.12 < 0.20`, + // `0.07 < 0.25`, `0.03 < 0.29`. Only the number moved, and it moved by exactly `padding`. + const offset: Real = 0.3 - width / 2 + 0.02; + var k: u32 = 0; + while (k < 3) : (k += 1) { + // Along +Z, which neither wall blocks — and which is nonetheless never served: the two + // wall normals are ANTIPARALLEL, so their crease is exactly parallel and + // `slideAlongCrease` returns its documented third answer, no edge to slide along. + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(0, 0, 0.2), 1.0 / 60.0); + const p = r.position.toArray(); + + try testing.expectApproxEqAbs(offset, @abs(p[0]), api_tol); + // INSIDE the doorway — the assertion that matters, and the one a tunnelling + // depenetration would break. + try testing.expect(@abs(p[0]) < width / 2); + try testing.expectApproxEqAbs(@as(Real, 0.02), p[1], api_tol); + // **THE +Z REQUEST IS NOW SERVED, where this test used to assert it was not.** The two wall + // normals are antiparallel and neither opposes a motion along +Z, so both contacts obstruct + // nothing — and the loop now sets a non-obstructing contact aside instead of spending an + // iteration on it. Walking down a corridor is the case a game actually walks into, and it + // used to serve nothing at all. + try testing.expectApproxEqAbs(0.2 * @as(Real, @floatFromInt(k + 1)), p[2], api_tol); + } + // The SIGN alternates from call to call and the magnitude does not, so nothing above reads + // the sign. Measured identical at iteration counts of 3, 4 and 5 — the alternation is per + // CALL, not per iteration, which is why the count does not enter this answer at all. + } +} + +// --------------------------------------------------------------------------- +// Gate G closing round — six findings from external review +// --------------------------------------------------------------------------- + +/// Counts how many broadphase candidates a box query is offered, and whether one of them is a +/// particular body. `queryAabb`'s collector contract is `add(user_data: u32)`. +const CandidateCount = struct { + looking_for: api.BodyId, + total: u32 = 0, + found: bool = false, + + pub fn add(self: *CandidateCount, user_data: u32) void { + self.total += 1; + if (user_data == self.looking_for) self.found = true; + } +}; + +test "P1-1 the pushes are accumulated and applied ONCE, after the publication" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // The box sits 8 m away, so the move ADVANCES before it pushes — which is what makes the + // accumulator observable at all: a move that pushes from a standing start barely advances. + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 380); + const box_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av(0.5, 0.5, 0.5) } }); + const box = try world.addBody(gpa, .{ + .entity = ent(381), + .body_type = .dynamic, + .shape = box_shape, + .position = av(8, 0.5, 0), + .mass = 1, + }); + var desc = baseDescriptor(); + desc.entity = ent(382); + desc.position = av(0, 0.02, 0); + // **THE FORCE CEILING IS DELIBERATELY SLACK AND THE MASSES ARE UNITY, and without that this test + // does not discriminate.** A first version used the default 70 kg against a ceiling of `100/60`, + // and both forms then answer `2 · 100/60` — the ceiling binds on every iteration, so whether the + // second one reads the first one's result changes nothing. Confirmed by probe: re-applying the + // push inside the slide loop broke NO test. With the ceiling slack the `closing` term decides + // instead, and the two forms differ by exactly a factor of two. + desc.mass = 1; + desc.max_push_force = 1000; + const id = try addMover(gpa, &world, &chars, desc); + + // `dt = 1 s` so the derived speed is `10 / 1 = 10 m/s`, a sane number rather than the 600 m/s a + // 10 m displacement at 60 Hz implies. + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(10, 0, 0), 1); + + // The character stops `padding` short of the box: 8 − 0.5 − 0.3 − 0.02 = 7.18. + try testing.expectApproxEqAbs(@as(Real, 7.18), r.position.toArray()[0], api_tol); + + // **EVERY PLANNED PUSH IS EXACTLY `mass · closing = 1 · 10 = 10`**, and they are SUMMED per body — + // a planner cannot read a velocity that has not been written yet, so each contact plans against + // the same pre-call value. + // + // **THE NUMBER OF CONTACTS AGAINST THE BOX DIFFERS BY PRECISION, and it is measured rather than + // designed: two at f32, one at f64.** A head-on wall's slide cancels the whole motion, so the + // second contact exists only because an f32 residue leaves a sliver of `remaining` behind — it is + // FLOAT NOISE, not a path this engine plans. Asserting `20` at both precisions would have been + // pinning that noise, and it is what made the f64 leg of the matrix red on the first attempt. + // + // So with a SLACK ceiling the answer is per-precision, `n · 10` for whatever `n` the geometry + // produced. Applied INSIDE the loop the f32 answer would be `10` — the second iteration seeing the + // box already leaving at 10 m/s and computing a closing speed of exactly zero. + const expected_push: Real = if (Real == f32) 20 else 10; + try testing.expectApproxEqAbs(expected_push, world.bm.linearVelocity(box).?.toArray()[0], api_tol); + try testing.expectEqual(api.GroundState.grounded, r.ground.state); +} + +test "P1-1 the force ceiling holds however many contacts one body takes" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // The same scene as above, with a ceiling that BINDS. + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 430); + const box_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av(0.5, 0.5, 0.5) } }); + const box = try world.addBody(gpa, .{ + .entity = ent(431), + .body_type = .dynamic, + .shape = box_shape, + .position = av(8, 0.5, 0), + .mass = 1, + }); + var desc = baseDescriptor(); + desc.entity = ent(432); + desc.position = av(0, 0.02, 0); + desc.mass = 1; + desc.max_push_force = 5; + const id = try addMover(gpa, &world, &chars, desc); + + _ = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(10, 0, 0), 1); + + // **`max_push_force` IS A CEILING PER CALL, NOT PER CONTACT**, and an earlier form broke that: the + // pushes were appended without coalescing and applied one `addImpulse` per entry, each capped at + // `max_push_force · dt`, so two contacts on one body delivered TWICE the declared ceiling and + // three would have delivered three times. A ceiling one can exceed by being touched twice is not a + // ceiling. The per-body sum is now capped ONCE. + // + // Ceiling `5 · 1 = 5`, each contact planning `1 · 10 = 10`, so the summed 10 or 20 clamps to 5 and + // the unit-mass box leaves at exactly 5 m/s. + // + // **AND THIS NUMBER IS THE SAME AT BOTH PRECISIONS**, where the slack-ceiling test's is not — which + // is the second half of what coalescing bought: the answer stops depending on how many contacts + // the sweep happened to make against one body, an iteration detail and not a physical quantity. + try testing.expectApproxEqAbs(@as(Real, 5), world.bm.linearVelocity(box).?.toArray()[0], api_tol); +} + +test "P1-1 a publication that fails leaves every body velocity untouched" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 385); + const box_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av(0.5, 0.5, 0.5) } }); + const box = try world.addBody(gpa, .{ + .entity = ent(386), + .body_type = .dynamic, + .shape = box_shape, + .position = av(3, 0.5, 0), + .mass = 1, + }); + var desc = baseDescriptor(); + desc.entity = ent(387); + desc.position = av(0, 0.02, 0); + desc.max_push_force = 100; + const id = try addMover(gpa, &world, &chars, desc); + + // **The injection needs BOTH fail indices, and the second one is why gate F recorded this + // failure as un-injectable.** `syncPresenceTo`'s only fallible call is `Broadphase.update`, whose + // reservation grows an `ArrayListUnmanaged`, and a list grows through `remap` first, falling back + // to `alloc` only when that returns null. With `fail_index` alone the growth went through `remap` + // and the call reported ZERO allocations seen. `resize_fail_index` closes the other door. + // + // **MEASURED LIMIT, stated rather than implied.** The growth lands on the 34th append for this + // operation sequence, and an append happens only when a proxy leaves its fat box — so it lands on + // a POSE WRITE that travels. Five constructions were tried to land it on a call that also pushes, + // and none did: a move that pushes is a move that barely advances, hence does not refit, hence + // never grows the log. So what this test pins is the invariant on the reachable half — a failed + // publication changes no velocity anywhere — and the ORDERING that protects the push is carried + // by the structural argument in `PendingPushes`, not by a counterfactual here. + var fired = false; + var round: u32 = 0; + while (round < 60) : (round += 1) { + const f: Real = @floatFromInt(round); + var fa = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0, .resize_fail_index = 0 }); + chars.setCharacterPosition(fa.allocator(), &world.bp, &world.bm, &world.store, id, v(20 + f * 5, 0.02, 0)) catch |e| { + try testing.expectEqual(error.OutOfMemory, e); + fired = true; + break; + }; + } + // NON-VACUITY: without this the loop could pass by never having injected anything. + try testing.expect(fired); + + // Nothing in the world moved — no velocity, and the character's own record is where it was. + try testing.expect(world.bm.linearVelocity(box).?.eql(Vec3r.zero)); + + // RETRYABLE with a working allocator, and the push then happens exactly once per contact. + try chars.setCharacterPosition(gpa, &world.bp, &world.bm, &world.store, id, v(0, 0.02, 0)); + _ = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(5, 0, 0), 1.0 / 60.0); + try testing.expect(world.bm.linearVelocity(box).?.toArray()[0] > 0); +} + +test "P1-2 a teleport leaves no permanently fat leaf behind it" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 390); + var desc = baseDescriptor(); + desc.entity = ent(391); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + const presence = (try chars.getCharacterInnerBody(id)).?; + + // 50 m in one teleport, far beyond the 0.1 m fat margin. + try chars.setCharacterPosition(gpa, &world.bp, &world.bm, &world.store, id, v(50, 0.02, 0)); + + // A small box at the MIDPOINT of that trajectory, where the character has never been and is not + // now. An interim form published the UNION of the old and the new box, and `Bvh.update` returns + // WITHOUT refitting once its stored fat box contains the new tight one — so that leaf covered the + // whole 50 m permanently and no later call shrank it. The presence must not be a candidate here. + var mid = CandidateCount{ .looking_for = presence }; + _ = world.bp.queryAabb(math.Aabb(Real).fromMinMax(v(24, 0, -1), v(26, 2, 1)), &mid); + try testing.expect(!mid.found); + + // NON-VACUITY: the same query AT the new pose does find it, so the collector and the traversal + // both work and the assertion above is about the box's extent and not about a broken probe. + var at_new = CandidateCount{ .looking_for = presence }; + _ = world.bp.queryAabb(math.Aabb(Real).fromMinMax(v(49, 0, -1), v(51, 2, 1)), &at_new); + try testing.expect(at_new.found); +} + +test "P1-3 destroying a character removes its broadphase proxy, leaf and all" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + var desc = baseDescriptor(); + desc.entity = ent(401); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + const presence = (try chars.getCharacterInnerBody(id)).?; + + const box = math.Aabb(Real).fromMinMax(v(-1, 0, -1), v(1, 2, 1)); + + // Before: the query traverses the leaf and is offered it. + var before = CandidateCount{ .looking_for = presence }; + _ = world.bp.queryAabb(box, &before); + try testing.expect(before.found); + + chars.destroyCharacter(gpa, &world.bp, &world.store, &world.bm, id); + + // After: nothing at all. **Asserted on REACHABILITY and not on a node count**, which is the whole + // point: a leaked leaf keeps its last box and every query over that region keeps visiting it, + // against a body handle that has been freed and whose slot will be recycled. A count of nodes + // would not have said the leaf was still reachable. + var after = CandidateCount{ .looking_for = presence }; + _ = world.bp.queryAabb(box, &after); + try testing.expect(!after.found); + try testing.expectEqual(@as(u32, 0), after.total); +} + +test "P2-1 ground_velocity is read at the body's SURFACE, not at the penetration midpoint" { + const gpa = testing.allocator; + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // A platform spinning about +Z, so the point's HEIGHT is what the cross product reads: for + // `ω = (0,0,ω)` and `r = (0,y,0)` the velocity is `(−ω·y, 0, 0)`. About +Y — the obvious choice — + // the height cancels out entirely and this fix would be unobservable, which is why the axis is +Z. + const plat_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av(4, 0.5, 4) } }); + const platform = try world.addBody(gpa, .{ + .entity = ent(410), + .body_type = .kinematic, + .shape = plat_shape, + .position = av(0, 0, 0), + }); + world.bm.setAngularVelocity(platform, v(0, 0, 2)); + + // FRANK INTERPENETRATION, which is what puts the verdict on the MANIFOLD path: the ground sweep + // starts overlapping, returns distance zero, and its normal is `−direction` and unusable. The + // rotating-platform test that already existed has the character separated by `padding`, so it + // goes through the SWEEP and never reaches this point at all. + var desc = baseDescriptor(); + desc.entity = ent(411); + desc.position = av(0, 0.3, 0); // capsule bottom at 0.3, platform top at 0.5 ⇒ 0.2 m of overlap + const id = try addMover(gpa, &world, &chars, desc); + + const ground = try chars.groundOf(&world.bp, &world.bm, &world.store, id); + try testing.expectEqual(api.GroundState.grounded, ground.state); + + // CLOSED FORM. The manifold point is the MIDPOINT of the two surface points, `(0.3+0.5)/2 = 0.4`; + // the platform's own surface is at `0.5`. So `r = (0, 0.5, 0)` and + // `ω × r = (0,0,2) × (0,0.5,0) = (−1, 0, 0)`. + try testing.expectApproxEqAbs(@as(Real, -1), ground.velocity.toArray()[0], api_tol); + // And the REFUTED value is named, so the test discriminates instead of merely accepting: the + // midpoint would give `−0.8`, which this bound excludes. + try testing.expect(@abs(ground.velocity.toArray()[0] - (-0.8)) > 0.1); +} + +test "P2-2 step_height is validated like every other stored physical parameter" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // It is a SWEEP DISTANCE — `tryStepUp`'s lift and `stepDown`'s probe hand it straight to + // `sweepNearest` — and it was the one stored physical parameter with no guard. + for ([_]f32{ std.math.nan(f32), std.math.inf(f32), -0.1 }) |bad| { + var d = baseDescriptor(); + d.step_height = bad; + try testing.expectError(CharacterError.InvalidDimensions, chars.createCharacter(gpa, &store, &bm, d)); + } + // ZERO is legal and is the disabler the suite already exercises: no climb, no floor-sticking. + var ok = baseDescriptor(); + ok.step_height = 0; + const id = try chars.createCharacter(gpa, &store, &bm, ok); + try testing.expectApproxEqAbs(@as(Real, 0), chars.get(id).?.step_height, api_tol); + // Nothing was left behind by the three refusals: one character, one capsule, one presence. + try testing.expectEqual(@as(u32, 1), chars.count()); + try testing.expectEqual(@as(u32, 1), store.count()); + try testing.expectEqual(@as(u32, 1), bm.count()); +} + +test "a base EXACTLY on the floor is no longer frozen — seven heights, both directions" { + const gpa = testing.allocator; + + // **THE DEFECT THIS PINNED IS NOW FIXED, and the comment that described it as pinned-not-fixed is + // DELETED rather than amended — it described a state that no longer exists.** + // + // At exact tangency the horizontal sweep reported a contact at distance zero, `paddedAdvance` + // returned `max(0, 0 − padding) = 0`, and the slide returned a horizontal motion projected on a + // horizontal plane unchanged — so all four slide iterations were consumed with no progress and the + // remainder was dropped. The character never moved again, since it never left zero. + // + // Closed in `depenetrate`, which resolves to `padding` of clearance instead of to touching. That + // pass was MANUFACTURING the frozen state, not merely failing to leave it: a character starting + // 0.05 m inside the floor was resolved to a base of exactly `0.000000000` and froze, so the + // reachability was never authoring alone. + // + // BOTH DIRECTIONS, and the second is the half whose absence let a slope bound through at gate F: + // `0` now serves the whole metre like the others, AND the six clear heights keep exactly the + // behaviour they had — a capsule already standing off is invisible to the overlap query, so + // nothing lifts it. + const heights = [_]f32{ 0, 0.005, 0.01, 0.019, 0.02, 0.05, 0.2 }; + for (heights) |start_y| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 420); + var desc = baseDescriptor(); + desc.entity = ent(421); + desc.position = av(0, start_y, 0); + const id = try addMover(gpa, &world, &chars, desc); + + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0); + + // The whole metre, at EVERY height including zero. + try testing.expectApproxEqAbs(@as(Real, 1), r.position.toArray()[0], api_tol); + + // **THE GUARD DOES NOT OVER-FIRE, and the resting height is the composition of THREE mechanisms + // whose split this asserts.** Depenetration lifts an exactly-tangent base to `padding`, and it + // reaches nothing else: a capsule already standing off is `.separated`, hence invisible to the + // overlap query. Floor-sticking then pulls a GROUNDED character above `padding` down to + // `padding`, which is pre-existing and not part of this fix. And a base strictly inside + // `(0, padding)` is touched by neither — no contact for the overlap query, and a padded advance + // that clamps to zero for the down-sweep — so it stays exactly where it was. + // + // 0.2 is the third case and it is the NON-VACUITY one: the entry ground probe is bounded by + // `padding + predictive_contact_distance` and does not reach the floor from there, so the + // character enters NOT grounded, floor-sticking is skipped, and it stays at 0.2 reporting + // `.in_air`. MEASURED, and it is what proves the new branch does not touch a character that is + // in contact with nothing — a formula covering only "grounded" cases would have hidden it. + const airborne = start_y > 0.1; + const expected_y: Real = if (airborne) start_y else if (start_y > 0 and start_y < 0.02) start_y else 0.02; + try testing.expectApproxEqAbs(expected_y, r.position.toArray()[1], api_tol); + try testing.expectEqual( + if (airborne) api.GroundState.in_air else api.GroundState.grounded, + r.ground.state, + ); + } +} + +test "the stand-off floor serves a zero padding, and its scale limit is MEASURED" { + const gpa = testing.allocator; + + // `padding = 0` is legal — it means no PHYSICAL margin — and it stays in the domain: removing it + // would mask this defect instead of closing it. But `penetration + 0` at a tangency of `−0.0` + // moves nothing, so the freeze was intact on that path until the target gained a numerical floor. + // + // **The floor is a NUMERICAL TOLERANCE and §1.11.2 governs it**, unlike `padding`, `max_slope` and + // `predictive_contact_distance`, which are named physical parameters it deliberately does not + // reach. Its `k` must EXCEED `contact_margin_conv_k` rather than equal it: the point is to leave + // the contact margin, not to sit on its edge. + for ([_]f32{ 0, 0.005, 0.01, 0.019, 0.02, 0.05 }) |start_y| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 440); + var desc = baseDescriptor(); + desc.entity = ent(441); + desc.position = av(0, start_y, 0); + desc.padding = 0; + const id = try addMover(gpa, &world, &chars, desc); + + // THREE calls, because a mode that works once and stalls on the next is the dangerous one. + var previous: Real = 0; + var k: u32 = 0; + while (k < 3) : (k += 1) { + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0); + try testing.expectApproxEqAbs(previous + 1, r.position.toArray()[0], api_tol); + previous = r.position.toArray()[0]; + } + } + + // A 5 m collider instead of a half-space, so the pair's coordinate scale is not degenerate. + { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + _ = try addBox(gpa, &world, av(5, 5, 5), av(0, -5, 0), 442); + var desc = baseDescriptor(); + desc.entity = ent(443); + desc.position = av(0, 0, 0); + desc.padding = 0; + const id = try addMover(gpa, &world, &chars, desc); + var previous: Real = 0; + var k: u32 = 0; + while (k < 3) : (k += 1) { + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0); + try testing.expectApproxEqAbs(previous + 1, r.position.toArray()[0], api_tol); + previous = r.position.toArray()[0]; + } + } +} + +test "a 1 km collider stalls because slideNormal cannot resolve — a FOURTH mechanism" { + const gpa = testing.allocator; + + // **This stall was expected to fall with the tangency consequence and it did NOT, and the trace says + // why: it is a different mechanism.** `slideNormal` returns NULL at that scale — traced, + // `hit = 0.000000000`, `adv = 0.000000000`, `normal = false` — so the loop takes its documented + // "no usable normal: stop rather than guess a direction" exit, zeroes the remainder and serves + // nothing. It never reaches the non-opposing test, which is why setting a contact aside cannot help. + // + // Serving it would mean inventing a direction the narrowphase declines to supply, which this loop + // refuses by design and which is the right refusal. So the stall stays, now with a precise cause + // instead of the vague large-collider one it carried, and it belongs with the §1.11.4 bis class: + // f64 serves every call on the same scene. + for ([_]f32{ 0.02, std.math.floatMin(f32) }) |pad| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + _ = try addBox(gpa, &world, av(1000, 1000, 1000), av(0, -1000, 0), 450); + var desc = baseDescriptor(); + desc.entity = ent(451); + desc.position = av(0, 0.02, 0); + desc.padding = pad; + const id = try addMover(gpa, &world, &chars, desc); + + const first = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0); + try testing.expectApproxEqAbs(@as(Real, 1), first.position.toArray()[0], api_tol); + + const second = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0); + const served = second.position.toArray()[0] - first.position.toArray()[0]; + try testing.expectApproxEqAbs(if (Real == f32) @as(Real, 0) else @as(Real, 1), served, api_tol); + } + + // The 5 m contrast, which keeps this a SCALE statement and not a box statement — and it carries the + // `padding = floatMin` leg too, the path the `padding > 0` branch opens and which the tangency fix + // had to close: a positive padding whose addition changes no bit. + for ([_]f32{ 0.02, std.math.floatMin(f32) }) |pad| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + _ = try addBox(gpa, &world, av(5, 5, 5), av(0, -5, 0), 460); + var desc = baseDescriptor(); + desc.entity = ent(461); + desc.position = av(0, 0.02, 0); + desc.padding = pad; + const id = try addMover(gpa, &world, &chars, desc); + var previous: Real = 0; + var k: u32 = 0; + while (k < 3) : (k += 1) { + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0); + try testing.expectApproxEqAbs(previous + 1, r.position.toArray()[0], api_tol); + previous = r.position.toArray()[0]; + } + } +} + +test "DOMAIN TABLE — measured behaviour at every legal bound of the descriptor" { + const gpa = testing.allocator; + + // **This table exists because NO gate of this milestone enumerated the legal bounds and asked what + // the code does at each.** Gate F decided which values to REJECT and never asked that question, and + // `padding = 0` and the `padding` upper region both fell into that hole — the first froze the + // character, and it took three rounds and five `paddedAdvance` call sites to close. + // + // Every row is MEASURED against one scene: a ground half-space, a 0.2 m step at `x >= 1.5`, and four + // calls of `(0.5, −0.05, 0)`. The reference row is the default descriptor, which walks and climbs. + const Row = struct { + name: []const u8, + mut: *const fn (*api.CharacterDescriptor) void, + /// Expected served distance on each of the four calls. + served: [4]Real, + /// Expected final base height. + y: Real, + }; + const M = struct { + fn base(_: *api.CharacterDescriptor) void {} + fn padZero(d: *api.CharacterDescriptor) void { + d.padding = 0; + } + fn padHi(d: *api.CharacterDescriptor) void { + d.padding = 0.29999; + } + fn padOverR(d: *api.CharacterDescriptor) void { + d.padding = 0.6; + } + fn pushZero(d: *api.CharacterDescriptor) void { + d.max_push_force = 0; + } + fn stepZero(d: *api.CharacterDescriptor) void { + d.step_height = 0; + } + fn predZero(d: *api.CharacterDescriptor) void { + d.predictive_contact_distance = 0; + } + fn massMin(d: *api.CharacterDescriptor) void { + d.mass = std.math.floatMin(f32); + } + fn slopeZero(d: *api.CharacterDescriptor) void { + d.max_slope = 0; + } + fn slopeMax(d: *api.CharacterDescriptor) void { + d.max_slope = std.math.pi / 2.0; + } + fn layerMax(d: *api.CharacterDescriptor) void { + d.collision_layer = 31; + } + fn sphere(d: *api.CharacterDescriptor) void { + d.height = 0.6; + } + }; + const rows = [_]Row{ + // The reference: walks 0.5 a call and ends standing on the step, `padding` above it. + .{ .name = "defaults", .mut = M.base, .served = .{ 0.5, 0.5, 0.5, 0.5 }, .y = 0.20199 }, + // CLOSED this round. It ends on the step at exactly 0.2 — no physical margin, as asked. + .{ .name = "padding = 0", .mut = M.padZero, .served = .{ 0.5, 0.5, 0.5, 0.5 }, .y = 0.2 }, + // **NOT a defect: the declared stand-off HONOURED.** A character demanding 0.3 m of clearance + // cannot approach the riser closer than 0.3 m and cannot fit onto a 0.2 m step with 0.3 m of + // clearance above it — so it stops short and does not climb. The guard above refuses + // `padding >= radius` on the geometric-incoherence motive, not on this. + .{ .name = "padding = r - eps", .mut = M.padHi, .served = .{ 0.5, 0.5, 0, 0 }, .y = 0.02 }, + // **NO upper bound**, the guard that briefly rejected this having been measured away. + .{ .name = "padding = 2r", .mut = M.padOverR, .served = .{ 0.5, 0.5, 0, 0 }, .y = 0.02 }, + // Pushing disabled changes nothing here — no dynamic body in the scene. + .{ .name = "max_push_force = 0", .mut = M.pushZero, .served = .{ 0.5, 0.5, 0.5, 0.5 }, .y = 0.20199 }, + // No climb and no floor-sticking: blocked by the riser, which is the correct answer. + .{ .name = "step_height = 0", .mut = M.stepZero, .served = .{ 0.5, 0.5, 0.1979, 0 }, .y = 0.00199 }, + // Inert today, and this row is what says so — the field the algorithm has not yet justified. + .{ .name = "predictive = 0", .mut = M.predZero, .served = .{ 0.5, 0.5, 0.5, 0.5 }, .y = 0.20199 }, + // The smallest normal mass: only the push impulse reads it, and there is nothing to push. + .{ .name = "mass = floatMin", .mut = M.massMin, .served = .{ 0.5, 0.5, 0.5, 0.5 }, .y = 0.20199 }, + // `cos 0 = 1` and the floor's normal is exactly `+Y`, so `1 >= 1` holds: a perfectly flat floor + // stays walkable at a zero slope limit. Anything tilted would not be. + .{ .name = "max_slope = 0", .mut = M.slopeZero, .served = .{ 0.5, 0.5, 0.5, 0.5 }, .y = 0.20199 }, + // Everything walkable, including the riser — so the character WALKS UP the vertical face + // instead of climbing it as a step, and ends part-way up. The bound's consequence, not a bug. + .{ .name = "max_slope = pi/2", .mut = M.slopeMax, .served = .{ 0.5, 0.5, 0.1979, 0.0399 }, .y = 0.06572 }, + // The top layer index is as usable as the bottom one — the mask is 32 bits and 31 is in it. + .{ .name = "collision_layer = 31", .mut = M.layerMax, .served = .{ 0.5, 0.5, 0.5, 0.5 }, .y = 0.20199 }, + // `height == 2 · radius` is the limiting relation: a capsule degenerate to a SPHERE, still served. + .{ .name = "height = 2r", .mut = M.sphere, .served = .{ 0.5, 0.5, 0.5, 0.5 }, .y = 0.20199 }, + }; + + for (rows) |row| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 900); + _ = try addBox(gpa, &world, av(0.5, 0.1, 2), av(2, 0.1, 0), 901); + var desc = baseDescriptor(); + desc.entity = ent(902); + desc.position = av(0, 0.02, 0); + row.mut(&desc); + const id = try addMover(gpa, &world, &chars, desc); + + var prev: Real = 0; + for (row.served, 0..) |expected, k| { + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(0.5, -0.05, 0), 1.0 / 60.0); + const served = r.position.toArray()[0] - prev; + prev = r.position.toArray()[0]; + errdefer std.debug.print("row {s}, call {d}\n", .{ row.name, k }); + try testing.expectApproxEqAbs(expected, served, 1e-3); + } + try testing.expectApproxEqAbs(row.y, prev * 0 + chars.get(id).?.position.toArray()[1], 1e-3); + // Every row ends GROUNDED — none of the legal bounds loses the floor. + try testing.expectEqual(api.GroundState.grounded, chars.reportedGround(id).?); + } +} + +test "the stand-off floor never overrides a requested padding, and the stall is COLLIDER-SIZE bound" { + const gpa = testing.allocator; + + // **The floor applies only at `padding == 0`, and an earlier `@max` form let it OVERRIDE a padding + // the caller had asked for.** `64 · floatEps(f32) = 7.6e-6`, so that floor crossed the 0.02 default + // at 2 632 m and reached 3.8 cm at 5 km: a caller asking for 2 cm silently got 3.8, and two identical + // scenes translated apart stopped at different distances. A supported region answering wrongly + // without saying so, which is the one failure direction this milestone refuses. + // + // Now a non-zero `padding` is honoured EXACTLY and is invariant under translation. Measured over five + // decades of distance against a half-space floor: the whole metre, every call, at both precisions. + // **THE ASSERTION IS THE FINAL CLEARANCE, NOT THE ADVANCE, and the first version of this test was + // VACUOUS because it asserted the advance.** Starting at `y = padding` with a purely horizontal + // motion and checking `x` alone, the old `@max(padding, floor)` passes too: nothing there ever + // exercises `standoffTarget`. Confirmed by probe — restoring `@max` broke NOTHING. So the character + // walks into a WALL and the test measures how far short of it it stops. + // + // At 5 km the floor is `64 · floatEps(f32) · 5000.9 = 3.81e-2`, so `@max` would stop the character + // 3.8 cm short of the wall where it asked for 2 — an 1.8 cm gap, three orders above the tolerance. + for ([_]f32{ 0, 1000, 5000 }) |x0| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 930); + // A wall whose −X face stands 3 m ahead of the character's start. + const wall_face: f32 = x0 + 3; + _ = try addBox(gpa, &world, av(1, 2, 2), av(wall_face + 1, 2, 0), 932); + var desc = baseDescriptor(); + desc.entity = ent(931); + desc.position = av(x0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + + // Walk into it, generously, so the stop is the wall and not the request running out. + var k: u32 = 0; + while (k < 4) : (k += 1) { + _ = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(2, 0, 0), 1.0 / 60.0); + } + // The capsule's surface reaches `radius` ahead of its base, so the base rests at + // `wall_face − radius − padding`. `padding` and NOT the floor: that is the whole assertion. + const expected: Real = @as(Real, wall_face) - 0.3 - 0.02; + const got = chars.get(id).?.position.toArray()[0]; + try testing.expectApproxEqAbs(expected, got, @max(api_tol, @abs(expected) * 1e-6)); + } + + // **AND THE STALL REGIME IS NOT A DISTANCE, which is what the measurement corrected.** The prediction + // was that it returns where `padding` falls under the contact margin, at a `coordScale` beyond about + // 10 km — and there is NO stall at 50 km, because `gjk.zig`'s coordScale is RELATIVE, + // `|Δpos| + coreExtent(a) + coreExtent(b)`, which the M1.1.2/P1c symmetry fixed and which does not + // grow with distance from the origin. What grows it is the OTHER BODY'S SIZE. + // + // Measured boundary at f32: a 100 m half-extent collider serves every call, a 500 m one stalls from + // the second. At f64, 2 km still serves — so it is a precision regime and `-Dphysics_f64` is Phase + // 1's answer, exactly as §1.11.4 bis says for its own class. + for ([_]f32{ 100, 500 }) |half| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + _ = try addBox(gpa, &world, av(half, half, half), av(0, -half, 0), 940); + var desc = baseDescriptor(); + desc.entity = ent(941); + desc.position = av(0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + + const first = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0); + try testing.expectApproxEqAbs(@as(Real, 1), first.position.toArray()[0], api_tol); + + const second = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0); + const served = second.position.toArray()[0] - first.position.toArray()[0]; + const stalls = Real == f32 and half >= 500; + try testing.expectApproxEqAbs(if (stalls) @as(Real, 0) else @as(Real, 1), served, api_tol); + } +}