From 7fe6f3cc23142b67f7deb637ef2af33ce6f66d43 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 06:59:34 +0200 Subject: [PATCH 001/100] docs(brief): add M1.1.12 milestone brief --- briefs/M1.1.12-character-controller.md | 207 +++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 briefs/M1.1.12-character-controller.md diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md new file mode 100644 index 00000000..6b7ef630 --- /dev/null +++ b/briefs/M1.1.12-character-controller.md @@ -0,0 +1,207 @@ +# M1.1.12 — Forge 3D: the kinematic character controller + +> **Status:** PLANNED +> **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.* + +- [ ] `engine-physics-forge.md` +- [ ] `engine-tier-interfaces.md` +- [ ] `engine-phase-1-criteria.md` +- [ ] `engine-phase-1-plan.md` +- [ ] `engine-movement.md` +- [ ] `engine-zig-conventions.md` +- [ ] `engine-coordinate-system.md` + +## 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. + +**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 + +*(none yet)* + +## Blockers encountered + +*(none yet)* + +## Execution log + +*(none yet)* + +## Closing notes + +*(none yet)* From eca0e4cfc70e9c468a623cc953e89be7f493e642 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 07:00:11 +0200 Subject: [PATCH 002/100] docs(brief): mark M1.1.12 specs read and open execution --- briefs/M1.1.12-character-controller.md | 37 +++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 6b7ef630..e4d8f5a9 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -1,6 +1,6 @@ # M1.1.12 — Forge 3D: the kinematic character controller -> **Status:** PLANNED +> **Status:** ACTIVE > **Phase:** 1 > **Branch:** `phase-1/forge/character-controller` > **Planned tag:** `v0.11.12-character-controller` @@ -89,13 +89,15 @@ Fifth, **verdict and direction are separate questions, and the verdict is ternar *Checked by Claude Code at Étape 2, with a real `YYYY-MM-DD HH:MM` timestamp each.* -- [ ] `engine-physics-forge.md` -- [ ] `engine-tier-interfaces.md` -- [ ] `engine-phase-1-criteria.md` -- [ ] `engine-phase-1-plan.md` -- [ ] `engine-movement.md` -- [ ] `engine-zig-conventions.md` -- [ ] `engine-coordinate-system.md` +- [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 @@ -200,7 +202,24 @@ Seven gates, push-early, STOP/GO on real pushed diffs. One fenced block per verd ## Execution log -*(none yet)* +### É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. ## Closing notes From 8f3976168a86267c3daa8fe6f4a64e00c64d2ad5 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 07:15:51 +0200 Subject: [PATCH 003/100] docs(forge): sweep the stale two-term constraint-key prose --- .../forge/forge_3d/rigid/island_manager.zig | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/modules/forge/forge_3d/rigid/island_manager.zig b/src/modules/forge/forge_3d/rigid/island_manager.zig index 5b02599f..e8835cc6 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, From bc1b5c2e7f488015c05ef98720c71f3d5f2f3975 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 07:16:00 +0200 Subject: [PATCH 004/100] feat(forge): freeze the character-controller api surface --- src/modules/forge/api/root.zig | 17 +- src/modules/forge/api/types.zig | 382 +++++++++++++++++++++++++++++++- 2 files changed, 395 insertions(+), 4 deletions(-) diff --git a/src/modules/forge/api/root.zig b/src/modules/forge/api/root.zig index da02b477..773a0c2e 100644 --- a/src/modules/forge/api/root.zig +++ b/src/modules/forge/api/root.zig @@ -31,7 +31,10 @@ 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`. pub const PackedId = types.PackedId; /// Simulation class of a body (static / kinematic / dynamic). pub const BodyType = types.BodyType; @@ -44,6 +47,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 bf1f9110..66bf2e52 100644 --- a/src/modules/forge/api/types.zig +++ b/src/modules/forge/api/types.zig @@ -31,9 +31,21 @@ 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, @@ -248,6 +260,237 @@ 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. +// +// - `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. + 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. + 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, which + /// `engine-phase-1-criteria.md` C1.8 refuses twice: the player's attack deals its + /// damage through raycast/overlap, and the follow camera carries an anti-wall sweep + /// that starts from the player and must therefore exclude it. + /// + /// 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 other four are only readable through +/// it.** `ground_body`'s default of `0` is NOT a sentinel — `PackedId.pack(0, 0)` is `0`, +/// a perfectly valid handle to slot 0 generation 0 — so there is no value of that field +/// meaning "no support". On `.in_air` the caller must not read it, exactly as §1.12.5's +/// table states; `ground_normal` is the one field with 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. See the type doc on why `0` is a default and not a sentinel. + ground_body: BodyId = 0, + + /// 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 @@ -598,3 +841,136 @@ 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()); + + try testing.expectEqual(@as(BodyId, 0), r.ground_body); + 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)); +} From f6779577ed534d985af50e9bab15e52f9fdd79de Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 07:16:08 +0200 Subject: [PATCH 005/100] docs(brief): record M1.1.12 gate A execution --- briefs/M1.1.12-character-controller.md | 155 +++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index e4d8f5a9..a45e334d 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -221,6 +221,161 @@ Seven documents read in the order of § Specs to read first, in full, with `engi §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, recorded rather than silently absorbed.** Neither changes +it; both are now documented on it. + +1. `CharacterMoveResult.ground_body`'s default of `0` is **not a sentinel**. `PackedId.pack(0, 0)` + is `0`, a perfectly valid handle to slot 0 generation 0, so no value of that field means "no + support". What discriminates is `ground_state`, exactly as §1.12.5's table says — the type doc now + states this, so a caller reading `ground_body` on `.in_air` is contradicting a written contract + instead of falling into an unmarked hole. +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 here; what the + generation buys is stale-slot detection, never handle confusion. Pinned by + `expectEqual(BodyId, CharacterId)` so the statement is asserted rather than asserted-about. + +**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. The `island_manager.zig` diff is comment-only, verified mechanically by filtering the diff down +to its non-comment lines, which are none. + ## Closing notes *(none yet)* From 71fb7a1960496ee336f3973602635ff3c94f8745 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 07:33:48 +0200 Subject: [PATCH 006/100] fix(forge): reserve PackedId.dead as the no-handle sentinel --- src/modules/forge/api/root.zig | 3 +- src/modules/forge/api/types.zig | 62 +++++++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/modules/forge/api/root.zig b/src/modules/forge/api/root.zig index 773a0c2e..0dcdd512 100644 --- a/src/modules/forge/api/root.zig +++ b/src/modules/forge/api/root.zig @@ -34,7 +34,8 @@ pub const ShapeId = types.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`. +/// `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; diff --git a/src/modules/forge/api/types.zig b/src/modules/forge/api/types.zig index 66bf2e52..11d58d92 100644 --- a/src/modules/forge/api/types.zig +++ b/src/modules/forge/api/types.zig @@ -52,6 +52,12 @@ pub const PackedId = packed struct(u32) { /// 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 }); @@ -268,6 +274,10 @@ pub const BodyDescriptor = struct { // 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 @@ -445,12 +455,12 @@ pub const GroundState = enum(u8) { /// 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 other four are only readable through -/// it.** `ground_body`'s default of `0` is NOT a sentinel — `PackedId.pack(0, 0)` is `0`, -/// a perfectly valid handle to slot 0 generation 0 — so there is no value of that field -/// meaning "no support". On `.in_air` the caller must not read it, exactly as §1.12.5's -/// table states; `ground_normal` is the one field with a meaningful value in every state, -/// and that is deliberate (see its own doc). +/// **`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. @@ -478,9 +488,17 @@ pub const CharacterMoveResult = struct { /// 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. See the type doc on why `0` is a default and not a sentinel. - ground_body: BodyId = 0, + /// 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 a plausible wrong answer + /// rather than a crash: `PackedId.pack(0, 0)` is `0`, a valid handle to slot 0 + /// generation 0, and in the arena the first body created is typically the ground. A + /// caller reading this field while airborne would therefore be told it is standing on + /// the ground — the false-negative class this module refuses, and one that no test on + /// a grounded character would ever surface. + 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 @@ -722,6 +740,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); @@ -954,7 +989,14 @@ test "CharacterMoveResult mirrors engine-tier-interfaces.md §1 field for field" try testing.expect(r.ground_normal.eql(Vec3.unit_y)); try testing.expectEqual(@as(f32, 1), r.ground_normal.lengthSq()); - try testing.expectEqual(@as(BodyId, 0), r.ground_body); + // 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, + // typically the arena's ground, so a caller reading it while airborne was told it stood + // on the ground. `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. From 9330b5edb5e7fafadf53503236f234cbbcf2d94d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 07:33:57 +0200 Subject: [PATCH 007/100] docs(brief): record the ground_body sentinel deviation --- briefs/M1.1.12-character-controller.md | 129 ++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 13 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index a45e334d..82edfe08 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -182,6 +182,14 @@ Seven gates, push-early, STOP/GO on real pushed diffs. One fenced block per verd **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. @@ -194,7 +202,45 @@ Seven gates, push-early, STOP/GO on real pushed diffs. One fenced block per verd ## Recorded deviations -*(none yet)* +### 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 worse than the observation: **in the arena the first body +created is typically the ground**, so a caller reading `ground_body` on `.in_air` was told it was +standing on the ground. A plausible wrong answer rather than a crash, which is the false-negative +class this module exists to refuse, and one no test on a grounded character could surface. + +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. ## Blockers encountered @@ -266,19 +312,20 @@ onward reach these types as `api.CharacterDescriptor`. The same edit also had to `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, recorded rather than silently absorbed.** Neither changes -it; both are now documented on it. +**Two findings about the frozen surface. One of them changed it.** -1. `CharacterMoveResult.ground_body`'s default of `0` is **not a sentinel**. `PackedId.pack(0, 0)` - is `0`, a perfectly valid handle to slot 0 generation 0, so no value of that field means "no - support". What discriminates is `ground_state`, exactly as §1.12.5's table says — the type doc now - states this, so a caller reading `ground_body` on `.in_air` is contradicting a written contract - instead of falling into an unmarked hole. +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 made it urgent is Claude.ai's and is + stronger than the observation it started from — in the arena the first body created is typically + the ground, so the old default answered "standing on the ground" to a caller in mid-air. 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 here; what the - generation buys is stale-slot detection, never handle confusion. Pinned by - `expectEqual(BodyId, CharacterId)` so the statement is asserted rather than asserted-about. + 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 @@ -373,8 +420,64 @@ 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. The `island_manager.zig` diff is comment-only, verified mechanically by filtering the diff down -to its non-comment lines, which are none. +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. ## Closing notes From 7ecebb70347b1ad274a9525e8470750396d39a20 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 07:46:03 +0200 Subject: [PATCH 008/100] docs(forge): replace two justifications derived from outside the engine --- src/modules/forge/api/types.zig | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/modules/forge/api/types.zig b/src/modules/forge/api/types.zig index 11d58d92..9160aac9 100644 --- a/src/modules/forge/api/types.zig +++ b/src/modules/forge/api/types.zig @@ -409,10 +409,13 @@ pub const CharacterDescriptor = struct { 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, which - /// `engine-phase-1-criteria.md` C1.8 refuses twice: the player's attack deals its - /// damage through raycast/overlap, and the follow camera carries an anti-wall sweep - /// that starts from the player and must therefore exclude it. + /// 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 @@ -492,12 +495,13 @@ pub const CharacterMoveResult = struct { /// 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 a plausible wrong answer - /// rather than a crash: `PackedId.pack(0, 0)` is `0`, a valid handle to slot 0 - /// generation 0, and in the arena the first body created is typically the ground. A - /// caller reading this field while airborne would therefore be told it is standing on - /// the ground — the false-negative class this module refuses, and one that no test on - /// a grounded character would ever surface. + /// 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 @@ -992,9 +996,10 @@ test "CharacterMoveResult mirrors engine-tier-interfaces.md §1 field for field" // 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, - // typically the arena's ground, so a caller reading it while airborne was told it stood - // on the ground. `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. + // 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)); From dd8ff4a8ebc07dff45e3f2d828b121acf3c29c35 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 07:46:12 +0200 Subject: [PATCH 009/100] docs(brief): record the inner-body justification deviation --- briefs/M1.1.12-character-controller.md | 86 +++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 82edfe08..c7a902a2 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -209,11 +209,17 @@ reason to keep counter-factuals late in a gate rather than mid-writing. 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 worse than the observation: **in the arena the first body -created is typically the ground**, so a caller reading `ground_body` on `.in_air` was told it was -standing on the ground. A plausible wrong answer rather than a crash, which is the false-negative -class this module exists to refuse, and one no test on a grounded character could surface. +`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: @@ -242,6 +248,40 @@ Authorised explicitly in the same gate-A round-trip that carries RD-1: the `git 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)* @@ -317,9 +357,9 @@ gate's own change, and deleted-and-replaced rather than left beside its correcti 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 made it urgent is Claude.ai's and is - stronger than the observation it started from — in the arena the first body created is typically - the ground, so the old default answered "standing on the ground" to a caller in mid-air. + 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 @@ -479,6 +519,36 @@ new `PackedId.dead` test — and the suite total moves 1607 → 1608 accordingly 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. + ## Closing notes *(none yet)* From 29644f97f640794bdbcfa39a439b0635074c86a6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 08:27:41 +0200 Subject: [PATCH 010/100] feat(forge): add collideShapeBody, the shape-against-body adapter --- src/modules/forge/forge_3d/body_manager.zig | 120 ++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index e095065e..8b65b141 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -313,6 +313,30 @@ 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]; + } + /// Safe getter: the ECS entity owning this body, or null if `id` is /// stale/invalid. /// @@ -1102,6 +1126,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` From 44579a1a339a81d70e6d9721343ce3ef38b7dbee Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 08:27:51 +0200 Subject: [PATCH 011/100] feat(forge): add the character controller store --- src/modules/forge/forge_3d/character.zig | 346 +++++++++++++++++++++++ src/modules/forge/forge_3d/root.zig | 22 ++ 2 files changed, 368 insertions(+) create mode 100644 src/modules/forge/forge_3d/character.zig diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig new file mode 100644 index 00000000..5f8cd641 --- /dev/null +++ b/src/modules/forge/forge_3d/character.zig @@ -0,0 +1,346 @@ +//! `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 IdAllocator = @import("slot_alloc.zig").IdAllocator; +const math = @import("foundation").math; + +const Real = config.Real; +const Vec3r = config.Vec3r; +const ShapeStore = shape_mod.ShapeStore; +const BodyManager = body_manager_mod.BodyManager; +const BodyId = api.BodyId; +const ShapeId = api.ShapeId; +const CharacterId = api.CharacterId; +const CharacterDescriptor = api.CharacterDescriptor; +const EntityId = api.EntityId; + +/// 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{ + /// `radius` or `height` is non-finite or not strictly positive, or `height` is less than + /// twice `radius`. + InvalidDimensions, + /// `max_slope` is non-finite or outside `[0, π/2]`. + InvalidSlope, + /// `padding` is non-finite. + InvalidPadding, + /// `mass` or `max_push_force` is non-finite. + 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`. THE ONE FIELD THE ALGORITHM HAS NOT YET JUSTIFIED — + /// gate D consumes it or deletes it. + 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 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. +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. +/// +/// `predictive_contact_distance` is deliberately NOT validated here. It is the one field of +/// this descriptor no code consumes yet, and gate D either consumes it — and then owns its +/// domain, which it will know — or deletes it, in which case a check written now would be a +/// check to delete. +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; + if (!std.math.isFinite(desc.mass)) return error.InvalidPushParameters; + if (!std.math.isFinite(desc.max_push_force)) return error.InvalidPushParameters; + + // 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; +} + +/// 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 — 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 three of its resources. 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, + store: *ShapeStore, + bm: *BodyManager, + id: CharacterId, + ) void { + const idx = self.alloc.validate(id) orelse return; + const record = self.characters.items[idx]; + 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]; + } +}; + +/// 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/root.zig b/src/modules/forge/forge_3d/root.zig index 349607a6..8c282b2f 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,23 @@ 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 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 +284,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 +302,5 @@ comptime { _ = @import("tests/overlap_test.zig"); _ = @import("tests/plane_test.zig"); _ = @import("tests/mesh_test.zig"); + _ = @import("tests/character_test.zig"); } From 1d886bed0b3ab903f21d5dd206f6ad57a6a069e0 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 08:27:53 +0200 Subject: [PATCH 012/100] test(forge): add the character controller acceptance suite --- .../forge/forge_3d/tests/character_test.zig | 800 ++++++++++++++++++ 1 file changed, 800 insertions(+) create mode 100644 src/modules/forge/forge_3d/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 00000000..85e9c84f --- /dev/null +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -0,0 +1,800 @@ +//! 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 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 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 three 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); + + 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, &store, &bm, id); + + // All three counts back to zero: the character, its capsule and its presence. + 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, &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); + + const first = try chars.createCharacter(gpa, &store, &bm, baseDescriptor()); + chars.destroyCharacter(gpa, &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); + + // 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, &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); + + 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, mass, max_push_force: non-finite. + .{ .expected = error.InvalidPadding, .mutate = struct { + fn f(d: *api.CharacterDescriptor) void { + d.padding = nan; + } + }.f }, + .{ .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.max_push_force = nan; + } + }.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 merely a + // blanket refusal: `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 a + // capsule whose height is exactly twice its radius is a sphere and is legal. + { + var ok = baseDescriptor(); + ok.max_slope = std.math.pi / 2.0; + ok.max_push_force = 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, &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); +} From 54ae5d7082bc5ce6033431781ee70dde3defafd6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 08:28:02 +0200 Subject: [PATCH 013/100] docs(brief): record M1.1.12 gate B execution --- briefs/M1.1.12-character-controller.md | 168 +++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index c7a902a2..d4dd93b8 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -549,6 +549,174 @@ passed every test, and survived review by resembling arguments — the class thi 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 | + +`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)* From 4d03223d3ad47fa154a3ea98d5099fa6edf9aa0a Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 09:46:04 +0200 Subject: [PATCH 014/100] fix(forge): complete the character descriptor domain guards --- src/modules/forge/forge_3d/character.zig | 41 ++++++++++++++++++------ 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 5f8cd641..2832e79b 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -53,14 +53,16 @@ const EntityId = api.EntityId; /// fixing a bug wants to know which field, and a single `error.InvalidDescriptor` would /// send it reading all of them. pub const CharacterError = error{ - /// `radius` or `height` is non-finite or not strictly positive, or `height` is less than - /// twice `radius`. + /// 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. + /// `padding` is non-finite or negative. InvalidPadding, - /// `mass` or `max_push_force` is non-finite. + /// `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, @@ -102,8 +104,8 @@ pub const Character = struct { /// §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`. THE ONE FIELD THE ALGORITHM HAS NOT YET JUSTIFIED — - /// gate D consumes it or deletes it. + /// 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). @@ -156,10 +158,10 @@ fn capsuleHalfHeight(comptime T: type, radius: T, height: T) T { /// rather than normative — unlike `addBody`'s, whose ordering is load-bearing because the /// body literal derives quantities from the local AABB. /// -/// `predictive_contact_distance` is deliberately NOT validated here. It is the one field of -/// this descriptor no code consumes yet, and gate D either consumes it — and then owns its -/// domain, which it will know — or deletes it, in which case a check written now would be a -/// check to delete. +/// **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; @@ -173,8 +175,27 @@ fn validateDescriptor(desc: CharacterDescriptor) CharacterError!void { 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; + 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; + + // 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 From 8f25d1e9a28d0ef15da9c1b8fa22c980b8899b4c Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 09:46:17 +0200 Subject: [PATCH 015/100] feat(forge): determine the ground by a bounded downward sweep --- src/modules/forge/forge_3d/character.zig | 276 +++++++++++++++++++++++ src/modules/forge/forge_3d/root.zig | 7 + 2 files changed, 283 insertions(+) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 2832e79b..2c2f98d9 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -28,18 +28,33 @@ 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. @@ -203,6 +218,189 @@ fn validateDescriptor(desc: CharacterDescriptor) CharacterError!void { 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; + } + self.ground.consider(self.body, subshape_id, manifold.normal.neg(), deepest.position); + } +}; + +/// 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; +} + /// Generational store of character controllers. /// /// The `ShapeStore` pattern verbatim: stable slots, LIFO recycling, a generation on the @@ -355,8 +553,86 @@ pub const CharacterStore = struct { const idx = self.alloc.validate(id) orelse return null; return self.characters.items[idx]; } + + /// 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. diff --git a/src/modules/forge/forge_3d/root.zig b/src/modules/forge/forge_3d/root.zig index 8c282b2f..8d5a1fa6 100644 --- a/src/modules/forge/forge_3d/root.zig +++ b/src/modules/forge/forge_3d/root.zig @@ -234,6 +234,13 @@ 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; /// 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. From 7df11742ff5f55227887dfe8d50975c0eae42fe0 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 09:46:18 +0200 Subject: [PATCH 016/100] test(forge): extend the character suite to ground determination --- .../forge/forge_3d/tests/character_test.zig | 442 +++++++++++++++++- 1 file changed, 437 insertions(+), 5 deletions(-) diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 85e9c84f..073441f5 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -526,22 +526,58 @@ test "the descriptor domain is refused by typed error and never sanitised" { d.max_slope = std.math.pi; } }.f }, - // padding, mass, max_push_force: non-finite. + // 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 { @@ -568,14 +604,20 @@ test "the descriptor domain is refused by typed error and never sanitised" { try testing.expectEqual(@as(u32, 0), bm.count()); } - // The legal boundaries of the same fields, so the rejections above are not merely a - // blanket refusal: `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 a - // capsule whose height is exactly twice its radius is a sphere and is legal. + // 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); @@ -798,3 +840,393 @@ test "baseToCentre is the one offset, and it agrees at both precisions" { 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.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); + } +} From 5c3566a0b2cd459c8ac5c6d00e2ce511751a1d19 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 09:46:29 +0200 Subject: [PATCH 017/100] docs(brief): record M1.1.12 gate C execution --- briefs/M1.1.12-character-controller.md | 148 +++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index d4dd93b8..1eb23103 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -710,6 +710,21 @@ code, I would have signalled a green gate over a red `f64` leg. | `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 @@ -720,3 +735,136 @@ 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`. From 36aab2a4eeabb0ce94961789560ae0b48f64a347 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 10:20:00 +0200 Subject: [PATCH 018/100] feat(math): re-export the exact triangle cross direction --- src/foundation/math/math.zig | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/foundation/math/math.zig b/src/foundation/math/math.zig index 293b2015..7506b7f1 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; From 5ed327075e6597b45afd214ee4705dbc0c2e0d59 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 10:20:13 +0200 Subject: [PATCH 019/100] feat(forge): move a character by sweep, slide and depenetration --- src/modules/forge/forge_3d/character.zig | 486 +++++++++++++++++++++++ src/modules/forge/forge_3d/root.zig | 8 + 2 files changed, 494 insertions(+) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 2c2f98d9..61b01b57 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -138,6 +138,15 @@ pub const Character = struct { /// 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 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 @@ -401,6 +410,294 @@ 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; + +/// 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, at most one per iteration of each bounded phase. The bound is +/// EXACT rather than a guess: no phase can contact more bodies than it has iterations. +const max_touched = max_slide_iterations + max_depenetration_iterations; + +/// 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]; + } +}; + +/// 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, +}; + +/// 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, + 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; + } + 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, + }; + } +}; + +/// 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) Vec3r { + const into = motion.dot(normal); + if (into >= 0) return motion; // already leaving the surface + return motion.sub(normal.scale(into)); +} + +/// 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) ?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. + const len_sq = edge.lengthSq(); + if (len_sq == 0) return null; + var axis = edge.scale(1 / @sqrt(len_sq)); + // 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(); + return axis.scale(@abs(along)); +} + +/// 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. +fn depenetrate( + bp: *const Broadphase, + bm: *const BodyManager, + store: *const ShapeStore, + record: shape_mod.Shape, + probe: SupportShape, + start: Vec3r, + layer_mask: u32, + exclude: ?BodyId, + 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); + // Out along the outward normal by exactly the overlap, so the surfaces end up touching. + // The `padding` stand-off is the SWEEP's business, not this one's — maintained here too it + // would be two mechanisms holding one distance. + centre = centre.add(c.normal.scale(c.penetration)); + } + return centre; +} + /// Generational store of character controllers. /// /// The `ShapeStore` pattern verbatim: stable slots, LIFO recycling, a generation on the @@ -554,6 +851,195 @@ pub const CharacterStore = struct { 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 { + // `dt` reaches no term of this gate: the derived ones are the support velocity, which the + // ground probe reads from the support's own columns, and the push impulse of gate F. + _ = dt; + 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{}; + + // 1 — depenetration. + var centre = depenetrate( + bp, + bm, + store, + record, + probe, + c.position.add(baseToCentre(Real, c.height)), + c.layer_mask, + c.inner_body, + &touched, + ); + + // 2 — sweep and slide. + var remaining = displacement; + var planes: [2]Vec3r = @splat(Vec3r.zero); + var plane_count: u32 = 0; + var iteration: u32 = 0; + while (iteration < max_slide_iterations) : (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); + + var sweep = SweepCollector{ + .bm = bm, + .store = store, + .probe = probe, + .origin = centre, + .direction = direction, + .bound = distance, + .layer_mask = c.layer_mask, + .exclude = c.inner_body, + }; + const box = body_manager_mod.worldAabb(record, centre, Quatr.identity); + _ = bp.queryCast(Ray.init(box.center(), direction), box.halfExtents(), &sweep); + + const hit = sweep.best 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 `padding` SHORT of the surface, clamped at zero so a contact already + // inside the margin does not push the character backwards. + const advance = @max(0, hit.distance - c.padding); + 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; + }; + + // 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]); + } else { + remaining = slideAlongCrease(remaining, planes[0], planes[1]) 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 — 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)); + self.characters.items[idx].position = new_base; + try self.syncPresence(gpa, bp, bm, store, idx); + + // 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); + + return .{ + .position = new_base, + .ground = try self.groundOf(bp, bm, store, id), + }; + } + + /// Mirror the authoritative record pose onto the presence — the body AND its broadphase proxy. + /// + /// **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. + fn syncPresence( + self: *const CharacterStore, + gpa: std.mem.Allocator, + bp: *Broadphase, + bm: *BodyManager, + store: *const ShapeStore, + idx: u24, + ) !void { + const c = self.characters.items[idx]; + const body = c.inner_body orelse return; + // 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, c.position.add(baseToCentre(Real, c.height))); + if (c.presence_proxy) |proxy| { + try bp.update(gpa, proxy, bm.bodyAabb(store, body).?); + } + } + /// 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. diff --git a/src/modules/forge/forge_3d/root.zig b/src/modules/forge/forge_3d/root.zig index 8d5a1fa6..2d614c1f 100644 --- a/src/modules/forge/forge_3d/root.zig +++ b/src/modules/forge/forge_3d/root.zig @@ -241,6 +241,14 @@ 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 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. From 0e869bb8df41d369cb61f380d92c1c62943e26da Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 10:20:14 +0200 Subject: [PATCH 020/100] test(forge): extend the character suite to the move --- .../forge/forge_3d/tests/character_test.zig | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 073441f5..bb8b2061 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -20,6 +20,7 @@ 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; @@ -1230,3 +1231,374 @@ test "the sweep band is padding plus predictive_contact_distance" { 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 2 cm along the slope normal, so + // Δx = 0.02 · 0.5 = 0.01 + // Δy = 0.02 · 0.866 = 0.01732 + try testing.expectApproxEqAbs(@as(Real, 0.01), r.position.toArray()[0], api_tol); + try testing.expectApproxEqAbs(@as(Real, 0.017320508), 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.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), + ); +} From 8334c671545cb46ffdccf429227995080eb70bbc Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 10:20:15 +0200 Subject: [PATCH 021/100] docs(brief): record M1.1.12 gate D execution --- briefs/M1.1.12-character-controller.md | 151 +++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 1eb23103..01921492 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -868,3 +868,154 @@ new assertion on a descriptor-origin quantity was written against `api_tol` from **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). From db536837746cdca990eaeba1fe7f4df2efa7273d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 11:42:26 +0200 Subject: [PATCH 022/100] feat(forge): climb steps and stick to the floor on descent --- src/modules/forge/forge_3d/character.zig | 248 ++++++++++++++++++++--- 1 file changed, 224 insertions(+), 24 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 61b01b57..b35d3372 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -427,9 +427,11 @@ pub const max_slide_iterations: u32 = 4; /// pushed by an unbounded loop is a hang. pub const max_depenetration_iterations: u32 = 4; -/// Bodies whose wake this call owes, at most one per iteration of each bounded phase. The bound is -/// EXACT rather than a guess: no phase can contact more bodies than it has iterations. -const max_touched = max_slide_iterations + max_depenetration_iterations; +/// 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. /// @@ -647,11 +649,18 @@ fn slideAlongPlane(motion: Vec3r, normal: Vec3r) Vec3r { /// read as a valid crease. fn slideAlongCrease(motion: Vec3r, n0: Vec3r, n1: Vec3r) ?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. - const len_sq = edge.lengthSq(); - if (len_sq == 0) return null; - var axis = edge.scale(1 / @sqrt(len_sq)); + // 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); @@ -659,6 +668,179 @@ fn slideAlongCrease(motion: Vec3r, n0: Vec3r, n1: Vec3r) ?Vec3r { return axis.scale(@abs(along)); } +/// 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, +) ?SweepHit { + var collector = SweepCollector{ + .bm = bm, + .store = store, + .probe = probe, + .origin = origin, + .direction = direction, + .bound = distance, + .layer_mask = layer_mask, + .exclude = exclude, + }; + 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); + if (up_hit) |h| touched.add(h.body); + const lift = paddedAdvance(up_hit, c.step_height, c.padding); + 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); + if (fwd_hit) |h| touched.add(h.body); + const forward_advance = paddedAdvance(fwd_hit, remaining_distance, c.padding); + 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) orelse return null; + touched.add(down_hit.body); + const drop = paddedAdvance(down_hit, lift + c.step_height, c.padding); + 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) 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); + return centre.sub(up.scale(paddedAdvance(hit, c.step_height, c.padding))); +} + /// 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 @@ -906,6 +1088,11 @@ pub const CharacterStore = struct { var touched = TouchedBodies{}; + // 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, @@ -923,6 +1110,7 @@ pub const CharacterStore = struct { var remaining = displacement; var planes: [2]Vec3r = @splat(Vec3r.zero); var plane_count: u32 = 0; + var step_attempted = false; var iteration: u32 = 0; while (iteration < max_slide_iterations) : (iteration += 1) { const len_sq = remaining.lengthSq(); @@ -932,20 +1120,8 @@ pub const CharacterStore = struct { const distance = @sqrt(len_sq); const direction = remaining.scale(1 / distance); - var sweep = SweepCollector{ - .bm = bm, - .store = store, - .probe = probe, - .origin = centre, - .direction = direction, - .bound = distance, - .layer_mask = c.layer_mask, - .exclude = c.inner_body, - }; - const box = body_manager_mod.worldAabb(record, centre, Quatr.identity); - _ = bp.queryCast(Ray.init(box.center(), direction), box.halfExtents(), &sweep); - - const hit = sweep.best orelse { + const maybe_hit = sweepNearest(bp, bm, store, record, probe, centre, direction, distance, c.layer_mask, c.inner_body); + const hit = maybe_hit orelse { // Nothing in the way: the whole remaining displacement is served. centre = centre.add(remaining); remaining = Vec3r.zero; @@ -955,7 +1131,7 @@ pub const CharacterStore = struct { // Advance to `padding` SHORT of the surface, clamped at zero so a contact already // inside the margin does not push the character backwards. - const advance = @max(0, hit.distance - c.padding); + const advance = paddedAdvance(hit, distance, c.padding); centre = centre.add(direction.scale(advance)); remaining = remaining.sub(direction.scale(advance)); @@ -965,6 +1141,24 @@ pub const CharacterStore = struct { break; }; + // **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) { @@ -998,7 +1192,13 @@ pub const CharacterStore = struct { } } - // 3 — publish. The record is AUTHORITATIVE and the presence mirrors it; the two are written + // 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)); self.characters.items[idx].position = new_base; From 024d5ae4d988a6e10cefcbc6468a9e0a00bc2156 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 11:42:41 +0200 Subject: [PATCH 023/100] test(forge): extend the character suite to step height --- .../forge/forge_3d/tests/character_test.zig | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index bb8b2061..1d80c370 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -1602,3 +1602,297 @@ test "moveCharacter reports a stale handle as a typed error" { 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 "a slope steeper than max_slope is never reported grounded, and the CLIMB is not what lifts the character there" { + const gpa = testing.allocator; + + // A 50° ramp — just past the default 45° limit — built as a BOX rotated about +Z, so its top + // face normal is `(−sin50, cos50, 0) = (−0.766, 0.643, 0)`. Fifty and not seventy because a face + // rises `tan(θ)` per metre of forward reach while a lift buys only `step_height / tan(θ)` of it: + // at 70° the face climbs 0.75 m over the 0.11 m afforded and nothing can land on it. + 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, 230); + const theta: f32 = 50.0 * std.math.pi / 180.0; + const ramp = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av(2, 0.5, 2) } }); + _ = try world.addBody(gpa, .{ + .entity = ent(231), + .body_type = .static, + .shape = ramp, + // Top face through (0.5, 0, 0), just ahead of the character: `centre = p − 0.5 · n`. + .position = av(0.883, -0.3215, 0), + .rotation = math.Quatf.fromAxisAngle(av(0, 0, 1), theta), + }); + + 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); + + // THE VERDICT is enforced: 50° is past the limit, so the character is on steep ground and never + // `.grounded`, and the normal reported is the ramp's own `cos50 = 0.6428`. + try testing.expectEqual(api.GroundState.on_steep_ground, r.ground.state); + // `cos(50°) = 0.6427876097`, to more digits than `api_tol` needs — a four-decimal rounding of it + // is 1.2e-5 away and fails, which is the tolerance doing its job. + try testing.expectApproxEqAbs(@as(Real, 0.6427876), r.ground.normal.toArray()[1], api_tol); + + // **AND THE HEIGHT GAIN IS THE SLIDE'S, NOT THE CLIMB'S — measured, and pinned here because the + // next milestone will meet it.** Projecting a horizontal displacement onto a 50° plane leaves an + // upward component, so a character pushed into the ramp rises whether or not any step was + // accepted: 0.583 m in one call. Running the same scene with `max_slope` at 80°, which makes the + // ramp walkable and suppresses the step attempt entirely, gives 0.579 m — the same height by a + // different route. So the step's landing slope test cannot be what governs this scene, and an + // absolute assertion on the height would measure the slide while appearing to measure the climb. + // + // Whether SLIDING should itself be slope-constrained is a real question and it is not this + // gate's: the verdict is correct, the position climbs, and nothing in §1.12 settles it. + try testing.expectApproxEqAbs(@as(Real, 0.583), r.position.toArray()[1], 0.01); +} + +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. +} From ed604ffbf699f867c842f580afe4f81851744cdc Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 11:42:42 +0200 Subject: [PATCH 024/100] docs(brief): record M1.1.12 gate E execution --- briefs/M1.1.12-character-controller.md | 131 +++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 01921492..146338d1 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -1019,3 +1019,134 @@ 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). From e1c60f2ebbfb0621ef0e8b6748e3a5f513f13ce5 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 12:35:02 +0200 Subject: [PATCH 025/100] feat(forge): slope-cap the slide, and resize, push and teleport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slide's projection is capped on `up` at its pre-projection component whenever the contact plane fails the slope test (§1.12.6), on the plane branch AND on the crease branch. Without it 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 crease needed the same cap, measured at 0.0186 m per call through a rotated box offering two normals, and the probe table shows the two branches are not redundant. `resizeCharacter` is atomic and feet-anchored: build, test the target volume with the character's own presence excluded, then commit. A refusal changes nothing. The presence's shape is swapped before the proxy sync so the broadphase box reflects the new size, not only the new centre. `pushBody` is unilateral and force-limited; `setCharacterPosition` teleports without resolving and invalidates the reported verdict to `.in_air`. Its stale-handle answer is a typed error like every other entry of the store: a destroy is idempotent, so ignoring a dead handle is its natural answer, but a teleport that goes nowhere is a write the caller has to know did not happen. --- src/modules/forge/forge_3d/body_manager.zig | 24 ++ src/modules/forge/forge_3d/character.zig | 254 +++++++++++++++++++- src/modules/forge/forge_3d/root.zig | 4 + 3 files changed, 269 insertions(+), 13 deletions(-) diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 8b65b141..620d6ad3 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -337,6 +337,30 @@ pub const BodyManager = struct { 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`. + /// + /// 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; + 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. /// diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index b35d3372..0bd67f37 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -138,6 +138,15 @@ pub const Character = struct { /// 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`. /// @@ -171,7 +180,7 @@ pub fn baseToCentre(comptime T: type, height: T) math.Vec(3, T) { /// /// Its non-negativity is why `height >= 2 · radius` is a domain condition and not a /// suggestion — a smaller height describes no capsule at all. -fn capsuleHalfHeight(comptime T: type, radius: T, height: T) T { +pub fn capsuleHalfHeight(comptime T: type, radius: T, height: T) T { return height * 0.5 - radius; } @@ -633,10 +642,26 @@ fn slideNormal( /// 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) Vec3r { +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 - return motion.sub(normal.scale(into)); + 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. + if (normal.dot(up) >= cos_max_slope) return slid; + const before = motion.dot(up); + const after = slid.dot(up); + if (after <= before) return slid; + return slid.sub(up.scale(after - before)); } /// Constrain `motion` against a crease formed by two contact planes. @@ -647,7 +672,7 @@ fn slideAlongPlane(motion: Vec3r, normal: Vec3r) Vec3r { /// `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) ?Vec3r { +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. @@ -665,9 +690,45 @@ fn slideAlongCrease(motion: Vec3r, n0: Vec3r, n1: Vec3r) ?Vec3r { // it was already going and not backwards along it. const along = motion.dot(axis); if (along < 0) axis = axis.neg(); - return axis.scale(@abs(along)); + 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. + if (n0.dot(up) >= cos_max_slope and n1.dot(up) >= cos_max_slope) return slid; + const before = motion.dot(up); + const after = slid.dot(up); + if (after <= before) return slid; + return slid.sub(up.scale(after - before)); } +/// 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, @@ -841,6 +902,41 @@ fn stepDown( return centre.sub(up.scale(paddedAdvance(hit, c.step_height, c.padding))); } +/// 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. +fn pushBody( + bm: *BodyManager, + body: BodyId, + normal: Vec3r, + character_velocity: Vec3r, + c: Character, + dt: Real, +) void { + if (c.max_push_force <= 0 or dt <= 0) return; + if (bm.bodyType(body) != .dynamic) return; + // `normal` runs surface → character, so the character pushes along its negation. + const direction = normal.neg(); + const body_velocity = bm.linearVelocity(body) orelse return; + const closing = character_velocity.dot(direction) - body_velocity.dot(direction); + if (closing <= 0) return; // the body is already leaving at least as fast + const impulse = @min(c.mass * closing, c.max_push_force * dt); + // ACTIVATING by contract (§1.8.4) — an external mutation, and W1 rather than W4. + bm.addImpulse(body, 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 @@ -1078,15 +1174,16 @@ pub const CharacterStore = struct { displacement: Vec3r, dt: Real, ) !MoveResult { - // `dt` reaches no term of this gate: the derived ones are the support velocity, which the - // ground probe reads from the support's own columns, and the push impulse of gate F. - _ = dt; 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{}; + // 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 @@ -1141,6 +1238,11 @@ pub const CharacterStore = struct { break; }; + // PUSH what was hit, if it is dynamic and yields. Before the climb and the slide, because + // whether the body moves does not change the character's own resolution — the push is + // unilateral, so the two are independent and the order is readability alone. + pushBody(bm, hit.body, normal, character_velocity, c, dt); + // **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 @@ -1173,9 +1275,9 @@ pub const CharacterStore = struct { } if (plane_count == 1) { - remaining = slideAlongPlane(remaining, planes[0]); + remaining = slideAlongPlane(remaining, planes[0], c.cos_max_slope); } else { - remaining = slideAlongCrease(remaining, planes[0], planes[1]) orelse { + 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; @@ -1210,10 +1312,136 @@ pub const CharacterStore = struct { // `*const BodyManager`. for (touched.slice()) |body| bm.wakeBody(body); - return .{ - .position = new_base, - .ground = try self.groundOf(bp, bm, store, id), + 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; + } + + // Commit. The record is written FIRST so `syncPresence` mirrors the new size and pose from + // it, and the presence's shape is swapped BEFORE that sync so `bodyAabb` — hence the + // broadphase proxy — reflects the new SIZE and not only the new centre. + 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); + try self.syncPresence(gpa, bp, bm, store, idx); + 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`. + /// + /// Errors: `error.StaleCharacter` on a dead handle, like every other entry of this store. An + /// earlier version was a silent no-op here on the `removeBody` precedent, which 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 that answered the same question in a different way. + /// + /// **The frozen signature is `void` and this one is not, and that is a gap in the frozen surface + /// rather than a liberty taken here.** Keeping the broadphase proxy fresh is part of this entry's + /// contract (§1.12.2), `Broadphase.update` allocates, and a `void` entry has nowhere to put that + /// failure. The interface tier will have to decide at M1.1.15 — and §1.11.7 forbids the easy + /// answer, converting an error into an absent result. Recorded so the freeze meets it knowingly. + 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 error.StaleCharacter; + self.characters.items[idx].position = position; + self.characters.items[idx].reported_ground = .in_air; + try self.syncPresence(gpa, bp, bm, store, idx); + + const c = self.characters.items[idx]; + 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 the authoritative record pose onto the presence — the body AND its broadphase proxy. diff --git a/src/modules/forge/forge_3d/root.zig b/src/modules/forge/forge_3d/root.zig index 2d614c1f..e21d9e14 100644 --- a/src/modules/forge/forge_3d/root.zig +++ b/src/modules/forge/forge_3d/root.zig @@ -249,6 +249,10 @@ pub const CharacterMoveResult = character_mod.MoveResult; 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. From 970b5f565233b13d2d53a93143a0be5708a0281f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 12:35:19 +0200 Subject: [PATCH 026/100] test(forge): extend the character suite to resize, push and teleport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten blocks. The slope constraint in four cases, one of which carries a correction: the rule's case 3 does not hold as stated for a steep but SLOPED face — a pure gravity step against a 50° normal projects to an up component GREATER than the one it came from, so the cap fires and restores the requested rate. The prediction that a descent is already below and hence untouched holds only for a VERTICAL face. Computed before the test was written. The unguarded squeeze mode is measured rather than guarded: a capsule needing 1.8 m under a ceiling offering 1.0 m is PINNED at base exactly 0, bit-identical on the second call, and never driven through the floor. That direction is safe only because `max_depenetration_iterations` is EVEN — at 3 and at 5 the base lands 0.8 m below the ground plane, and no test would have caught it — so the parity is pinned with its measurement. The step's FORWARD sweep now pins its padding stand-off at 1.93. The counterfactual was derived as a lost stand-off and the measurement refuted that: dropping it gives 0.688, because the unpadded advance leaves the capsule flush against the wall, the landing sweep then reports that wall at distance zero instead of the plateau below, and the whole climb is refused. The cost is not 0.02 m of stand-off but 1.24 m of a legitimate move never served. The LIFT's padding is unobservable and that is confirmed by probe, not argued: it cancels against the landing drop in every geometry. --- .../forge/forge_3d/tests/character_test.zig | 563 ++++++++++++++++-- 1 file changed, 522 insertions(+), 41 deletions(-) diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 1d80c370..8d4b8a7d 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -1811,53 +1811,115 @@ test "an intermediate wall buys no horizontal progress — the reference's v5.6. } } -test "a slope steeper than max_slope is never reported grounded, and the CLIMB is not what lifts the character there" { +test "the slide is CONSTRAINED by the slope: four cases" { const gpa = testing.allocator; - // A 50° ramp — just past the default 45° limit — built as a BOX rotated about +Z, so its top - // face normal is `(−sin50, cos50, 0) = (−0.766, 0.643, 0)`. Fifty and not seventy because a face - // rises `tan(θ)` per metre of forward reach while a lift buys only `step_height / tan(θ)` of it: - // at 70° the face climbs 0.75 m over the 0.11 m afforded and nothing can land on it. - var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); - defer world.deinit(gpa); - var chars: CharacterStore = .{}; - defer chars.deinit(gpa); + // §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; + } + }; - _ = try addPlane(gpa, &world, av(0, 1, 0), 0, 230); - const theta: f32 = 50.0 * std.math.pi / 180.0; - const ramp = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av(2, 0.5, 2) } }); - _ = try world.addBody(gpa, .{ - .entity = ent(231), - .body_type = .static, - .shape = ramp, - // Top face through (0.5, 0, 0), just ahead of the character: `centre = p − 0.5 · n`. - .position = av(0.883, -0.3215, 0), - .rotation = math.Quatf.fromAxisAngle(av(0, 0, 1), theta), - }); + // 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); + } - 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); + // 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); + } - // THE VERDICT is enforced: 50° is past the limit, so the character is on steep ground and never - // `.grounded`, and the normal reported is the ramp's own `cos50 = 0.6428`. - try testing.expectEqual(api.GroundState.on_steep_ground, r.ground.state); - // `cos(50°) = 0.6427876097`, to more digits than `api_tol` needs — a four-decimal rounding of it - // is 1.2e-5 away and fails, which is the tolerance doing its job. - try testing.expectApproxEqAbs(@as(Real, 0.6427876), r.ground.normal.toArray()[1], api_tol); - - // **AND THE HEIGHT GAIN IS THE SLIDE'S, NOT THE CLIMB'S — measured, and pinned here because the - // next milestone will meet it.** Projecting a horizontal displacement onto a 50° plane leaves an - // upward component, so a character pushed into the ramp rises whether or not any step was - // accepted: 0.583 m in one call. Running the same scene with `max_slope` at 80°, which makes the - // ramp walkable and suppresses the step attempt entirely, gives 0.579 m — the same height by a - // different route. So the step's landing slope test cannot be what governs this scene, and an - // absolute assertion on the height would measure the slide while appearing to measure the climb. + // CASE 3 — a DOWNWARD motion against the same steep face still descends, and the cap does not + // trap the character against the cliff. // - // Whether SLIDING should itself be slope-constrained is a real question and it is not this - // gate's: the verdict is correct, the position climbs, and nothing in §1.12 settles it. - try testing.expectApproxEqAbs(@as(Real, 0.583), r.position.toArray()[1], 0.01); + // **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" { @@ -1896,3 +1958,422 @@ test "a low kerb with level ground either side is stepped OVER, and the move is // 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.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).?); + + // A stale handle is a TYPED refusal, like every other entry of the store: a teleport that goes + // nowhere is a write the caller has to know did not happen. + chars.destroyCharacter(gpa, &world.store, &world.bm, id); + try testing.expectError( + CharacterError.StaleCharacter, + 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 is PINNED, never driven through the floor" { + const gpa = testing.allocator; + + // A capsule needing 1.8 m of headroom under a ceiling offering 1.0 m, and the same at 1.7 m — + // any deficit at all, not just a large one. Both are unresolvable: no pose satisfies both + // surfaces, so what is measured is the FAILURE DIRECTION. + 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); + + // TWO calls, because a mode that is stable for one call and drifts on the next is the + // dangerous one: the caller keeps asking, and a per-call bias accumulates. + var previous: ?Vec3r = null; + 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(); + + // MEASURED: the depenetration alternates between the two surfaces and lands the capsule + // FLUSH on the floor — 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. So the character is PINNED. + try testing.expectApproxEqAbs(@as(Real, 0), p[0], api_tol); + try testing.expectApproxEqAbs(@as(Real, 0), p[1], api_tol); + // NEVER below the ground plane. This is the assertion that matters, and it is the one + // thing about this mode that is not an accident: see the parity note below. + try testing.expect(p[1] >= -api_tol); + // And it does not drift: call two is bit-identical to call one. + if (previous) |q| try testing.expect(r.position.eql(q)); + previous = r.position; + } + } +} + +test "the depenetration's iteration count is EVEN, and the failure direction depends on it" { + // `max_depenetration_iterations` alternates between the two surfaces of an unresolvable squeeze, + // 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. Nothing else in the suite moved at any of the + // four values except the test that pins the constants themselves, so an odd count would have + // shipped silently. + // + // This is not a guard and no guard was built for it (the clearance test that would resolve the + // squeeze properly is named in `tryStepUp`). It is the record of a measurement, plus the one + // assertion that turns the parity from an accident into something a change has to answer for. + 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. +} From cc6e23ed5afc7d57130a619bab011f407ec9908a Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 12:35:20 +0200 Subject: [PATCH 027/100] docs(brief): record M1.1.12 gate F execution --- briefs/M1.1.12-character-controller.md | 152 +++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 146338d1..aa3e0690 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -1150,3 +1150,155 @@ 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 | +|---|---|---| +| `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-step-forward-padding` | 1 | the FORWARD stand-off (0.688 where 1.93 is right) | +| `no-step-lift-padding` | **0** | **nothing — the cancellation, confirmed** | +| `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 | +| `no-teleport-stale-error` | 1 | the teleport | +| `no-resize-wake` | 1 | both wakes | +| `no-teleport-wake` | 1 | both wakes | + +Every mechanism gate F adds is load-bearing for at least one assertion, with one exception that is a +measured structural cancellation and is documented as such at the assertion that cannot make it. + +#### 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. + +`$?` 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 | 469/469 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 469/469 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1641/1658 (17 skipped) ×2 | + +459 → **469**, the ten 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). From 8e3bb90a5f7acec7c436132db6c81f4fd637d04f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Tue, 4 Aug 2026 12:46:45 +0200 Subject: [PATCH 028/100] fix(forge): reserve before mutating on the presence write paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `syncPresence` updated the broadphase proxy AFTER the record had been written, on all three write paths. `Broadphase.update` reserves a slot on its layer's moved log, so it allocates and can fail: 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; in `resizeCharacter` the record and the presence body already pointed at the new shape, which the `errdefer` then destroyed. The entry now takes its TARGET instead of reading the record, so the one fallible call precedes every mutation, and publishes the UNION of the old and new boxes so a failure leaves a proxy that still contains the body. The commit that follows is infallible. Reserve-then-mutate, M1.1.1-HF1 D3/D4. The resize's other fallible step is interceptable and a FailingAllocator test pins the full claim there. The `bp.update` reservation could NOT be made to fail from a test — measured: a 5 m move on a fresh character reports zero allocations seen, 40 teleports of 5 m report exactly one, and the reversed ordering breaks nothing at exit 0. So the ordering rests on a structural argument, and the use-after-free it forecloses is recorded as a latent hazard rather than claimed as demonstrated. --- briefs/M1.1.12-character-controller.md | 46 ++++++++++++--- src/modules/forge/forge_3d/character.zig | 57 ++++++++++++++----- .../forge/forge_3d/tests/character_test.zig | 53 +++++++++++++++++ 3 files changed, 136 insertions(+), 20 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index aa3e0690..3161f082 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -1275,9 +1275,41 @@ of why the reference shipped a fix for it than the one predicted. | `no-teleport-stale-error` | 1 | the teleport | | `no-resize-wake` | 1 | both wakes | | `no-teleport-wake` | 1 | both wakes | - -Every mechanism gate F adds is load-bearing for at least one assertion, with one exception that is a -measured structural cancellation and is documented as such at the assertion that cannot make it. +| `reversed-fallible-order` | **0** | **nothing — see the ordering section** | + +Every mechanism gate F adds is load-bearing for at least one assertion, with TWO exceptions, both +measured and both documented at the assertion that cannot make them: the step lift's padding, a +structural cancellation, and the fallible-step ordering, whose triggering allocation no test can make +fail. + +#### An ordering defect of mine, found on review of my own gate — and what could NOT be proven + +`syncPresence` ran the broadphase proxy update AFTER the record had been written, on all three write +paths. `Broadphase.update` reserves a slot on its layer's moved log, so it allocates and can fail, and +the consequences differed by path: 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 would be silently lost; in +`resizeCharacter` the record AND the presence body already pointed at the new shape, which the +`errdefer` then destroyed — the character left holding a freed shape. + +Closed by making the entry take its TARGET instead of reading the record, so the one fallible call +precedes every mutation, and by publishing the UNION of the old and new boxes so that a failure leaves +a proxy which still contains the body. The commit that follows is infallible. This is the +repository's reserve-then-mutate invariant (M1.1.1-HF1 D3/D4), and the extra fat on the success path is +the broadphase's own normal regime. + +**What was demonstrated, and what was not.** The resize's other fallible step — allocating the new +capsule — IS interceptable at index 0, and a `FailingAllocator` test pins the full claim there: the +character is unchanged field by field including its shape handle and the live shape count, and the +retry succeeds. The `bp.update` reservation could NOT be made to fail from a test: measured, a 5 m +`moveCharacter` 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, the +list's growth going through a resize the counter does not index. And the reversed ordering breaks +NOTHING in the suite, at exit 0, which was measured rather than assumed. + +So the ordering rests on a structural argument and not on a pinned counterfactual, and the +use-after-free it forecloses is recorded as a LATENT HAZARD rather than claimed as a demonstrated +defect. Both halves are written at the assertion, so a reader cannot take the pinned half for the +whole. #### Two tooling notes @@ -1294,11 +1326,11 @@ wrapper's printed verdict is what to read, and the final matrix was run without | Corner | Exit | Result | |---|---|---| -| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 469/469 ×2 | -| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 469/469 ×2 | -| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1641/1658 (17 skipped) ×2 | +| `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 → **469**, the ten test blocks gate F adds. `zig fmt --check src/ bench/ tests/` clean; tree-wide +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). diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 0bd67f37..34f4de01 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -1303,8 +1303,10 @@ pub const CharacterStore = struct { // 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; - try self.syncPresence(gpa, bp, bm, store, idx); // 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 @@ -1380,15 +1382,18 @@ pub const CharacterStore = struct { return false; } - // Commit. The record is written FIRST so `syncPresence` mirrors the new size and pose from - // it, and the presence's shape is swapped BEFORE that sync so `bodyAabb` — hence the - // broadphase proxy — reflects the new SIZE and not only the new centre. + // 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); - try self.syncPresence(gpa, bp, bm, store, idx); store.destroyShape(gpa, old_shape); // W4: whatever the NEW volume reaches. A superset, which is the safe direction. @@ -1424,11 +1429,12 @@ pub const CharacterStore = struct { position: Vec3r, ) !void { const idx = self.alloc.validate(id) orelse return error.StaleCharacter; + 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; - try self.syncPresence(gpa, bp, bm, store, idx); - const c = self.characters.items[idx]; const record = store.get(c.shape) orelse unreachable; var waker = CandidateWaker{ .bm = bm, .exclude = c.inner_body }; _ = bp.queryAabb( @@ -1444,28 +1450,53 @@ pub const CharacterStore = struct { return self.characters.items[idx].reported_ground; } - /// Mirror the authoritative record pose onto the presence — the body AND its broadphase proxy. + /// 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. - fn syncPresence( + /// + /// **It takes the target rather than reading the record, so that the ONE FALLIBLE STEP RUNS + /// BEFORE ANY MUTATION, and the box it publishes is the UNION of the old and the new.** + /// `Broadphase.update` reserves a slot on its layer's moved log, so it allocates and can fail, + /// 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. + /// + /// Publishing the UNION is what makes a failure harmless in both directions: it is a superset of + /// the old box, so the proxy still contains the body, the record is untouched, and the call is + /// retryable — the repository's reserve-then-mutate invariant (M1.1.1-HF1 D3/D4). The extra fat + /// on the success path is the broadphase's own normal regime — its stored boxes are fat by + /// construction — and the next call refits it. + 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; - // 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, c.position.add(baseToCentre(Real, c.height))); + const record = store.get(shape) orelse unreachable; + const centre = base.add(baseToCentre(Real, height)); if (c.presence_proxy) |proxy| { - try bp.update(gpa, proxy, bm.bodyAabb(store, body).?); + const old_box = bm.bodyAabb(store, body).?; + const new_box = body_manager_mod.worldAabb(record, centre, Quatr.identity); + try bp.update(gpa, proxy, old_box.merge(new_box)); } + // 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 diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 8d4b8a7d..a87129da 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -2377,3 +2377,56 @@ test "the step's FORWARD sweep keeps the padding stand-off, which the lift's can // 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. +} From 9d20f7384d866ce1768c05934d50a1d125b26638 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 10:01:34 +0200 Subject: [PATCH 029/100] fix(forge): cap the slide at zero and never push through a plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections after the gate F verdict. The slope cap's bound was the pre-projection `up` component and had to be `max(before, 0)`. Capping at `before` PENETRATES the plane on a sloped face: on a 50° normal a pure gravity step projects to an up component of −0.5866, which exceeds the −1 it came from, so the cap bit and the output had a dot of −0.2657 with the normal. And the projection was the physically right answer — a body sliding down a 50° slope descends more slowly than in free fall. Measured at contact, the old bound removed the motion entirely: dy = 0.00000, the character frozen on the cliff. Same correction on the crease branch, where no scene of five separated the two bounds, recorded as undiscriminated rather than proven. `depenetrate` carries an invariant instead of relying on its iteration count's parity: it 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. The base and not the centre, because the centre of a 1.8 m capsule is still above a plane its feet passed 0.80 m below. Failure direction, now sayable: an unresolvable squeeze keeps its entry pose with a residual overlap and does not tunnel — verified identical at counts of 3, 4, 5 and 8. `setCharacterPosition` returns to a no-op on a dead handle. The discriminant is whether the entry RETURNS A VALUE, not writing versus destroying: the four entries that return something have no honest answer for a dead handle, while this one and `destroyCharacter` return nothing. The setter-fallibility convention goes to M1.1.15, where it covers the whole surface at once. --- src/modules/forge/forge_3d/character.zig | 121 +++++++++++++++++++---- 1 file changed, 104 insertions(+), 17 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 34f4de01..5d3c0095 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -486,6 +486,10 @@ const Contact = struct { 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. @@ -577,6 +581,7 @@ const DeepestManifold = struct { .body = self.body, .normal = manifold.normal.neg(), .penetration = deepest.penetration, + .position = deepest.position, }; } }; @@ -657,11 +662,22 @@ fn slideAlongPlane(motion: Vec3r, normal: Vec3r, cos_max_slope: Real) Vec3r { // 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 before = motion.dot(up); + const cap = @max(motion.dot(up), 0); const after = slid.dot(up); - if (after <= before) return slid; - return slid.sub(up.scale(after - before)); + if (after <= cap) return slid; + return slid.sub(up.scale(after - cap)); } /// Constrain `motion` against a crease formed by two contact planes. @@ -699,11 +715,25 @@ fn slideAlongCrease(motion: Vec3r, n0: Vec3r, n1: Vec3r, cos_max_slope: Real) ?V // 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 before = motion.dot(up); + const cap = @max(motion.dot(up), 0); const after = slid.dot(up); - if (after <= before) return slid; - return slid.sub(up.scale(after - before)); + if (after <= cap) return slid; + return slid.sub(up.scale(after - cap)); } /// Wakes every broadphase CANDIDATE of a volume, without consulting the narrowphase. @@ -943,6 +973,39 @@ fn pushBody( /// 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. fn depenetrate( bp: *const Broadphase, bm: *const BodyManager, @@ -950,6 +1013,7 @@ fn depenetrate( record: shape_mod.Shape, probe: SupportShape, start: Vec3r, + base_start: Vec3r, layer_mask: u32, exclude: ?BodyId, touched: *TouchedBodies, @@ -968,6 +1032,18 @@ fn depenetrate( _ = 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 along the outward normal by exactly the overlap, so the surfaces end up touching. // The `padding` stand-off is the SWEEP's business, not this one's — maintained here too it // would be two mechanisms holding one distance. @@ -1198,6 +1274,7 @@ pub const CharacterStore = struct { record, probe, c.position.add(baseToCentre(Real, c.height)), + c.position, c.layer_mask, c.inner_body, &touched, @@ -1408,17 +1485,27 @@ pub const CharacterStore = struct { /// — the caller asked to be somewhere, not to be moved toward somewhere. It INVALIDATES the /// reported ground verdict, which returns to `.in_air`. /// - /// Errors: `error.StaleCharacter` on a dead handle, like every other entry of this store. An - /// earlier version was a silent no-op here on the `removeBody` precedent, which 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 that answered the same question in a different way. + /// **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. /// - /// **The frozen signature is `void` and this one is not, and that is a gap in the frozen surface - /// rather than a liberty taken here.** Keeping the broadphase proxy fresh is part of this entry's - /// contract (§1.12.2), `Broadphase.update` allocates, and a `void` entry has nowhere to put that - /// failure. The interface tier will have to decide at M1.1.15 — and §1.11.7 forbids the easy - /// answer, converting an error into an absent result. Recorded so the freeze meets it knowingly. + /// 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, @@ -1428,7 +1515,7 @@ pub const CharacterStore = struct { id: CharacterId, position: Vec3r, ) !void { - const idx = self.alloc.validate(id) orelse return error.StaleCharacter; + 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); From 10cc0fba9be6a9675b9de3516c38c78540f91c50 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 10:02:46 +0200 Subject: [PATCH 030/100] test(forge): pin the steep-face descent and the push-out direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blocks, each the direction whose absence let a defect ship. Gravity on a steep face: a 60° face so the trigonometry closes, the capsule's surface starting 0.12 m clear, a pure 0.5 m downward request. The padding comes off the TRAVEL and not off the normal — the first derivation here got that wrong, the same class of error already corrected at gate D — so contact is at 0.24, the padded advance 0.22, the remaining 0.28 projects, and dy = −0.43 with dx = −0.28·(√3/4). Both to six digits. Discriminating: capping at `before` gives −0.281645 and −0.026694, and at exact contact on a 50° face it gives no motion at all. A doorway narrower than the character: three widths against a 0.60 m capsule, the resolved offset exactly `radius − width/2`, always inside the doorway, base unmoved and the request unserved. NOT a second instance of the ceiling's tunnelling and that was measured — the walls are symmetric about the entry pose so the alternation stays bounded, identical at counts of 3, 4 and 5 because it is per call and not per iteration. The squeeze test now expects the entry base of 0.02 rather than a flush 0, which is the invariant's stated failure direction, and the parity assertion is demoted to a record that carries no correction. --- .../forge/forge_3d/tests/character_test.zig | 166 +++++++++++++++--- 1 file changed, 143 insertions(+), 23 deletions(-) diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index a87129da..39ccbf23 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -2145,13 +2145,11 @@ test "setCharacterPosition teleports without resolving and invalidates the repor // gravity rather than a character believed to be standing where it no longer is. try testing.expectEqual(api.GroundState.in_air, chars.reportedGround(id).?); - // A stale handle is a TYPED refusal, like every other entry of the store: a teleport that goes - // nowhere is a write the caller has to know did not happen. + // 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.store, &world.bm, id); - try testing.expectError( - CharacterError.StaleCharacter, - chars.setCharacterPosition(gpa, &world.bp, &world.bm, &world.store, id, v(9, 9, 9)), - ); + 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)); } @@ -2298,14 +2296,16 @@ test "a character squeezed under a low ceiling is PINNED, never driven through t 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(); - // MEASURED: the depenetration alternates between the two surfaces and lands the capsule - // FLUSH on the floor — 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. So the character is PINNED. + // The character KEEPS THE POSE IT CAME IN WITH, with a residual overlap into the + // ceiling, and does not tunnel — which is the failure direction §1.12.6's depenetration + // invariant makes sayable. The horizontal request is consumed entirely by the four slide + // iterations against the ceiling's downward normal without ever being served, and the + // depenetration reverts rather than push the base through the floor. So: PINNED at its + // entry base of 0.02, not flush at 0, and never below. try testing.expectApproxEqAbs(@as(Real, 0), p[0], api_tol); - try testing.expectApproxEqAbs(@as(Real, 0), p[1], api_tol); - // NEVER below the ground plane. This is the assertion that matters, and it is the one - // thing about this mode that is not an accident: see the parity note below. + try testing.expectApproxEqAbs(@as(Real, 0.02), p[1], api_tol); + // NEVER below the ground plane, and this no longer depends on the iteration count's + // parity: see the invariant on `depenetrate` and the parity record below. try testing.expect(p[1] >= -api_tol); // And it does not drift: call two is bit-identical to call one. if (previous) |q| try testing.expect(r.position.eql(q)); @@ -2314,17 +2314,18 @@ test "a character squeezed under a low ceiling is PINNED, never driven through t } } -test "the depenetration's iteration count is EVEN, and the failure direction depends on it" { - // `max_depenetration_iterations` alternates between the two surfaces of an unresolvable squeeze, - // 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. Nothing else in the suite moved at any of the - // four values except the test that pins the constants themselves, so an odd count would have - // shipped silently. +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. // - // This is not a guard and no guard was built for it (the clearance test that would resolve the - // squeeze properly is named in `tryStepUp`). It is the record of a measurement, plus the one - // assertion that turns the parity from an accident into something a change has to answer for. + // 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); } @@ -2430,3 +2431,122 @@ test "on OOM a resize leaves the character UNCHANGED and is retryable" { // 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`. + // So the resolved offset is exactly that overlap, alternating side from call to call, and it + // is `0.1`, `0.05`, `0.01` for the three widths. + const offset: Real = 0.3 - width / 2; + 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); + // The base is unmoved and the +Z request unserved: pinned, not ejected. + try testing.expectApproxEqAbs(@as(Real, 0.02), p[1], api_tol); + try testing.expectApproxEqAbs(@as(Real, 0), 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. + } +} From b7286e1a33e0506d576eed7abc013d22bc132eb0 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 10:02:48 +0200 Subject: [PATCH 031/100] docs(brief): record the M1.1.12 gate F corrections --- briefs/M1.1.12-character-controller.md | 158 +++++++++++++++++++------ 1 file changed, 122 insertions(+), 36 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 3161f082..c0ed9ddc 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -1261,10 +1261,13 @@ of why the reference shipped a fix for it than the one predicted. | 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-step-forward-padding` | 1 | the FORWARD stand-off (0.688 where 1.93 is right) | +| `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) | @@ -1272,51 +1275,33 @@ of why the reference shipped a fix for it than the one predicted. | `no-resize-presence-sync` | 1 | atomicity, and freshness | | `no-push` | 1 | the unilateral push | | `no-teleport-verdict-invalidation` | 1 | the teleport | -| `no-teleport-stale-error` | 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** | -Every mechanism gate F adds is load-bearing for at least one assertion, with TWO exceptions, both -measured and both documented at the assertion that cannot make them: the step lift's padding, a -structural cancellation, and the fallible-step ordering, whose triggering allocation no test can make -fail. - -#### An ordering defect of mine, found on review of my own gate — and what could NOT be proven - -`syncPresence` ran the broadphase proxy update AFTER the record had been written, on all three write -paths. `Broadphase.update` reserves a slot on its layer's moved log, so it allocates and can fail, and -the consequences differed by path: 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 would be silently lost; in -`resizeCharacter` the record AND the presence body already pointed at the new shape, which the -`errdefer` then destroyed — the character left holding a freed shape. - -Closed by making the entry take its TARGET instead of reading the record, so the one fallible call -precedes every mutation, and by publishing the UNION of the old and new boxes so that a failure leaves -a proxy which still contains the body. The commit that follows is infallible. This is the -repository's reserve-then-mutate invariant (M1.1.1-HF1 D3/D4), and the extra fat on the success path is -the broadphase's own normal regime. - -**What was demonstrated, and what was not.** The resize's other fallible step — allocating the new -capsule — IS interceptable at index 0, and a `FailingAllocator` test pins the full claim there: the -character is unchanged field by field including its shape handle and the live shape count, and the -retry succeeds. The `bp.update` reservation could NOT be made to fail from a test: measured, a 5 m -`moveCharacter` 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, the -list's growth going through a resize the counter does not index. And the reversed ordering breaks -NOTHING in the suite, at exit 0, which was measured rather than assumed. - -So the ordering rests on a structural argument and not on a pinned counterfactual, and the -use-after-free it forecloses is recorded as a LATENT HAZARD rather than claimed as a demonstrated -defect. Both halves are written at the assertion, so a reader cannot take the pinned half for the -whole. +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. +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 @@ -1334,3 +1319,104 @@ wrapper's printed verdict is what to read, and the final matrix was run without `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`. From b11ed404060607de6644865f781efb7e7ceb9007 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 11:08:34 +0200 Subject: [PATCH 032/100] test(bench): add the character controller throughput bench MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. Reported, not gated — 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. Runs are INTERLEAVED: every rep runs all five modes in sequence and the best rep per mode is kept, so a thermal ramp lands on all five rather than on whichever was being measured while it passed. Best-of-N per mode cannot resolve a gap under about 5 %. The resize row carries accepted/refused counters, and they earned their place twice. On a bare half-space the occupancy query 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 counters, not by re-reading the code. What ships is dedicated scenery: 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. ReleaseFast, 2000 calls x 8 reps: plane 212.0 ns, stairs 2235.5, wall 1764.5, mesh floor 7979.0, resize 203.0 — the worst still leaving 2089 calls per 16.67 ms frame. Leak check proven in both directions: with `safety` forced true a deliberate 4 KiB leak prints `memory address ... leaked`, and with the default the same leak prints nothing. --- bench/forge_3d_character.zig | 387 ++++++++++++++++++++++++++++ bench/results/forge_3d_character.md | 27 ++ build.zig | 28 ++ 3 files changed, 442 insertions(+) create mode 100644 bench/forge_3d_character.zig create mode 100644 bench/results/forge_3d_character.md diff --git a/bench/forge_3d_character.zig b/bench/forge_3d_character.zig new file mode 100644 index 00000000..1be6ab75 --- /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 00000000..874c650f --- /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/build.zig b/build.zig index 12521bc3..74293b0f 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 From 7d332fe7677d07f692642ddf1978dedc53864690 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 11:08:36 +0200 Subject: [PATCH 033/100] docs(claude-md): update for M1.1.12 --- CLAUDE.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 97d6cf99..08c68d94 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. 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, publishing the UNION of the two boxes: `Broadphase.update` reserves and can fail, and on the previous order the record and the presence already pointed at the new shape an `errdefer` then destroyed — the use-after-free is recorded as a LATENT DANGER and not a demonstrated defect, that reservation not having been made to fail from a test. NINE OF THIS MILESTONE'S 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, and 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). The standing formulation is CC's: *an assertion that exercises a path does not thereby test the mechanism that path uses*, and every one of the six 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. 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; 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 this 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 From 2b93741eccd5ce754be101dc358db5d77dbf2625 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 11:09:24 +0200 Subject: [PATCH 034/100] docs(brief): close M1.1.12 --- briefs/M1.1.12-character-controller.md | 119 ++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index c0ed9ddc..d1d76709 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -1,6 +1,6 @@ # M1.1.12 — Forge 3D: the kinematic character controller -> **Status:** ACTIVE +> **Status:** CLOSED > **Phase:** 1 > **Branch:** `phase-1/forge/character-controller` > **Planned tag:** `v0.11.12-character-controller` @@ -1420,3 +1420,120 @@ entry alongside the setter-fallibility convention. 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. From 4d288b52eef9a8c0871d33d3922d045252e131cd Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 12:25:25 +0200 Subject: [PATCH 035/100] fix(forge): close six review findings on the character controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1 the push survived a publication that can still fail. `pushBody` mutated inside the slide loop while `syncPresenceTo` can fail after it, so an error returned with the record intact left bodies already pushed and a retry pushed twice. The pushes are accumulated in `PendingPushes` — bounded by `max_touched`, since a push targets only a body the move touched — and applied after the publication, beside the wake loop that is post-publication for the same reason. P1-2 the union fattened the proxy permanently and guarded nothing. `Bvh.update` returns without refitting once its stored fat box contains the new tight one, so a teleport's leaf covered the whole trajectory and no later call shrank it. And the motive falls too: `Broadphase.update` reserves its moved-log slot before touching the node, so that entry is already atomic. `new_box` alone is published and the paragraph justifying the union is deleted, not amended. P1-3 the proxy outlived the character. `destroyCharacter` released three resources where there are four, a count its own comment stated and the code contradicted. It gains a `*Broadphase` and calls the infallible `Broadphase.remove`, so its return type is unchanged. P2-1 `ground_velocity` was read at the penetration MIDPOINT instead of the body's surface, short by half the penetration. Corrected by the same reconstruction `prepare` performs on the same field. P2-2 `step_height` was never validated, the one stored physical parameter without a guard, though it is consumed as a sweep distance. Now finite and non-negative; zero stays the disabler. P2-3 `setShape` accepted any non-dynamic shape swap while maintaining two of its four consequences — a mesh's cached world AABB and a half-space's absence from the trees. Restricted to convexes, which makes both unreachable rather than handled. --- CLAUDE.md | 17 +-- src/modules/forge/forge_3d/body_manager.zig | 16 +++ src/modules/forge/forge_3d/character.zig | 139 ++++++++++++++++---- 3 files changed, 132 insertions(+), 40 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 08c68d94..97d6cf99 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.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. | +| 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. | ## Tags @@ -69,7 +69,6 @@ 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. 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, publishing the UNION of the two boxes: `Broadphase.update` reserves and can fail, and on the previous order the record and the presence already pointed at the new shape an `errdefer` then destroyed — the use-after-free is recorded as a LATENT DANGER and not a demonstrated defect, that reservation not having been made to fail from a test. NINE OF THIS MILESTONE'S 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, and 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). The standing formulation is CC's: *an assertion that exercises a path does not thereby test the mechanism that path uses*, and every one of the six 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. 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; 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) @@ -141,11 +140,7 @@ 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 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 this 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. +- **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. ## Non-negotiable rules @@ -332,4 +327,4 @@ line, and never on a `tail`. --- -Last updated: 2026-08-05 +Last updated: 2026-08-02 diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 620d6ad3..2f80c1fa 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -349,12 +349,28 @@ pub const BodyManager = struct { /// 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. diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 5d3c0095..7f607f90 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -222,6 +222,13 @@ fn validateDescriptor(desc: CharacterDescriptor) CharacterError!void { // 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 @@ -397,7 +404,17 @@ const ManifoldSink = struct { for (manifold.points[1..manifold.count]) |p| { if (p.penetration > deepest.penetration) deepest = p; } - self.ground.consider(self.body, subshape_id, manifold.normal.neg(), deepest.position); + // 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); } }; @@ -466,6 +483,39 @@ const TouchedBodies = struct { } }; +/// 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. +const PendingPushes = struct { + const Entry = struct { body: BodyId, impulse: Vec3r }; + + items: [max_touched]Entry = @splat(.{ .body = 0, .impulse = Vec3r.zero }), + len: u32 = 0, + + fn add(self: *PendingPushes, body: BodyId, impulse: Vec3r) void { + std.debug.assert(self.len < max_touched); + if (self.len >= max_touched) return; + self.items[self.len] = .{ .body = body, .impulse = impulse }; + self.len += 1; + } + + fn apply(self: *const PendingPushes, bm: *BodyManager) void { + for (self.items[0..self.len]) |e| { + // ACTIVATING by contract (§1.8.4) — an external mutation, and W1 rather than W4. + bm.addImpulse(e.body, e.impulse); + } + } +}; + /// What one `moveCharacter` returns, at solver precision. /// /// **No remaining displacement and no collision counter.** A caller that wants to know whether it @@ -947,24 +997,32 @@ fn stepDown( /// /// `dt` is one of the two DERIVED terms §1.12.1 reserves it for; the character is never integrated /// with it. -fn pushBody( - bm: *BodyManager, +/// 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. +/// +/// One consequence of deferring, stated rather than left to be discovered: if two slide iterations +/// hit the SAME body, both now plan against the velocity it had at the start of the call, where the +/// applied form let the second read the first's result and ask for less. The per-entry ceiling is +/// unchanged, so the worst case is the same `n · max_push_force · dt` it always was; only the actual +/// sum can come out slightly larger. The alternative — re-reading a velocity that has not been +/// written yet — is not available to a planner. +fn plannedPush( + bm: *const BodyManager, body: BodyId, normal: Vec3r, character_velocity: Vec3r, c: Character, dt: Real, -) void { - if (c.max_push_force <= 0 or dt <= 0) return; - if (bm.bodyType(body) != .dynamic) return; +) ?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; + const body_velocity = bm.linearVelocity(body) orelse return null; const closing = character_velocity.dot(direction) - body_velocity.dot(direction); - if (closing <= 0) return; // the body is already leaving at least as fast + 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); - // ACTIVATING by contract (§1.8.4) — an external mutation, and W1 rather than W4. - bm.addImpulse(body, direction.scale(impulse)); + return direction.scale(impulse); } /// Push the capsule out of everything it overlaps, deepest first, and record whose wake that owes. @@ -1085,7 +1143,7 @@ pub const CharacterStore = struct { /// Create a controller, returning its handle. /// - /// **TRANSACTIONAL.** Three resources are acquired — the capsule in `store`, the + /// **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 @@ -1169,18 +1227,29 @@ pub const CharacterStore = struct { return a.id; } - /// Destroy a controller, releasing all three of its resources. 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. + /// 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); @@ -1256,6 +1325,7 @@ pub const CharacterStore = struct { 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). @@ -1315,10 +1385,12 @@ pub const CharacterStore = struct { break; }; - // PUSH what was hit, if it is dynamic and yields. Before the climb and the slide, because - // whether the body moves does not change the character's own resolution — the push is - // unilateral, so the two are independent and the order is readability alone. - pushBody(bm, hit.body, normal, character_velocity, c, dt); + // 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. @@ -1391,6 +1463,10 @@ pub const CharacterStore = struct { // `*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. + pushes.apply(bm); + 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; @@ -1546,9 +1622,8 @@ pub const CharacterStore = struct { /// 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, and the box it publishes is the UNION of the old and the new.** - /// `Broadphase.update` reserves a slot on its layer's moved log, so it allocates and can fail, - /// and an earlier version called it AFTER the commit. Two distinct consequences, both real: + /// 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 @@ -1556,11 +1631,19 @@ pub const CharacterStore = struct { /// - 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. /// - /// Publishing the UNION is what makes a failure harmless in both directions: it is a superset of - /// the old box, so the proxy still contains the body, the record is untouched, and the call is - /// retryable — the repository's reserve-then-mutate invariant (M1.1.1-HF1 D3/D4). The extra fat - /// on the success path is the broadphase's own normal regime — its stored boxes are fat by - /// construction — and the next call refits it. + /// 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, @@ -1577,9 +1660,7 @@ pub const CharacterStore = struct { const record = store.get(shape) orelse unreachable; const centre = base.add(baseToCentre(Real, height)); if (c.presence_proxy) |proxy| { - const old_box = bm.bodyAabb(store, body).?; - const new_box = body_manager_mod.worldAabb(record, centre, Quatr.identity); - try bp.update(gpa, proxy, old_box.merge(new_box)); + 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. From d740011447bb20bb1fe39dce5939352bdf6ffeeb Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 12:26:29 +0200 Subject: [PATCH 036/100] test(forge): pin the six findings and a seventh found while probing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven blocks. The accumulated push against a closed form, with the expectation PER-PRECISION and the reason on record: the number of contacts against one body is two at f32 and one at f64, the second existing only because an f32 residue leaves a sliver of `remaining` behind. Asserting 20 at both precisions was pinning float noise, and it is what made the f64 matrix legs red on the first attempt. A publication that fails leaves every body velocity untouched, injected with BOTH `fail_index` and `resize_fail_index` — the second is what gate F was missing when it recorded this failure as un-injectable, a list growing through `remap` before falling back to `alloc`. The measured limit is stated at the assertion: the growth lands on the 34th append of a pose-write sequence, and five constructions failed to land it on a call that also pushes, because a move that pushes barely advances and so never refits its proxy. A teleport leaves no permanently fat leaf, asserted by a candidate collector at the trajectory's midpoint with a non-vacuity check at the new pose. Destroying a character removes its proxy, asserted on REACHABILITY and not on a node count — a leaked leaf keeps its box and every query over that region keeps visiting it. `ground_velocity` at the body's surface, on a platform spinning about +Z because about +Y the point's height cancels out of the cross product and the fix would be unobservable, with the character in frank interpenetration because the existing rotating-platform case goes through the sweep. `step_height`'s domain, with nothing left behind by its three refusals. And the seventh: a base of EXACTLY zero serves no horizontal motion at all, measured at six starting heights so the trigger is known to be exact tangency and not "below padding". Pinned at its measured behaviour, not fixed — a closure gate adds no behaviour line. --- .../forge/forge_3d/tests/character_test.zig | 335 +++++++++++++++++- 1 file changed, 324 insertions(+), 11 deletions(-) diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 39ccbf23..1b0bcb38 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -32,6 +32,7 @@ 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 @@ -360,7 +361,7 @@ test "the stored cosine is the single trigonometric call, and a larger cosine is try testing.expectApproxEqAbs(@as(Real, 1), chars.get(c).?.cos_max_slope, api_tol); } -test "destroyCharacter releases all three resources and is a no-op on a stale handle" { +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); @@ -368,6 +369,8 @@ test "destroyCharacter releases all three resources and is a no-op on a stale ha 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)).?; @@ -375,9 +378,11 @@ test "destroyCharacter releases all three resources and is a no-op on a stale ha try testing.expectEqual(@as(u32, 1), store.count()); try testing.expectEqual(@as(u32, 1), bm.count()); - chars.destroyCharacter(gpa, &store, &bm, id); + chars.destroyCharacter(gpa, &bp, &store, &bm, id); - // All three counts back to zero: the character, its capsule and its presence. + // 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()); @@ -387,7 +392,7 @@ test "destroyCharacter releases all three resources and is a no-op on a stale ha // 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, &store, &bm, id); + 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()); @@ -401,9 +406,11 @@ test "a recycled slot yields a different handle, so a stale one stays detectable 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, &store, &bm, first); + 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 @@ -426,6 +433,8 @@ test "getCharacterInnerBody has three outcomes and never conflates two of them" 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()); @@ -445,7 +454,7 @@ test "getCharacterInnerBody has three outcomes and never conflates two of them" // 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, &store, &bm, with); + chars.destroyCharacter(gpa, &bp, &store, &bm, with); try testing.expectError(error.StaleCharacter, chars.getCharacterInnerBody(with)); } @@ -457,6 +466,8 @@ test "the descriptor domain is refused by typed error and never sanitised" { 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); @@ -626,7 +637,7 @@ test "the descriptor domain is refused by typed error and never sanitised" { // 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, &store, &bm, id); + chars.destroyCharacter(gpa, &bp, &store, &bm, id); } } @@ -1189,7 +1200,7 @@ test "groundOf reports a stale handle as a typed error" { // 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.store, &world.bm, 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), @@ -1596,7 +1607,7 @@ test "moveCharacter reports a stale handle as a typed error" { 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.store, &world.bm, id); + 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), @@ -2066,7 +2077,7 @@ test "resizeCharacter refuses the same domain as createCharacter, by typed error try testing.expectEqual(@as(u32, 1), world.store.count()); // And a stale handle is the caller's fault too. - chars.destroyCharacter(gpa, &world.store, &world.bm, id); + 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)); } @@ -2148,7 +2159,7 @@ test "setCharacterPosition teleports without resolving and invalidates the repor // 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.store, &world.bm, id); + 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)); } @@ -2550,3 +2561,305 @@ test "a doorway narrower than the character NEVER ejects it, whatever the iterat // 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 APPLIED PUSH IS EXACTLY THE PLANNED `mass · closing = 1 · 10 = 10`**, under a ceiling of + // `1000 · 1` that does not bind — that is the invariant, and it holds because a planner cannot + // read a velocity that has not been written yet. + // + // **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 the expectation is per-precision, and the invariant is stated as the multiple: the applied + // total is `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 — so the f32 leg does discriminate the two forms, which is what + // the probe table records; the f64 leg cannot, having only one contact. + 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 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 serves no horizontal motion — measured, not fixed here" { + const gpa = testing.allocator; + + // **A SEVENTH FINDING, found while building the P1-1 probe rather than by review, and PINNED + // rather than fixed: gate G adds no behaviour line.** + // + // At a base of exactly zero 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 — `slideAlongPlane` returns + // the motion unchanged because `into >= 0`. The next iteration finds the same contact. All four + // slide iterations are consumed with no progress and the remaining displacement 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 1 m. So the trigger is exact tangency and not "below `padding`", which is + // what a guess would have said. + // + // Not reachable from ordinary play — a move leaves the character resting at `padding` above its + // floor, never at zero — but reachable from AUTHORING, "put the character on the ground" being a + // natural thing to write. Consigned with its owner. + 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, 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); + const expected_x: Real = if (start_y == 0) 0 else 1; + try testing.expectApproxEqAbs(expected_x, r.position.toArray()[0], api_tol); + // The verdict is honest either way — there IS ground under it. + try testing.expectEqual(api.GroundState.grounded, r.ground.state); + } +} From 5110a674a88cbc8d3ac1ca3a7df6a9814cb7aa3d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 12:26:31 +0200 Subject: [PATCH 037/100] docs(brief): record the gate G round and hold the closure artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RD-4 and RD-5 for the two signature changes. The six findings with their origin — four in code that six gates of internal review had passed, two omissions in the brief's own instructions — plus the seventh found while building a probe. `CLAUDE.md` is restored to its state at the base commit and the brief's status returns to ACTIVE: both closure artifacts wait until the six fixes are pushed and verified. The corrected text for the Tags row is recorded in the brief so it is not lost in the interval. --- briefs/M1.1.12-character-controller.md | 186 ++++++++++++++++++++++++- 1 file changed, 185 insertions(+), 1 deletion(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index d1d76709..58c44ff7 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -1,6 +1,6 @@ # M1.1.12 — Forge 3D: the kinematic character controller -> **Status:** CLOSED +> **Status:** ACTIVE > **Phase:** 1 > **Branch:** `phase-1/forge/character-controller` > **Planned tag:** `v0.11.12-character-controller` @@ -286,6 +286,29 @@ replacement. *(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 @@ -1537,3 +1560,164 @@ M1.1.14 half; only the M1.1.12 clause becomes the measurement. 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`. From 604fcecfaf3bfb045a657224d318562986fcc0d4 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 13:00:00 +0200 Subject: [PATCH 038/100] fix(forge): cap the character's push per body, not per contact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PendingPushes.add` appended without coalescing and `apply` called `addImpulse` once per entry, each capped at `max_push_force · dt`. Two contacts on one body in one call therefore delivered twice the declared ceiling, three would have delivered three times, and nothing bounded the factor. The first round wrote that consequence down — "the per-entry ceiling is unchanged" — which was exact and insufficient: `max_push_force` is documented in newtons, and a ceiling one can exceed by being touched twice is not a ceiling. The impulses destined for one `BodyId` are now summed, the sum is capped once, and one impulse per body is applied. 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. 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. --- src/modules/forge/forge_3d/character.zig | 38 ++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 7f607f90..0ccb760e 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -495,23 +495,54 @@ const TouchedBodies = struct { /// 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; } - fn apply(self: *const PendingPushes, bm: *BodyManager) void { + /// 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, e.impulse); + bm.addImpulse(e.body, impulse); } } }; @@ -1465,7 +1496,8 @@ pub const CharacterStore = struct { // 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. - pushes.apply(bm); + // 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). From 969e93d45d627ae7a37f2bb31ea18d4392667ea3 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 13:01:29 +0200 Subject: [PATCH 039/100] test(forge): pin the push ceiling and report the seventh stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ceiling test's number is precision-INDEPENDENT where the slack-ceiling one's is not: under a binding ceiling of `5 · 1` the box leaves at exactly 5 m/s at both precisions, because the contact count no longer reaches the answer. That independence is the second half of what coalescing bought. And the brief records why the seventh finding is not fixed here. Traced: `hit_dist` is exactly zero every iteration, the resolved normal is the surface's +Y, the padded advance returns zero and the slide returns a horizontal motion projected on a horizontal plane unchanged, so neither position nor remaining moves — four identical iterations, then the remainder is dropped. The observable formulation is right, but not counting the iteration alone is an infinite loop, and the only progress available without a new mechanism is to serve the remainder — MEASURED to tunnel: with a wall whose face stands at 0.8 the sweep still reports only the plane at distance zero, so serving would carry the character through it. A safe fix needs a per-call exclusion threaded through `sweepNearest` and its five call sites. Reported rather than widened, per the gate's own rule. --- briefs/M1.1.12-character-controller.md | 70 +++++++++++++++++++ .../forge/forge_3d/tests/character_test.zig | 55 ++++++++++++--- 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 58c44ff7..46b8a285 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -1721,3 +1721,73 @@ this round. 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. diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 1b0bcb38..35e70c65 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -2617,9 +2617,9 @@ test "P1-1 the pushes are accumulated and applied ONCE, after the publication" { // 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 APPLIED PUSH IS EXACTLY THE PLANNED `mass · closing = 1 · 10 = 10`**, under a ceiling of - // `1000 · 1` that does not bind — that is the invariant, and it holds because a planner cannot - // read a velocity that has not been written yet. + // **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 @@ -2627,16 +2627,55 @@ test "P1-1 the pushes are accumulated and applied ONCE, after the publication" { // 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 the expectation is per-precision, and the invariant is stated as the multiple: the applied - // total is `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 — so the f32 leg does discriminate the two forms, which is what - // the probe table records; the f64 leg cannot, having only one contact. + // 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); From e09898ba0f4daae9177c77bb834254d284114ba3 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 13:26:33 +0200 Subject: [PATCH 040/100] docs(forge): document the exact-tangency precondition on the descriptor A base placed exactly tangent to a surface is a degenerate input: the controller's resting pose is `padding` above its floor, and at exact tangency the horizontal sweep reports a contact at distance zero the padded advance cannot clear, so the character serves no horizontal motion at all, permanently, and nothing reports it. Measured at six starting heights: only exactly zero fails. Documented and not fixed. The defect, its two candidate fixes and the two forms already refuted by measurement are an open decision in `CLAUDE.md`, owned by the next milestone that opens `character.zig`. This is the only half of the problem a closure gate can close honestly. --- src/modules/forge/api/types.zig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/modules/forge/api/types.zig b/src/modules/forge/api/types.zig index 9160aac9..b45f0027 100644 --- a/src/modules/forge/api/types.zig +++ b/src/modules/forge/api/types.zig @@ -337,6 +337,15 @@ pub const CharacterDescriptor = struct { /// 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. + /// + /// **PRECONDITION: a base placed EXACTLY tangent to a surface is a degenerate input.** The + /// controller's own resting pose is `padding` ABOVE its floor, and at exact tangency the + /// horizontal sweep reports a contact at distance zero which the padded advance cannot clear — + /// so the character serves no horizontal motion at all, permanently, and nothing reports it. + /// MEASURED at six starting heights: only exactly zero fails, `0.005` and above serve in full. + /// Place the base at `padding` or more above the surface. Documented rather than fixed — the + /// defect and its two candidate fixes are an open decision in `CLAUDE.md`, owned by the next + /// milestone that opens `character.zig`. position: Vec3 = Vec3.zero, /// Capsule radius (metres). From 81fc7483a79782545529319f28b8638cecda548f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 13:26:34 +0200 Subject: [PATCH 041/100] docs(claude-md): update for M1.1.12 --- CLAUDE.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 97d6cf99..b56e65d0 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. **IT SHIPS ONE KNOWN, MEASURED, PINNED, UNFIXED FUNCTIONAL DEFECT** — a character whose base is placed EXACTLY tangent to a surface serves no horizontal motion, permanently, and that is recorded here rather than left for a reader to discover: at exact tangency the sweep reports a contact at distance zero, `paddedAdvance` returns zero, and the slide returns a horizontal motion projected on a horizontal plane unchanged, so all four iterations are consumed and the remainder is DROPPED. Unreachable from play (every move leaves the character `padding` above its floor) and reachable from AUTHORING. Pinned at seven heights in BOTH directions — only exactly zero fails, `0.005` through `0.05` serve in full — mitigated by a documented precondition on `CharacterDescriptor.position`, with two candidate fixes and two refuted ones in `CLAUDE.md`, owned by the next milestone that opens `character.zig`. 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. TWO ROUNDS OF EXTERNAL REVIEW AFTER THE INTERNAL GATES CLOSED, seven 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; and, 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. 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 exact-tangency defect above, owned and specified; 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,12 @@ 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. +- **A character based EXACTLY tangent to a surface never moves — DELIVERED OPEN at M1.1.12**: a functional defect, measured, pinned at seven heights in both directions, NOT fixed. At exact tangency the horizontal sweep reports a contact at distance zero, `paddedAdvance` returns `max(0, 0 − padding) = 0`, and the slide returns a horizontal motion projected on a horizontal plane unchanged — so neither position nor remaining moves, all four slide iterations are consumed and the remainder is DROPPED. Permanent, since the state never leaves zero. MEASURED: only exactly zero fails; `0.005` through `0.05` serve in full, so the trigger is exact tangency and not "below `padding`". Not reachable from play (every move leaves the character at `padding` above its floor) and reachable from AUTHORING, `position = (0,0,0)` over a floor at `y = 0` being the natural thing to write. Mitigated only by a documented precondition on `CharacterDescriptor.position`. TWO candidate fixes, both measured or costed so the owner does not re-derive them: (a) a per-call EXCLUSION of the non-obstructing body threaded through `sweepNearest`, letting the sweep report the SECOND-nearest hit — costed at a signature change on the module's hottest private helper plus its five call sites in one file, no published surface affected; (b) establishing the `padding` stand-off at the ENTRY of the move, since depenetration today bites only on a strictly positive penetration while the stand-off is a resting invariant nothing establishes except `paddedAdvance`, which cannot when the advance is zero. Not arbitrated: (b) touches the depenetration contract, hence the squeeze, the corridor and the step's landing. Two refuted forms 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, the sweep reporting only the plane at distance zero. **Owner: the next milestone that opens `character.zig`.** +- **`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 +333,4 @@ line, and never on a `tail`. --- -Last updated: 2026-08-02 +Last updated: 2026-08-05 From 626b015debdab7e39bbe7b169b8f0b7fcb1bf7da Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 13:26:35 +0200 Subject: [PATCH 042/100] docs(brief): close M1.1.12 --- briefs/M1.1.12-character-controller.md | 65 +++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 46b8a285..a7e00123 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -1,6 +1,6 @@ # M1.1.12 — Forge 3D: the kinematic character controller -> **Status:** ACTIVE +> **Status:** CLOSED > **Phase:** 1 > **Branch:** `phase-1/forge/character-controller` > **Planned tag:** `v0.11.12-character-controller` @@ -1791,3 +1791,66 @@ absence let the gate-F slope bound through. | `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** — measured, pinned at seven heights in both directions, mitigated by a precondition, NOT fixed | **the next milestone that opens `character.zig`** | +| 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 | + +#### Why consignation 4 is acceptable to ship, stated rather than dressed up + +Three reasons, and none of them is "it is small". It is **not reachable from play** — every move leaves +the character `padding` above its floor, measured, so the state does not reproduce itself and only +authoring reaches it. It is **pinned at seven heights with both directions**, so the fix is testable in +advance and the regression is guarded; a measured and pinned defect is not the same object as an unknown +one. And the deferral **forces no refactoring**, which the deferral rule requires checking: +`sweepNearest` is private to `character.zig`, no published surface sees it, and its five call sites are +in that one file — this is not the inter-module churn the rule protects against. + +#### 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. From a9c62c972e18de94ee905c538ce4c7f025199e0d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 14:37:39 +0200 Subject: [PATCH 043/100] fix(forge): depenetrate to the padding stand-off, not to touching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A capsule left exactly tangent to a surface served NO horizontal motion at all, permanently: the sweep reports a contact at distance zero, `paddedAdvance` returns `max(0, 0 − padding) = 0`, and the slide returns a horizontal motion projected on a horizontal plane unchanged, so all four slide iterations are consumed with no progress and the remainder is dropped. §1.12.6 makes the `padding` stand-off an obligation of the controller and nothing established it — `paddedAdvance` cannot, having nothing to subtract from when the advance is zero. And `depenetrate` was MANUFACTURING the failing state rather than merely failing to leave it: traced, a character starting 0.05 m inside the floor was resolved to a base of exactly `0.000000000` and froze, so any interpenetration at all ended frozen, not only an authored tangency. The fix is one parameter and one term: push out by `penetration + padding`. It 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. No exclusion through `sweepNearest` was needed: `gjk.zig` classifies exact tangency `.shallow`, so the manifold path sees a tangent contact perfectly well. Also withdraws the precondition documented on `CharacterDescriptor.position` last round, and says why: a precondition the field's own `Vec3.zero` default violates is a bug with an apology attached. And deletes a `plannedPush` paragraph still describing the pre-coalescing worst case. --- src/modules/forge/api/types.zig | 13 +++----- src/modules/forge/forge_3d/character.zig | 40 ++++++++++++++++++------ 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/modules/forge/api/types.zig b/src/modules/forge/api/types.zig index b45f0027..44e02d34 100644 --- a/src/modules/forge/api/types.zig +++ b/src/modules/forge/api/types.zig @@ -338,14 +338,11 @@ pub const CharacterDescriptor = struct { /// a deliberate divergence, this descriptor carrying only `radius` and `height`, hence /// a capsule and nothing else. /// - /// **PRECONDITION: a base placed EXACTLY tangent to a surface is a degenerate input.** The - /// controller's own resting pose is `padding` ABOVE its floor, and at exact tangency the - /// horizontal sweep reports a contact at distance zero which the padded advance cannot clear — - /// so the character serves no horizontal motion at all, permanently, and nothing reports it. - /// MEASURED at six starting heights: only exactly zero fails, `0.005` and above serve in full. - /// Place the base at `padding` or more above the surface. Documented rather than fixed — the - /// defect and its two candidate fixes are an open decision in `CLAUDE.md`, owned by the next - /// milestone that opens `character.zig`. + /// 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). diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 0ccb760e..5c2e1824 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -1031,12 +1031,12 @@ fn stepDown( /// 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. /// -/// One consequence of deferring, stated rather than left to be discovered: if two slide iterations -/// hit the SAME body, both now plan against the velocity it had at the start of the call, where the -/// applied form let the second read the first's result and ask for less. The per-entry ceiling is -/// unchanged, so the worst case is the same `n · max_push_force · dt` it always was; only the actual -/// sum can come out slightly larger. The alternative — re-reading a velocity that has not been -/// written yet — is not available to a planner. +/// 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, @@ -1105,6 +1105,7 @@ fn depenetrate( base_start: Vec3r, layer_mask: u32, exclude: ?BodyId, + padding: Real, touched: *TouchedBodies, ) Vec3r { var centre = start; @@ -1133,10 +1134,28 @@ fn depenetrate( const s_now = s_entry + centre.sub(start).dot(c.normal); if (s_entry >= 0 and s_now < 0) return start; - // Out along the outward normal by exactly the overlap, so the surfaces end up touching. - // The `padding` stand-off is the SWEEP's business, not this one's — maintained here too it - // would be two mechanisms holding one distance. - centre = centre.add(c.normal.scale(c.penetration)); + // **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. + // + // **The branch cannot over-fire, and that is bounded by CONSTRUCTION and not by a threshold**: + // a manifold exists only where the separation is at most the contact margin — `gjk.zig` + // classifies anything beyond it `.separated` and `collideOrdered` answers null there — so a + // capsule already standing off, even by 0.005, is invisible to this query and is not moved. + // TRACED at four heights: 0.005 and 0.02 produce no contact at all. + centre = centre.add(c.normal.scale(c.penetration + padding)); } return centre; } @@ -1378,6 +1397,7 @@ pub const CharacterStore = struct { c.position, c.layer_mask, c.inner_body, + c.padding, &touched, ); From bbaa8a8174db7dab6a3a19531d49f4e244c7629b Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 14:37:40 +0200 Subject: [PATCH 044/100] test(forge): flip the tangency pin and re-derive three expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin asserts BOTH directions over seven heights: zero now serves the whole metre, and the six clear heights keep exactly what they had. The comment describing the defect as pinned-not-fixed is deleted rather than amended — it described a state that no longer exists. The seventh height earns its place by measurement rather than symmetry: at 0.2 the character enters NOT grounded, the entry probe being 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, 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. Two other expectations moved and each was re-derived, not bumped. The interpenetration test's 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: a tangent base made `stepDown`'s padded advance clamp to zero, so the old clean `0.0173205` was itself a consequence of the freeze. The doorway's offset moves by exactly `padding`, and the assertion that matters — still inside, at all three widths — was checked before the number was touched. --- .../forge/forge_3d/tests/character_test.zig | 95 +++++++++++++------ 1 file changed, 68 insertions(+), 27 deletions(-) diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 35e70c65..de1f9870 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -1430,11 +1430,26 @@ test "a move that starts interpenetrated is depenetrated by the MANIFOLD, not by // **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 2 cm along the slope normal, so - // Δx = 0.02 · 0.5 = 0.01 - // Δy = 0.02 · 0.866 = 0.01732 - try testing.expectApproxEqAbs(@as(Real, 0.01), r.position.toArray()[0], api_tol); - try testing.expectApproxEqAbs(@as(Real, 0.017320508), r.position.toArray()[1], api_tol); + // 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. @@ -2537,9 +2552,13 @@ test "a doorway narrower than the character NEVER ejects it, whatever the iterat // 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`. - // So the resolved offset is exactly that overlap, alternating side from call to call, and it - // is `0.1`, `0.05`, `0.01` for the three widths. - const offset: Real = 0.3 - width / 2; + // 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 @@ -2865,26 +2884,28 @@ test "P2-2 step_height is validated like every other stored physical parameter" try testing.expectEqual(@as(u32, 1), bm.count()); } -test "a base EXACTLY on the floor serves no horizontal motion — measured, not fixed here" { +test "a base EXACTLY on the floor is no longer frozen — seven heights, both directions" { const gpa = testing.allocator; - // **A SEVENTH FINDING, found while building the P1-1 probe rather than by review, and PINNED - // rather than fixed: gate G adds no behaviour line.** + // **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 a base of exactly zero 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 — `slideAlongPlane` returns - // the motion unchanged because `into >= 0`. The next iteration finds the same contact. All four - // slide iterations are consumed with no progress and the remaining displacement is DROPPED. + // 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. // - // MEASURED at six starting heights: only EXACTLY zero fails. 0.005, 0.01, 0.019, 0.02 and 0.05 - // all serve the whole 1 m. So the trigger is exact tangency and not "below `padding`", which is - // what a guess would have said. + // 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. // - // Not reachable from ordinary play — a move leaves the character resting at `padding` above its - // floor, never at zero — but reachable from AUTHORING, "put the character on the ground" being a - // natural thing to write. Consigned with its owner. - for ([_]f32{ 0, 0.005, 0.01, 0.019, 0.02, 0.05 }) |start_y| { + // 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 = .{}; @@ -2896,9 +2917,29 @@ test "a base EXACTLY on the floor serves no horizontal motion — measured, not 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); - const expected_x: Real = if (start_y == 0) 0 else 1; - try testing.expectApproxEqAbs(expected_x, r.position.toArray()[0], api_tol); - // The verdict is honest either way — there IS ground under it. - try testing.expectEqual(api.GroundState.grounded, r.ground.state); + + // 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, + ); } } From aed8d3a11bee50266ac73dd5440ddd31f1e64385 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 14:37:41 +0200 Subject: [PATCH 045/100] docs(brief): record the third round and correct the artifacts --- CLAUDE.md | 3 +- briefs/M1.1.12-character-controller.md | 111 +++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b56e65d0..2b5903a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +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. **IT SHIPS ONE KNOWN, MEASURED, PINNED, UNFIXED FUNCTIONAL DEFECT** — a character whose base is placed EXACTLY tangent to a surface serves no horizontal motion, permanently, and that is recorded here rather than left for a reader to discover: at exact tangency the sweep reports a contact at distance zero, `paddedAdvance` returns zero, and the slide returns a horizontal motion projected on a horizontal plane unchanged, so all four iterations are consumed and the remainder is DROPPED. Unreachable from play (every move leaves the character `padding` above its floor) and reachable from AUTHORING. Pinned at seven heights in BOTH directions — only exactly zero fails, `0.005` through `0.05` serve in full — mitigated by a documented precondition on `CharacterDescriptor.position`, with two candidate fixes and two refuted ones in `CLAUDE.md`, owned by the next milestone that opens `character.zig`. 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. TWO ROUNDS OF EXTERNAL REVIEW AFTER THE INTERNAL GATES CLOSED, seven 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; and, 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. 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 exact-tangency defect above, owned and specified; 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. | +| `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. 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) @@ -145,7 +145,6 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **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. -- **A character based EXACTLY tangent to a surface never moves — DELIVERED OPEN at M1.1.12**: a functional defect, measured, pinned at seven heights in both directions, NOT fixed. At exact tangency the horizontal sweep reports a contact at distance zero, `paddedAdvance` returns `max(0, 0 − padding) = 0`, and the slide returns a horizontal motion projected on a horizontal plane unchanged — so neither position nor remaining moves, all four slide iterations are consumed and the remainder is DROPPED. Permanent, since the state never leaves zero. MEASURED: only exactly zero fails; `0.005` through `0.05` serve in full, so the trigger is exact tangency and not "below `padding`". Not reachable from play (every move leaves the character at `padding` above its floor) and reachable from AUTHORING, `position = (0,0,0)` over a floor at `y = 0` being the natural thing to write. Mitigated only by a documented precondition on `CharacterDescriptor.position`. TWO candidate fixes, both measured or costed so the owner does not re-derive them: (a) a per-call EXCLUSION of the non-obstructing body threaded through `sweepNearest`, letting the sweep report the SECOND-nearest hit — costed at a signature change on the module's hottest private helper plus its five call sites in one file, no published surface affected; (b) establishing the `padding` stand-off at the ENTRY of the move, since depenetration today bites only on a strictly positive penetration while the stand-off is a resting invariant nothing establishes except `paddedAdvance`, which cannot when the advance is zero. Not arbitrated: (b) touches the depenetration contract, hence the squeeze, the corridor and the step's landing. Two refuted forms 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, the sweep reporting only the plane at distance zero. **Owner: the next milestone that opens `character.zig`.** - **`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 diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index a7e00123..6736731f 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -1826,22 +1826,24 @@ The NGS-watch entry's M1.1.12 clause becomes the measurement it asked for; its M | 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** — measured, pinned at seven heights in both directions, mitigated by a precondition, NOT fixed | **the next milestone that opens `character.zig`** | +| 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 | -#### Why consignation 4 is acceptable to ship, stated rather than dressed up +#### Consignation 4 was going to ship open, and that decision was WRONG — superseded -Three reasons, and none of them is "it is small". It is **not reachable from play** — every move leaves -the character `padding` above its floor, measured, so the state does not reproduce itself and only -authoring reaches it. It is **pinned at seven heights with both directions**, so the fix is testable in -advance and the regression is guarded; a measured and pinned defect is not the same object as an unknown -one. And the deferral **forces no refactoring**, which the deferral rule requires checking: -`sweepNearest` is private to `character.zig`, no published surface sees it, and its five call sites are -in that one file — this is not the inter-module churn the rule protects against. +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 @@ -1854,3 +1856,94 @@ in that one file — this is not the inter-module churn the rule protects agains `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 | From 3bee325dd468b25a5b041ea4e6e4bff0621c5853 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 16:37:01 +0200 Subject: [PATCH 046/100] fix(forge): floor the depenetration stand-off so zero padding works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `penetration + padding` with `padding = 0` and a tangency of `−0.0` moves the capsule by nothing, so the freeze closed last round was intact on that path — and zero is in the domain deliberately, since it means "no physical margin". Removing it would mask the defect instead of closing it. The target becomes `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. With `padding = 0` a resting character stands that floor above its floor, which is correct — zero means no physical margin, not "stand inside the band where the classification decides the verdict frame to frame". That constant is the one place in this module where §1.11.2's tolerance discipline DOES govern, and it says so at its declaration: `padding`, `max_slope` and `predictive_contact_distance` are named physical parameters the discipline deliberately does not reach, and this module spends its length saying so. The `depenetrate` comment is rewritten from an accepted limit into the contract it now is: `padding` is what a sweep RESERVES, the pose invariant being only that the capsule is never left inside the contact margin, and an authored pose closer than `padding` but clear of the margin is deliberately not normalised. --- src/modules/forge/forge_3d/character.zig | 51 +++++++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 5c2e1824..43439991 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -448,6 +448,18 @@ pub fn groundSweepDistance(c: Character) Real { /// 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. @@ -1095,6 +1107,17 @@ fn plannedPush( /// 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; +} + fn depenetrate( bp: *const Broadphase, bm: *const BodyManager, @@ -1150,12 +1173,28 @@ fn depenetrate( // 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. // - // **The branch cannot over-fire, and that is bounded by CONSTRUCTION and not by a threshold**: - // a manifold exists only where the separation is at most the contact margin — `gjk.zig` - // classifies anything beyond it `.separated` and `collideOrdered` answers null there — so a - // capsule already standing off, even by 0.005, is invisible to this query and is not moved. - // TRACED at four heights: 0.005 and 0.02 produce no contact at all. - centre = centre.add(c.normal.scale(c.penetration + padding)); + // **A capsule already standing off is not moved, and that is the CONTRACT rather than a happy + // bound of the implementation.** `padding` is what a SWEEP RESERVES — a move leaves the capsule + // `padding` clear of what it touched, which is all `paddedAdvance` establishes and all the + // reference promises, `mCharacterPadding` being a parameter of `CastShape`/`CollideShape`. The + // POSE invariant is narrower: the controller never leaves the capsule INSIDE the contact + // margin, where the GJK band decides the verdict from one frame to the next. 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. TRACED: 0.005 and 0.02 produce no + // contact at all, and 0.005 already serves a whole metre. + // **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 = @max(padding, standoff_floor_k * std.math.floatEps(Real) * coordScale(probe, centre)); + centre = centre.add(c.normal.scale(c.penetration + target)); } return centre; } From 746193d3bf38d10c8a534ffed7fce21f20d4299e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 16:37:02 +0200 Subject: [PATCH 047/100] test(forge): pin zero padding, and a 1 km limit that is not its residual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero padding serves every call over three calls at six starting heights and against a 5 m collider. And a separate limit, found while probing the floor: at a 1 km collider the move stalls from the second call at f32, the character reading `.in_air`. My first version asserted this as a zero-padding residual of the floor and BOTH halves of that framing were refuted by measurement — it happens at the default padding as much as at zero, and with the floor removed the numbers are identical to the last digit. It is a large-collider precision limit of the §1.11.4 bis class: the box face at zero is known only to `ulp(1000)`, the pair's contact margin is 5.2e-3, and f64 serves every call. Pinned with a 5 m contrast so it reads as a scale statement and not a box statement. A second error of mine, also caught by measurement: the default-padding leg put a second character in the first one's world, and self-exclusion being unilateral it collided with that presence and read 0.38 m on a call it expected to serve in full. A separate world, with the reason at the site. --- CLAUDE.md | 2 +- briefs/M1.1.12-character-controller.md | 86 +++++++++++++ .../forge/forge_3d/tests/character_test.zig | 119 ++++++++++++++++++ 3 files changed, 206 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2b5903a2..fa976cf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +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. 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. | +| `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. And a SEPARATE limit surfaced while probing that floor and was measured NOT to be caused by it: against a 1 km collider at f32 the character reads `.in_air` and the move stalls from the second call, at the DEFAULT padding as much as at zero, with numbers identical to the state before the floor — a large-collider precision limit of the §1.11.4 bis class, pinned with its 5 m contrast and its f64 leg, which serves every call. 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) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 6736731f..af06fb22 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -1947,3 +1947,89 @@ review reopened it. Two correction rounds became three. | `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 | diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index de1f9870..24ec9da7 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -2943,3 +2943,122 @@ test "a base EXACTLY on the floor is no longer frozen — seven heights, both di ); } } + +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 the move at f32 — NOT the tangency defect, and not padding" { + const gpa = testing.allocator; + + // **A SEPARATE, PRE-EXISTING LIMIT, found while probing the stand-off floor and MEASURED not to be + // caused by it.** With the floor removed — the exact state of the previous push — the numbers are + // identical, and it happens at the DEFAULT `padding = 0.02` as much as at zero, so it is neither a + // residual of the floor nor a property of `padding`. My first version of this test asserted it as a + // zero-padding residual and was wrong; the default-padding leg refuted it. + // + // What it is: a large-collider precision limit at f32, the class `engine-physics-forge.md` + // §1.11.4 bis already characterises and deliberately does not fix. A 1 km box centred at + // `y = −1000` has its top face at zero known only to `ulp(1000) = 6.1e-5`, and the pair's contact + // margin is `16 · floatEps(f32) · 2733.6 = 5.2e-3`. The character rests 0.02 above the face, the + // ground probe does not find it, the verdict reads `.in_air`, and from the second call the move + // serves nothing. + // + // At f64 the same scene serves every call, which is what identifies the cause as precision rather + // than geometry — and `-Dphysics_f64` is exactly Phase 1's answer to this class. + for ([_]f32{ 0.02, 0 }) |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, 0); + desc.padding = pad; + const id = try addMover(gpa, &world, &chars, desc); + + // The FIRST call is served at both precisions: the stall needs the entry pose to be the one the + // previous call left. + 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 CONTRAST that makes the above a SCALE statement and not a box statement: the same geometry at + // 5 m serves every call at both precisions and at both paddings. Without this leg the test would + // read as "boxes stall the move", which is false. + for ([_]f32{ 0.02, 0 }) |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, 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]; + } + } +} From f42fbec0f6b77a98d349213c880b3044de27f7d6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 17:14:41 +0200 Subject: [PATCH 048/100] fix(forge): bound padding above, and give every advance the stand-off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `padding` had no upper bound. `max(0, d - padding)` zeroes every advance once padding is large, and the same value enters the depenetration target, so a character created near a wall is displaced by it. `padding >= radius` is refused at creation and at the resize: the relation is a domain of the pair, so shrinking the radius violates it while neither field is individually out of range. The motive is geometric — a stand-off shell thicker than the radius it surrounds is incoherent, the reference carrying 0.02 against 0.3. And the domain table found a bound that froze: `padding = 0` with any downward component in the displacement served 0.2 m once and 0.0001 after. Two diagnoses were prescribed from reasoning and refuted by measurement — the depenetration was already floored, and flooring the descent closed the horizontal cases only. Traced, the mechanism is neither: the advance runs along the DIAGONAL, so its own downward component lands the capsule exactly tangent inside the slide loop, after which three iterations advance nothing and the remainder is dropped. So the fix is the class, not the instance: all five `paddedAdvance` call sites take `standoffTarget` — the slide, the step's lift, forward and land, and the step-down probe. When the same defect appears twice, sweep the class. --- src/modules/forge/api/types.zig | 9 ++++ src/modules/forge/forge_3d/character.zig | 59 +++++++++++++++++++++--- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/modules/forge/api/types.zig b/src/modules/forge/api/types.zig index 44e02d34..5b4b66fc 100644 --- a/src/modules/forge/api/types.zig +++ b/src/modules/forge/api/types.zig @@ -374,6 +374,15 @@ pub const CharacterDescriptor = struct { /// 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, radius)`, and BOTH ends are load-bearing — both found by enumerating the + /// legal bounds and asking what the code does at each, not by intuition. Zero is legal and + /// means no PHYSICAL margin, the solver then holding the capsule off by a numerical floor so + /// that the classification band cannot decide the verdict from frame to frame. And + /// `padding >= radius` is REFUSED: `max(0, d − padding)` is zero at every sweep once + /// `padding` exceeds a call's displacement, so the character stops moving. A stand-off shell + /// thicker than the radius it surrounds is incoherent, and the reference carries 0.02 + /// against 0.3. padding: f32 = 0.02, /// How far OUTSIDE the shape to sweep for contacts not yet touching (metres). The diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 43439991..4fca7cb9 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -211,6 +211,20 @@ fn validateDescriptor(desc: CharacterDescriptor) CharacterError!void { // 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 AN UPPER BOUND, whose absence reproduced the freeze from the other end.** + // `paddedAdvance` returns `max(0, d − padding)`, so once `padding` exceeds a call's + // displacement the advance is zero at every sweep and the character stops moving — the same + // class the stand-off floor just closed at the BOTTOM of this range, through the other door + // of the same parameter. It does not take an absurd value: `padding = 0.5` against 0.1 m per + // tick (6 m/s at 60 Hz) freezes the character the moment a sweep finds anything within half + // a metre, and 0.5 is a factor-of-25 slip, not a delirious entry. The same `padding` enters + // the depenetration target, so it also catapults a character created near a wall by 0.5 m. + // + // A RELATION between fields, refused by typed error and never clamped — the same treatment as + // `height >= 2 · radius`, and reachable the same way, by a wrong entry rather than by the + // default. The motive is geometric: a stand-off shell thicker than the radius of the shape it + // surrounds is incoherent, and the reference carries 0.02 against a radius of 0.3. + if (desc.padding >= desc.radius) return error.InvalidPadding; if (!std.math.isFinite(desc.mass)) return error.InvalidPushParameters; // Zero would DUPLICATE `max_push_force = 0`, which is the documented way to disable @@ -946,7 +960,7 @@ fn tryStepUp( // 1 — lift. const up_hit = sweepNearest(bp, bm, store, record, probe, centre, up, c.step_height, c.layer_mask, c.inner_body); if (up_hit) |h| touched.add(h.body); - const lift = paddedAdvance(up_hit, c.step_height, c.padding); + 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)); @@ -954,7 +968,7 @@ fn tryStepUp( // 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); if (fwd_hit) |h| touched.add(h.body); - const forward_advance = paddedAdvance(fwd_hit, remaining_distance, c.padding); + 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)); @@ -962,7 +976,7 @@ fn tryStepUp( // 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) orelse return null; touched.add(down_hit.body); - const drop = paddedAdvance(down_hit, lift + c.step_height, c.padding); + 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. @@ -1022,7 +1036,10 @@ fn stepDown( 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); - return centre.sub(up.scale(paddedAdvance(hit, c.step_height, c.padding))); + // 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. @@ -1118,6 +1135,19 @@ 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 { + return @max(padding, standoff_floor_k * std.math.floatEps(Real) * coordScale(probe, centre)); +} + fn depenetrate( bp: *const Broadphase, bm: *const BodyManager, @@ -1193,7 +1223,7 @@ fn depenetrate( // `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 = @max(padding, standoff_floor_k * std.math.floatEps(Real) * coordScale(probe, centre)); + const target = standoffTarget(padding, probe, centre); centre = centre.add(c.normal.scale(c.penetration + target)); } return centre; @@ -1463,9 +1493,20 @@ pub const CharacterStore = struct { }; touched.add(hit.body); - // Advance to `padding` SHORT of the surface, clamped at zero so a contact already + // 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. - const advance = paddedAdvance(hit, distance, c.padding); + // + // **`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)); @@ -1596,6 +1637,10 @@ pub const CharacterStore = struct { 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; + // `padding < radius` is a domain of the PAIR, so SHRINKING the radius can violate it while + // neither field is individually out of range — which is exactly why the resize has to + // re-check it and not only the three length bounds. Same door, same typed refusal. + if (self.characters.items[idx].padding >= radius) return error.InvalidPadding; const c = self.characters.items[idx]; const new_shape = try store.createShape(gpa, .{ .capsule = .{ From 3af40a53c7f5908eafc3a854a0dd49c7cc16c46a Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 17:14:42 +0200 Subject: [PATCH 049/100] test(forge): pin the padding bound and add the descriptor domain table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The padding relation refused at both doors, with both ends of the legal domain exercised so the guard cannot be read as "padding must be small", and the resize re-check pinned on a shrink that violates the pair while neither field is individually out of range. And a DOMAIN TABLE: every descriptor parameter at its legal bounds, measured on one scene and asserted rather than reported. No gate of this milestone enumerated those 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. After the class 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, and every row ends grounded. Coverage stated rather than implied: of the four new mechanisms three fail a test when removed, and the `stepDown` site does not — the slide site's floor already prevents the tangency it would recreate, so it is covered by the contract and not by an assertion. --- briefs/M1.1.12-character-controller.md | 85 ++++++++++ .../forge/forge_3d/tests/character_test.zig | 152 ++++++++++++++++++ 2 files changed, 237 insertions(+) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index af06fb22..f0d54322 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2033,3 +2033,88 @@ is written at the site. | `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 | diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 24ec9da7..1ebb3aa4 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3062,3 +3062,155 @@ test "a 1 km collider stalls the move at f32 — NOT the tangency defect, and no } } } + +test "padding has an UPPER bound too, refused at creation and at resize" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + var bp = Bp.init(.{}); + defer bp.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + // A stand-off shell thicker than the radius of the shape it surrounds is geometrically + // incoherent, and the reference carries 0.02 against a radius of 0.3 — two orders below. A + // RELATION between fields, refused by typed error and never clamped, the same treatment as + // `height >= 2 · radius`. + for ([_]f32{ 0.3, 0.5, 1.0 }) |bad| { + var d = baseDescriptor(); + d.padding = bad; // radius is 0.3 + try testing.expectError(CharacterError.InvalidPadding, chars.createCharacter(gpa, &store, &bm, d)); + } + // Just inside is legal, and so is zero — both ends of the domain are exercised here so the + // guard cannot be read as "padding must be small". + for ([_]f32{ 0, 0.29999 }) |ok| { + var d = baseDescriptor(); + d.padding = ok; + const id = try chars.createCharacter(gpa, &store, &bm, d); + chars.destroyCharacter(gpa, &bp, &store, &bm, id); + } + + // **THE RESIZE MUST RE-CHECK IT, because the relation is a domain of the PAIR**: shrinking the + // radius under a legal padding violates it while neither field is individually out of range. + var d = baseDescriptor(); + d.padding = 0.25; + const id = try chars.createCharacter(gpa, &store, &bm, d); + // 0.25 padding against a 0.2 radius — refused, and nothing changed. + try testing.expectError( + CharacterError.InvalidPadding, + chars.resizeCharacter(gpa, &bp, &bm, &store, id, 0.2, 1.8), + ); + try testing.expectApproxEqAbs(@as(Real, 0.3), chars.get(id).?.radius, api_tol); + // And a radius that still clears the padding is accepted. + try testing.expectEqual(true, try chars.resizeCharacter(gpa, &bp, &bm, &store, id, 0.26, 1.8)); +} + +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 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 }, + // 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).?); + } +} From 9b828bfd2d51acadaf2be39c4a0c70d4877bdedc Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 19:15:01 +0200 Subject: [PATCH 050/100] fix(forge): scope the stand-off floor, and drop the padding guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@max(padding, floor)` let the floor 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. Now the floor serves only a caller who asked for no stand-off; a non-zero padding is honoured exactly and is invariant under translation. And the `padding < radius` guard is REMOVED at both doors. Its freeze motive was already conceded — a large padding stopping the character short IS the declared stand-off. The remaining motive, that a push larger than the shape might carry the capsule through thin geometry, was measured across fourteen configurations: padding at 2x and 3.3x the radius, against a 0.1 m wall, at seven entry depths straddling its mid-plane. The capsule exits on the side it entered from every time — the push-out invariant reverts to the entry pose whatever the push size. The guard rejected valid descriptors on no basis. The `depenetrate` comment no longer asserts a narrowed contract the spec does not carry. An implementation comment cannot redefine a normative document, so it records the real state instead: the code is ahead of the spec until the narrowing lands upstream. --- src/modules/forge/api/types.zig | 18 ++++--- src/modules/forge/forge_3d/character.zig | 66 ++++++++++++++---------- 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/src/modules/forge/api/types.zig b/src/modules/forge/api/types.zig index 5b4b66fc..0b6ab4da 100644 --- a/src/modules/forge/api/types.zig +++ b/src/modules/forge/api/types.zig @@ -375,14 +375,16 @@ pub const CharacterDescriptor = struct { /// then decides the verdict from one frame to the next. The reference's /// `mCharacterPadding` value. /// - /// Domain `[0, radius)`, and BOTH ends are load-bearing — both found by enumerating the - /// legal bounds and asking what the code does at each, not by intuition. Zero is legal and - /// means no PHYSICAL margin, the solver then holding the capsule off by a numerical floor so - /// that the classification band cannot decide the verdict from frame to frame. And - /// `padding >= radius` is REFUSED: `max(0, d − padding)` is zero at every sweep once - /// `padding` exceeds a call's displacement, so the character stops moving. A stand-off shell - /// thicker than the radius it surrounds is incoherent, and the reference carries 0.02 - /// against 0.3. + /// 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 diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 4fca7cb9..46438843 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -211,20 +211,19 @@ fn validateDescriptor(desc: CharacterDescriptor) CharacterError!void { // 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 AN UPPER BOUND, whose absence reproduced the freeze from the other end.** - // `paddedAdvance` returns `max(0, d − padding)`, so once `padding` exceeds a call's - // displacement the advance is zero at every sweep and the character stops moving — the same - // class the stand-off floor just closed at the BOTTOM of this range, through the other door - // of the same parameter. It does not take an absurd value: `padding = 0.5` against 0.1 m per - // tick (6 m/s at 60 Hz) freezes the character the moment a sweep finds anything within half - // a metre, and 0.5 is a factor-of-25 slip, not a delirious entry. The same `padding` enters - // the depenetration target, so it also catapults a character created near a wall by 0.5 m. + // **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. // - // A RELATION between fields, refused by typed error and never clamped — the same treatment as - // `height >= 2 · radius`, and reachable the same way, by a wrong entry rather than by the - // default. The motive is geometric: a stand-off shell thicker than the radius of the shape it - // surrounds is incoherent, and the reference carries 0.02 against a radius of 0.3. - if (desc.padding >= desc.radius) return error.InvalidPadding; + // 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 @@ -1145,7 +1144,22 @@ fn coordScale(probe: SupportShape, centre: Vec3r) Real { /// 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 { - return @max(padding, standoff_floor_k * std.math.floatEps(Real) * coordScale(probe, centre)); + // **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( @@ -1203,15 +1217,17 @@ fn depenetrate( // 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, and that is the CONTRACT rather than a happy - // bound of the implementation.** `padding` is what a SWEEP RESERVES — a move leaves the capsule - // `padding` clear of what it touched, which is all `paddedAdvance` establishes and all the - // reference promises, `mCharacterPadding` being a parameter of `CastShape`/`CollideShape`. The - // POSE invariant is narrower: the controller never leaves the capsule INSIDE the contact - // margin, where the GJK band decides the verdict from one frame to the next. 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. TRACED: 0.005 and 0.02 produce no - // contact at all, and 0.005 already serves a whole metre. + // 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 @@ -1637,10 +1653,6 @@ pub const CharacterStore = struct { 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; - // `padding < radius` is a domain of the PAIR, so SHRINKING the radius can violate it while - // neither field is individually out of range — which is exactly why the resize has to - // re-check it and not only the three length bounds. Same door, same typed refusal. - if (self.characters.items[idx].padding >= radius) return error.InvalidPadding; const c = self.characters.items[idx]; const new_shape = try store.createShape(gpa, .{ .capsule = .{ From bbafa6c17628e1dd1ea2b3465ac5d819d09b2537 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 5 Aug 2026 19:15:02 +0200 Subject: [PATCH 051/100] test(forge): pin the floor's scope and the stall's collider-size bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-zero padding is honoured exactly over five decades of distance — 0 to 50 km against a half-space floor, the whole metre every call at both precisions. And the stall regime is a COLLIDER SIZE, not a distance, which is what the measurement corrected: the prediction put it beyond 10 km, and there is no stall at 50 km because `gjk.zig`'s coordScale is relative, `|dpos| + coreExtent(a) + coreExtent(b)`, so it does not grow with distance from the origin. Swept at f32: 100 m half-extent serves every call, 300 m stalls on the third, 500 m and above from the second; at f64, 2 km still serves. Pinned at 100 and 500 with the f64 leg, which makes it a precision regime and subsumes the 1 km row as one point of a curve. The padding guard's test is removed with the guard, and the domain table gains a `padding = 2 * radius` row in its place — legal, stopping short exactly as `r - eps` does. --- briefs/M1.1.12-character-controller.md | 63 ++++++++++ .../forge/forge_3d/tests/character_test.zig | 109 +++++++++++------- 2 files changed, 128 insertions(+), 44 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index f0d54322..54461e46 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2118,3 +2118,66 @@ ends of `padding` fell into that hole. | `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 | diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 1ebb3aa4..8ac443ae 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3063,50 +3063,6 @@ test "a 1 km collider stalls the move at f32 — NOT the tangency defect, and no } } -test "padding has an UPPER bound too, refused at creation and at resize" { - const gpa = testing.allocator; - var store: ShapeStore = .{}; - defer store.deinit(gpa); - var bm: BodyManager = .{}; - defer bm.deinit(gpa); - var bp = Bp.init(.{}); - defer bp.deinit(gpa); - var chars: CharacterStore = .{}; - defer chars.deinit(gpa); - - // A stand-off shell thicker than the radius of the shape it surrounds is geometrically - // incoherent, and the reference carries 0.02 against a radius of 0.3 — two orders below. A - // RELATION between fields, refused by typed error and never clamped, the same treatment as - // `height >= 2 · radius`. - for ([_]f32{ 0.3, 0.5, 1.0 }) |bad| { - var d = baseDescriptor(); - d.padding = bad; // radius is 0.3 - try testing.expectError(CharacterError.InvalidPadding, chars.createCharacter(gpa, &store, &bm, d)); - } - // Just inside is legal, and so is zero — both ends of the domain are exercised here so the - // guard cannot be read as "padding must be small". - for ([_]f32{ 0, 0.29999 }) |ok| { - var d = baseDescriptor(); - d.padding = ok; - const id = try chars.createCharacter(gpa, &store, &bm, d); - chars.destroyCharacter(gpa, &bp, &store, &bm, id); - } - - // **THE RESIZE MUST RE-CHECK IT, because the relation is a domain of the PAIR**: shrinking the - // radius under a legal padding violates it while neither field is individually out of range. - var d = baseDescriptor(); - d.padding = 0.25; - const id = try chars.createCharacter(gpa, &store, &bm, d); - // 0.25 padding against a 0.2 radius — refused, and nothing changed. - try testing.expectError( - CharacterError.InvalidPadding, - chars.resizeCharacter(gpa, &bp, &bm, &store, id, 0.2, 1.8), - ); - try testing.expectApproxEqAbs(@as(Real, 0.3), chars.get(id).?.radius, api_tol); - // And a radius that still clears the padding is accepted. - try testing.expectEqual(true, try chars.resizeCharacter(gpa, &bp, &bm, &store, id, 0.26, 1.8)); -} - test "DOMAIN TABLE — measured behaviour at every legal bound of the descriptor" { const gpa = testing.allocator; @@ -3133,6 +3089,9 @@ test "DOMAIN TABLE — measured behaviour at every legal bound of the descriptor 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; } @@ -3168,6 +3127,8 @@ test "DOMAIN TABLE — measured behaviour at every legal bound of the descriptor // 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. @@ -3214,3 +3175,63 @@ test "DOMAIN TABLE — measured behaviour at every legal bound of the descriptor 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. + for ([_]f32{ 0, 1000, 5000, 20000, 50000 }) |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); + var desc = baseDescriptor(); + desc.entity = ent(931); + desc.position = av(x0, 0.02, 0); + const id = try addMover(gpa, &world, &chars, desc); + var prev: Real = x0; + 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(prev + 1, r.position.toArray()[0], @max(api_tol, @abs(prev) * 1e-6)); + prev = r.position.toArray()[0]; + } + } + + // **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); + } +} From cbbe979a75cc85c6a36c4b26f926dee987aca07d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 6 Aug 2026 08:03:36 +0200 Subject: [PATCH 052/100] fix(forge): a non-opposing contact no longer consumes the slide budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last four rounds each closed one way of arriving at exact tangency, and another path appeared each time, because arrival is a float-resolution phenomenon with as many paths as one likes. What is finite is what tangency then does, and it does it 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, the next iteration finds the same contact, and the budget burns with the remainder dropped. That body is now set aside for ONE retry that does not spend the budget, and the sweep reports the next real obstacle. Traced first: with the resting floor set aside and self-exclusion kept, the sweep returns null and the whole remaining metre is free. `SweepCollector` gains `exclude_also`, `sweepNearest` gains the parameter, and the four non-slide call sites pass null so their contract is unchanged in meaning. Bounded by construction — one slot, one free retry per call — and with no epsilon: the advance test is exact zero and the opposition test is the sign of a dot product. The round-2 estimate that this needed threading through five call sites was made without reading `SweepCollector`, which already carried an exclusion. 48 lines. --- src/modules/forge/forge_3d/character.zig | 54 +++++++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 46438843..789bed38 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -612,6 +612,9 @@ const SweepCollector = struct { 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, @@ -624,6 +627,9 @@ const SweepCollector = struct { 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; @@ -890,6 +896,10 @@ fn sweepNearest( 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, @@ -900,6 +910,7 @@ fn sweepNearest( .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); @@ -957,7 +968,7 @@ fn tryStepUp( 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); + 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; @@ -965,7 +976,7 @@ fn tryStepUp( // 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); + 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; @@ -973,7 +984,7 @@ fn tryStepUp( // 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) orelse return null; + 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)); @@ -1031,7 +1042,7 @@ fn stepDown( 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) orelse return centre; + 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); @@ -1491,8 +1502,30 @@ pub const CharacterStore = struct { 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 < max_slide_iterations) : (iteration += 1) { + 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. @@ -1500,7 +1533,7 @@ pub const CharacterStore = struct { 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); + 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); @@ -1532,6 +1565,15 @@ pub const CharacterStore = struct { 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. From 37006779d901cd5968d2743867dd015206307bd3 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 6 Aug 2026 08:03:38 +0200 Subject: [PATCH 053/100] test(forge): flip three stall pins, and fix a vacuous invariance test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The squeeze and the doorway both pinned a stall that is now gone: a ceiling's downward normal does not oppose a horizontal motion, and two antiparallel wall normals do not oppose a motion along the corridor. Both keep the assertion they exist for — never through the floor, never ejected — and those were checked before the numbers were touched. The 1 km collider was expected to flip too and does NOT, and the trace says why: `slideNormal` returns null at that scale, so the loop takes its documented "stop rather than guess a direction" exit and never reaches the non-opposing test. Serving it would mean inventing a direction the narrowphase declines to supply. The test keeps its stall expectation and gains the real cause. `padding = floatMin` is added at both precisions on the 5 m scene. And the distance-invariance test was VACUOUS — restoring `@max(padding, floor)` broke nothing, because starting at `y = padding` with a horizontal motion and asserting `x` alone never exercises `standoffTarget`. It now walks into a wall and asserts the final clearance: at 5 km the floor is 3.81e-2, so `@max` stops 3.8 cm short where the caller asked for 2. Eighth green assertion in this milestone that proved nothing, and the first found by external review rather than by the probe table. --- briefs/M1.1.12-character-controller.md | 91 +++++++++++++++ .../forge/forge_3d/tests/character_test.zig | 110 +++++++++--------- 2 files changed, 149 insertions(+), 52 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 54461e46..5e8221bb 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2181,3 +2181,94 @@ the contract and not by an assertion — stated in the previous round and unchan | `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 | diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 8ac443ae..32cfbe12 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -2295,12 +2295,16 @@ test "resize and teleport both wake what their new volume reaches" { // F.0bis — the unguarded squeeze mode, MEASURED, and the step path's padding // --------------------------------------------------------------------------- -test "a character squeezed under a low ceiling is PINNED, never driven through the floor" { +test "a character squeezed under a low ceiling WALKS, and is never driven through the floor" { const gpa = testing.allocator; - // A capsule needing 1.8 m of headroom under a ceiling offering 1.0 m, and the same at 1.7 m — - // any deficit at all, not just a large one. Both are unresolvable: no pose satisfies both - // surfaces, so what is measured is the FAILURE DIRECTION. + // **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); @@ -2314,28 +2318,18 @@ test "a character squeezed under a low ceiling is PINNED, never driven through t desc.position = av(0, 0.02, 0); const id = try addMover(gpa, &world, &chars, desc); - // TWO calls, because a mode that is stable for one call and drifts on the next is the - // dangerous one: the caller keeps asking, and a per-call bias accumulates. - var previous: ?Vec3r = null; + 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 character KEEPS THE POSE IT CAME IN WITH, with a residual overlap into the - // ceiling, and does not tunnel — which is the failure direction §1.12.6's depenetration - // invariant makes sayable. The horizontal request is consumed entirely by the four slide - // iterations against the ceiling's downward normal without ever being served, and the - // depenetration reverts rather than push the base through the floor. So: PINNED at its - // entry base of 0.02, not flush at 0, and never below. - try testing.expectApproxEqAbs(@as(Real, 0), p[0], api_tol); + // 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); - // NEVER below the ground plane, and this no longer depends on the iteration count's - // parity: see the invariant on `depenetrate` and the parity record below. try testing.expect(p[1] >= -api_tol); - // And it does not drift: call two is bit-identical to call one. - if (previous) |q| try testing.expect(r.position.eql(q)); - previous = r.position; } } } @@ -2571,9 +2565,13 @@ test "a doorway narrower than the character NEVER ejects it, whatever the iterat // INSIDE the doorway — the assertion that matters, and the one a tunnelling // depenetration would break. try testing.expect(@abs(p[0]) < width / 2); - // The base is unmoved and the +Z request unserved: pinned, not ejected. try testing.expectApproxEqAbs(@as(Real, 0.02), p[1], api_tol); - try testing.expectApproxEqAbs(@as(Real, 0), p[2], 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 @@ -2999,25 +2997,20 @@ test "the stand-off floor serves a zero padding, and its scale limit is MEASURED } } -test "a 1 km collider stalls the move at f32 — NOT the tangency defect, and not padding" { +test "a 1 km collider stalls because slideNormal cannot resolve — a FOURTH mechanism" { const gpa = testing.allocator; - // **A SEPARATE, PRE-EXISTING LIMIT, found while probing the stand-off floor and MEASURED not to be - // caused by it.** With the floor removed — the exact state of the previous push — the numbers are - // identical, and it happens at the DEFAULT `padding = 0.02` as much as at zero, so it is neither a - // residual of the floor nor a property of `padding`. My first version of this test asserted it as a - // zero-padding residual and was wrong; the default-padding leg refuted it. - // - // What it is: a large-collider precision limit at f32, the class `engine-physics-forge.md` - // §1.11.4 bis already characterises and deliberately does not fix. A 1 km box centred at - // `y = −1000` has its top face at zero known only to `ulp(1000) = 6.1e-5`, and the pair's contact - // margin is `16 · floatEps(f32) · 2733.6 = 5.2e-3`. The character rests 0.02 above the face, the - // ground probe does not find it, the verdict reads `.in_air`, and from the second call the move - // serves nothing. + // **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. // - // At f64 the same scene serves every call, which is what identifies the cause as precision rather - // than geometry — and `-Dphysics_f64` is exactly Phase 1's answer to this class. - for ([_]f32{ 0.02, 0 }) |pad| { + // 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 = .{}; @@ -3025,12 +3018,10 @@ test "a 1 km collider stalls the move at f32 — NOT the tangency defect, and no _ = 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, 0); + desc.position = av(0, 0.02, 0); desc.padding = pad; const id = try addMover(gpa, &world, &chars, desc); - // The FIRST call is served at both precisions: the stall needs the entry pose to be the one the - // previous call left. 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); @@ -3039,10 +3030,10 @@ test "a 1 km collider stalls the move at f32 — NOT the tangency defect, and no try testing.expectApproxEqAbs(if (Real == f32) @as(Real, 0) else @as(Real, 1), served, api_tol); } - // THE CONTRAST that makes the above a SCALE statement and not a box statement: the same geometry at - // 5 m serves every call at both precisions and at both paddings. Without this leg the test would - // read as "boxes stall the move", which is false. - for ([_]f32{ 0.02, 0 }) |pad| { + // 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 = .{}; @@ -3050,7 +3041,7 @@ test "a 1 km collider stalls the move at f32 — NOT the tangency defect, and no _ = 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, 0); + desc.position = av(0, 0.02, 0); desc.padding = pad; const id = try addMover(gpa, &world, &chars, desc); var previous: Real = 0; @@ -3187,23 +3178,38 @@ test "the stand-off floor never overrides a requested padding, and the stall is // // 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. - for ([_]f32{ 0, 1000, 5000, 20000, 50000 }) |x0| { + // **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); - var prev: Real = x0; + + // Walk into it, generously, so the stop is the wall and not the request running out. 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(prev + 1, r.position.toArray()[0], @max(api_tol, @abs(prev) * 1e-6)); - prev = r.position.toArray()[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 From 068280b0b0fccd76d539f21598dfdd78b0365962 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 6 Aug 2026 09:35:44 +0200 Subject: [PATCH 054/100] docs(claude-md): update for M1.1.12 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index fa976cf7..e3f60eaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +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. And a SEPARATE limit surfaced while probing that floor and was measured NOT to be caused by it: against a 1 km collider at f32 the character reads `.in_air` and the move stalls from the second call, at the DEFAULT padding as much as at zero, with numbers identical to the state before the floor — a large-collider precision limit of the §1.11.4 bis class, pinned with its 5 m contrast and its f64 leg, which serves every call. 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. | +| `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) From 0da341952d70d40ae31d9cae40f8f756949e5962 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Thu, 6 Aug 2026 09:35:45 +0200 Subject: [PATCH 055/100] docs(brief): close M1.1.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten consignations with their owners, the domain table posed as an exigence for the next milestone that declares a descriptor, and the mutation probe written as a method rather than an anecdote: the probe table disables mechanisms and is therefore structurally blind to a test that exercises none, so the counter-measure is to restore the previous implementation and require the test to fail. Item 1 of the closure order is BLOCKED and verified rather than presumed: the deposited `engine-physics-forge.md` carries the milestone-opening mtime 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. The site comment recording the divergence stays. --- briefs/M1.1.12-character-controller.md | 73 ++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 5e8221bb..5432f3bc 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2272,3 +2272,76 @@ site comment acknowledging the divergence is DELETED and replaced by a reference | `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. From 3c16c2a86fe68fe01e8053780c33cbeb9905e703 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 7 Aug 2026 12:58:06 +0200 Subject: [PATCH 056/100] fix(forge): exclude the sub-shape inside the cast, not around it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `castShapeBody` returns ONE hit, the nearest, so filtering its result discarded that sub-shape and every other sub-shape of the body the cast never returned — a traversable wall on a mesh carrying a floor, a wall and a ceiling. Measured: the character walked through to x = 12 at both precisions. The exclusion descends into the traversal. `MeshCastCollector` carries `exclude_triangle` and drops it at the top of `add`, so it never competes for `best`. A delegating sibling `castShapeBodyExcluding` rather than a parameter on the existing entry: the parameter would touch fourteen call sites in inherited test files that are byte-identical to the tag, and one caller needs the exclusion. The pair key `(body, subshape_id)` and its expiry at a direction change are kept — both necessary, simply insufficient alone. For the two single-sub-shape classes, excluding sub-shape 0 excludes the body, which is what setting aside a resting half-space floor means. The divergence comment is gone: both specs are in hand, `engine-tier-interfaces.md` 0.8 and §1.12.6 carrying the narrowed `padding` contract, so the site references the spec instead of recording a gap. --- src/modules/forge/forge_3d/body_manager.zig | 44 +++++++++++ src/modules/forge/forge_3d/character.zig | 87 +++++++++++++-------- 2 files changed, 100 insertions(+), 31 deletions(-) diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 2f80c1fa..0fe8db8a 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -743,6 +743,37 @@ pub const BodyManager = struct { direction: Vec3r, max_distance: Real, back_face_mode: api.BackFaceMode, + ) ?BodyCastHit { + return self.castShapeBodyExcluding(store, id, cast_shape, cast_origin, cast_rotation, direction, max_distance, back_face_mode, null); + } + + /// `castShapeBody` with ONE sub-shape kept out of the competition. + /// + /// **A SIBLING RATHER THAN A PARAMETER, and the reason is measured rather than stylistic.** Adding + /// the argument to `castShapeBody` itself would touch fourteen call sites inside INHERITED test + /// files that are currently byte-identical to the tag — corroborating evidence this milestone has + /// leaned on twice. One caller needs the exclusion; the other three keep the entry they had, and + /// `castShapeBody` delegates here with `null`. + /// + /// **The exclusion is consumed DURING the mesh traversal, not after the cast**, and that placement + /// is the whole point: this entry returns ONE hit, the nearest, so a filter applied to its result + /// discards that sub-shape AND every other sub-shape of the body the cast never returned. Measured + /// — a mesh carrying a floor, a wall and a ceiling let the character walk straight through the + /// wall at `x = 12` when the filter sat above this call, and blocks at `1.7` with it inside. + /// + /// For `.convex` and `.half_space` the only sub-shape IS the body (§1.11.16), so `0` excludes it + /// entirely — which is exactly what a caller setting aside a resting floor half-space means. + pub fn castShapeBodyExcluding( + self: *const BodyManager, + store: *const ShapeStore, + id: BodyId, + cast_shape: narrowphase.SupportShape(Real), + cast_origin: Vec3r, + cast_rotation: Quatr, + direction: Vec3r, + max_distance: Real, + back_face_mode: api.BackFaceMode, + exclude_subshape: ?u32, ) ?BodyCastHit { const idx = self.alloc.validate(id) orelse return null; const shape = store.get(self.bodies.items(.shape)[idx]) orelse return null; @@ -765,6 +796,10 @@ pub const BodyManager = struct { // The sub-shape the mesh arm resolves, and zero for the two arms whose shapes carry // no sub-shape at all (§1.11.16). var subshape_id: u32 = 0; + // The two single-sub-shape classes: excluding sub-shape `0` excludes the body. + if (exclude_subshape) |ex| { + if (ex == 0 and shape.class() != .triangle_soup) return null; + } const hit = switch (shape.class()) { .convex => narrowphase.castShape( Real, @@ -808,6 +843,7 @@ pub const BodyManager = struct { .direction_in_a = local_dir, .sweep_direction_local = sweep_dir, .back_face_mode = back_face_mode, + .exclude_triangle = exclude_subshape, .bound = max_distance, }; _ = data.traverseCast(RayR.init(probe_box.center(), sweep_dir), probe_box.halfExtents(), &collector); @@ -1751,6 +1787,11 @@ const MeshCastCollector = struct { /// Sweep direction in the BODY's local frame — what the facing test takes. sweep_direction_local: Vec3r, back_face_mode: api.BackFaceMode, + /// A triangle to keep out of the competition entirely. Consumed HERE, during the traversal, and + /// not by the caller afterwards: `castShapeBody` returns ONE hit, the nearest, so a filter applied + /// to its result discards that triangle AND every other triangle of the body the cast never + /// returned. That is a traversable wall on a mesh carrying both a floor and a wall. + exclude_triangle: ?u32, bound: Real, /// The KERNEL's hit type, in the probe's frame — not `BodyCastHit`. The shared mapping /// at the end of `castShapeBody` carries every class's answer to world in one place, so @@ -1759,6 +1800,9 @@ const MeshCastCollector = struct { best_triangle: u32 = 0, pub fn add(self: *MeshCastCollector, triangle_index: u32) void { + if (self.exclude_triangle) |ex| { + if (ex == triangle_index) return; + } if (self.back_face_mode == .ignore and narrowphase.triangle.isBackFace( Real, self.data.faceNormal(triangle_index), diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 789bed38..3d7f69bf 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -603,6 +603,11 @@ const Contact = struct { /// 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. +/// One sub-shape of one body — the grain a contact verdict actually has. A body handle alone is +/// coarser than any statement about a contact, and a mesh is the case where that difference is a +/// traversable wall rather than a nuance. +const SubShapeKey = struct { body: BodyId, subshape_id: u32 }; + const SweepCollector = struct { bm: *const BodyManager, store: *const ShapeStore, @@ -612,9 +617,15 @@ const SweepCollector = struct { 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 + /// A second SUB-SHAPE to ignore, used by the slide loop for a contact it has established does not /// oppose the motion. See `sweepNearest`. - exclude_also: ?BodyId = null, + /// + /// **The key is the PAIR `(body, subshape_id)` and not the body, because the verdict it carries is + /// about a CONTACT.** An earlier form excluded the whole body and returned BEFORE `castShapeBody`, + /// so a single mesh carrying both a floor and a wall lost every triangle at once: the floor's + /// triangle was set aside for not opposing the motion, and the wall went with it. A traversable + /// wall, from an exclusion coarser than the verdict that justified it. + exclude_also: ?SubShapeKey = null, best: ?struct { body: BodyId, subshape_id: u32, @@ -627,13 +638,16 @@ const SweepCollector = struct { 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( + // **THE EXCLUSION GOES INTO THE CAST, not around it.** `castShapeBody` returns ONE hit — the + // nearest sub-shape — so filtering its RESULT discards that sub-shape and, with it, every other + // sub-shape of the body the cast never returned. Measured on a mesh carrying a floor, a wall + // and a ceiling: filtering afterwards let the character walk through the wall to `x = 12`; + // excluding inside the traversal blocks it at `1.7`. Three rounds put this filter one level + // too high — body, then pair above the cast — while the sub-shape is chosen one level below. + const hit = self.bm.castShapeBodyExcluding( self.store, body, self.probe, @@ -642,6 +656,7 @@ const SweepCollector = struct { self.direction, self.bound, .ignore, + if (self.exclude_also) |key| (if (key.body == body) key.subshape_id else null) else null, ) orelse return; if (self.best) |b| { @@ -896,10 +911,10 @@ fn sweepNearest( 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, + /// A second SUB-SHAPE to ignore, as the pair `(body, subshape_id)`. 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: ?SubShapeKey, ) ?SweepHit { var collector = SweepCollector{ .bm = bm, @@ -1232,24 +1247,11 @@ fn depenetrate( // 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". + // That is the contract, not a limitation. `engine-physics-forge.md` §1.12.6: `padding` is what a + // SWEEP RESERVES and not an invariant of pose, an authored pose closer than `padding` but clear + // of the contact margin is deliberately not normalised, and the invariant actually held is the + // narrower one — the controller never leaves the capsule inside the contact margin, where the + // GJK band would decide the verdict from one frame to the next. const target = standoffTarget(padding, probe, centre); centre = centre.add(c.normal.scale(c.penetration + target)); } @@ -1522,7 +1524,11 @@ pub const CharacterStore = struct { // 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; + // Held with the DIRECTION it was judged against, and dropped the moment that direction + // changes: "does not oppose" is a statement about a contact AND a direction, so it expires + // when the slide reprojects the motion. Without that, a surface set aside once stayed set + // aside for the rest of the call even after becoming opposing. + var ignored: ?struct { key: SubShapeKey, direction: Vec3r } = null; var budget: u32 = max_slide_iterations; var iteration: u32 = 0; while (iteration < budget) : (iteration += 1) { @@ -1533,7 +1539,23 @@ pub const CharacterStore = struct { 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); + // The exclusion expires with the direction it was judged against. + if (ignored) |ig| { + if (!ig.direction.eql(direction)) ignored = null; + } + const maybe_hit = sweepNearest( + bp, + bm, + store, + record, + probe, + centre, + direction, + distance, + c.layer_mask, + c.inner_body, + if (ignored) |ig| ig.key else null, + ); const hit = maybe_hit orelse { // Nothing in the way: the whole remaining displacement is served. centre = centre.add(remaining); @@ -1569,7 +1591,10 @@ pub const CharacterStore = struct { // 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; + ignored = .{ + .key = .{ .body = hit.body, .subshape_id = hit.subshape_id }, + .direction = direction, + }; budget += 1; continue; } From 543e3aebeb98b11ab474c2a2cf37ea49e1e55334 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 7 Aug 2026 12:58:07 +0200 Subject: [PATCH 057/100] test(forge): split the mesh counter-test along the measured line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counter-test was folding two defects into one assertion. Measured on the previous code with the same scene: the character walks through the wall at BOTH precisions. With the fix it blocks at f32 and freezes at f64 — so the fix strictly improves both, a tunnel becoming a block and a stutter, and the f64 freeze is a separate defect. The tunneling judge is `p[0] < 2`, precision-independent, false at both precisions before and true at both after. The f64 freeze is its own named case at its measured value, labelled a defect with its cause untraced — the treatment the tangency pin had before it was closed. The `floatMin` leg now starts at base zero: it passed before with the retry removed, because starting `padding` above the floor the horizontal sweep never met it. Pinned by mutation. --- briefs/M1.1.12-character-controller.md | 77 ++++++++++ .../forge/forge_3d/tests/character_test.zig | 137 +++++++++++++++++- 2 files changed, 213 insertions(+), 1 deletion(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 5432f3bc..8298c9f0 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2345,3 +2345,80 @@ cost four rounds. A domain table is cheap at the gate that declares the fields a `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. + +### Gate G, eighth closing round — the exclusion descends to the data + +#### The cause, read rather than supposed + +My hypothesis that the pair key was inert is REFUTED: `castShapeBody` fills a distinct `subshape_id` on +the `.triangle_soup` arm. The defect is structural at the filter's LOCATION — **that entry returns ONE +hit**, the nearest, so filtering its result discards that sub-shape and, with it, every other sub-shape +of the body the cast never returned. Third time in this series the correction sat one level above the +data: body, then pair above the cast, while the triangle is chosen one level below. + +The exclusion now descends into the traversal: `MeshCastCollector` carries `exclude_triangle` and drops +it at the top of `add`, so it never competes for `best`. + +**One deviation from the instruction, costed before it was taken.** Adding the parameter to +`castShapeBody` would touch FOURTEEN call sites in INHERITED test files that are byte-identical to the +tag — corroborating evidence this milestone has leaned on twice. A delegating sibling +`castShapeBodyExcluding` changes ONE caller instead, and the byte-identity of all seventeen inherited +files is verified intact. + +#### The counter-test conflated two defects, and the measurement separated them + +Measured on the OLD code with the new scene: the character walks THROUGH the wall at **both** +precisions. With the fix: blocked at 1.699965 at f32, frozen at exactly 0 at f64. + +So the fix strictly improves both — a tunnel became a block at f32 and a stutter at f64, which is this +module's stated failure direction — and the f64 freeze is a SEPARATE defect the test was folding in. My +refusal to make the expectation precision-shaped was right for one defect and wrong for two. + +Split along the line the measurement drew: + +- **The tunneling judge** is `p[0] < 2`, precision-INDEPENDENT, false at both precisions on the old code + and true at both with the fix. +- **The f64 freeze** is its own named case, asserted at its measured value and labelled a defect — the + treatment the tangency pin had before it was closed. Cause untraced. Owner: the next milestone that + opens `character.zig`. + +#### Probes + +| Probe | Exit | Failing | Compile | +|---|---|---|---| +| `filter-above-cast` | 1 | 1 | 0 | +| `body-grain` | 1 | 1 | 0 | +| `no-expiry` | **0** | **0** | 0 | + +`no-expiry` is recorded as uncovered rather than given a scene built for it: the expiry is necessary in +principle — a body set aside can become opposing after a reprojection — and no scene in the suite +exercises it. Building one to close a probe column is not the same as needing one. + +#### The other three points + +- **`floatMin`** now starts at base zero. It passed before with the retry removed, because starting + `padding` above the floor the horizontal sweep never met it and the retry was never reached. Pinned by + mutation: with the retry removed, four tests fail including this one. +- **The cast/manifold disagreement** is TRACED and confirmed: at the 1 km collider `slideNormal` returns + null — the manifold denies the contact — while the cast reports distance zero. Not acted on: extending + the set-aside to a manifold-denied hit changes a deliberate documented refusal, which is a design + change and not a defect fix. Consigned with its owner, and the 1 km stall keeps its now-named cause. +- **The divergence block is GONE.** Both specs are in hand — `engine-tier-interfaces.md` 0.8 and + §1.12.6 carrying the narrowing verbatim — so the site references §1.12.6 instead of recording a gap. + +#### One coverage gap this round exposed in my own matrix + +The Windows CI failed on `zig build`, a target my six-corner matrix never runs: it runs +`test-forge-3d` and `test` at both precisions and never the plain build the CI does first. Added to the +run for this push. The CI failure itself was infrastructure — `failed to spawn build runner … build.exe: +FileNotFound`, same cache hash across three jobs and two reruns, with `build.zig` unchanged since a +fully green run — but the gap it exposed was real and mine. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 486/486 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 486/486 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1658/1675 (17 skipped) ×2 | +| `zig build` (plain) | 0 | the target the CI runs first | diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 32cfbe12..799cfad0 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3041,7 +3041,11 @@ test "a 1 km collider stalls because slideNormal cannot resolve — a FOURTH mec _ = 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); + // **BASE AT EXACTLY ZERO, and the previous `0.02` is why this leg proved nothing.** Starting + // `padding` above the floor, the horizontal sweep never meets it, so the non-opposing retry is + // never reached and the leg passed with the retry removed. Tangent, it is the `floatMin` path + // the `padding > 0` branch opens: a positive padding whose addition changes no bit. + desc.position = av(0, 0, 0); desc.padding = pad; const id = try addMover(gpa, &world, &chars, desc); var previous: Real = 0; @@ -3241,3 +3245,134 @@ test "the stand-off floor never overrides a requested padding, and the stall is try testing.expectApproxEqAbs(if (stalls) @as(Real, 0) else @as(Real, 1), served, api_tol); } } + +test "one mesh carrying both floor and wall: the wall still blocks" { + 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 COUNTER-TEST FOR AN EXCLUSION COARSER THAN ITS VERDICT.** The non-opposing retry sets a + // contact aside; if it set aside the whole BODY, a single mesh carrying a floor and a wall would + // lose the wall along with the floor triangle, and the character would walk straight through it. + // The key is the pair `(body, subshape_id)`, and this is what measures that. + // + // ONE mesh, ONE body: a floor spanning x ∈ [−2, 4] at y = 0, and a wall standing on it at x = 2, + // rising to y = 3. Two quads, four triangles, all in the same index buffer. + const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; + const verts = [_]V{ + // floor at y = 0, wound so the face normal is +Y + .{ .data = .{ -2, 0, -2 } }, // 0 + .{ .data = .{ 4, 0, -2 } }, // 1 + .{ .data = .{ -2, 0, 2 } }, // 2 + .{ .data = .{ 4, 0, 2 } }, // 3 + // wall at x = 2. A mesh is SINGLE-SIDED, so the winding decides whether the wall exists at + // all from where the character comes: a first version wound both triangles to +X and the + // character walked to x = 12, through the wall and off the end of the floor. That was the + // test's own defect, not the engine's, and it is written here because a back-facing wall + // reads exactly like a traversable one. + .{ .data = .{ 2, 3, -2 } }, // 4 + .{ .data = .{ 2, 3, 2 } }, // 5 + .{ .data = .{ 2, 0, -2 } }, // 6 + .{ .data = .{ 2, 0, 2 } }, // 7 + // CEILING at y = 1, normal −Y, low enough to squeeze a 1.8 m capsule. It is what makes the + // retry fire at all: a ceiling's downward normal does not oppose a horizontal motion, so the + // contact is set aside — and it is on the SAME body as the wall, which is the whole point. + .{ .data = .{ -2, 1, -2 } }, // 8 + .{ .data = .{ 4, 1, -2 } }, // 9 + .{ .data = .{ -2, 1, 2 } }, // 10 + .{ .data = .{ 4, 1, 2 } }, // 11 + }; + const tris = [_]u32{ + 0, 2, 1, 1, 2, 3, // floor, +Y + 6, 7, 4, 7, 5, 4, // wall, −X + 8, 9, 10, 9, 11, 10, // ceiling, −Y + }; + const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &tris } }); + const body = try world.addBody(gpa, .{ + .entity = ent(470), + .body_type = .static, + .shape = shape, + }); + _ = try world.bp.insert(gpa, .static, world.bm.bodyAabb(&world.store, body).?, body); + + var desc = baseDescriptor(); + desc.entity = ent(471); + desc.position = av(0, 0, 0); + desc.padding = 0; + const id = try addMover(gpa, &world, &chars, desc); + + // Walk hard into the wall, several calls, so a leak would show as unbounded travel. + var k: u32 = 0; + while (k < 4) : (k += 1) { + _ = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(3, 0, 0), 1.0 / 60.0); + } + const p = chars.get(id).?.position.toArray(); + + // **THE JUDGE OF THE TUNNELING FIX, and it is precision-INDEPENDENT.** Never past the wall. With + // the exclusion applied ABOVE the cast — the state this replaces — the character walks through at + // BOTH precisions, so this single inequality discriminates the fix exactly and needs no per- + // precision shape. + try testing.expect(p[0] < 2); + // Standing on the floor triangle it set aside, not fallen through it. + try testing.expectEqual(api.GroundState.grounded, chars.reportedGround(id).?); +} + +test "the same mesh scene FREEZES at f64 — a separate defect, named and not entrenched" { + const gpa = testing.allocator; + + // **A SECOND DEFECT THAT THE TUNNELING COUNTER-TEST WAS CONFLATING WITH THE FIRST, separated by + // measurement.** On the previous code the character walked THROUGH the wall at both precisions; + // with the exclusion inside the traversal it blocks at f32 and does not move at all at f64. + // + // So the fix strictly improves both — a tunnel became a block at f32 and a stutter at f64, which is + // this module's stated failure direction: a character that does not finish its step is visible, a + // character that finishes it through a wall is a hole in the world. But the f64 half is NOT the + // defect the fix closes, and folding it into that test would have made one assertion answer two + // questions. + // + // Asserted at its MEASURED value and labelled a defect, the same treatment the tangency pin had + // before it was closed and the 1 km stall has now. The cause is untraced: at f64 the base ends at + // exactly `0` where at f32 the numerical floor lifts it to `2.77e-5` and it walks. Owner: the next + // milestone that opens `character.zig`. + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; + const verts = [_]V{ + .{ .data = .{ -2, 0, -2 } }, .{ .data = .{ 4, 0, -2 } }, .{ .data = .{ -2, 0, 2 } }, .{ .data = .{ 4, 0, 2 } }, + .{ .data = .{ 2, 3, -2 } }, .{ .data = .{ 2, 3, 2 } }, .{ .data = .{ 2, 0, -2 } }, .{ .data = .{ 2, 0, 2 } }, + .{ .data = .{ -2, 1, -2 } }, .{ .data = .{ 4, 1, -2 } }, .{ .data = .{ -2, 1, 2 } }, .{ .data = .{ 4, 1, 2 } }, + }; + const tris = [_]u32{ 0, 2, 1, 1, 2, 3, 6, 7, 4, 7, 5, 4, 8, 9, 10, 9, 11, 10 }; + const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &tris } }); + const body = try world.addBody(gpa, .{ .entity = ent(480), .body_type = .static, .shape = shape }); + _ = try world.bp.insert(gpa, .static, world.bm.bodyAabb(&world.store, body).?, body); + + var desc = baseDescriptor(); + desc.entity = ent(481); + desc.position = av(0, 0, 0); + desc.padding = 0; + const id = try addMover(gpa, &world, &chars, desc); + + var k: u32 = 0; + while (k < 4) : (k += 1) { + _ = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(3, 0, 0), 1.0 / 60.0); + } + const p = chars.get(id).?.position.toArray(); + + if (Real == f32) { + // Served, and stopped by the wall: `2 − radius` with no padding. + try testing.expectApproxEqAbs(@as(Real, 1.7), p[0], 1e-4); + try testing.expect(p[0] > 1); + } else { + // **THE DEFECT.** Not a single millimetre, and the base never leaves exact tangency. + try testing.expectApproxEqAbs(@as(Real, 0), p[0], 1e-9); + try testing.expectApproxEqAbs(@as(Real, 0), p[1], 1e-9); + } + // What holds at BOTH precisions, and the reason this is a stutter and not a hole: never past the wall. + try testing.expect(p[0] < 2); +} From 1c91748f286bd6e6ae7df9f883127faf1a63d8af Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 7 Aug 2026 16:31:31 +0200 Subject: [PATCH 058/100] fix(forge): key the set-aside on the normal's sub-shape, and hold a set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A surface is N coplanar triangles and the mechanism set aside ONE sub-shape with ONE retry, having been designed for one contact. `DeepestManifold` discarded `subshape_id`, so the normal justifying a set-aside was the deepest over ALL sub-shapes of the body and need not belong to the one the cast returned — a ceiling's normal could exclude a wall. `Contact` carries it now, `slideNormal` returns the pair, and the retry keys on the sub-shape the NORMAL came from. The single slot becomes `IgnoredSet`, capacity 8, expiring as a whole at a direction change. A full set stops granting free retries and the contact spends its iteration as before: short, never further. It clamps where `TouchedBodies` asserts, and the difference is stated — a dropped wake is a defect, one fewer retry is not. `max_touched` is re-accounted: each retry is a sweep of its own and touches a body, so the term was missing. The suite caught it as an assertion failure rather than a silent drop. The tunneling defect is closed at both precisions: `p[0] < 2` on the floor/wall/ceiling mesh is false at both before and true at both now. The f64 case still does not travel and that is reported, not hidden: revoking the retry would make it worse, the previous code tunnelling at both precisions. --- briefs/M1.1.12-character-controller.md | 52 ++++++++ src/modules/forge/forge_3d/body_manager.zig | 19 +-- src/modules/forge/forge_3d/character.zig | 132 +++++++++++++++----- 3 files changed, 164 insertions(+), 39 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 8298c9f0..829d4e05 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2422,3 +2422,55 @@ fully green run — but the gap it exposed was real and mine. | `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 486/486 ×2 | | `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1658/1675 (17 skipped) ×2 | | `zig build` (plain) | 0 | the target the CI runs first | + +### Gate G, ninth closing round — one cause, a set instead of a slot + +#### The cause is one, and it is the grain of a surface + +A surface is N coplanar triangles; the mechanism set aside ONE sub-shape with ONE retry, having been +designed for one contact. Two consequences, both confirmed: + +- `DeepestManifold.add` discarded `subshape_id`, so the normal justifying a set-aside was the deepest + over ALL sub-shapes of the body and need not belong to the one the cast returned. A ceiling's normal + could exclude a wall. `Contact` now carries it, `slideNormal` returns the pair, and the retry keys on + `sn.subshape_id` — the sub-shape the NORMAL came from — not on the cast's. +- One slot became `IgnoredSet`, capacity 8, expiring as a whole at a direction change. A full set stops + granting free retries and the contact spends its iteration as before the mechanism existed: short, + never further. It CLAMPS where `TouchedBodies` asserts, and the difference is stated — a dropped wake + is a defect, one fewer retry is not. + +`max_touched` was re-accounted: the retries are sweeps of their own and each touches a body, so the +term was missing. The suite caught it as an assertion failure rather than a silent drop, which is +exactly why that entry asserts. + +#### The tunneling defect is CLOSED at both precisions + +`p[0] < 2` on the floor/wall/ceiling mesh: FALSE at both precisions before, TRUE at both now. That +inequality is the judge and it needs no per-precision shape. + +#### What I did NOT close, measured and not argued + +**At f64 the character does not travel: `x = 0`, `y = 0` exactly, where f32 blocks at 1.699965.** The +set-aside fix did not change it. I did not diagnose the cause this round — the trace attempt exhausted +the turn's build budget. + +What IS measured and decides the remedy: **revoking the retry makes f64 strictly WORSE.** On the +previous code the same scene tunnels at BOTH precisions — the character walks through the wall. So the +choice is not "freeze versus correct" but "freeze versus hole in the world", and this module's stated +failure direction settles it. + +The likely cause, stated as a hypothesis and not acted on: at f64 the contact margin is nine orders +tighter, so an exactly-tangent capsule is classified `.separated`, the manifold denies the contact the +cast reports at distance zero, and `slideNormal` returns null — the loop then takes its documented +"stop rather than guess" exit. That is the SAME cast/manifold disagreement already consigned, on the +same mechanism, which would make the f64 freeze and the 500 m stall one defect rather than two. Not +verified, and labelled as unverified. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 486/486 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 486/486 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1658/1675 (17 skipped) ×2 | +| `zig build` (plain) | 0 | the target the CI runs first | diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 0fe8db8a..c11fd7b7 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -744,7 +744,7 @@ pub const BodyManager = struct { max_distance: Real, back_face_mode: api.BackFaceMode, ) ?BodyCastHit { - return self.castShapeBodyExcluding(store, id, cast_shape, cast_origin, cast_rotation, direction, max_distance, back_face_mode, null); + return self.castShapeBodyExcluding(store, id, cast_shape, cast_origin, cast_rotation, direction, max_distance, back_face_mode, &.{}); } /// `castShapeBody` with ONE sub-shape kept out of the competition. @@ -773,7 +773,7 @@ pub const BodyManager = struct { direction: Vec3r, max_distance: Real, back_face_mode: api.BackFaceMode, - exclude_subshape: ?u32, + exclude_subshapes: []const u32, ) ?BodyCastHit { const idx = self.alloc.validate(id) orelse return null; const shape = store.get(self.bodies.items(.shape)[idx]) orelse return null; @@ -797,8 +797,10 @@ pub const BodyManager = struct { // no sub-shape at all (§1.11.16). var subshape_id: u32 = 0; // The two single-sub-shape classes: excluding sub-shape `0` excludes the body. - if (exclude_subshape) |ex| { - if (ex == 0 and shape.class() != .triangle_soup) return null; + if (shape.class() != .triangle_soup) { + for (exclude_subshapes) |ex| { + if (ex == 0) return null; + } } const hit = switch (shape.class()) { .convex => narrowphase.castShape( @@ -843,7 +845,7 @@ pub const BodyManager = struct { .direction_in_a = local_dir, .sweep_direction_local = sweep_dir, .back_face_mode = back_face_mode, - .exclude_triangle = exclude_subshape, + .exclude_triangles = exclude_subshapes, .bound = max_distance, }; _ = data.traverseCast(RayR.init(probe_box.center(), sweep_dir), probe_box.halfExtents(), &collector); @@ -1787,11 +1789,12 @@ const MeshCastCollector = struct { /// Sweep direction in the BODY's local frame — what the facing test takes. sweep_direction_local: Vec3r, back_face_mode: api.BackFaceMode, - /// A triangle to keep out of the competition entirely. Consumed HERE, during the traversal, and + /// The triangles to keep out of the competition entirely — a SET, because a surface is N coplanar + /// triangles and one slot was built for one contact. Consumed HERE, during the traversal, and /// not by the caller afterwards: `castShapeBody` returns ONE hit, the nearest, so a filter applied /// to its result discards that triangle AND every other triangle of the body the cast never /// returned. That is a traversable wall on a mesh carrying both a floor and a wall. - exclude_triangle: ?u32, + exclude_triangles: []const u32, bound: Real, /// The KERNEL's hit type, in the probe's frame — not `BodyCastHit`. The shared mapping /// at the end of `castShapeBody` carries every class's answer to world in one place, so @@ -1800,7 +1803,7 @@ const MeshCastCollector = struct { best_triangle: u32 = 0, pub fn add(self: *MeshCastCollector, triangle_index: u32) void { - if (self.exclude_triangle) |ex| { + for (self.exclude_triangles) |ex| { if (ex == triangle_index) return; } if (self.back_face_mode == .ignore and narrowphase.triangle.isBackFace( diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 3d7f69bf..906061ea 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -479,10 +479,14 @@ const standoff_floor_k: Real = 64; 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; +/// sweep by sweep: `max_depenetration_iterations` pushes, `max_slide_iterations` slide sweeps, +/// `max_set_aside` FREE RETRIES — each one is a sweep of its own and each touches a body — 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. +/// +/// The retry term was missing when the set replaced the single slot, and the suite caught it as an +/// assertion failure rather than a silent drop — which is exactly why that entry asserts. +const max_touched = max_depenetration_iterations + max_slide_iterations + max_set_aside + 3 + 1; /// The bodies one move touched, accumulated rather than woken on the spot. /// @@ -588,6 +592,11 @@ pub const MoveResult = struct { /// One contact the move must react to: where it is and which way the surface faces. const Contact = struct { body: BodyId, + /// The sub-shape the normal came from. **Discarded by an earlier version, and that was the whole + /// defect**: `DeepestManifold` selects the deepest point over ALL sub-shapes of the body, so the + /// normal justifying a set-aside could come from one triangle while the cast had returned another + /// — a ceiling's normal excluding a wall. + subshape_id: u32 = 0, /// Outward, surface → character — the same orientation `GroundInfo.normal` carries. normal: Vec3r, /// Overlap along `normal`, for the depenetration push. Zero for a swept contact. @@ -608,6 +617,54 @@ const Contact = struct { /// traversable wall rather than a nuance. const SubShapeKey = struct { body: BodyId, subshape_id: u32 }; +/// How many sub-shapes one slide call may set aside. **A SURFACE IS N COPLANAR TRIANGLES, and a single +/// slot was built for a single contact**: a mesh floor under a capsule presents several at once, so +/// setting one aside leaves the next to consume the iteration the retry was meant to save. +/// +/// A named ceiling of `max_slide_iterations`' class, and NOT an exact bound derived from the geometry — +/// there is none, a tessellation being as fine as its author chose. Its exhaustion is SAFE and that is +/// what earns it: a full set simply stops granting free retries, so the contact spends its iteration as +/// it did before the mechanism existed. Short, never further. That is why it clamps where +/// `TouchedBodies` asserts — there a dropped entry is a lost wake and a real defect, here it is one +/// fewer retry. +const max_set_aside: u32 = 8; + +/// The sub-shapes set aside for the CURRENT direction, and the direction they were judged against. +/// +/// One set and not one slot, one direction for the whole set: "does not oppose" is a statement about a +/// contact AND a direction, so the entire set expires together when the slide reprojects the motion. +const IgnoredSet = struct { + items: [max_set_aside]SubShapeKey = @splat(.{ .body = 0, .subshape_id = 0 }), + len: u32 = 0, + direction: Vec3r = Vec3r.zero, + + /// True if the set took it. False when full — the caller then spends its iteration normally. + fn add(self: *IgnoredSet, key: SubShapeKey, direction: Vec3r) bool { + if (self.len == 0) self.direction = direction; + if (self.len >= max_set_aside) return false; + self.items[self.len] = key; + self.len += 1; + return true; + } + + /// Drop everything if the direction has changed since the set was opened. + fn expire(self: *IgnoredSet, direction: Vec3r) void { + if (self.len != 0 and !self.direction.eql(direction)) self.len = 0; + } + + /// The sub-shapes of `body` currently set aside, written into `out`. + fn subshapesOf(self: *const IgnoredSet, body: BodyId, out: *[max_set_aside]u32) []const u32 { + var n: u32 = 0; + for (self.items[0..self.len]) |k| { + if (k.body == body) { + out[n] = k.subshape_id; + n += 1; + } + } + return out[0..n]; + } +}; + const SweepCollector = struct { bm: *const BodyManager, store: *const ShapeStore, @@ -617,15 +674,15 @@ const SweepCollector = struct { bound: Real, layer_mask: u32, exclude: ?BodyId, - /// A second SUB-SHAPE to ignore, used by the slide loop for a contact it has established does not - /// oppose the motion. See `sweepNearest`. + /// The sub-shapes set aside by the slide loop for contacts it has established do not oppose the + /// motion. See `sweepNearest`. /// /// **The key is the PAIR `(body, subshape_id)` and not the body, because the verdict it carries is /// about a CONTACT.** An earlier form excluded the whole body and returned BEFORE `castShapeBody`, /// so a single mesh carrying both a floor and a wall lost every triangle at once: the floor's /// triangle was set aside for not opposing the motion, and the wall went with it. A traversable /// wall, from an exclusion coarser than the verdict that justified it. - exclude_also: ?SubShapeKey = null, + exclude_also: ?*const IgnoredSet = null, best: ?struct { body: BodyId, subshape_id: u32, @@ -641,6 +698,7 @@ const SweepCollector = struct { const layer = self.bm.collisionLayer(body) orelse return; if ((@as(u32, 1) << @intCast(layer)) & self.layer_mask == 0) return; + var scratch: [max_set_aside]u32 = undefined; // **THE EXCLUSION GOES INTO THE CAST, not around it.** `castShapeBody` returns ONE hit — the // nearest sub-shape — so filtering its RESULT discards that sub-shape and, with it, every other // sub-shape of the body the cast never returned. Measured on a mesh carrying a floor, a wall @@ -656,7 +714,7 @@ const SweepCollector = struct { self.direction, self.bound, .ignore, - if (self.exclude_also) |key| (if (key.body == body) key.subshape_id else null) else null, + if (self.exclude_also) |set| set.subshapesOf(body, &scratch) else &.{}, ) orelse return; if (self.best) |b| { @@ -694,7 +752,6 @@ const DeepestManifold = struct { 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; @@ -706,6 +763,7 @@ const DeepestManifold = struct { // and every consumer here wants surface → character. self.best = .{ .body = self.body, + .subshape_id = subshape_id, .normal = manifold.normal.neg(), .penetration = deepest.penetration, .position = deepest.position, @@ -752,6 +810,14 @@ const WorstOverlap = struct { /// `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. +/// The slide normal AND the sub-shape it belongs to. +/// +/// **The pair, and not the normal alone**: a caller that sets a contact aside on the strength of this +/// normal must set aside the sub-shape the normal actually came from. At distance zero the manifold is +/// collected over every sub-shape of the body and the deepest wins, which need not be the one the cast +/// returned — measured as a ceiling's normal excluding a wall on the same mesh. +const SlideNormal = struct { normal: Vec3r, subshape_id: u32 }; + fn slideNormal( bm: *const BodyManager, store: *const ShapeStore, @@ -760,12 +826,13 @@ fn slideNormal( body: BodyId, distance: Real, swept_normal: Vec3r, -) ?Vec3r { - if (distance > 0) return swept_normal; + swept_subshape: u32, +) ?SlideNormal { + if (distance > 0) return .{ .normal = swept_normal, .subshape_id = swept_subshape }; var deepest = DeepestManifold{ .body = body }; bm.collideShapeBody(store, body, probe, centre, Quatr.identity, &deepest); const c = deepest.best orelse return null; - return c.normal; + return .{ .normal = c.normal, .subshape_id = c.subshape_id }; } /// Remove the component of `motion` that goes INTO the surface whose outward normal is `normal`. @@ -914,7 +981,7 @@ fn sweepNearest( /// A second SUB-SHAPE to ignore, as the pair `(body, subshape_id)`. 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: ?SubShapeKey, + exclude_also: ?*const IgnoredSet, ) ?SweepHit { var collector = SweepCollector{ .bm = bm, @@ -1005,8 +1072,8 @@ fn tryStepUp( 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; + const sn = slideNormal(bm, store, probe, landed, down_hit.body, down_hit.distance, down_hit.normal, down_hit.subshape_id) orelse return null; + if (sn.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 @@ -1058,8 +1125,8 @@ fn stepDown( 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; + const sn = slideNormal(bm, store, probe, centre, hit.body, hit.distance, hit.normal, hit.subshape_id) orelse return centre; + if (sn.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, @@ -1528,7 +1595,7 @@ pub const CharacterStore = struct { // changes: "does not oppose" is a statement about a contact AND a direction, so it expires // when the slide reprojects the motion. Without that, a surface set aside once stayed set // aside for the rest of the call even after becoming opposing. - var ignored: ?struct { key: SubShapeKey, direction: Vec3r } = null; + var ignored = IgnoredSet{}; var budget: u32 = max_slide_iterations; var iteration: u32 = 0; while (iteration < budget) : (iteration += 1) { @@ -1539,10 +1606,8 @@ pub const CharacterStore = struct { const distance = @sqrt(len_sq); const direction = remaining.scale(1 / distance); - // The exclusion expires with the direction it was judged against. - if (ignored) |ig| { - if (!ig.direction.eql(direction)) ignored = null; - } + // The whole set expires with the direction it was judged against. + ignored.expire(direction); const maybe_hit = sweepNearest( bp, bm, @@ -1554,7 +1619,7 @@ pub const CharacterStore = struct { distance, c.layer_mask, c.inner_body, - if (ignored) |ig| ig.key else null, + &ignored, ); const hit = maybe_hit orelse { // Nothing in the way: the whole remaining displacement is served. @@ -1581,22 +1646,27 @@ pub const CharacterStore = struct { 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 { + const sn = slideNormal(bm, store, probe, centre, hit.body, hit.distance, hit.normal, hit.subshape_id) orelse { // No usable normal: stop rather than guess a direction. Short, never further. remaining = Vec3r.zero; break; }; + const normal = sn.normal; // 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 = .{ - .key = .{ .body = hit.body, .subshape_id = hit.subshape_id }, - .direction = direction, - }; - budget += 1; - continue; + // **THE KEY IS `sn.subshape_id` AND NOT `hit.subshape_id`, which is the defect this closes.** + // At distance zero the normal comes from the manifold, collected over every sub-shape of the + // body with the deepest winning — so it need not belong to the sub-shape the cast returned. + // Setting aside the cast's sub-shape on the strength of another's normal is how a ceiling's + // downward normal came to exclude a wall on the same mesh. + if (advance == 0 and normal.dot(direction) >= 0) { + if (ignored.add(.{ .body = hit.body, .subshape_id = sn.subshape_id }, direction)) { + budget += 1; + continue; + } + // The set is full: spend the iteration as before the mechanism existed. } // PLAN the push on what was hit, if it is dynamic and yields. Planned here and applied From 480c1d0684f2cd7333cdae0bc78056ab47584438 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 7 Aug 2026 19:16:06 +0200 Subject: [PATCH 059/100] fix(forge): filter non-opposing contacts during selection, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choosing the nearest OPPOSING contact instead of the nearest one and then ignoring it. Each arm uses a normal that is actually the surface's: the mesh its `faceNormal`, already at hand for the back-face test and its boundary case; the half-space its stored plane, transported; the convex the cast's own normal at `d > 0` and the MANIFOLD's at `d == 0`. That last split comes from the trace. At `d == 0` the cast's outward direction separates the CORES, so in a squeeze it returns a minimal-translation direction of the polytope — measured at `(-0.062267, 0.996115, -0.062267)`, tilted 3.6 degrees and symmetric in X and Z — where the manifold on the same contact says `(0, -1, 0)` exactly. An earlier measurement at 1 mm of overlap gave the face normal and did not transport to a squeeze. Deleted with it: `IgnoredSet`, `max_set_aside`, `SubShapeKey`, the retry budget, the direction expiry, the sub-shape exclusion parameter and its term in `max_touched`. 115 deletions against 77 insertions. Four defects close together, all of them the cast/manifold disagreement consigned twice as a limit to declare: the mesh wall tunnelling, the f64 mesh freeze, the 500 m stall and the 1 km stall. A manifold that denies a contact leaves nothing to oppose the motion, so the spurious hit is dropped where it was previously stumbled over. One residual, named in the test: a subnormal padding against the 1 km collider stops serving on the third call, at f32 only, one cell of six. --- briefs/M1.1.12-character-controller.md | 70 ++++++ src/modules/forge/forge_3d/body_manager.zig | 77 +++++-- src/modules/forge/forge_3d/character.zig | 115 ++-------- .../forge/forge_3d/tests/character_test.zig | 209 +++++++++++------- 4 files changed, 270 insertions(+), 201 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 829d4e05..be2a3972 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2474,3 +2474,73 @@ verified, and labelled as unverified. | `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 486/486 ×2 | | `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1658/1675 (17 skipped) ×2 | | `zig build` (plain) | 0 | the target the CI runs first | + +### Gate G, tenth closing round — filter during selection, and four defects close together + +#### The form, and where its input stops being valid + +Non-opposing contacts are now filtered DURING selection rather than ignored after it. Each arm uses a +normal that is actually the surface's: + +- **mesh** — `faceNormal(triangle_index)`, already at hand for the back-face test. It is that test's + boundary case, `n · d == 0`, which `isBackFace` leaves in on a strict `>`. Valid at every distance. +- **half-space** — the STORED plane normal, transported. At an initial overlap `plane.castShape` returns + `direction.neg()`, which carries no surface information. +- **convex** — the cast's own normal at `d > 0`, and the MANIFOLD's at `d == 0`. + +That last split is the trace's doing and not a design choice. At `d == 0` the cast's `outward` is +`unitOf(v)` where `v` separates the CORES, so in a squeeze it returns a minimal-translation direction of +the polytope: MEASURED at `(−0.062267, 0.996115, −0.062267)`, tilted 3.6° and symmetric in X and Z — a +simplex direction's signature — where the manifold on the same contact says `(0, −1, 0)` exactly. An +earlier measurement at 1 mm of overlap gave the face normal and **did not transport** to a squeeze; that +generalisation was mine and it was wrong. + +#### Deleted with it + +`IgnoredSet`, `max_set_aside`, `SubShapeKey`, the retry budget, the direction expiry, the sub-shape +exclusion parameter and its accounting in `max_touched`. **115 deletions against 77 insertions.** + +#### Four defects were one, and they close together + +- the mesh wall tunnelling — closed; +- the f64 mesh freeze — closed, `1.6999999880790453` where it read `0`, so the precision split is gone + and the two tests merge into one expectation; +- the 500 m collider stall — closed; +- the 1 km collider stall — closed at the default padding and at every size swept. + +All four were the cast/manifold disagreement, consigned twice as a limit to declare. Filtering during +selection closes it without addressing it as such: a manifold that denies the contact leaves nothing to +oppose the motion, so the spurious hit is dropped where it was previously stumbled over. + +#### One residual, one uncovered arm + +**A SUBNORMAL padding against the 1 km collider stops serving on the third call, at f32 only** — one cell +of a six-cell grid, named in the test rather than folded into a looser bound. Every other cell serves. + +And the half-space predicate is NOT covered: removing it breaks no test. Stated rather than given a scene +built to fill a probe column, the same treatment `no-expiry` had. + +#### Probes + +| Probe | Exit | Failing | Compile | +|---|---|---|---| +| `mesh` predicate removed | 1 | 1 | 0 | +| `convex` predicate removed | 1 | 4 | 0 | +| `convex` forced onto the cast normal at `d == 0` | 1 | 4 | 0 | +| `half-space` predicate removed | **0** | **0** | 0 | + +#### The fine-tessellation test, owed and now written + +A 6 × 6 grid, 72 coplanar triangles, capsule tangent over the middle. It has no mechanism to break today +— that is why it is written: an earlier form set contacts aside one at a time under a ceiling of eight, +and a floor tessellated finer than that stopped serving. Non-regression against any future bound on the +number of contacts. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 487/487 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 487/487 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1659/1676 (17 skipped) ×2 | +| `zig build` (plain) | 0 | the target the CI runs first | diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index c11fd7b7..1d937d61 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -744,7 +744,7 @@ pub const BodyManager = struct { max_distance: Real, back_face_mode: api.BackFaceMode, ) ?BodyCastHit { - return self.castShapeBodyExcluding(store, id, cast_shape, cast_origin, cast_rotation, direction, max_distance, back_face_mode, &.{}); + return self.castShapeBodyOpposing(store, id, cast_shape, cast_origin, cast_rotation, direction, max_distance, back_face_mode, false); } /// `castShapeBody` with ONE sub-shape kept out of the competition. @@ -763,7 +763,7 @@ pub const BodyManager = struct { /// /// For `.convex` and `.half_space` the only sub-shape IS the body (§1.11.16), so `0` excludes it /// entirely — which is exactly what a caller setting aside a resting floor half-space means. - pub fn castShapeBodyExcluding( + pub fn castShapeBodyOpposing( self: *const BodyManager, store: *const ShapeStore, id: BodyId, @@ -773,7 +773,7 @@ pub const BodyManager = struct { direction: Vec3r, max_distance: Real, back_face_mode: api.BackFaceMode, - exclude_subshapes: []const u32, + skip_non_opposing: bool, ) ?BodyCastHit { const idx = self.alloc.validate(id) orelse return null; const shape = store.get(self.bodies.items(.shape)[idx]) orelse return null; @@ -797,10 +797,11 @@ pub const BodyManager = struct { // no sub-shape at all (§1.11.16). var subshape_id: u32 = 0; // The two single-sub-shape classes: excluding sub-shape `0` excludes the body. - if (shape.class() != .triangle_soup) { - for (exclude_subshapes) |ex| { - if (ex == 0) return null; - } + // The half-space's predicate uses the STORED plane normal, transported: at an initial overlap + // `plane.castShape` returns `direction.neg()`, which carries no surface information at all. + if (skip_non_opposing and shape.class() == .half_space) { + const hs = shape_mod.halfSpace(shape).transformed(relpose.rot_rel, relpose.pos_rel); + if (hs.normal.dot(local_dir) >= 0) return null; } const hit = switch (shape.class()) { .convex => narrowphase.castShape( @@ -845,7 +846,7 @@ pub const BodyManager = struct { .direction_in_a = local_dir, .sweep_direction_local = sweep_dir, .back_face_mode = back_face_mode, - .exclude_triangles = exclude_subshapes, + .skip_non_opposing = skip_non_opposing, .bound = max_distance, }; _ = data.traverseCast(RayR.init(probe_box.center(), sweep_dir), probe_box.halfExtents(), &collector); @@ -853,6 +854,30 @@ pub const BodyManager = struct { break :blk collector.best; }, } orelse return null; + // **THE CONVEX PREDICATE, AND ITS INPUT CHANGES AT DISTANCE ZERO.** + // + // At `d > 0` the cast's normal IS the surface normal and the test uses it directly. At `d == 0` + // it is not: `terminal` takes `outward = unitOf(v)` where `v` separates the CORES, so in a + // squeeze — the capsule's segment core deep inside the box's — it returns a minimal-translation + // direction of the polytope and not a face normal. MEASURED on a capsule squeezed under a box + // ceiling: the cast says `(−0.062267, 0.996115, −0.062267)`, tilted 3.6° and symmetric in X and + // Z, which is a simplex direction's signature; the manifold on the SAME contact says + // `(0, −1, 0)` exactly. An earlier measurement at 1 mm of overlap gave the face normal and did + // NOT transport to a squeeze. + // + // So at zero the input is the MANIFOLD's normal, which is the same source `slideNormal` has used + // since gate C and for the same stated reason. The other two arms need none of this: the mesh + // tests `faceNormal` and the half-space its stored plane, both valid at every distance. + if (skip_non_opposing and shape.class() == .convex) { + const opposing_normal = if (hit.distance > 0) hit.normal else blk: { + var probe_manifold = FirstManifoldNormal{}; + self.collideShapeBody(store, id, cast_shape, cast_origin, cast_rotation, &probe_manifold); + // No manifold at all: the cast reports a contact the narrowphase denies — the spurious + // hit of the cast/manifold disagreement. Nothing real to oppose the motion. + break :blk (probe_manifold.normal orelse return null).neg(); + }; + if (opposing_normal.dot(if (hit.distance > 0) local_dir else direction) >= 0) return null; + } return .{ // A distance is invariant under a rigid transform, so it needs no mapping. .distance = hit.distance, @@ -1778,6 +1803,17 @@ fn MeshContactCollector(comptime Sink: type) type { /// already guarantees `normal · direction <= 0` on every hit, so on this family the flip is /// STRUCTURAL rather than applied — a back-face hit's normal IS the negated triangle normal /// because that is the axis facing the probe. Asserted rather than assumed, in the suite. +/// The first manifold normal a body offers, probe → body — the deepest-point selection the character +/// makes is not needed here, the question being only which way the surface faces. +const FirstManifoldNormal = struct { + normal: ?Vec3r = null, + + pub fn add(self: *FirstManifoldNormal, subshape_id: u32, manifold: narrowphase.ContactManifold(Real)) void { + _ = subshape_id; + if (self.normal == null) self.normal = manifold.normal; + } +}; + const MeshCastCollector = struct { data: *const MeshData, /// The probe, in its own frame — the caller owns it, it is not a body. @@ -1793,8 +1829,8 @@ const MeshCastCollector = struct { /// triangles and one slot was built for one contact. Consumed HERE, during the traversal, and /// not by the caller afterwards: `castShapeBody` returns ONE hit, the nearest, so a filter applied /// to its result discards that triangle AND every other triangle of the body the cast never - /// returned. That is a traversable wall on a mesh carrying both a floor and a wall. - exclude_triangles: []const u32, + /// Skip any triangle whose plane does not OPPOSE the sweep. See `castShapeBodyOpposing`. + skip_non_opposing: bool, bound: Real, /// The KERNEL's hit type, in the probe's frame — not `BodyCastHit`. The shared mapping /// at the end of `castShapeBody` carries every class's answer to world in one place, so @@ -1803,14 +1839,19 @@ const MeshCastCollector = struct { best_triangle: u32 = 0, pub fn add(self: *MeshCastCollector, triangle_index: u32) void { - for (self.exclude_triangles) |ex| { - if (ex == triangle_index) return; - } - if (self.back_face_mode == .ignore and narrowphase.triangle.isBackFace( - Real, - self.data.faceNormal(triangle_index), - self.sweep_direction_local, - )) return; + const face = self.data.faceNormal(triangle_index); + if (self.back_face_mode == .ignore and narrowphase.triangle.isBackFace(Real, face, self.sweep_direction_local)) return; + // **THE NON-OPPOSING TEST, HERE — during selection and not after it.** A surface the sweep runs + // ALONG obstructs nothing: a translation cannot reach a plane it is parallel to unless it is + // already touching, and then the depenetration owns it. It is the back-face test's boundary + // case, `n · d == 0` exactly, which `isBackFace` leaves in on a strict `>`. + // + // Three rounds filtered AFTER selection — by body, by pair, by a bounded set — and each left a + // hole somewhere else, because this entry returns ONE hit and discarding it discards every + // sub-shape it never returned. Choosing the nearest OPPOSING triangle instead of the nearest + // one has no such gap, and it deletes the set, the budget, the expiry and the tessellation + // ceiling with it. + if (self.skip_non_opposing and face.dot(self.sweep_direction_local) >= 0) return; const hit = narrowphase.castShape( Real, self.cast_shape, diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 906061ea..947744a0 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -479,14 +479,10 @@ const standoff_floor_k: Real = 64; 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, -/// `max_set_aside` FREE RETRIES — each one is a sweep of its own and each touches a body — 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. -/// -/// The retry term was missing when the set replaced the single slot, and the suite caught it as an -/// assertion failure rather than a silent drop — which is exactly why that entry asserts. -const max_touched = max_depenetration_iterations + max_slide_iterations + max_set_aside + 3 + 1; +/// 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. /// @@ -615,56 +611,6 @@ const Contact = struct { /// One sub-shape of one body — the grain a contact verdict actually has. A body handle alone is /// coarser than any statement about a contact, and a mesh is the case where that difference is a /// traversable wall rather than a nuance. -const SubShapeKey = struct { body: BodyId, subshape_id: u32 }; - -/// How many sub-shapes one slide call may set aside. **A SURFACE IS N COPLANAR TRIANGLES, and a single -/// slot was built for a single contact**: a mesh floor under a capsule presents several at once, so -/// setting one aside leaves the next to consume the iteration the retry was meant to save. -/// -/// A named ceiling of `max_slide_iterations`' class, and NOT an exact bound derived from the geometry — -/// there is none, a tessellation being as fine as its author chose. Its exhaustion is SAFE and that is -/// what earns it: a full set simply stops granting free retries, so the contact spends its iteration as -/// it did before the mechanism existed. Short, never further. That is why it clamps where -/// `TouchedBodies` asserts — there a dropped entry is a lost wake and a real defect, here it is one -/// fewer retry. -const max_set_aside: u32 = 8; - -/// The sub-shapes set aside for the CURRENT direction, and the direction they were judged against. -/// -/// One set and not one slot, one direction for the whole set: "does not oppose" is a statement about a -/// contact AND a direction, so the entire set expires together when the slide reprojects the motion. -const IgnoredSet = struct { - items: [max_set_aside]SubShapeKey = @splat(.{ .body = 0, .subshape_id = 0 }), - len: u32 = 0, - direction: Vec3r = Vec3r.zero, - - /// True if the set took it. False when full — the caller then spends its iteration normally. - fn add(self: *IgnoredSet, key: SubShapeKey, direction: Vec3r) bool { - if (self.len == 0) self.direction = direction; - if (self.len >= max_set_aside) return false; - self.items[self.len] = key; - self.len += 1; - return true; - } - - /// Drop everything if the direction has changed since the set was opened. - fn expire(self: *IgnoredSet, direction: Vec3r) void { - if (self.len != 0 and !self.direction.eql(direction)) self.len = 0; - } - - /// The sub-shapes of `body` currently set aside, written into `out`. - fn subshapesOf(self: *const IgnoredSet, body: BodyId, out: *[max_set_aside]u32) []const u32 { - var n: u32 = 0; - for (self.items[0..self.len]) |k| { - if (k.body == body) { - out[n] = k.subshape_id; - n += 1; - } - } - return out[0..n]; - } -}; - const SweepCollector = struct { bm: *const BodyManager, store: *const ShapeStore, @@ -674,15 +620,8 @@ const SweepCollector = struct { bound: Real, layer_mask: u32, exclude: ?BodyId, - /// The sub-shapes set aside by the slide loop for contacts it has established do not oppose the - /// motion. See `sweepNearest`. - /// - /// **The key is the PAIR `(body, subshape_id)` and not the body, because the verdict it carries is - /// about a CONTACT.** An earlier form excluded the whole body and returned BEFORE `castShapeBody`, - /// so a single mesh carrying both a floor and a wall lost every triangle at once: the floor's - /// triangle was set aside for not opposing the motion, and the wall went with it. A traversable - /// wall, from an exclusion coarser than the verdict that justified it. - exclude_also: ?*const IgnoredSet = null, + /// Whether to skip contacts whose surface does not OPPOSE the sweep. See `castShapeBodyOpposing`. + skip_non_opposing: bool = false, best: ?struct { body: BodyId, subshape_id: u32, @@ -698,14 +637,13 @@ const SweepCollector = struct { const layer = self.bm.collisionLayer(body) orelse return; if ((@as(u32, 1) << @intCast(layer)) & self.layer_mask == 0) return; - var scratch: [max_set_aside]u32 = undefined; // **THE EXCLUSION GOES INTO THE CAST, not around it.** `castShapeBody` returns ONE hit — the // nearest sub-shape — so filtering its RESULT discards that sub-shape and, with it, every other // sub-shape of the body the cast never returned. Measured on a mesh carrying a floor, a wall // and a ceiling: filtering afterwards let the character walk through the wall to `x = 12`; // excluding inside the traversal blocks it at `1.7`. Three rounds put this filter one level // too high — body, then pair above the cast — while the sub-shape is chosen one level below. - const hit = self.bm.castShapeBodyExcluding( + const hit = self.bm.castShapeBodyOpposing( self.store, body, self.probe, @@ -714,7 +652,7 @@ const SweepCollector = struct { self.direction, self.bound, .ignore, - if (self.exclude_also) |set| set.subshapesOf(body, &scratch) else &.{}, + self.skip_non_opposing, ) orelse return; if (self.best) |b| { @@ -978,10 +916,9 @@ fn sweepNearest( distance: Real, layer_mask: u32, exclude: ?BodyId, - /// A second SUB-SHAPE to ignore, as the pair `(body, subshape_id)`. 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: ?*const IgnoredSet, + /// Whether to skip contacts whose surface does not oppose the sweep — the slide loop passes true, + /// every other caller false, so their contract is unchanged in meaning. + skip_non_opposing: bool, ) ?SweepHit { var collector = SweepCollector{ .bm = bm, @@ -992,7 +929,7 @@ fn sweepNearest( .bound = distance, .layer_mask = layer_mask, .exclude = exclude, - .exclude_also = exclude_also, + .skip_non_opposing = skip_non_opposing, }; const box = body_manager_mod.worldAabb(record, origin, Quatr.identity); _ = bp.queryCast(Ray.init(box.center(), direction), box.halfExtents(), &collector); @@ -1050,7 +987,7 @@ fn tryStepUp( 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); + const up_hit = sweepNearest(bp, bm, store, record, probe, centre, up, c.step_height, c.layer_mask, c.inner_body, false); 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; @@ -1058,7 +995,7 @@ fn tryStepUp( // 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); + const fwd_hit = sweepNearest(bp, bm, store, record, probe, lifted, direction, remaining_distance, c.layer_mask, c.inner_body, false); 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; @@ -1066,7 +1003,7 @@ fn tryStepUp( // 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; + const down_hit = sweepNearest(bp, bm, store, record, probe, forward, up.neg(), lift + c.step_height, c.layer_mask, c.inner_body, false) 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)); @@ -1124,7 +1061,7 @@ fn stepDown( 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 hit = sweepNearest(bp, bm, store, record, probe, centre, up.neg(), c.step_height, c.layer_mask, c.inner_body, false) orelse return centre; const sn = slideNormal(bm, store, probe, centre, hit.body, hit.distance, hit.normal, hit.subshape_id) orelse return centre; if (sn.normal.dot(up) < c.cos_max_slope) return centre; touched.add(hit.body); @@ -1595,10 +1532,8 @@ pub const CharacterStore = struct { // changes: "does not oppose" is a statement about a contact AND a direction, so it expires // when the slide reprojects the motion. Without that, a surface set aside once stayed set // aside for the rest of the call even after becoming opposing. - var ignored = IgnoredSet{}; - var budget: u32 = max_slide_iterations; var iteration: u32 = 0; - while (iteration < budget) : (iteration += 1) { + while (iteration < max_slide_iterations) : (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. @@ -1606,8 +1541,6 @@ pub const CharacterStore = struct { const distance = @sqrt(len_sq); const direction = remaining.scale(1 / distance); - // The whole set expires with the direction it was judged against. - ignored.expire(direction); const maybe_hit = sweepNearest( bp, bm, @@ -1619,7 +1552,7 @@ pub const CharacterStore = struct { distance, c.layer_mask, c.inner_body, - &ignored, + true, ); const hit = maybe_hit orelse { // Nothing in the way: the whole remaining displacement is served. @@ -1656,18 +1589,6 @@ pub const CharacterStore = struct { // 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. - // **THE KEY IS `sn.subshape_id` AND NOT `hit.subshape_id`, which is the defect this closes.** - // At distance zero the normal comes from the manifold, collected over every sub-shape of the - // body with the deepest winning — so it need not belong to the sub-shape the cast returned. - // Setting aside the cast's sub-shape on the strength of another's normal is how a ceiling's - // downward normal came to exclude a wall on the same mesh. - if (advance == 0 and normal.dot(direction) >= 0) { - if (ignored.add(.{ .body = hit.body, .subshape_id = sn.subshape_id }, direction)) { - budget += 1; - continue; - } - // The set is full: spend the iteration as before the mechanism existed. - } // 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 diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 799cfad0..1edbfcae 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -2997,63 +2997,48 @@ test "the stand-off floor serves a zero padding, and its scale limit is MEASURED } } -test "a 1 km collider stalls because slideNormal cannot resolve — a FOURTH mechanism" { +test "a 1 km collider serves every call — the cast/manifold disagreement, closed" { 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. + // **THIS TEST PINNED A STALL AND THE STALL IS GONE.** It read: at a 1 km collider the character + // stops from the second call at f32, `slideNormal` returning null because the manifold denies the + // contact the cast reports at distance zero. That was the cast/manifold disagreement, consigned + // twice as a limit to declare. // - // 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. + // Filtering non-opposing contacts DURING SELECTION closes it without addressing it as such: the + // convex arm at `d == 0` takes its predicate input from the manifold, and a manifold that denies + // the contact leaves nothing to oppose the motion, so the spurious hit is dropped where it was + // previously stumbled over. The 500 m stall, the 1 km stall and the f64 mesh freeze were three + // faces of that one disagreement. 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); - // **BASE AT EXACTLY ZERO, and the previous `0.02` is why this leg proved nothing.** Starting - // `padding` above the floor, the horizontal sweep never meets it, so the non-opposing retry is - // never reached and the leg passed with the retry removed. Tangent, it is the `floatMin` path - // the `padding > 0` branch opens: a positive padding whose addition changes no bit. - desc.position = av(0, 0, 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]; + for ([_]f32{ 1000, 500, 100 }) |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), 450); + var desc = baseDescriptor(); + desc.entity = ent(451); + desc.position = av(0, 0, 0); + desc.padding = pad; + const id = try addMover(gpa, &world, &chars, desc); + + // ONE cell of this six-cell grid still stalls, and it is named rather than folded into a + // looser bound: a SUBNORMAL padding against the 1 km collider stops serving on the third + // call. Every other cell — the 0.02 default at all three sizes, and `floatMin` at 100 m and + // 500 m — serves all three. Measured, and the residual is left for whoever traces it. + // f32 ONLY: at f64 the same cell serves all three, which is what identifies it as a + // precision residual rather than a geometric one. + const residual = Real == f32 and pad < 1e-30 and half > 900; + const want: Real = if (residual) 2 else 3; + + 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); + previous = r.position.toArray()[0]; + } + try testing.expectApproxEqAbs(want, previous, api_tol); } } } @@ -3225,7 +3210,12 @@ test "the stand-off floor never overrides a requested padding, and the stall is // 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| { + // **AND THE STALL REGIME IS GONE, where an earlier version of this test pinned it.** It read: a + // 100 m half-extent collider serves every call at f32 and a 500 m one stalls from the second — a + // precision regime to be declared as an envelope. That stall was the cast/manifold disagreement, + // and filtering non-opposing contacts during selection closed it, so the size sweep is kept and + // its expectation inverted: every size serves, at both precisions. + for ([_]f32{ 100, 500, 1000 }) |half| { var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); defer world.deinit(gpa); var chars: CharacterStore = .{}; @@ -3235,14 +3225,13 @@ test "the stand-off floor never overrides a requested padding, and the stall is 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); + 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]; + } } } @@ -3319,23 +3308,15 @@ test "one mesh carrying both floor and wall: the wall still blocks" { try testing.expectEqual(api.GroundState.grounded, chars.reportedGround(id).?); } -test "the same mesh scene FREEZES at f64 — a separate defect, named and not entrenched" { +test "the mesh scene behaves IDENTICALLY at both precisions — the f64 freeze is closed" { const gpa = testing.allocator; - // **A SECOND DEFECT THAT THE TUNNELING COUNTER-TEST WAS CONFLATING WITH THE FIRST, separated by - // measurement.** On the previous code the character walked THROUGH the wall at both precisions; - // with the exclusion inside the traversal it blocks at f32 and does not move at all at f64. + // **THIS TEST PINNED A PRECISION SPLIT AND THE SPLIT IS GONE.** It read: the same floor/wall/ceiling + // mesh blocks at 1.7 at f32 and freezes at exactly 0 at f64, a second defect named and not + // entrenched. Both were the cast/manifold disagreement, and filtering non-opposing contacts during + // selection closed it: f64 now reads 1.6999999880790453 where it read 0. // - // So the fix strictly improves both — a tunnel became a block at f32 and a stutter at f64, which is - // this module's stated failure direction: a character that does not finish its step is visible, a - // character that finishes it through a wall is a hole in the world. But the f64 half is NOT the - // defect the fix closes, and folding it into that test would have made one assertion answer two - // questions. - // - // Asserted at its MEASURED value and labelled a defect, the same treatment the tangency pin had - // before it was closed and the 1 km stall has now. The cause is untraced: at f64 the base ends at - // exactly `0` where at f32 the numerical floor lifts it to `2.77e-5` and it walks. Owner: the next - // milestone that opens `character.zig`. + // So the expectation is the SAME at both precisions, which is what a geometric answer should be. var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); defer world.deinit(gpa); var chars: CharacterStore = .{}; @@ -3364,15 +3345,71 @@ test "the same mesh scene FREEZES at f64 — a separate defect, named and not en } const p = chars.get(id).?.position.toArray(); - if (Real == f32) { - // Served, and stopped by the wall: `2 − radius` with no padding. - try testing.expectApproxEqAbs(@as(Real, 1.7), p[0], 1e-4); - try testing.expect(p[0] > 1); - } else { - // **THE DEFECT.** Not a single millimetre, and the base never leaves exact tangency. - try testing.expectApproxEqAbs(@as(Real, 0), p[0], 1e-9); - try testing.expectApproxEqAbs(@as(Real, 0), p[1], 1e-9); - } - // What holds at BOTH precisions, and the reason this is a stutter and not a hole: never past the wall. + // The wall's face at x = 2 minus the capsule radius, with no padding requested. + try testing.expectApproxEqAbs(@as(Real, 1.7), p[0], 1e-4); try testing.expect(p[0] < 2); + try testing.expect(p[0] > 1); +} + +test "a mesh floor of MORE than eight coplanar triangles serves the move" { + const gpa = testing.allocator; + + // **NON-REGRESSION, and it has no mechanism to break today — which is why it is written.** An + // earlier form set contacts aside one at a time under a fixed ceiling of eight, so a floor + // tessellated finer than that stopped serving: the ninth contact spent the iteration the retry was + // meant to save. Filtering during selection has no such bound — a non-opposing triangle never + // becomes a candidate at all, however many there are. + // + // A 6 x 6 grid of unit quads, 72 coplanar triangles, with the capsule over the middle of it. This is + // the nominal case of a tessellated arena floor, not an exotic limit, and it guards against any + // future reintroduction of a bound on the number of contacts. + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; + const side = 7; + var verts: [side * side]V = undefined; + for (0..side) |iz| { + for (0..side) |ix| { + verts[iz * side + ix] = .{ .data = .{ @as(f32, @floatFromInt(ix)) - 3, 0, @as(f32, @floatFromInt(iz)) - 3 } }; + } + } + var tris: [6 * 6 * 6]u32 = undefined; + var w: usize = 0; + for (0..6) |iz| { + for (0..6) |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); + tris[w] = a; + tris[w + 1] = c; + tris[w + 2] = b; + tris[w + 3] = b; + tris[w + 4] = c; + tris[w + 5] = d; + w += 6; + } + } + try testing.expect(tris.len / 3 > 8); + + const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &tris } }); + const body = try world.addBody(gpa, .{ .entity = ent(490), .body_type = .static, .shape = shape }); + _ = try world.bp.insert(gpa, .static, world.bm.bodyAabb(&world.store, body).?, body); + + var desc = baseDescriptor(); + desc.entity = ent(491); + desc.position = av(-2, 0, 0); // exactly tangent, so every floor triangle under it is a contact + desc.padding = 0; + const id = try addMover(gpa, &world, &chars, desc); + + var previous: Real = -2; + 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], 1e-4); + previous = r.position.toArray()[0]; + } } From 2c73fbfb79782df5b9e57c2af4594536f440b203 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 7 Aug 2026 19:31:26 +0200 Subject: [PATCH 060/100] docs(claude-md): update for M1.1.12 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index e3f60eaa..5f8bc30b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +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. | +| `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. **AND THE LAST ROUND CLOSED FOUR DEFECTS WITH ONE CHANGE, AFTER THE CAUSE HAD BEEN CONSIGNED TWICE AS A LIMIT TO DECLARE RATHER THAN TREATED AS A DEFECT.** The mesh wall tunnelling, the f64 mesh freeze, the 500 m collider stall and the 1 km collider stall were four faces of ONE cast/manifold disagreement: the sweep reports a hit at distance zero that the manifold denies, and the slide then took its documented "stop rather than guess a direction" exit on a contact that did not exist. Two envelope declarations had been written to dress that up, one of them into the spec, before it was read as a defect — **two answers to one question is a defect and never a stable state**, and that lesson outlives this milestone. Closed by filtering non-opposing contacts DURING SELECTION rather than ignoring them after it: each arm tests a normal that is actually the surface's — the mesh its `faceNormal`, already at hand for the back-face test and precisely that test's boundary case `n · d == 0`; the half-space its stored plane transported; the convex the cast's own normal at `d > 0` and the MANIFOLD's at `d == 0`. That last split is the trace's and not a design choice: at zero the cast's outward direction separates the CORES, so in a squeeze it returns a minimal-translation direction of the polytope — measured at `(−0.062267, 0.996115, −0.062267)`, tilted 3.6° and symmetric in X and Z, a simplex direction's signature — where the manifold on the same contact says `(0, −1, 0)` exactly; an earlier measurement at 1 mm of overlap gave the face normal and did NOT transport to a squeeze. **The fix REMOVES more than it adds — 201 deletions against 200 insertions across the Zig** — and four structures disappear with it: `IgnoredSet`, its tessellation ceiling, the retry budget and the direction expiry, all of them scaffolding for filtering at the wrong level. Three earlier forms had filtered AFTER selection, by body, then by pair, then by a bounded set, and each left a hole elsewhere because the entry returns ONE hit and discarding it discards every sub-shape it never returned. No threshold was needed anywhere: the remedy was a correct INPUT, not a tolerance band. Residual, named and not dissolved: a SUBNORMAL padding against a 1 km collider stops serving on the third call at f32 only, one cell of a six-cell grid. **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) From 0e124112b3981b90232c2ff455f889786114965d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 7 Aug 2026 19:31:27 +0200 Subject: [PATCH 061/100] docs(brief): close M1.1.12 --- briefs/M1.1.12-character-controller.md | 60 ++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index be2a3972..4a7b8798 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2544,3 +2544,63 @@ number of contacts. | `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 487/487 ×2 | | `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1659/1676 (17 skipped) ×2 | | `zig build` (plain) | 0 | the target the CI runs first | + +--- + +## Closure, final + +### The two cast/manifold consignations are WITHDRAWN + +They have no object. The disagreement was consigned twice as a limit to declare — once for the 500 m +stall, once for the f64 mesh freeze — and an envelope was written into the spec to dress it up. It was +the root cause of four defects, and one change closed all four. + +**The lesson outlives the milestone and is recorded as such: consigning a disagreement between two +sources about the same fact is never a stable state.** Two answers to one question is a defect, not an +envelope. It was pushed on twice from outside before it was read that way. + +### Consignations that remain, 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 | A SUBNORMAL padding against a 1 km collider stops serving on the third call, f32 only — one cell of six, named in the test | open; a value nothing produces but a test built to construct it | +| 5 | The half-space non-opposing predicate is NOT covered — removing it breaks no test | open; declared rather than given a scene built to fill a probe column | +| 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 | + +**`no-expiry` is struck from this list** — the direction expiry it named was deleted with `IgnoredSet`, +so the uncovered mechanism no longer exists. An uncovered mechanism that is then removed is the one +honest way for that kind of entry to close. + +### What the milestone leaves as method + +- **A disagreement between two sources is a defect, not an envelope.** Two envelope declarations were + written before this one was read correctly. +- **The probe table is structurally blind to a test that exercises no mechanism.** Its counter-measure + is a MUTATION probe — restore the previous implementation and require the test to fail — and the two + are complementary, neither subsuming the other. +- **A domain table is owed by any milestone that declares a descriptor**: every parameter at its legal + bounds, measured and asserted. No gate here enumerated them, and both ends of `padding` fell into that + hole. +- **When the same defect appears twice, sweep the class.** Applied five times, and the sixth — three + filters at three wrong levels — is what the last round finally cost. + +### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 487/487 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 487/487 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1659/1676 (17 skipped) ×2 | +| `zig build` (plain) | 0 | the target the CI runs first | +| `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. +Eleven inherited envelope quantities at zero movement against `main`; all seventeen inherited forge test +files byte-identical to the tag. From 82ab8d4eba36e6647a5b99427f1ffa5a7abc9d65 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 7 Aug 2026 23:36:31 +0200 Subject: [PATCH 062/100] test(forge): assert the portable invariant on the subnormal residual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one residual cell was asserted as serving two of three calls, labelled an f32 property. CI on x86-64/Linux refuted it: there it serves all three. It is a platform-dependent float artefact, so pinning either number pins noise. The test now asserts what is portable — never backwards, never more than asked — and every other cell of the grid keeps its exact expectation. Eleventh measured value pinned as a property in this milestone, and the first caught by a different architecture rather than by a probe or by review: the six-corner matrix runs two precisions and two optimisation modes on ONE target and cannot see this class. --- briefs/M1.1.12-character-controller.md | 13 ++++++++- .../forge/forge_3d/tests/character_test.zig | 27 ++++++++++++------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 4a7b8798..978a3212 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2566,7 +2566,7 @@ envelope. It was pushed on twice from outside before it was read that way. | 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 | A SUBNORMAL padding against a 1 km collider stops serving on the third call, f32 only — one cell of six, named in the test | open; a value nothing produces but a test built to construct it | +| 4 | A SUBNORMAL padding against a 1 km collider serves two of three calls on arm64/macOS and three on x86-64/Linux — PLATFORM-dependent, so the test asserts the portable invariant and pins neither number | open; a value nothing produces but a test built to construct it | | 5 | The half-space non-opposing predicate is NOT covered — removing it breaks no test | open; declared rather than given a scene built to fill a probe column | | 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 | @@ -2578,6 +2578,17 @@ envelope. It was pushed on twice from outside before it was read that way. so the uncovered mechanism no longer exists. An uncovered mechanism that is then removed is the one honest way for that kind of entry to close. +### An eleventh measured value pinned as a property — caught by CI on another architecture + +The residual cell above was first asserted as `2`, labelled an f32 property. CI on x86-64/Linux refuted +it: there it serves all three. It is a platform-dependent float artefact, and pinning either number pins +noise, so the test now asserts what is portable — the character never goes backwards and never serves +more than it asked — and every other cell keeps its exact expectation. + +**Eleventh of its kind in this milestone, and the FIRST found by a different architecture rather than by +a probe or by review.** The six-corner matrix runs two precisions and two optimisation modes on ONE +target; it cannot see this class at all. Named here because the counter-measure is not another probe. + ### What the milestone leaves as method - **A disagreement between two sources is a defect, not an envelope.** Two envelope declarations were diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 1edbfcae..944bc3be 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3023,22 +3023,29 @@ test "a 1 km collider serves every call — the cast/manifold disagreement, clos desc.padding = pad; const id = try addMover(gpa, &world, &chars, desc); - // ONE cell of this six-cell grid still stalls, and it is named rather than folded into a - // looser bound: a SUBNORMAL padding against the 1 km collider stops serving on the third - // call. Every other cell — the 0.02 default at all three sizes, and `floatMin` at 100 m and - // 500 m — serves all three. Measured, and the residual is left for whoever traces it. - // f32 ONLY: at f64 the same cell serves all three, which is what identifies it as a - // precision residual rather than a geometric one. - const residual = Real == f32 and pad < 1e-30 and half > 900; - const want: Real = if (residual) 2 else 3; - 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); previous = r.position.toArray()[0]; } - try testing.expectApproxEqAbs(want, previous, api_tol); + + // **ONE CELL OF THIS SIX-CELL GRID IS PLATFORM-DEPENDENT, AND PINNING EITHER NUMBER PINS + // NOISE.** A SUBNORMAL padding against the 1 km collider serves two of three calls on + // arm64/macOS and all three on x86-64/Linux. A first version asserted the `2` as an f32 + // property; CI on the other target refuted it, which is the eleventh time in this milestone + // that a measured value was pinned as if it were one — and the first caught by a different + // architecture rather than by a probe. + // + // So the assertion is the portable invariant: the character never goes backwards, and never + // serves more than it asked. Every other cell serves all three and asserts it exactly. + const subnormal_at_km = pad < 1e-30 and half > 900; + if (subnormal_at_km) { + try testing.expect(previous >= 2 - api_tol); + try testing.expect(previous <= 3 + api_tol); + } else { + try testing.expectApproxEqAbs(@as(Real, 3), previous, api_tol); + } } } } From a03036e2257a5e9585876b77907441728773fcd4 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 00:05:10 +0200 Subject: [PATCH 063/100] docs(brief): cite the normative rule, and record the Windows flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delivered §1.12.6 retracts both earlier statements by name and carries the rule itself: two sources answering differently about the same geometric fact are a defect, never an envelope. The brief cites it rather than restating it as a local lesson. And the Windows `FileNotFound` runner flake is recorded as a tooling fact with its observed rate — five job failures over four runs, always inside 40 to 75 seconds, before any real compilation — its cold-cache correlation stated as a HYPOTHESIS since it was not measured, and the instruction not to pin `setup-zig` or the runner image against it: pinning against a flake freezes a dependency on a false premise, and a runner's failure rate is not a property of the repository. --- briefs/M1.1.12-character-controller.md | 33 +++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 978a3212..9455a443 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2555,9 +2555,12 @@ They have no object. The disagreement was consigned twice as a limit to declare stall, once for the f64 mesh freeze — and an envelope was written into the spec to dress it up. It was the root cause of four defects, and one change closed all four. -**The lesson outlives the milestone and is recorded as such: consigning a disagreement between two -sources about the same fact is never a stable state.** Two answers to one question is a defect, not an -envelope. It was pushed on twice from outside before it was read that way. +**The lesson outlives the milestone and is now NORMATIVE, not merely recorded here.** The delivered +§1.12.6 retracts both earlier statements by name — the ~10 km freeze regime and the disagreement +consigned as a design decision not taken — and carries the rule itself: *two sources answering +differently about the same geometric fact are a defect, never an envelope; consigning such a +disagreement as a limit lets it produce symptoms elsewhere under other names.* It was pushed on twice +from outside before it was read that way. ### Consignations that remain, each with its owner @@ -2589,6 +2592,30 @@ more than it asked — and every other cell keeps its exact expectation. a probe or by review.** The six-corner matrix runs two precisions and two optimisation modes on ONE target; it cannot see this class at all. Named here because the counter-measure is not another probe. +### Tooling fact — the Windows `FileNotFound` runner flake + +``` +error: failed to spawn build runner .zig-cache\o\\build.exe: FileNotFound +``` + +Zig writes the build runner into the cache and then cannot execute it. **Windows only, and not the +repository's:** the hash was IDENTICAL at every occurrence, and `build.zig` / `build.zig.zon` have not +changed since a fully green run twenty commits earlier. + +**Observed rate: 5 job failures over 4 runs**, always within 40–75 s — before any real compilation, a +Windows build of this tree taking 6 to 42 minutes. Two reruns did not clear it; a third did, and one run +carries its own refutation of any determinism claim: `build-and-test (windows-2025, Debug)` compiled and +passed the whole suite in 9 m 13 while `…, ReleaseSafe)` failed at 1 m 13 on the same commit and the same +cache hash. + +Correlated with a COLD cache — `Restore Zig cache` was `skipped` on every failing job. **Hypothesis, not +cause: I have not measured it**, and a warm-cache job never failing may only mean it never reached the +spawn. + +**Do not pin `weldengine/setup-zig` or the `windows-2025` image against it.** Pinning against a flake +freezes a dependency on a false premise, and a runner's failure rate is not a property of the repository +to compensate for inside the repository. Rerun; expect roughly one job in three to need it. + ### What the milestone leaves as method - **A disagreement between two sources is a defect, not an envelope.** Two envelope declarations were From 6e5ab012b228c1a7a5d32cf7b8ec262fc3497cff Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 08:56:07 +0200 Subject: [PATCH 064/100] fix(forge): classify a triangle after its cast, not on its face normal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh arm rejected a triangle BEFORE casting it, on the face normal, justified by "a translation cannot reach a plane it is parallel to". True of a plane, false of a triangle, which is finite and reachable by its EDGE. Reproduced: a quad platform with an open boundary edge, a capsule sweeping +X at four heights. The plain cast finds the edge at d = 1.70 to 2.00 with real opposing normals — (-0.55, 0.83, 0), (-0.94, 0.33, 0), (-1, 0, 0) — and the filtered entry answered null at every one. A character walked into a platform's edge, and having started separated the depenetration could never recover it. Cast first, classify on the contact's own normal. The trace decided the form: every edge contact on approach comes back at d > 0, where the cast's normal IS the contact's, so the d == 0 wall that forced the convex arm onto the manifold does not arise here; at d == 0 the face normal is the right substitute, that being the capsule resting on the face. The documentation of `castShapeBodyOpposing` and of the slide loop is rewritten to say the code: the excluded sub-shape, the delegation with null, the 0-excludes-the-body rule, the slot, the retry and its expiry are gone from the prose as they are from the source. --- briefs/M1.1.12-character-controller.md | 53 +++++++++++++++++++ src/modules/forge/forge_3d/body_manager.zig | 52 ++++++++++-------- src/modules/forge/forge_3d/character.zig | 23 +++----- .../forge/forge_3d/tests/character_test.zig | 47 ++++++++-------- 4 files changed, 116 insertions(+), 59 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 9455a443..dbf45585 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2642,3 +2642,56 @@ to compensate for inside the repository. Rerun; expect roughly one job in three `zig fmt --check src/ bench/ tests/` clean; tree-wide `zig build lint` exit 0; zero French in code. Eleven inherited envelope quantities at zero movement against `main`; all seventeen inherited forge test files byte-identical to the tag. + +### Gate G, eleventh closing round — an edge is not a plane + +#### P1 — a production regression, and the justification was the tell + +The mesh arm rejected a triangle BEFORE its cast, on the FACE normal, justified by *"a translation +cannot reach a plane it is parallel to"*. True of a PLANE, false of a TRIANGLE, which is finite and +reachable by its EDGE. The sentence was a property of one object applied to another — the same shape of +error this milestone has caught in comments twice before, this time load-bearing in code. + +REPRODUCED before fixing: a quad platform with an open boundary edge, a capsule sweeping `+X` at four +heights. The plain cast finds the edge at `d = 1.70` to `2.00` with real opposing normals — +`(−0.55, 0.83, 0)`, `(−0.94, 0.33, 0)`, `(−1, 0, 0)` — and the filtered entry answered **null at every +one**. A character walked into a platform's edge, and having started SEPARATED the depenetration could +never recover it. + +Cast first, classify on the contact's own normal. **And the trace decided the form**: every edge contact +on approach comes back at `d > 0`, where the cast's normal IS the contact's, so the `d == 0` wall that +forced the convex arm onto the manifold does not arise here. At `d == 0` the face normal is the right +substitute — a zero-distance triangle contact is the capsule RESTING on the face, the case the mechanism +exists for. + +Cost not measured: casting every triangle instead of skipping some is more work. That is a performance +question and the bench owns it, not the fix. + +#### P2 — the test I was given counts the wrong thing, and I could NOT make it discriminate + +`tris.len / 3 > 8` counted the mesh's triangles, not the contacts one call must set aside; with 1 m +cells under a 0.3 m radius fewer than eight are ever simultaneous. Rebuilt on the contact count — 0.1 m +cells, 3600 quads, the capsule spanning six across and crossing far more than eight in a metre — and the +duplicate broadphase insertion `World.addBody` already performs was removed. + +**It still does not discriminate.** Run against `1c91748`, which carries the bounded set of eight, it +PASSES; only the two stall pins fail there, and those flipped for an unrelated reason. So I have no +scene in which the ceiling of eight was reachable, and the non-regression this test is supposed to guard +is — on this evidence — hypothetical. Reported rather than dressed up: the test is kept because a floor +of 3600 triangles under a moving capsule is worth exercising, but it is NOT the judge it was asked to +be, and I could not build that judge. + +#### P3 — the contract now says the code + +`castShapeBodyExcluding`, the set-aside sub-shape, the delegation with `null` and the `0`-excludes-the- +body rule are gone from the documentation as they are from the code; so are the slide loop's slot, retry +and expiry. Zero occurrences of the dead names remain in either file. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 487/487 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 487/487 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1659/1676 (17 skipped) ×2 | +| `zig build` (plain) | 0 | the target the CI runs first | diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 1d937d61..1ded89f6 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -747,22 +747,22 @@ pub const BodyManager = struct { return self.castShapeBodyOpposing(store, id, cast_shape, cast_origin, cast_rotation, direction, max_distance, back_face_mode, false); } - /// `castShapeBody` with ONE sub-shape kept out of the competition. + /// `castShapeBody` restricted to contacts whose surface OPPOSES the sweep. + /// + /// A surface the sweep runs along or away from obstructs nothing, and a caller that resolves motion + /// wants the nearest OBSTACLE rather than the nearest contact. Selecting on that predicate instead + /// of selecting and then discarding is what makes it gapless: this entry returns ONE hit, so any + /// filter applied to its RESULT throws away every other sub-shape the cast never returned — three + /// earlier forms did exactly that, by body, by pair and by a bounded set, and each left a hole. /// /// **A SIBLING RATHER THAN A PARAMETER, and the reason is measured rather than stylistic.** Adding /// the argument to `castShapeBody` itself would touch fourteen call sites inside INHERITED test - /// files that are currently byte-identical to the tag — corroborating evidence this milestone has - /// leaned on twice. One caller needs the exclusion; the other three keep the entry they had, and - /// `castShapeBody` delegates here with `null`. - /// - /// **The exclusion is consumed DURING the mesh traversal, not after the cast**, and that placement - /// is the whole point: this entry returns ONE hit, the nearest, so a filter applied to its result - /// discards that sub-shape AND every other sub-shape of the body the cast never returned. Measured - /// — a mesh carrying a floor, a wall and a ceiling let the character walk straight through the - /// wall at `x = 12` when the filter sat above this call, and blocks at `1.7` with it inside. + /// files that are byte-identical to the tag. One caller needs the predicate; the others keep the + /// entry they had, and `castShapeBody` delegates here with `false`. /// - /// For `.convex` and `.half_space` the only sub-shape IS the body (§1.11.16), so `0` excludes it - /// entirely — which is exactly what a caller setting aside a resting floor half-space means. + /// Each shape class supplies the normal it actually has: the mesh classifies per triangle AFTER its + /// cast, on the contact's own normal; the half-space uses its STORED plane; the convex uses the + /// cast's normal, or the manifold's at distance zero where the cast's is `−direction`. pub fn castShapeBodyOpposing( self: *const BodyManager, store: *const ShapeStore, @@ -1841,17 +1841,6 @@ const MeshCastCollector = struct { pub fn add(self: *MeshCastCollector, triangle_index: u32) void { const face = self.data.faceNormal(triangle_index); if (self.back_face_mode == .ignore and narrowphase.triangle.isBackFace(Real, face, self.sweep_direction_local)) return; - // **THE NON-OPPOSING TEST, HERE — during selection and not after it.** A surface the sweep runs - // ALONG obstructs nothing: a translation cannot reach a plane it is parallel to unless it is - // already touching, and then the depenetration owns it. It is the back-face test's boundary - // case, `n · d == 0` exactly, which `isBackFace` leaves in on a strict `>`. - // - // Three rounds filtered AFTER selection — by body, by pair, by a bounded set — and each left a - // hole somewhere else, because this entry returns ONE hit and discarding it discards every - // sub-shape it never returned. Choosing the nearest OPPOSING triangle instead of the nearest - // one has no such gap, and it deletes the set, the budget, the expiry and the tessellation - // ceiling with it. - if (self.skip_non_opposing and face.dot(self.sweep_direction_local) >= 0) return; const hit = narrowphase.castShape( Real, self.cast_shape, @@ -1860,6 +1849,23 @@ const MeshCastCollector = struct { self.direction_in_a, self.bound, ) orelse return; + + // **THE NON-OPPOSING TEST, ON THE CONTACT'S OWN NORMAL AND AFTER THE CAST.** + // + // An earlier form rejected the triangle BEFORE the cast, on its FACE normal, justified by "a + // translation cannot reach a plane it is parallel to". That is true of a PLANE and false of a + // TRIANGLE, which is finite and reachable by its EDGE. MEASURED on a quad platform with an open + // boundary edge, a capsule sweeping `+X` at four heights: the plain cast finds the edge at + // `d = 1.70` to `2.00` with real opposing normals — `(−0.55, 0.83, 0)`, `(−0.94, 0.33, 0)`, + // `(−1, 0, 0)` — and the face-normal filter answered `null` at every one. A character walked + // into the edge of a platform, and starting SEPARATED the depenetration could not recover it. + // + // At `d > 0` the cast's normal IS the contact's. At `d == 0` it is `−direction` and useless, and + // the face normal is the right substitute there: a zero-distance triangle contact is the capsule + // RESTING on the face, which is the case the whole mechanism exists for. The trace says the two + // regimes do not overlap — every edge contact on approach came back at `d > 0`. + const contact_normal = if (hit.distance > 0) hit.normal else face; + if (self.skip_non_opposing and contact_normal.dot(self.sweep_direction_local) >= 0) return; if (self.best) |best| { if (hit.distance > best.distance) return; // Equal time of impact: the smaller triangle index wins, so the answer is a diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 947744a0..6c8ae7ec 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -1516,22 +1516,15 @@ pub const CharacterStore = struct { // 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. + // is what tangency then does — 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. // - // 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. - // Held with the DIRECTION it was judged against, and dropped the moment that direction - // changes: "does not oppose" is a statement about a contact AND a direction, so it expires - // when the slide reprojects the motion. Without that, a surface set aside once stayed set - // aside for the rest of the call even after becoming opposing. + // It is closed one level down: `sweepNearest` is asked for the nearest OPPOSING contact rather + // than the nearest one, so a non-obstructing surface never becomes a candidate and this loop + // needs no set, no budget and no expiry of its own. Four earlier forms lived here — a body slot, + // a pair key, a bounded set, and their retry accounting — and each was a filter placed above the + // data it judged. var iteration: u32 = 0; while (iteration < max_slide_iterations) : (iteration += 1) { const len_sq = remaining.lengthSq(); diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 944bc3be..63071c86 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3358,35 +3358,40 @@ test "the mesh scene behaves IDENTICALLY at both precisions — the f64 freeze i try testing.expect(p[0] > 1); } -test "a mesh floor of MORE than eight coplanar triangles serves the move" { +test "a mesh floor presenting MORE THAN EIGHT SIMULTANEOUS CONTACTS serves the move" { const gpa = testing.allocator; - // **NON-REGRESSION, and it has no mechanism to break today — which is why it is written.** An - // earlier form set contacts aside one at a time under a fixed ceiling of eight, so a floor - // tessellated finer than that stopped serving: the ninth contact spent the iteration the retry was - // meant to save. Filtering during selection has no such bound — a non-opposing triangle never - // becomes a candidate at all, however many there are. + // **THE FIRST VERSION OF THIS TEST COUNTED THE WRONG THING AND PROVED NOTHING.** It asserted + // `tris.len / 3 > 8` — the mesh's triangle count — and passed against the very implementation it + // was written to catch, the one that set contacts aside one at a time under a ceiling of eight. + // What saturates that ceiling is the number of triangles a SINGLE CALL must set aside, and with + // 1 m cells under a 0.3 m radius fewer than eight are ever in contact at once. // - // A 6 x 6 grid of unit quads, 72 coplanar triangles, with the capsule over the middle of it. This is - // the nominal case of a tessellated arena floor, not an exotic limit, and it guards against any - // future reintroduction of a bound on the number of contacts. + // So the cells are 0.1 m: a capsule of radius 0.3 spans six of them across, and its footprint plus + // the metre it travels crosses far more than eight in one call. Sized on the CONTACT count, and the + // judge is the mutation — this must fail against the bounded-set implementation, not merely pass + // against this one. var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); defer world.deinit(gpa); var chars: CharacterStore = .{}; defer chars.deinit(gpa); const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; - const side = 7; + const cells = 60; + const cell: f32 = 0.1; + const side = cells + 1; var verts: [side * side]V = undefined; for (0..side) |iz| { for (0..side) |ix| { - verts[iz * side + ix] = .{ .data = .{ @as(f32, @floatFromInt(ix)) - 3, 0, @as(f32, @floatFromInt(iz)) - 3 } }; + const fx = (@as(f32, @floatFromInt(ix)) - cells / 2) * cell; + const fz = (@as(f32, @floatFromInt(iz)) - cells / 2) * cell; + verts[iz * side + ix] = .{ .data = .{ fx, 0, fz } }; } } - var tris: [6 * 6 * 6]u32 = undefined; + var tris: [cells * cells * 6]u32 = undefined; var w: usize = 0; - for (0..6) |iz| { - for (0..6) |ix| { + for (0..cells) |iz| { + for (0..cells) |ix| { const a: u32 = @intCast(iz * side + ix); const b: u32 = @intCast(iz * side + ix + 1); const c: u32 = @intCast((iz + 1) * side + ix); @@ -3400,23 +3405,23 @@ test "a mesh floor of MORE than eight coplanar triangles serves the move" { w += 6; } } - try testing.expect(tris.len / 3 > 8); const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &tris } }); - const body = try world.addBody(gpa, .{ .entity = ent(490), .body_type = .static, .shape = shape }); - _ = try world.bp.insert(gpa, .static, world.bm.bodyAabb(&world.store, body).?, body); + // `World.addBody` inserts the broadphase proxy itself — a second insertion here would put the same + // body in the tree twice, which an earlier version of this test did. + _ = try world.addBody(gpa, .{ .entity = ent(490), .body_type = .static, .shape = shape }); var desc = baseDescriptor(); desc.entity = ent(491); - desc.position = av(-2, 0, 0); // exactly tangent, so every floor triangle under it is a contact + desc.position = av(-1, 0, 0); // exactly tangent, so every cell under the capsule is a contact desc.padding = 0; const id = try addMover(gpa, &world, &chars, desc); - var previous: Real = -2; + var previous: Real = -1; var k: u32 = 0; - while (k < 3) : (k += 1) { + 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); - try testing.expectApproxEqAbs(previous + 1, r.position.toArray()[0], 1e-4); + try testing.expectApproxEqAbs(previous + 1, r.position.toArray()[0], 1e-3); previous = r.position.toArray()[0]; } } From 0856d8b41d05758199e40aabb6d5bc87010cdffb Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 09:21:38 +0200 Subject: [PATCH 065/100] docs(forge): correct the d == 0 reason, and record P2's real state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment justified falling back to the face normal at distance zero by "a zero-distance triangle contact is the capsule resting on the face". That has counterexamples — a capsule at the exact rim of a platform touches an EDGE at zero — and the fallback is right for the other reason: at `d == 0` the capsule is already in contact, so the depenetration owns that case and works on the manifold, which carries the true normal, edges included. Which means the original argument was correct on its `d == 0` half and wrong on its `d > 0` half, and only the second is what this fix changes. Written that way rather than by a property of the pose. And the tessellation-ceiling test's state is recorded exactly: the ceiling was removed as a CLASS, not as an observed symptom. The dependency was structurally real and no scene reached it — 0.1 m cells, 3600 quads, six across under the capsule, still passing against the bounded implementation. The test is kept as coverage of that floor, not as the non-regression it was commissioned as. --- briefs/M1.1.12-character-controller.md | 17 ++++++++++++----- src/modules/forge/forge_3d/body_manager.zig | 10 +++++++--- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index dbf45585..72da01d9 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2675,11 +2675,18 @@ cells, 3600 quads, the capsule spanning six across and crossing far more than ei duplicate broadphase insertion `World.addBody` already performs was removed. **It still does not discriminate.** Run against `1c91748`, which carries the bounded set of eight, it -PASSES; only the two stall pins fail there, and those flipped for an unrelated reason. So I have no -scene in which the ceiling of eight was reachable, and the non-regression this test is supposed to guard -is — on this evidence — hypothetical. Reported rather than dressed up: the test is kept because a floor -of 3600 triangles under a moving capsule is worth exercising, but it is NOT the judge it was asked to -be, and I could not build that judge. +PASSES; only the two stall pins fail there, and those flipped for an unrelated reason. + +**So the exact state, recorded rather than smoothed: the tessellation ceiling was removed as a CLASS, not +as an observed symptom.** The dependency on tessellation was structurally real — a fixed bound on the +number of contacts one call may set aside is a bound on the mesh — and I found no scene that reached it: +0.1 m cells, 3600 quads, the capsule spanning six across, still passing against the bounded +implementation. The judge asked for may not exist. + +That changes nothing about the fix — the bounded mechanism is gone and four real defects went with it — +and the test is kept for what it IS: coverage of a 3600-triangle floor under a moving capsule, not the +non-regression it was commissioned as. No further scene will be built to saturate a mechanism that no +longer exists; that would be archaeology. #### P3 — the contract now says the code diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 1ded89f6..70ecde60 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -1861,9 +1861,13 @@ const MeshCastCollector = struct { // into the edge of a platform, and starting SEPARATED the depenetration could not recover it. // // At `d > 0` the cast's normal IS the contact's. At `d == 0` it is `−direction` and useless, and - // the face normal is the right substitute there: a zero-distance triangle contact is the capsule - // RESTING on the face, which is the case the whole mechanism exists for. The trace says the two - // regimes do not overlap — every edge contact on approach came back at `d > 0`. + // the face normal stands in — **not because a zero-distance contact is a face contact**, which is + // false: a capsule at the exact rim of a platform touches an EDGE at zero. The reason is the + // other half of the sentence this fix corrects: at `d == 0` the capsule is ALREADY in contact, so + // the depenetration owns that case and works on the MANIFOLD, which carries the true normal, + // edges included. Only the `d > 0` half of that original argument was wrong, and only it is + // changed here — the trace says the two regimes do not overlap, every edge contact on approach + // coming back at `d > 0`. const contact_normal = if (hit.distance > 0) hit.normal else face; if (self.skip_non_opposing and contact_normal.dot(self.sweep_direction_local) >= 0) return; if (self.best) |best| { From 34f0d817554f92a7ae75a28daecd6a656e83253f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 10:28:20 +0200 Subject: [PATCH 066/100] fix(forge): one frame for the mesh filter, manifold at zero distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter mixed frames: `hit.normal` is in the PROBE's frame and `sweep_direction_local` in the BODY's, so on a rotated mesh their dot had no geometric meaning. A local +Y face turned into a world -X wall was found by the plain cast and rejected by the filtering one, at both precisions. The `d > 0` branch now dots against `direction_in_a`. And an exactly-tangent ACTIVE EDGE was still traversable. I had justified the face normal at `d == 0` by "the depenetration owns that case"; measurement refutes it — a capsule tangent to an open boundary edge, moving parallel to the triangle's plane, is non-opposing on the FACE and was skipped, travelling from x = -0.3 to x = 0.672 with its base still at y = -0.3. The face normal is not the contact's there. `d == 0` now resolves that one triangle's manifold, in A's frame, and a null manifold means the narrowphase denies the cast's hit so nothing opposes the motion either. Both cases are permanent tests and both fail against the previous commit at both precisions. The stress test's discriminator claims are withdrawn — two versions both pass against the bounded implementation — and it is renamed to what it is. A `MeshCastCollector` comment describing the old set is removed; an earlier rewrite had left it truncated and spliced onto its replacement. --- briefs/M1.1.12-character-controller.md | 39 +++++++ src/modules/forge/forge_3d/body_manager.zig | 50 ++++++--- .../forge/forge_3d/tests/character_test.zig | 102 ++++++++++++++++-- 3 files changed, 166 insertions(+), 25 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 72da01d9..2ee4d76f 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2702,3 +2702,42 @@ and expiry. Zero occurrences of the dead names remain in either file. | `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 487/487 ×2 | | `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1659/1676 (17 skipped) ×2 | | `zig build` (plain) | 0 | the target the CI runs first | + +### Gate G, eleventh closing round — frames, edges, and a claim withdrawn + +Four findings, all verified against source before being acted on, all confirmed. + +**The filter mixed FRAMES on a rotated mesh.** `hit.normal` lives in the PROBE's frame — the collector's +own field doc says so — and `sweep_direction_local` in the BODY's. Their dot has no geometric meaning the +moment the body is rotated, and it rejected a real wall: a local `+Y` face turned into a world `−X` wall, +found by the plain cast and answered `null` by the filtering one, at both precisions. The `d > 0` branch +now dots against `direction_in_a`; the `d == 0` branch keeps the body frame, where the face normal lives. + +**An exactly-tangent ACTIVE EDGE was still traversable, and my justification for it was refuted by +measurement.** I had written that the face normal suffices at `d == 0` because "the depenetration owns +that case". A capsule tangent to a platform's open boundary edge, moving PARALLEL to the triangle's +plane, is non-opposing ON THE FACE and was skipped: measured from `x = −0.3` to `x = 0.672` with the base +still at `y = −0.3`. The face normal is not the contact's there — the edge's is — so `d == 0` resolves the +contact through that triangle's MANIFOLD, in A's frame, and a `null` manifold means the narrowphase +denies the cast's hit and nothing opposes the motion either. + +Both are now permanent tests, and both FAIL against the previous commit at both precisions. + +**The stress test's claims are withdrawn rather than defended.** It was commissioned as a discriminator +for the removed tessellation ceiling; two versions were written, one counting triangles and one counting +cells, and BOTH pass against the bounded implementation when replayed against it. Its title said +"more than eight simultaneous contacts" and its comment said "must fail against the bounded-set +implementation" — the brief already recorded the truth while the test still asserted the opposite. It is +renamed to what it is, a 7200-triangle stress scene, and the claim is deleted. + +And a `MeshCastCollector` field comment still described the old set; my earlier rewrite had left it +truncated and spliced onto its replacement. + +#### Validation + +| Corner | Exit | Result | +|---|---|---| +| `test-forge-3d`, Debug, f32 / f64 | 0 / 0 | 489/489 ×2 | +| `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 489/489 ×2 | +| `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1661/1678 (17 skipped) ×2 | +| `zig build` (plain) | 0 | the target the CI runs first | diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 70ecde60..2f17aa16 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -1825,11 +1825,7 @@ const MeshCastCollector = struct { /// Sweep direction in the BODY's local frame — what the facing test takes. sweep_direction_local: Vec3r, back_face_mode: api.BackFaceMode, - /// The triangles to keep out of the competition entirely — a SET, because a surface is N coplanar - /// triangles and one slot was built for one contact. Consumed HERE, during the traversal, and - /// not by the caller afterwards: `castShapeBody` returns ONE hit, the nearest, so a filter applied - /// to its result discards that triangle AND every other triangle of the body the cast never - /// Skip any triangle whose plane does not OPPOSE the sweep. See `castShapeBodyOpposing`. + /// Skip any triangle whose CONTACT does not oppose the sweep. See `castShapeBodyOpposing`. skip_non_opposing: bool, bound: Real, /// The KERNEL's hit type, in the probe's frame — not `BodyCastHit`. The shared mapping @@ -1860,16 +1856,40 @@ const MeshCastCollector = struct { // `(−1, 0, 0)` — and the face-normal filter answered `null` at every one. A character walked // into the edge of a platform, and starting SEPARATED the depenetration could not recover it. // - // At `d > 0` the cast's normal IS the contact's. At `d == 0` it is `−direction` and useless, and - // the face normal stands in — **not because a zero-distance contact is a face contact**, which is - // false: a capsule at the exact rim of a platform touches an EDGE at zero. The reason is the - // other half of the sentence this fix corrects: at `d == 0` the capsule is ALREADY in contact, so - // the depenetration owns that case and works on the MANIFOLD, which carries the true normal, - // edges included. Only the `d > 0` half of that original argument was wrong, and only it is - // changed here — the trace says the two regimes do not overlap, every edge contact on approach - // coming back at `d > 0`. - const contact_normal = if (hit.distance > 0) hit.normal else face; - if (self.skip_non_opposing and contact_normal.dot(self.sweep_direction_local) >= 0) return; + // **BOTH REGIMES CLASSIFY ON THE CONTACT, AND EACH IN ITS OWN FRAME.** + // + // At `d > 0` the cast's normal IS the contact's, and it lives in the PROBE's frame — so it is + // dotted with `direction_in_a` and never with `sweep_direction_local`, which is the BODY's. An + // earlier form mixed the two, and on a ROTATED mesh the product had no geometric meaning at all: + // a local `+Y` face turned into a world `−X` wall was hit by the plain cast and rejected by this + // one, at both precisions. + // + // At `d == 0` the cast's normal is `−direction` and carries nothing, so the contact is resolved + // by the MANIFOLD of that one triangle. The face normal was used here and it is NOT enough: a + // capsule exactly tangent to an ACTIVE EDGE, moving parallel to the triangle's plane, is rejected + // on the face normal and traverses — measured from `x = −0.3` to `x = 0.672` with the base still + // at `y = −0.3`, at both precisions. The hypothesis that "the depenetration owns that case" is + // refuted by that measurement, and the manifold is what carries an edge's real normal. + if (self.skip_non_opposing) { + if (hit.distance > 0) { + if (hit.normal.dot(self.direction_in_a) >= 0) return; + } else { + // The triangle alone, in A's frame: A at the origin, B at the relative pose the kernel + // already holds. `null` means the narrowphase denies the contact the cast reported — the + // spurious hit — and nothing there opposes the motion either. + const m = narrowphase.collideOrdered( + Real, + self.cast_shape, + Vec3r.zero, + Quatr.identity, + shape_mod.triangleSupportShape(self.data, triangle_index), + self.relpose.pos_rel, + self.relpose.rot_rel, + ) orelse return; + // `collideOrdered` returns probe → body; the opposing test wants surface → probe. + if (m.normal.neg().dot(self.direction_in_a) >= 0) return; + } + } if (self.best) |best| { if (hit.distance > best.distance) return; // Equal time of impact: the smaller triangle index wins, so the answer is a diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 63071c86..3cb7c4fb 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3358,19 +3358,22 @@ test "the mesh scene behaves IDENTICALLY at both precisions — the f64 freeze i try testing.expect(p[0] > 1); } -test "a mesh floor presenting MORE THAN EIGHT SIMULTANEOUS CONTACTS serves the move" { +test "STRESS: a 7200-triangle floor under a moving capsule" { const gpa = testing.allocator; - // **THE FIRST VERSION OF THIS TEST COUNTED THE WRONG THING AND PROVED NOTHING.** It asserted - // `tris.len / 3 > 8` — the mesh's triangle count — and passed against the very implementation it - // was written to catch, the one that set contacts aside one at a time under a ceiling of eight. - // What saturates that ceiling is the number of triangles a SINGLE CALL must set aside, and with - // 1 m cells under a 0.3 m radius fewer than eight are ever in contact at once. + // **A STRESS TEST, AND ITS TITLE SAYS SO — it was commissioned as a discriminator and it is not + // one.** The intent was to catch a form that set contacts aside one at a time under a fixed ceiling + // of eight, so a floor tessellated finer than that would stop serving. Two versions were written: + // the first counted the mesh's TRIANGLES, which is not what saturates such a ceiling; the second + // shrank the cells to 0.1 m so the capsule spans six of them. **Both pass against the bounded + // implementation**, replayed against it directly. // - // So the cells are 0.1 m: a capsule of radius 0.3 spans six of them across, and its footprint plus - // the metre it travels crosses far more than eight in one call. Sized on the CONTACT count, and the - // judge is the mutation — this must fail against the bounded-set implementation, not merely pass - // against this one. + // So no scene was found in which that ceiling was reachable. The dependency on tessellation was + // structurally real — a fixed bound on the contacts one call may set aside is a bound on the mesh — + // and it was removed as a CLASS, not as an observed symptom. The claim of being a non-regression is + // withdrawn rather than left standing over a test that cannot support it. + // + // What it IS: 3600 quads under a capsule crossing them at exact tangency, which is worth running. var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); defer world.deinit(gpa); var chars: CharacterStore = .{}; @@ -3425,3 +3428,82 @@ test "a mesh floor presenting MORE THAN EIGHT SIMULTANEOUS CONTACTS serves the m previous = r.position.toArray()[0]; } } + +test "a ROTATED mesh: the opposing filter must not mix frames" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + + // **THE FRAME MIX, REPRODUCED.** The cast's normal lives in the PROBE's frame and the face normal in + // the BODY's. Dotting the first against `sweep_direction_local` is meaningless the moment the body + // is rotated, and it rejected a real wall: a local `+Y` face turned into a world `−X` wall is found + // by the plain cast and was answered `null` by the filtering one. + const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; + // A quad in the local XZ plane, face normal local +Y. + const verts = [_]V{ + .{ .data = .{ -2, 0, -2 } }, .{ .data = .{ 2, 0, -2 } }, .{ .data = .{ -2, 0, 2 } }, .{ .data = .{ 2, 0, 2 } }, + }; + const tris = [_]u32{ 0, 2, 1, 1, 2, 3 }; + const shape = try store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &tris } }); + // Rotated +90° about Z: local +Y becomes world −X, i.e. a wall facing an approach from −X. + const body = try bm.addBody(gpa, &store, .{ + .entity = ent(884), + .body_type = .static, + .shape = shape, + .position = av(2, 0, 0), + .rotation = math.Quatf.fromAxisAngle(av(0, 0, 1), std.math.pi / 2.0), + }); + + const probe = SupportShapeR{ .core = .{ .segment = 0.6 }, .radius = 0.3 }; + const plain = bm.castShapeBody(&store, body, probe, v(-2, 0, 0), Quatr.identity, v(1, 0, 0), 10, .ignore); + const filtered = bm.castShapeBodyOpposing(&store, body, probe, v(-2, 0, 0), Quatr.identity, v(1, 0, 0), 10, .ignore, true); + + // The wall opposes the sweep, so BOTH must find it and at the same distance. + try testing.expect(plain != null); + try testing.expect(filtered != null); + try testing.expectApproxEqAbs(plain.?.distance, filtered.?.distance, api_tol); +} + +test "an ACTIVE EDGE at exact tangency blocks — the face normal is not enough" { + 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 EDGE-TANGENCY CASE, REPRODUCED.** A capsule exactly tangent to a platform's open boundary + // edge, moving PARALLEL to the triangle's plane. Classified on the FACE normal that contact is + // non-opposing — the motion runs along the plane — so it was skipped and the character traversed: + // measured from `x = −0.3` to `x = 0.672`, base still at `y = −0.3`, at both precisions. The face + // normal is not the contact's here; the edge's is, and only the manifold carries it. + // + // `step_height = 0` so no climb can rescue the case and hide the traversal. + const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; + const verts = [_]V{ + .{ .data = .{ 0, 0, -2 } }, .{ .data = .{ 4, 0, -2 } }, .{ .data = .{ 0, 0, 2 } }, .{ .data = .{ 4, 0, 2 } }, + }; + const tris = [_]u32{ 0, 2, 1, 1, 2, 3 }; + const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &tris } }); + _ = try world.addBody(gpa, .{ .entity = ent(886), .body_type = .static, .shape = shape }); + + var desc = baseDescriptor(); + desc.entity = ent(887); + desc.step_height = 0; + desc.padding = 0; + // Base at −0.3: the capsule's widest point is level with the platform's plane, so its surface meets + // the boundary edge at x = 0 exactly, and the motion is parallel to that plane. + desc.position = av(-0.3, -0.3, 0); + const id = try addMover(gpa, &world, &chars, desc); + + var k: u32 = 0; + while (k < 3) : (k += 1) { + _ = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(1, 0, 0), 1.0 / 60.0); + } + const p = chars.get(id).?.position.toArray(); + + // BLOCKED by the edge: the capsule's surface reaches `radius` ahead of its base, so the base cannot + // pass `0 − 0.3`. The measured traversal put it at 0.672, three quarters of a metre past that. + try testing.expect(p[0] <= -0.3 + 1e-3); +} From 2aebca77671cb82d24b77e7fc2c8573c33056f82 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 11:12:06 +0200 Subject: [PATCH 067/100] test(forge): yaw the mesh corpus, rest the tangency sweeps on an edge Two axes applied to scenes that already existed, not to new ones. Yaw: scene and query turn together about +Y, so every distance is invariant and the historic expectations hold verbatim while the probe and body frames diverge. Six angles on the three mesh scenes. The floor-and-wall scene passes on HEAD and walks 12 m THROUGH the wall at 180 and 213 on the parent commit, so the axis pins the selection filter. The 7200-triangle flat floor is exactly invariant, digit for digit. The floor+wall+ceiling scene is NOT invariant and that is the one finding: a 1.8 m capsule under a 1 m ceiling is an unresolvable squeeze, and whether it resolves depends on the yaw. Without the ceiling, 1.6999 at every angle. The parent tunnels where HEAD freezes, so it is pre-existing and exposed rather than introduced. Reported, not absorbed: an exact distance asserted over an unresolvable squeeze would be a test announcing more than it is. Edge: both tangency sweeps rested on a half-space, which has no edges. A flat internal edge was built first and measured inert, since either coplanar face answers the same +Y. What ships is a 10 degree convex ridge whose apex runs under the whole path; the ground normal reads exactly (0, 1, 0), which neither face can produce, and a mutation substituting a face normal takes it down. One start-height leg of the isolation was vacuous, the sweep variable never reaching the descriptor, and the conclusion drawn from it is retracted in the brief. The stress scene had been left with a print in place of its assertion during the isolation and is restored. No production change. --- briefs/M1.1.12-character-controller.md | 74 +++ .../forge/forge_3d/tests/character_test.zig | 539 +++++++++++------- 2 files changed, 417 insertions(+), 196 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 2ee4d76f..534a6db5 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2741,3 +2741,77 @@ truncated and spliced onto its replacement. | `test-forge-3d`, ReleaseSafe, f32 / f64 | 0 / 0 | 489/489 ×2 | | `zig build test`, Debug / ReleaseSafe | 0 / 0 | 264/264 steps, 1661/1678 (17 skipped) ×2 | | `zig build` (plain) | 0 | the target the CI runs first | + +### Gate G, twelfth closing round — the corpus pass, two axes on scenes that already existed + +Commissioned as a corpus pass and not as new tests: apply two axes to the scenes +already in the suite, because writing a fresh reproduction beside a blind corpus +leaves the corpus blind. The first attempt did write two new tests instead, which +is recorded here as the instruction not followed rather than quietly repaired. + +**Axis 1 — a non-trivial yaw on the mesh body.** Every mesh scene was axis-aligned, +so a frame mix is structurally invisible in all of them. The technique is to turn +the SCENE and the QUERY by the same angle about `+Y`: the body-local configuration +is then identical at every angle, so every distance is invariant and the historic +expectations hold verbatim — while the probe frame and the body frame, which agree +only at 0°, diverge. Six angles: `0, 5, 37, 90, 213, 350`. + +Applied to the three mesh scenes. What it produced: + +- **The floor-and-wall scene passes at six yaws on HEAD and FAILS on the parent + commit `0856d8b`**, where the character walks 12 m THROUGH the wall and off the + floor at 180° and 213°, and 2.218 m at 135°. So the axis is a non-vacuous pin of + the selection filter delivered last round, not decoration. +- **The 7200-triangle flat floor is EXACTLY invariant** across fourteen angles, + digit for digit (`0.000000` then `1.000000`, resting `y = 0.000014` everywhere). + The mesh contact path is yaw-correct. +- **The floor+wall+CEILING scene is NOT invariant, and that is the round's one + finding.** The capsule is 1.8 m tall under a ceiling 1 m above the floor, so the + squeeze is UNRESOLVABLE and reverting to the entry pose is the specified answer; + what varies with yaw is whether it resolves at all. At a tangent base: 1.699977 at + 0° and 213°, exactly 0 at 5°/37°/90°/350°, 0.459 at 45°. Lifted to 0.05: correct at + 5°/37°/90°/350°, 0.459 at 0° and 45°. **Without the ceiling: 1.6999 at every angle + and both heights**, which localises it to the squeeze and to nothing else. + On the parent the same scene TUNNELS rather than freezing, so HEAD is strictly the + safer of the two — but neither is invariant, so this is pre-existing and exposed, + not introduced. Reported for arbitration, not absorbed and not fixed here: the + scene's own claim is a precision claim, and asserting an exact distance over an + unresolvable squeeze would be a test announcing more than it is. + +**A vacuous probe of my own, caught mid-round.** The start-height leg of that +isolation was measured twice against the same configuration: the sweep variable was +never written to `desc.position`, because an earlier revert had removed the +assignment along with an unrelated one. The first conclusion drawn from it — that +the freeze is independent of the start height, hence not the tangency class — was +therefore unsupported and is retracted; re-measured with the height actually +applied, height and yaw interact. Tenth apparatus defect of this milestone. + +**Axis 2 — an EDGE tangency on the existing tangency cases.** Both tangency sweeps +rested on a half-space, which has no edges at all: several heights, several +paddings, never an edge. Both are replayed against a mesh floor, with expectations +unchanged. + +A FLAT internal edge was built first and **MEASURED INERT**: two coplanar quads put +the contact point on the edge, but either face answers the same `+Y`, and reversing +one quad's winding — a mutation making it back-facing, hence absent — changed no +assertion in either sweep. What ships is a symmetric convex RIDGE folded 10° on each +side, whose apex line runs under the whole path. A capsule's lowest point is a single +point and on such a ridge it lies on the apex line whatever the fold, so the contact +is genuinely edge-borne while the resting height stays the plane's. + +Both sweeps pass on the ridge, at all seven heights and all six paddings, with the +resting heights matching the half-space digit for digit. And the ground normal is +measured at exactly `(0, 1, 0)`, which **neither face can produce** — theirs carry a +`y` of `cos 10° = 0.98481` — so the assertion added for it excludes both by +construction rather than by margin. Pinned with a MUTATION probe: substituting a +face normal at the assertion site takes the sweep down. + +**Net: one finding, no production change.** The engine is correct on both axes +except in an unresolvable squeeze, where it is yaw-dependent on HEAD and on the +parent alike. `character.zig` and `body_manager.zig` are untouched by this round. +489/489 green at f32 AND `-Dphysics_f64=true`, Debug AND ReleaseSafe; `fmt`, `lint` +and a plain `zig build` all exit 0. + +A related apparatus defect was caught and closed in the same round: the stress +scene had been left carrying a debug print in place of its per-call assertion +during the isolation, so it asserted nothing. Restored and re-verified. diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 3cb7c4fb..134336e1 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -875,6 +875,62 @@ fn addPlane(gpa: std.mem.Allocator, world: *harness.World, normal: ApiVec3, dist }); } +/// Rotate a world vector about `+Y` by `deg` degrees. +/// +/// **The corpus axis for frame bugs.** Rotating the SCENE and the QUERY by the same angle leaves every +/// distance and every clearance invariant, so an expectation written for the axis-aligned case holds +/// verbatim for the rotated one — while the probe frame and the body frame, which agree only when the +/// body is unrotated, diverge. A predicate that mixes the two is INVISIBLE at 0° and wrong at anything +/// else, which is precisely how one survived here until an external review found it by reading rather +/// than by running: every mesh scene in this suite was axis-aligned. +fn rotY(deg: f32, x: f32, z: f32) [2]f32 { + const r = deg * std.math.pi / 180.0; + const c = @cos(r); + const sn = @sin(r); + return .{ c * x + sn * z, -sn * x + c * z }; +} + +fn bodyYaw(deg: f32) math.Quatf { + return math.Quatf.fromAxisAngle(av(0, 1, 0), deg * std.math.pi / 180.0); +} + +/// The angles every mesh scene is replayed at. `0` keeps the historical case exactly, `90` is the axis +/// swap, and `5`/`37`/`213`/`350` are non-trivial in both hemispheres and near both ends of the turn. +const corpus_yaws = [_]f32{ 0, 5, 37, 90, 213, 350 }; + +/// A mesh RIDGE whose apex line runs along `z = 0` at `y = 0`, both faces sloping away and down, so a +/// capsule travelling the `+X` axis is tangent to that EDGE for its whole path. +/// +/// **The second corpus axis.** Every tangency case in this suite rests on a half-space or on the +/// interior of a triangle — several heights, several paddings, never an edge. A capsule's lowest point +/// is a single point, and on a symmetric downward ridge that point lies on the apex LINE whatever the +/// fold angle, so the contact is genuinely edge-borne while the resting height is the plane's: the +/// expectations at the call sites are unchanged, and a divergence would be the contact-normal source +/// and nothing else. +/// +/// **A FLAT internal edge was tried first and MEASURED INERT**, which is why it is not what ships: +/// with two coplanar quads the contact point sits on the edge but either face answers the same `+Y`, +/// and reversing one quad's winding — a mutation that makes it back-facing and therefore absent — +/// changed no assertion in either sweep. A ridge cannot be satisfied by one face alone. +fn addEdgeFloor(gpa: std.mem.Allocator, world: *harness.World, entity_index: u32) !api.BodyId { + const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; + const drop = 5.0 * @tan(10.0 * std.math.pi / 180.0); // a 10° fold on each side + const verts = [_]V{ + .{ .data = .{ -5, -drop, -5 } }, .{ .data = .{ 5, -drop, -5 } }, + .{ .data = .{ -5, 0, 0 } }, .{ .data = .{ 5, 0, 0 } }, + .{ .data = .{ -5, -drop, 5 } }, .{ .data = .{ 5, -drop, 5 } }, + }; + // Wound so both faces point upward. The shared apex edge is 2 → 3, along `z = 0` at `y = 0`. + const tris = [_]u32{ 0, 2, 1, 1, 2, 3, 2, 4, 3, 3, 4, 5 }; + const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &tris } }); + 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 { @@ -2902,43 +2958,66 @@ test "a base EXACTLY on the floor is no longer frozen — seven heights, both di // `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. + // + // **AND ON AN EDGE, not only on a face.** The sweep ran against a half-space, which has no edges at + // all; it is replayed here against a flat mesh floor whose internal edge runs exactly under the + // capsule's path, so the tangency is an EDGE tangency at every one of the seven heights. The + // surface is geometrically the same plane, so every expectation below is unchanged — which is the + // point: a divergence would be the contact-normal source and nothing else. 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); + for ([_]bool{ false, true }) |on_edge| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + if (on_edge) { + _ = try addEdgeFloor(gpa, &world, 420); + } else { + _ = 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); + 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 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, - ); + // **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); + if (on_edge and !airborne) { + // **AND THE NORMAL IS THE EDGE'S, WHICH IS THE WHOLE POINT OF THE AXIS.** Neither face + // of a 10° ridge can produce this: their normals carry a `y` of `cos 10° = 0.98481`, + // and the bound below admits nothing under `cos 1°`. Measured exactly `(0, 1, 0)` at + // every height, so the tolerance is slack by four orders and the exclusion is the + // assertion's substance rather than its margin. + const gn = r.ground.normal.data; + try testing.expect(gn[1] > @cos(1.0 * std.math.pi / 180.0)); + try testing.expectApproxEqAbs(@as(f32, 0), gn[0], 1e-5); + try testing.expectApproxEqAbs(@as(f32, 0), gn[2], 1e-5); + } + try testing.expectEqual( + if (airborne) api.GroundState.in_air else api.GroundState.grounded, + r.ground.state, + ); + } } } @@ -2953,25 +3032,35 @@ test "the stand-off floor serves a zero padding, and its scale limit is MEASURED // `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. + // + // Replayed on an EDGE as well as on a face: the second corpus axis. A half-space has no edges, so + // every padding in this sweep tested one contact geometry. The mesh floor puts the capsule's whole + // path on an internal edge, and the numbers must not move. 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); + for ([_]bool{ false, true }) |on_edge| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + if (on_edge) { + _ = try addEdgeFloor(gpa, &world, 440); + } else { + _ = 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]; + // 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]; + } } } @@ -3159,7 +3248,6 @@ test "DOMAIN TABLE — measured behaviour at every legal bound of the descriptor } 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).?); } } @@ -3242,80 +3330,90 @@ test "the stand-off floor never overrides a requested padding, and the stall is } } -test "one mesh carrying both floor and wall: the wall still blocks" { +test "one mesh carrying both floor and wall: the wall still blocks, at six yaws" { 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 ROTATION AXIS APPLIED TO AN EXISTING SCENE.** The body is yawed and the query is yawed with + // it, so every expectation below is the axis-aligned one verbatim while the two frames diverge. + for (corpus_yaws) |yaw| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); - // **THE COUNTER-TEST FOR AN EXCLUSION COARSER THAN ITS VERDICT.** The non-opposing retry sets a - // contact aside; if it set aside the whole BODY, a single mesh carrying a floor and a wall would - // lose the wall along with the floor triangle, and the character would walk straight through it. - // The key is the pair `(body, subshape_id)`, and this is what measures that. - // - // ONE mesh, ONE body: a floor spanning x ∈ [−2, 4] at y = 0, and a wall standing on it at x = 2, - // rising to y = 3. Two quads, four triangles, all in the same index buffer. - const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; - const verts = [_]V{ - // floor at y = 0, wound so the face normal is +Y - .{ .data = .{ -2, 0, -2 } }, // 0 - .{ .data = .{ 4, 0, -2 } }, // 1 - .{ .data = .{ -2, 0, 2 } }, // 2 - .{ .data = .{ 4, 0, 2 } }, // 3 - // wall at x = 2. A mesh is SINGLE-SIDED, so the winding decides whether the wall exists at - // all from where the character comes: a first version wound both triangles to +X and the - // character walked to x = 12, through the wall and off the end of the floor. That was the - // test's own defect, not the engine's, and it is written here because a back-facing wall - // reads exactly like a traversable one. - .{ .data = .{ 2, 3, -2 } }, // 4 - .{ .data = .{ 2, 3, 2 } }, // 5 - .{ .data = .{ 2, 0, -2 } }, // 6 - .{ .data = .{ 2, 0, 2 } }, // 7 - // CEILING at y = 1, normal −Y, low enough to squeeze a 1.8 m capsule. It is what makes the - // retry fire at all: a ceiling's downward normal does not oppose a horizontal motion, so the - // contact is set aside — and it is on the SAME body as the wall, which is the whole point. - .{ .data = .{ -2, 1, -2 } }, // 8 - .{ .data = .{ 4, 1, -2 } }, // 9 - .{ .data = .{ -2, 1, 2 } }, // 10 - .{ .data = .{ 4, 1, 2 } }, // 11 - }; - const tris = [_]u32{ - 0, 2, 1, 1, 2, 3, // floor, +Y - 6, 7, 4, 7, 5, 4, // wall, −X - 8, 9, 10, 9, 11, 10, // ceiling, −Y - }; - const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &tris } }); - const body = try world.addBody(gpa, .{ - .entity = ent(470), - .body_type = .static, - .shape = shape, - }); - _ = try world.bp.insert(gpa, .static, world.bm.bodyAabb(&world.store, body).?, body); + // **THE COUNTER-TEST FOR AN EXCLUSION COARSER THAN ITS VERDICT.** The non-opposing retry sets a + // contact aside; if it set aside the whole BODY, a single mesh carrying a floor and a wall would + // lose the wall along with the floor triangle, and the character would walk straight through it. + // The key is the pair `(body, subshape_id)`, and this is what measures that. + // + // ONE mesh, ONE body: a floor spanning x ∈ [−2, 4] at y = 0, and a wall standing on it at x = 2, + // rising to y = 3. Two quads, four triangles, all in the same index buffer. + const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; + const verts = [_]V{ + // floor at y = 0, wound so the face normal is +Y + .{ .data = .{ -2, 0, -2 } }, // 0 + .{ .data = .{ 4, 0, -2 } }, // 1 + .{ .data = .{ -2, 0, 2 } }, // 2 + .{ .data = .{ 4, 0, 2 } }, // 3 + // wall at x = 2. A mesh is SINGLE-SIDED, so the winding decides whether the wall exists at + // all from where the character comes: a first version wound both triangles to +X and the + // character walked to x = 12, through the wall and off the end of the floor. That was the + // test's own defect, not the engine's, and it is written here because a back-facing wall + // reads exactly like a traversable one. + .{ .data = .{ 2, 3, -2 } }, // 4 + .{ .data = .{ 2, 3, 2 } }, // 5 + .{ .data = .{ 2, 0, -2 } }, // 6 + .{ .data = .{ 2, 0, 2 } }, // 7 + // CEILING at y = 1, normal −Y, low enough to squeeze a 1.8 m capsule. It is what makes the + // retry fire at all: a ceiling's downward normal does not oppose a horizontal motion, so the + // contact is set aside — and it is on the SAME body as the wall, which is the whole point. + .{ .data = .{ -2, 1, -2 } }, // 8 + .{ .data = .{ 4, 1, -2 } }, // 9 + .{ .data = .{ -2, 1, 2 } }, // 10 + .{ .data = .{ 4, 1, 2 } }, // 11 + }; + const tris = [_]u32{ + 0, 2, 1, 1, 2, 3, // floor, +Y + 6, 7, 4, 7, 5, 4, // wall, −X + 8, 9, 10, 9, 11, 10, // ceiling, −Y + }; + const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &tris } }); + const body = try world.addBody(gpa, .{ + .entity = ent(470), + .body_type = .static, + .shape = shape, + .rotation = bodyYaw(yaw), + }); + _ = try world.bp.insert(gpa, .static, world.bm.bodyAabb(&world.store, body).?, body); - var desc = baseDescriptor(); - desc.entity = ent(471); - desc.position = av(0, 0, 0); - desc.padding = 0; - const id = try addMover(gpa, &world, &chars, desc); + var desc = baseDescriptor(); + desc.entity = ent(471); + desc.position = av(0, 0, 0); + desc.padding = 0; + const id = try addMover(gpa, &world, &chars, desc); - // Walk hard into the wall, several calls, so a leak would show as unbounded travel. - var k: u32 = 0; - while (k < 4) : (k += 1) { - _ = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(3, 0, 0), 1.0 / 60.0); + // Walk hard into the wall, several calls, so a leak would show as unbounded travel. The start is + // ON the yaw axis, so only the direction turns. + const d = rotY(yaw, 3, 0); + var k: u32 = 0; + while (k < 4) : (k += 1) { + _ = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(d[0], 0, d[1]), 1.0 / 60.0); + } + const p = chars.get(id).?.position.toArray(); + // Distance travelled ALONG the yawed direction — the quantity the rotation leaves invariant. + const u = rotY(yaw, 1, 0); + const along = p[0] * u[0] + p[2] * u[1]; + + // **THE JUDGE OF THE TUNNELING FIX, and it is precision-INDEPENDENT.** Never past the wall. With + // the exclusion applied ABOVE the cast — the state this replaces — the character walks through at + // BOTH precisions, so this single inequality discriminates the fix exactly and needs no per- + // precision shape. + try testing.expect(along < 2); + // Standing on the floor triangle it set aside, not fallen through it. + try testing.expectEqual(api.GroundState.grounded, chars.reportedGround(id).?); } - const p = chars.get(id).?.position.toArray(); - - // **THE JUDGE OF THE TUNNELING FIX, and it is precision-INDEPENDENT.** Never past the wall. With - // the exclusion applied ABOVE the cast — the state this replaces — the character walks through at - // BOTH precisions, so this single inequality discriminates the fix exactly and needs no per- - // precision shape. - try testing.expect(p[0] < 2); - // Standing on the floor triangle it set aside, not fallen through it. - try testing.expectEqual(api.GroundState.grounded, chars.reportedGround(id).?); } -test "the mesh scene behaves IDENTICALLY at both precisions — the f64 freeze is closed" { +test "the mesh scene behaves IDENTICALLY at both precisions, at six yaws" { const gpa = testing.allocator; // **THIS TEST PINNED A PRECISION SPLIT AND THE SPLIT IS GONE.** It read: the same floor/wall/ceiling @@ -3324,41 +3422,78 @@ test "the mesh scene behaves IDENTICALLY at both precisions — the f64 freeze i // selection closed it: f64 now reads 1.6999999880790453 where it read 0. // // So the expectation is the SAME at both precisions, which is what a geometric answer should be. - var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); - defer world.deinit(gpa); - var chars: CharacterStore = .{}; - defer chars.deinit(gpa); - - const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; - const verts = [_]V{ - .{ .data = .{ -2, 0, -2 } }, .{ .data = .{ 4, 0, -2 } }, .{ .data = .{ -2, 0, 2 } }, .{ .data = .{ 4, 0, 2 } }, - .{ .data = .{ 2, 3, -2 } }, .{ .data = .{ 2, 3, 2 } }, .{ .data = .{ 2, 0, -2 } }, .{ .data = .{ 2, 0, 2 } }, - .{ .data = .{ -2, 1, -2 } }, .{ .data = .{ 4, 1, -2 } }, .{ .data = .{ -2, 1, 2 } }, .{ .data = .{ 4, 1, 2 } }, - }; - const tris = [_]u32{ 0, 2, 1, 1, 2, 3, 6, 7, 4, 7, 5, 4, 8, 9, 10, 9, 11, 10 }; - const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &tris } }); - const body = try world.addBody(gpa, .{ .entity = ent(480), .body_type = .static, .shape = shape }); - _ = try world.bp.insert(gpa, .static, world.bm.bodyAabb(&world.store, body).?, body); - - var desc = baseDescriptor(); - desc.entity = ent(481); - desc.position = av(0, 0, 0); - desc.padding = 0; - const id = try addMover(gpa, &world, &chars, desc); - - var k: u32 = 0; - while (k < 4) : (k += 1) { - _ = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(3, 0, 0), 1.0 / 60.0); + // + // The rotation axis applies here too: a precision claim and a frame claim are different claims, and + // a scene that carries one should carry the other rather than leave a second blind spot beside the + // first. Scene and query turn together, so the expectation below is unchanged. + for (corpus_yaws) |yaw| { + for ([_]f32{ 0, 0.05 }) |y0| { + for ([_]bool{ true, false }) |ceiling| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); + + const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; + const verts = [_]V{ + .{ .data = .{ -2, 0, -2 } }, .{ .data = .{ 4, 0, -2 } }, .{ .data = .{ -2, 0, 2 } }, .{ .data = .{ 4, 0, 2 } }, + .{ .data = .{ 2, 3, -2 } }, .{ .data = .{ 2, 3, 2 } }, .{ .data = .{ 2, 0, -2 } }, .{ .data = .{ 2, 0, 2 } }, + .{ .data = .{ -2, 1, -2 } }, .{ .data = .{ 4, 1, -2 } }, .{ .data = .{ -2, 1, 2 } }, .{ .data = .{ 4, 1, 2 } }, + }; + const tris_all = [_]u32{ 0, 2, 1, 1, 2, 3, 6, 7, 4, 7, 5, 4, 8, 9, 10, 9, 11, 10 }; + const tris: []const u32 = if (ceiling) tris_all[0..] else tris_all[0..12]; + const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = tris } }); + const body = try world.addBody(gpa, .{ + .entity = ent(480), + .body_type = .static, + .shape = shape, + .rotation = bodyYaw(yaw), + }); + _ = try world.bp.insert(gpa, .static, world.bm.bodyAabb(&world.store, body).?, body); + + var desc = baseDescriptor(); + desc.entity = ent(481); + desc.position = av(0, y0, 0); + desc.padding = 0; + const id = try addMover(gpa, &world, &chars, desc); + + var k: u32 = 0; + const d = rotY(yaw, 3, 0); + while (k < 4) : (k += 1) { + _ = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(d[0], 0, d[1]), 1.0 / 60.0); + } + const p = chars.get(id).?.position.toArray(); + const u = rotY(yaw, 1, 0); + const along = p[0] * u[0] + p[2] * u[1]; + + // **NEVER PAST THE WALL, at every yaw, every start height, ceiling or not.** This + // inequality is the judge the rotation axis EARNED: replayed against the parent commit + // the same scene walks 12 m THROUGH the wall and off the floor at 180° and 213°, so the + // axis is a non-vacuous pin of the selection filter and not decoration. + try testing.expect(along < 2); + + if (!ceiling) { + // The wall's face at x = 2 minus the capsule radius, with no padding requested — + // the full distance at EVERY yaw and BOTH start heights, and the same at both + // precisions. The frame claim and the precision claim, together. + try testing.expectApproxEqAbs(@as(Real, 1.7), along, 1e-4); + continue; + } + + // **WITH the ceiling the distance is yaw- AND height-dependent, and that is MEASURED, + // not assumed.** The capsule is 1.8 m tall under a ceiling 1 m above the floor, so the + // squeeze is UNRESOLVABLE: reverting to the entry pose is the specified answer, and + // what varies is whether it resolves at all. At a tangent base: 1.699977 at 0°, exactly + // 0 at 5°/37°/90°/350°, 1.699977 at 213°. Lifted to 0.05: correct at 5°/37°/90°/350° + // and 0.459152 at 0°. Reported rather than absorbed, and asserted only where it holds — + // an exact distance claimed over an unresolvable squeeze would be a test announcing + // more than it is, which this suite has already paid for three times. + } + } } - const p = chars.get(id).?.position.toArray(); - - // The wall's face at x = 2 minus the capsule radius, with no padding requested. - try testing.expectApproxEqAbs(@as(Real, 1.7), p[0], 1e-4); - try testing.expect(p[0] < 2); - try testing.expect(p[0] > 1); } -test "STRESS: a 7200-triangle floor under a moving capsule" { +test "STRESS: a 7200-triangle floor under a moving capsule, at six yaws" { const gpa = testing.allocator; // **A STRESS TEST, AND ITS TITLE SAYS SO — it was commissioned as a discriminator and it is not @@ -3373,59 +3508,71 @@ test "STRESS: a 7200-triangle floor under a moving capsule" { // and it was removed as a CLASS, not as an observed symptom. The claim of being a non-regression is // withdrawn rather than left standing over a test that cannot support it. // - // What it IS: 3600 quads under a capsule crossing them at exact tangency, which is worth running. - var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); - defer world.deinit(gpa); - var chars: CharacterStore = .{}; - defer chars.deinit(gpa); + // What it IS: 3600 quads under a capsule crossing them at exact tangency, which is worth running — + // and, since the rotation axis was applied to the corpus, at four yaws, so the capsule crosses the + // cells DIAGONALLY at three of them instead of along a row. + for (corpus_yaws) |yaw| { + var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer world.deinit(gpa); + var chars: CharacterStore = .{}; + defer chars.deinit(gpa); - const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; - const cells = 60; - const cell: f32 = 0.1; - const side = cells + 1; - var verts: [side * side]V = undefined; - for (0..side) |iz| { - for (0..side) |ix| { - const fx = (@as(f32, @floatFromInt(ix)) - cells / 2) * cell; - const fz = (@as(f32, @floatFromInt(iz)) - cells / 2) * cell; - verts[iz * side + ix] = .{ .data = .{ fx, 0, fz } }; + const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; + const cells = 60; + const cell: f32 = 0.1; + const side = cells + 1; + var verts: [side * side]V = undefined; + for (0..side) |iz| { + for (0..side) |ix| { + const fx = (@as(f32, @floatFromInt(ix)) - cells / 2) * cell; + const fz = (@as(f32, @floatFromInt(iz)) - cells / 2) * cell; + verts[iz * side + ix] = .{ .data = .{ fx, 0, fz } }; + } } - } - var tris: [cells * cells * 6]u32 = undefined; - var w: usize = 0; - for (0..cells) |iz| { - for (0..cells) |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); - tris[w] = a; - tris[w + 1] = c; - tris[w + 2] = b; - tris[w + 3] = b; - tris[w + 4] = c; - tris[w + 5] = d; - w += 6; + var tris: [cells * cells * 6]u32 = undefined; + var w: usize = 0; + for (0..cells) |iz| { + for (0..cells) |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); + tris[w] = a; + tris[w + 1] = c; + tris[w + 2] = b; + tris[w + 3] = b; + tris[w + 4] = c; + tris[w + 5] = d; + w += 6; + } } - } - const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &tris } }); - // `World.addBody` inserts the broadphase proxy itself — a second insertion here would put the same - // body in the tree twice, which an earlier version of this test did. - _ = try world.addBody(gpa, .{ .entity = ent(490), .body_type = .static, .shape = shape }); + const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &tris } }); + // `World.addBody` inserts the broadphase proxy itself — a second insertion here would put the same + // body in the tree twice, which an earlier version of this test did. + _ = try world.addBody(gpa, .{ + .entity = ent(490), + .body_type = .static, + .shape = shape, + .rotation = bodyYaw(yaw), + }); - var desc = baseDescriptor(); - desc.entity = ent(491); - desc.position = av(-1, 0, 0); // exactly tangent, so every cell under the capsule is a contact - desc.padding = 0; - const id = try addMover(gpa, &world, &chars, desc); + var desc = baseDescriptor(); + desc.entity = ent(491); + const s0 = rotY(yaw, -1, 0); + desc.position = av(s0[0], 0, s0[1]); // exactly tangent, so every cell under the capsule is a contact + desc.padding = 0; + const id = try addMover(gpa, &world, &chars, desc); - var previous: Real = -1; - 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); - try testing.expectApproxEqAbs(previous + 1, r.position.toArray()[0], 1e-3); - previous = r.position.toArray()[0]; + const u = rotY(yaw, 1, 0); + var previous: Real = -1; + var k: u32 = 0; + while (k < 2) : (k += 1) { + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(u[0], 0, u[1]), 1.0 / 60.0); + const q = r.position.toArray(); + try testing.expectApproxEqAbs(previous + 1, q[0] * u[0] + q[2] * u[1], 1e-3); + previous = q[0] * u[0] + q[2] * u[1]; + } } } From 8a7b474282c590ced09ce71c04a40cb8b2858b35 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 11:26:03 +0200 Subject: [PATCH 068/100] docs(claude): consign the squeeze's yaw dependence, bounded by measure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth M1.1.12 open decision. The resolution of an insoluble squeeze depends on the scene's yaw; what makes it consignable rather than a defect left open is the measurement that bounds it — remove the over-constraint and the invariance is exact at every angle and both heights, so the dependence exists only where no correct answer exists. The parent commit tunnels where HEAD freezes, so it is pre-existing and exposed, and the milestone is strictly better. Owner named: the next milestone opening character.zig, M1.1.14 being the likeliest to care. Footer date refreshed to the day of this edit. --- CLAUDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5f8bc30b..08067908 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,7 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **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. +- **Rotation invariance of an insoluble squeeze (opened at M1.1.12)**: when a character is taller than the space that holds it, the pose depenetration resolves to a position that depends on the scene's YAW — measured 1.699977 at 0° and 213°, exactly 0 at 5°, 37°, 90° and 350°, 0.459 at 45°. Bounded by measurement rather than by argument: with the over-constraint removed the invariance is EXACT at every angle and both heights, so the dependence exists only where no correct answer exists. Pre-existing and merely exposed by M1.1.12's yaw axis — the parent commit TUNNELS through the wall in the same scene where HEAD freezes, so the milestone is strictly better. Not fixed here: choosing which resolution an over-constrained configuration should produce is a design question, not a defect repair. Owner: the next milestone that opens `character.zig`; M1.1.14 is the one most likely to care, rotation invariance being a sibling of the determinism it owns. ## Non-negotiable rules @@ -332,4 +333,4 @@ line, and never on a `tail`. --- -Last updated: 2026-08-05 +Last updated: 2026-08-08 From f5730a3a37b70082dfc4608969ced2b04ea9a774 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 13:02:58 +0200 Subject: [PATCH 069/100] docs(brief): the Windows FileNotFound is a poisoned cache, not a flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section is rewritten rather than amended: it called the failure a flake, correlated it with a cold cache, and prescribed a rerun, and all three were wrong. The entry restored from the Actions cache holds the build runner's object directory without its build.exe, so Zig spawns an executable it never wrote — same hash, four consecutive attempts, 0.26 s into the step, while windows Debug and both ubuntu corners pass every time. The cold-cache correlation is refuted on fourteen runs: the restore step reads success on the failures and on the interleaved successes alike, the earlier claim having been drawn from a single row. And a rerun is not the remedy, since it restores the same entry — the remedy is eviction, tested as a hypothesis that could fail: with the entry deleted and nothing else changed, zig build went from dying in 0.26 s to success on the same commit. A second, distinct class is recorded beside it: two run-test steps stopping answering for a minute with 1636 + 32 = 1668, hence no failed test. Same class as M1.1.1-HF3's E9/E9b. Its arbitration is infrastructure, outside the milestone. Still no pinning of setup-zig or the runner image. --- briefs/M1.1.12-character-controller.md | 62 +++++++++++++++++++------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 534a6db5..3ad27d4f 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2592,29 +2592,61 @@ more than it asked — and every other cell keeps its exact expectation. a probe or by review.** The six-corner matrix runs two precisions and two optimisation modes on ONE target; it cannot see this class at all. Named here because the counter-measure is not another probe. -### Tooling fact — the Windows `FileNotFound` runner flake +### Tooling fact — a POISONED Zig cache entry, not a flake (revised, and the revision is the content) ``` error: failed to spawn build runner .zig-cache\o\\build.exe: FileNotFound ``` -Zig writes the build runner into the cache and then cannot execute it. **Windows only, and not the -repository's:** the hash was IDENTICAL at every occurrence, and `build.zig` / `build.zig.zon` have not -changed since a fully green run twenty commits earlier. +**This section previously called it a flake, correlated it with a COLD cache, and told the reader to +rerun. Every one of those three claims was wrong, and the entry is rewritten rather than amended +because a corrected sentence beside a superseded one is the motif this milestone already paid for +three times.** + +What it is: a cache entry restored from the Actions cache containing the build runner's object +DIRECTORY without its `build.exe`. Zig reads the manifest, concludes the runner is already built, +and spawns an executable it never wrote — so the step dies before compiling anything, in under a +second, with the same object hash every time. The hash is stable because it derives from `build.zig` +and the compiler version, neither of which moved. + +**Measured, on `windows-2025 / ReleaseSafe` only:** the identical signature and the identical hash +`2a9e4e08…` on `34f0d81`, `2aebca7` and `8a7b474`, and again on a rerun of the last — four +consecutive attempts, `0.26 s` into the step, while `zig fmt --check` passed immediately before it and +both ubuntu corners and `windows-2025 / Debug` passed every time. + +**The cold-cache correlation was REFUTED by measurement.** Fourteen CI runs of this branch read +`Restore Zig cache -> success` on the failing runs AND on the three interleaved successes, so the +restore step discriminates nothing. The earlier correlation had been drawn from a SINGLE row — one +Debug job whose restore was skipped — which is the "one row is not a pattern" error in its purest +form. + +**And rerunning is NOT the remedy**, which is what made the diagnosis reachable: a rerun restores +the same poisoned entry, so it reproduces the failure exactly. The remedy is to DELETE the cache +entry. Tested as a hypothesis that could fail: Guy evicted the `windows-2025 / ReleaseSafe` entry +from the Actions interface — no workflow change — and on the very next rerun of the same commit +`zig build` went from dying in `0.26 s` to **success**, compiling the tree and saving a fresh cache. +Nothing else changed. The hypothesis held on the one thing it predicted. + +**Do not pin `weldengine/setup-zig` or the `windows-2025` image.** That was right for the wrong +reason and stays right for a better one: there is no flake to compensate for, and pinning would +freeze a dependency against a cache-content accident that eviction clears in one action. + +### Tooling fact — `test runner failed to respond`, zero failed assertions -**Observed rate: 5 job failures over 4 runs**, always within 40–75 s — before any real compilation, a -Windows build of this tree taking 6 to 42 minutes. Two reruns did not clear it; a third did, and one run -carries its own refutation of any determinism claim: `build-and-test (windows-2025, Debug)` compiled and -passed the whole suite in 9 m 13 while `…, ReleaseSafe)` failed at 1 m 13 on the same commit and the same -cache hash. +``` +error: test runner failed to respond for 1m2.024ms +Build Summary: 261/264 steps succeeded (2 failed); 1636/1668 tests passed (32 skipped) +``` -Correlated with a COLD cache — `Restore Zig cache` was `skipped` on every failing job. **Hypothesis, not -cause: I have not measured it**, and a warm-cache job never failing may only mean it never reached the -spawn. +A SECOND and distinct `windows-2025 / ReleaseSafe` class, and the arithmetic is the discriminant: +`1636 + 32 = 1668`, so **no test failed** — two `run test` steps stopped answering the build runner +for just over a minute. It is the same class `CLAUDE.md` records from M1.1.1-HF3's E9/E9b, where four +consecutive occurrences were instrumented and did not recur on the following merge run. -**Do not pin `weldengine/setup-zig` or the `windows-2025` image against it.** Pinning against a flake -freezes a dependency on a false premise, and a runner's failure rate is not a property of the repository -to compensate for inside the repository. Rerun; expect roughly one job in three to need it. +Recorded here because it is what the job's terminal state is now, once the poisoned-cache class above +was removed from underneath it — and recording it is NOT a third explanation of the first: the two have +different signatures, different steps, and different arithmetic. Its arbitration is infrastructure and +outside this milestone. ### What the milestone leaves as method From 4fd57f10623147e2a2bb89c873a8bca485e6a009 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 14:02:10 +0200 Subject: [PATCH 070/100] docs(brief): a second post-eviction instance, and no recurrence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run that pushed the previous section restored a Zig cache again and built through, so the discriminant was never whether a cache is restored — the refuted correlation's reach — but whether that entry holds the runner it claims. Two instances now separate the two. And the test-runner class did not recur: the same job passed zig build test on that run and the whole CI workflow went green, ci-gate included, so it is intermittent rather than a standing state, which is what M1.1.1-HF3's E9/E9b already recorded. The sentence describing it as the current terminal state is corrected rather than left beside its correction. The windows bench cancellation on the same run is pointed at the CLAUDE.md budget entry that already arbitrates it, not restated. --- briefs/M1.1.12-character-controller.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 3ad27d4f..2b5ad365 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2627,6 +2627,12 @@ from the Actions interface — no workflow change — and on the very next rerun `zig build` went from dying in `0.26 s` to **success**, compiling the tree and saving a fresh cache. Nothing else changed. The hypothesis held on the one thing it predicted. +**And a SECOND instance separates the two things the first could not.** The very next run — the push of +this section, a fresh commit — restored a Zig cache again (`Restore Zig cache -> success`, the healthy +entry the previous job had just saved) and built through. So the discriminant was never *whether* a +cache is restored, which is what the refuted correlation had reached for; it is whether THAT entry +holds the runner it claims. A restored-and-healthy cache is the normal, fast case. + **Do not pin `weldengine/setup-zig` or the `windows-2025` image.** That was right for the wrong reason and stays right for a better one: there is no flake to compensate for, and pinning would freeze a dependency against a cache-content accident that eviction clears in one action. @@ -2643,10 +2649,18 @@ A SECOND and distinct `windows-2025 / ReleaseSafe` class, and the arithmetic is for just over a minute. It is the same class `CLAUDE.md` records from M1.1.1-HF3's E9/E9b, where four consecutive occurrences were instrumented and did not recur on the following merge run. -Recorded here because it is what the job's terminal state is now, once the poisoned-cache class above -was removed from underneath it — and recording it is NOT a third explanation of the first: the two have -different signatures, different steps, and different arithmetic. Its arbitration is infrastructure and -outside this milestone. +Recorded here because it is what the job's terminal state WAS on the run that first got past the +poisoned-cache class above — and recording it is NOT a third explanation of that class: the two have +different signatures, different steps, and different arithmetic. + +**It did not recur on the next run**, where the same job passed `zig build test` and the whole CI +workflow went green, `ci-gate` included. So it is intermittent and not a standing state, which is +exactly what M1.1.1-HF3's E9/E9b recorded and why that entry says to rerun the job before suspecting +code. Its arbitration is infrastructure and outside this milestone. + +Sibling, and already arbitrated elsewhere: `bench-ecs-smoke (windows-2025)` was CANCELLED on that same +green run. That is the `bench.yml` ten-minute budget entry in `CLAUDE.md`, measured at M1.1.11.1 — +cancelled at 9 m 28 and passing on rerun at 7 m 34 on the same commit. It does not gate `ci-gate`. ### What the milestone leaves as method From f3955fe183029cb3819c2c49ae24ee86f89937e2 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 16:00:21 +0200 Subject: [PATCH 071/100] fix(forge): carry the retained contact's normal through to the slide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cast computed the answer and dropped it. At distance zero every arm of castShapeBodyOpposing derives a real surface normal to render its opposing verdict — the manifold of the retained triangle, the manifold of the convex, the stored plane — and none of them reached the caller: BodyCastHit.normal is fixed at -direction there by 1.11.11. slideNormal then asked a SECOND time, over the WHOLE body, keeping the deepest point across every sub-shape, which could be the ceiling where the cast had retained the wall. Two answers to one geometric fact, with the body's yaw deciding which. BodyCastHit gains contact_normal, filled only at distance zero and only when a verdict was asked for, so castShapeBody delegates with false and the query family pays nothing and keeps its 1.11.11 contract. slideNormal prefers it and asks nobody; its whole-body fallback stays for the callers that request no verdict, where it is the only source rather than a second one. The half-space transport is hoisted and computed once instead of twice. Measured over fourteen yaws and both start heights, 28 cells: at f32, 25 read 1.6999 and NOT ONE reads zero, against six frozen cells before; at f64, 26 read 1.700000. Three cells at f32 and two at f64 still stop short, and their cells differ between precisions, so a second source is still open — reported, not dressed as an envelope, and no common expectation is pinned until it is settled. Also: two redundant broadphase inserts removed, World.addBody already inserting the proxy; and the domain table's every-row-ends-grounded sentence gets its assertion back rather than standing as a claim the code no longer checked. --- src/modules/forge/forge_3d/body_manager.zig | 51 +++++++++++++++++-- src/modules/forge/forge_3d/character.zig | 29 ++++++++--- .../forge/forge_3d/tests/character_test.zig | 35 ++++++++----- 3 files changed, 92 insertions(+), 23 deletions(-) diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 2f17aa16..1bfa61a3 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -76,6 +76,16 @@ const ApiQuat = @import("foundation").math.Quatf; /// the kernel's `CastHit`, whose fields are in the cast shape's frame: the frames /// differ, so the types do too rather than one being quietly reinterpreted. pub const BodyCastHit = struct { + /// The CONTACT's own normal at an initial overlap, in WORLD, surface → probe — or null when the + /// sweep travelled, where `normal` already is it, and null when nobody asked. + /// + /// **It exists because the answer was computed and thrown away.** At distance zero every arm of + /// `castShapeBodyOpposing` derives a real surface normal to render its opposing verdict — the + /// manifold of the retained triangle, the manifold of the convex, the stored plane — and `normal` + /// carries none of them: §1.11.11 fixes it at `−direction` there, which is a contract this field + /// does NOT touch. Without this channel the caller had to ask a second time, over the whole body, + /// and could be answered about a different sub-shape than the one the cast retained. + contact_normal: ?Vec3r = null, /// Distance along the cast direction at first touch, in `[0, max_distance]`. distance: Real, /// Which SUB-SHAPE of the hit body was touched — a mesh's triangle index, and `0` for a @@ -796,12 +806,22 @@ pub const BodyManager = struct { // The sub-shape the mesh arm resolves, and zero for the two arms whose shapes carry // no sub-shape at all (§1.11.16). var subshape_id: u32 = 0; + // **THE SURFACE NORMAL EACH ARM ALREADY DERIVES, KEPT INSTEAD OF DISCARDED.** World frame, + // surface → probe, filled only at distance zero and only when the verdict was asked for — + // `castShapeBody` delegates with `skip_non_opposing = false`, so the query family pays + // nothing and its §1.11.11 contract is untouched. + var contact_normal: ?Vec3r = null; + // Transported ONCE and read twice — by the predicate below and by the cast arm. Two calls + // computed the same plane from the same inputs, which is a drift risk for no gain. + const hs: ?@TypeOf(shape_mod.halfSpace(shape)) = if (shape.class() == .half_space) + shape_mod.halfSpace(shape).transformed(relpose.rot_rel, relpose.pos_rel) + else + null; // The two single-sub-shape classes: excluding sub-shape `0` excludes the body. // The half-space's predicate uses the STORED plane normal, transported: at an initial overlap // `plane.castShape` returns `direction.neg()`, which carries no surface information at all. if (skip_non_opposing and shape.class() == .half_space) { - const hs = shape_mod.halfSpace(shape).transformed(relpose.rot_rel, relpose.pos_rel); - if (hs.normal.dot(local_dir) >= 0) return null; + if (hs.?.normal.dot(local_dir) >= 0) return null; } const hit = switch (shape.class()) { .convex => narrowphase.castShape( @@ -814,7 +834,7 @@ pub const BodyManager = struct { ), .half_space => narrowphase.plane.castShape( Real, - shape_mod.halfSpace(shape).transformed(relpose.rot_rel, relpose.pos_rel), + hs.?, cast_shape, local_dir, max_distance, @@ -851,6 +871,8 @@ pub const BodyManager = struct { }; _ = data.traverseCast(RayR.init(probe_box.center(), sweep_dir), probe_box.halfExtents(), &collector); subshape_id = collector.best_triangle; + // A's frame, like everything the collector holds — carried to world with `normal`. + if (collector.best_contact_normal) |n| contact_normal = cast_rotation.rotateVec3(n); break :blk collector.best; }, } orelse return null; @@ -877,8 +899,17 @@ pub const BodyManager = struct { break :blk (probe_manifold.normal orelse return null).neg(); }; if (opposing_normal.dot(if (hit.distance > 0) local_dir else direction) >= 0) return null; + // Already WORLD on this arm: `collideShapeBody` is a world-space call, which is why the + // predicate above dots it with `direction` and not with `local_dir`. + if (hit.distance <= 0) contact_normal = opposing_normal; + } + // The half-space's plane is the contact normal at every distance; at zero it is the only arm + // whose answer needs no second computation of any kind. + if (skip_non_opposing and shape.class() == .half_space and hit.distance <= 0) { + contact_normal = cast_rotation.rotateVec3(hs.?.normal); } return .{ + .contact_normal = contact_normal, // A distance is invariant under a rigid transform, so it needs no mapping. .distance = hit.distance, // The mesh arm is the only one that fills this; the other two carry zero @@ -1833,6 +1864,9 @@ const MeshCastCollector = struct { /// this arm must hand it the same frame the other two do. best: ?narrowphase.CastHit(Real) = null, best_triangle: u32 = 0, + /// The retained triangle's CONTACT normal in A's frame, surface → probe — set only at distance + /// zero, where it is the manifold this collector already computes for its verdict. + best_contact_normal: ?Vec3r = null, pub fn add(self: *MeshCastCollector, triangle_index: u32) void { const face = self.data.faceNormal(triangle_index); @@ -1870,6 +1904,12 @@ const MeshCastCollector = struct { // on the face normal and traverses — measured from `x = −0.3` to `x = 0.672` with the base still // at `y = −0.3`, at both precisions. The hypothesis that "the depenetration owns that case" is // refuted by that measurement, and the manifold is what carries an edge's real normal. + // **AND THE NORMAL IT RENDERS THE VERDICT ON IS THE ONE IT HANDS BACK.** An earlier form + // computed the manifold below, used it, and dropped it; the caller then asked again over the + // WHOLE body and could be answered about the ceiling where this collector had retained the + // wall. Two answers to one geometric fact, with the body's yaw deciding which — the class this + // module refuses, and the reason the field exists rather than the recomputation. + var contact_normal: ?Vec3r = null; if (self.skip_non_opposing) { if (hit.distance > 0) { if (hit.normal.dot(self.direction_in_a) >= 0) return; @@ -1887,7 +1927,9 @@ const MeshCastCollector = struct { self.relpose.rot_rel, ) orelse return; // `collideOrdered` returns probe → body; the opposing test wants surface → probe. - if (m.normal.neg().dot(self.direction_in_a) >= 0) return; + const n = m.normal.neg(); + if (n.dot(self.direction_in_a) >= 0) return; + contact_normal = n; } } if (self.best) |best| { @@ -1898,6 +1940,7 @@ const MeshCastCollector = struct { } self.best = hit; self.best_triangle = triangle_index; + self.best_contact_normal = contact_normal; self.bound = hit.distance; } diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 6c8ae7ec..3f6ee5e8 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -627,6 +627,7 @@ const SweepCollector = struct { subshape_id: u32, distance: Real, normal: Vec3r, + contact_normal: ?Vec3r, } = null, pub fn add(self: *SweepCollector, user_data: u32) void { @@ -669,6 +670,7 @@ const SweepCollector = struct { .subshape_id = hit.subshape_id, .distance = hit.distance, .normal = hit.normal, + .contact_normal = hit.contact_normal, }; // Tightened TO, not below, so an equal distance still reaches the tie-break. self.bound = hit.distance; @@ -751,9 +753,18 @@ const WorstOverlap = struct { /// The slide normal AND the sub-shape it belongs to. /// /// **The pair, and not the normal alone**: a caller that sets a contact aside on the strength of this -/// normal must set aside the sub-shape the normal actually came from. At distance zero the manifold is -/// collected over every sub-shape of the body and the deepest wins, which need not be the one the cast -/// returned — measured as a ceiling's normal excluding a wall on the same mesh. +/// normal must set aside the sub-shape the normal actually came from. +/// +/// **AND AT DISTANCE ZERO THE CAST'S OWN ANSWER IS PREFERRED TO A SECOND OPINION.** The whole-body +/// fallback below collects the manifold over EVERY sub-shape and keeps the deepest, which need not be +/// the one the cast retained — measured as a ceiling's normal displacing a wall's on the same mesh, +/// with the body's YAW deciding which won, and a character frozen at six of fourteen angles as a +/// result. That was never "no answer is correct in an insoluble squeeze": the right answer had been +/// computed, one call earlier, and dropped. A caller that asked the cast for an opposing verdict now +/// receives the normal that verdict was rendered on, and this function asks nobody. +/// +/// The fallback remains for the callers that ask for no verdict — the ground probe and the step's +/// sweeps — where it is the ONLY source and therefore not a second one. const SlideNormal = struct { normal: Vec3r, subshape_id: u32 }; fn slideNormal( @@ -765,8 +776,10 @@ fn slideNormal( distance: Real, swept_normal: Vec3r, swept_subshape: u32, + swept_contact_normal: ?Vec3r, ) ?SlideNormal { if (distance > 0) return .{ .normal = swept_normal, .subshape_id = swept_subshape }; + if (swept_contact_normal) |n| return .{ .normal = n, .subshape_id = swept_subshape }; var deepest = DeepestManifold{ .body = body }; bm.collideShapeBody(store, body, probe, centre, Quatr.identity, &deepest); const c = deepest.best orelse return null; @@ -899,6 +912,9 @@ const SweepHit = struct { /// 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, + /// The contact's own normal at distance zero, from the manifold the cast's own verdict was + /// rendered on. Null when the sweep travelled, and null for a caller that asked for no verdict. + contact_normal: ?Vec3r, }; /// Nearest contact of the capsule swept from `origin` along `direction` for at most `distance`. @@ -939,6 +955,7 @@ fn sweepNearest( .subshape_id = best.subshape_id, .distance = best.distance, .normal = best.normal, + .contact_normal = best.contact_normal, }; } @@ -1009,7 +1026,7 @@ fn tryStepUp( const landed = forward.sub(up.scale(drop)); // 4 — the landing must be walkable. - const sn = slideNormal(bm, store, probe, landed, down_hit.body, down_hit.distance, down_hit.normal, down_hit.subshape_id) orelse return null; + const sn = slideNormal(bm, store, probe, landed, down_hit.body, down_hit.distance, down_hit.normal, down_hit.subshape_id, down_hit.contact_normal) orelse return null; if (sn.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 @@ -1062,7 +1079,7 @@ fn stepDown( touched: *TouchedBodies, ) Vec3r { const hit = sweepNearest(bp, bm, store, record, probe, centre, up.neg(), c.step_height, c.layer_mask, c.inner_body, false) orelse return centre; - const sn = slideNormal(bm, store, probe, centre, hit.body, hit.distance, hit.normal, hit.subshape_id) orelse return centre; + const sn = slideNormal(bm, store, probe, centre, hit.body, hit.distance, hit.normal, hit.subshape_id, hit.contact_normal) orelse return centre; if (sn.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 @@ -1572,7 +1589,7 @@ pub const CharacterStore = struct { centre = centre.add(direction.scale(advance)); remaining = remaining.sub(direction.scale(advance)); - const sn = slideNormal(bm, store, probe, centre, hit.body, hit.distance, hit.normal, hit.subshape_id) orelse { + const sn = slideNormal(bm, store, probe, centre, hit.body, hit.distance, hit.normal, hit.subshape_id, hit.contact_normal) orelse { // No usable normal: stop rather than guess a direction. Short, never further. remaining = Vec3r.zero; break; diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 134336e1..9a69b7cc 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3247,7 +3247,11 @@ test "DOMAIN TABLE — measured behaviour at every legal bound of the descriptor 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. + // Every row ends GROUNDED — none of the legal bounds loses the floor. **The sentence stood here + // WITHOUT the assertion under it**, a text claiming a property of every row that the code had + // stopped checking. Restored rather than deleted: it is a real property and worth pinning. + errdefer std.debug.print("row {s} ground\n", .{row.name}); + try testing.expectEqual(api.GroundState.grounded, chars.reportedGround(id).?); } } @@ -3377,13 +3381,12 @@ test "one mesh carrying both floor and wall: the wall still blocks, at six yaws" 8, 9, 10, 9, 11, 10, // ceiling, −Y }; const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = &tris } }); - const body = try world.addBody(gpa, .{ + _ = try world.addBody(gpa, .{ .entity = ent(470), .body_type = .static, .shape = shape, .rotation = bodyYaw(yaw), }); - _ = try world.bp.insert(gpa, .static, world.bm.bodyAabb(&world.store, body).?, body); var desc = baseDescriptor(); desc.entity = ent(471); @@ -3443,13 +3446,12 @@ test "the mesh scene behaves IDENTICALLY at both precisions, at six yaws" { const tris_all = [_]u32{ 0, 2, 1, 1, 2, 3, 6, 7, 4, 7, 5, 4, 8, 9, 10, 9, 11, 10 }; const tris: []const u32 = if (ceiling) tris_all[0..] else tris_all[0..12]; const shape = try world.store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &verts, .indices = tris } }); - const body = try world.addBody(gpa, .{ + _ = try world.addBody(gpa, .{ .entity = ent(480), .body_type = .static, .shape = shape, .rotation = bodyYaw(yaw), }); - _ = try world.bp.insert(gpa, .static, world.bm.bodyAabb(&world.store, body).?, body); var desc = baseDescriptor(); desc.entity = ent(481); @@ -3480,14 +3482,21 @@ test "the mesh scene behaves IDENTICALLY at both precisions, at six yaws" { continue; } - // **WITH the ceiling the distance is yaw- AND height-dependent, and that is MEASURED, - // not assumed.** The capsule is 1.8 m tall under a ceiling 1 m above the floor, so the - // squeeze is UNRESOLVABLE: reverting to the entry pose is the specified answer, and - // what varies is whether it resolves at all. At a tangent base: 1.699977 at 0°, exactly - // 0 at 5°/37°/90°/350°, 1.699977 at 213°. Lifted to 0.05: correct at 5°/37°/90°/350° - // and 0.459152 at 0°. Reported rather than absorbed, and asserted only where it holds — - // an exact distance claimed over an unresolvable squeeze would be a test announcing - // more than it is, which this suite has already paid for three times. + // **WITH the ceiling a RESIDUE remains, and the numbers here are the post-fix ones.** + // + // Transporting the retained contact's normal to the slide removed the FREEZE class + // entirely at f32 and most of the yaw dependence with it. Swept over fourteen yaws and + // both start heights, 28 cells: at f32, 25 read 1.6999 and NOT ONE reads zero, against + // six frozen cells before the fix; the three left are 0.459218 at 0°/0.00, 0.506016 at + // 90°/0.05 and 0.459151 at 315°/0.05. At f64, 26 read 1.700000 and the two left are + // 0.459167 at 15°/0.00 and an exact freeze at 90°/0.00. + // + // **The residue's CELLS differ between the two precisions, so a second source is still + // open** — `slideNormal`'s whole-body fallback stays live for the callers that ask for + // no verdict, and `depenetrate` selects over the whole body too. That is reported and + // NOT dressed as an envelope, and no common expectation is pinned here until it is + // settled: an exact distance asserted over cells that disagree would be the fifteenth + // green assertion of this milestone to prove nothing. } } } From 36c68665e7f546ebbf6a7ce4b57440db1d26f3b3 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 16:32:05 +0200 Subject: [PATCH 072/100] test(forge): pin that no yaw freezes, and consign the traced residue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The property is stable where the value is not: on the ceiling scene no cell returns zero at any yaw, at f32. Six of twenty-eight did before the fix and none does after, so it discriminates, and it claims nothing it does not check. A bound of 0.1 and not != 0 on purpose — the two measured populations are 0 and >= 0.459, so any bound strictly between separates them, and a dribble of 1e-9 is a freeze in every sense that matters. f32 only, because one cell still freezes at f64 and asserting it at both is what this suite has been caught doing. Mutation probe: replayed against the parent commit the test FAILS on that exact line. And CLAUDE.md's fifth open decision is replaced. The residue's source is now traced and it is NEITHER candidate: slideNormal's fallback never fires in this scene, and the stop happens inside the slide loop, not in depenetration. --- CLAUDE.md | 2 +- .../forge/forge_3d/tests/character_test.zig | 20 ++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 08067908..83c05afc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,7 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **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. -- **Rotation invariance of an insoluble squeeze (opened at M1.1.12)**: when a character is taller than the space that holds it, the pose depenetration resolves to a position that depends on the scene's YAW — measured 1.699977 at 0° and 213°, exactly 0 at 5°, 37°, 90° and 350°, 0.459 at 45°. Bounded by measurement rather than by argument: with the over-constraint removed the invariance is EXACT at every angle and both heights, so the dependence exists only where no correct answer exists. Pre-existing and merely exposed by M1.1.12's yaw axis — the parent commit TUNNELS through the wall in the same scene where HEAD freezes, so the milestone is strictly better. Not fixed here: choosing which resolution an over-constrained configuration should produce is a design question, not a defect repair. Owner: the next milestone that opens `character.zig`; M1.1.14 is the one most likely to care, rotation invariance being a sibling of the determinism it owns. +- **Residual yaw dependence in an insoluble squeeze (opened at M1.1.12)**: when a character is taller than the space that holds it, the distance served still depends on the scene's YAW. M1.1.12 closed the severe half of this: carrying the SELECTED contact's normal through to the slide, instead of re-querying the whole body, removed the freeze class entirely at f32 — six cells of twenty-eight went from 0 to served. What remains is a PARTIAL serve, ~0.459 where ~1.700 is the norm, on three of twenty-eight cells at f32 and two at f64 — and the cells DIFFER between precisions, which by §1.12.6's own rule marks a second source still open rather than an envelope. Two candidates were named and both are probably NOT instances of that class: `slideNormal`'s whole-body fallback serves callers that never determined a contact, so it is their only source; and `depenetrate`'s `WorstOverlap` asks a genuinely different question. Which source feeds the residual cells is untraced. Pinned by the property that holds — no cell freezes at any yaw — not by a common value, which is not available. Owner: the next milestone that opens `character.zig`; M1.1.14 is the likeliest, rotation invariance being a sibling of the determinism it owns. ## Non-negotiable rules diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 9a69b7cc..b9153b2e 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3492,11 +3492,21 @@ test "the mesh scene behaves IDENTICALLY at both precisions, at six yaws" { // 0.459167 at 15°/0.00 and an exact freeze at 90°/0.00. // // **The residue's CELLS differ between the two precisions, so a second source is still - // open** — `slideNormal`'s whole-body fallback stays live for the callers that ask for - // no verdict, and `depenetrate` selects over the whole body too. That is reported and - // NOT dressed as an envelope, and no common expectation is pinned here until it is - // settled: an exact distance asserted over cells that disagree would be the fifteenth - // green assertion of this milestone to prove nothing. + // open**, which `CLAUDE.md` carries with its owner. No common VALUE is pinned here, + // because none is available and an exact distance asserted over cells that disagree + // would be the fifteenth green assertion of this milestone to prove nothing. + // + // **What IS pinned is the property that holds: no cell freezes.** Before the fix six of + // twenty-eight cells returned exactly zero — a character that never moves again — and + // after it none does. The two populations are `0` and `>= 0.459`, so any bound strictly + // between them separates them; `0.1` is far from both and is not a tolerance on + // anything. A bound and not `!= 0` on purpose: a dribble of `1e-9` is a freeze in every + // sense that matters, and `!= 0` would accept it. + // + // f32 ONLY, and the gate is the measurement's: at f64 one cell of the twenty-eight + // still freezes (90°, base 0), so the same assertion there would be false. Claiming it + // at both precisions is exactly what this suite has been caught doing. + if (Real == f32) try testing.expect(along > 0.1); } } } From 90dfb4a21c3e01973162a19e3249e8d47f8c4ff7 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 17:13:03 +0200 Subject: [PATCH 073/100] fix(forge): the character's cast path bypassed the internal-edge fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active-edge flags baked at M1.1.11.1 are NOT unconsumed: internalEdgeNormal consults them and is called on the contact path. What bypassed it was the character's cast path, which calls collideOrdered directly — so a capsule deep under a flat quad received the normal of the quad's INTERNAL DIAGONAL, measured horizontal and opposite on the two triangles sharing it, and verified by arithmetic against that edge's horizontal perpendicular. The slide read the pair as a crease, a third plane as a corner, and stopped. The edge is recovered from the contact POINT's distance to the three segments, which is what internalEdgeNormal already does. feature_id cannot serve: decoded on the faulty contact it carries a reference face on the SEGMENT (per-core-type ids, segment 6 and triangle 8) and incident winding VERTICES, class_edge never fires, and half the faulty contacts are single-point manifolds with two FACE halves and no edge information at all. So this is a reuse and not a mechanism. It closes ONE of the three residual cells and not the others: at f32, 26 of 28 converge and the two left are 0.506016 at 90 deg base 0.05 and 0.459151 at 315 deg base 0.05 — both at the lifted base where the closed one was the tangent base. At f64 nothing moved, the correction's noise gate being far tighter there. So the convergence condition is NOT met: no common expectation is pinned, the no-freeze property stays the pin, and the consignation stands. --- src/modules/forge/forge_3d/body_manager.zig | 27 ++++++++++++++++++- .../forge/forge_3d/tests/character_test.zig | 11 ++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 1bfa61a3..71b8338b 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -1926,8 +1926,33 @@ const MeshCastCollector = struct { self.relpose.pos_rel, self.relpose.rot_rel, ) orelse return; + // **THE INTERNAL-EDGE CORRECTION, ON THIS PATH TOO.** The contact path has consumed + // the flags baked at creation since M1.1.11.1; this one called `collideOrdered` + // directly and bypassed the consumer, so a capsule DEEP under a flat quad received + // the normal of the quad's internal diagonal — horizontal, and OPPOSITE on the two + // triangles sharing it. The slide read the pair as a crease, then a third plane as a + // corner, and stopped: traced as the residual partial serve of ~0.459 where ~1.700 is + // the norm. + // + // The edge is recovered from the contact POINT's distance to the three segments, not + // from `feature_id` — which decodes here as a reference face on the SEGMENT (id 6) and + // incident winding VERTICES, never `class_edge`, and for a single-point manifold + // carries no edge information at all. So this is the existing function reused with the + // data this collector already holds, and not a second way of naming an edge. + // + // The mesh is B in this call — A is the probe at the origin — so `mesh_is_a` is false + // and the returned face normal is negated to stay A→B like `m.normal`. + var contact = m; + if (internalEdgeNormal( + self.data, + triangle_index, + self.relpose.pos_rel, + self.relpose.rot_rel, + m, + false, + )) |face_world| contact.normal = face_world.neg(); // `collideOrdered` returns probe → body; the opposing test wants surface → probe. - const n = m.normal.neg(); + const n = contact.normal.neg(); if (n.dot(self.direction_in_a) >= 0) return; contact_normal = n; } diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index b9153b2e..4cb582d1 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3506,6 +3506,17 @@ test "the mesh scene behaves IDENTICALLY at both precisions, at six yaws" { // f32 ONLY, and the gate is the measurement's: at f64 one cell of the twenty-eight // still freezes (90°, base 0), so the same assertion there would be false. Claiming it // at both precisions is exactly what this suite has been caught doing. + // + // **The internal-edge correction closed one of the three residual cells and not the + // other two.** The character's cast path called `collideOrdered` directly and bypassed + // the consumer of the active-edge flags that the CONTACT path has used since + // M1.1.11.1, so a capsule deep under a flat quad received the normal of the quad's + // internal diagonal — horizontal, and opposite on the two triangles sharing it. With + // the correction branched in, f32 reads 26 of 28 converged, the two left being 0.506016 + // at 90°/0.05 and 0.459151 at 315°/0.05 — both at the LIFTED base, where the closed one + // was the tangent base. At f64 nothing moved at all, the correction's noise gate being + // 2^29 tighter there. So the residue has a further cause again, and no common value is + // pinned. if (Real == f32) try testing.expect(along > 0.1); } } From 501c2416437b707abcf8716cc186193eb8440fa2 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 17:54:44 +0200 Subject: [PATCH 074/100] docs(claude): the residue has a closed cause and an unnamed remainder Rewritten because the entry now describes something else: two thirds of the yaw dependence are closed and their parts are named. The severe half went with the retained contact's normal reaching the slide; one further cause was the internal diagonal of a flat quad returning its own normal in deep overlap, horizontal and anti-parallel between the two triangles, which the plane accumulator read as a sharp edge. The remedy was already in the module and merely bypassed. What remains is unnamed, and the entry keeps the one quantitative constraint we have on it: f64 does not move a digit where f32 gains a cell, and its noise guard is tighter by 2^29, so the cause sits above that guard at f32 and below it at f64. Both previously named candidates are recorded as traced and eliminated rather than merely doubted. --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 83c05afc..1405795c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,7 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **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. -- **Residual yaw dependence in an insoluble squeeze (opened at M1.1.12)**: when a character is taller than the space that holds it, the distance served still depends on the scene's YAW. M1.1.12 closed the severe half of this: carrying the SELECTED contact's normal through to the slide, instead of re-querying the whole body, removed the freeze class entirely at f32 — six cells of twenty-eight went from 0 to served. What remains is a PARTIAL serve, ~0.459 where ~1.700 is the norm, on three of twenty-eight cells at f32 and two at f64 — and the cells DIFFER between precisions, which by §1.12.6's own rule marks a second source still open rather than an envelope. Two candidates were named and both are probably NOT instances of that class: `slideNormal`'s whole-body fallback serves callers that never determined a contact, so it is their only source; and `depenetrate`'s `WorstOverlap` asks a genuinely different question. Which source feeds the residual cells is untraced. Pinned by the property that holds — no cell freezes at any yaw — not by a common value, which is not available. Owner: the next milestone that opens `character.zig`; M1.1.14 is the likeliest, rotation invariance being a sibling of the determinism it owns. +- **Residual yaw dependence in an insoluble squeeze (opened at M1.1.12)**: when a character is taller than the space that holds it, the distance served still depends on the scene's YAW. M1.1.12 closed two thirds of this and named the parts. First the SEVERE half: carrying the SELECTED contact's normal through to the slide, instead of re-querying the whole body, removed the freeze class entirely at f32 — six cells of twenty-eight went from 0 to served. Then one named cause: in deep overlap the two triangles of a flat quad each returned the normal of their shared INTERNAL diagonal — horizontal and mutually anti-parallel, which the plane accumulator read as a sharp edge and exited as a corner. The remedy was already in the module and merely bypassed: `internalEdgeNormal` consults the active-edge flags baked at M1.1.11.1, and the character path reached `collideOrdered` directly instead of going through it. Wiring it closes exactly the tangent-base cell. What remains is UNNAMED: two cells of twenty-eight at f32, both at a RAISED base, and f64 does not move at all — its noise guard is tighter by 2^29, so whatever feeds the residual is above that guard at f32 and below it at f64. `slideNormal`'s whole-body fallback and `depenetrate`'s `WorstOverlap` were both traced and both ELIMINATED: the first never fires on this path, the second is not where the motion stops (`exit=corner`, inside the slide loop). Pinned by the property that holds — no cell freezes at any yaw, at f32 — not by a common value, which is not available. Owner: the next milestone that opens `character.zig`; M1.1.14 is the likeliest, rotation invariance being a sibling of the determinism it owns. ## Non-negotiable rules From 4438a4e7d439fcd7d0d44247ad91dc1c6357e5d2 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 19:14:13 +0200 Subject: [PATCH 075/100] test(forge): pin the internal-edge cell to its value, not to a bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The magnitude bound does not discriminate it: that cell read 0.459 before the correction reached the character's cast path and ~1.700 after, and along > 0.1 accepts both, so removing the internalEdgeNormal call outright left the suite green. A bound is the right shape for the no-freeze property and the wrong one for a fix whose whole effect is WHICH value is served. Pinned to the measured stop at both precisions, 1.699976100 at f32 and 1.699999988 at f64. The 2.4e-5 gap between them IS the stand-off floor, 64 * floatEps(Real) * coordScale, which vanishes at f64 — so the split is by precision with a named cause and not by platform. Mutation probe against 36c6866: fails with actual 0.45921806 against expected 1.6999761. --- .../forge/forge_3d/tests/character_test.zig | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 4cb582d1..1101b5d5 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3518,6 +3518,22 @@ test "the mesh scene behaves IDENTICALLY at both precisions, at six yaws" { // 2^29 tighter there. So the residue has a further cause again, and no common value is // pinned. if (Real == f32) try testing.expect(along > 0.1); + + // **AND THE INTERNAL-EDGE CELL IS PINNED TO ITS VALUE, because the magnitude bound + // above does NOT discriminate it.** That cell read 0.459 before the correction was + // branched onto the character's cast path and ~1.700 after, and `> 0.1` accepts both — + // so removing the `internalEdgeNormal` call outright would leave this test green. A + // bound is the right shape for the no-freeze property and the wrong one for a fix whose + // whole effect is WHICH value is served. + // + // The stop is the wall face at `x = 2` less the capsule radius, less the stand-off + // floor. That floor is `64 · floatEps(Real) · coordScale`, so it is ~2.4e-5 at f32 and + // vanishes at f64 — measured 1.699976100 and 1.699999988, a gap that IS the floor and + // not noise, which is why the value is split by precision and not by platform. + if (yaw == 0 and y0 == 0) { + const expected: Real = if (Real == f32) 1.699976100 else 1.699999988; + try testing.expectApproxEqAbs(expected, along, api_tol); + } } } } From 94ee5171b4529cb4cb5669b51672fed6da7108e3 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 8 Aug 2026 22:44:50 +0200 Subject: [PATCH 076/100] fix(forge): a contact opposes only beyond transport noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A horizontal triangle's normal does not survive a quaternion yaw exactly: transported through 90 degrees it reads (7.4e-16, 1, 1.1e-15) at f64 and (3.97e-7, 1, 3.97e-7) at f32. Its dot with a horizontal sweep is then a small NEGATIVE residue where the geometry has an exact zero, so an exact >= 0 test admitted a FLOOR as an obstacle to horizontal travel. Advance zero, iteration spent, plane slot spent; the slide against that near-vertical normal injected a vertical residue into the motion, which admitted the CEILING the same way; and two near-anti-parallel planes formed a crease whose axis is their cross product, which is pure noise. At f64 that axis re-found the floor and the character froze at exactly zero every call, at f32 it happened to find a real contact. A coin toss arbitrated by rounding. opposing_noise_k = 16, as k * floatEps(Real) and nothing else: n . d is a product of two UNIT vectors, hence dimensionless, so no coordinate scale belongs in it — the difference from contactMargin, whose operand is a distance. The band reads NON-OPPOSING and discards: discarding a contact that opposed by a hair advances by a hair into a surface and the next call's depenetration recovers it, while reading it as opposing freezes, and a freeze recovers from nothing. Measured residues are 3.3 and 5 floatEps, so k = 16 is a 3x margin, and the angle it costs is asin(16 * floatEps) — 1.1e-4 degrees at f32. Governed by 1.11.2, unlike max_slope, padding and predictive_contact_distance. One predicate shared by all four arms so they cannot drift. All twenty-eight cells now converge at BOTH precisions: f32 in [1.699976300, 1.699986600], f64 in [1.699999905, 1.700000054]. So the common expectation replaces the magnitude bound, the Real == f32 guard is gone, the title is true, and the fifth open decision is deleted rather than rewritten. Mutation probe against 4438a4e: actual -0 against expected 1.7. --- CLAUDE.md | 1 - src/modules/forge/forge_3d/body_manager.zig | 49 +++++++++++++++-- .../forge/forge_3d/tests/character_test.zig | 55 +++++++++++++------ 3 files changed, 82 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1405795c..17947092 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,6 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **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. -- **Residual yaw dependence in an insoluble squeeze (opened at M1.1.12)**: when a character is taller than the space that holds it, the distance served still depends on the scene's YAW. M1.1.12 closed two thirds of this and named the parts. First the SEVERE half: carrying the SELECTED contact's normal through to the slide, instead of re-querying the whole body, removed the freeze class entirely at f32 — six cells of twenty-eight went from 0 to served. Then one named cause: in deep overlap the two triangles of a flat quad each returned the normal of their shared INTERNAL diagonal — horizontal and mutually anti-parallel, which the plane accumulator read as a sharp edge and exited as a corner. The remedy was already in the module and merely bypassed: `internalEdgeNormal` consults the active-edge flags baked at M1.1.11.1, and the character path reached `collideOrdered` directly instead of going through it. Wiring it closes exactly the tangent-base cell. What remains is UNNAMED: two cells of twenty-eight at f32, both at a RAISED base, and f64 does not move at all — its noise guard is tighter by 2^29, so whatever feeds the residual is above that guard at f32 and below it at f64. `slideNormal`'s whole-body fallback and `depenetrate`'s `WorstOverlap` were both traced and both ELIMINATED: the first never fires on this path, the second is not where the motion stops (`exit=corner`, inside the slide loop). Pinned by the property that holds — no cell freezes at any yaw, at f32 — not by a common value, which is not available. Owner: the next milestone that opens `character.zig`; M1.1.14 is the likeliest, rotation invariance being a sibling of the determinism it owns. ## Non-negotiable rules diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 71b8338b..f7b54a1d 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -821,7 +821,7 @@ pub const BodyManager = struct { // The half-space's predicate uses the STORED plane normal, transported: at an initial overlap // `plane.castShape` returns `direction.neg()`, which carries no surface information at all. if (skip_non_opposing and shape.class() == .half_space) { - if (hs.?.normal.dot(local_dir) >= 0) return null; + if (!opposes(hs.?.normal, local_dir)) return null; } const hit = switch (shape.class()) { .convex => narrowphase.castShape( @@ -898,7 +898,7 @@ pub const BodyManager = struct { // hit of the cast/manifold disagreement. Nothing real to oppose the motion. break :blk (probe_manifold.normal orelse return null).neg(); }; - if (opposing_normal.dot(if (hit.distance > 0) local_dir else direction) >= 0) return null; + if (!opposes(opposing_normal, if (hit.distance > 0) local_dir else direction)) return null; // Already WORLD on this arm: `collideShapeBody` is a world-space call, which is why the // predicate above dots it with `direction` and not with `local_dir`. if (hit.distance <= 0) contact_normal = opposing_normal; @@ -1643,6 +1643,47 @@ const SingleManifoldCollector = struct { } }; +/// Slack, in ULPs of 1, on the test that decides whether a surface OPPOSES a sweep. +/// +/// **`k · floatEps(Real)` and nothing else — no coordinate scale.** `n · d` is the product of two +/// UNIT vectors and is therefore DIMENSIONLESS, so a length scale has no business in it. That is what +/// separates this constant from `contactMargin`, whose operand is a distance and which is right to +/// carry `coordScale`. +/// +/// **It IS governed by §1.11.2's tolerance discipline**, unlike `max_slope`, `padding` and +/// `predictive_contact_distance`, which are named PHYSICAL parameters of the character and +/// deliberately escape it. This one absorbs transport rounding and expresses no modelling intent. +/// +/// **Why a band exists here at all, and it is measured rather than argued.** A horizontal triangle's +/// normal does not survive a quaternion yaw exactly: transported through 90° it reads +/// `(7.4e-16, 1, 1.1e-15)` at f64 and `(3.97e-7, 1, 3.97e-7)` at f32. Its dot with a horizontal sweep +/// is then a small NEGATIVE residue where the geometry has an exact zero — so an exact `>= 0` admitted +/// a FLOOR as an obstacle to horizontal travel. The advance is zero, the iteration is spent, a plane +/// slot is spent; the slide against that near-vertical normal then injects a vertical residue into the +/// motion, which admits the CEILING by the same route; and two near-anti-parallel planes form a crease +/// whose axis is their cross product, i.e. pure noise. At f64 that noise axis re-found the floor and +/// the character froze at exactly zero, every call; at f32 it happened to find a real contact and +/// served. A coin toss arbitrated by rounding, which is what this closes. +/// +/// **The direction of the band is decided and not symmetric**: it reads NON-OPPOSING, so a contact +/// within it is DISCARDED. Discarding one that opposed by a hair advances by a hair into a surface, +/// and the depenetration at the head of the next call recovers that; reading it as opposing FREEZES, +/// and a freeze recovers from nothing. +/// +/// `k = 16` against measured residues of `3.3 · floatEps` at f32 and `5 · floatEps` at f64 — a 3× +/// margin, and the same value `contact_margin_conv_k` carries for the same reason: the residue is +/// ACCUMULATED across the transport, the direction's normalisation and the dot's own three products. +/// What it costs in angle is `asin(16 · floatEps)` — `1.1e-4` degrees at f32, `2.0e-13` at f64. No +/// modelling notices that. The 3.6° cone this milestone REFUSED is thirty thousand times wider and is +/// closed by `internalEdgeNormal` instead, which is a different mechanism for a different cause. +const opposing_noise_k: comptime_int = 16; + +/// Whether a surface with outward normal `n` opposes travel along unit direction `d`, by more than +/// float noise. One predicate for all four arms, so they cannot drift apart. +fn opposes(n: Vec3r, d: Vec3r) bool { + return n.dot(d) < -@as(Real, opposing_noise_k) * std.math.floatEps(Real); +} + /// Slack, in ULPs of 1, on the test that a contact normal ALREADY IS the face normal — and on /// the tie band that decides which edges a contact could have come from. Both compare /// quantities of order 1 (a dot product of two unit vectors) or a length against the triangle's @@ -1912,7 +1953,7 @@ const MeshCastCollector = struct { var contact_normal: ?Vec3r = null; if (self.skip_non_opposing) { if (hit.distance > 0) { - if (hit.normal.dot(self.direction_in_a) >= 0) return; + if (!opposes(hit.normal, self.direction_in_a)) return; } else { // The triangle alone, in A's frame: A at the origin, B at the relative pose the kernel // already holds. `null` means the narrowphase denies the contact the cast reported — the @@ -1953,7 +1994,7 @@ const MeshCastCollector = struct { )) |face_world| contact.normal = face_world.neg(); // `collideOrdered` returns probe → body; the opposing test wants surface → probe. const n = contact.normal.neg(); - if (n.dot(self.direction_in_a) >= 0) return; + if (!opposes(n, self.direction_in_a)) return; contact_normal = n; } } diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 1101b5d5..50777234 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3419,16 +3419,19 @@ test "one mesh carrying both floor and wall: the wall still blocks, at six yaws" test "the mesh scene behaves IDENTICALLY at both precisions, at six yaws" { const gpa = testing.allocator; - // **THIS TEST PINNED A PRECISION SPLIT AND THE SPLIT IS GONE.** It read: the same floor/wall/ceiling - // mesh blocks at 1.7 at f32 and freezes at exactly 0 at f64, a second defect named and not - // entrenched. Both were the cast/manifold disagreement, and filtering non-opposing contacts during - // selection closed it: f64 now reads 1.6999999880790453 where it read 0. + // **THIS TEST PINNED A PRECISION SPLIT THREE TIMES AND THE SPLIT IS GONE.** Each time it read as + // one defect and was three: the same floor/wall/ceiling mesh blocked at 1.7 at f32 and froze at + // exactly 0 at f64, then froze at six yaws of twenty-eight, then served 0.459 at three of them. + // The causes were, in order, a cast/manifold disagreement resolved by filtering during selection; + // a whole-body re-query displacing the contact the cast had SELECTED; the character's cast path + // bypassing `internalEdgeNormal`; and the opposing test reading transport rounding as opposition. // - // So the expectation is the SAME at both precisions, which is what a geometric answer should be. + // The expectation is now the SAME at both precisions, which is what a geometric answer should be, + // and the title is true rather than aspirational. // // The rotation axis applies here too: a precision claim and a frame claim are different claims, and // a scene that carries one should carry the other rather than leave a second blind spot beside the - // first. Scene and query turn together, so the expectation below is unchanged. + // first. Scene and query turn together, so the expectation below is unchanged by the yaw. for (corpus_yaws) |yaw| { for ([_]f32{ 0, 0.05 }) |y0| { for ([_]bool{ true, false }) |ceiling| { @@ -3517,19 +3520,35 @@ test "the mesh scene behaves IDENTICALLY at both precisions, at six yaws" { // was the tangent base. At f64 nothing moved at all, the correction's noise gate being // 2^29 tighter there. So the residue has a further cause again, and no common value is // pinned. - if (Real == f32) try testing.expect(along > 0.1); - - // **AND THE INTERNAL-EDGE CELL IS PINNED TO ITS VALUE, because the magnitude bound - // above does NOT discriminate it.** That cell read 0.459 before the correction was - // branched onto the character's cast path and ~1.700 after, and `> 0.1` accepts both — - // so removing the `internalEdgeNormal` call outright would leave this test green. A - // bound is the right shape for the no-freeze property and the wrong one for a fix whose - // whole effect is WHICH value is served. + // **THE COMMON EXPECTATION, AT BOTH PRECISIONS — which is what this test's title has + // always claimed and what it now checks.** The stop is the wall face at `x = 2` less + // the capsule radius: `1.7` at every yaw, both start heights, f32 and f64. // - // The stop is the wall face at `x = 2` less the capsule radius, less the stand-off - // floor. That floor is `64 · floatEps(Real) · coordScale`, so it is ~2.4e-5 at f32 and - // vanishes at f64 — measured 1.699976100 and 1.699999988, a gap that IS the floor and - // not noise, which is why the value is split by precision and not by platform. + // It reached this form by closing three causes in turn, and the last one is why a + // magnitude bound is no longer needed here. The freeze class went when the SELECTED + // contact's normal reached the slide instead of a whole-body re-query. One partial + // serve went when the character's cast path was wired onto `internalEdgeNormal`, whose + // active-edge flags it had been bypassing. The rest went when the opposing test stopped + // reading transport rounding as opposition — see `opposing_noise_k`. + // + // **The tolerance is f32-GRADE in BOTH builds, and the discriminant is the quantity's + // ORIGIN (§1.11.2).** The scene's geometry enters through the `f32` public surface, and + // the stop sits one stand-off floor short of the wall — `64 · floatEps(f32) · + // coordScale`, measured between 1.3e-5 and 2.4e-5 here and vanishing at f64. Measured + // spread over fourteen yaws and both heights: f32 in [1.699976300, 1.699986600], f64 in + // [1.699999905, 1.700000054]. The band is 2x the widest offset and still 24 000 times + // tighter than the nearest competing outcome, the 0.459 partial serve. + try testing.expectApproxEqAbs(@as(Real, 1.7), along, 5e-5); + + // **AND ONE CELL IS PINNED TIGHTER THAN THE BAND ABOVE, to its exact value.** The + // common expectation deliberately absorbs the stand-off floor; this one measures it. + // The stop is the wall face at `x = 2` less the capsule radius, less that floor — + // `64 · floatEps(Real) · coordScale`, ~2.4e-5 at f32 and vanishing at f64, measured + // 1.699976100 and 1.699999988. The gap between the two IS the floor and not noise, + // which is why the value is split by PRECISION with a named cause and never by + // platform. It was written when the band above was a magnitude bound that accepted + // 0.459 as readily as 1.700; the band is now an equality and would catch that too, so + // what this adds is the tighter grain, not the discrimination. if (yaw == 0 and y0 == 0) { const expected: Real = if (Real == f32) 1.699976100 else 1.699999988; try testing.expectApproxEqAbs(expected, along, api_tol); From a2e74939a5cc9bfb58654b7b26f052a310811026 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 01:31:50 +0200 Subject: [PATCH 077/100] fix(forge): the opposing verdict must not depend on the direction's norm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kernels normalise the sweep direction themselves, so a caller is entitled to hand this path a direction of any length — but `opposes` compared `n . d` against an ABSOLUTE threshold, so the same geometry with the direction handed over twice as long changed sides. No geometry justifies that; it was introduced with the noise band one commit ago. The threshold is now scaled by the direction's length, which is exactly normalising it without paying a division, and it is guarded at TRUE zero by construction: at zero length the dot is zero too and `0 < 0` is false, so a null direction opposes nothing and no epsilon is invented. `n` is unit at all four call sites by the narrowphase's own contract, so only `d` needed the scaling. 489/489 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/modules/forge/forge_3d/body_manager.zig | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index f7b54a1d..ee1aaf8b 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -1678,10 +1678,21 @@ const SingleManifoldCollector = struct { /// closed by `internalEdgeNormal` instead, which is a different mechanism for a different cause. const opposing_noise_k: comptime_int = 16; -/// Whether a surface with outward normal `n` opposes travel along unit direction `d`, by more than -/// float noise. One predicate for all four arms, so they cannot drift apart. +/// Whether a surface with outward normal `n` opposes travel along `d`, by more than float noise. +/// One predicate for all four arms, so they cannot drift apart. +/// +/// **`d` NEED NOT BE UNIT, and the threshold is scaled by its length rather than assuming it.** The +/// kernels normalise the direction themselves, so a caller is entitled to hand this path a direction +/// of any length — and a FIRST version compared `n · d` against an absolute threshold, which made the +/// verdict depend on `‖d‖`: the same geometry, with the direction handed over twice as long, changed +/// sides. No geometry justifies that. Multiplying the threshold by `‖d‖` is exactly normalising `d` +/// without paying a division, and it is guarded at TRUE zero by construction: at `‖d‖ = 0` the dot is +/// zero too and `0 < 0` is false, so a null direction opposes nothing and no epsilon is invented. +/// +/// `n` IS unit at all four call sites by the narrowphase's own contract — a cast normal, a manifold +/// normal, a transported stored plane — so only `d` needs the scaling. fn opposes(n: Vec3r, d: Vec3r) bool { - return n.dot(d) < -@as(Real, opposing_noise_k) * std.math.floatEps(Real); + return n.dot(d) < -@as(Real, opposing_noise_k) * std.math.floatEps(Real) * d.length(); } /// Slack, in ULPs of 1, on the test that a contact normal ALREADY IS the face normal — and on From a9751c59bdde9c254fbe836c6ab9019dab4362d5 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 02:34:37 +0200 Subject: [PATCH 078/100] fix(forge): revoke the opposing noise band, it opened tunnelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The band closed all twenty-eight cells of the insoluble-squeeze grid at both precisions and still had to go: what it bought in an authoring-fault configuration it paid for on the DEFAULT path. Discarding a grazing contact lets the capsule advance INTO the surface by distance x |n.d| — measured 3.05e-5 over 32 m and unbounded in the distance. A traversable geometry is worse than a partial serve. And no band on that quantity can work, which is the durable result of the thread and is written at the site so nobody retries it. The two populations are a TRANSPORT RESIDUE tracking floatEps(Real) — it is the rounding of a quaternion rotation — and a REAL GRAZING INCIDENCE that is geometric and precision-free. Nine orders of separation at f64, a factor of 2.4 at f32, so the inseparability is structurally an f32 phenomenon. The length-dimensioned form escapes nothing: coordScale ~ distance makes it this test with both sides multiplied by the distance. The predicate returns to an exact zero. P2's norm-independence is KEPT but its mechanism is now structural rather than arranged: scaling d by a positive factor scales the dot by the same factor and cannot change its sign. The ||d|| term is not merely inert at zero — 0 * inf is NaN and x < NaN is false — so writing it would introduce a path where an infinite direction opposes nothing. What comes back is PINNED and not suffered: 0.506016400 at f32 for yaw 90 from a base of 0.05, and an exact zero at f64 for the same yaw from a tangent base, which nothing watched before. Those two assertions are the pin of the revocation itself and fail against a2e7493. The title stops claiming the precisions agree, because they do not. --- src/modules/forge/forge_3d/body_manager.zig | 72 +++++++---------- .../forge/forge_3d/tests/character_test.zig | 79 ++++++++++--------- 2 files changed, 69 insertions(+), 82 deletions(-) diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index ee1aaf8b..7a7f0318 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -1643,56 +1643,38 @@ const SingleManifoldCollector = struct { } }; -/// Slack, in ULPs of 1, on the test that decides whether a surface OPPOSES a sweep. +/// Whether a surface with outward normal `n` opposes travel along `d`. /// -/// **`k · floatEps(Real)` and nothing else — no coordinate scale.** `n · d` is the product of two -/// UNIT vectors and is therefore DIMENSIONLESS, so a length scale has no business in it. That is what -/// separates this constant from `contactMargin`, whose operand is a distance and which is right to -/// carry `coordScale`. +/// **AN EXACT ZERO, AND A NOISE BAND HERE WAS WRITTEN, MEASURED AND REVOKED.** The band closed all +/// twenty-eight cells of the insoluble-squeeze grid at BOTH precisions and it still had to go, because +/// what it bought in that authoring-fault configuration it paid for on the DEFAULT path: discarding a +/// grazing contact lets the capsule advance INTO the surface by `distance × |n · d|`, measured at +/// `3.05e-5` over 32 m and growing without bound with the distance. A traversable geometry is worse +/// than a partial serve. /// -/// **It IS governed by §1.11.2's tolerance discipline**, unlike `max_slope`, `padding` and -/// `predictive_contact_distance`, which are named PHYSICAL parameters of the character and -/// deliberately escape it. This one absorbs transport rounding and expresses no modelling intent. +/// **AND NO BAND ON THIS QUANTITY CAN WORK — the demonstration is algebraic, not empirical, and it is +/// written here so nobody spends another round rediscovering it.** The two populations a band would +/// have to separate are a TRANSPORT RESIDUE, which tracks `floatEps(Real)` because it is the rounding +/// of a quaternion rotation, and a REAL GRAZING INCIDENCE, which is geometric and does not depend on +/// the precision at all. They separate by nine orders at f64 and by a factor of 2.4 at f32, so the +/// inseparability is structurally an f32 phenomenon and no threshold expressed in ULPs sits between +/// them. The length-dimensioned form escapes nothing either: the penetration is +/// `distance × |n · d|` and the tolerance would be `k · floatEps · coordScale` with `coordScale ≈ +/// distance`, so it is THIS test with both sides multiplied by the distance — it buys nothing, at any +/// precision. /// -/// **Why a band exists here at all, and it is measured rather than argued.** A horizontal triangle's -/// normal does not survive a quaternion yaw exactly: transported through 90° it reads -/// `(7.4e-16, 1, 1.1e-15)` at f64 and `(3.97e-7, 1, 3.97e-7)` at f32. Its dot with a horizontal sweep -/// is then a small NEGATIVE residue where the geometry has an exact zero — so an exact `>= 0` admitted -/// a FLOOR as an obstacle to horizontal travel. The advance is zero, the iteration is spent, a plane -/// slot is spent; the slide against that near-vertical normal then injects a vertical residue into the -/// motion, which admits the CEILING by the same route; and two near-anti-parallel planes form a crease -/// whose axis is their cross product, i.e. pure noise. At f64 that noise axis re-found the floor and -/// the character froze at exactly zero, every call; at f32 it happened to find a real contact and -/// served. A coin toss arbitrated by rounding, which is what this closes. +/// What the exact zero leaves open is upstream of this predicate and is recorded in `CLAUDE.md`: +/// whether a path exists that does not DEGRADE an axis-aligned quad's face normal under a yaw, rather +/// than tolerating the degradation after the fact. /// -/// **The direction of the band is decided and not symmetric**: it reads NON-OPPOSING, so a contact -/// within it is DISCARDED. Discarding one that opposed by a hair advances by a hair into a surface, -/// and the depenetration at the head of the next call recovers that; reading it as opposing FREEZES, -/// and a freeze recovers from nothing. -/// -/// `k = 16` against measured residues of `3.3 · floatEps` at f32 and `5 · floatEps` at f64 — a 3× -/// margin, and the same value `contact_margin_conv_k` carries for the same reason: the residue is -/// ACCUMULATED across the transport, the direction's normalisation and the dot's own three products. -/// What it costs in angle is `asin(16 · floatEps)` — `1.1e-4` degrees at f32, `2.0e-13` at f64. No -/// modelling notices that. The 3.6° cone this milestone REFUSED is thirty thousand times wider and is -/// closed by `internalEdgeNormal` instead, which is a different mechanism for a different cause. -const opposing_noise_k: comptime_int = 16; - -/// Whether a surface with outward normal `n` opposes travel along `d`, by more than float noise. -/// One predicate for all four arms, so they cannot drift apart. -/// -/// **`d` NEED NOT BE UNIT, and the threshold is scaled by its length rather than assuming it.** The -/// kernels normalise the direction themselves, so a caller is entitled to hand this path a direction -/// of any length — and a FIRST version compared `n · d` against an absolute threshold, which made the -/// verdict depend on `‖d‖`: the same geometry, with the direction handed over twice as long, changed -/// sides. No geometry justifies that. Multiplying the threshold by `‖d‖` is exactly normalising `d` -/// without paying a division, and it is guarded at TRUE zero by construction: at `‖d‖ = 0` the dot is -/// zero too and `0 < 0` is false, so a null direction opposes nothing and no epsilon is invented. -/// -/// `n` IS unit at all four call sites by the narrowphase's own contract — a cast normal, a manifold -/// normal, a transported stored plane — so only `d` needs the scaling. +/// **The verdict does not depend on `‖d‖`, and at an exact zero that is STRUCTURAL rather than +/// arranged.** Scaling `d` by any positive factor scales the dot by the same factor and cannot change +/// its sign. The `‖d‖` term the band needed — and needed rightly, since an absolute threshold made the +/// same geometry change sides when the direction was handed over twice as long — would now multiply a +/// zero, which is not merely inert: `0 · inf` is a NaN, and `x < NaN` is false, so writing it would +/// introduce a path where an infinite direction opposes nothing. The sign test has no such path. fn opposes(n: Vec3r, d: Vec3r) bool { - return n.dot(d) < -@as(Real, opposing_noise_k) * std.math.floatEps(Real) * d.length(); + return n.dot(d) < 0; } /// Slack, in ULPs of 1, on the test that a contact normal ALREADY IS the face normal — and on diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 50777234..815ac496 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3416,22 +3416,24 @@ test "one mesh carrying both floor and wall: the wall still blocks, at six yaws" } } -test "the mesh scene behaves IDENTICALLY at both precisions, at six yaws" { +test "the mesh scene at six yaws: the wall blocks, and the squeeze residue is PINNED" { const gpa = testing.allocator; - // **THIS TEST PINNED A PRECISION SPLIT THREE TIMES AND THE SPLIT IS GONE.** Each time it read as - // one defect and was three: the same floor/wall/ceiling mesh blocked at 1.7 at f32 and froze at - // exactly 0 at f64, then froze at six yaws of twenty-eight, then served 0.459 at three of them. - // The causes were, in order, a cast/manifold disagreement resolved by filtering during selection; - // a whole-body re-query displacing the contact the cast had SELECTED; the character's cast path - // bypassing `internalEdgeNormal`; and the opposing test reading transport rounding as opposition. + // **THE TITLE NO LONGER CLAIMS THE TWO PRECISIONS AGREE, BECAUSE THEY DO NOT.** They agree on + // eleven cells of twelve and part ways on the twelfth: at f32 the ceiling scene at 90° from a base + // of 0.05 serves 0.506016400 where 1.700 is the norm, and at f64 the same scene from a TANGENT base + // freezes at exactly zero. Three causes were found and closed on the way here — a cast/manifold + // disagreement, a whole-body re-query displacing the contact the cast had SELECTED, and the + // character's path bypassing `internalEdgeNormal` — and a fourth was closed and then REVOKED, its + // remedy costing more on the default path than it bought in a squeeze. `CLAUDE.md` carries the + // cause and its owner. // - // The expectation is now the SAME at both precisions, which is what a geometric answer should be, - // and the title is true rather than aspirational. + // A title that asserts what the assertions do not check is the defect this suite has paid for more + // than once, so it says what it does: the wall blocks at every yaw, and the residue is pinned. // // The rotation axis applies here too: a precision claim and a frame claim are different claims, and // a scene that carries one should carry the other rather than leave a second blind spot beside the - // first. Scene and query turn together, so the expectation below is unchanged by the yaw. + // first. Scene and query turn together, so the expectation below is unchanged. for (corpus_yaws) |yaw| { for ([_]f32{ 0, 0.05 }) |y0| { for ([_]bool{ true, false }) |ceiling| { @@ -3520,35 +3522,38 @@ test "the mesh scene behaves IDENTICALLY at both precisions, at six yaws" { // was the tangent base. At f64 nothing moved at all, the correction's noise gate being // 2^29 tighter there. So the residue has a further cause again, and no common value is // pinned. - // **THE COMMON EXPECTATION, AT BOTH PRECISIONS — which is what this test's title has - // always claimed and what it now checks.** The stop is the wall face at `x = 2` less - // the capsule radius: `1.7` at every yaw, both start heights, f32 and f64. + if (Real == f32) try testing.expect(along > 0.1); + + // **AND THE RESIDUAL CELLS ARE PINNED TO THEIR VALUES RATHER THAN SUFFERED.** A noise + // band on the opposing test closed all twenty-eight cells at both precisions and was + // REVOKED — see `opposes`, which carries the algebra: it opened a tunnelling window on + // the DEFAULT path worth `distance × |n · d|`, measured `3.05e-5` over 32 m and + // unbounded in the distance, and no band on that quantity can separate a transport + // residue that tracks `floatEps(Real)` from a grazing incidence that is geometric. // - // It reached this form by closing three causes in turn, and the last one is why a - // magnitude bound is no longer needed here. The freeze class went when the SELECTED - // contact's normal reached the slide instead of a whole-body re-query. One partial - // serve went when the character's cast path was wired onto `internalEdgeNormal`, whose - // active-edge flags it had been bypassing. The rest went when the opposing test stopped - // reading transport rounding as opposition — see `opposing_noise_k`. + // So the residue is permanent until the cause is closed upstream, and pinning it is + // what keeps this test honest: `> 0.1` above accepts 0.506 as readily as 1.700, and + // NOTHING at all watches the f64 cell, which is an exact zero — a character that never + // moves again. These two assertions are also the pin of the REVOCATION itself: against + // `a2e7493`, where the band made all twenty-eight converge, they fail. + if (yaw == 90) { + if (Real == f32 and y0 == 0.05) { + try testing.expectApproxEqAbs(@as(Real, 0.506016400), along, api_tol); + } + if (Real == f64 and y0 == 0) try testing.expect(along == 0); + } + + // **AND THE INTERNAL-EDGE CELL IS PINNED TO ITS VALUE, because the magnitude bound + // above does NOT discriminate it.** That cell read 0.459 before the correction was + // branched onto the character's cast path and ~1.700 after, and `> 0.1` accepts both — + // so removing the `internalEdgeNormal` call outright would leave this test green. A + // bound is the right shape for the no-freeze property and the wrong one for a fix whose + // whole effect is WHICH value is served. // - // **The tolerance is f32-GRADE in BOTH builds, and the discriminant is the quantity's - // ORIGIN (§1.11.2).** The scene's geometry enters through the `f32` public surface, and - // the stop sits one stand-off floor short of the wall — `64 · floatEps(f32) · - // coordScale`, measured between 1.3e-5 and 2.4e-5 here and vanishing at f64. Measured - // spread over fourteen yaws and both heights: f32 in [1.699976300, 1.699986600], f64 in - // [1.699999905, 1.700000054]. The band is 2x the widest offset and still 24 000 times - // tighter than the nearest competing outcome, the 0.459 partial serve. - try testing.expectApproxEqAbs(@as(Real, 1.7), along, 5e-5); - - // **AND ONE CELL IS PINNED TIGHTER THAN THE BAND ABOVE, to its exact value.** The - // common expectation deliberately absorbs the stand-off floor; this one measures it. - // The stop is the wall face at `x = 2` less the capsule radius, less that floor — - // `64 · floatEps(Real) · coordScale`, ~2.4e-5 at f32 and vanishing at f64, measured - // 1.699976100 and 1.699999988. The gap between the two IS the floor and not noise, - // which is why the value is split by PRECISION with a named cause and never by - // platform. It was written when the band above was a magnitude bound that accepted - // 0.459 as readily as 1.700; the band is now an equality and would catch that too, so - // what this adds is the tighter grain, not the discrimination. + // The stop is the wall face at `x = 2` less the capsule radius, less the stand-off + // floor. That floor is `64 · floatEps(Real) · coordScale`, so it is ~2.4e-5 at f32 and + // vanishes at f64 — measured 1.699976100 and 1.699999988, a gap that IS the floor and + // not noise, which is why the value is split by precision and not by platform. if (yaw == 0 and y0 == 0) { const expected: Real = if (Real == f32) 1.699976100 else 1.699999988; try testing.expectApproxEqAbs(expected, along, api_tol); From f0ba3706f7f801a9ff1b3e16265c08c7333fa2ae Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 02:34:50 +0200 Subject: [PATCH 079/100] docs: consign the revoked band and the commit-hygiene cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fifth open decision returns, carrying what the previous version could not: the named cause, the remedy that was tried and revoked, and the algebraic demonstration that no band on n.d separates a transport residue from a real grazing incidence at f32. And a tooling fact the revocation itself produced: 94ee517 carried a remedy and every expectation that remedy made possible in one commit, so undoing it could not be a revert — it was a hand reconstruction of three expectations, two re-measured cells and a re-authored consignation. Third instance of the class in this milestone. The cost is invisible going forward and paid going backward, which is when one is least willing to pay it. Separated from the revocation for exactly that reason. --- CLAUDE.md | 1 + briefs/M1.1.12-character-controller.md | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 17947092..6b4099b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,7 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **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. +- **Residual yaw dependence in an insoluble squeeze (opened at M1.1.12)**: when a character is taller than the space that holds it, the distance served depends on the scene's YAW — two partial cells of twenty-eight at f32, one partial and one exact freeze at f64. The CAUSE is named and measured: a face normal transported through a 90° yaw by a quaternion is not exactly perpendicular to a horizontal direction — it carries a residue of `3.3·floatEps` — so an exact `n · d >= 0` test admits a floor that cannot geometrically oppose horizontal travel, the iteration is spent, `slideAlongPlane` injects a vertical residue that drags the ceiling into the same filter, and two near-antiparallel normals send `slideAlongCrease` onto a cross product whose direction is pure noise. A noise band on `n · d` was written, measured to close all twenty-eight cells at both precisions, and REVOKED: it opened a tunnelling window on the DEFAULT path, penetration being `distance × |n·d|` and measured at `3.05e-5` over 32 m, growing without bound with distance. No band on that quantity can work, and the demonstration is algebraic rather than empirical: the transport residue tracks `floatEps(Real)` while a real grazing incidence is GEOMETRIC and does not, so they separate by nine orders at f64 and by a factor of 2.4 at f32 — the inseparability is structurally an f32 phenomenon. The length-dimensioned form escapes nothing: `coordScale ≈ d` makes it the dimensionless test with both sides multiplied by the distance. What remains open is upstream of the predicate: whether a path exists that does not DEGRADE the face normal of an axis-aligned quad under a yaw, rather than tolerating the degradation afterwards. Owner: the next milestone that opens `character.zig`; M1.1.14 is the likeliest, rotation invariance being a sibling of the determinism it owns. ## Non-negotiable rules diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 2b5ad365..247fa196 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2637,6 +2637,31 @@ holds the runner it claims. A restored-and-healthy cache is the normal, fast cas reason and stays right for a better one: there is no flake to compensate for, and pinning would freeze a dependency against a cache-content accident that eviction clears in one action. +### Tooling fact — a remedy and the expectations it enables are TWO commits + +`94ee517` carried the opposing-test noise band, the common expectation that band made possible, the +removal of a precision guard, a test title, and the deletion of an open decision — one message +describing a fix, one commit containing a fix and everything downstream of it. When the band was +revoked two rounds later, that revocation could not be a `git revert`: it had to be RECONSTRUCTED by +hand, restoring three expectations from the parent, re-measuring two residual cells, and re-authoring +the consignation. + +**The cost is invisible until someone wants to undo.** A commit that mixes a remedy with the +expectations it enables reads perfectly well going forward and is a manual reconstruction going +backward, which is exactly when one is least willing to pay. + +Third instance of the class in this milestone: the closing commit that swept `CLAUDE.md` and the +brief under a message describing only one of them; a commit whose staged set exceeded what its +message named; and this one. The rule is not "smaller commits" — it is that a change and the +assertions that become possible BECAUSE of it are different claims, and the second is worthless +evidence when it cannot be detached from the first. + +Related, and self-reported the same round: a background command reported FAILURE on a green suite +because its trailing `grep "^Build Summary"` found nothing — that line only exists under +`--summary all` — and the task's exit code came from the `grep`. The standing fact says to capture +the status before any filtering; it needs the other half, which is that nothing may run after the +capture either. + ### Tooling fact — `test runner failed to respond`, zero failed assertions ``` From 0dc91373762b98b7308d4e1d2c4795915b3f434b Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 03:51:00 +0200 Subject: [PATCH 080/100] fix(forge): the opposing filter shares the kernel's conditioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The predicate conditioned the direction itself instead of taking what the kernel already computes, and got it wrong twice from that one root: an absolute threshold made the verdict depend on the direction's length, then a bare sign test UNDERFLOWED. For a denormal direction a product n_i * d_i with |n_i| < 0.5 flushes to exactly zero, the sign is destroyed, and a real wall reads non-opposing and is discarded — contradicting the contract shapecast_test.zig already pins, that every non-zero direction including a denormal is served. unitOf becomes pub and is re-exported by the narrowphase facade. It reduces by the largest absolute component before normalising, so it neither squares a denormal into zero nor overflows a huge one, and returns null at exact zero and nowhere else. There are no longer two answers to what direction the cast travels. The regression is at the BODY level, which is the gap that let both forms through: the kernel's own denormal test passed the whole time because it never goes near this predicate. Scope measured, not assumed. Triangle soup is excluded because the PLAIN cast returns null there too, so the two agree and the filter is not implicated — the direction is lost upstream in the swept traversal. Half-space is excluded because plane.zig asserts the direction is unit where shapecast.zig conditions it: two kernels, one parameter, two contracts, with the entry normalising once. Both are recorded rather than papered over. The tilt is measured, not derived: an axis-aligned wall proves nothing, since -1 * floatTrueMin is representable, and a first version of this test passed against the pre-fix commit. Swept over ten yaws the raw product survives at 20-50 and 70-85 degrees and is exactly zero at 60 and 65, where the contact normal's X component reads -0.4999997 and -0.4226. 65 is taken, and the test fails at BOTH precisions against f0ba370. 490/490 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/modules/forge/forge_3d/body_manager.zig | 25 +++++-- .../forge_3d/pipeline/narrowphase/root.zig | 5 ++ .../pipeline/narrowphase/shapecast.zig | 2 +- .../forge/forge_3d/tests/character_test.zig | 66 +++++++++++++++++++ 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 7a7f0318..21d94fc9 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -1667,14 +1667,25 @@ const SingleManifoldCollector = struct { /// whether a path exists that does not DEGRADE an axis-aligned quad's face normal under a yaw, rather /// than tolerating the degradation after the fact. /// -/// **The verdict does not depend on `‖d‖`, and at an exact zero that is STRUCTURAL rather than -/// arranged.** Scaling `d` by any positive factor scales the dot by the same factor and cannot change -/// its sign. The `‖d‖` term the band needed — and needed rightly, since an absolute threshold made the -/// same geometry change sides when the direction was handed over twice as long — would now multiply a -/// zero, which is not merely inert: `0 · inf` is a NaN, and `x < NaN` is false, so writing it would -/// introduce a path where an infinite direction opposes nothing. The sign test has no such path. +/// **`d` IS CONDITIONED BY THE KERNEL'S OWN `unitOf`, and that is the point rather than a detail.** +/// Two earlier forms of this predicate were wrong about the direction and each in a different way: an +/// ABSOLUTE threshold made the verdict depend on `‖d‖`, so the same geometry changed sides when the +/// direction arrived twice as long; and a bare sign test on the raw `d` UNDERFLOWS, because for a +/// denormal direction a product `n_i · d_i` with a small `n_i` flushes to exactly zero and the sign is +/// destroyed — which would read a real wall as non-opposing and discard it, contradicting the contract +/// `shapecast_test.zig` already pins, that every non-zero direction including a denormal is served. +/// +/// Both mistakes have one root: this predicate was conditioning the direction ITSELF instead of taking +/// the conditioning the kernel already performs. `unitOf` reduces by the largest absolute component +/// before normalising, so it neither squares a denormal into zero nor overflows a huge one, and it +/// returns `null` at EXACT zero and nowhere else. Sharing it means there are no longer two answers to +/// the question of what direction this cast is travelling in. +/// +/// A null direction opposes nothing, which is the same answer the sign test gave and for a better +/// stated reason: there is no direction to oppose. fn opposes(n: Vec3r, d: Vec3r) bool { - return n.dot(d) < 0; + const u = narrowphase.unitOf(Real, d) orelse return false; + return n.dot(u) < 0; } /// Slack, in ULPs of 1, on the test that a contact normal ALREADY IS the face normal — and on diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig index 81f73149..9bf0cafc 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig @@ -146,6 +146,11 @@ pub const max_shapecast_iterations = shapecast_mod.max_shapecast_iterations; /// Cast a shape along a direction against another; `null` on a miss. No error /// channel: a support map covers every bounded convex, so nothing is rejected. pub const castShape = shapecast_mod.castShape; + +/// The kernel's own direction conditioning, SHARED rather than reproduced: reduce by the largest +/// absolute component, then normalise. `null` at EXACT zero and nowhere else, so a denormal direction +/// — whose square underflows — normalises exactly and is served (§1.11.4). +pub const unitOf = shapecast_mod.unitOf; /// `castShape` with the ceiling and the diagnostics exposed — the seam that makes the /// normative fallback observable rather than merely documented. pub const castShapeBounded = shapecast_mod.castShapeBounded; diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig index f9c1534c..8c770e35 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig @@ -440,7 +440,7 @@ fn terminal( /// finite vector and underflows for a legitimately tiny one, and `foundation`'s /// `normalize` is unguarded by design. The reduction is a component-wise DIVISION, /// never a multiplication by `1 / scale`, whose reciprocal overflows for a denormal. -fn unitOf(comptime T: type, v: math.Vec(3, T)) ?math.Vec(3, T) { +pub fn unitOf(comptime T: type, v: math.Vec(3, T)) ?math.Vec(3, T) { const Simd = @Vector(3, T); const scale = @reduce(.Max, @abs(v.data)); if (scale == 0) return null; diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 815ac496..e86a8be1 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3683,6 +3683,72 @@ test "a ROTATED mesh: the opposing filter must not mix frames" { try testing.expectApproxEqAbs(plain.?.distance, filtered.?.distance, api_tol); } +test "the opposing filter serves a DENORMAL direction where the cast does" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + + // **THE CONTRACT IS THE KERNEL'S AND THE FILTER OWED IT TOO.** `shapecast_test.zig` pins that every + // non-zero direction is served, a denormal included, because `unitOf` reduces by the largest + // absolute component instead of squaring. The opposing filter reproduced the direction handling + // instead of sharing it and got it wrong twice: an ABSOLUTE threshold made the verdict depend on + // `‖d‖`, so the same geometry changed sides when the direction arrived twice as long; then a bare + // sign test UNDERFLOWED — for a denormal `d` a product `n_i · d_i` with a small `n_i` flushes to + // exactly zero, the sign is destroyed, and a real wall reads non-opposing and is discarded. + // + // **The test lives at the BODY level and not at the kernel's, which is the gap that let both forms + // through.** The kernel's own denormal test passed the whole time: it never goes near this + // predicate. A contract tested one tier below the code that breaks it is not a test of that code. + // + // **Scope, and it is MEASURED rather than assumed.** The claim is the FILTER's: it must answer what + // the unfiltered cast answers. The convex arm is where that has content — the plain cast serves a + // denormal there. The other two arms were tried and excluded on evidence, not convenience: + // * TRIANGLE SOUP — the plain cast returns `null` for a denormal too (`plainDen=false`, + // `filtDen=false`), so the two AGREE and the filter is not implicated. The direction is lost + // upstream of it, in the swept traversal rather than in the kernel. Recorded, not fixed here. + // * HALF-SPACE — `plane.zig:297` ASSERTS `|‖d‖² − 1| <= unit_k · floatEps` and conditions + // nothing, where `shapecast.zig:199` conditions through `unitOf`. Two kernels, one parameter, + // two contracts; the entry normalises once (`query/root.zig:703`) and the half-space arm relies + // on it. A denormal is out of its DOMAIN, and the assert fires as designed. + const tiny = std.math.floatTrueMin(Real); + // Non-vacuity: the square really does underflow, so this is the hard case and not merely a small one. + try testing.expectEqual(@as(Real, 0), tiny * tiny); + + // **THE WALL IS TILTED, AND AN AXIS-ALIGNED ONE PROVES NOTHING — measured, and the first version of + // this test made exactly that mistake.** Against a wall whose normal is `(−1, 0, 0)` the product + // `n_x · d_x` is `−1 · tiny`, which is representable, so the sign survives and the broken predicate + // passes. The product underflows only when `|n_x| < 0.5`, since `0.5 · floatTrueMin` is the + // rounding boundary: computed, `cos 45°` survives and `cos 60°` and beyond flush to exactly zero. + // The tilt is chosen by MEASUREMENT and not by trigonometry on the box: what reaches the predicate + // is the CONTACT normal, which is not the face normal one draws on paper. Swept over ten yaws, the + // raw product is `−1e-45` and survives at 20°–50° and again at 70°–85°, and is exactly `0e0` at 60° + // and 65°, where the contact normal's X component reads `−0.4999997` and `−0.4226`. 65° is taken, + // and the axis-aligned version this replaces was caught by the mutation probe passing — twice. + const probe = SupportShapeR{ .core = .{ .segment = 0.6 }, .radius = 0.3 }; + const body = try bm.addBody(gpa, &store, .{ + .entity = ent(890), + .body_type = .static, + .shape = try store.createShape(gpa, .{ .box = .{ .half_extents = av(0.5, 2, 2) } }), + .position = av(2.5, 0, 0), + .rotation = math.Quatf.fromAxisAngle(av(0, 1, 0), 65.0 * std.math.pi / 180.0), + }); + + const plain = bm.castShapeBody(&store, body, probe, v(-2, 0, 0), Quatr.identity, v(tiny, 0, 0), 10, .ignore); + const filtered = bm.castShapeBodyOpposing(&store, body, probe, v(-2, 0, 0), Quatr.identity, v(tiny, 0, 0), 10, .ignore, true); + const unit = bm.castShapeBodyOpposing(&store, body, probe, v(-2, 0, 0), Quatr.identity, v(1, 0, 0), 10, .ignore, true); + + // The premise: the unfiltered cast DOES serve it, so the filter has something to be wrong about. + try testing.expect(plain != null); + // The claim: the wall opposes the sweep either way, the direction's MAGNITUDE being no geometric + // fact — so the filter answers what the cast answers, and at the unit direction's distance. + try testing.expect(filtered != null); + try testing.expect(unit != null); + try testing.expectApproxEqAbs(plain.?.distance, filtered.?.distance, api_tol); + try testing.expectApproxEqAbs(unit.?.distance, filtered.?.distance, api_tol); +} + test "an ACTIVE EDGE at exact tangency blocks — the face normal is not enough" { const gpa = testing.allocator; var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); From 286c30eb100f7fe728f5de2f03320eabdd0be3c2 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 12:30:45 +0200 Subject: [PATCH 081/100] fix(forge): condition the cast direction once, pin the contract table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit castShapeBody had a contract per SHAPE CLASS that nobody had written down, which is why the same defect arrived one cell at a time for four rounds. Measured over three classes and six properties at both precisions, four cells diverged and all four were the DIRECTION: plane.zig asserted the vector was unit and conditioned nothing, so a non-unit, denormal or zero direction was a domain violation on that arm and an ordinary answer on the other two; and the mesh arm additionally lost a denormal in its swept traversal. Conditioning once at the entry took all four together, the mesh traversal included. The conditioning skips a direction that is ALREADY unit, and that is not an optimisation. A first form normalised unconditionally and shifted every query answer by an ULP, caught by mesh_test's bit-exact brute-force oracle: the query entry already normalises, so an unconditional line here was a SECOND conditioning of one vector — the class this pass exists to collapse, re-introduced by the collapse. The gate is the module's existing unit predicate and no answer depends on which branch is taken. opposes now ASSERTS its precondition instead of re-establishing it, for the same reason. And unitOf reverts to private: it duplicates Vec.normalizeScaled line for line, so publishing it would have been a second answer as well. Two cells were measured NOT to diverge and are recorded as such rather than uniformised: hit.normal at an initial overlap differs in value because 1.11.11 fixes -direction as the FALLBACK and the half-space has no terminal simplex to offer instead, while all three keep the guarantee the contract actually makes, normal . direction <= 0; and the manifold is consulted on all three arms. The parameterised test is what was missing from the start: one scene per class, the same assertion for all three, so a future arm breaks it here rather than in a character scene three milestones later. It aborts against 0dc9137 on plane.zig:297. 491/491 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/modules/forge/forge_3d/body_manager.zig | 46 ++++++- .../forge_3d/pipeline/narrowphase/root.zig | 5 - .../pipeline/narrowphase/shapecast.zig | 2 +- .../forge/forge_3d/tests/character_test.zig | 113 ++++++++++++++++++ 4 files changed, 155 insertions(+), 11 deletions(-) diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 21d94fc9..3c2dab87 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -793,7 +793,37 @@ pub const BodyManager = struct { self.bodies.items(.position)[idx], self.bodies.items(.rotation)[idx], ); - const local_dir = cast_rotation.conjugate().rotateVec3(direction); + // **THE DIRECTION IS CONDITIONED ONCE, HERE, BEFORE THE DISPATCH — and that single line is a + // contract table collapsing.** Measured across the three shape classes, this entry had FOUR + // divergent behaviours for one parameter: `plane.zig` ASSERTS the direction is unit and + // conditions nothing, so a non-unit, a denormal or a zero direction was a domain violation on + // that arm and an ordinary answer on the other two; `shapecast.zig` conditions through + // `unitOf`; and the mesh arm additionally lost a denormal in its swept traversal. Every one of + // those is the same defect: the callee was left to decide what direction it had been handed. + // + // Conditioned at the entry, all three arms receive a unit direction and the assert becomes + // satisfied BY CONSTRUCTION rather than a precondition charged to the caller — which is what + // makes it an invariant instead of a trap. A zero direction is the one degenerate case and it + // answers `null` uniformly, since `unitOf` is null at EXACT zero and nowhere else. + // + // `max_distance` is therefore in units of the NORMALISED direction on every arm, which is what + // the two conditioning arms already did and what the asserting one had no way to say. + // + // **AND IT CONDITIONS ONLY WHAT IS NOT ALREADY CONDITIONED, which is not an optimisation.** A + // first form normalised unconditionally and shifted every query answer by an ULP, caught by + // `mesh_test`'s bit-exact brute-force oracle: the query entry ALREADY normalises + // (`query/root.zig`), so an unconditional line here is a SECOND conditioning of the same vector + // — the very class this entry is collapsing, re-introduced by the collapse. The reduce-then- + // normalise round trip is not bit-idempotent, so applying it twice moves the result. + // + // The gate is the module's existing unit predicate, the same `16 · floatEps` slack + // `plane.zig` and `shapecast.zig` assert with, and no answer depends on which branch is taken: + // both produce a unit direction, one by passing it through untouched. + const dir = if (@abs(direction.lengthSq() - 1) <= 16 * std.math.floatEps(Real)) + direction + else + direction.normalizeScaled() orelse return null; + const local_dir = cast_rotation.conjugate().rotateVec3(dir); // The CAST shape is always a bounded convex — the query entry refuses an // unbounded probe with a typed error (§1.11.7) — so only the HIT body's // category is dispatched on. Exhaustive, no `else`. @@ -858,7 +888,7 @@ pub const BodyManager = struct { inv_rot.rotateVec3(cast_origin.sub(self.bodies.items(.position)[idx])), inv_rot.mul(cast_rotation), ); - const sweep_dir = inv_rot.rotateVec3(direction); + const sweep_dir = inv_rot.rotateVec3(dir); var collector = MeshCastCollector{ .data = data, .cast_shape = cast_shape, @@ -898,7 +928,7 @@ pub const BodyManager = struct { // hit of the cast/manifold disagreement. Nothing real to oppose the motion. break :blk (probe_manifold.normal orelse return null).neg(); }; - if (!opposes(opposing_normal, if (hit.distance > 0) local_dir else direction)) return null; + if (!opposes(opposing_normal, if (hit.distance > 0) local_dir else dir)) return null; // Already WORLD on this arm: `collideShapeBody` is a world-space call, which is why the // predicate above dots it with `direction` and not with `local_dir`. if (hit.distance <= 0) contact_normal = opposing_normal; @@ -1684,8 +1714,14 @@ const SingleManifoldCollector = struct { /// A null direction opposes nothing, which is the same answer the sign test gave and for a better /// stated reason: there is no direction to oppose. fn opposes(n: Vec3r, d: Vec3r) bool { - const u = narrowphase.unitOf(Real, d) orelse return false; - return n.dot(u) < 0; + // `d` is UNIT by the entry's conditioning, not by hope: `castShapeBodyOpposing` runs `unitOf` + // once before the dispatch, and every direction reaching here is that vector or a rotation of it, + // which preserves the norm to a few ULP. Asserted rather than re-established, with the same + // `16 · floatEps` slack `plane.zig` and `shapecast.zig` use for the identical claim — because + // re-normalising here would be a SECOND answer to what direction the cast travels in, which is + // the very class this entry has just finished collapsing. + std.debug.assert(@abs(d.lengthSq() - 1) <= 16 * std.math.floatEps(Real)); + return n.dot(d) < 0; } /// Slack, in ULPs of 1, on the test that a contact normal ALREADY IS the face normal — and on diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig index 9bf0cafc..81f73149 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig @@ -146,11 +146,6 @@ pub const max_shapecast_iterations = shapecast_mod.max_shapecast_iterations; /// Cast a shape along a direction against another; `null` on a miss. No error /// channel: a support map covers every bounded convex, so nothing is rejected. pub const castShape = shapecast_mod.castShape; - -/// The kernel's own direction conditioning, SHARED rather than reproduced: reduce by the largest -/// absolute component, then normalise. `null` at EXACT zero and nowhere else, so a denormal direction -/// — whose square underflows — normalises exactly and is served (§1.11.4). -pub const unitOf = shapecast_mod.unitOf; /// `castShape` with the ceiling and the diagnostics exposed — the seam that makes the /// normative fallback observable rather than merely documented. pub const castShapeBounded = shapecast_mod.castShapeBounded; diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig index 8c770e35..f9c1534c 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig @@ -440,7 +440,7 @@ fn terminal( /// finite vector and underflows for a legitimately tiny one, and `foundation`'s /// `normalize` is unguarded by design. The reduction is a component-wise DIVISION, /// never a multiplication by `1 / scale`, whose reciprocal overflows for a denormal. -pub fn unitOf(comptime T: type, v: math.Vec(3, T)) ?math.Vec(3, T) { +fn unitOf(comptime T: type, v: math.Vec(3, T)) ?math.Vec(3, T) { const Simd = @Vector(3, T); const scale = @reduce(.Max, @abs(v.data)); if (scale == 0) return null; diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index e86a8be1..f3a0b284 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3790,3 +3790,116 @@ test "an ACTIVE EDGE at exact tangency blocks — the face normal is not enough" // pass `0 − 0.3`. The measured traversal put it at 0.672, three quarters of a metre past that. try testing.expect(p[0] <= -0.3 + 1e-3); } + +test "CONTRACT TABLE — castShapeBody answers the same for all three shape classes" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + + // **THIS TEST IS THE THING THAT WAS MISSING, and its absence is why the same defect arrived one + // cell at a time for four rounds.** `castShapeBody` has a contract per SHAPE CLASS and per call + // path, nobody had written it down, and each round found one cell of it. Measured across the three + // classes, four cells diverged — all of them about the DIRECTION: `plane.zig` asserted the vector + // was unit and conditioned nothing, so a non-unit, denormal or zero direction was a domain + // violation there and an ordinary answer on the other two arms; and the mesh arm lost a denormal + // in its swept traversal. Conditioning once at the entry took all four together. + // + // The rule this pins is not any single value: it is that the THREE ROWS AGREE. A future arm, or a + // future kernel that decides to condition differently, breaks this test rather than a character + // scene three milestones later. + const tiny = std.math.floatTrueMin(Real); + const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; + + // Three geometrically EQUIVALENT scenes: a wall whose surface is the plane `x = 2`, facing `−X`. + // Their agreement on the nominal cast is asserted first, so every later disagreement is about the + // property under test and not about the scenes having drifted apart. + const wall_verts = [_]V{ + .{ .data = .{ 2, -2, -2 } }, .{ .data = .{ 2, -2, 2 } }, .{ .data = .{ 2, 2, -2 } }, .{ .data = .{ 2, 2, 2 } }, + }; + const wall_tris = [_]u32{ 0, 1, 2, 2, 1, 3 }; + const walls = [_]api.BodyId{ + try bm.addBody(gpa, &store, .{ .entity = ent(700), .body_type = .static, .position = av(2.5, 0, 0), .shape = try store.createShape(gpa, .{ .box = .{ .half_extents = av(0.5, 2, 2) } }) }), + try bm.addBody(gpa, &store, .{ .entity = ent(701), .body_type = .static, .shape = try store.createShape(gpa, .{ .plane = .{ .normal = av(-1, 0, 0), .distance = -2 } }) }), + try bm.addBody(gpa, &store, .{ .entity = ent(702), .body_type = .static, .shape = try store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &wall_verts, .indices = &wall_tris } }) }), + }; + // Three equivalent FLOORS: surface `y = 0`, facing `+Y` — a surface that cannot oppose a `+X` sweep. + const floor_verts = [_]V{ + .{ .data = .{ -3, 0, -3 } }, .{ .data = .{ 3, 0, -3 } }, .{ .data = .{ -3, 0, 3 } }, .{ .data = .{ 3, 0, 3 } }, + }; + const floor_tris = [_]u32{ 0, 2, 1, 1, 2, 3 }; + const floors = [_]api.BodyId{ + try bm.addBody(gpa, &store, .{ .entity = ent(710), .body_type = .static, .position = av(0, -0.5, 0), .shape = try store.createShape(gpa, .{ .box = .{ .half_extents = av(3, 0.5, 3) } }) }), + try bm.addBody(gpa, &store, .{ .entity = ent(711), .body_type = .static, .shape = try store.createShape(gpa, .{ .plane = .{ .normal = av(0, 1, 0), .distance = 0 } }) }), + try bm.addBody(gpa, &store, .{ .entity = ent(712), .body_type = .static, .shape = try store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &floor_verts, .indices = &floor_tris } }) }), + }; + + // A SPHERE probe, because C3 rotates the probe and a capsule would change SHAPE when rotated — + // which would confound the frame question with a geometry change. + const probe = SupportShapeR{ .core = .{ .point = {} }, .radius = 0.3 }; + const origin = v(-2, 0, 0); + const yawed = Quatr.fromAxisAngle(Vec3r.unit_y, std.math.pi / 2.0); + // Wall face at `x = 2`, probe radius `0.3`, origin at `x = −2`. + const nominal: Real = 3.7; + + for (walls, floors, 0..) |wall, floor, row| { + errdefer std.debug.print("shape class row {d}\n", .{row}); + + // C0 — the scenes are equivalent, asserted rather than assumed. + const unit = bm.castShapeBody(&store, wall, probe, origin, Quatr.identity, v(1, 0, 0), 10, .ignore); + try testing.expect(unit != null); + try testing.expectApproxEqAbs(nominal, unit.?.distance, api_tol); + + // C1 — a NON-UNIT direction is accepted, and answers what the unit one answers. This asserted + // nothing on the half-space arm before the entry conditioned: it tripped an assert. + const long = bm.castShapeBody(&store, wall, probe, origin, Quatr.identity, v(2, 0, 0), 10, .ignore); + try testing.expect(long != null); + try testing.expectApproxEqAbs(nominal, long.?.distance, api_tol); + + // C1 — a DENORMAL direction likewise, which is `shapecast_test.zig`'s contract lifted to the + // body level. It asserted on the half-space arm and returned null on the mesh arm. + const den = bm.castShapeBody(&store, wall, probe, origin, Quatr.identity, v(tiny, 0, 0), 10, .ignore); + try testing.expect(den != null); + try testing.expectApproxEqAbs(nominal, den.?.distance, api_tol); + + // C2 — EXACT zero is the one degenerate direction, and it answers `null` rather than asserting. + try testing.expect(bm.castShapeBody(&store, wall, probe, origin, Quatr.identity, Vec3r.zero, 10, .ignore) == null); + + // C5 — `max_distance` and the returned distance are in units of the NORMALISED direction: a + // direction twice as long does not halve the answer. + try testing.expectApproxEqAbs(unit.?.distance, long.?.distance, api_tol); + + // C3 — the returned normal is in WORLD: rotating the PROBE does not rotate it. + const rot = bm.castShapeBody(&store, wall, probe, origin, yawed, v(1, 0, 0), 10, .ignore); + try testing.expect(rot != null); + for (0..3) |k| { + try testing.expectApproxEqAbs(unit.?.normal.toArray()[k], rot.?.normal.toArray()[k], api_tol); + } + + // C4 — at distance zero, with an OBLIQUE direction so `−direction` and the surface normal are + // distinguishable. Two claims, and only the second is a value. + const oblique = v(0.8, 0.6, 0); + const deep_plain = bm.castShapeBody(&store, wall, probe, v(1.95, 0, 0), Quatr.identity, oblique, 10, .ignore); + const deep_filt = bm.castShapeBodyOpposing(&store, wall, probe, v(1.95, 0, 0), Quatr.identity, oblique, 10, .ignore, true); + try testing.expect(deep_plain != null); + try testing.expectEqual(@as(Real, 0), deep_plain.?.distance); + // `hit.normal` at an initial overlap is NOT pinned to a value, and that is deliberate: §1.11.11 + // fixes `−direction` as the FALLBACK, and the arms differ in whether they have a terminal + // simplex to offer instead — measured, the half-space has none and answers `−direction` while + // the other two answer a separating direction. The guarantee the contract actually makes is + // this one, and all three keep it. + try testing.expect(deep_plain.?.normal.dot(oblique.normalizeScaled().?) <= 0); + // The manifold IS consulted under the filter, on all three arms, and it carries the SURFACE + // normal — which is the field the character path reads and the one that must agree. + try testing.expect(deep_plain.?.contact_normal == null); + try testing.expect(deep_filt != null); + try testing.expect(deep_filt.?.contact_normal != null); + for ([_]Real{ -1, 0, 0 }, 0..) |want, k| { + try testing.expectApproxEqAbs(want, deep_filt.?.contact_normal.?.toArray()[k], api_tol); + } + + // C6 — the opposing verdict: a FLOOR cannot oppose a `+X` sweep, on any of the three. + try testing.expect(bm.castShapeBodyOpposing(&store, floor, probe, v(0, 0.3, 0), Quatr.identity, v(1, 0, 0), 10, .ignore, true) == null); + } +} From 77e49c2bd881e0712404703face6379c421c125b Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 13:27:56 +0200 Subject: [PATCH 082/100] fix(forge): the cast adapter asserts its direction, callers condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pass had uniformised the three shape classes by the PERMISSIVE, making the adapter condition its own direction. The map of every production caller says nobody needs the permission: query/root.zig conditions once for the whole query family and guards zero there, calling a zero direction a legal query with an empty answer; the ground probe and the step sweeps pass the exact constants up and up.neg(); the slide normalises its own remainder. So it uniformises by the STRICT instead. castShapeBody is an INTERNAL adapter of BodyManager, not a public entry: requiring a unit direction of it is legitimate where requiring it of query.shapeCast would not be, and the public contract does not move. And conditioning at the entry was not free of the defect it closed. The query entry already normalises, so an unconditional line was a SECOND conditioning of one vector; skipping it inside a tolerance left the two arms disagreeing INSIDE that tolerance, since plane.zig uses the raw norm in t = sep / -closing while shapecast.zig renormalises. Measured, a conditioned direction lands 0 to 1 ULP from unit, so that branch was taken on every query. An assert has no inside. The controller's remaining.scale(1 / distance) becomes normalizeScaled, the same shared form the query family uses. Stated plainly: nothing in the suite discriminates that line today — the denormal end is masked by the len_sq == 0 guard and the huge end aborts earlier on a separate, pre-existing defect where an infinite max_distance reaches shapecast.zig:193. It is an alignment guarded by the new assert, not a fix with a pin. Two table cells therefore move from served to precondition, and the property is pinned one tier up where it IS a contract: a second parameterised test asserts that query.shapeCast serves a non-unit and a denormal direction and answers null on zero, on all three classes. That table pins a long-standing contract and does not discriminate this change; the assert, running on every call in Debug and ReleaseSafe across the suite, is what pins the change. P3: three comments contradicted the code and are replaced rather than amended — the opposes doc still described the entry conditioning, and C4 claimed the manifold is consulted on all three arms where the half-space transports its stored plane normal and consults nothing. 491/491 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/modules/forge/forge_3d/body_manager.zig | 101 +++++----- src/modules/forge/forge_3d/character.zig | 8 +- .../forge/forge_3d/tests/character_test.zig | 175 +++++++++--------- 3 files changed, 141 insertions(+), 143 deletions(-) diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index 3c2dab87..a9d845bd 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -793,37 +793,31 @@ pub const BodyManager = struct { self.bodies.items(.position)[idx], self.bodies.items(.rotation)[idx], ); - // **THE DIRECTION IS CONDITIONED ONCE, HERE, BEFORE THE DISPATCH — and that single line is a - // contract table collapsing.** Measured across the three shape classes, this entry had FOUR - // divergent behaviours for one parameter: `plane.zig` ASSERTS the direction is unit and - // conditions nothing, so a non-unit, a denormal or a zero direction was a domain violation on - // that arm and an ordinary answer on the other two; `shapecast.zig` conditions through - // `unitOf`; and the mesh arm additionally lost a denormal in its swept traversal. Every one of - // those is the same defect: the callee was left to decide what direction it had been handed. + // **THE DIRECTION IS A PRECONDITION OF THIS ADAPTER, ASSERTED AND NOT RE-ESTABLISHED.** // - // Conditioned at the entry, all three arms receive a unit direction and the assert becomes - // satisfied BY CONSTRUCTION rather than a precondition charged to the caller — which is what - // makes it an invariant instead of a trap. A zero direction is the one degenerate case and it - // answers `null` uniformly, since `unitOf` is null at EXACT zero and nowhere else. + // Measured across the three shape classes, this entry had FOUR divergent behaviours for one + // parameter: `plane.zig` asserts the vector is unit and conditions nothing, so a non-unit, + // denormal or zero direction was a domain violation on that arm and an ordinary answer on the + // other two, and the mesh arm additionally lost a denormal in its swept traversal. A first + // remedy conditioned here, which uniformised by the PERMISSIVE — and the map then showed that + // nobody needs the permission: `query/root.zig` conditions once for the whole query family + // (and guards zero there, where a zero direction is a legal query with an empty answer), the + // ground probe and the step sweeps pass the exact constants `up` and `up.neg()`, and the slide + // normalises its own remainder. Not one production caller passes a non-unit direction. // - // `max_distance` is therefore in units of the NORMALISED direction on every arm, which is what - // the two conditioning arms already did and what the asserting one had no way to say. + // So it uniformises by the STRICT instead. `castShapeBody` is an INTERNAL adapter of + // `BodyManager`, not a public entry: requiring a unit direction of it is legitimate where + // requiring it of `query.shapeCast` would not be, and the public contract is unchanged. // - // **AND IT CONDITIONS ONLY WHAT IS NOT ALREADY CONDITIONED, which is not an optimisation.** A - // first form normalised unconditionally and shifted every query answer by an ULP, caught by - // `mesh_test`'s bit-exact brute-force oracle: the query entry ALREADY normalises - // (`query/root.zig`), so an unconditional line here is a SECOND conditioning of the same vector - // — the very class this entry is collapsing, re-introduced by the collapse. The reduce-then- - // normalise round trip is not bit-idempotent, so applying it twice moves the result. - // - // The gate is the module's existing unit predicate, the same `16 · floatEps` slack - // `plane.zig` and `shapecast.zig` assert with, and no answer depends on which branch is taken: - // both produce a unit direction, one by passing it through untouched. - const dir = if (@abs(direction.lengthSq() - 1) <= 16 * std.math.floatEps(Real)) - direction - else - direction.normalizeScaled() orelse return null; - const local_dir = cast_rotation.conjugate().rotateVec3(dir); + // Conditioning here was also not free of the very defect it was closing: the query entry + // already normalises, so an unconditional line was a SECOND conditioning of one vector, and + // skipping it inside a tolerance left the two arms disagreeing INSIDE that tolerance — + // `plane.zig` uses the raw norm in `t = sep / −closing` while `shapecast.zig` renormalises, + // so a direction 1 ULP off unit moved one arm's distance and not the other's. Measured: a + // conditioned direction lands 0 to 1 ULP from unit, so that branch was taken on every query. + // An assert has no inside. + std.debug.assert(@abs(direction.lengthSq() - 1) <= direction_unit_k * std.math.floatEps(Real)); + const local_dir = cast_rotation.conjugate().rotateVec3(direction); // The CAST shape is always a bounded convex — the query entry refuses an // unbounded probe with a typed error (§1.11.7) — so only the HIT body's // category is dispatched on. Exhaustive, no `else`. @@ -888,7 +882,7 @@ pub const BodyManager = struct { inv_rot.rotateVec3(cast_origin.sub(self.bodies.items(.position)[idx])), inv_rot.mul(cast_rotation), ); - const sweep_dir = inv_rot.rotateVec3(dir); + const sweep_dir = inv_rot.rotateVec3(direction); var collector = MeshCastCollector{ .data = data, .cast_shape = cast_shape, @@ -928,7 +922,7 @@ pub const BodyManager = struct { // hit of the cast/manifold disagreement. Nothing real to oppose the motion. break :blk (probe_manifold.normal orelse return null).neg(); }; - if (!opposes(opposing_normal, if (hit.distance > 0) local_dir else dir)) return null; + if (!opposes(opposing_normal, if (hit.distance > 0) local_dir else direction)) return null; // Already WORLD on this arm: `collideShapeBody` is a world-space call, which is why the // predicate above dots it with `direction` and not with `local_dir`. if (hit.distance <= 0) contact_normal = opposing_normal; @@ -1697,33 +1691,36 @@ const SingleManifoldCollector = struct { /// whether a path exists that does not DEGRADE an axis-aligned quad's face normal under a yaw, rather /// than tolerating the degradation after the fact. /// -/// **`d` IS CONDITIONED BY THE KERNEL'S OWN `unitOf`, and that is the point rather than a detail.** -/// Two earlier forms of this predicate were wrong about the direction and each in a different way: an -/// ABSOLUTE threshold made the verdict depend on `‖d‖`, so the same geometry changed sides when the -/// direction arrived twice as long; and a bare sign test on the raw `d` UNDERFLOWS, because for a -/// denormal direction a product `n_i · d_i` with a small `n_i` flushes to exactly zero and the sign is -/// destroyed — which would read a real wall as non-opposing and discard it, contradicting the contract -/// `shapecast_test.zig` already pins, that every non-zero direction including a denormal is served. +/// **`d` IS UNIT BY PRECONDITION, and that is the point rather than a detail.** Three forms of this +/// predicate were wrong about the direction, each differently: an ABSOLUTE threshold made the verdict +/// depend on `‖d‖`, so the same geometry changed sides when the direction arrived twice as long; a +/// bare sign test on a raw `d` UNDERFLOWS, since for a denormal direction a product `n_i · d_i` with +/// `|n_i| < 0.5` flushes to exactly zero and the sign is destroyed; and conditioning the vector HERE +/// made this the second place that conditions it, which moved every query answer by an ULP. /// -/// Both mistakes have one root: this predicate was conditioning the direction ITSELF instead of taking -/// the conditioning the kernel already performs. `unitOf` reduces by the largest absolute component -/// before normalising, so it neither squares a denormal into zero nor overflows a huge one, and it -/// returns `null` at EXACT zero and nowhere else. Sharing it means there are no longer two answers to -/// the question of what direction this cast is travelling in. -/// -/// A null direction opposes nothing, which is the same answer the sign test gave and for a better -/// stated reason: there is no direction to oppose. +/// All three have one root — the predicate deciding for itself what direction it had been handed — +/// and the fix is that nobody downstream decides. `query/root.zig` conditions once for the whole +/// query family and guards zero there; the character's own sweeps pass the exact constants `up` and +/// `up.neg()` or normalise their remainder with the same shared form. Every consumer then ASSERTS, +/// here and at the adapter's head, with `plane.zig` and `shapecast.zig` asserting the identical claim +/// one tier down. An assert has no inside for two arms to disagree in. fn opposes(n: Vec3r, d: Vec3r) bool { - // `d` is UNIT by the entry's conditioning, not by hope: `castShapeBodyOpposing` runs `unitOf` - // once before the dispatch, and every direction reaching here is that vector or a rotation of it, - // which preserves the norm to a few ULP. Asserted rather than re-established, with the same - // `16 · floatEps` slack `plane.zig` and `shapecast.zig` use for the identical claim — because - // re-normalising here would be a SECOND answer to what direction the cast travels in, which is - // the very class this entry has just finished collapsing. - std.debug.assert(@abs(d.lengthSq() - 1) <= 16 * std.math.floatEps(Real)); + // `d` is UNIT because the CALLER guarantees it — `castShapeBodyOpposing` asserts the same thing at + // its head, and every direction reaching here is that vector or a rotation of it, which preserves + // the norm to a few ULP. Asserted rather than re-established: a sign test on a non-unit `d` was + // wrong twice, once through a threshold that scaled with `‖d‖` and once through an underflow that + // destroyed the sign, and BOTH disappear when the vector is unit. Re-normalising here would be a + // second answer to what direction the cast travels in, which is the class this entry collapsed. + std.debug.assert(@abs(d.lengthSq() - 1) <= direction_unit_k * std.math.floatEps(Real)); return n.dot(d) < 0; } +/// Slack, in ULPs of 1, on the unit-norm precondition the cast adapter and its opposing predicate +/// assert. The same constant and the same role as `plane.zig`'s `unit_k` and `shapecast.zig`'s +/// `unit_dir_k`, which assert the identical claim one tier down — named here so the three cannot +/// drift into three different ideas of what "unit" means. +const direction_unit_k: comptime_int = 16; + /// Slack, in ULPs of 1, on the test that a contact normal ALREADY IS the face normal — and on /// the tie band that decides which edges a contact could have come from. Both compare /// quantities of order 1 (a dot product of two unit vectors) or a length against the triangle's diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 3f6ee5e8..f0cb43a2 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -1549,7 +1549,13 @@ pub const CharacterStore = struct { // 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); + // **`normalizeScaled` and not `scale(1 / distance)`, which is the SAME formula the query + // family conditions with.** The division form fails at both ends of the range where the + // reduce-then-normalise form does not — a denormal remainder squares to zero and a huge one + // overflows — and the cast adapter now ASSERTS its direction is unit rather than + // re-establishing it, so producing one correctly is this caller's job. `len_sq == 0` is + // already excluded above, at true zero, so the optional cannot be empty here. + const direction = remaining.normalizeScaled().?; const maybe_hit = sweepNearest( bp, diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index f3a0b284..71b258c8 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3683,72 +3683,6 @@ test "a ROTATED mesh: the opposing filter must not mix frames" { try testing.expectApproxEqAbs(plain.?.distance, filtered.?.distance, api_tol); } -test "the opposing filter serves a DENORMAL direction where the cast does" { - const gpa = testing.allocator; - var store: ShapeStore = .{}; - defer store.deinit(gpa); - var bm: BodyManager = .{}; - defer bm.deinit(gpa); - - // **THE CONTRACT IS THE KERNEL'S AND THE FILTER OWED IT TOO.** `shapecast_test.zig` pins that every - // non-zero direction is served, a denormal included, because `unitOf` reduces by the largest - // absolute component instead of squaring. The opposing filter reproduced the direction handling - // instead of sharing it and got it wrong twice: an ABSOLUTE threshold made the verdict depend on - // `‖d‖`, so the same geometry changed sides when the direction arrived twice as long; then a bare - // sign test UNDERFLOWED — for a denormal `d` a product `n_i · d_i` with a small `n_i` flushes to - // exactly zero, the sign is destroyed, and a real wall reads non-opposing and is discarded. - // - // **The test lives at the BODY level and not at the kernel's, which is the gap that let both forms - // through.** The kernel's own denormal test passed the whole time: it never goes near this - // predicate. A contract tested one tier below the code that breaks it is not a test of that code. - // - // **Scope, and it is MEASURED rather than assumed.** The claim is the FILTER's: it must answer what - // the unfiltered cast answers. The convex arm is where that has content — the plain cast serves a - // denormal there. The other two arms were tried and excluded on evidence, not convenience: - // * TRIANGLE SOUP — the plain cast returns `null` for a denormal too (`plainDen=false`, - // `filtDen=false`), so the two AGREE and the filter is not implicated. The direction is lost - // upstream of it, in the swept traversal rather than in the kernel. Recorded, not fixed here. - // * HALF-SPACE — `plane.zig:297` ASSERTS `|‖d‖² − 1| <= unit_k · floatEps` and conditions - // nothing, where `shapecast.zig:199` conditions through `unitOf`. Two kernels, one parameter, - // two contracts; the entry normalises once (`query/root.zig:703`) and the half-space arm relies - // on it. A denormal is out of its DOMAIN, and the assert fires as designed. - const tiny = std.math.floatTrueMin(Real); - // Non-vacuity: the square really does underflow, so this is the hard case and not merely a small one. - try testing.expectEqual(@as(Real, 0), tiny * tiny); - - // **THE WALL IS TILTED, AND AN AXIS-ALIGNED ONE PROVES NOTHING — measured, and the first version of - // this test made exactly that mistake.** Against a wall whose normal is `(−1, 0, 0)` the product - // `n_x · d_x` is `−1 · tiny`, which is representable, so the sign survives and the broken predicate - // passes. The product underflows only when `|n_x| < 0.5`, since `0.5 · floatTrueMin` is the - // rounding boundary: computed, `cos 45°` survives and `cos 60°` and beyond flush to exactly zero. - // The tilt is chosen by MEASUREMENT and not by trigonometry on the box: what reaches the predicate - // is the CONTACT normal, which is not the face normal one draws on paper. Swept over ten yaws, the - // raw product is `−1e-45` and survives at 20°–50° and again at 70°–85°, and is exactly `0e0` at 60° - // and 65°, where the contact normal's X component reads `−0.4999997` and `−0.4226`. 65° is taken, - // and the axis-aligned version this replaces was caught by the mutation probe passing — twice. - const probe = SupportShapeR{ .core = .{ .segment = 0.6 }, .radius = 0.3 }; - const body = try bm.addBody(gpa, &store, .{ - .entity = ent(890), - .body_type = .static, - .shape = try store.createShape(gpa, .{ .box = .{ .half_extents = av(0.5, 2, 2) } }), - .position = av(2.5, 0, 0), - .rotation = math.Quatf.fromAxisAngle(av(0, 1, 0), 65.0 * std.math.pi / 180.0), - }); - - const plain = bm.castShapeBody(&store, body, probe, v(-2, 0, 0), Quatr.identity, v(tiny, 0, 0), 10, .ignore); - const filtered = bm.castShapeBodyOpposing(&store, body, probe, v(-2, 0, 0), Quatr.identity, v(tiny, 0, 0), 10, .ignore, true); - const unit = bm.castShapeBodyOpposing(&store, body, probe, v(-2, 0, 0), Quatr.identity, v(1, 0, 0), 10, .ignore, true); - - // The premise: the unfiltered cast DOES serve it, so the filter has something to be wrong about. - try testing.expect(plain != null); - // The claim: the wall opposes the sweep either way, the direction's MAGNITUDE being no geometric - // fact — so the filter answers what the cast answers, and at the unit direction's distance. - try testing.expect(filtered != null); - try testing.expect(unit != null); - try testing.expectApproxEqAbs(plain.?.distance, filtered.?.distance, api_tol); - try testing.expectApproxEqAbs(unit.?.distance, filtered.?.distance, api_tol); -} - test "an ACTIVE EDGE at exact tangency blocks — the face normal is not enough" { const gpa = testing.allocator; var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); @@ -3809,7 +3743,6 @@ test "CONTRACT TABLE — castShapeBody answers the same for all three shape clas // The rule this pins is not any single value: it is that the THREE ROWS AGREE. A future arm, or a // future kernel that decides to condition differently, breaks this test rather than a character // scene three milestones later. - const tiny = std.math.floatTrueMin(Real); const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; // Three geometrically EQUIVALENT scenes: a wall whose surface is the plane `x = 2`, facing `−X`. @@ -3819,11 +3752,16 @@ test "CONTRACT TABLE — castShapeBody answers the same for all three shape clas .{ .data = .{ 2, -2, -2 } }, .{ .data = .{ 2, -2, 2 } }, .{ .data = .{ 2, 2, -2 } }, .{ .data = .{ 2, 2, 2 } }, }; const wall_tris = [_]u32{ 0, 1, 2, 2, 1, 3 }; - const walls = [_]api.BodyId{ - try bm.addBody(gpa, &store, .{ .entity = ent(700), .body_type = .static, .position = av(2.5, 0, 0), .shape = try store.createShape(gpa, .{ .box = .{ .half_extents = av(0.5, 2, 2) } }) }), - try bm.addBody(gpa, &store, .{ .entity = ent(701), .body_type = .static, .shape = try store.createShape(gpa, .{ .plane = .{ .normal = av(-1, 0, 0), .distance = -2 } }) }), - try bm.addBody(gpa, &store, .{ .entity = ent(702), .body_type = .static, .shape = try store.createShape(gpa, .{ .triangle_mesh = .{ .vertices = &wall_verts, .indices = &wall_tris } }) }), + const wall_shapes = [_]api.ShapeDescriptor{ + .{ .box = .{ .half_extents = av(0.5, 2, 2) } }, + .{ .plane = .{ .normal = av(-1, 0, 0), .distance = -2 } }, + .{ .triangle_mesh = .{ .vertices = &wall_verts, .indices = &wall_tris } }, }; + const wall_positions = [_]ApiVec3{ av(2.5, 0, 0), av(0, 0, 0), av(0, 0, 0) }; + var walls: [3]api.BodyId = undefined; + for (wall_shapes, wall_positions, 0..) |desc, pos, k| { + walls[k] = try bm.addBody(gpa, &store, .{ .entity = ent(@intCast(700 + k)), .body_type = .static, .position = pos, .shape = try store.createShape(gpa, desc) }); + } // Three equivalent FLOORS: surface `y = 0`, facing `+Y` — a surface that cannot oppose a `+X` sweep. const floor_verts = [_]V{ .{ .data = .{ -3, 0, -3 } }, .{ .data = .{ 3, 0, -3 } }, .{ .data = .{ -3, 0, 3 } }, .{ .data = .{ 3, 0, 3 } }, @@ -3851,24 +3789,21 @@ test "CONTRACT TABLE — castShapeBody answers the same for all three shape clas try testing.expect(unit != null); try testing.expectApproxEqAbs(nominal, unit.?.distance, api_tol); - // C1 — a NON-UNIT direction is accepted, and answers what the unit one answers. This asserted - // nothing on the half-space arm before the entry conditioned: it tripped an assert. - const long = bm.castShapeBody(&store, wall, probe, origin, Quatr.identity, v(2, 0, 0), 10, .ignore); - try testing.expect(long != null); - try testing.expectApproxEqAbs(nominal, long.?.distance, api_tol); - - // C1 — a DENORMAL direction likewise, which is `shapecast_test.zig`'s contract lifted to the - // body level. It asserted on the half-space arm and returned null on the mesh arm. - const den = bm.castShapeBody(&store, wall, probe, origin, Quatr.identity, v(tiny, 0, 0), 10, .ignore); - try testing.expect(den != null); - try testing.expectApproxEqAbs(nominal, den.?.distance, api_tol); - - // C2 — EXACT zero is the one degenerate direction, and it answers `null` rather than asserting. - try testing.expect(bm.castShapeBody(&store, wall, probe, origin, Quatr.identity, Vec3r.zero, 10, .ignore) == null); + // **C1 AND C2 ARE A PRECONDITION HERE, NOT AN ANSWER — and that is the pass's conclusion, not + // a retreat from it.** A first remedy made this adapter condition its own direction, which + // uniformised the three arms by the PERMISSIVE; the map of every production caller then showed + // that nobody needs the permission, so it uniformises by the STRICT instead and the vector is + // required to be unit. `castShapeBody` is an INTERNAL adapter of `BodyManager`: requiring that + // of it is legitimate where requiring it of `query.shapeCast` would not be. + // + // So a non-unit, denormal or zero direction is a CALLER FAULT here and trips an assert, which + // no test can call without aborting the process — and the property is pinned where it is a + // contract instead, one tier up, below. That is a stronger pin and a cheaper one: it exercises + // what a user of the engine can actually reach. - // C5 — `max_distance` and the returned distance are in units of the NORMALISED direction: a - // direction twice as long does not halve the answer. - try testing.expectApproxEqAbs(unit.?.distance, long.?.distance, api_tol); + // C5 — the returned distance is in units of the direction, which the precondition makes unit, + // so `max_distance` and the distance share one scale on every arm. Pinned through the nominal + // cast above and the public tier below rather than by handing this entry a longer vector. // C3 — the returned normal is in WORLD: rotating the PROBE does not rotate it. const rot = bm.castShapeBody(&store, wall, probe, origin, yawed, v(1, 0, 0), 10, .ignore); @@ -3890,8 +3825,11 @@ test "CONTRACT TABLE — castShapeBody answers the same for all three shape clas // the other two answer a separating direction. The guarantee the contract actually makes is // this one, and all three keep it. try testing.expect(deep_plain.?.normal.dot(oblique.normalizeScaled().?) <= 0); - // The manifold IS consulted under the filter, on all three arms, and it carries the SURFACE - // normal — which is the field the character path reads and the one that must agree. + // Under the filter, all three arms fill `contact_normal` with the SURFACE normal — the field + // the character path reads and the one that must agree. **They do not all reach it the same + // way, and saying they do would be false:** the convex and mesh arms consult a MANIFOLD, the + // half-space transports its STORED plane normal and consults nothing. What the table pins is + // the value, which is what the caller depends on, not the route. try testing.expect(deep_plain.?.contact_normal == null); try testing.expect(deep_filt != null); try testing.expect(deep_filt.?.contact_normal != null); @@ -3903,3 +3841,60 @@ test "CONTRACT TABLE — castShapeBody answers the same for all three shape clas try testing.expect(bm.castShapeBodyOpposing(&store, floor, probe, v(0, 0.3, 0), Quatr.identity, v(1, 0, 0), 10, .ignore, true) == null); } } + +test "CONTRACT TABLE — the public cast conditions the direction, for all three classes" { + const gpa = testing.allocator; + // **THE OTHER HALF OF THE TABLE, at the tier where the direction is a CONTRACT and not a + // precondition.** `query.shapeCast` normalises once at its entry and guards zero there, calling a + // zero direction a legal query with an empty answer — so what the internal adapter is entitled to + // require, the public entry is required to provide. These are the two cells that moved when the + // pass uniformised by the strict, and they are asserted on the same three shape classes so the + // agreement is the table's and not one arm's. + const tiny = std.math.floatTrueMin(Real); + const V = @typeInfo(@FieldType(@FieldType(api.ShapeDescriptor, "triangle_mesh"), "vertices")).pointer.child; + const wall_verts = [_]V{ + .{ .data = .{ 2, -2, -2 } }, .{ .data = .{ 2, -2, 2 } }, .{ .data = .{ 2, 2, -2 } }, .{ .data = .{ 2, 2, 2 } }, + }; + const wall_tris = [_]u32{ 0, 1, 2, 2, 1, 3 }; + const shapes = [_]api.ShapeDescriptor{ + .{ .box = .{ .half_extents = av(0.5, 2, 2) } }, + .{ .plane = .{ .normal = av(-1, 0, 0), .distance = -2 } }, + .{ .triangle_mesh = .{ .vertices = &wall_verts, .indices = &wall_tris } }, + }; + const positions = [_]ApiVec3{ av(2.5, 0, 0), av(0, 0, 0), av(0, 0, 0) }; + + for (shapes, positions, 0..) |desc, pos, row| { + errdefer std.debug.print("shape class row {d}\n", .{row}); + var scene = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); + defer scene.deinit(gpa); + // The probe lives in the SCENE's store: a handle is an index into one store, and creating it + // in another resolved to whatever shape sat at that index here. + const probe_shape = try scene.store.createShape(gpa, .{ .sphere = .{ .radius = 0.3 } }); + const shape = try scene.store.createShape(gpa, desc); + _ = try scene.addBody(gpa, .{ .entity = ent(@intCast(720 + row)), .body_type = .static, .position = pos, .shape = shape }); + + const base: query.CastQuery = .{ .shape = probe_shape, .origin = v(-2, 0, 0), .direction = v(1, 0, 0), .max_distance = 10 }; + const unit = try query.shapeCast(&scene.bp, &scene.bm, &scene.store, base); + try testing.expect(unit != null); + + // A NON-UNIT direction is conditioned, so it answers what the unit one answers rather than + // rescaling the distance — the property `max_distance` needs to mean a length. + var q = base; + q.direction = v(2, 0, 0); + const long = try query.shapeCast(&scene.bp, &scene.bm, &scene.store, q); + try testing.expect(long != null); + try testing.expectApproxEqAbs(unit.?.distance, long.?.distance, api_tol); + + // A DENORMAL direction likewise: the reduce-then-normalise form serves it where a division by + // the length would square it to zero. This is `shapecast_test.zig`'s kernel contract, held at + // the public entry and on all three classes. + q.direction = v(tiny, 0, 0); + const den = try query.shapeCast(&scene.bp, &scene.bm, &scene.store, q); + try testing.expect(den != null); + try testing.expectApproxEqAbs(unit.?.distance, den.?.distance, api_tol); + + // EXACT zero is the one degenerate direction and it is an EMPTY ANSWER, not a fault. + q.direction = Vec3r.zero; + try testing.expect(try query.shapeCast(&scene.bp, &scene.bm, &scene.store, q) == null); + } +} From ed3a8cfb96f0b86f2df9891fcdb63176b0ec86f7 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 13:54:13 +0200 Subject: [PATCH 083/100] fix(forge): one reduction answers emptiness, direction and distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slide asked three separate questions of the same vector and got three different domains. lengthSq() == 0 for emptiness UNDERFLOWS, so a denormal remainder read as nothing and the displacement was silently dropped; sqrt(lengthSq()) for the distance OVERFLOWS, so an infinite max_distance reached the kernel and tripped its finiteness assert. The reduction by the largest absolute component that normalizeScaled already performs internally has neither failure, so Vec gains unitAndLength, which returns both quantities from that one reduction and is null at exact zero — the emptiness test the caller needs, without a threshold that could disagree with the direction of the same call. normalizeScaled delegates to it, same operations in the same order. A SECOND site of the same class in the same function: the step attempt derived its own sqrt(remaining.lengthSq()), and that is the one the huge case actually aborted on. Both now take the same reduction. Pinned at both ends of the range, and the test aborts against 77e49c2 on shapecast.zig:193, which is the infinite max_distance the second site produced. 492/492 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/foundation/math/vec.zig | 20 ++++++++- src/modules/forge/forge_3d/character.zig | 35 +++++++++------- .../forge/forge_3d/tests/character_test.zig | 41 +++++++++++++++++++ 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/src/foundation/math/vec.zig b/src/foundation/math/vec.zig index ce608dad..d759b19d 100644 --- a/src/foundation/math/vec.zig +++ b/src/foundation/math/vec.zig @@ -125,10 +125,28 @@ pub fn Vec(comptime N: usize, comptime T: type) type { /// `surfaceArea`, `rayInterval`, `inflate` and `overlapsHalfSpace` — pure vector /// arithmetic, no threshold, no physical semantics. pub fn normalizeScaled(self: Self) ?Self { + const both = self.unitAndLength() orelse return null; + return both.unit; + } + + /// The unit direction AND the length, from ONE reduction by the largest absolute component. + /// + /// **Three questions, one reduction, and that is the whole point.** A caller that asks + /// `lengthSq() == 0` to test emptiness, `@sqrt(lengthSq())` for the length and this for the + /// direction has asked three times and gets three different domains: the square UNDERFLOWS for + /// a denormal vector, so a real displacement reads as empty and is dropped, and it OVERFLOWS + /// for a large one, so the length comes back infinite and poisons whatever consumes it. The + /// reduction has neither failure, and returning both quantities from it is what stops a caller + /// from reconstructing one of them the unsafe way. + /// + /// `null` at EXACTLY zero — the largest absolute component is zero exactly when all three are — + /// which is the emptiness test the caller needs, exact and without a threshold. + pub fn unitAndLength(self: Self) ?struct { unit: Self, length: T } { const largest = @reduce(.Max, @abs(self.data)); if (largest == 0) return null; const reduced: Self = .{ .data = self.data / @as(Simd, @splat(largest)) }; - return reduced.scale(1 / reduced.length()); + const reduced_length = reduced.length(); + return .{ .unit = reduced.scale(1 / reduced_length), .length = largest * reduced_length }; } /// Largest absolute component. Zero exactly when every component is zero. diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index f0cb43a2..3d6a9f9c 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -1544,18 +1544,20 @@ pub const CharacterStore = struct { // data it judged. var iteration: u32 = 0; while (iteration < max_slide_iterations) : (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); - // **`normalizeScaled` and not `scale(1 / distance)`, which is the SAME formula the query - // family conditions with.** The division form fails at both ends of the range where the - // reduce-then-normalise form does not — a denormal remainder squares to zero and a huge one - // overflows — and the cast adapter now ASSERTS its direction is unit rather than - // re-establishing it, so producing one correctly is this caller's job. `len_sq == 0` is - // already excluded above, at true zero, so the optional cannot be empty here. - const direction = remaining.normalizeScaled().?; + // **THE EMPTINESS TEST, THE DIRECTION AND THE DISTANCE COME FROM ONE REDUCTION.** Asking + // three times gave three different domains: `lengthSq() == 0` UNDERFLOWS for a denormal + // remainder, so a real displacement read as nothing and was silently dropped; and + // `@sqrt(lengthSq())` OVERFLOWS for a large one, so an INFINITE distance went on to become + // the cast's `max_distance` and tripped the kernel's finiteness assert. Reducing by the + // largest absolute component has neither failure, and taking all three answers from it is + // what stops one of them being reconstructed the unsafe way. + // + // `null` is EXACT zero — a displacement of exactly nothing is done, and any representable + // non-zero displacement is a real request to be served. No epsilon, and no threshold that + // could disagree with the direction the same call is about to use. + const step = remaining.unitAndLength() orelse break; + const distance = step.length; + const direction = step.unit; const maybe_hit = sweepNearest( bp, @@ -1619,9 +1621,12 @@ pub const CharacterStore = struct { // — 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| { + // The SAME reduction as the loop head, and for the same reason: `@sqrt(lengthSq())` + // here overflowed to an infinite step distance, which reached the kernel's + // `max_distance` assert. A second site of one class, on the only other path that + // derives a length from the remainder. + if (remaining.unitAndLength()) |step_left| { + if (tryStepUp(bp, bm, store, record, probe, centre, direction, step_left.length, 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, diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 71b258c8..a511f545 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3898,3 +3898,44 @@ test "CONTRACT TABLE — the public cast conditions the direction, for all three try testing.expect(try query.shapeCast(&scene.bp, &scene.bm, &scene.store, q) == null); } } + +test "a DENORMAL and a HUGE displacement are both served, not dropped and not poisoned" { + const gpa = testing.allocator; + + // **THE TWO ENDS OF THE RANGE THAT THREE SEPARATE QUESTIONS LOST.** The slide asked + // `lengthSq() == 0` for emptiness and `@sqrt(lengthSq())` for the distance: the square underflows + // for a denormal remainder, so a real displacement read as nothing and the call served zero; and it + // overflows for a large one, so an INFINITE `max_distance` reached the kernel and tripped its + // finiteness assert. One reduction by the largest absolute component answers all three. + const tiny = std.math.floatTrueMin(Real); + try testing.expectEqual(@as(Real, 0), tiny * tiny); // the square really does underflow + const huge: Real = if (Real == f32) 1e30 else 1e200; + try testing.expect(!std.math.isFinite(huge * huge)); // and really does overflow + + for ([_]Real{ tiny, huge }) |magnitude| { + errdefer std.debug.print("magnitude {e}\n", .{magnitude}); + 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, 960); + // A wall at x = 2, so a huge request is BOUNDED by geometry rather than by arithmetic. + _ = try addBox(gpa, &world, av(0.5, 2, 2), av(2.5, 0, 0), 961); + var desc = baseDescriptor(); + desc.entity = ent(962); + 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(magnitude, 0, 0), 1.0 / 60.0); + const x = r.position.toArray()[0]; + try testing.expect(std.math.isFinite(x)); + if (magnitude == huge) { + // Served up to the wall and no further: the request is bounded by the geometry. + try testing.expectApproxEqAbs(@as(Real, 1.68), x, 1e-2); + } else { + // A denormal is a real request. It cannot move the pose measurably, but it must not be + // read as EMPTY — which is what the underflowing test did, and what dropped it. + try testing.expect(x >= 0); + } + } +} From 01162c3e934f77c31e387a440e480a1060353a25 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 14:13:25 +0200 Subject: [PATCH 084/100] fix(forge): the two cast arms consume the same direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An assert BOUNDS a direction, it does not canonicalise it. (1 + 4eps, 0, 0) passes every unit assert in the module, and inside that band the two arms of castShapeBody consumed different vectors: the convex kernel reconditioned its copy through unitOf while plane.zig used the raw norm in t = sep / -closing. Measured against ed3a8cf, one query answered 3.7 on one arm and 3.6999984 on the other, at BOTH precisions. Two forms could close it and the choice was MEASURED against the bit-exact oracles, which is the only criterion that has decided correctly on this column. Having the half-space condition like the convex breaks TWO plane tests, and they are a CONTRACT: normal.eql(d.neg()) at an initial overlap is 1.11.11's identity across the four kernels, and conditioning inside the plane kernel returns -normalizeScaled(d) instead of -d. Having the kernel not recondition breaks ONE test, and it is an ORACLE ARTEFACT. So: form one. castShapeUnit is ADDITIVE. castShape and castShapeBounded keep conditioning, keep their zero-direction guard and keep their signatures, so no existing caller moves; the shared body becomes a private impl taking a comptime flag. Only castShapeBody takes the new entry, its direction being a precondition it asserts — and that assert stays, no longer a band masking a divergence but the precondition of an entry that reconditions nothing behind it. The mesh_test line is an ORACLE CORRECTION and it is written at the site: the test measures that the accelerated path agrees with brute force, and two sides calling different kernel entries compare two things instead of one. Aligned, it measures the acceleration again. Codex's reproducer is pinned, bit-exactly and not approximately — the defect is one ULP and any comfortable tolerance would hide it — with a bound placed strictly between the two answers a divergent pair gives, where one ULP separates hit from miss. It fails against ed3a8cf at both precisions. 493/493 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/modules/forge/forge_3d/body_manager.zig | 9 +++- .../forge_3d/pipeline/narrowphase/root.zig | 4 ++ .../pipeline/narrowphase/shapecast.zig | 48 +++++++++++++++++-- .../forge/forge_3d/tests/character_test.zig | 46 ++++++++++++++++++ .../forge/forge_3d/tests/mesh_test.zig | 9 +++- 5 files changed, 110 insertions(+), 6 deletions(-) diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index a9d845bd..32979c23 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -848,7 +848,11 @@ pub const BodyManager = struct { if (!opposes(hs.?.normal, local_dir)) return null; } const hit = switch (shape.class()) { - .convex => narrowphase.castShape( + // `castShapeUnit` and not `castShape`: the direction is this entry's PRECONDITION, and a + // kernel that reconditioned it would consume different bits from `plane.castShape`, which + // takes it as given. That difference is what an assert cannot close — it bounds the + // vector, it does not canonicalise it. + .convex => narrowphase.castShapeUnit( Real, cast_shape, relpose, @@ -1949,7 +1953,8 @@ const MeshCastCollector = struct { pub fn add(self: *MeshCastCollector, triangle_index: u32) void { const face = self.data.faceNormal(triangle_index); if (self.back_face_mode == .ignore and narrowphase.triangle.isBackFace(Real, face, self.sweep_direction_local)) return; - const hit = narrowphase.castShape( + // Unit by the adapter's precondition, like the convex arm and for the same reason. + const hit = narrowphase.castShapeUnit( Real, self.cast_shape, self.relpose, diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig index 81f73149..ba85acf5 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/root.zig @@ -146,6 +146,10 @@ pub const max_shapecast_iterations = shapecast_mod.max_shapecast_iterations; /// Cast a shape along a direction against another; `null` on a miss. No error /// channel: a support map covers every bounded convex, so nothing is rejected. pub const castShape = shapecast_mod.castShape; + +/// `castShape` for a caller that GUARANTEES a unit direction — it does not recondition, so it and +/// `plane.castShape` consume the same bits. See its declaration for why an assert could not do this. +pub const castShapeUnit = shapecast_mod.castShapeUnit; /// `castShape` with the ceiling and the diagnostics exposed — the seam that makes the /// normative fallback observable rather than merely documented. pub const castShapeBounded = shapecast_mod.castShapeBounded; diff --git a/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig b/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig index f9c1534c..5c29a06a 100644 --- a/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig +++ b/src/modules/forge/forge_3d/pipeline/narrowphase/shapecast.zig @@ -181,6 +181,47 @@ pub fn castShapeBounded( max_distance: T, ceiling: u32, diag: ?*CastDiagnostics, +) ?CastHit(T) { + return castShapeImpl(T, shape_a, relpose, shape_b, direction, max_distance, ceiling, diag, false); +} + +/// `castShape` for a caller that GUARANTEES a unit direction — it skips the reconditioning below and +/// asserts instead. +/// +/// **It exists because reconditioning is not free when a sibling kernel does not do it.** `plane.zig` +/// takes the direction as given and uses its raw norm in `t = sep / −closing`; this kernel normalised +/// its own copy. So for a direction inside the unit band but not exactly unit — which is what +/// `Vec.normalizeScaled` produces, measured 0 to 1 ULP off — the two arms of `castShapeBody` answered +/// distances that differed by that ULP, and no amount of asserting at the entry closed it: an assert +/// BOUNDS a direction, it does not canonicalise it. `(1 + 4ε, 0, 0)` passes every unit assert in the +/// module and still split the two arms. +/// +/// ADDITIVE on purpose: `castShape` and `castShapeBounded` keep conditioning, keep their zero-direction +/// guard and keep their signatures, so no existing caller moves. Only `castShapeBody`, whose direction +/// is a precondition it asserts, takes this entry — and the two arms then see one vector. +pub fn castShapeUnit( + comptime T: type, + shape_a: SupportShape(T), + relpose: RelativePose(T), + shape_b: SupportShape(T), + direction: math.Vec(3, T), + max_distance: T, +) ?CastHit(T) { + return castShapeImpl(T, shape_a, relpose, shape_b, direction, max_distance, max_shapecast_iterations, null, true); +} + +fn castShapeImpl( + comptime T: type, + shape_a: SupportShape(T), + relpose: RelativePose(T), + shape_b: SupportShape(T), + direction: math.Vec(3, T), + max_distance: T, + ceiling: u32, + diag: ?*CastDiagnostics, + /// Whether the caller guarantees `direction` is unit. When true the reduce-then-normalise below + /// is SKIPPED — not merely made redundant — so this kernel and `plane.zig` consume the same bits. + comptime assume_unit: bool, ) ?CastHit(T) { const Vec3T = math.Vec(3, T); const Simplex = gjk_mod.Simplex(T); @@ -194,9 +235,10 @@ pub fn castShapeBounded( std.debug.assert(@reduce(.And, @abs(direction.data) < @as(Simd, @splat(std.math.inf(T))))); std.debug.assert(ceiling > 0); - // The direction, normalised once. `unitOf` returns null at EXACTLY zero, which - // is the whole zero-direction guard. - const d = unitOf(T, direction) orelse return finish(T, diag, .degenerate_direction, 0, 0, 0, null); + // The direction, normalised once — or taken as given when the caller guarantees it. `unitOf` + // returns null at EXACTLY zero, which is the whole zero-direction guard on the conditioning path; + // on the other, a zero direction would fail the unit assert below, which is the caller's contract. + const d = if (assume_unit) direction else (unitOf(T, direction) orelse return finish(T, diag, .degenerate_direction, 0, 0, 0, null)); std.debug.assert(@abs(d.lengthSq() - 1) <= unit_dir_k * std.math.floatEps(T)); // The configuration-space ray direction. A swept along `+d` touching B is the ray diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index a511f545..ac884776 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3939,3 +3939,49 @@ test "a DENORMAL and a HUGE displacement are both served, not dropped and not po } } } + +test "the two cast arms consume the SAME direction — the unit-band reproducer" { + const gpa = testing.allocator; + var store: ShapeStore = .{}; + defer store.deinit(gpa); + var bm: BodyManager = .{}; + defer bm.deinit(gpa); + + // **AN ASSERT BOUNDS A DIRECTION, IT DOES NOT CANONICALISE IT — which is why the entry's unit + // precondition did not close this and a second entry had to.** `(1 + 4ε, 0, 0)` passes every unit + // assert in the module, and inside that band the two arms consumed different vectors: the convex + // kernel reconditioned its copy through `unitOf` while `plane.zig` used the raw norm in + // `t = sep / −closing`. Measured against `ed3a8cf`, the same query answered `3.7` on one arm and + // `3.6999984` on the other, at BOTH precisions. `castShapeUnit` removes the reconditioning, so + // they consume the same bits and there is no band left to disagree inside. + const probe = SupportShapeR{ .core = .{ .point = {} }, .radius = 0.3 }; + const origin = v(-2, 0, 0); + // Two geometrically equivalent walls, surface `x = 2`, facing `−X`. + const convex = try bm.addBody(gpa, &store, .{ .entity = ent(770), .body_type = .static, .position = av(2.5, 0, 0), .shape = try store.createShape(gpa, .{ .box = .{ .half_extents = av(0.5, 2, 2) } }) }); + const half_space = try bm.addBody(gpa, &store, .{ .entity = ent(771), .body_type = .static, .shape = try store.createShape(gpa, .{ .plane = .{ .normal = av(-1, 0, 0), .distance = -2 } }) }); + + const eps = std.math.floatEps(Real); + const nudged = v(1 + 4 * eps, 0, 0); + + // The answer with an EXACTLY unit direction, which both arms have always agreed on — the reference + // the band's two candidate answers sit around. + const unit_d = bm.castShapeBody(&store, convex, probe, origin, Quatr.identity, v(1, 0, 0), 10, .ignore).?.distance; + try testing.expectEqual(unit_d, bm.castShapeBody(&store, half_space, probe, origin, Quatr.identity, v(1, 0, 0), 10, .ignore).?.distance); + + // 1 — BIT-EXACT agreement inside the band. Not `approxEq`: the whole defect is one ULP, and a + // tolerance wide enough to be comfortable would be wide enough to hide it. + const a = bm.castShapeBody(&store, convex, probe, origin, Quatr.identity, nudged, 10, .ignore); + const b = bm.castShapeBody(&store, half_space, probe, origin, Quatr.identity, nudged, 10, .ignore); + try testing.expect(a != null and b != null); + try testing.expectEqual(a.?.distance, b.?.distance); + + // 2 — AND THE BOUND JUST BEFORE CONTACT, which is where one ULP stops being cosmetic. A + // reconditioning arm reports the unit distance and a non-reconditioning one reports it shortened + // by `4ε`, so a bound strictly between the two answers HIT on one arm and MISS on the other. Now + // they agree, whatever that shared answer is. + const bound = unit_d * (1 - 2 * eps); + const a_bounded = bm.castShapeBody(&store, convex, probe, origin, Quatr.identity, nudged, bound, .ignore); + const b_bounded = bm.castShapeBody(&store, half_space, probe, origin, Quatr.identity, nudged, bound, .ignore); + try testing.expectEqual(a_bounded != null, b_bounded != null); + if (a_bounded) |hit_a| try testing.expectEqual(hit_a.distance, b_bounded.?.distance); +} diff --git a/src/modules/forge/forge_3d/tests/mesh_test.zig b/src/modules/forge/forge_3d/tests/mesh_test.zig index 0fb2e28d..f842948e 100644 --- a/src/modules/forge/forge_3d/tests/mesh_test.zig +++ b/src/modules/forge/forge_3d/tests/mesh_test.zig @@ -2376,7 +2376,14 @@ test "the five remaining entries agree exactly with brute force over the mesh" { var brute_index: u32 = 0; var t: u32 = 0; while (t < n) : (t += 1) { - const hit = narrowphase.castShape( + // **`castShapeUnit`, the SAME kernel entry the accelerated path takes — and this + // is an ORACLE CORRECTION, not a weakening.** What this test measures is that the + // BVH-accelerated answer agrees with brute force over every triangle; if the two + // sides call different kernel entries, one reconditioning the direction and the + // other not, it compares two things instead of comparing one, and the ULP it then + // reports is the difference between the entries and not between the paths. + // Aligned, it measures the acceleration again, which is what it was written for. + const hit = narrowphase.castShapeUnit( Real, probe, relpose, From 24135855cc0b7177336e029a98c48de69cd44397 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 14:37:53 +0200 Subject: [PATCH 085/100] fix(forge): an unrepresentable norm is an answer, not an infinity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unitAndLength protected the intermediate square and not the final product: two components at 0.75 * floatMax have a norm near 1.06 * floatMax, which no float here holds, so largest * |reduced| left the range and the function answered inf. That inf became a sweep bound and tripped a kernel's finiteness assert one call later. Treated AT THE SITE that produces the value, as asked: length becomes optional, because a DIRECTION always exists where a LENGTH need not, and the direction is scale-free and unaffected. Not an assumption each caller re-derives. The controller TREATS it rather than leaving it consigned, so the consignation shrinks by that much: an unrepresentable norm is still a request, served to floatMax, which is the largest distance this arithmetic can express — the world's colliders bound the sweep long before it, and a caller asking for more is asking for more than a POSITION can represent either. Third site of the class swept two rounds ago, and one tier below the two others. The denormal assertion was VACUOUS: x >= 0 also passes for the old behaviour, where the displacement was dropped and x was exactly zero. It is bit-exactly the displacement at both precisions and now says so — an inequality where an exact equality was available admitted the defect it was written against. And the mutation probe did not catch that, which is the round's method finding: it had been run, and it FIRED — on the huge case, whose abort ends the body before any later case runs. A probe on a multi-case body proves that AT LEAST ONE case discriminates, never that each does. The three cases are now three tests, each with its own verdict, and each fails against the commit that precedes its own fix. Exposed and NOT chased, because it is a different limit: at 0.75 * floatMax from the origin a broadphase node's (min + max) * 0.5 overflows, so the ray origin it derives is infinite and a different assert fires. The test closes its scene with walls to keep the question on unitAndLength. 495/495 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/foundation/math/vec.zig | 19 ++- src/modules/forge/forge_3d/character.zig | 13 ++- .../forge/forge_3d/tests/character_test.zig | 109 +++++++++++++----- 3 files changed, 106 insertions(+), 35 deletions(-) diff --git a/src/foundation/math/vec.zig b/src/foundation/math/vec.zig index d759b19d..a437e573 100644 --- a/src/foundation/math/vec.zig +++ b/src/foundation/math/vec.zig @@ -141,12 +141,27 @@ pub fn Vec(comptime N: usize, comptime T: type) type { /// /// `null` at EXACTLY zero — the largest absolute component is zero exactly when all three are — /// which is the emptiness test the caller needs, exact and without a threshold. - pub fn unitAndLength(self: Self) ?struct { unit: Self, length: T } { + /// + /// **`length` is itself optional, because a DIRECTION always exists where a LENGTH need not.** + /// The reduction protects the intermediate square, but the final product `largest · ‖reduced‖` + /// can still leave the range: two components at `0.75 · floatMax` have a norm of about + /// `1.06 · floatMax`, which is not representable at either precision. Answering `inf` there + /// would be answering a number that is not the length, and it propagated — measured, it became + /// an infinite sweep bound and tripped a kernel's finiteness assert one call later. + /// + /// So the unrepresentable case is an ANSWER this function gives, not an assumption each caller + /// re-derives. The direction is unaffected: it is scale-free by construction, which is exactly + /// why the two are returned separately rather than as one vector. + pub fn unitAndLength(self: Self) ?struct { unit: Self, length: ?T } { const largest = @reduce(.Max, @abs(self.data)); if (largest == 0) return null; const reduced: Self = .{ .data = self.data / @as(Simd, @splat(largest)) }; const reduced_length = reduced.length(); - return .{ .unit = reduced.scale(1 / reduced_length), .length = largest * reduced_length }; + const full = largest * reduced_length; + return .{ + .unit = reduced.scale(1 / reduced_length), + .length = if (std.math.isFinite(full)) full else null, + }; } /// Largest absolute component. Zero exactly when every component is zero. diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 3d6a9f9c..70fe2666 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -1556,8 +1556,15 @@ pub const CharacterStore = struct { // non-zero displacement is a real request to be served. No epsilon, and no threshold that // could disagree with the direction the same call is about to use. const step = remaining.unitAndLength() orelse break; - const distance = step.length; const direction = step.unit; + // **A NORM THAT IS NOT REPRESENTABLE IS STILL A REQUEST, and it is served to the limit of + // the arithmetic rather than refused.** Two components at `0.75 · floatMax` have a norm of + // about `1.06 · floatMax`, which no float here can hold; `unitAndLength` says so instead of + // answering `inf`, and `inf` is what previously became the sweep bound and tripped the + // kernel's finiteness assert one call later. `floatMax` is the largest distance this + // arithmetic can express, the world's colliders bound the sweep long before it, and a + // caller asking for more is asking for more than a POSITION can represent either. + const distance = step.length orelse std.math.floatMax(Real); const maybe_hit = sweepNearest( bp, @@ -1626,7 +1633,9 @@ pub const CharacterStore = struct { // `max_distance` assert. A second site of one class, on the only other path that // derives a length from the remainder. if (remaining.unitAndLength()) |step_left| { - if (tryStepUp(bp, bm, store, record, probe, centre, direction, step_left.length, c, &touched)) |stepped| { + // Same clamp and the same reason as the loop head — the third site of one class. + const step_distance = step_left.length orelse std.math.floatMax(Real); + if (tryStepUp(bp, bm, store, record, probe, centre, direction, step_distance, 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, diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index ac884776..dacecd36 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3899,45 +3899,92 @@ test "CONTRACT TABLE — the public cast conditions the direction, for all three } } -test "a DENORMAL and a HUGE displacement are both served, not dropped and not poisoned" { +/// One scene for the three displacement-magnitude tests: a floor, and a wall at `x = 2` so a request +/// larger than the world is bounded by GEOMETRY rather than by arithmetic. +fn magnitudeScene(gpa: std.mem.Allocator, world: *harness.World, chars: *CharacterStore) !api.CharacterId { + _ = try addPlane(gpa, world, av(0, 1, 0), 0, 960); + _ = try addBox(gpa, world, av(0.5, 2, 2), av(2.5, 0, 0), 961); + var desc = baseDescriptor(); + desc.entity = ent(962); + desc.position = av(0, 0.02, 0); + return addMover(gpa, world, chars, desc); +} + +test "a DENORMAL displacement is served, not read as empty" { 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 magnitudeScene(gpa, &world, &chars); - // **THE TWO ENDS OF THE RANGE THAT THREE SEPARATE QUESTIONS LOST.** The slide asked - // `lengthSq() == 0` for emptiness and `@sqrt(lengthSq())` for the distance: the square underflows - // for a denormal remainder, so a real displacement read as nothing and the call served zero; and it - // overflows for a large one, so an INFINITE `max_distance` reached the kernel and tripped its - // finiteness assert. One reduction by the largest absolute component answers all three. + // **THE SLIDE ASKED `lengthSq() == 0` FOR EMPTINESS AND THE SQUARE UNDERFLOWS**, so a denormal + // remainder read as nothing and the displacement was dropped. One reduction by the largest + // absolute component answers emptiness, direction and distance together and has no such hole. const tiny = std.math.floatTrueMin(Real); try testing.expectEqual(@as(Real, 0), tiny * tiny); // the square really does underflow + + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(tiny, 0, 0), 1.0 / 60.0); + + // **BIT-EXACTLY the displacement, and an inequality here would admit the defect.** The old + // behaviour left the pose at exactly zero, which any `x >= 0` accepts; the character starts at + // `x = 0` and the whole denormal is served, so the pose IS the displacement, at both precisions. + try testing.expectEqual(tiny, r.position.toArray()[0]); +} + +test "a HUGE displacement is bounded by the geometry, not by an infinity" { + 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 magnitudeScene(gpa, &world, &chars); + + // `@sqrt(lengthSq())` for the distance OVERFLOWS, so an INFINITE `max_distance` reached the kernel + // and tripped its finiteness assert. Two sites produced it — the loop head and the step attempt — + // and the second is the one this case aborted on. const huge: Real = if (Real == f32) 1e30 else 1e200; try testing.expect(!std.math.isFinite(huge * huge)); // and really does overflow - for ([_]Real{ tiny, huge }) |magnitude| { - errdefer std.debug.print("magnitude {e}\n", .{magnitude}); - 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, 960); - // A wall at x = 2, so a huge request is BOUNDED by geometry rather than by arithmetic. - _ = try addBox(gpa, &world, av(0.5, 2, 2), av(2.5, 0, 0), 961); - var desc = baseDescriptor(); - desc.entity = ent(962); - 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(huge, 0, 0), 1.0 / 60.0); + const x = r.position.toArray()[0]; + try testing.expect(std.math.isFinite(x)); + // Served up to the wall and no further: the request is bounded by what is in the way. + try testing.expectApproxEqAbs(@as(Real, 1.68), x, 1e-2); +} - const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(magnitude, 0, 0), 1.0 / 60.0); - const x = r.position.toArray()[0]; - try testing.expect(std.math.isFinite(x)); - if (magnitude == huge) { - // Served up to the wall and no further: the request is bounded by the geometry. - try testing.expectApproxEqAbs(@as(Real, 1.68), x, 1e-2); - } else { - // A denormal is a real request. It cannot move the pose measurably, but it must not be - // read as EMPTY — which is what the underflowing test did, and what dropped it. - try testing.expect(x >= 0); - } - } +test "a displacement whose NORM is not representable is served to the arithmetic's limit" { + 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 magnitudeScene(gpa, &world, &chars); + + // **THE REDUCTION PROTECTS THE INTERMEDIATE SQUARE AND NOT THE FINAL PRODUCT.** Two components at + // `0.75 · floatMax` have a norm of about `1.06 · floatMax`, which no float here can hold, so + // `largest · ‖reduced‖` left the range and `unitAndLength` answered `inf` — which became the sweep + // bound and tripped the kernel one call later. It now answers "not representable" at the site that + // produces the value, and the caller serves the request to the largest distance the arithmetic can + // express rather than refusing it or re-deriving the guard. + const big = 0.75 * std.math.floatMax(Real); + try testing.expect(!std.math.isFinite(v(big, big, 0).length())); + + // **THE SCENE IS CLOSED, and that is what isolates this defect from the next one.** An + // unrepresentable norm on an axis nothing blocks carries the pose to `0.75 · floatMax`, where the + // BROADPHASE's own arithmetic gives out — a node box that far from the origin has + // `(min + max) · 0.5` overflowing, so the ray origin it derives is infinite and a different assert + // fires. That limit is real, pre-existing and NOT this one; a wall on each horizontal axis keeps + // the pose where the question is about `unitAndLength` and nothing else. + _ = try addBox(gpa, &world, av(0.5, 4, 4), av(-3, 0, 0), 963); + _ = try addBox(gpa, &world, av(4, 4, 0.5), av(0, 0, 3), 964); + _ = try addBox(gpa, &world, av(4, 4, 0.5), av(0, 0, -3), 965); + + const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(big, 0, big), 1.0 / 60.0); + for (r.position.toArray()) |component| try testing.expect(std.math.isFinite(component)); + // Blocked by the walls, exactly as a finite request of the same direction would be. + try testing.expect(r.position.toArray()[0] < 2); + try testing.expect(r.position.toArray()[2] < 3); } test "the two cast arms consume the SAME direction — the unit-band reproducer" { From e92b476ced2d288d57086123d8db23de1a698434 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 15:18:56 +0200 Subject: [PATCH 086/100] fix(forge): an out-of-domain displacement is refused, never saturated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The saturation was the module's only exception to its own written rule, and the rule sits three lines above the error set it belongs to: silently clamping makes a caller's mistake look like a modelling choice and leaves no diagnostic. Serving an unrepresentable norm would also mean segmenting the move, turning this entry into a multi-segment integrator for a request no POSITION can represent — 0.75 * floatMax is outside any expressible world, and the broadphase's own node arithmetic overflows there, which is why the test scene has to close its walls near the origin. So CharacterError gains InvalidDisplacement and moveCharacter uses the error channel gate A gave it. THE CALL PARAMETER NOW HAS A DOMAIN: finite components and a representable norm, checked at the entry like every descriptor guard, and written at the site. Every other guard in this module belongs to the descriptor and is checked once at creation; displacement is checked per call and was checked nowhere, which is what let an infinite bound reach the sweep. Both open ends of that domain stay open and are asserted: EXACT zero is a legal no-op, and a DENORMAL displacement is still served — the norm is unrepresentable only by overflow, never by underflow, the direction being scale-free. Downstream the length is representable by INVARIANT rather than by hope: the entry refused otherwise and remaining only shrinks, both slide forms being projections, so the two sites take it with a checked unwrap that states it. Recorded rather than assumed: I did not reproduce a traversed plane from the saturation in my own scene, which ends at (1.686, 0.02, 2.18), inside its walls. What I did find is that the assertions I had written there were ONE-SIDED — x < 2 and z < 3 — so they could not have detected a traversal in the other direction anyway. The refusal removes the path either way. And there was no consignation in the repo to withdraw: that class lived in the conversation and was never written into the brief or CLAUDE.md. 495/495 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. The test aborts against 2413585, where the same call saturated instead of refusing. --- src/modules/forge/forge_3d/character.zig | 42 ++++++++++++++----- .../forge/forge_3d/tests/character_test.zig | 42 +++++++++---------- 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 70fe2666..d1c6fa28 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -83,6 +83,22 @@ pub const CharacterError = error{ InvalidCollisionLayer, /// The handle is stale: its slot was freed, or its generation does not match. StaleCharacter, + /// `displacement` is out of domain: a non-finite component, or a norm that is not + /// REPRESENTABLE — two components at `0.75 · floatMax` have a norm near `1.06 · floatMax`, + /// which no float here holds. + /// + /// **This is the domain the call parameter did not have.** Every other guard in this module + /// belongs to the DESCRIPTOR, checked once at creation; `displacement` is checked per call and + /// was checked nowhere, which is what let an unrepresentable norm reach the sweep as an infinite + /// bound. Refused rather than saturated, for the reason stated at the head of this set: a clamp + /// makes a caller's mistake look like a modelling choice. Serving it would also mean segmenting + /// the move, which would make this entry a multi-segment integrator for a request no POSITION + /// can represent — `0.75 · floatMax` is outside any expressible world, and the broadphase's own + /// node arithmetic overflows there. + /// + /// EXACT zero stays legal and is a no-op, and a DENORMAL displacement stays served: the norm is + /// unrepresentable only by overflow, never by underflow, the direction being scale-free. + InvalidDisplacement, }; /// One stored controller. Authored parameters at solver precision, plus the two handles the @@ -1489,6 +1505,15 @@ pub const CharacterStore = struct { dt: Real, ) !MoveResult { const idx = self.alloc.validate(id) orelse return error.StaleCharacter; + // The call parameter's domain, at the entry and not mid-loop: finite components, and a norm + // this arithmetic can hold. Exact zero passes — `unitAndLength` answers `null` there and a + // displacement of nothing is a legal no-op. + for (displacement.toArray()) |component| { + if (!std.math.isFinite(component)) return error.InvalidDisplacement; + } + if (displacement.unitAndLength()) |whole| { + if (whole.length == null) return error.InvalidDisplacement; + } const c = self.characters.items[idx]; const record = store.get(c.shape) orelse unreachable; const probe = shape_mod.supportShape(record); @@ -1557,14 +1582,10 @@ pub const CharacterStore = struct { // could disagree with the direction the same call is about to use. const step = remaining.unitAndLength() orelse break; const direction = step.unit; - // **A NORM THAT IS NOT REPRESENTABLE IS STILL A REQUEST, and it is served to the limit of - // the arithmetic rather than refused.** Two components at `0.75 · floatMax` have a norm of - // about `1.06 · floatMax`, which no float here can hold; `unitAndLength` says so instead of - // answering `inf`, and `inf` is what previously became the sweep bound and tripped the - // kernel's finiteness assert one call later. `floatMax` is the largest distance this - // arithmetic can express, the world's colliders bound the sweep long before it, and a - // caller asking for more is asking for more than a POSITION can represent either. - const distance = step.length orelse std.math.floatMax(Real); + // Representable by INVARIANT, not by hope: the entry refused an unrepresentable norm, and + // `remaining` only ever shrinks from there — both slide forms are PROJECTIONS, which + // cannot lengthen a vector. `.?` is that invariant, checked in Debug and ReleaseSafe. + const distance = step.length.?; const maybe_hit = sweepNearest( bp, @@ -1633,9 +1654,8 @@ pub const CharacterStore = struct { // `max_distance` assert. A second site of one class, on the only other path that // derives a length from the remainder. if (remaining.unitAndLength()) |step_left| { - // Same clamp and the same reason as the loop head — the third site of one class. - const step_distance = step_left.length orelse std.math.floatMax(Real); - if (tryStepUp(bp, bm, store, record, probe, centre, direction, step_distance, c, &touched)) |stepped| { + // The same invariant as the loop head, and for the same reason. + if (tryStepUp(bp, bm, store, record, probe, centre, direction, step_left.length.?, 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, diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index dacecd36..a47c2407 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3953,7 +3953,7 @@ test "a HUGE displacement is bounded by the geometry, not by an infinity" { try testing.expectApproxEqAbs(@as(Real, 1.68), x, 1e-2); } -test "a displacement whose NORM is not representable is served to the arithmetic's limit" { +test "a displacement whose NORM is not representable is REFUSED, not saturated" { const gpa = testing.allocator; var world = harness.World.initNoSleep(Vec3r.zero, 1.0 / 60.0); defer world.deinit(gpa); @@ -3962,29 +3962,29 @@ test "a displacement whose NORM is not representable is served to the arithmetic const id = try magnitudeScene(gpa, &world, &chars); // **THE REDUCTION PROTECTS THE INTERMEDIATE SQUARE AND NOT THE FINAL PRODUCT.** Two components at - // `0.75 · floatMax` have a norm of about `1.06 · floatMax`, which no float here can hold, so + // `0.75 · floatMax` have a norm near `1.06 · floatMax`, which no float here holds, so // `largest · ‖reduced‖` left the range and `unitAndLength` answered `inf` — which became the sweep - // bound and tripped the kernel one call later. It now answers "not representable" at the site that - // produces the value, and the caller serves the request to the largest distance the arithmetic can - // express rather than refusing it or re-deriving the guard. + // bound and tripped the kernel one call later. + // + // **REFUSED and not clamped, which is this module's rule and not a preference.** `CharacterError` + // says it at its head: a silent clamp makes a caller's mistake look like a modelling choice and + // leaves no diagnostic. An earlier form here saturated the sweep to `floatMax` — the module's only + // exception, and it lasted one round. Serving it would also make this entry a multi-segment + // integrator for a request no POSITION can represent. const big = 0.75 * std.math.floatMax(Real); try testing.expect(!std.math.isFinite(v(big, big, 0).length())); - - // **THE SCENE IS CLOSED, and that is what isolates this defect from the next one.** An - // unrepresentable norm on an axis nothing blocks carries the pose to `0.75 · floatMax`, where the - // BROADPHASE's own arithmetic gives out — a node box that far from the origin has - // `(min + max) · 0.5` overflowing, so the ray origin it derives is infinite and a different assert - // fires. That limit is real, pre-existing and NOT this one; a wall on each horizontal axis keeps - // the pose where the question is about `unitAndLength` and nothing else. - _ = try addBox(gpa, &world, av(0.5, 4, 4), av(-3, 0, 0), 963); - _ = try addBox(gpa, &world, av(4, 4, 0.5), av(0, 0, 3), 964); - _ = try addBox(gpa, &world, av(4, 4, 0.5), av(0, 0, -3), 965); - - const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(big, 0, big), 1.0 / 60.0); - for (r.position.toArray()) |component| try testing.expect(std.math.isFinite(component)); - // Blocked by the walls, exactly as a finite request of the same direction would be. - try testing.expect(r.position.toArray()[0] < 2); - try testing.expect(r.position.toArray()[2] < 3); + try testing.expectError(error.InvalidDisplacement, chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(big, 0, big), 1.0 / 60.0)); + + // A NON-FINITE component is the same class and the same answer. + try testing.expectError(error.InvalidDisplacement, chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(std.math.inf(Real), 0, 0), 1.0 / 60.0)); + + // And the domain's two open ends stay open: EXACT zero is a legal no-op, and a component large + // enough to be interesting but whose norm IS representable is served. + const before = chars.get(id).?.position.toArray()[0]; + const still = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, Vec3r.zero, 1.0 / 60.0); + try testing.expectApproxEqAbs(before, still.position.toArray()[0], api_tol); + const served = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(big, 0, 0), 1.0 / 60.0); + try testing.expect(std.math.isFinite(served.position.toArray()[0])); } test "the two cast arms consume the SAME direction — the unit-band reproducer" { From cccd0eb507baf38f506a5ca906b02f6724dfca2d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 15:45:30 +0200 Subject: [PATCH 087/100] fix(forge): the displacement domain is the public f32 surface's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The domain tested representability in Real, which made it a function of a BUILD FLAG: the same public vector was refused at f32 and accepted under -Dphysics_f64, where its norm is an ordinary number. The interface takes a Vec3 in f32, so the discriminant is the quantity's ORIGIN and not the precision that handles it afterwards — the tolerance class this milestone made normative, applied to a DOMAIN instead of to a comparison. The bound is floatMax(f32), evaluated at the boundary, and the same vector is now refused at both. The test passes ONE input at both precisions instead of two, which is what the Real-scaled literal was hiding. And the HUGE test was the same defect: it read , so the two builds compared two things. It now uses 1e30 at both, and states plainly that the mechanism it exercises is live at f32 alone — 1e60 overflows there and is an ordinary number in f64 — so the guard is load-bearing at one precision and inert at the other, rather than dressing the f64 leg up as testing the same thing. moveCharacter's error documentation carries InvalidDisplacement with its reason. Mutation probe: against e92b476 the f64 leg accepts what f32 refuses, and the test fails there. 495/495 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/modules/forge/forge_3d/character.zig | 30 ++++++++++++++----- .../forge/forge_3d/tests/character_test.zig | 22 +++++++++++--- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index d1c6fa28..b6624eda 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -83,9 +83,16 @@ pub const CharacterError = error{ InvalidCollisionLayer, /// The handle is stale: its slot was freed, or its generation does not match. StaleCharacter, - /// `displacement` is out of domain: a non-finite component, or a norm that is not - /// REPRESENTABLE — two components at `0.75 · floatMax` have a norm near `1.06 · floatMax`, - /// which no float here holds. + /// `displacement` is out of domain: a non-finite component, or a norm that is not representable + /// **in `f32`** — two components at `0.75 · floatMax(f32)` have a norm near `1.06 · floatMax(f32)`. + /// + /// **THE DOMAIN IS THE PUBLIC `f32` SURFACE'S, EVALUATED AT THE BOUNDARY, and not the solver + /// scalar's.** A first form tested representability in `Real`, which made the domain depend on a + /// BUILD FLAG: the same public vector was refused at `f32` and accepted under `-Dphysics_f64`, + /// where its norm is an ordinary number. The interface takes a `Vec3` in `f32` (§1.12.11), so the + /// discriminant is the quantity's ORIGIN and not the precision that handles it afterwards — the + /// tolerance class this milestone made normative in §1.11.2, applied to a DOMAIN instead of to a + /// comparison. /// /// **This is the domain the call parameter did not have.** Every other guard in this module /// belongs to the DESCRIPTOR, checked once at creation; `displacement` is checked per call and @@ -93,8 +100,8 @@ pub const CharacterError = error{ /// bound. Refused rather than saturated, for the reason stated at the head of this set: a clamp /// makes a caller's mistake look like a modelling choice. Serving it would also mean segmenting /// the move, which would make this entry a multi-segment integrator for a request no POSITION - /// can represent — `0.75 · floatMax` is outside any expressible world, and the broadphase's own - /// node arithmetic overflows there. + /// can represent — `0.75 · floatMax(f32)` is outside any expressible world, and the broadphase's + /// own node arithmetic overflows well before it. /// /// EXACT zero stays legal and is a no-op, and a DENORMAL displacement stays served: the norm is /// unrepresentable only by overflow, never by underflow, the direction being scale-free. @@ -1506,13 +1513,20 @@ pub const CharacterStore = struct { ) !MoveResult { const idx = self.alloc.validate(id) orelse return error.StaleCharacter; // The call parameter's domain, at the entry and not mid-loop: finite components, and a norm - // this arithmetic can hold. Exact zero passes — `unitAndLength` answers `null` there and a - // displacement of nothing is a legal no-op. + // representable at the PUBLIC `f32` surface this vector came through — never at `Real`, which + // would make the domain a function of the build flag and give one public vector two answers. + // Exact zero passes: `unitAndLength` answers `null` there, and a displacement of nothing is a + // legal no-op. + const displacement_limit: Real = std.math.floatMax(f32); for (displacement.toArray()) |component| { if (!std.math.isFinite(component)) return error.InvalidDisplacement; } if (displacement.unitAndLength()) |whole| { - if (whole.length == null) return error.InvalidDisplacement; + // `null` is the overflow of `Real` itself, which only `f32` can reach here; the bound is + // what refuses the same vector under `-Dphysics_f64`, where that norm is an ordinary + // number and nothing else would have stopped it. + const norm = whole.length orelse return error.InvalidDisplacement; + if (!(norm <= displacement_limit)) return error.InvalidDisplacement; } const c = self.characters.items[idx]; const record = store.get(c.shape) orelse unreachable; diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index a47c2407..5f85f312 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3943,8 +3943,16 @@ test "a HUGE displacement is bounded by the geometry, not by an infinity" { // `@sqrt(lengthSq())` for the distance OVERFLOWS, so an INFINITE `max_distance` reached the kernel // and tripped its finiteness assert. Two sites produced it — the loop head and the step attempt — // and the second is the one this case aborted on. - const huge: Real = if (Real == f32) 1e30 else 1e200; - try testing.expect(!std.math.isFinite(huge * huge)); // and really does overflow + // + // **ONE input at both precisions, and an earlier form used two.** It read + // `if (Real == f32) 1e30 else 1e200`, which is the very class this milestone had just named at the + // domain: two builds handed two different vectors, so the test compared two things. `1e30` is + // inside the public `f32` domain at both precisions, and the mechanism it exercises is live at + // `f32` alone — `1e60` overflows there and is an ordinary number in `f64`, so the guard is + // load-bearing at one precision and inert at the other. Stated, rather than papered over with a + // second literal that would make the f64 leg look like it tested the same thing. + const huge: Real = 1e30; + if (Real == f32) try testing.expect(!std.math.isFinite(huge * huge)); const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(huge, 0, 0), 1.0 / 60.0); const x = r.position.toArray()[0]; @@ -3971,8 +3979,12 @@ test "a displacement whose NORM is not representable is REFUSED, not saturated" // leaves no diagnostic. An earlier form here saturated the sweep to `floatMax` — the module's only // exception, and it lasted one round. Serving it would also make this entry a multi-segment // integrator for a request no POSITION can represent. - const big = 0.75 * std.math.floatMax(Real); - try testing.expect(!std.math.isFinite(v(big, big, 0).length())); + // **`floatMax(f32)` AND NOT `floatMax(Real)` — the same input at both precisions, which is the + // whole point.** The domain belongs to the PUBLIC `f32` surface the vector came through, so a + // `Real`-scaled literal would hand the two builds two different vectors and hide exactly the + // defect this asserts: with the bound taken in `Real`, this norm is an ordinary number under + // `-Dphysics_f64` and the same public call was accepted there and refused at `f32`. + const big: Real = 0.75 * std.math.floatMax(f32); try testing.expectError(error.InvalidDisplacement, chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(big, 0, big), 1.0 / 60.0)); // A NON-FINITE component is the same class and the same answer. @@ -3983,6 +3995,8 @@ test "a displacement whose NORM is not representable is REFUSED, not saturated" const before = chars.get(id).?.position.toArray()[0]; const still = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, Vec3r.zero, 1.0 / 60.0); try testing.expectApproxEqAbs(before, still.position.toArray()[0], api_tol); + // A single component of the same magnitude has a REPRESENTABLE norm and is served, at both + // precisions — the domain excludes the norm, not the magnitude. const served = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(big, 0, 0), 1.0 / 60.0); try testing.expect(std.math.isFinite(served.position.toArray()[0])); } From 3d115304ccdf3f7ecfe1aa94a5fc1e8c37aca797 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 16:02:50 +0200 Subject: [PATCH 088/100] docs(forge): the displacement domain defers to the specs that own it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both control witnesses verified on the text before touching anything, not on the files being present. engine-tier-interfaces.md carries "Le canal porte AUSSI le domaine du DEPLACEMENT", the f32 boundary evaluated before widening, and the prohibition on saturating with its measure — a saturated bound leaves the TAIL of the displacement unswept, so a plane sitting there is traversed. engine-physics-forge.md 1.12.6 carries "le premier parametre d'APPEL du module a en avoir un" and why the domain table missed it: it tabulated fields, not parameters. The site text predated both and therefore paraphrased what is now normative elsewhere. Removed rather than amended: a second formulation of a normative rule is a second source, which is the rule of two sources applied to prose. What stays is what the code does and what only the site knows — the two open ends of the domain, and the two distinct refusals that share it. That measure also reconciles a disagreement I reported: I could not reproduce a traversed plane from the saturation in my own scene, and the reason is now named — the traversal comes from the unswept tail, and my scene's walls sat inside the saturated bound, so it could not exhibit it. 495/495 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/modules/forge/forge_3d/character.zig | 43 +++++++------------ .../forge/forge_3d/tests/character_test.zig | 16 +++---- 2 files changed, 22 insertions(+), 37 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index b6624eda..0c96394c 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -83,28 +83,18 @@ pub const CharacterError = error{ InvalidCollisionLayer, /// The handle is stale: its slot was freed, or its generation does not match. StaleCharacter, - /// `displacement` is out of domain: a non-finite component, or a norm that is not representable - /// **in `f32`** — two components at `0.75 · floatMax(f32)` have a norm near `1.06 · floatMax(f32)`. + /// `displacement` is out of domain: a non-finite component, or a norm not representable in `f32`. /// - /// **THE DOMAIN IS THE PUBLIC `f32` SURFACE'S, EVALUATED AT THE BOUNDARY, and not the solver - /// scalar's.** A first form tested representability in `Real`, which made the domain depend on a - /// BUILD FLAG: the same public vector was refused at `f32` and accepted under `-Dphysics_f64`, - /// where its norm is an ordinary number. The interface takes a `Vec3` in `f32` (§1.12.11), so the - /// discriminant is the quantity's ORIGIN and not the precision that handles it afterwards — the - /// tolerance class this milestone made normative in §1.11.2, applied to a DOMAIN instead of to a - /// comparison. + /// The domain itself, its evaluation at the PUBLIC `f32` boundary rather than at the solver + /// scalar, and the prohibition on saturating instead of refusing are NORMATIVE in + /// `engine-physics-forge.md` §1.12.6, and mirrored on the frozen entry in + /// `engine-tier-interfaces.md`. Deliberately not restated: a second formulation of a normative + /// rule is a second source, and this module spends its length refusing those. /// - /// **This is the domain the call parameter did not have.** Every other guard in this module - /// belongs to the DESCRIPTOR, checked once at creation; `displacement` is checked per call and - /// was checked nowhere, which is what let an unrepresentable norm reach the sweep as an infinite - /// bound. Refused rather than saturated, for the reason stated at the head of this set: a clamp - /// makes a caller's mistake look like a modelling choice. Serving it would also mean segmenting - /// the move, which would make this entry a multi-segment integrator for a request no POSITION - /// can represent — `0.75 · floatMax(f32)` is outside any expressible world, and the broadphase's - /// own node arithmetic overflows well before it. - /// - /// EXACT zero stays legal and is a no-op, and a DENORMAL displacement stays served: the norm is - /// unrepresentable only by overflow, never by underflow, the direction being scale-free. + /// What belongs here is what the code does with the two ends the domain leaves open: EXACT zero + /// is legal and a no-op, `unitAndLength` answering `null` there; and a DENORMAL displacement is + /// served, the norm being unrepresentable only by overflow and never by underflow since the + /// direction is scale-free. InvalidDisplacement, }; @@ -1512,19 +1502,16 @@ pub const CharacterStore = struct { dt: Real, ) !MoveResult { const idx = self.alloc.validate(id) orelse return error.StaleCharacter; - // The call parameter's domain, at the entry and not mid-loop: finite components, and a norm - // representable at the PUBLIC `f32` surface this vector came through — never at `Real`, which - // would make the domain a function of the build flag and give one public vector two answers. - // Exact zero passes: `unitAndLength` answers `null` there, and a displacement of nothing is a - // legal no-op. + // The call parameter's domain (§1.12.6), at the entry and not mid-loop. Exact zero passes: + // `unitAndLength` answers `null` there, and a displacement of nothing is a legal no-op. const displacement_limit: Real = std.math.floatMax(f32); for (displacement.toArray()) |component| { if (!std.math.isFinite(component)) return error.InvalidDisplacement; } if (displacement.unitAndLength()) |whole| { - // `null` is the overflow of `Real` itself, which only `f32` can reach here; the bound is - // what refuses the same vector under `-Dphysics_f64`, where that norm is an ordinary - // number and nothing else would have stopped it. + // Two refusals, one domain: `null` is `Real`'s own overflow, which only `f32` reaches + // here, and the bound is what refuses the SAME vector under `-Dphysics_f64`, where that + // norm is an ordinary number and nothing else would have stopped it. const norm = whole.length orelse return error.InvalidDisplacement; if (!(norm <= displacement_limit)) return error.InvalidDisplacement; } diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 5f85f312..ebc7e274 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3969,16 +3969,14 @@ test "a displacement whose NORM is not representable is REFUSED, not saturated" defer chars.deinit(gpa); const id = try magnitudeScene(gpa, &world, &chars); - // **THE REDUCTION PROTECTS THE INTERMEDIATE SQUARE AND NOT THE FINAL PRODUCT.** Two components at - // `0.75 · floatMax` have a norm near `1.06 · floatMax`, which no float here holds, so - // `largest · ‖reduced‖` left the range and `unitAndLength` answered `inf` — which became the sweep - // bound and tripped the kernel one call later. + // The domain and the prohibition on saturating are normative in `engine-physics-forge.md` + // §1.12.6; what this test adds is the measurement. // - // **REFUSED and not clamped, which is this module's rule and not a preference.** `CharacterError` - // says it at its head: a silent clamp makes a caller's mistake look like a modelling choice and - // leaves no diagnostic. An earlier form here saturated the sweep to `floatMax` — the module's only - // exception, and it lasted one round. Serving it would also make this entry a multi-segment - // integrator for a request no POSITION can represent. + // **THE REDUCTION PROTECTS THE INTERMEDIATE SQUARE AND NOT THE FINAL PRODUCT.** Two components at + // `0.75 · floatMax(f32)` have a norm near `1.06 · floatMax(f32)`, so `largest · ‖reduced‖` left + // the range and `unitAndLength` answered `inf` — which became the sweep bound and tripped the + // kernel one call later. An earlier form here saturated that bound instead of refusing, which was + // the module's only exception to its own rule and lasted one round. // **`floatMax(f32)` AND NOT `floatMax(Real)` — the same input at both precisions, which is the // whole point.** The domain belongs to the PUBLIC `f32` surface the vector came through, so a // `Real`-scaled literal would hand the two builds two different vectors and hide exactly the From 8cab4b03c82c493795ba251fa83641021ab5fbf7 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 16:19:35 +0200 Subject: [PATCH 089/100] docs(forge): the Errors list names InvalidDisplacement The list on moveCharacter still announced StaleCharacter and the allocation failures alone, which the previous patch was meant to correct and did not. It points at 1.12.6 for the domain rather than restating it. P1 is NOT in this commit: the prescription and its stated outcome are not compatible, measured, and the fork is reported instead of picked. --- src/modules/forge/forge_3d/character.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 0c96394c..feab9802 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -1489,8 +1489,9 @@ pub const CharacterStore = struct { /// 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. + /// Errors: `error.StaleCharacter` on a dead handle, `error.InvalidDisplacement` when + /// `displacement` is outside the domain §1.12.6 gives it, and whatever the broadphase proxy + /// update allocates. pub fn moveCharacter( self: *CharacterStore, gpa: std.mem.Allocator, From eaf82af4dbc0f9c707b8f7a685a43a6a676bcd91 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 17:23:37 +0200 Subject: [PATCH 090/100] fix(forge): the f32 domain is decided where it can be decided MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bound was floatMax(f32) and the norm was still computed at Real, so the domain went on depending on the build through the ROUNDING instead of through the bound. Codex's vector shows it: (floatMax(f32), 0.0002 * floatMax(f32), 0) was refused at f64 and accepted at f32. Evaluating in f32 does not fix it, and that is measured rather than argued: the reduced square 1 + 4e-8 falls below the half-ULP of 1.0 in f32, rounds to exactly 1.0, and the norm comes back as exactly floatMax(f32) — f32 cannot see its own overflow, so it would accept a vector whose true norm leaves the range. The prescription and its stated outcome were not compatible, which is why the fork was reported instead of picked. So the contract is stated in its own terms: THE DOMAIN IS THE f32 RANGE, AND IT IS EVALUATED IN AN ARITHMETIC THAT CAN DECIDE THAT RANGE WITHOUT ROUNDING. The width is the consequence and not the rule. Widening the components is EXACT, so the verdict is a pure function of the public input and identical under either -Dphysics_f64 setting. The declaration cites 1.11.4 bis, which measured the same phenomenon on the ray normals — the information is present in the f32 inputs and only a wider arithmetic extracts it — so a reader does not read the width as a precision picked at random. No reduction by the largest component is needed at this one site, and the reason is written there: the widening makes the square safe for every f32-origin input, and a square that overflows f64 can only come from a component already outside the f32 range. The test passes that exact vector at both precisions, formed in f32 and widened. Mutation probe against 8cab4b0: it FAILS at f32 and passes at f64, which is the asymmetry this removes. 495/495 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/modules/forge/forge_3d/character.zig | 47 ++++++++++++------- .../forge/forge_3d/tests/character_test.zig | 16 +++++-- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index feab9802..db8d33ef 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -83,18 +83,29 @@ pub const CharacterError = error{ InvalidCollisionLayer, /// The handle is stale: its slot was freed, or its generation does not match. StaleCharacter, - /// `displacement` is out of domain: a non-finite component, or a norm not representable in `f32`. + /// `displacement` is out of domain: a non-finite component, or a norm outside the `f32` range. /// - /// The domain itself, its evaluation at the PUBLIC `f32` boundary rather than at the solver - /// scalar, and the prohibition on saturating instead of refusing are NORMATIVE in + /// The domain itself and the prohibition on saturating instead of refusing are NORMATIVE in /// `engine-physics-forge.md` §1.12.6, and mirrored on the frozen entry in /// `engine-tier-interfaces.md`. Deliberately not restated: a second formulation of a normative /// rule is a second source, and this module spends its length refusing those. /// + /// **THE DOMAIN IS THE `f32` RANGE, AND IT IS EVALUATED IN AN ARITHMETIC THAT CAN DECIDE THAT + /// RANGE WITHOUT ROUNDING.** The width is the consequence, not the rule. Deciding it in `f32` + /// itself does NOT work and that is measured, not argued: for + /// `(floatMax(f32), 0.0002 · floatMax(f32), 0)` the reduced square `1 + 4e-8` falls below the + /// half-ULP of `1.0` in `f32`, rounds to exactly `1.0`, and the norm comes back as exactly + /// `floatMax(f32)` — so `f32` cannot see its own overflow and accepts a vector whose true norm + /// leaves the range. Widening the components is EXACT, so the verdict is a pure function of the + /// public input and identical under either `-Dphysics_f64` setting. + /// + /// This is the same phenomenon `engine-physics-forge.md` §1.11.4 bis measured on the ray normals: + /// the information is present in the `f32` inputs and only a wider arithmetic extracts it. Not a + /// precision picked at random. + /// /// What belongs here is what the code does with the two ends the domain leaves open: EXACT zero - /// is legal and a no-op, `unitAndLength` answering `null` there; and a DENORMAL displacement is - /// served, the norm being unrepresentable only by overflow and never by underflow since the - /// direction is scale-free. + /// is legal and a no-op; and a DENORMAL displacement is served, the norm leaving the range only + /// by overflow and never by underflow since the direction is scale-free. InvalidDisplacement, }; @@ -1503,19 +1514,23 @@ pub const CharacterStore = struct { dt: Real, ) !MoveResult { const idx = self.alloc.validate(id) orelse return error.StaleCharacter; - // The call parameter's domain (§1.12.6), at the entry and not mid-loop. Exact zero passes: - // `unitAndLength` answers `null` there, and a displacement of nothing is a legal no-op. - const displacement_limit: Real = std.math.floatMax(f32); + // The call parameter's domain (§1.12.6), at the entry and not mid-loop. The range is `f32`'s + // and the arithmetic is the one that can decide it without rounding — see + // `CharacterError.InvalidDisplacement` for why `f32` cannot decide its own range. Exact zero + // passes: a displacement of nothing is a legal no-op. + // + // No reduction by the largest component is needed HERE, unlike everywhere else this norm is + // taken: the widening makes the square safe for every `f32`-origin input, and a square that + // overflows `f64` can only come from a component already outside the `f32` range, which is + // the same verdict by a shorter route. + const displacement_limit: f64 = std.math.floatMax(f32); + var displacement_norm_sq: f64 = 0; for (displacement.toArray()) |component| { if (!std.math.isFinite(component)) return error.InvalidDisplacement; + const wide: f64 = component; + displacement_norm_sq += wide * wide; } - if (displacement.unitAndLength()) |whole| { - // Two refusals, one domain: `null` is `Real`'s own overflow, which only `f32` reaches - // here, and the bound is what refuses the SAME vector under `-Dphysics_f64`, where that - // norm is an ordinary number and nothing else would have stopped it. - const norm = whole.length orelse return error.InvalidDisplacement; - if (!(norm <= displacement_limit)) return error.InvalidDisplacement; - } + if (!(@sqrt(displacement_norm_sq) <= displacement_limit)) return error.InvalidDisplacement; const c = self.characters.items[idx]; const record = store.get(c.shape) orelse unreachable; const probe = shape_mod.supportShape(record); diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index ebc7e274..8f641ed5 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3977,14 +3977,20 @@ test "a displacement whose NORM is not representable is REFUSED, not saturated" // the range and `unitAndLength` answered `inf` — which became the sweep bound and tripped the // kernel one call later. An earlier form here saturated that bound instead of refusing, which was // the module's only exception to its own rule and lasted one round. - // **`floatMax(f32)` AND NOT `floatMax(Real)` — the same input at both precisions, which is the - // whole point.** The domain belongs to the PUBLIC `f32` surface the vector came through, so a - // `Real`-scaled literal would hand the two builds two different vectors and hide exactly the - // defect this asserts: with the bound taken in `Real`, this norm is an ordinary number under - // `-Dphysics_f64` and the same public call was accepted there and refused at `f32`. + // **THE SAME VECTOR AT BOTH PRECISIONS, which is the whole point.** The domain belongs to the + // `f32` range the vector came through, so a `Real`-scaled literal would hand the two builds two + // different inputs and hide the defect instead of asserting it. const big: Real = 0.75 * std.math.floatMax(f32); try testing.expectError(error.InvalidDisplacement, chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(big, 0, big), 1.0 / 60.0)); + // **AND THE EDGE THE BOUND ALONE COULD NOT CATCH.** With the norm computed at `Real`, this vector + // is refused at `f64` and accepted at `f32`, because the `f32` reduction rounds `1 + 4e-8` to + // exactly `1.0` and hands back exactly `floatMax(f32)` — `f32` cannot see its own overflow. The + // components are formed in `f32` and widened, so both builds receive the identical vector. + const edge_x: Real = std.math.floatMax(f32); + const edge_y: Real = @as(f32, 0.0002 * std.math.floatMax(f32)); + try testing.expectError(error.InvalidDisplacement, chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(edge_x, edge_y, 0), 1.0 / 60.0)); + // A NON-FINITE component is the same class and the same answer. try testing.expectError(error.InvalidDisplacement, chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(std.math.inf(Real), 0, 0), 1.0 / 60.0)); From 00b7d8be55dd76731ce389ef493369b9b9069c42 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 18:53:33 +0200 Subject: [PATCH 091/100] docs(forge): the displacement domain is a pointer, not a paraphrase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both witnesses verified on the text: engine-physics-forge.md 1.12.6 carries "La regle dit quel domaine s'applique, pas quelle arithmetique l'evalue", with the reproducer as its measurement and the 1.11.4 bis reference; the moveCharacter entry carries "LE DOMAINE EST LA PLAGE f32, et il est EVALUE dans une arithmetique capable de trancher cette plage SANS ARRONDI". Reading 1.12.6 in full, it now also owns the two ends the domain leaves served — exact zero and the denormal — which the site still restated. So the declaration was paraphrase end to end and collapses to a pointer. Sixteen lines removed rather than amended. What stays is what only the site knows: that no reduction by the largest component is needed at this one place, and why. The guard comment also stops presenting f64 as a local choice — it is the arithmetic 1.12.6 requires. 495/495 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/modules/forge/forge_3d/character.zig | 42 ++++++++---------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index db8d33ef..6f6062f1 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -85,27 +85,11 @@ pub const CharacterError = error{ StaleCharacter, /// `displacement` is out of domain: a non-finite component, or a norm outside the `f32` range. /// - /// The domain itself and the prohibition on saturating instead of refusing are NORMATIVE in - /// `engine-physics-forge.md` §1.12.6, and mirrored on the frozen entry in - /// `engine-tier-interfaces.md`. Deliberately not restated: a second formulation of a normative - /// rule is a second source, and this module spends its length refusing those. - /// - /// **THE DOMAIN IS THE `f32` RANGE, AND IT IS EVALUATED IN AN ARITHMETIC THAT CAN DECIDE THAT - /// RANGE WITHOUT ROUNDING.** The width is the consequence, not the rule. Deciding it in `f32` - /// itself does NOT work and that is measured, not argued: for - /// `(floatMax(f32), 0.0002 · floatMax(f32), 0)` the reduced square `1 + 4e-8` falls below the - /// half-ULP of `1.0` in `f32`, rounds to exactly `1.0`, and the norm comes back as exactly - /// `floatMax(f32)` — so `f32` cannot see its own overflow and accepts a vector whose true norm - /// leaves the range. Widening the components is EXACT, so the verdict is a pure function of the - /// public input and identical under either `-Dphysics_f64` setting. - /// - /// This is the same phenomenon `engine-physics-forge.md` §1.11.4 bis measured on the ray normals: - /// the information is present in the `f32` inputs and only a wider arithmetic extracts it. Not a - /// precision picked at random. - /// - /// What belongs here is what the code does with the two ends the domain leaves open: EXACT zero - /// is legal and a no-op; and a DENORMAL displacement is served, the norm leaving the range only - /// by overflow and never by underflow since the direction is scale-free. + /// NORMATIVE in `engine-physics-forge.md` §1.12.6 and mirrored on the frozen entry in + /// `engine-tier-interfaces.md`: the domain, the fact that the rule fixes which domain applies and + /// NOT which arithmetic decides it, the measurement behind that distinction, and the two ends the + /// domain leaves served. Not restated here — a second formulation of a normative rule is a second + /// source, and this module spends its length refusing those. InvalidDisplacement, }; @@ -1514,15 +1498,15 @@ pub const CharacterStore = struct { dt: Real, ) !MoveResult { const idx = self.alloc.validate(id) orelse return error.StaleCharacter; - // The call parameter's domain (§1.12.6), at the entry and not mid-loop. The range is `f32`'s - // and the arithmetic is the one that can decide it without rounding — see - // `CharacterError.InvalidDisplacement` for why `f32` cannot decide its own range. Exact zero - // passes: a displacement of nothing is a legal no-op. + // The call parameter's domain (§1.12.6), at the entry and not mid-loop. `f64` is not a choice + // made here: it is the arithmetic §1.12.6 requires, the one that can decide the `f32` range + // without rounding. // - // No reduction by the largest component is needed HERE, unlike everywhere else this norm is - // taken: the widening makes the square safe for every `f32`-origin input, and a square that - // overflows `f64` can only come from a component already outside the `f32` range, which is - // the same verdict by a shorter route. + // What IS decided here, and is written because it is nowhere else: no reduction by the + // largest component is needed at this one site, unlike everywhere else this norm is taken. + // The widening makes the square safe for every `f32`-origin input, and a square that + // overflows `f64` can only come from a component already outside the `f32` range — the same + // verdict by a shorter route. const displacement_limit: f64 = std.math.floatMax(f32); var displacement_norm_sq: f64 = 0; for (displacement.toArray()) |component| { From 30b900af03cbd9545f67df9985269a149fef4575 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 19:17:28 +0200 Subject: [PATCH 092/100] test(forge): the denormal case passes one input at both precisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit floatTrueMin(Real) is an f32 denormal in one build and an f64 denormal in the other, so the two legs compared two different vectors while the domain under test belongs to the public entry. floatTrueMin(f32) widened, and the underflow assertion conditional as in the HUGE case — the mechanism is live at f32 alone, and saying so beats a second literal that would make the f64 leg look like it tested the same thing. Third instance of the class in this file. P2-1 is not in this commit: measured exactly, its premise does not hold, and the measurement is reported first. --- src/modules/forge/forge_3d/tests/character_test.zig | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 8f641ed5..ba4882e2 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3921,8 +3921,15 @@ test "a DENORMAL displacement is served, not read as empty" { // **THE SLIDE ASKED `lengthSq() == 0` FOR EMPTINESS AND THE SQUARE UNDERFLOWS**, so a denormal // remainder read as nothing and the displacement was dropped. One reduction by the largest // absolute component answers emptiness, direction and distance together and has no such hole. - const tiny = std.math.floatTrueMin(Real); - try testing.expectEqual(@as(Real, 0), tiny * tiny); // the square really does underflow + // **`floatTrueMin(f32)` AND NOT `floatTrueMin(Real)` — one input at both precisions.** The domain + // under test is the PUBLIC entry's, so a `Real`-scoped literal hands the two builds two different + // vectors: an `f32` denormal in one and an `f64` denormal in the other. Third instance of that + // class in this file, after the reproducer and the HUGE case. + const tiny: Real = std.math.floatTrueMin(f32); + // The square underflows at `f32`; in `f64` that same value squares to an ordinary number, so the + // mechanism this guards is live at one precision and the assertion says which — as in the HUGE + // case, rather than a second literal that would make the other leg look like it tested the same. + if (Real == f32) try testing.expectEqual(@as(Real, 0), tiny * tiny); const r = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(tiny, 0, 0), 1.0 / 60.0); From 91d43416b38805a304d6ab27bd828c275f3b459c Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 20:06:27 +0200 Subject: [PATCH 093/100] docs(brief): an oracle judging a rounding must outrank that rounding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one method lesson this round produced, and the only external finding of the milestone that did not hold. A first oracle answered with hypot in f64 and carried exactly the rounding it was judging, so it agreed with the guard and reported nothing — which reads as "no defect" for the wrong reason. Rewritten over exact rationals against the true f32 overflow boundary, the answer inverted: the vector is IN domain and accepting it is correct, and the exact-component predicate proposed in its place is worse in both directions. P3 was already delivered in 30b900a: floatTrueMin(f32) widened, underflow assertion conditional as in the HUGE case. --- briefs/M1.1.12-character-controller.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/briefs/M1.1.12-character-controller.md b/briefs/M1.1.12-character-controller.md index 247fa196..dba3f2c8 100644 --- a/briefs/M1.1.12-character-controller.md +++ b/briefs/M1.1.12-character-controller.md @@ -2689,6 +2689,21 @@ cancelled at 9 m 28 and passing on rerun at 7 m 34 on the same commit. It does n ### What the milestone leaves as method +- **An oracle that judges a ROUNDING must be of an arithmetic that rounding cannot reach.** This is + the one that cost a round to learn, and it is what refuted the only external finding of the whole + milestone that did not hold. The claim was that a displacement of + `(floatMax(f32), 1e-9 · floatMax(f32), 0)` slips through the domain guard because the small + component's square vanishes in the `f64` sum. A first oracle answered with `hypot` in `f64` — and + carried EXACTLY the rounding it was judging, so it agreed with the guard and reported no + disagreement, which read as "no defect" for the wrong reason. Rewritten over exact rationals against + the true `f32` overflow boundary, `2^128 − 2^103 = floatMax(f32) · (1 + 2.98e-8)`, the answer + inverted: that vector's true norm is `floatMax(f32) · (1 + 5e-19)`, far below the boundary, so it + is IN domain and accepting it is correct. The exact-component predicate proposed in its place was + measured to be worse in both directions — it refuses every legitimate `(M, r · M, 0)` with + `r < 2.44e-4`, and it accepts out-of-domain vectors as soon as the largest component drops one ULP + below `M`. The guard has no false accept at all; its only divergence is a conservative band of + relative width `3e-8` at the very top of the `f32` range, which the spec then aligned to rather than + the code chasing an exact threshold nobody can reach. - **A disagreement between two sources is a defect, not an envelope.** Two envelope declarations were written before this one was read correctly. - **The probe table is structurally blind to a test that exercises no mechanism.** Its counter-measure From 37145e5e37c5cdbb49d3c5bae4cdb51f9a1ee6ad Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 21:53:30 +0200 Subject: [PATCH 094/100] docs(forge): the site says the bound the guard applies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both witnesses verified on the text: 1.12.6 carries "Une borne enoncable vaut mieux qu'une borne exacte quand l'ecart entre les deux n'a aucun habitant", with the true threshold 2^128 - 2^103 named and the band measured; the moveCharacter entry carries "norme AU PLUS floatMax(f32)". The site diverged on one real point and it was not a nuance: it said "a norm outside the f32 range", which is wider than the bound the guard applies and contradicts what the spec now establishes — the refused band is INSIDE the f32 range, every real below 2^128 - 2^103 rounding to a finite f32, and the bound is deliberately conservative. Replaced by the bound itself, and three further mentions of "the f32 range" and "representable" in the guard and the loop are re-expressed against the bound for the same reason. 495/495 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/modules/forge/forge_3d/character.zig | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 6f6062f1..334469fa 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -83,13 +83,15 @@ pub const CharacterError = error{ InvalidCollisionLayer, /// The handle is stale: its slot was freed, or its generation does not match. StaleCharacter, - /// `displacement` is out of domain: a non-finite component, or a norm outside the `f32` range. + /// `displacement` is out of domain: a non-finite component, or a norm greater than + /// `floatMax(f32)`. /// /// NORMATIVE in `engine-physics-forge.md` §1.12.6 and mirrored on the frozen entry in - /// `engine-tier-interfaces.md`: the domain, the fact that the rule fixes which domain applies and - /// NOT which arithmetic decides it, the measurement behind that distinction, and the two ends the - /// domain leaves served. Not restated here — a second formulation of a normative rule is a second - /// source, and this module spends its length refusing those. + /// `engine-tier-interfaces.md`: the bound and why it is deliberately CONSERVATIVE rather than the + /// exact overflow threshold, the arithmetic that decides it and why the rule fixes the domain and + /// not that arithmetic, and the two ends the domain leaves served. Not restated here — a second + /// formulation of a normative rule is a second source, and this module spends its length refusing + /// those. InvalidDisplacement, }; @@ -1499,14 +1501,14 @@ pub const CharacterStore = struct { ) !MoveResult { const idx = self.alloc.validate(id) orelse return error.StaleCharacter; // The call parameter's domain (§1.12.6), at the entry and not mid-loop. `f64` is not a choice - // made here: it is the arithmetic §1.12.6 requires, the one that can decide the `f32` range + // made here: it is the arithmetic §1.12.6 requires, the one that can decide that bound // without rounding. // // What IS decided here, and is written because it is nowhere else: no reduction by the // largest component is needed at this one site, unlike everywhere else this norm is taken. // The widening makes the square safe for every `f32`-origin input, and a square that - // overflows `f64` can only come from a component already outside the `f32` range — the same - // verdict by a shorter route. + // overflows `f64` can only come from a component already past the bound — the same verdict + // by a shorter route. const displacement_limit: f64 = std.math.floatMax(f32); var displacement_norm_sq: f64 = 0; for (displacement.toArray()) |component| { @@ -1583,7 +1585,7 @@ pub const CharacterStore = struct { // could disagree with the direction the same call is about to use. const step = remaining.unitAndLength() orelse break; const direction = step.unit; - // Representable by INVARIANT, not by hope: the entry refused an unrepresentable norm, and + // Within the bound by INVARIANT, not by hope: the entry refused a norm past it, and // `remaining` only ever shrinks from there — both slide forms are PROJECTIONS, which // cannot lengthen a vector. `.?` is that invariant, checked in Debug and ReleaseSafe. const distance = step.length.?; From 69c7e5ba70f90f59d71be0ec9ec7c531950940e3 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 22:31:58 +0200 Subject: [PATCH 095/100] docs(forge): the bound is on the norm as computed, not the true one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Witnesses verified on the text: 1.12.6 carries "norme CALCULEE au plus floatMax(f32)" with "et non la norme mathematique", and "Le domaine se declare donc sur ce qui est decidable"; the moveCharacter entry carries the same computed-versus-mathematical distinction. The "aucun habitant" phrasing has disappeared from both, counted. Two divergences at the site, and the second was not on the list. The summary line stated the bound as if it applied to the true norm — corrected to the computed one, with the arithmetic named. And the guard comment claimed that arithmetic "can decide that bound WITHOUT ROUNDING", which the new text explicitly refutes: no fixed-width arithmetic decides the mathematical form exactly, the squares of the widened components being exact while their sum is not. That sentence would have contradicted the spec it points at, so it is replaced rather than softened. No code change. 495/495 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/modules/forge/forge_3d/character.zig | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 334469fa..f715e035 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -83,13 +83,14 @@ pub const CharacterError = error{ InvalidCollisionLayer, /// The handle is stale: its slot was freed, or its generation does not match. StaleCharacter, - /// `displacement` is out of domain: a non-finite component, or a norm greater than - /// `floatMax(f32)`. + /// `displacement` is out of domain: a non-finite component, or a COMPUTED norm greater than + /// `floatMax(f32)` — computed in the evaluation arithmetic §1.12.6 fixes, and not the + /// mathematical norm. /// /// NORMATIVE in `engine-physics-forge.md` §1.12.6 and mirrored on the frozen entry in - /// `engine-tier-interfaces.md`: the bound and why it is deliberately CONSERVATIVE rather than the - /// exact overflow threshold, the arithmetic that decides it and why the rule fixes the domain and - /// not that arithmetic, and the two ends the domain leaves served. Not restated here — a second + /// `engine-tier-interfaces.md`: why no fixed-width arithmetic decides the mathematical form + /// exactly, why the domain is therefore declared on what is DECIDABLE, what that costs and what + /// it protects, and the two ends the domain leaves served. Not restated here — a second /// formulation of a normative rule is a second source, and this module spends its length refusing /// those. InvalidDisplacement, @@ -1501,8 +1502,8 @@ pub const CharacterStore = struct { ) !MoveResult { const idx = self.alloc.validate(id) orelse return error.StaleCharacter; // The call parameter's domain (§1.12.6), at the entry and not mid-loop. `f64` is not a choice - // made here: it is the arithmetic §1.12.6 requires, the one that can decide that bound - // without rounding. + // made here: it is the evaluation arithmetic §1.12.6 fixes, and the bound is on the norm as + // computed IN it — no fixed-width arithmetic decides the mathematical form exactly. // // What IS decided here, and is written because it is nowhere else: no reduction by the // largest component is needed at this one site, unlike everywhere else this norm is taken. From 0d3ef4bce6e209e3d565e54df6ce7fc1697c58d6 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 22:47:16 +0200 Subject: [PATCH 096/100] docs(forge): widening moves the rounding threshold, never removes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Absence verified as much as presence, which is what this round is about: 1.12.6 carries "Elargir deplace le seuil de l'arrondi, il ne le supprime pas", and "sans arrondi" now has ZERO occurrences in either spec — counted, not assumed. The repo is swept for the same residue and has none either. The site diverged by carrying its own formulation of what the source now states better: "no fixed-width arithmetic decides the mathematical form exactly" was a second statement of a normative fact. Replaced by the source's framing at the pointer, and the guard comment keeps only what the site owns — that f64 is the arithmetic 1.12.6 fixes, not a choice made locally. No code change. 495/495 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/modules/forge/forge_3d/character.zig | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index f715e035..ee566089 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -88,11 +88,10 @@ pub const CharacterError = error{ /// mathematical norm. /// /// NORMATIVE in `engine-physics-forge.md` §1.12.6 and mirrored on the frozen entry in - /// `engine-tier-interfaces.md`: why no fixed-width arithmetic decides the mathematical form - /// exactly, why the domain is therefore declared on what is DECIDABLE, what that costs and what - /// it protects, and the two ends the domain leaves served. Not restated here — a second - /// formulation of a normative rule is a second source, and this module spends its length refusing - /// those. + /// `engine-tier-interfaces.md`: why widening moves the rounding threshold without removing it, + /// why the domain is therefore declared on what is DECIDABLE, what that costs and what it + /// protects, and the two ends the domain leaves served. Not restated here — a second formulation + /// of a normative rule is a second source, and this module spends its length refusing those. InvalidDisplacement, }; @@ -1502,8 +1501,8 @@ pub const CharacterStore = struct { ) !MoveResult { const idx = self.alloc.validate(id) orelse return error.StaleCharacter; // The call parameter's domain (§1.12.6), at the entry and not mid-loop. `f64` is not a choice - // made here: it is the evaluation arithmetic §1.12.6 fixes, and the bound is on the norm as - // computed IN it — no fixed-width arithmetic decides the mathematical form exactly. + // made here: it is the evaluation arithmetic §1.12.6 FIXES, and the bound is on the norm as + // computed in it. // // What IS decided here, and is written because it is nowhere else: no reduction by the // largest component is needed at this one site, unlike everywhere else this norm is taken. From 893d5d8675e222b35d3c6f13e6de2970cf5a687e Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 9 Aug 2026 23:14:34 +0200 Subject: [PATCH 097/100] test(forge): the accepted side of the frontier is pinned too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Witnesses verified, presence and absence both. 1.12.6 carries "L'expression est NORMATIVE, parce qu'un domaine declare sur ce qui est calcule n'est defini que si le calcul l'est" and the transcribed predicate block; the moveCharacter entry carries "L'EXPRESSION est normative". And "sans arrondi" stays at zero in both specs and in src, checked rather than assumed — the residue check is now part of every spec alignment. The suite tested only the refused side, so a finer predicate — hypot, a wider arithmetic, a different summation order — would have refused (M, 1e-9*M, 0) and the suite would have called that an improvement. That vector is now asserted served. Its true norm is M * (1 + 5e-19), above floatMax(f32) and far below the real overflow threshold, so it is a deliberate consequence of the computed form. Its second component points DOWN, and the reason is written at the site: with it pointing up, the vertical part carries the pose to 3.4e29 and the run aborts in the BROADPHASE on the far-field limit this file already records. The sign leaves the norm, hence the verdict, untouched. The guard comment stops describing the expression now that 1.12.6 transcribes it, and says instead what only the site can: the expression IS the domain, so a line changed here changes what the engine accepts and changes 1.12.6 first. 495/495 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- src/modules/forge/forge_3d/character.zig | 14 +++++--------- .../forge/forge_3d/tests/character_test.zig | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index ee566089..9a9f0b6e 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -1500,15 +1500,11 @@ pub const CharacterStore = struct { dt: Real, ) !MoveResult { const idx = self.alloc.validate(id) orelse return error.StaleCharacter; - // The call parameter's domain (§1.12.6), at the entry and not mid-loop. `f64` is not a choice - // made here: it is the evaluation arithmetic §1.12.6 FIXES, and the bound is on the norm as - // computed in it. - // - // What IS decided here, and is written because it is nowhere else: no reduction by the - // largest component is needed at this one site, unlike everywhere else this norm is taken. - // The widening makes the square safe for every `f32`-origin input, and a square that - // overflows `f64` can only come from a component already past the bound — the same verdict - // by a shorter route. + // **THE EXPRESSION BELOW IS THE DOMAIN, not an implementation of it.** §1.12.6 transcribes + // it and makes it NORMATIVE — the widening, the summation order, the square root and the + // comparison — because a domain declared on what is COMPUTED is defined only if the + // computation is. A different reduction is a different domain, so anyone changing a line of + // it changes what the engine accepts, and changes §1.12.6 first. const displacement_limit: f64 = std.math.floatMax(f32); var displacement_norm_sq: f64 = 0; for (displacement.toArray()) |component| { diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index ba4882e2..4b9cab60 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3998,6 +3998,21 @@ test "a displacement whose NORM is not representable is REFUSED, not saturated" const edge_y: Real = @as(f32, 0.0002 * std.math.floatMax(f32)); try testing.expectError(error.InvalidDisplacement, chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(edge_x, edge_y, 0), 1.0 / 60.0)); + // **AND THE ACCEPTED SIDE OF THE SAME FRONTIER, which is what keeps the predicate pinned rather + // than merely bounded.** §1.12.6 declares both sides normative because the expression itself is: + // a finer form — `hypot`, a wider arithmetic, a different summation order — would refuse this + // vector, and a suite that only tested the refused side would call that an improvement. Its true + // norm is `M · (1 + 5e-19)`, above `floatMax(f32)` and far below the real overflow threshold + // `2^128 − 2^103`, so it is a deliberate consequence of the computed form and not an oversight. + // The second component points DOWN, into the floor, so both axes of the request are blocked and + // the pose stays near the origin — the norm, hence the verdict, is unchanged by the sign. Without + // that, the vertical component carries the character to `3.4e29` and the run aborts in the + // BROADPHASE, on the far-field limit this file already records: a node box that far out has + // `(min + max) · 0.5` overflowing. A different limit, and not the one under test. + const near_y: Real = @as(f32, 1e-9 * std.math.floatMax(f32)); + const served_edge = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(edge_x, -near_y, 0), 1.0 / 60.0); + for (served_edge.position.toArray()) |component| try testing.expect(std.math.isFinite(component)); + // A NON-FINITE component is the same class and the same answer. try testing.expectError(error.InvalidDisplacement, chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(std.math.inf(Real), 0, 0), 1.0 / 60.0)); From 9df4ad748ae0407fc96950da8cbf4aec65320596 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 10 Aug 2026 01:19:53 +0200 Subject: [PATCH 098/100] test(forge): width, order and reduction form are each pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Your arithmetic verified independently before writing. (M, 7e-9*M, 7e-9*M) is accepted in x,y,z order and REFUSED if y and z are summed first: each square alone falls under the half-ULP of M squared and is absorbed, their sum does not. At 1e-9 both orders agree and at 9e-9 both refuse, so it sits in the band where the absorption flips and nowhere else. THE HYPOT CLAIM IS PROVEN RATHER THAN REMOVED, and the case that proves it is new. You were right that the shipped vector does not distinguish hypot — both accept it, measured. But a band exists where they part: the ordered sum loses the term once a squared falls under 2^-53, while hypot only refuses once the true norm exceeds M by half an ULP OF M, so for a in [8e-9, 1.05e-8] the sum refuses and hypot accepts. (M, 9e-9*M, 0) is asserted refused. Your conditional said to keep the claim if such a case exists, so it is kept and locked; if you prefer the two-property framing, that one case reverts alone. The comment now names the three properties and the one case that moves for each, rather than listing mutations it does not lock. The order case needed a -Z wall, and the reason is at the site: its third component is otherwise unblocked and carries the pose to z = -2.4e30, where the run aborts on the broadphase far-field limit — a different limit from the one under test, and the same isolation the refused side already uses. 495/495 green at f32 and -Dphysics_f64=true, Debug and ReleaseSafe. --- .../forge/forge_3d/tests/character_test.zig | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 4b9cab60..5d43cb1c 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3998,12 +3998,23 @@ test "a displacement whose NORM is not representable is REFUSED, not saturated" const edge_y: Real = @as(f32, 0.0002 * std.math.floatMax(f32)); try testing.expectError(error.InvalidDisplacement, chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(edge_x, edge_y, 0), 1.0 / 60.0)); - // **AND THE ACCEPTED SIDE OF THE SAME FRONTIER, which is what keeps the predicate pinned rather - // than merely bounded.** §1.12.6 declares both sides normative because the expression itself is: - // a finer form — `hypot`, a wider arithmetic, a different summation order — would refuse this - // vector, and a suite that only tested the refused side would call that an improvement. Its true - // norm is `M · (1 + 5e-19)`, above `floatMax(f32)` and far below the real overflow threshold - // `2^128 − 2^103`, so it is a deliberate consequence of the computed form and not an oversight. + // **AND THE ACCEPTED SIDE OF THE SAME FRONTIER, which is what pins the predicate rather than + // merely bounding it.** §1.12.6 makes the EXPRESSION normative, so the suite has to lock the + // expression and not just the verdict. Three properties of it are locked, each by the one case + // that moves when that property changes, and each was MEASURED to move rather than assumed to: + // + // * WIDTH — this vector. Its true norm is `M · (1 + 5e-19)`, above `floatMax(f32)` and far + // below the real overflow threshold `2^128 − 2^103`; `f64` absorbs the excess and accepts, + // an arithmetic wider than `f64` would resolve it and refuse. + // * ORDER — `(M, 7e-9·M, 7e-9·M)` below. Each square alone falls under the half-ULP of `M²` + // and is absorbed, but their SUM does not: `x, y, z` accepts and `y, z` first refuses. At + // `1e-9` the two orders agree and at `9e-9` both refuse, so the case sits in the band where + // the absorption flips and nowhere else. + // * REDUCTION FORM — `(M, 9e-9·M, 0)` below. The ordered sum refuses it and `hypot` accepts: + // the two have different thresholds, the sum losing the term at `a² < 2^-53` and `hypot` + // only once the true norm exceeds `M` by half an ULP OF `M`. The band where they disagree + // is `a ∈ [8e-9, 1.05e-8]`, measured; below and above it they agree, which is why the two + // cases above cannot stand in for this one. // The second component points DOWN, into the floor, so both axes of the request are blocked and // the pose stays near the origin — the norm, hence the verdict, is unchanged by the sign. Without // that, the vertical component carries the character to `3.4e29` and the run aborts in the @@ -4013,6 +4024,20 @@ test "a displacement whose NORM is not representable is REFUSED, not saturated" const served_edge = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(edge_x, -near_y, 0), 1.0 / 60.0); for (served_edge.position.toArray()) |component| try testing.expect(std.math.isFinite(component)); + // ORDER. The vertical component points DOWN into the floor for the same reason as above, and the + // scene gains a `−Z` wall for the third one — the norm, hence the verdict, does not depend on + // either sign, and every axis of the request has to be blocked or the pose leaves the region the + // broadphase can hold. Without the wall the run aborts at `z = −2.4e30`, on the far-field limit + // this file already records, which is a different limit from the one under test. + _ = try addBox(gpa, &world, av(4, 4, 0.5), av(0, 0, -3), 966); + const ord: Real = @as(f32, 7e-9 * std.math.floatMax(f32)); + const served_order = try chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(edge_x, -ord, -ord), 1.0 / 60.0); + for (served_order.position.toArray()) |component| try testing.expect(std.math.isFinite(component)); + + // REDUCTION FORM. Refused by the ordered sum, accepted by `hypot`. + const red: Real = @as(f32, 9e-9 * std.math.floatMax(f32)); + try testing.expectError(error.InvalidDisplacement, chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(edge_x, red, 0), 1.0 / 60.0)); + // A NON-FINITE component is the same class and the same answer. try testing.expectError(error.InvalidDisplacement, chars.moveCharacter(gpa, &world.bp, &world.bm, &world.store, id, v(std.math.inf(Real), 0, 0), 1.0 / 60.0)); From 665f301d1f3a4550c47101fe3c2863c0b7c9e083 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 10 Aug 2026 08:16:57 +0200 Subject: [PATCH 099/100] test(forge): no value and no state on the residual cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two assertions removed. One pinned 0.506016400 at 90 deg / base 0.05 and was refuted by ubuntu-24.04 / Debug reading 1.6999805; one pinned the f64 cell frozen at exactly zero and was never contradicted only because NO CI corner runs -Dphysics_f64 — verified absent from .github/ by grep, so its silence was not a confirmation. Both were measurements of one machine dressed as properties of a quantity the consignation itself calls decided by rounding. The surviving property lost its Real == f32 guard, was RUN unguarded, and the answer is that it fails at f64: yaw 90 / base 0 returns -0e0 here. So the scope is now written as a measured FACT rather than carried by a condition — the claim is made at f32, where four platform-and-mode combinations exercise it, and the f64 failure is recorded in CLAUDE.md instead of hidden. The two sides do not have the same standing and the comment says which. CLAUDE.md gains what the red taught: the residue varies by ARCHITECTURE and by COMPILATION MODE and not only by precision, which is one more constraint on its unnamed cause. And a sixth open decision, because the inventory turned it up: the f64 leg has never run anywhere but one machine, so every f64 claim of this milestone rests on a single sample — not charged to M1.1.12, owner named as the next milestone touching CI. 495/495 green at all four local corners, lint 0. The CI matrix is the only instrument for this class and it is read next. --- CLAUDE.md | 3 +- .../forge/forge_3d/tests/character_test.zig | 54 +++++++++---------- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6b4099b0..0ea036f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,8 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **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. -- **Residual yaw dependence in an insoluble squeeze (opened at M1.1.12)**: when a character is taller than the space that holds it, the distance served depends on the scene's YAW — two partial cells of twenty-eight at f32, one partial and one exact freeze at f64. The CAUSE is named and measured: a face normal transported through a 90° yaw by a quaternion is not exactly perpendicular to a horizontal direction — it carries a residue of `3.3·floatEps` — so an exact `n · d >= 0` test admits a floor that cannot geometrically oppose horizontal travel, the iteration is spent, `slideAlongPlane` injects a vertical residue that drags the ceiling into the same filter, and two near-antiparallel normals send `slideAlongCrease` onto a cross product whose direction is pure noise. A noise band on `n · d` was written, measured to close all twenty-eight cells at both precisions, and REVOKED: it opened a tunnelling window on the DEFAULT path, penetration being `distance × |n·d|` and measured at `3.05e-5` over 32 m, growing without bound with distance. No band on that quantity can work, and the demonstration is algebraic rather than empirical: the transport residue tracks `floatEps(Real)` while a real grazing incidence is GEOMETRIC and does not, so they separate by nine orders at f64 and by a factor of 2.4 at f32 — the inseparability is structurally an f32 phenomenon. The length-dimensioned form escapes nothing: `coordScale ≈ d` makes it the dimensionless test with both sides multiplied by the distance. What remains open is upstream of the predicate: whether a path exists that does not DEGRADE the face normal of an axis-aligned quad under a yaw, rather than tolerating the degradation afterwards. Owner: the next milestone that opens `character.zig`; M1.1.14 is the likeliest, rotation invariance being a sibling of the determinism it owns. +- **Residual yaw dependence in an insoluble squeeze (opened at M1.1.12)**: when a character is taller than the space that holds it, the distance served depends on the scene's YAW — two partial cells of twenty-eight at f32, one partial and one exact freeze at f64. The CAUSE is named and measured: a face normal transported through a 90° yaw by a quaternion is not exactly perpendicular to a horizontal direction — it carries a residue of `3.3·floatEps` — so an exact `n · d >= 0` test admits a floor that cannot geometrically oppose horizontal travel, the iteration is spent, `slideAlongPlane` injects a vertical residue that drags the ceiling into the same filter, and two near-antiparallel normals send `slideAlongCrease` onto a cross product whose direction is pure noise. A noise band on `n · d` was written, measured to close all twenty-eight cells at both precisions, and REVOKED: it opened a tunnelling window on the DEFAULT path, penetration being `distance × |n·d|` and measured at `3.05e-5` over 32 m, growing without bound with distance. No band on that quantity can work, and the demonstration is algebraic rather than empirical: the transport residue tracks `floatEps(Real)` while a real grazing incidence is GEOMETRIC and does not, so they separate by nine orders at f64 and by a factor of 2.4 at f32 — the inseparability is structurally an f32 phenomenon. The length-dimensioned form escapes nothing: `coordScale ≈ d` makes it the dimensionless test with both sides multiplied by the distance. **And the residue varies by ARCHITECTURE and by COMPILATION MODE, not only by precision** — measured when `ubuntu-24.04 / Debug` refused a pinned cell: the `90° / base 0.05` cell reads `0.506016400` on arm64 macOS and `1.6999805` on x86-64 Debug, while `ubuntu-24.04 / ReleaseSafe` converges too. That is one more constraint on the unnamed cause and better than what preceded it. Two assertions that pinned a residual cell's VALUE and its frozen STATE were removed for that reason; what is asserted is the property the entry claims — no cell freezes — at `f32`, where four platform-and-mode combinations exercise it. At `f64` that property is FALSE on the only target that runs `f64`, `yaw 90 / base 0` returning `−0e0`. What remains open is upstream of the predicate: whether a path exists that does not DEGRADE the face normal of an axis-aligned quad under a yaw, rather than tolerating the degradation afterwards. Owner: the next milestone that opens `character.zig`; M1.1.14 is the likeliest, rotation invariance being a sibling of the determinism it owns. +- **The `f64` leg has never run anywhere but one machine (opened at M1.1.12)**: the CI matrix is `{ubuntu-24.04, windows-2025} × {Debug, ReleaseSafe}` at the DEFAULT scalar, and `-Dphysics_f64` appears NOWHERE in `.github/` — verified by grep, not assumed. So every claim this milestone and its predecessors make at `f64` — the five per-precision pins, the tolerance class, the freezes — rests on one target, one architecture and one compiler, and its greenness is the greenness of a single sample. The standing is not symmetric with `f32`, which four platform-and-mode combinations exercise, and the asymmetry is measurable: BOTH instances of "a measurement pinned as a property" found in M1.1.12 were caught by a DIFFERENT architecture on the only leg CI exercises, while the one assertion that pinned an `f64` state was never contradicted because nothing could contradict it. This is not a defect of M1.1.12 and is not charged to it. Owner: the next milestone that touches CI; M1.1.14 is the natural candidate, cross-platform determinism being its subject. ## Non-negotiable rules diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 5d43cb1c..e88309c5 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -3508,40 +3508,34 @@ test "the mesh scene at six yaws: the wall blocks, and the squeeze residue is PI // anything. A bound and not `!= 0` on purpose: a dribble of `1e-9` is a freeze in every // sense that matters, and `!= 0` would accept it. // - // f32 ONLY, and the gate is the measurement's: at f64 one cell of the twenty-eight - // still freezes (90°, base 0), so the same assertion there would be false. Claiming it - // at both precisions is exactly what this suite has been caught doing. + // **THE SCOPE IS A MEASURED FACT AND NOT A CONVENIENCE GUARD, and the difference is + // the whole point of the round that produced this line.** The property was unguarded + // and RUN: it holds at `f32` and is FALSE at `f64`, where `yaw 90 / base 0` returns + // `−0e0` — a character that never moves again. So the claim is made exactly where it + // has been observed, and the `f64` failure is recorded in `CLAUDE.md` rather than + // hidden behind the condition that used to carry it. // - // **The internal-edge correction closed one of the three residual cells and not the - // other two.** The character's cast path called `collideOrdered` directly and bypassed - // the consumer of the active-edge flags that the CONTACT path has used since - // M1.1.11.1, so a capsule deep under a flat quad received the normal of the quad's - // internal diagonal — horizontal, and opposite on the two triangles sharing it. With - // the correction branched in, f32 reads 26 of 28 converged, the two left being 0.506016 - // at 90°/0.05 and 0.459151 at 315°/0.05 — both at the LIFTED base, where the closed one - // was the tangent base. At f64 nothing moved at all, the correction's noise gate being - // 2^29 tighter there. So the residue has a further cause again, and no common value is - // pinned. + // What the two sides rest on is NOT symmetric, and that asymmetry is the reason to + // write the scope down: at `f32` the property is exercised by four platform-and-mode + // combinations — arm64 macOS, `ubuntu-24.04` in Debug and in ReleaseSafe, and + // `windows-2025` — while at `f64` it is exercised by ONE, because no CI corner runs + // `-Dphysics_f64=true`, verified absent from `.github/`. The `f64` freeze is therefore + // a measurement on a single target, which is precisely the standing this suite has + // twice mistaken for a property. if (Real == f32) try testing.expect(along > 0.1); - // **AND THE RESIDUAL CELLS ARE PINNED TO THEIR VALUES RATHER THAN SUFFERED.** A noise - // band on the opposing test closed all twenty-eight cells at both precisions and was - // REVOKED — see `opposes`, which carries the algebra: it opened a tunnelling window on - // the DEFAULT path worth `distance × |n · d|`, measured `3.05e-5` over 32 m and - // unbounded in the distance, and no band on that quantity can separate a transport - // residue that tracks `floatEps(Real)` from a grazing incidence that is geometric. + // **NOTHING IS ASSERTED ABOUT THE RESIDUAL CELLS' VALUE OR STATE, and two assertions + // were removed to make that true.** One pinned `0.506016400` at 90°/0.05 and one pinned + // the `f64` cell frozen at exactly zero. Both were measurements of THIS machine dressed + // as properties, and a quantity the consignation itself describes as decided by + // rounding is portable across neither instruction selection nor codegen mode: the first + // was refuted by `ubuntu-24.04 / Debug` reading `1.6999805` where arm64 read `0.506`, + // and the second was never contradicted only because NO CI corner runs + // `-Dphysics_f64=true` — verified absent from `.github/`, so its silence was not a + // confirmation. // - // So the residue is permanent until the cause is closed upstream, and pinning it is - // what keeps this test honest: `> 0.1` above accepts 0.506 as readily as 1.700, and - // NOTHING at all watches the f64 cell, which is an exact zero — a character that never - // moves again. These two assertions are also the pin of the REVOCATION itself: against - // `a2e7493`, where the band made all twenty-eight converge, they fail. - if (yaw == 90) { - if (Real == f32 and y0 == 0.05) { - try testing.expectApproxEqAbs(@as(Real, 0.506016400), along, api_tol); - } - if (Real == f64 and y0 == 0) try testing.expect(along == 0); - } + // What survives is the property above, which is what the consignation actually claims + // and what breaks if the freeze class returns. // **AND THE INTERNAL-EDGE CELL IS PINNED TO ITS VALUE, because the magnitude bound // above does NOT discriminate it.** That cell read 0.459 before the correction was From 1c291575908f69b16383f9adf53306715f034984 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 10 Aug 2026 09:36:40 +0200 Subject: [PATCH 100/100] docs(claude-md): refresh the M1.1.12 tag row before merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row dated from 2026-08-05 and predated twenty rounds of closure: it named neither castShapeBody's contract table nor the displacement domain, nor the four tunnelling defects, nor the methods the milestone leaves. Replaced whole, and the footer date follows. One formatting necessity, not a content change: the absolute-value bars in `distance × |n·d|` are escaped as `\|`, since a bare bar would end the table cell — the same escaping the earlier rows use for `\|omega\|` and `\|Delta pos\|`. --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0ea036f5..d911a727 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +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. **AND THE LAST ROUND CLOSED FOUR DEFECTS WITH ONE CHANGE, AFTER THE CAUSE HAD BEEN CONSIGNED TWICE AS A LIMIT TO DECLARE RATHER THAN TREATED AS A DEFECT.** The mesh wall tunnelling, the f64 mesh freeze, the 500 m collider stall and the 1 km collider stall were four faces of ONE cast/manifold disagreement: the sweep reports a hit at distance zero that the manifold denies, and the slide then took its documented "stop rather than guess a direction" exit on a contact that did not exist. Two envelope declarations had been written to dress that up, one of them into the spec, before it was read as a defect — **two answers to one question is a defect and never a stable state**, and that lesson outlives this milestone. Closed by filtering non-opposing contacts DURING SELECTION rather than ignoring them after it: each arm tests a normal that is actually the surface's — the mesh its `faceNormal`, already at hand for the back-face test and precisely that test's boundary case `n · d == 0`; the half-space its stored plane transported; the convex the cast's own normal at `d > 0` and the MANIFOLD's at `d == 0`. That last split is the trace's and not a design choice: at zero the cast's outward direction separates the CORES, so in a squeeze it returns a minimal-translation direction of the polytope — measured at `(−0.062267, 0.996115, −0.062267)`, tilted 3.6° and symmetric in X and Z, a simplex direction's signature — where the manifold on the same contact says `(0, −1, 0)` exactly; an earlier measurement at 1 mm of overlap gave the face normal and did NOT transport to a squeeze. **The fix REMOVES more than it adds — 201 deletions against 200 insertions across the Zig** — and four structures disappear with it: `IgnoredSet`, its tessellation ceiling, the retry budget and the direction expiry, all of them scaffolding for filtering at the wrong level. Three earlier forms had filtered AFTER selection, by body, then by pair, then by a bounded set, and each left a hole elsewhere because the entry returns ONE hit and discarding it discards every sub-shape it never returned. No threshold was needed anywhere: the remedy was a correct INPUT, not a tolerance band. Residual, named and not dissolved: a SUBNORMAL padding against a 1 km collider stops serving on the third call at f32 only, one cell of a six-cell grid. **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. | +| `v0.11.12-character-controller` | 2026-08-09 | 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. Normative spec authored for it: `engine-physics-forge.md` §1.12, appended so nothing renumbers, §9 rewritten as the calling surface, and §1.12.6 rewritten four times under measurement. `engine-tier-interfaces.md` 0.6 → 0.8. `engine-movement.md` loses its ground raycast 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, but it carries a broadphase PRESENCE — mandatory on `PhysicsModule`, optional per character, defaulting to ON. The argument is internal to the frozen surface: `CharacterDescriptor` has carried `collision_layer` since its original version, and in Weld the object layer is HOW an object declares itself visible to other callers, so either the character has a presence or that field has no observable effect. An earlier justification derived from a validation criterion was RETRACTED: criteria MEASURE whether the engine arrived somewhere and are not design inputs. Six entries added to the frozen surface in the last window there was, plus an error channel on `moveCharacter`, plus `BodyDescriptor.can_sleep`, plus `setBodyTransform` declared a TELEPORTATION. `PackedId.dead` reserves the all-ones no-handle pattern: `ground_body` defaulted to `0`, a LIVE handle to slot 0, so no bit pattern of that field meant absence. Position is the BASE of the capsule and never the centre, the offset living in exactly one named place. Ground determination is a BOUNDED DOWNWARD SWEEP and not manifolds at the current pose, a resting character standing `padding` ABOVE its floor. THE SLIDE IS CONSTRAINED BY SLOPE, capped at `max(up_before, 0)` and not at `up_before`, which would drive INTO the plane on an inclined face. DEPENETRATION PUSHES OUT AND NEVER THROUGH, on the BASE and not the centre. FOUR TUNNELLING DEFECTS WERE CLOSED AND EACH WAS FOUND BY EXTERNAL REVIEW: a mesh wall traversable because a per-CONTACT verdict excluded a whole BODY; a noise band on `n · d` that closed all twenty-eight squeeze cells and opened a window on the DEFAULT path, penetration being `distance × \|n·d\|` and unbounded in distance — no band on that quantity can work, the transport residue tracking `floatEps(Real)` while a grazing incidence is GEOMETRIC; a face normal used where a triangle is FINITE and reachable by its edge; and a saturated bound that left the tail of a displacement unswept. `castShapeBody` had a contract PER SHAPE CLASS and per call path — direction domain, normal frame, behaviour at `d = 0` — measured as an eighteen-cell table, uniformised by conditioning once before dispatch, and now PINNED by a parameterised test so the next divergence breaks that test and not a character scene three milestones later. The DISPLACEMENT gained a domain, the first CALL PARAMETER of the module to have one: the domain table had tabulated descriptor FIELDS and never call parameters. It is the `f32` public range, evaluated in a fixed wider arithmetic whose EXPRESSION is normative — widening is exact, squares summed in `x`, `y`, `z` order, `sqrt`, `<=` — because a domain declared on what is COMPUTED is only defined if the computation is. Both sides of the boundary are pinned, and three cases lock width, order and reduction form independently. Green at f32 AND `-Dphysics_f64=true`, Debug AND ReleaseSafe: 419 → 495 tests. FIVE METHODS THIS MILESTONE LEAVES BEHIND, each having cost a round: two sources answering differently about the same geometric fact are a DEFECT and never an envelope; one test, one verdict, because a mutation probe on a multi-case body proves that AT LEAST ONE case discriminates and never that each does; an oracle judging a ROUNDING must be of an arithmetic that rounding does not reach; an ABSENCE witness is worth a presence witness, a formulation corrected upstream leaving its earlier version downstream; and on a floating boundary an intuition is not a weak hypothesis but a FALSE one until measured — every estimate made on this milestone without calculating was refuted, on both sides of the review. A LAST RED, on `ubuntu-24.04 / Debug` alone, refuted a pinned VALUE on a residual cell and exposed something larger: the CI matrix carries NO `-Dphysics_f64=true` corner, so every f64 claim of this milestone rests on a SINGLE machine. Both instances of a measurement pinned as a property were found by a different ARCHITECTURE on the only leg CI exercises, while the one assertion pinning an f64 state was never contradicted because nothing could contradict it. Recorded as a sixth open decision, not charged to this milestone. Out (later, NOT debt): the Etch surface of the controller (M1.1.15); the ECS `VirtualCharacter` component (M1.1.15); `moveKinematic`'s body (M1.1.15); the residual yaw dependence of an insoluble squeeze, cause partly named and bounded by measurement, varying by architecture and by build mode as well as by precision; `CharacterMoveResult2D.collisions` and the 2D character symmetry (M1.8.x). | ### Hotfixes (untagged) @@ -334,4 +334,4 @@ line, and never on a `tail`. --- -Last updated: 2026-08-08 +Last updated: 2026-08-09