diff --git a/CLAUDE.md b/CLAUDE.md index 68808d6a..65dc229d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,13 +10,13 @@ knowledge base — see § Quick links spec. | Field | Value | |---|---| | Phase | 1 (Etch ↔ ECS) | -| Current milestone | M1.1.14 — Cross-platform determinism of `forge_3d` — PR #71 open (draft), NOT closed. An external review found **six defects, five of them the milestone's own dominant family**: an artefact rendering a verdict on something other than what it claims to measure, and answering green. All six are corrected (P1-4 regeneration, P1-3 the ten float-env sites, P1-1 the scenario's absent relief and unobserved character, P1-2 the cosine bit table, P1-5 the fourth trace's non-vacuity, P2-6 an unmeasurable window reading as a match) and the witnesses are RE-BASELINED. M1.1.13.1 is CLOSED, squash-merged to `main` (tag `v0.11.13-solver-tgs-soft`). | -| Last released tag | `v0.11.13-solver-tgs-soft` (posted by Guy after merge) | -| Active branch | `phase-1/forge/determinism` (PR #71 open, draft, not merged) | -| Next planned milestone | M1.1.15 — `step()` / `PhysicsWorld` / `PhysicsModule` freeze, ECS `Transform` sync, the `f32` → `Real` widening of the public surface as ONE grouped decision, and the typed-bus → Etch `EventStore` bridge that M1.1.13's sensor deltas wait on. M1.1.0–M1.1.13.1 CLOSED; M1.1.14 code-complete. **Determinism is now an INSTRUMENT the plan depends on**: M1.1.25 replays `zig build forge-determinism` at N workers and M1.A replays it on a rebuilt scheduler DAG, both at either precision. | +| Current milestone | M1.1.15 — `forge_3d` orchestration: `PhysicsWorld`, the tick cycle, and the solver → ECS publication — PR #72, ready for review. Delivers the eleven-step cycle as production code, proxy lifetime including character presences, wake + write composition with cause W4, `moveKinematic`, the `Sleeping` marker, ONE elected publisher per entity, ONE registered system (the tick with its publication, in `fixed_update`), ONE named precision crossing replacing four diverged helpers, and `src/interfaces/PhysicsModule.zig` created UNFROZEN. **The ECS → solver direction is M1.1.26's**, with the Tier 1 Etch service: the tick says WHEN a write happened and never WHO produced it, and the service is the first place that sees both the component path and the API path. **The freeze is M1.1.26 too** — surface guards and `WELD_PHYSICS_PROTOCOL_VERSION` are its business, and the interface file asserts their absence so no silence reads as a freeze already taken. | +| Last released tag | `v0.11.14-determinism` (posted by Guy after merge of PR #71, 2026-08-20) | +| Active branch | `phase-1/forge/physics-world-orchestration` (PR #72, ready for review, not merged) | +| Next planned milestone | M1.1.16 — real joints (M1.1.16–24 cover joints, advanced shapes, the vehicle constraint and save/restore; M1.1.25 is per-island parallel resolution). **M1.1.26 is the FREEZE**, and it carries two preconditions this milestone measured: `ModuleContext` is declared NOWHERE in the repository while `engine-tier-interfaces.md` §1 types `init` with it, and the frozen pose setters cannot stay `void` now that bodies carry broadphase proxies. M1.1.0–M1.1.14 CLOSED. **Determinism stays an INSTRUMENT the plan depends on**: M1.1.25 replays `zig build forge-determinism` at N workers and M1.A replays it on a rebuilt scheduler DAG, both at either precision. | | CI matrix | `{ubuntu-24.04, windows-2025, ubuntu-24.04-arm} × {Debug, ReleaseSafe} × {f32, f64}` — **12 cells**, every one pinned `-Dcpu=baseline` (`ARCH-031` rule 6, third axis). `zig build lint` and `zig build forge-determinism` both run on the cell path; before M1.1.14 the first ran in NO workflow and the second in none either. Cache restored to every cell, keyed by os · mode · precision · cpu · zig version · zon hash · sha, with an all-or-nothing size guard on BOTH save steps. | | Determinism instrument | `zig build forge-determinism` — canonical scenario, 1000 frames, one worker, no RNG, **NINE elements** since the review: the eighth and ninth are a kinematic character on a riser and three mesh ramps forming a closed bowl, plus a lone box that sleeps inside the compared window, whose surface cosines bracket `cos(max_slope)` on both sides so a wrong cosine costs METRES of trajectory. **Eight witnesses committed** under `src/modules/forge/forge_3d/tests/determinism/witnesses/` with `SHA256SUMS.txt` and a `PROVENANCE.txt` carrying run URL, cell, CPU pinning, PR-head sha, cross-mode result, the REPORTED `zig version`, and a per-file generator mode. Regeneration is gated on a `Witness-regen:` trailer in the PR head commit. **Replayed by M1.1.25 at N workers and by M1.A on a rebuilt DAG** — it is an instrument, not a test. | -| Test floor | **Per platform, never absolute.** `forge_3d` reconciles exactly everywhere — 557 collected against 557 source blocks — and it is THE oracle. The repository total is not: measured at M1.1.14 after the review, `ubuntu-24.04` and macOS collect 1869, `windows-2025` 1867, the difference being `shm_posix.zig` + `transport_posix.zig`. The `dead-tests` guard is ACTIVE on `zig build lint` and on the `pre-commit` hook, and its bilateral control — closure minus a DECLARED uncollected list against the suite's own collected total — reconciles on all three platforms. | +| Test floor | **Per platform, never absolute, and re-derived FROM THE SUITE at every gate — never from the closure's own arithmetic**, which the `dead-tests` guard refused once at M1.1.15 in exactly those words. Measured at M1.1.15 close: `ubuntu-24.04` and macOS collect **1906**, `windows-2025` **1904**, the difference being `shm_posix.zig` + `transport_posix.zig`. `forge_3d` reconciles exactly everywhere — **571** — and it is THE oracle. The guard is ACTIVE on `zig build lint` and on the `pre-commit` hook. | ## Tags @@ -76,6 +76,7 @@ knowledge base — see § Quick links spec. | `v0.11.13-sensors-triggers` | 2026-08-11 | M1.1.13 — Forge 3D: sensors and triggers | Fifteenth M1.1 sub-milestone, and the one that closes a chain open since the corpus's first physics draft: `TriggerEnter` / `TriggerExit` were declared and `CollisionShape.is_trigger` specified, while `BroadphaseLayer.trigger` had NO producer and NO consumer — exactly where `BodyType.kinematic` stood before M1.1.12. Delivers the LOWER HALF by design: the engine produces an overlap STATE and two DELTAS, and the translation into typed events on the Tier 0 bus is M1.1.15's, because `forge_3d` depends only on `foundation/math` and `forge/api/` (a C1.1 exit metric) while the bus lives on `World`. **The state and not the event stream is the source of truth**: the Tier 0 bus drops its oldest entry on saturation, so an ownership set rebuilt from the flow would be wrong on the first saturation. Normative model: `engine-physics-solver.md` §1.13, twelve subsections, with §1.13.6 REVISED mid-milestone to lift this milestone's single design blocker. `engine-tier-interfaces.md` 0.8 → 0.9, §12 count unchanged at 27 — **no interface function was added, and that is the design**. TWO FIELDS ON `BodyDescriptor` IN THE SECOND-TO-LAST WINDOW: `is_trigger` and `trigger_layer_mask`, transcribed field for field, name for name, default for default; after the M1.1.15 freeze neither could land at all and nothing would let a caller declare a trigger. THE ROLE IS A PROPERTY OF THE INSTANCE, never of the geometry — the shape store is shared, so the field on `ShapeDescriptor` would force two bodies sharing a sphere to share their nature; pinned as an ABSENCE over the union's five payload variants, with the visit count asserted so a removed variant cannot shrink the check in silence. `CollisionShape` gains the authoring mask, 48 → 52 bytes, and THE THREE OFFSETS THAT DECIDE THE 52 ARE PINNED — `collision_layer` 44, `is_trigger` 45, the `u32` 48 — in the comptime block AND in the test that doubles it, because size alone cannot catch a different arrangement landing on the same size; measured by PERMUTING two adjacent one-byte fields, where the size and align pins pass and the offset pins fall. A first version of that comment stated two false facts about the padding and was rewritten to say no more than the pins establish. BROAD CLASS: `broadLayerFor(is_trigger, body_type)`, DERIVED and never stored, role first then `static` to `static` and EVERYTHING ELSE to `dynamic` — a kinematic body lands in `dynamic` deliberately, the class naming what MOVES and not what is SIMULATED. **THE `trigger` ROW AND COLUMN OF `default_layer_pairs` GO TO `false` IN FULL, REVISING AN M1.1.1 DECISION** that set `dynamic × trigger` to `true` and asserted it positively: killing the pair at the source is less work than filtering it downstream every tick and a stronger guarantee, since a trigger that never reaches step 4 cannot be forgotten by a downstream filter. The M1.1.1 assertion is DELETED, not commented, and replaced by the absence on the same scene plus a direct read of the constant in both index orders and a full symmetry check. It follows that THE SENSOR PASS CANNOT CONSULT THAT MATRIX — it reads `false` everywhere — and detection is filtered by the trigger's own UNILATERAL object-layer mask instead: the matrix governs the RESPONSE absolutely, the mask governs what is SEEN, and the two never substitute (§1.13.2). THE PASS DOES NOT REUSE `computePairs`, AND THAT IS AN IMPOSSIBILITY RATHER THAN A PREFERENCE: pair generation is moved-driven, so it returns a DELTA and never a snapshot, and two motionless bodies overlapping for a hundred ticks do not appear in it. `pipeline/sensor.zig` enumerates the TRIGGER proxies and descends per trigger; `Broadphase` gains `forEachInLayer` and `queryHalfSpace` and `Bvh` gains `forEachLeaf`, there having been no way to enumerate one layer's proxies at all — the second having first been `queryHalfSpaceTrees`, named for its OMISSION because the domain bound excluded the unbounded lists, and the bound falling it is the OMISSION that went and not merely the name: it visits trees AND lists, symmetric with `queryAabb`. **§1.13.6's TWO NEW RULES**, which lifted the blocker: the PROBE IS FIXED BY A RULE and never by availability — trigger when convex, candidate otherwise, ALWAYS the trigger when both are, because the boolean is symmetric in exact arithmetic but nothing guarantees bit equality of the two orders in float and M1.1.14 must VERIFY that order rather than establish it; and THE SENSOR ROLE IS REFUSED ON A MESH by typed error at creation (`error.TriggerShapeMustBeSolid`), which is GEOMETRY and not an implementation limit: a `MeshShape` is a SURFACE and not a solid (§1.11.17), so membership is false everywhere on it — a sensor answers « who is inside » and a surface has no inside. THE HALF-SPACE KEEPS THE ROLE, being a volume with a well-defined interior: it is the kill plane under the level. A DOMAIN BOUND excluding {half-space, mesh} × {half-space, mesh} stood through two closing reviews and was RETRACTED at the second: it grouped the two by BODY TYPE where the question is whether the shape has an INTERIOR, and it was justified by a second false claim — that two statics' overlap cannot vary, when a static body is movable by pose write and this module treats that case explicitly. With mesh triggers refused at the source, the only cell that was a real piece of work — mesh × mesh — is unreachable, and the two remaining kernels are written and analytic: two half-spaces meet unless their normals are exactly opposite with disjoint boundaries, and a surface meets a half-space iff one of the vertices REFERENCED BY ITS TRIANGLES does, a triangle being the convex hull of its three — the stored array is NOT that set, `MeshData` validating the index bound and never the converse, so an unreferenced vertex inside the solid made a surface wholly outside it answer overlap. NO FALSE NEGATIVE REMAINS. That bound was DECORATIVE when first written, an `orelse return` below it returning the same answer, and now unwraps the candidate probe so its removal fires. THE OBSERVABLE STATE IS A SET OF ORIENTED ENTITY PAIRS, never body handles: AGGREGATION IS THE DEDUP (several body overlaps between two entities collapse to one pair, so the exit fires when the LAST disappears, with no special case), reflexive pairs are dropped, the pair is oriented from the trigger so two mutually detecting triggers produce TWO pairs, and the set is REBUILT IN FULL every tick — which is what makes body-handle recycling harmless, a property of the reconstruction and not of the type. Two deltas and no third list: §1.13.12 refuses `TriggerStay` where both corpora carry a `CollisionStay`. Sorted by `(trigger_entity, other_entity)` on the COMPLETE identity, index AND generation, the comparator WRITTEN OUT rather than bitcast to the packed `u64` whose field order is a layout accident; no hashed container anywhere. **THE PASS IS FILTERED BY NO SLEEP STATE ON EITHER SIDE, and that is what forbids the phantom exit STRUCTURALLY**: membership derived from anything the sleep system filters would make a body that falls asleep inside a trigger leave the set without moving by one ULP. It runs at STEP 10 BIS — after the proxy update so poses are final and every proxy is valid including sleepers', before step 11 so membership is established for the tick in which a body falls asleep. The price is explicit and accepted: a fully resting scene pays the pass every tick. THREE EXCLUSIONS FROM THE RESPONSE: no constraint, no impulse, no island; no wake cause; and the character controller ignores trigger bodies BY CONSTRUCTION in its collection paths — `admitsCandidate`, one function for all three collectors, refusing stale then ROLE then mask, because a rule posted in two places out of three is how the third comes to disagree. Left alone a trigger would stop blocking rigid bodies while still blocking the player, an invisible wall for the player alone. A FOURTH observable was found in review: `WorstOverlap` serves the depenetration AND `resizeCharacter`'s occupancy test, whose verdict is a `bool` no position case exercises — and `engine-movement.md` gates the stand-up on it, so a trigger ceiling answering `false` is a crouched character who can never stand again. THE EIGHT QUERY ENTRIES, BY CONTRAST, KEEP SEEING TRIGGERS, tested in BOTH directions so nobody restores the symmetry by reflex. Bench REPORTED, not gated, ReleaseFast over 1000 static bodies: floor 5.0 ns with nothing to enumerate, 1005.0 ns for one trigger holding 8 pairs, 122745.0 ns for 64 triggers holding 1002 — the cost follows the TRIGGER count and its pair yield, which is what the triggers-outward direction intends. 495 → 526 forge tests green at f32 AND `-Dphysics_f64=true`, Debug AND ReleaseSafe. METHOD, and it is the milestone's real yield: every counter-factual changes the OBJECT and never the expected constant — an oracle judging a LAYOUT is tested by a change of layout, one judging a SET by a change of the set — and three tooling facts were self-reported, the third RETROACTIVE: a compile error and an assertion failure share an exit code, so the `compilation errors` grep was replayed over all 27 retained counter-factual logs of gates A to E, finding no unknown invalidated probe and refining the rule — for a COMPTIME assert the compile error IS the measurement. Out (later, NOT debt): all emission and the `TriggerEnter` / `TriggerExit` translation, plus the bus-to-Etch bridge (M1.1.15); any treatment of the `debris` class and any `is_debris` field; an object-layer MATRIX; `step()` / `PhysicsWorld` / `PhysicsModule` instantiation and the production `BodyType → BroadphaseLayer` wiring (M1.1.15); renaming the `dynamic` broad class; any hysteresis, debounce or dwell time; `TriggerStay` in any form; `forge_2d` and `Collider2D.is_sensor` (M1.8.11). | | `v0.11.13-solver-tgs-soft` | 2026-08-13 | M1.1.13.1 — Rigid solver port: SI + NGS → TGS Soft | Substep loop (4 substeps, soft constraints, relax-only friction, restitution `<=` + `total_normal_impulse`); NGS removed. Deviation B1: `integration.zig` decomposed, reset AFTER the last substep. Two N8 re-baselines in `mesh_test`. Rest overlap 5.069 / 7.217 mm replaces `5.9e-7`. 527 tests. Detail: `briefs/m1.1.13.1-solver-tgs-soft.md`. | | `v0.11.14-determinism` | 2026-08-16 | M1.1.14 — Cross-platform determinism of `forge_3d` | C1.1 **level 1** green — 1000-frame hash chain bit-identical between `ubuntu-24.04` and `windows-2025` on all four `(precision, mode)` keys — and **level 2 point 1** green on a real `ubuntu-24.04-arm` cell: the four discrete traces identical to the x86_64 witness over 60 frames, both precisions, both modes. FOUR behavioural changes, not the two the frozen Scope named: deterministic `cos` replacing `@cos` at the `max_slope` conversion; the float environment INSTALLED at thread spawn and at each process entry and ASSERTED at the physics entry; float `@reduce` replaced by an explicit left fold at 18 sites, behind a `no_float_reduce` lint rule — a Zig backend defect, written up and NOT filed; and `shm.zig` moved from `std.heap.pageSize()` to `page_size_min` at 8 sites, which is what made AArch64 compile at all. Eight witnesses committed with per-file provenance and a reader that names the first differing frame AND which of the four invariants moved. CI matrix `{ubuntu-24.04, windows-2025, ubuntu-24.04-arm} × {Debug, ReleaseSafe} × {f32, f64}` = **12 cells**, every one pinned to `-Dcpu=baseline`, with `zig build lint` and `zig build forge-determinism` on the cell path. Dead-test guard ACTIVE. The M1.1.13.1 slider residual PERSISTS and is characterised: 4 ULP at f32, 3 at f64, against `2^31` an energy injection would need — rounding, not energy. 527 → 552 forge tests. **AN EXTERNAL REVIEW THEN FOUND SIX DEFECTS, five of them the milestone's own dominant family** — an artefact judging something other than what it claims to measure, and answering green — and the class therefore SURVIVES ITS OWN DOCTRINE wherever no mechanical guard covers it, which is the milestone's real finding. All six corrected. **Regeneration worked only when it was pointless**: with correct witnesses `--write-witness` exited 0, with one byte altered — the only case where regenerating means anything — it wrote the files and then exited 1, so under `set -euo pipefail` the CI step died exactly in the case it exists for. **`ARCH-031` rule 5's site set was measured, not inherited: TEN, not three** — seven were uncovered including `determinism_main` itself, the instrument asserting the guarantee without installing it, and three sites are inside modules where `install()`'s own doc comment declared a fourth-in-a-module to be a defect; the enumeration is DELETED in favour of the predicate, and the class was swept over four texts. **BIT-NEUTRALITY OF THE INSTALL IS MEASURED, and by a deduction stronger than the byte comparison**: the environment assertion, live in Debug AND ReleaseSafe with its reader witnessed on both ISAs by per-field perturbations whose encoding differs between architectures, PASSED on all 12 cells when nothing installed on the witness path — so the inherited state equalled `engine_default` everywhere, and installing a value into a state already holding it is a bit-level no-op; corroborated end to end on the 8 cells where level 1 applies. **The scenario had NEITHER a step NOR a slope** while its header claimed both, and the character reached NO artifact — `mobile` holds rigid bodies and a virtual character owns none — so the controller ran 1000 frames and every bit was discarded; the guard written for that case could not catch it, a count pinned alongside the change it must catch catching nothing. Fixed with mesh ramps (rotated boxes abandoned after three measured failures, one wedging the character in a crevice for 775 frames), a bowl closing an unbounded 2.2 m/cycle drift that would empty the instrument at a longer replay, and the walk raised 0.03 → 0.06 because **the scenario walked below the threshold of its own step arm** — swept, no riser is climbed at 0.03 at any height. The cosine bracket bites BOTH ways, measured: `max_y` 0.0063 / 0.9463 / 2.6707 at cosines 0.9211 / 0.7074 / 0.3624. **The deterministic cosine is pinned to an ORACLE THAT NEVER CALLS `@cos`** — pi to 80 digits in exact decimal arithmetic, computed twice by different Machin-like formulas and required to agree to 70 — in a two-column table separating CORRECTNESS from REPRODUCIBILITY; at f32 the implementation is correctly rounded on all twelve arguments and at f64 nine of twelve, worst absolute error 3.14 eps. **And the bound had to be ABSOLUTE, not in ULP**: at the f64 nearest pi/2 the error is 1.6e11 ULP of the true value while being 2.0e-21 absolute, the smallest in the table, so a ULP bound would fail on the most accurate row. The fourth discrete trace is now an ORACLE rather than an accumulator — one real removal at frame 196, the mesh against the frictionless sphere, asserted ON THE SET and never on its cardinality because the size returns to 11 one tick later. `divergenceFrame` no longer answers `none` on an empty, truncated or wrong-precision window. Witnesses RE-BASELINED with a `Witness-regen:` trailer, and the prediction written before the run held exactly: 4 chain witnesses CHANGED, the 4 ISA-independent ones IDENTICAL, the 4 ARM cells green through the stale-witness push. Detail: `briefs/m1.1.14-determinism.md`. | +| `v0.11.15-orchestration` | 2026-08-23 | M1.1.15 — `forge_3d` orchestration: `PhysicsWorld`, tick cycle, ECS `Transform` sync | Sixteenth M1.1 sub-milestone, and the first where the eleven-step cycle of `engine-physics-solver.md` §1.7 exists as PRODUCTION code instead of a test harness — reparented without moving a witness bit, 12/12 cells green and the eight witnesses byte-identical throughout. `PhysicsWorld` owns the tick, the substep cadence, the scratch buffers and the per-tick lifetime of everything the steps share; a `Step` enum with comptime adjacency asserts and a `StepTrace` recorder make the ORDER observable rather than asserted in prose. Proxy lifetime covers every body AND the character presences the store creates without being able to insert; class assignment follows §1.13.3's fixed priority, `is_trigger` first then body type. **Gate C found a Gate B defect the gate's own test could not see**: `createCharacter` inserted the presence's proxy but never registered it in `PhysicsWorld.bodies`, so step 2 pruned every pair a presence belonged to, every tick, and cause W4 could never fire for a character — invisible because Gate B counted proxies in the broadphase and the defect lived in the gap between insertion and registration. Wake + write composed on every gameplay-facing setter (§1.8.4), W4 orchestrated for its three named producers plus body removal and static/kinematic teleportation, both directions asserted. `moveKinematic` derives BOTH velocities from a target pose over `dt` — `ω = 2 · vec(q_target · conj(q_current)) / dt`, sign normalised for the short path, so no trigonometry and no `ARCH-031` rule 4 exposure — and its test is ROTATION-ONLY, which is what discriminates: a linear-only implementation passes a combined case because its linear answer is right. `setBodyTransform` stays a teleportation deriving nothing; the split is contractual. **ECS synchronisation, and the ordering trap is the milestone's sharpest measurement.** An island sleeps at step 11, AFTER steps 6 and 7 wrote its last pose; sync-out runs after step 11 and skips tagged bodies, so tagging first never publishes that last pose and the object rests at a slightly wrong place forever. The arbitration taken — untag the woken, publish, THEN tag the newly asleep — is written in `sync.zig` with its motive. The counter-factual REFUTED its own prediction and had to be read further: both halves of the guard fail, because the unpublished final velocity stays in the ECS and sync-in pushes it back as an activating write, waking the sleeper on **29 of 30 ticks**; closing that channel separates them — with `Velocity` removed the immobility half PASSES while the published pose is off by **1.88e-5 m**, which is the value half doing the work. `Sleeping` is a zero-size marker with no precedent in the repo, lifted by measurement. **F-D1, found in review**: `World.getMut` stamps `changed_tick` unconditionally and `Changed` is built on that stamp, so publishing a bit-identical value reported a change that never happened — an immobile kinematic platform held awake republished a constant zero forever and `Velocity` being replicated with a rollback strategy, the false delta left on the wire. Both directions now read before they write, and the guard asserts the SIGNAL and never the value, the value being already correct under the defect. **ONE named precision crossing** replaces four private helpers of identical semantics under two names, which had ALREADY diverged — `character.zig`'s carried an `if (Real == f32) return v;` short circuit the other two did not — plus the fourteen narrowings of the new sync seam, a second boundary in all but name. Written against a named `WorldReal` and never a literal `f32`, per `engine-physics-queries.md` §1.11.8 rewritten 2026-08-21 on THREE scalars (world, solver, render). **The verification is two halves and neither alone is one**: a new `no_precision_crossing` lint rule flags any narrowing in a production file of the forge module OR the interface tier outside the boundary — with a DECLARED escape list and a bilateral control, empty today, on the `dead_tests` pattern — and the widening half — which has no token to flag — is caught by the type system, measured as compiling CLEAN at f32 and failing at f64 with `expected type 'Vec(3,f64)', found 'Vec(3,f32)'`. At the default precision the type system proves NOTHING; the six f64 cells are what make that half a check. `src/interfaces/PhysicsModule.zig` created UNFROZEN — first file of that directory — holding the three body pose/velocity contracts MOVED out of `api/types.zig`, with a test that fails the day `WELD_PHYSICS_PROTOCOL_VERSION` appears. The comptime assert block is deliberately absent: surface guards belong to M1.1.26, a block covering three of twenty-seven entries would PASS an implementation missing the other twenty-four, and its first entry needs `ModuleContext`, declared nowhere in the repository. **The false-`struct_size` premise class had FOUR members, not the two the scope named**, all in one file; correcting two and leaving two would reproduce the motif named at M1.1.11.1, so the class was swept — `ARCH-018` carries the contract (leading `struct_size`, minor version, end-only appends), and the `ground_body` sentinel keeps a stronger reason of its own: `struct_size` says which fields were SENT, never what a sent value MEANS. **The closing review then found six defects, and three of them were ONE class — a property held by one entry and not by its twin**: `addBody` was not transactional where `createCharacter` was, `destroyCharacter` skipped W4 where `removeBody` applies it first, and both pose writers committed before their fallible proxy refresh, so `moveKinematic`'s retry derived `target − target` and published ZERO for a move that happened. Swept over all six twin pairs, not the three sites. **ONE ELECTED PUBLISHER PER ENTITY** — the non-trigger of smallest identity, else the trigger of smallest identity, a character presence never; identity is the complete handle, so the election does not depend on insertion order. An exclusion of triggers was wrong twice over: §1.13.7 says nothing about INTEGRATION, so a `.dynamic` trigger falls under gravity and its pose is a resolved fact, and an exclusion arbitrates only solid-against-trigger, leaving two triggers or two solids in silent last-write-wins. **The ECS → solver direction was RE-SCOPED OUT to M1.1.26** by round-trip: the tick says WHEN a write happened and never WHO produced it, and a solver-side provenance does not close it either, since the publication deliberately does not write a kinematic's pose — so the Tier 1 Etch service, which sees both the component path and the API path, is the only place it can live. ONE registered system remains, the tick with its publication. The election runs as a single sorted pre-pass: the per-body form was 3·N², 363 million comparisons at the C1.1 target, paid even by a scene with no trigger. **A character presence carries its character's own entity**, so the seam walked one entity as two bodies — publishing the presence's constant zero velocity over the entity's, and teleporting the presence past the sweep and depenetration on a gameplay `Transform` write; the distinction now lives on the registration record. **And the synchronisation had no caller outside its own tests**: three systems are registered across three phases, since the DAG is forward dataflow and two writers of one component in a phase conflict by construction, so only the phase order can sequence them — which forced the publication predicate to become *tagged AND still asleep* so the structural marker changes can defer to the phase flush. One finding was REFUSED on the corpus: §1.8.5's letter names W4 for statics and kinematics, but its own stated reason holds identically for a teleported dynamic, and restricting it would manufacture a silent false negative §1.13.6 refuses. 1869 → 1930 collected on `ubuntu-24.04` and macOS; `test-forge-3d` 552 → 576. Green at f32 AND f64, Debug AND ReleaseSafe, 12/12 cells. Out (later, NOT debt): the freeze itself with `WELD_PHYSICS_PROTOCOL_VERSION` and the surface guards (M1.1.26), which owes `ModuleContext` and the `void`-vs-fallible pose setters as PRECONDITIONS; `TriggerEnter`/`TriggerExit` emission and the Tier 0 bus → Etch `EventStore` bridge (M1.1.26); `large_world` and the engine-wide home of the world scalar (owner: whoever delivers it, or Kinesis at M1.2.x); per-island parallel resolution (M1.1.25); joints and advanced shapes (M1.1.16–24). | ### Hotfixes (untagged) @@ -142,7 +143,10 @@ Hotfix milestones are merged to `main` without a tag (Guy decision, - **Open design item — faithful port of the reference friction model (M1.1.7 RD-3, re-pointed at M1.1.13.1)**: order (friction first, non-penetration last, cone clamped with the previous iteration's λₙ) + per-manifold aggregation (one tangential budget for the patch) + twist friction around the normal, **together and never in fragments**. The three were measured individually in scratch at M1.1.7 and each made the five-box stack worse at f32 (tables in `briefs/M1.1.7-solver-ngs-position.md` RD-3, f32 and f64, 600/1200/1800 ticks); they are pieces of a model that is only coherent whole. Needs a dedicated milestone. The M1.1.6 comment crediting "Jolt order" to normal-then-friction was a false attribution and died with `velocity_solver.zig`; **the divergence itself retired at M1.1.13.1** — the pinned Box2D v3 source is normal-first too, and friction now runs in the relax sweep only, after every normal point of its constraint. What that scheduling change made visible is the quantity to watch: the creation-order permutation spread grew from under 1e-3 m to 6.6e-3 m laterally while TIGHTENING four orders along the contact normal (2e-8 m), which is the settling transient of an order-sensitive Gauss-Seidel sweep and the thing a whole-model port would be expected to improve. - **NGS resting fixed point grows with chain length (M1.1.8 RD-2) — pre-existing, exposed, unowned**: M1.1.7 RD-1 established that the slop is a fixed point approached from above, measured on a single box. At six chained contacts the attained value no longer returns under `slop + 16·floatEps·6` within 200 ticks, at either precision, WITH OR WITHOUT sleeping — the never-slept control fails identically, which is the proof the characteristic is pre-existing and not a wake artefact. Five boxes settle at 0.004999 (f32), under the bound; the sixth crosses it. To be characterised BEFORE joints (M1.1.16), which lengthen chains. Not scoped to a milestone yet. - **IPC crash-recovery tests assert BEHAVIOUR, not latency (decided at M1.1.9)** — four wall-clock assertions were removed from `tests/ipc/crash_recovery.zig` and every behaviour assertion kept: `expectError(error.UnexpectedEof)` proves detection, `exit_code != null` + `== 0` prove the clean exit, `result.complete` + `replayed == 3` prove the replay. The reason is not that the bounds were too tight: `try expect(nowMs() - t0 < 100)` **is not a hang guard at all**, since it runs only once the `recvFrame` loop has already returned — an EOF that never arrived would hang forever and the assertion would never fire. What it measured was kernel scheduling latency between `kill` and EOF, with no Weld code on that path, on a machine the test suite itself saturates. MEASURED with a temporary probe: 0-1 ms idle, 18-64 ms at load average 5, 14-67 ms at load average 32-91, and one crossing of the 100 ms bound during a pre-push run, which is what refused the push. C0.4 carries no figure — its metric is functional and its verification names the file — so nothing normative was weakened; the numbers live in `engine-phase-0-plan.md:371` and `validation/s6-go-nogo.md` G4, and the M0.7 brief's acceptance line ("detection < 100 ms, replay < 500 ms aggregate", `briefs/M0.7-ipc-scm-rights-windows-fuzz.md:88`) is a closed record that is NOT patched. A duration is a benchmark, not a test (`engine-zig-conventions.md` §13). **Residual, verified not assumed:** the two remaining `recvFrame` sites have no timeout of any kind — `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`). A hang there does not stall the other IPC cases — one exe per case, by deliberate design — but the build step never completes, so `zig build test` hangs as a whole. Closing §13 for real needs a bounded receive primitive in Tier 0 IPC; owned by whoever next opens that surface, not by a physics milestone. -- **Public surface precision boundary — owned by M1.1.15 (M1.1.9)**: the query core is written at the solver scalar, the public surface stays `f32`, and `engine-physics-forge.md` §1.11.8 states the consequence normatively — in an `f64` world a query is expressed and returned at `f32` resolution. This is NOT query-specific: `BodyDescriptor.position`, the interface `Transform` and `core.ecs.components.Transform.pos` are all `f32`, so `-Dphysics_f64` today buys precision INSIDE the solve and not at the API. Widening is one decision over all of them together or none, and it belongs to the freeze milestone. `convVec3`/`convQuat` are the abstraction point: a later widening touches the aliases and those conversions, no call site. +- **Public surface precision boundary — ADDRESSED at M1.1.15, and re-pointed (opened M1.1.9)**: the entry as written said the widening was one grouped decision over `BodyDescriptor`, the interface pose, the query results and the ECS `Transform`, owned by the freeze milestone. `engine-physics-queries.md` §1.11.8 was REWRITTEN on 2026-08-21 and reframes it: there are THREE scalars, the public surface follows the WORLD scalar rather than a literal type, `large_world` (`ARCH-022`) is what moves it, and `large_world = true` IMPLIES `-Dphysics_f64` while the converse stays legitimate and distinct. M1.1.15 delivered the part that could be delivered without `large_world`: ONE named crossing (`forge/api/precision.zig`) replacing four diverged private helpers, a named `WorldReal` so no crossing spells a literal `f32`, and a two-halved mechanical verification — a lint rule for the narrowing direction, the `f64` type system for the widening one. **What remains is `large_world` itself**, which crosses `Transform`, the hierarchical `TransformSystem`, serialisation and Render, and is a project of its own. Owner: unassigned; the first non-forge module needing the world scalar meets it first. +- **M1.D.13 — `proxyOf` is linear and step 2 calls it twice per retained pair (opened at M1.1.15)**: `PhysicsWorld.proxyOf` searches the registration list, and `pairStillOverlaps` resolves both endpoints of every retained pair through it, every tick. Real and measured on the code rather than on a bench; left because this milestone's only performance requirement is a 5 % non-regression on the existing benches, which it meets. It is step 2 of the eleven-step cycle. **Owner: M1.1.26 or earlier** — the first placement said M1.1.25 and was WRONG: C1.1 requires 1 000 dynamic and 10 000 static bodies at 60 Hz, M1.1.26 is what closes C1.1, and in the plan's execution order M1.1.25 comes AFTER M1.1.26. An owner placed behind the gate it makes fail is not an owner. A `BodyId → proxy` index stays outside M1.1.15. +- **M1.D.12 — the world scalar has a forge-local home and an engine-wide meaning (opened at M1.1.15)**: `forge/api/precision.zig` is FORGE's single crossing point and its header says so honestly, but `WorldReal` describes the ENGINE. The day `large_world` lands, Kinesis, Render and scene serialisation all need it and none of them can import `weld_forge`, so there will be either a SECOND point — the exact defect M1.1.15 spent itself removing — or a MOVE. Left deliberately: the cost of moving is a file relocation plus rerouting eleven internal call sites, with NO API change, so it is cheap now and cheap later and pointless before a second consumer exists. Owner: the milestone delivering `large_world`, or the first non-forge module needing the world scalar — Kinesis at M1.2.x will meet it first. +- **`ModuleContext` is a PRECONDITION of the M1.1.26 freeze (opened at M1.1.15)**: `engine-tier-interfaces.md` §1 types the interface's first entry `fn (*core.ModuleContext) anyerror!Impl`, and `ModuleContext` is declared NOWHERE in this repository — measured, the name occurs in three comments and in no declaration. The freeze cannot write its comptime assert block without it, and minting it is a Tier 0 project reaching the scheduler and the asset loader, not a line of glue. It must be discovered BEFORE M1.1.26 begins, not during. Second precondition, same owner: the frozen pose setters are `void` while `PhysicsWorld`'s are allocator-taking and fallible, because every one of them refreshes a broadphase proxy and `Broadphase.update` reserves — two ways out, a reservation seam making `update` infallible or an error channel on the setters, and both belong to the freeze. - **Far-field conditioning is characterised, not fixed (M1.1.9)**: `engine-physics-forge.md` §1.11.4 bis. The normal's LENGTH is a structural invariant at any distance because the kernel normalises it. Its ORIENTATION degrades as `ulp(distance) / radius` at `f32` — about 1e-4 at 5 km on a unit shape — and a rim-grazing hit/miss decision becomes unresolvable inside that same band, about 4 mm at 50 km. MEASURED, in f32 on origin (−3000.4, −3999.7, 0) direction (0.6, 0.8, 0), radius 1: current kernel 0.999915421, general quadratic in f64 arithmetic 0.999999999, general quadratic in f32 0.4999512, `a`-corrected perpendicular form in f32 bit-identical to the current kernel because `f32(d · d)` rounds to one. So the information is in the inputs and f32 arithmetic does not extract it, and solving the full quadratic is strictly worse. `-Dphysics_f64` is Phase 1's answer, clean to 1e-12 out to 100 km. A compensated or double-width intermediate would recover the rest at roughly twice the cost of the hot-path dot products; that decision belongs to M1.1.15, which owns precision, not to the milestone that writes the kernels. Any acceptance suite that only exercises axis-aligned rays sees none of this — the cancellation is exactly zero there. - **M1.1.9 scope boundary (queries: raycast)**: only the raycast is implemented; the COMPLETE family's signatures freeze here because a comptime strategy interface cannot gain a method after M1.1.15 (§1.11.7) — the deferral rule, not zeal. `error.UnsupportedShape` is structurally UNREACHABLE through the query path today: `shape.supportShape` maps a box to `radius = 0` unconditionally and the store holds only sphere/box/capsule, so no `SupportShape` reaching a kernel from a body can be a rounded box. The latch is required by construction, E3 pins the error at kernel level, and the end-to-end path becomes exerciseable at M1.1.11 with Plane and MeshShape. Dated unreachability, not debt. A query takes `*const BodyManager` and therefore CANNOT wake anything, which makes "a sleeping body answers and stays asleep" structural rather than merely tested. The `0.003886328` far-from-origin figure recorded in the M1.1.8 brief is not reproducible from a rebuilt probe (both legs read `0.003882778`, on `main` itself): a frozen brief records what its own probe measured, and a future re-measurement should not chase it. - **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. @@ -353,4 +357,4 @@ line, and never on a `tail`. --- -Last updated: 2026-08-17 +Last updated: 2026-08-24 diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md new file mode 100644 index 00000000..66d2a7ad --- /dev/null +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -0,0 +1,1576 @@ +# M1.1.15 — forge_3d orchestration: `PhysicsWorld`, tick cycle, and ECS `Transform` synchronisation + +> **Status:** CLOSED +> **Phase:** 1.1 +> **Branch:** `phase-1/forge/physics-world-orchestration` +> **Planned tag:** `v0.11.15-orchestration` +> **Dependencies:** M1.1.8 (islands, sleep, total resolution order), M1.1.12 (kinematic character controller — `moveKinematic` left a typed stub for this milestone), M1.1.13 (sensors), M1.1.13.1 (TGS Soft — the eleven-step cycle this milestone gives an owner to), M1.1.14 (determinism harness — the instrument this milestone must leave green) +> **Opened:** 2026-08-21 +> **Closed:** 2026-08-24 + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +`forge_3d` has every mechanism the arena needs and no owner for the tick that runs them. The eleven-step cycle of `engine-physics-solver.md` §1.7 exists as a set of callable halves; the determinism harness drives them by hand; nine call sites across the module name a `PhysicsWorld` that does not exist. This milestone creates that owner, wires the pieces the store cannot wire from inside (broadphase layers, presences, wake composition), and connects the solver to the ECS in both directions so a `Transform` written by gameplay reaches the solver and a pose resolved by the solver reaches gameplay. + +**This milestone does not freeze `PhysicsModule`.** The freeze is M1.1.26, after the Etch slice has exercised the surface. `src/interfaces/PhysicsModule.zig` is created here because the contract prose that belongs on its declarations currently lives in `forge/api/types.zig` waiting for it — but it lands **without** `WELD_PHYSICS_PROTOCOL_VERSION`, and a test attests that absence. A surface that is written, exercised, and never declared frozen is how a freeze happens by inertia, and that is the named failure mode of this milestone. + +## Scope + +- **`PhysicsWorld`** — sole owner of the tick. It executes exactly the eleven steps of `engine-physics-solver.md` §1.7 in that order, step 10 bis included, and owns the substep cadence, the impulse scratch buffers, and the per-tick lifetime of everything the steps share. +- **`BodyType` → `BroadphaseLayer` wiring and proxy lifetime.** Proxy insertion, update, and removal for every body, including a character's broadphase presence, which the character store creates without being able to insert. Class assignment follows the fixed priority of `engine-physics-solver.md` §1.13.3: `is_trigger` first, then body type. +- **Wake + write composition on every gameplay-facing setter** (`engine-physics-solver.md` §1.8.4). An external mutation wakes and resets the sleep window; a solver-internal write does neither. Cause **W4** is orchestrated here for its three named producers — `moveCharacter`, `setCharacterPosition`, and any successful `resizeCharacter` (`engine-physics-queries.md` §1.12.10) — plus body removal and static/kinematic teleportation. +- **`moveKinematic` real body.** It derives **both** velocities from a target pose over a `dt`, on the shape of `BodyInterface::MoveKinematic`. `setBodyTransform` stays a teleportation deriving nothing: the split is contractual, not an oversight. +- **`Transform` synchronisation, both directions**, with an authority rule per `BodyType` and a fixed position in the tick (see Notes for the arbitration this milestone applies). +- **`Velocity` synchronisation.** A gameplay write of `Velocity` is applied before step 3 of the cycle and composes wake + write; the solver publishes the resolved value on the way out. C1.1 requires that an Etch system can read and write `Velocity` and that the solver applies it. +- **`Sleeping` ECS tag** — zero-size component, added and removed by the orchestrator as islands sleep and wake. It carries the archetype-level skip of `engine-physics-solver.md` §1.8.6 and is the only way a rule can ask whether a body is asleep. +- **One named precision-crossing point**, public, replacing the four private helpers of identical semantics under two names (`widen` in `mesh.zig`, `convVec3` in `character.zig` and in `body_manager.zig`, `convQuat` in `body_manager.zig`). Written against the **world scalar**, never against a literal `f32` (`engine-physics-queries.md` §1.11.8). +- **`src/interfaces/PhysicsModule.zig`**, created and **not frozen**. The contract block of `forge/api/types.zig` (the three body pose/velocity entries) **moves** onto its declarations — moved, not copied. +- **Two false premises removed from code comments**: `api/types.zig:1042` and `:1116` argue from `engine-c-api.md` having no `struct_size` and no minor version. It has both, in §1.1 bis, and `ARCH-018` carries the evolution contract. + +## Out of scope + +- **The freeze itself** — `WELD_PHYSICS_PROTOCOL_VERSION`, surface guards, the normative update of `engine-tier-interfaces.md`. All M1.1.26. +- **`TriggerEnter` / `TriggerExit` emission, the Tier 0 bus → `EventStore` bridge, the Tier 1 physics service and its Etch wrappers, `getTriggerOverlaps`.** All M1.1.26. Sensors keep producing state and two deltas here, and nothing consumes them yet. +- **Mutating a body's sensor role after creation.** The atomic operation described at `body_manager.zig:433-467` is owed *the day that direction is opened*, and opening it needs an interface entry that does not exist. Whether it lands is a pre-freeze-window decision, and it belongs to M1.1.26 with the rest of them. +- **`large_world`.** The world scalar stays `f32` in this milestone. Widening it crosses `Transform`, the hierarchical `TransformSystem`, scene serialisation and Render, and is a project of its own (`engine-coordinate-system.md` §6). What is required here is that no new code hard-codes `f32` where the world scalar is meant. +- **Per-island parallel resolution** (M1.1.25). The tick is single-worker, and the scratch buffer stays unique. +- **Real joints, advanced shapes, vehicle constraint, save/restore** (M1.1.16-24). +- **Witness regeneration.** See the Gates: a red witness here is a defect, not an act to declare. +- **`ARCH-030` access-set enforcement** (M1.A), which runs after M1.1.26. + +## Specs to read first + +Order follows ownership. + +1. `engine-corpus-map.md` §2 — which document owns what, before writing into anything. +2. `engine-physics-solver.md` §1.7 (the eleven steps and their frozen numbering), §1.8.4 (internal write vs external mutation), §1.8.5 (W1–W4 and the `build` fixpoint), §1.8.6 (what sleep saves, and the archetype skip), §1.8.7 (pair retention is the wake graph), §1.13.3–1.13.4 (broad class assignment, step 10 bis). +3. `engine-physics-queries.md` §1.11.8 (**rewritten 2026-08-21** — three scalars, the single crossing point), §1.12.10 (character wake), §1.12.11 (controller precision). +4. `engine-tier-interfaces.md` §1 — the surface and its scalar note. Read it as the surface that will freeze, not as one that has. +5. `engine-invariants.md` — `ARCH-022` (coordinates and the large-world build flag), `ARCH-031` (float execution discipline: every arithmetic added on a compared path is subject to it, explicit left folds and the `@reduce` ban included), `ARCH-018` (Tier 3 ABI evolution contract), `ARCH-004` (POD components). +6. `engine-physics-forge.md` §1.4 (what native integration buys, including the archetype skip claim), §2 (ECS components). +7. `engine-ecs-internals.md` §8 — observers, command buffers, and when structural changes actually apply. The `Sleeping` tag is a structural change per transition. +8. `engine-phase-1-criteria.md` C1.1 — the determinism contract, whose level 1 requires "same ISA, build, configuration and worker count". +9. `engine-development-workflow.md` §5.5 — verdict discipline, non-negotiable and listed in Notes. + +## Files to create or modify + +- `src/modules/forge/forge_3d/world.zig` — create — `PhysicsWorld`: tick ownership, substep cadence, scratches, proxy lifetime, wake composition. +- `src/interfaces/PhysicsModule.zig` — create — the interface declarations, **unfrozen**; receives the contract block moved out of `api/types.zig`. +- `src/modules/forge/forge_3d/root.zig` — modify — export `PhysicsWorld`; rename the misleading `CharacterMoveResult` alias (see Notes). +- `src/modules/forge/forge_3d/body_manager.zig` — modify — remove `convVec3` / `convQuat` in favour of the single crossing point; `moveKinematic` support. +- `src/modules/forge/forge_3d/character.zig` — modify — remove the local `convVec3`; presence handed to the orchestrator for insertion. +- `src/modules/forge/forge_3d/mesh.zig` — modify — remove `widen`. +- `src/modules/forge/forge_3d/config.zig` — modify — name the world scalar alongside `Real`, so that a call site can say which one it means. +- `src/modules/forge/api/components.zig` — modify — `Sleeping` zero-size tag. +- `src/modules/forge/api/types.zig` — modify — contract block moved out; the two false `struct_size` premises corrected. +- `src/modules/forge/sync.zig` — create — ECS ↔ solver synchronisation systems (in and out) and their registration. +- `src/modules/forge/forge_3d/tests/world_test.zig` — create — tick order, substep cadence, proxy lifetime, wake composition. +- `tests/physics/transform_sync_test.zig` — create — both directions of synchronisation, authority per `BodyType`, sleeping-body freeze. +- `src/modules/forge/forge_3d/tests/determinism/` — modify only if the harness must call `PhysicsWorld.step()` instead of the halves. **The scenario itself is frozen**: body creation order is part of the contract, and reordering it invalidates every committed witness. + +## Acceptance criteria + +### Tests + +- `src/modules/forge/forge_3d/tests/world_test.zig` — `test "step executes the eleven cycle steps in the frozen order"` — the observed order is the sequence of `engine-physics-solver.md` §1.7, step 10 bis between 10 and 11. The assertion reads the **order**, not the fact that each ran. +- `world_test.zig` — `test "substep cadence: warm start is applied inside the substep loop, every substep"` — measured on the count of applications for `substep_count > 1`, not on a single-substep run, which cannot discriminate. +- `world_test.zig` — `test "a proxy exists for every live body and for every character presence, and none outlives its owner"` — the count is read per broad class, so a body landing in the wrong class fails rather than passing on the total. +- `world_test.zig` — `test "trigger role wins over body type in class assignment"` — a kinematic sensor lands in `trigger`, not in `dynamic`. +- `world_test.zig` — `test "external mutation wakes and resets the window; a solver-internal write does neither"` — both directions asserted; a test that only shows the wake cannot tell a correct rule from one that always wakes. +- `world_test.zig` — `test "W4: moveCharacter wakes the sleeping bodies retained in pair with the presence"` — and its counter-factual **at the right instant**: a character moving through a region holding no retained pair wakes nobody. +- `world_test.zig` — `test "W4: removing a body wakes the sleepers retained in pair with it"`. +- `world_test.zig` — `test "moveKinematic derives both velocities from the target pose over dt"` — angular included, and asserted on a rotation-only move, where a linear-only implementation still passes a combined case. +- `world_test.zig` — `test "setBodyTransform derives no velocity"` — the paired negative that gives the previous test its meaning. +- `tests/physics/transform_sync_test.zig` — `test "solver pose reaches Transform for every awake body"` — asserted per entity identity, never on an aggregate another entity can satisfy. +- `transform_sync_test.zig` — `test "a sleeping island's Transform is not rewritten and its pose is bit-frozen"` — bit equality, not an epsilon. +- `transform_sync_test.zig` — `test "gameplay Velocity write is applied by the solver and wakes the body"`. +- `transform_sync_test.zig` — `test "authority per BodyType"` — one case per type, each naming the type it covers. +- `transform_sync_test.zig` — `test "Sleeping tag tracks island state in both directions"` — added on sleep, removed on wake, over at least one full cycle each way. +- `src/modules/forge/api/types.zig` — the existing pinned-boundary test extended: the public surface is the **world scalar**, and the test states which mode it measures. +- Interface non-freeze attestation — `test "PhysicsModule carries no protocol version yet"` — mechanical, so that M1.1.26 must delete it deliberately rather than inherit a frozen-by-inertia surface. + +### Benchmarks + +None new. Existing benches must not regress by more than 5 % (`engine-phase-1-plan.md`, Phase 0 non-regression). + +### Observable behavior + +- `zig build forge-determinism` — the eight committed witnesses are **byte-identical** after the tick is reparented onto `PhysicsWorld.step()`, at both precisions, on the twelve-cell matrix. +- A scene driven for N ticks through `PhysicsWorld.step()` alone — no hand-driven halves anywhere outside the module's own unit tests. +- Per-platform test floor respected and stated with its platform: 1869 collected, 1867 on Windows. Any comparison to a total from before M1.1.14 compares two denominators. + +### CI + +- `zig build` clean, zero warnings, on the twelve-cell matrix (`{ubuntu-24.04, windows-2025, ubuntu-24.04-arm} × {Debug, ReleaseSafe} × {f32, f64}`, `-Dcpu` pinned) +- `zig build test` green (Debug + ReleaseSafe) +- `zig fmt --check` green +- `commit-msg` hook green on every commit of the branch +- `zig build forge-determinism` green on every cell, with **no** `Witness-regen:` trailer anywhere in the branch + +## Gates + +Gate-by-gate STOP/GO. Review is on the pushed diff, never on a self-report. Fix-as-you-go: a gap found at any gate is closed inside this milestone. + +**Gate A — `PhysicsWorld` and the tick.** The eleven steps under one owner, substep cadence, scratches, and the harness reparented onto `step()`. Exit: the eight witnesses byte-identical at both precisions, and the step-order test reads an order rather than a set. +*Named STOP condition:* a witness goes red at this gate. The reparenting changes who calls, not what is computed, so a red witness means the sequence moved. **Isolate the moved step and return** — do not regenerate. `Witness-regen:` is admissible only for an intentional, isolated physics change, and this gate contains none. + +**Gate B — proxies and body lifetime.** Layer wiring, insertion, update, removal, character presences. Exit: per-class proxy counts green, presences inserted, nothing outliving its owner, witnesses still green. + +**Gate C — wake composition.** W1 and W4 composed at the boundary; the three character producers; removal and teleportation. Exit: both directions of the wake rule asserted, and the counter-factual taken at the level the claim is made — a body that must *not* wake, in a scene where a wake would be visible. +*Named STOP condition:* the canonical determinism scenario changes behaviour under W4. It holds a scripted kinematic character, and its own header states nothing stands near that character but the flat half-space. If W4 makes it diverge, either that statement is false or W4 is wired wrong. **STOP and return** — in both cases the answer is a measurement, not a new witness. + +**Gate D — ECS synchronisation.** Both directions, authority per `BodyType`, `Velocity`, `Sleeping` tag and the archetype skip. Exit: per-entity assertions green, a sleeping island's pose bit-frozen, and the tag observed across a full sleep→wake→sleep cycle. + +**Gate E — precision crossing point, interface file, false premises.** One public named crossing, the four private helpers gone, `src/interfaces/PhysicsModule.zig` created unfrozen with the contract block moved onto it, the two `struct_size` comments corrected. Exit: a mechanical check that no other site crosses the boundary; the non-freeze attestation green. + +**Gate F — closure.** `CLAUDE.md` §3.4 patch **inside the PR**, not after merge: current-state table, tags table +1 row, open decisions, last-updated date. Audits per `engine-development-workflow.md` §3.6.1, brief closure, PR opened. Squash message and tag annotation come from Claude.ai; merge and tag are Guy's. + +## Conventions + +- **Branch:** `phase-1/forge/physics-world-orchestration` +- **Final tag:** `v0.11.15-orchestration` +- **PR title:** `Phase 1 / Forge / forge_3d orchestration: PhysicsWorld and ECS Transform synchronisation` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md` §4.3). Subject ≤ 72 characters, measured before use. +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md` §4.6) + +## Notes + +**`Transform` authority, arbitrated here rather than left open.** Per `BodyType`: + +- **`dynamic`** — the solver is the authority. A gameplay write to `Transform` on a dynamic body is **overwritten** at the next sync-out, and that is the contract rather than a bug: the legitimate way to move a dynamic body is `setBodyTransform` (teleportation, wakes, derives no velocity) or a force/impulse. Making the sync detect and honour a direct write would give two authorities over one fact, which is the defect class this module refuses. +- **`kinematic`** — gameplay is the authority. `Transform` written by a rule is pushed in at sync-in; `moveKinematic` is what derives velocities from it when the caller wants a moving platform that a standing character inherits. +- **`static`** — no per-tick synchronisation in either direction. A static body that moves is a teleportation through the interface, which wakes by W4. + +Sync-in runs before step 1; sync-out runs after step 11. Sensors' step 10 bis therefore sees final poses, which is its stated premise. + +**The misleading alias.** `forge_3d/root.zig:255` exports the internal `MoveResult` under the name `CharacterMoveResult`, which is also the name of the interface type in `forge/api/types.zig` — a flat six-field struct at the world scalar, against an internal two-field struct nesting `GroundInfo` at solver precision. Both names live in one import graph, one of them about to become irreversible. The internal type keeps its own name; the alias is renamed. Measured: zero consumers outside doc comments, so the change is one line. The interface tier owes the flatten-and-narrow conversion, at the same named place as the other three crossings. + +**Verdict discipline, carried from M1.1.14 and not re-negotiable** (`engine-development-workflow.md` §5.5): + +- A verdict carries the identity, the size and the perimeter of its object. A count says *collected* or *source*; a green says which set of steps it covers; a measurement says its platform. +- An assertion naming an element reads an identity of that element, never an aggregate another element can satisfy. Six M1.1.14 findings were this one point. +- A counter-factual has a correct instant as much as a correct object, and it is taken at the level the claim is made — local proves nothing about the cell. +- A control whose green alone has been seen is indistinguishable from a control that judges nothing. +- A witness of absence is obtained by a different mechanism from the fix. +- A doctrine is written on a complete measurement and names the discriminant, never the idiom. + +**`ARCH-031` applies to every arithmetic this milestone adds on a compared path** — `moveKinematic`'s velocity derivation is exactly that. Explicit left-fold reductions, no `@reduce` on float element types, no external transcendental. + +**Open items deliberately left untouched**, measured and recorded, not this milestone's: `M1.D.5` (`zig_codegen` subtree), `M1.D.8` (ARM cache collapse, cause not established), `M1.D.9` (`bench.yml` without a CPU axis), `M1.D.10` (`setup-zig`), `M1.D.11` (mechanical enforcement of FPU install sites). + +**Corpus state at opening.** The specs were patched on 2026-08-21 before this brief: `engine-physics-queries.md` §1.11.8 rewritten on three named scalars (it previously contradicted `ARCH-022`), `engine-coordinate-system.md` §6 corrected, `engine-tier-interfaces.md` given a scalar note and a repaired `handle_dead` justification, and the milestone split recorded in `engine-phase-1-plan.md`. The `weld-spec/` mirror is synchronised. Read the current text, not a memory of it. + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone. The log is not a marketing report: it serves review and post-mortem debugging.* + +## Specs read + +Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword search. + +- [x] `engine-corpus-map.md` — 2026-08-21 22:58 — 276 lines. §2 ownership map; §3 drift registry, including the two families added on 2026-08-21 by this milestone's opening recon (a cross-cutting rule locally re-declared in the opposite sense; a justification resting on the absence of a mechanism elsewhere — which is exactly the two `struct_size` comments this milestone corrects). +- [x] `engine-physics-solver.md` — 2026-08-21 22:59 — 438 lines. §1.7 the eleven anchors (step 8 removed at frozen number, 5 bis empty, 10 bis between 10 and 11); §1.7.1 substep loop, soft coefficients, warm-start seeding once per tick against application per substep; §1.8.4 internal write vs external mutation; §1.8.5 W1-W4 and the `build` wake fixpoint; §1.8.6 what sleep saves, and the archetype-level `Transform` skip named as this milestone's; §1.8.7 retention is the wake graph; §1.13.3 class assignment priority; §1.13.4 step 10 bis. +- [x] `engine-physics-queries.md` — 2026-08-21 23:00 — 415 lines. §1.11.8 rewritten on three named scalars (world / solver / render) and the single named crossing point this milestone owes; §1.12.5 `moveKinematic` and `setAngularVelocity` as the only authorable sources of `ω`; §1.12.10 character wake is W4 and needs a producer, not a fifth cause; §1.12.11 controller precision. +- [x] `engine-tier-interfaces.md` — 2026-08-21 23:00 — 1908 lines. §1 read as the surface that *will* freeze: the scalar note at its head, `moveKinematic` carrying an in-place comment that names its body a typed stub at M1.1.12 whose realisation is M1.1.15, the `handle_dead` justification already corrected for M1.1.15, the 27-entry count in §12 and its counting convention (life-cycle trio excluded). +- [x] `engine-invariants.md` — 2026-08-21 23:00 — 1035 lines. `ARCH-022` (coordinates, large-world as a build flag read at comptime), `ARCH-031` (six rules — float mode, contraction, reduction order with the `@reduce` ban, transcendentals, FPU state, pinned CPU features — plus the perimeter stated in extension: `fixed_update` is inside), `ARCH-018` (`struct_size` + minor version — the premise the two code comments deny), `ARCH-004` (POD components, which the zero-size `Sleeping` tag must satisfy), `ARCH-030` (declared ECS access, M1.A, out of scope here). +- [x] `engine-physics-forge.md` — 2026-08-21 23:01 — 1275 lines. §1.4 the archetype-level O(1) skip attributed to `Transform` synchronisation and explicitly *not* to the solver; §2 ECS components (`RigidBody`, `Velocity`, `PhysicsForces`, `CollisionShape` with `is_trigger` / `trigger_layer_mask`); §9 the controller calling surface and `VirtualCharacter`; §13 the `physics_query` Etch surface. +- [x] `engine-ecs-internals.md` — 2026-08-21 23:01 — 1247 lines. §8 observers and their dispatch at command-buffer flush, the uniform `ObserverFn` signature, and the constraint that an observer defers its own structural changes; §6 command buffers and phase flush points — which is what makes each `Sleeping` transition a structural change; §5 change detection (a `get_mut` marks `changed_tick`). +- [x] `engine-phase-1-criteria.md` — 2026-08-21 22:58 — 339 lines. C1.1: level 1 requires the same ISA, build, configuration **and worker count**; `Transform` synchronisation and "an Etch system can read and write `Velocity` and the solver applies it" are named metrics; the `PhysicsModule` freeze is C1.1's, delivered at M1.1.26. +- [x] `engine-development-workflow.md` — 2026-08-21 23:01 — 1121 lines. §5.5 verdict discipline in full (identity/size/perimeter of the measured object; a counter-factual taken at the level the claim is made; a control whose green alone was seen); §3.6.1 the two closing audits; §4.3 commit convention; §4.4 **draft PR opened right after the first gate, because `ci.yml` fires on `pull_request` and not on a branch push**; §4.6 squash format; §4.8 the established tooling facts. + +## Execution log + +**Gate A — `PhysicsWorld` and the tick.** 2026-08-21. + +- **Recon.** The eleven-step cycle had exactly one composition in the repo, and it + was inside an acceptance suite: `tests/solver_test.zig`'s `World`, driven by ten + test files including the determinism scenario. `world.zig` did not exist; nine call + sites named it. So the milestone's first act is a MOVE, not a write. +- **The move.** `PhysicsWorld` in `forge_3d/world.zig` receives that composition with + its call sequence, arguments and arithmetic unchanged. `tests/solver_test.zig` keeps + the name `World` as an alias for it, which is what leaves the ten driving suites + untouched and, more to the point, leaves ONE owner instead of two. +- **`step()` reads as the cycle.** Nine stage methods in the frozen order, each one + beginning with its own `enter()`, plus the two code-free anchors as comments where + they would run (3 read-only, 5 bis the empty composite seam of §1.7.3). The stage + methods are not decoration: a recorder wired at the CALL SITE would be blind to the + work being reordered while the records stay put, and binding each record to the + first statement of the method that carries its name is what closes that. +- **Step 10 bis became unconditional.** The harness gated it on `sensors_on`, + defaulting to `false`. In production that reads "sensors silently do not work until + someone remembers", which is the silent-limitation class this module refuses. Since + `SensorState.update` takes a `*const BodyManager`, the pass cannot alter a bit of + body state, so making it unconditional is witness-neutral by construction — and + measured so. +- **Five counter-factuals RUN, each naming the PAIR it moved.** Two pairs, differing + in the one property that decides the conclusion — whether inverting the two stages + has a physical consequence. `(proxy_update, sensor_pass)` is HARMLESS to invert: the + sensor pass takes a `*const BodyManager`. `(build_constraints, island_partition)` is + not: inverting them partitions last tick's constraint array and then rebuilds it. + (A) CALLS of 10 / 10 bis swapped: `1 failed` of 561, the order test. (D) CALLS of + 4 / 5 swapped: `4 failed, 35 crashed`. (C) BODIES of 10 / 10 bis swapped, each + `enter()` left in place: suite GREEN, 560/561. (C') BODIES of 4 / 5 swapped, same + mutation on the consequential pair: `3 failed, 35 crashed` and the order test + PASSES — exactly (D) minus the order test. (B) warm start hoisted out of the substep + loop: `expected 16, found 4` plus six physics tests. +- **Instrument green on all four local corners**, `{Debug, ReleaseSafe} × {f32, f64}` + on macOS aarch64: self-reproducible, the four discrete traces byte-identical to the + committed witnesses, no divergence within `K = 60`. The four CHAIN witnesses are + x86_64 artifacts and this host reports them as `REPORTED, not gated` — level 1 is + intra-ISA — so their byte-identity is a CI reading and is what the draft PR exists + to obtain (`engine-development-workflow.md` §4.4). +- **One `exit=1` self-reported and diagnosed as apparatus, not code:** the fourth + corner failed in a shell loop that passed `-Doptimize=ReleaseSafe -Dphysics_f64=true` + through an unquoted `$args`, which `zsh` hands over as ONE argv entry (§4.8). It + fails loudly, so no false green was possible; re-run as literal flags, exit 0. +- **Counts, with their denominators.** `zig build test-forge-3d` collects **561** on + this branch (560 pass, 1 skip) against **555** on `main` measured in a worktree at + `e02d27b`, both on macOS aarch64: `+6`, exactly the six blocks added in + `tests/world_test.zig`. Full suite: 1875 collected. The `dead-tests` declared floor + was re-derived FROM THE SUITE (1875), not from the closure's arithmetic, which is + what its own failure message forbids. + +- **Gate A corrections F1-F3, after review.** 2026-08-21. + - **F1 — probe C's conclusion was overgeneralised, and the review refuted it from my + own batch.** The text in `world.zig` and in `tests/world_test.zig` read "a record + moved away from its work is undetectable by any test here". False: swapping the + BODIES of two stage methods executes the work in the same order as swapping their + CALLS, so on a pair whose inversion has physical consequence the physics guards fire + identically. MEASURED rather than deduced — probe C' on `(build_constraints, + island_partition)` gives `3 failed, 35 crashed` with the order test passing, which + is probe D minus the order test. The defect was that probe C named no pair, so + nothing bounded its conclusion, which is the incomplete-measure doctrine §5.5 + forbids by name. Both files now name both pairs, carry the bounded form, and — the + claim that was buried under the residual — say what this test guards ALONE: the + 10 / 10 bis adjacency, harmless to invert and therefore covered by nothing else, + where probe A reports `1 failed` of 561. + - **F2 — see Recorded deviations.** The counter stays where the injection happens; its + exclusion from the reported surface moves from a doc comment into the type. + - **F3 — the test name announced eleven and the measurement covers nine.** Renamed to + `test "step executes the nine coded steps of the eleven-anchor cycle in order"`. The + gap was already correct and documented (anchors 3 and 5 bis carry no code, 8 is + retired at a frozen number, 6 and 7 are one `solveTick`), but a green on the old name + read as eleven steps verified, and a verdict carries the size of its object. The + frozen Acceptance criteria keep the original wording; ruled a recon-side name + correction and explicitly not a Recorded deviation. + - Re-verified after the three: forge suite 561 collected / 560 pass / 1 skip, + `forge-determinism` green at both precisions, `lint` conservation OK at 1875, + `zig fmt --check` clean. + +- **A SILENT DELETION, self-reported: I truncated this brief.** 2026-08-22. The edit that + rewrote the `## Notes` block sliced from `## Notes` to the END of the file, which + removed the two sections that followed it — `## Blockers encountered` (then carrying + "None at Gate A.") and an empty `## Closing notes`. Nothing flagged it: the language + audit was green, the suite was green, all three hooks passed, and the commit landed. + That is the class exactly — the artefact observed is green and the object is gone. + Scope, measured rather than assumed: the FROZEN SECTION is byte-identical to the + attached original (158 lines), the header block identical bar the `Status:` line, and + no measurement or journal entry was lost — only the two headings. Both restored. + **The check added for it is BOUNDED, and the bound is measured on this artefact.** The + heading list — compared against the attached original, zero missing required, currently + `original 14, repo 15, MISSING none` with the living `## Notes` as the one addition — + detects the DISAPPEARANCE OF A HEADING and not the loss of content under a heading that + survives. It caught this instance for a reason that does not generalise: the two lost + sections were the LAST in the file, so the cut that took them took their headings too. A + cut stopping before the next heading leaves that heading in place, takes its body, and + the list stays green — MEASURED: gutting `## Out of scope`'s body while keeping its + heading reports `missing = NONE`. + **So the strong control is a HASH of the frozen section**, which is a declared invariant + — this brief's own preamble says it is not modifiable outside a Claude.ai round-trip, and + until now that rule held by attention alone, which is an intention and not a guarantee. + **The control is published as a RECIPE and not as a `(size, digest)` pair**, because the + first form did not replay: the slice ends on a BLANK line, so "158 lines" and "157 lines" + name different byte strings depending on whether the final line terminator is kept, and + the review's digest and mine were both correct for their own bytes — + `2a2a3b4367006452…` for a `"\n".join` that drops the last terminator, `2251576287d2…` + for the form that keeps it. A size that does not reproduce its digest would read as a + violation to the next reader, so what is recorded is the command: + + sed -n '/^# FROZEN SECTION$/,/^# LIVING SECTION$/p' | sed '$d' | shasum -a 256 + + **`wc -l` on that slice reports 158**, `sha256 = + 2251576287d24b3b9fe5152c8f7f3888d2c4c622f332a953c1bf81e1656d4c78`, identical between the + attached original and this file, and replayable in a shell with no script. The review's + correction to 157 counts THE SAME BYTES without the final terminator — the reading whose + digest is `2a2a3b4367006452…` — so both figures are right for their own instrument, and + naming the instrument is what keeps the figure from reading as a violation. **No tolerance is needed, and that is a measurement and not an assumption**: + `Status:` and `Closed:`, the two fields the protocol lets Claude Code edit, live in the + header ABOVE `# FROZEN SECTION` — zero occurrences inside the slice. Counter-factuals RUN + against THAT recipe, three: one character changed inside the slice gives + `2f05822e4c03…`; the gutted-body case above gives `4eeeaec65c9a…` while the heading list + still reports 15 headings and zero missing; flipping `Status: ACTIVE` to `CLOSED` leaves + the digest bit-identical at `2251576287d2…`. + +**Gate B — proxies and body lifetime.** 2026-08-22. + +- **What the store cannot do.** `CharacterStore.createCharacter` builds the presence — a + `.kinematic` body carrying the controller's own capsule — but a proxy needs a LAYER, and + the store has no `BodyType` → `BroadphaseLayer` derivation. `PhysicsWorld.createCharacter` + is that half: it creates, derives the layer from the presence's OWN stored flags, inserts, + and hands the handle back through the `setPresenceProxy` seam the store already exposed + for this. Transactional — a failed insertion destroys the character rather than leaving a + presence no tree holds. +- **The layer is derived BY THE RULE and never by a literal.** `broadLayerFor(isTrigger(p), + bodyType(p))`, read off the body. A hard-coded `.dynamic` would agree with §1.13.3 today + and stop agreeing the day the rule moves, which is the whole failure mode of a second + copy. +- **Counts are PER CLASS.** `proxyCountIn(layer)` walks `forEachInLayer`, which visits the + layer's tree leaves AND its live unbounded slots — so a half-space, which has no AABB and + lives outside the trees, is counted where a tree-only walk would miss it. Every assertion + is on the four-tuple `[static, dynamic, debris, trigger]`, never on a sum. +- **The counting scene holds a character**, because the presence is inserted by the + orchestrator and by nothing else: a scene without one is green on the empty set of exactly + what the test exists to guard. +- **"Nothing outlives its owner" is measured AFTER destruction, in a populated scene.** Five + proxies remain when the character is destroyed, so a leaked one shows as an excess in its + class; destroying into an empty world and counting zero discriminates nothing. +- **Three counter-factuals RUN, and the third had to be rewritten because the first attempt + measured NOTHING.** (1) `broadLayerFor` narrowed so a kinematic sensor lands in `dynamic`: + `5 failed` — four pre-existing guards plus the new one, so the priority is not solely + guarded here; and A TOTAL WOULD NOT HAVE CAUGHT IT, by arithmetic on the same scene, since + `[0,1,0,1]` and `[0,2,0,0]` both sum to 2. (2) the insertion neutralised in + `createCharacter`: `2 failed`, both new — nothing else in the repo guards presence + insertion. (3) the proxy removal neutralised at destroy: `2 failed`, the new one plus a + pre-existing character-store test. The FIRST attempt at (3) reported `1/4 steps` and + `compilation-errors=1` — neutralising the only use of `bp` makes it an unused parameter, + which Zig refuses — so it measured nothing, and the grep for `compilation errors` is what + said so before any failure list was read. +- **A COVERAGE STATEMENT the frozen scenario forces, recorded rather than hidden.** The + canonical determinism scenario creates its character through the STORE and gives the + presence no proxy — measured, `setPresenceProxy` appears nowhere in it. Routing it through + `PhysicsWorld.createCharacter` would add candidate pairs and move the `retained pair set` + trace, so the witnesses would go red for a reason that is not a defect. The scenario is + frozen and stays untouched; the consequence is that **the determinism instrument does not + exercise presence insertion**, which the Gate B suite does instead. +- **A tooling fact, self-reported.** The first pass of these probes ran against UNCOMMITTED + work, and `git checkout -- ` restored `world.zig` from the index, destroying the + gate's own additions. The fact is in `engine-development-workflow.md` §4.8 and was still + walked into; the rule that holds is to COMMIT before probing, which is what the second pass + did. A second one the same hour: the commit that followed was rejected by the `dead-tests` + conservation hook and my own `grep` filtered the reason out of the output — a pipe masking + a hook rejection, §4.8 again, caught only because `git log` disagreed with what I had just + reported. +- **Counts, with their denominators.** `zig build test-forge-3d`: **564** collected on macOS + aarch64 (563 pass, 1 skip), against 561 at gate A — `+3`, the three blocks added. Full + suite: **1878** collected (1859 pass, 19 skip). The declared floor was re-derived FROM THE + SUITE at the value the suite reported, 1878 / 1876, never from the closure's arithmetic — + the control refused the commit until it was, which is the control working. +- **Witnesses unmoved**, both precisions locally: chain plus the four discrete traces OK, no + divergence within `K = 60`. Gate B touches insertion and lifetime, not arithmetic. + +**Gate C — wake composition.** 2026-08-22. + +- **The two intentions composed at the boundary.** The store keeps them apart by contract + (§1.8.4): `setLinearVelocity`, `setAngularVelocity`, `setPosition` and `setRotation` are + NON-activating, because the substep loop and the restitution pass drive them on every body + in contact every tick and nothing in contact could otherwise sleep. This tier adds the + wake. `addForce`, `addTorque` and `addImpulse` activate in the STORE and are delegated + unchanged, so the wake happens once and not twice. +- **W4 has one helper and five producers.** `wakeRetainedPartners` — body removal, + static/kinematic teleportation, and the controller's three (`moveCharacter`, + `setCharacterPosition`, and `resizeCharacter` ON SUCCESS ONLY, since `false` means the + target volume was occupied and nothing moved). The retained candidate set IS the wake + graph (§1.8.7): a sleeper emits nothing in broadphase, so nothing else can notice that + what supported it has moved or gone. +- **A REAL DEFECT, found by the W4 test and belonging to gate B.** `createCharacter` + inserted the presence's proxy into the broadphase but never registered the presence in + `PhysicsWorld.bodies`. `pairStillOverlaps` resolves a retained pair's endpoints through + `proxyOf`, which searches THAT list, so an unregistered presence resolved to `null` and + step 2 pruned EVERY pair involving it on EVERY tick. W4 was therefore structurally unable + to fire for the only producer the engine has, and any contact candidate against a presence + was dropped from the retained set each tick. **Gate B's own test could not see it**: it + counted proxies in the BROADPHASE, and the defect lived in the gap between the insertion + and the registration — two facts, one measured. Fixed by registering at creation and + deregistering at destroy; the proxy handle survives a `resizeCharacter`, which updates it + in place, so the registration is valid for the character's whole life. +- **A MEASUREMENT CORRECTED AN ASSERTION, not the other way round.** The window test first + expected `sleep_time == 0` after a solver-internal write and read `0.5`: a sleeping body's + window holds the ACCUMULATED value that made it eligible — `time_before_sleep` — and zero + is what a WAKE writes. The two directions therefore read DIFFERENT values on the same + field, which discriminates more sharply than the pair of zeroes first written. +- **Counter-factuals IN SCENE, at the level the claim is made.** Each W4 test carries a + second sleeper that shares no pair with the actor, so ONE act must wake one and not the + other; and the `moveCharacter` test places the presence inside the broadphase FAT MARGIN + and out of contact — close enough that the pair is retained, far enough that no manifold + is built — because a touching presence would be woken by `build`'s fixpoint (§1.8.5) and + the test would be measuring that instead of W4. Preconditions are asserted so it cannot + pass vacuously: the pair exists, the distant one does not, both bodies are asleep, and no + constraint touches the presence. +- **The named STOP condition did NOT fire, and the reason matters more than the green.** The + canonical scenario's witnesses are unchanged at both precisions. Not because the fix is + provably neutral in general, but because that scenario creates its character through the + STORE and therefore does not take the fixed path — the same coverage gap recorded in + Closing notes, now with a second consequence. +- **Counts, macOS aarch64.** `test-forge-3d` **570** collected (569 pass, 1 skip), up from + 564 — `+6`. Full suite **1884** (1865 pass, 19 skip); the declared floor re-derived from + the suite at 1884 / 1882. + + +**Gate C bis — `moveKinematic`, after the round-trip.** 2026-08-22. + +- Writes the target pose, derives both velocities over `dt`, and composes the wake like any + external pose write. `ω = 2 · vec(q_target · conj(q_current)) / dt`, sign normalised for + the short path; no trigonometry, so no `ARCH-031` rule 4 exposure and nothing to move off + a compared path. +- **The test is a ROTATION-ONLY move**, which is what discriminates: a linear-only + implementation passes a combined case, because its linear answer is right and the angular + error hides behind it. Its expectation comes from the CONTRACT — the target written as + quaternion components, not as an angle — and not from re-running the implementation's own + path. The axis is asserted alongside the magnitude, since a formula reading the wrong + components would still satisfy a magnitude-only check. +- **Two counter-factuals RUN**, and both first attempts measured NOTHING: removing the + short-path flip leaves `dq` never mutated and removing the angular derivation leaves `dq` + unused, and Zig refuses both — `compilation-errors=1`, caught by the grep before any + failure list was read. Rewritten compilable: the short-path flip removed gives `1 failed`, + the angular derivation zeroed gives `1 failed`, and it is THIS test both times. So it is + the sole guard of both properties. +- Witnesses unchanged at both precisions, as expected: kinematic bodies are not integrated, + so nothing on a compared path moved. +- **Counts, macOS aarch64** — added at Gate D, which is the defect this line closes: leaving + a gate without its count is what let the next gate's "unchanged" compare against a stale + baseline. `test-forge-3d` **571** collected (570 pass, 1 skip), up from 570 — the one test + above. + + +**Gate D — ECS synchronisation, both directions.** 2026-08-22. + +- `src/modules/forge/sync.zig` is the seam: `syncIn` before step 1, `syncOut` after step 11, + and `stepSynchronised` composing both around one tick so the ORDER is exercised by the + tests rather than left to each call site to remember. `PhysicsWorld` still imports no ECS. +- **One authority per fact, keyed on `BodyType`.** The solver owns a dynamic pose, gameplay + owns a kinematic one, a static body is synchronised in neither direction. A gameplay write + to a dynamic `Transform` is overwritten at the next publication, and that is the contract: + honouring it instead would give two authorities over one fact. +- **A write is pushed only when it CHANGED.** An unchanged value is not a mutation, and + pushing it every tick would compose a wake every tick — nothing resting on a kinematic + platform could ever sleep, and §1.8.4's separation would be undone by the seam meant to + respect it. +- **The `Sleeping` tag is ZERO-SIZE, and no precedent for that existed in the repo.** Lifted + by measurement rather than by assumption: registered, added, removed and re-added in a real + `World`, with `@sizeOf` and the empty field list pinned. It satisfies `ARCH-004`. +- **THE ORDERING ARBITRATION, and it is the whole of this gate.** An island falls asleep at + step 11, AFTER steps 6 and 7 wrote its final pose, and sync-out runs after step 11. Tag + first and that final pose is never published: the entity keeps tick N−1's pose and holds it + for as long as it sleeps. The object rests at a slightly wrong place and jumps when woken. + **The arbitration taken: the tag is REMOVED before publication and ADDED after it**, in + three passes — untag the woken, publish everything untagged, tag the newly asleep. Both + transition ticks publish, and every tick in between is skipped. The mirror ordering trades + the defect for its twin on wake, where the first moved pose would go unpublished. The + motive is written in `sync.zig`'s header, at the site, not only here. +- **The counter-factual, and what it measured is NOT what was predicted.** The prediction was + that the immobility half passes on the defect and only the value half refutes it. Measured + on the delivered scene, BOTH halves fail — so the reading had to be taken further rather + than accepted. Under the defect the sleeper wakes on **29 of 30 ticks** and its `Transform` + changes 29 times: the unpublished final velocity stays in the ECS, differs from the + solver's zeroed one, and `syncIn` pushes it back in as an external activating write. The + immobility half was therefore firing on a SECOND consequence, not on the pose. Closing that + channel — the same defect with `Velocity` removed from the entity — separates them: + + | defect "tag before publishing" | entity carries `Velocity` | `Velocity` removed | + |---|---|---| + | (b) the VALUE | fails | fails, published pose off by **1.88e-5 m** | + | (a) the IMMOBILITY | fails, through the wake/sleep thrash | **passes** | + + So the structural claim holds and is now measured, not argued: an immobility check is + satisfied by the defect, and it is the value assertion that refuses it. On the delivered + scene the guard is doubled, by two independent consequences of one cause. +- **The first attempt at that counter-factual measured NOTHING** and said so before any + failure list was read: swapping the two pass blocks textually cut a brace, + `compilation-errors=2`. Rewritten as a condition change — pass 2 testing the solver's sleep + flag instead of the tag, which is "tag before publishing" in effect — it compiles and + fires. The four probes of this gate were all run against a COMMITTED tree, after Gate B + lost uncommitted work to a `git checkout --` used as a probe. +- **`build.zig` is outside the brief's file list, and the reason is structural**: a new module + and a new test target cannot exist without being declared there. `forge_sync_module` plus a + `TestSpec.forge` flag so the sync tests link the forge modules, and + `tests/physics/transform_sync_test.zig` on the suite path. +- **Counts, macOS aarch64, and one of them was WRONG before it was measured.** The first + draft of this line read "570 collected, unchanged", copied from Gate C. Re-measured at + every gate head in a worktree, the series is 561 / 564 / 570 / **571** / **571** — Gate C + bis added the `moveKinematic` test and journaled no count, so "unchanged" was comparing + against a baseline one gate stale. `test-forge-3d` is **571** collected (570 pass, 1 skip), + genuinely unchanged from Gate C bis: the two `Sleeping` tests land in `weld_forge`, which + that target does not collect. Full suite **1893** collected (1874 pass, 19 skip), + up from 1885 — `+8` (6 sync tests, 2 component tests). Declared floor re-derived FROM THE + SUITE at 1893 / 1891, never from the closure's arithmetic. +- Witnesses unchanged at both precisions: nothing on a compared path moved — the sync seam + runs outside `step()` and the canonical scenario does not use it. + + +**F-D1 — the change predicate was missing on the outward side.** 2026-08-23, carried with +gate E's first push. + +- `World.getMut` stamps `changed_tick` UNCONDITIONALLY (`core/ecs/world.zig`) and the + `Changed` filter is built on that stamp (`core/ecs/query.zig`) — both verified by + reading, not taken on the review's word. So sync-out's `getMut` on every awake body + reported a change that had not happened, every tick. An immobile kinematic platform held + awake by a character standing on it republished a constant zero forever, and `Velocity` + being replicated with a rollback strategy, the false delta left on the wire. +- Corrected symmetrically with sync-in, which already had the rule for its own motive, and + the header now states BOTH motives rather than one. +- **The guard reads the SIGNAL and never the value**, which is the whole point: under the + defect the value is already correct. `markedThisTick` reads `archetype.changedTick` + against `World.current_tick`, the idiom `tests/ecs/change_detection.zig` established. +- **The scene is exactly immobile and not approximately so** — zero gravity, sleeping off, + one dynamic body with no contact: `v = 0` damped is `0` and `x + 0 · dt` is `x`, so a + stamp can only come from an unconditional write and never from a settling residue. Each + tick opens a NEW ECS frame and the test touches no component itself, so publication is + the only possible author. +- **Counter-factual RUN**, and the mutation changes only the signal: reading through + `getMut` instead of `get` restores the unconditional stamp with the values bit-identical. + `compilation-errors=0`, `1 failed`, and it is this test. **The first attempt destroyed + uncommitted work** — `git checkout --` on a file whose fix was not yet committed, the + Gate B lesson repeated verbatim. Everything since is probed only against a committed tree. + + +**Gate E — the precision crossing, the interface file, and the false premises.** 2026-08-23. + +- **§1.11.8 was RE-READ in the patched corpus rather than recalled**, and it had been + rewritten on 2026-08-21 into something the opening memory did not contain: THREE scalars + (world, solver, render), a public surface at the WORLD scalar and not at a literal type, + `large_world` implying `-Dphysics_f64` while the converse stays legitimate and distinct, + and the measured note that `large_world` does not exist in the repository at all. So the + crossing is written against a named `WorldReal`, which is `f32` today and is pinned as + such by a test that names the decision. +- **`forge/api/precision.zig` is the single point**, and it lives in `api/` for a reason + §1.11.8 forces: it places the boundary at "the only place that knows both scalars", and + `src/interfaces/` wraps an implementation, so `forge_3d` — where three of the four helpers + lived — cannot import it without inverting the dependency. `api/` is the only place the + solver, the ECS seam and the interface tier can all reach. +- **The four helpers had ALREADY diverged**, which is the argument made concrete rather than + in principle: `character.zig`'s `convVec3` carried an `if (Real == f32) return v;` short + circuit that `body_manager.zig`'s and `mesh.zig`'s did not, so one of the three took a + different path at the default precision. Eleven call sites rerouted, the short circuit + deliberately not reproduced, and a test measures that its removal changes no answer. +- **Gate D's own seam was a second boundary in all but name.** Measured, not assumed: the + fourteen `@floatCast` of forge production all lived in `sync.zig` and all were world ↔ + solver. They are routed through the same point; forge production now spells zero + narrowings outside it. +- **THE MECHANICAL VERIFICATION IS TWO HALVES, and neither alone is the deliverable.** + Narrowing has a token; widening has none, `f32` coercing to `f64` implicitly. So: + + | counter-factual | f32 | f64 | + |---|---|---| + | a narrowing put back in a production file | `lint rc=1`, named at `body_manager.zig:322:12` | same | + | a widening site bypassing the crossing | **compiles clean** | `error: expected type 'math.vec.Vec(3,f64)', found 'math.vec.Vec(3,f32)'` | + + The f32 column is the finding: at the default precision the two scalars coincide, the + type system proves NOTHING, and the six `f64` cells are what make that half a check. +- **The lint rule's scope is a deliberate exception to its sibling's own argument**, stated + in the rule: `no_float_reduce` refuses path allowlists because a float reduction in a + bench corrupts the measurement; a `@floatCast` in a test IS the assertion's arithmetic, a + test comparing a solver value to a published `Transform` having to narrow one of them to + compare at all. Twenty-one such sites across seven files. The residual is named — a + production-grade helper written inside a test file escapes — and the escape marker has + ZERO users, so its behaviour rests on the rule's own tests and on nothing else. +- **The false-premise class had FOUR members, not the two the Scope names.** `api/types.zig` + lines 551 and 886 carry the same argument as 1042 and 1116. Correcting two and leaving two + standing beside them is the exact motif this repository named at M1.1.11.1 — corrected + text added without deleting what it replaced — so the class was swept. `ARCH-018` was read + in its current text and states the contract in full: a leading `struct_size` the HOST + fills, a minor version beside the major, and members appended AT THE END only. So a + defaulted field appended at the end is the supported minor addition, and what the count + asserts really guards is an insertion ANYWHERE ELSE. The `ground_body` sentinel keeps its + own, stronger reason: `struct_size` says which fields were SENT and never what a sent + value means, and `0` is a well-formed handle to slot 0. +- **`engine-c-api.md` is NOT in this milestone's attached set**, and the two copies on this + machine date from July, before the 2026-08-21 patch — measured. So the correction rests on + `ARCH-018`'s current text, which I did read, plus the frozen brief's statement that §1.1 + bis carries both. Nothing is quoted from §1.1 bis. The same false claim appears in the + CLOSED briefs of M1.1.12 and M1.1.13; those are records of what was believed then and are + NOT retro-patched. +- **The alias rename cost one line, and the zero-consumer claim was verified** rather than + inherited: `forge_3d.CharacterMoveResult` had exactly one declaration and no consumer, the + other four mentions being doc comments that correctly name the API type. It is now + `MoveResult`, the internal type's own name. +- **`src/interfaces/PhysicsModule.zig` is created on the NARROW reading**, and the wide one + is named so the arbitration is reviewable. The wide reading mirrors §1's whole + `PhysicsModule(comptime Impl: type)` with its twenty-seven `assertFn`s; the narrow one + creates the file, unfrozen, holding the moved contract. Narrow, for two MEASURED reasons: + the assert block IS a surface guard and surface guards are M1.1.26's by this brief's own + Out of scope, and a block covering three of twenty-seven entries would PASS an + implementation missing the other twenty-four — a check that under-checks reads as a check; + and the block's first entry is typed `fn (*core.ModuleContext) anyerror!Impl` while + **`ModuleContext` does not exist in this repository** — the name appears in three comments + and in no declaration, and `src/interfaces/` did not exist either. Minting it would be + inventing a Tier 0 type that reaches the scheduler and the asset loader. +- **The non-freeze is ATTESTED and not left to a silence**: a test fails the day + `WELD_PHYSICS_PROTOCOL_VERSION` appears, with a non-vacuity half proving `@hasDecl` finds + what is really there. Counter-factual RUN: declaring the constant gives + `compilation-errors=0`, `1 failed`, and it is that test. +- **Counts, macOS aarch64.** `test-forge-3d` **571** collected (570 pass, 1 skip), + unchanged — this gate adds no forge-internal test. Full suite **1906** collected (1887 + pass, 19 skip), up from 1894: `+1` F-D1, `+3` `precision.zig`, `+7` the lint rule, `+2` the + interface file. Floor re-derived FROM THE SUITE at each step, never from the closure's + arithmetic — which the guard refused once, correctly, and said so in those words. +- Witnesses unchanged at both precisions; `forge-determinism` green at f32 and f64, 4/4 + traces each, chain OK at 1000 frames. + + +**Closing review pass — F-F1 and F-F2, both on `no_precision_crossing`.** 2026-08-23. + +- **F-F1 — the guard did not cover the place §1.11.8 puts the boundary.** `governs()` was + restricted to `src/modules/forge/`, and a test locked that restriction, so it was a choice + rather than an oversight — but a wrong one: the diagnostic claims that §1.11.8 makes the + boundary UNIQUE, which is a statement about the engine, from a control that looked at one + module. §1.11.8 places the boundary AT THE INTERFACE TIER, and `src/interfaces/` — created + by this milestone — is where M1.1.26 writes the adapter, which is conversion code by + definition. The argument for putting the crossing POINT in `api/` still stands and is + about dependencies; it says nothing about where the guard must look. The perimeter is now + a table of two prefixes, each carrying its reason, and the locking test is REWRITTEN onto + the real perimeter rather than deleted: it asserts the interface tier is flagged, the + forge module is flagged, and `core` / `foundation` / `etch` are not, on the same source. + **Counter-factual RUN on the tree**: a narrowing added to `src/interfaces/PhysicsModule.zig` + gives `lint rc=1` naming `PhysicsModule.zig:134:12`. It was invisible before. +- **F-F2 — the escape was not counted, and the pattern for counting it is in the same + binary.** `WELD_NOT_A_WORLD_CROSSING` exempted a site with nobody watching, and a guard + whose exclusions are invisible converges towards zero coverage through additions each of + which is locally justified. `dead_tests` already answers this with a DECLARED exclusion + list and a bilateral control, so the same shape is applied: `declared_escapes`, entries + carrying `file` / `reason` / `owner`, and the marker takes effect ONLY for a declared file. + Both halves are checked — an undeclared marker is reported at its site with a message + naming which of the two things to fix, and a declaration nobody used is reported as stale. + **The list is EMPTY, and zero is the cheapest moment in the project's life to install the + mechanism** rather than the moment a first exemption argues for itself. +- **The three states measured ON THE TREE, not only in unit tests**: marker without a + declaration → refused; the same site declared → `lint rc=0`, zero diagnostics; the + declaration kept and the site removed → stale, reported at `file:1:1`; restored → zero. +- **One defect of my own, caught before it shipped.** The first version of the stale-half + test re-implemented `checkDeclarations`' body inside the test, over a fixture — the + "scratch copy instead of the shipped code" motif this repository named at M1.1.11.1. The + list being empty, a test driving the real entry point alone would have passed against a + function that returned immediately, which is why a fixture was needed at all. Resolved by + splitting the body into `reportStale(declared, seen)` and keeping `checkDeclarations` as + the ONLY binding to the real list: the test drives the shipped function over a non-empty + fixture, and no caller can point the tree's control at a fixture by accident. +- **A zero-length array cannot be indexed at comptime**, even on a branch that cannot be + reached at a count of zero — `tally.seen[i]` refused to compile. Indexing through a slice + moves the bound to run time, which is what lets the mechanism exist at the count it must be + installed at. Measured, not worked around: the alternative was to seed the list with a + placeholder entry, which is exactly the invisible exemption the control exists to prevent. +- **Counts, macOS aarch64.** Suite **1909** collected (1890 pass, 19 skip), up from 1906 — + `+3`, the rule's own tests. Floor re-derived FROM THE SUITE at 1909 / 1907; the guard + refused the first attempt and printed its standing instruction, which is the second time it + did so on this milestone. + + +**Closing NO-GO pass — six confirmed findings, one rejected, one class swept.** 2026-08-23. + +- **THE CLASS, and it is what the three separate findings were.** A property held by one + entry and not by its twin. Reviewed pair by pair over `world.zig`'s whole surface on TWO + properties — transactionality and wake composition — rather than at the three reported + sites: `addBody` ↔ `createCharacter` (transactional: only the second was), `removeBody` ↔ + `destroyCharacter` (W4: only the first did), `setBodyTransform` ↔ `moveKinematic` (both + committed before their fallible step), `moveCharacter` / `setCharacterPosition` / + `resizeCharacter` (all three already compose W4, and `resizeCharacter` correctly only on + success), `setLinearVelocity` / `setAngularVelocity` ↔ `addForce` / `addTorque` / + `addImpulse` (all five already correct on §1.8.4's split). Three pairs were defective, three + were not; what the review covered is the six pairs and not the three findings. +- **F1 — `addBody` had three fallible steps and no `errdefer`.** A failing `bp.insert` left a + body no proxy represents; a failing `bodies.append` left a body AND a leaked proxy that step + 10 would keep updating for a registration that does not exist. Guarded by an EXHAUSTIVE + FAIL-INDEX SWEEP (the M1.1.1-HF2 shape), the allocation count measured from a successful + call rather than guessed, and THREE observables asserted after each failure — the body + store, the proxy count, the registration list — because any single one passes against two of + the three leaks. Counter-factual: removing both `errdefer`s gives `1 failed`, that test. +- **F2 — both pose writers committed before the proxy refresh.** After a failure the + broadphase described a body that had moved. And Codex's sub-point is exact: `moveKinematic` + derives its velocities FROM the current pose, so a retry computed `target − target` and + published ZERO for a move that had happened — the lie this entry exists to remove, arriving + by the error path. Pose (and velocities) restored on failure; wakes deliberately NOT rolled + back, a spurious wake costing simulation time and never an answer. The failure is DRIVEN, + not assumed: `Broadphase.update` allocates only when the moved log must grow, so a single + move never reaches the error path — the first version of both tests asserted + `error.OutOfMemory` and got `void`. They now move until an allocation is genuinely needed + and assert `failed`, without which they would name a branch they never entered. + Counter-factual: emptying both `errdefer` blocks gives `2 failed`, those two. +- **F3 — one entity, two bodies, on the milestone's central path.** `character.zig` creates + the presence with `.entity = desc.entity`, so the sync seam walked the controller's inner + body as if it answered for the character. OUTWARD it published the presence's velocity — + exactly zero forever, a presence being kinematic and moved by pose write — over the entity's + own, every tick. INWARD a gameplay `Transform` write read as a kinematic pose change and + went through `setBodyTransform`, TELEPORTING the presence past `moveCharacter`'s sweep and + depenetration. The distinction is carried on the registration record (`BodyKind`) because it + cannot be recovered from the entity or the body type. Counter-factual: removing the four + skips gives `1 failed`, the test that measures both directions. +- **F5 — the synchronisation had no caller outside its own tests.** Three systems are + registered, and THREE rather than the two the Scope names is a reading with a reason: a + sync-in and a sync-out with nothing between them advance no simulation, which is F5's own + complaint one level down. **The order cannot come from the accesses** — the DAG is forward + dataflow and two writers of one component in one phase is a `WriteWriteConflict` by + construction, and all three write the solver — so it comes from the phases: `pre_update`, + `fixed_update` (which `ARCH-031` names as inside the float perimeter), `post_update`. + Counter-factual: registering two instead of three gives `1 failed`, the end-to-end test. +- **Two findings of my own inside F5, neither reported by the review.** `ctx.gpa` is the + PER-FRAME allocator while `refreshProxy` reaches `Broadphase.update`, which reserves memory + the tree keeps ACROSS ticks — handing it the frame allocator would free that memory under + the tree at frame end, so the published handle carries the persistent allocator and says + why. And the two `Sleeping` transitions are STRUCTURAL, so inside a scheduler they must be + recorded and applied at the phase flush; that forced the publication predicate to become + **tagged AND still asleep**, since the tag alone was correct only while pass (1)'s untag + applied immediately. The new form depends on no ordering at all and both halves of the + original trap stay closed. +- **F6 REJECTED, and the corpus is what rejects it.** §1.8.5 names W4 as "removal of a body, + or teleportation of a static/kinematic", and the reason it gives — a sleeper emits nothing + in broadphase that could notice — holds identically for a teleported DYNAMIC body: its own + island wakes by W1 then W2, and the sleepers of ANOTHER island retained in a pair with it + wake by nothing. Restricting W4 to the two named kinds would manufacture a silent false + negative on a legal configuration, which §1.13.6 refuses in as many words. The code is + right and the corpus is narrow; the amendment is carried by Guy in the corpus pass, and + `setBodyTransform` now states the deliberate breadth with that motive so the next review + does not reopen it. +- **F7 — SIGNAL, not a fix.** `proxyOf` is linear and `pairStillOverlaps` calls it twice per + retained pair per tick, which is step 2 of the cycle. Real, and outside a brief whose only + performance requirement is a 5 % non-regression on the existing benches. Recorded with an + owner in the open decisions. +- **THE DISAMBIGUATION I OWED, and it corrects my own Gate F signal.** + `PhysicsModule.zig:134:12` was an INJECTED probe and not a discovered site — the pushed file + is 130 lines, so the line does not exist in it. The signal said "a narrowing ADDED to", but + a line number quoted beside a real path reads as a finding, and that is the reading to + refuse. +- **F8/F9 — and the first counter-factual for them measured NOTHING.** The declared escape was + matched with a POSIX-spelled suffix against a path the runner spells with backslashes on + Windows, so the escape could never fire there and the tree would go red on one platform + only. The awareness already existed one function below, in `isTest`. Reverting the fix + failed NO test: the list being empty, `declaredIndexFor` never iterates, and the only test + on it drove `endsWithPath` directly — proving the helper correct without proving the call + site used it. Split into `declaredIndexIn` so a fixture drives the SHIPPED lookup; the + replayed probe then fails exactly that test. Same technique as `reportStale`, same reason, + found by my own probe rather than by review. +- **`git checkout --` destroyed uncommitted work a THIRD time**, on the very pass whose + Closing notes record "probe only against a committed tree" as a standing rule. Recorded as + it happened rather than smoothed: the rule was written and then not applied, which makes it + a discipline that needs a mechanism and not a resolution. +- **Counts, macOS aarch64.** Suite **1918** collected (1899 pass, 19 skip), up from 1909 — + `+9`: three transactionality tests and a W4 test in `world_test.zig`, a presence test and + the registration test in `transform_sync_test.zig`, three on the lint rule. `test-forge-3d` + **575** collected, up from 571. Floor re-derived FROM THE SUITE at 1918 / 1916. + + +**Second NO-GO pass — six findings, two settled by the corpus rather than by preference.** +2026-08-23. + +- **P1-1 — a gameplay `Velocity` write was LOST, and the fix is a merge and not an ordering.** + Phases run `pre_update → fixed_update → update → post_update`, so a rule writing `Velocity` + in `update` writes it after sync-in read it, and a publication in `post_update` overwrote it + before the next frame could see it. C1.1 requires in as many words that an Etch system can + write `Velocity` and the solver apply it, and it could not. **An intra-phase constraint is + not available and inventing one would be the defect**: the scheduler has no + `runs_before` / `runs_after`, the only intra-phase order is a `Writes(X) → Reads(X)` edge, + and giving the tick a declared access purely to manufacture that edge would make the + declared set a lie — `ARCH-030` makes that set the TYPE of the view a system receives. The + tick and the publication become ONE `fixed_update` system, so the order is a line of code. + Two systems, not three. **The guard writes `Velocity` FROM AN `update` SYSTEM**, never from + the test body between dispatches: a write issued outside every phase is picked up by the + next `pre_update` whatever the publication does, so a test shaped that way passes under the + defect. Counter-factual: splitting the merged system back into three phases gives + `3 failed`, and the decisive one is the C1.1 test. +- **P1-2 — a trigger body publishes nothing and still receives.** Excluding only the character + presence covered the instance and not the class: §1.13.1 states that a MIXED BODY DOES NOT + EXIST and that an entity which must both collide and detect carries TWO bodies, and §1.13.8 + rule 2 restates it when it drops the reflexive pair. Which body carries the facts is settled + by §1.13.7 — a trigger has no manifold, no constraint and no impulse, so it has no resolved + fact at all. **This reading was CORRECTED at the fourth pass**: the island exclusion is the + CONSTRAINT island only, and the amended §1.13.7 grants a dynamic trigger the INTEGRATION + SINGLETON. Excluded from publication, KEPT in reception, and an entity's + `Sleeping` marker is its solid body's. The guard registers the trigger AFTER the solid, so + under the defect the trigger wins the last write, nine metres away — not a tolerance + question. Counter-factual: removing the three publication skips gives `1 failed`, that test. +- **The reception assertion was wrong on its first form and the measurement said so.** It + asserted the trigger's pose equals the pose published at the END of the same tick, and + sync-in runs BEFORE the step: the trigger carries what the ECS held going IN. Restated + exactly on the one-tick relationship rather than loosened into a tolerance — `3.4513178` + against `3.5043554` is a tick of fall, not noise. +- **P1-3 — the resolver registered its handle type on the FRAME allocator.** `resolve` called + `ensureComponentRegistered` to obtain an id, and a registration keeps the type's name for + the world's whole life. It is the same defect this file already avoided one level up, + arriving through the lookup. Now a pure `componentId` lookup that allocates nothing; + `publishPhysicsWorld` is the only site that registers and it takes the persistent allocator. + The counter-factual is a dispatch BEFORE publication — a test that always publishes first + finds the type already registered and never takes the other path — and the assertion is on + the REGISTRY rather than on the absence of a crash. Restoring the registering form makes + that test **abort** rather than fail: the testing allocator detects the retained memory, + which is a stronger signal than the assertion asked for. +- **P1-4 — no action, and the debt is not this milestone's.** §1.8.5's letter is narrower than + its own reason; Guy produces the amendment before merge. The type guard is NOT restored and + the comment stands: it is correct, it is dated, and the corpus is what catches up. +- **P2-5 — PREFLIGHT, not idempotence, and the reason is which failure is real.** Calling + `registerSystems` twice is what a caller can actually produce and it is deterministic: both + names are checked absent BEFORE the first registration, so a second call mutates nothing and + reports `error.SystemAlreadyRegistered`. Idempotence would accept it in silence, hiding a + double wiring rather than naming it. The count is what discriminates in the test — an + implementation that registered then failed would leave three. RESIDUAL, stated: an + allocation failure between the two leaves the first standing, since `SystemScheduler` exposes + no removal. Counter-factual: removing the preflight gives `1 failed`, that test. + **Superseded at the eighth pass**: with one registered system that residue is gone, and what + remains is a different Tier 0 debt — `registerSystem` is not transactional for itself. +- **P2-6 — my owner was BEHIND the gate it makes fail.** C1.1 requires 1 000 dynamic and + 10 000 static bodies at 60 Hz, M1.1.26 closes C1.1, and M1.1.25 executes AFTER M1.1.26. + `M1.D.13` repointed to M1.1.26 or earlier. +- **P2-7 — I reported F8 and F9 as one item and they are two.** `runLint` accepts a partial + file list and then checked EVERY declaration, so the first real exemption would be reported + stale by any targeted scan — a guard failing on correct input, the worst kind, because the + fix a reader reaches for is deleting the declaration. The aggregate control now runs only on + a full scan. Counter-factual, with a REAL exemption in place: full scan clean either way, + targeted scan on an unrelated file clean WITH the gate and reporting the declaration stale + WITHOUT it. +- **The disambiguation, restated because it was owed twice.** + `PhysicsModule.zig:134:12` was an INJECTED probe and not a discovered site — the file + carries 130 lines, so the line does not exist in it. +- **Counts, macOS aarch64.** Suite **1922** collected (1903 pass, 19 skip), up from 1918 — + `+4`. `test-forge-3d` **575**, unchanged: nothing this pass touched is forge-internal. + Floor re-derived FROM THE SUITE at 1922 / 1920, and the guard refused the first attempt + again. + +**Third NO-GO pass — the trigger rule was wrong, and a dangling access set fell out of it.** +2026-08-23. + +- **P1-1 — exclusion by NATURE was too broad; the rule is exclusion by CONCURRENCY.** §1.13.7 + says a trigger has no manifold, no constraint and no impulse — and says NOTHING about + integration, its island exclusion covering the CONSTRAINT island and not the integration + singleton. `integration.zig` filters on `body_type != .dynamic` and never on + the trigger flag, so a `.dynamic` trigger falls under gravity and its pose IS a fact the + solver resolved. Excluded from publication it drifted away from its entity in silence, and + its detection volume with it. **A trigger yields only when another body on the same entity + publishes for it** — a `rigid_body` registration that is not itself a trigger, so a + character presence does not count and two triggers settle nothing between them. That covers + the four cases without restricting any: solid + trigger, the solid publishes; lone dynamic + trigger, it publishes its integrated pose; lone kinematic trigger, it receives from + gameplay; lone static trigger, nothing, like any static. Reception never changed — it is + governed by type. The trigger's TYPE is not restricted in the contract: `BodyDescriptor` + admits all three and the surface freezes soon. Counter-factual: reverting to the by-nature + form gives `1 failed`, the lone-dynamic-trigger test. +- **P1-2 — a FUTURE conflict, written down rather than fixed.** `engine-coordinate-system.md` + §4.3 runs `TransformSystem` at the head of `fixed_update` AND in `post_update`, writing + `Transform` in both, while `scheduler.zig` makes two writers of one component in one phase a + hard registration error with no declarative ordering to resolve it. `TransformSystem` is + declared NOWHERE in this repository — measured — so nothing is broken today, and the + conflict is DECLARATIVE rather than physical: §4.3 has dynamic bodies carry no parent by + convention, so the two systems write disjoint entity sets and the access model cannot say + so. A test registers a competing `Transform` writer in `fixed_update` and observes + `error.WriteWriteConflict`, with a non-vacuity leg registering the same writer in a phase + forge does not write. The Tier 0 decision it waits on is a Closing-notes precondition. +- **P2-3 exposed a defect of MINE that had nothing to do with P2-3.** Declaring + `WritesResource(PhysicsWorldRef)` was the fix asked for; writing the test that guards it is + what found the real problem. **A `&.{ ... }` literal in the descriptor passed to + `registerSystem` is a TEMPORARY**, and `SystemScheduler` stores the descriptor with that + slice — so once `registerSystems` returned, every `type_name` in it pointed at dead stack. + The probe printed `type=''` and then took a FAULT. Nothing had crashed because the DAG edges + are computed AT registration and no later path reads the accesses; the first thing that did + was this very test. The tree's other call sites survive by ACCIDENT, registering inside the + function that consumes the scheduler, so the API invites it. Fixed by giving both access + sets file scope. +- **And the first P2-3 counter-factual MEASURED NOTHING**, which is what led there: dropping + the declaration left every test green, because no test asserted the declared set. A + declaration nobody checks is the class this milestone keeps finding, and it was mine. + Replayed after the guard exists: `1 failed`, that test. +- **P2-5 — I traded a false positive for a false negative.** `full_scan` meant "no argument", + so `weld_lint lint src bench tests tools` names every default root and covers everything yet + skipped the aggregate control. The test is now on COVERAGE. Measured with a STALE + declaration in place, since a USED one makes all four spellings agree at zero and + discriminates nothing: no argument reports it, `src bench tests tools` reports it, one + unrelated file stays silent, and `src` alone stays silent. +- **P1-4 — the amendment landed, so two of my sentences became false.** §1.8.5 is amended in + KB and mirror; `world.zig`'s W4 block no longer says the letter is narrower than its reason + nor that the amendment is carried separately. The REASONING is kept verbatim, because it is + what the amended text rests on. +- **`git checkout --` destroyed uncommitted work a FOURTH time**, on a test written minutes + earlier. The rule as written was not enough because it names an intention. What replaces it + is a MECHANISM: copy the file to the scratchpad before mutating, restore from the copy. It + proved itself in the same session — a probe timed out mid-run and left the mutation in the + tree, and the backup restored it where a `checkout` would have taken the surrounding work + with it. +- **Counts, macOS aarch64.** Suite **1927** collected (1908 pass, 19 skip), up from 1922 — + `+5`. `test-forge-3d` **575**, unchanged. Floor re-derived FROM THE SUITE at 1927 / 1925. + +**Fourth NO-GO pass — election, watermark, and one divergence carried to the corpus.** +2026-08-23. + +- **P1-1 — the concurrency rule was incomplete, and an ELECTION replaces it.** Yielding "when + a solid body exists" arbitrates solid-against-trigger and nothing else: two triggers on one + entity do not exclude each other, and two solids never enter the arbitration at all. The + descriptor admits both, so both were legal configurations in silent last-write-wins — what + §1.13.6 refuses in as many words. One criterion at two levels: the non-trigger of smallest + identity if one exists, else the trigger of smallest identity, a character presence never + eligible. **Identity is the COMPLETE handle**, index and generation together as §1.13.11's + ordering key is, which makes the election independent of insertion order and therefore + deterministic. Two tests, each asserting the published VALUE is the elected body's and not + merely that one write happened — the loser is eight metres away, so an election that picked + the other cannot satisfy them. Counter-factual: reverting to the concurrency form gives + `2 failed`, both of them. +- **P1-2 — sync-in was UNDOING `moveKinematic`, and the fix is F-D1 mirrored.** The entry + writes pose and both velocities into the SOLVER; gameplay owns a kinematic `Transform` and + physics never publishes it, so a value comparison read "they differ" and pushed the stale ECS + pose back through `setBodyTransform`, cancelling the move before the step — same for the + velocity setters and for teleporting a kinematic. A value comparison CANNOT tell "gameplay + wrote" from "the solver moved"; the change stamp can, and it is exact rather than heuristic + because the outward direction never stamps a component it does not own. Sync-in now pushes + only what was written since its own last run. The adapter does NOT mirror mutations into the + ECS: that would make every interface entry know the ECS, and `PhysicsWorld` importing + nothing of it is a property this milestone has held since gate D. +- **The watermark had to be INCLUSIVE, and the boundary case is the normal one.** Sync-in runs + in `pre_update` and records the tick it ran at; a gameplay write in `update` of that SAME + frame carries the same tick number while being strictly later in time. With `>` the C1.1 + write from an `update` system never reached the solver — measured, five tests red — and `>=` + is what makes the ordinary path work. Re-pushing a value published at the same tick is + harmless because the value comparison still has to find a difference. +- **One storage for the watermark, and the direct path pays for it.** It lives in the resource, + so `syncIn` now requires a published world and reports `error.PhysicsWorldNotPublished` + otherwise. Thirteen test call sites gained a `publishPhysicsWorld`. A second copy for the + direct path would be a second thing to drift — the defect class this milestone opened on. +- **And the direct path needed a FRAME BOUNDARY it did not have.** The stamp gate is + meaningless while `current_tick` never advances, and `stepSynchronised` must not call + `beginFrame` itself — the boundary belongs to the app loop, which is what `dispatchFrame` + does. Every direct drive in the test file now goes through one `frame()` helper that opens + the frame first, so both paths have the same shape. +- **P1-3 — the code is right, the corpus is imprecise, and the divergence is CARRIED not + patched.** §1.13.7 point 1 deduces "no pair reaches it, HENCE no manifold, no constraint, no + impulse, no island entry", and the deduction covers islands formed BY PAIRS. A singleton + island comes from the enumeration of dynamics, and a dynamic trigger is integrated by + gravity: without an island it could never sleep and would be integrated forever. + `island_manager.zig` is UNTOUCHED. Reported to Guy, who amends §1.13.7 before closure — + recorded here on 2026-08-23 so the next reader finds the divergence and not a silent + disagreement. +- **P2-4 — preflight the DESCRIPTORS, not the names.** A `WriteWriteConflict` is ordinary and + deterministic, not an allocation residual: another system already writing `Transform` in + `fixed_update` would have let the first registration land and refused the second, which is + the partial state the preflight exists to prevent. Both systems' writes are checked against + the phase before the first registration. +- **P2-5 — normalised before judging.** `src`, `./src` and an absolute `/…/weld/src` name one + directory and gave three verdicts. A leading `./` is stripped, trailing separators are + stripped, and a path counts when it IS the root or ends with it as a WHOLE component — the + separator check being what keeps `mysrc` from counting, pinned as its own non-vacuity leg. + `weld_lint/main.zig` was outside every test closure, so the helper was untestable where it + lived; it is wired in, which is what let these three tests exist at all. +- **P2-6 — D17, third instance on this milestone.** The header block still argued the + exclusion it had replaced, and under that wording it invited reintroducing the blanket skip + this pass removed. Rewritten on the election, with an explicit "do not reintroduce a blanket + skip here" and the two reasons why. +- **Counts, macOS aarch64.** Suite **1933** collected (1914 pass, 19 skip), up from 1927 — + `+6`. `test-forge-3d` **575**, unchanged. Floor re-derived FROM THE SUITE at 1933 / 1931. + +**Fifth pass — the re-scope, and one election made linear.** 2026-08-23. + +- **`syncIn` OUT, with everything only it needed.** `changedSince`, `recordSyncInTick`, + `vecFrom`, the `last_in_tick` field, the `pre_update` registration and its access set. + `stepSynchronised` becomes `stepAndPublish`, which is what it now does — a name that still + claimed both directions would be D17 a fifth time. Four reception tests removed and two + reduced to their publication half, each saying in place what left and where it resumes. + Test removal declared: `gameplay Velocity write is applied by the solver and wakes the body`, + `a gameplay Velocity written from an update system reaches the solver`, `a lone kinematic + trigger still receives what gameplay writes`, `moveKinematic issued from an update system is + not undone by the next sync-in`; `authority per BodyType` → `publication authority per + BodyType`; `a trigger body publishes nothing and still receives the entity's pose` → `a + trigger sharing an entity with a solid body does not publish`. +- **P1-3 — the election was 3·N² and is now one pre-pass.** Asking "who is my entity's + publisher?" from inside each of three passes swept the whole list each time: 363 million + comparisons at C1.1's 11 000 bodies, paid in full by a scene with no trigger and no + multi-body entity. The question is per entity and its answer is fixed for the tick, so it is + answered ONCE, by sorting candidates on the total key `(entity, is_trigger, body)` and taking + the head of each entity run — the two-level criterion expressed as an ordering. No hashed + container, per §1.13.11's discipline. RESIDUAL, stated: two allocations per tick for the + scratch, O(1) in count and O(N) in size, which replaces an O(N²) sweep. +- **A counter-factual on that pre-pass MEASURED NOTHING, and closing it added a test.** + Removing the criterion's SECOND level — triggers sorting after solids — left every test + green, because in each of them the solid happens to be created first and identity alone + already elects it. The new test creates the TRIGGER first, so the loser holds the smaller + handle and only the second level can elect the solid; the replayed probe then fails exactly + that test. Electing the last of a run instead of the head fails three. +- **P2-4 — not corrected, and it is now moot**: its residual was the watermark's second + registration, and the watermark left. Recorded in the deferral entry rather than fixed. +- **P2-5 — D17, FOURTH instance on this milestone.** The header still read §1.13.7 as + forbidding any island entry. The amended text distinguishes a CONSTRAINT island, which a + trigger cannot enter by construction since no pair reaches it, from an INTEGRATION SINGLETON, + which it enters explicitly — the singleton comes from the enumeration of dynamics, and + without one a dynamic trigger could never sleep and would be integrated forever. Both the + comment and this journal now carry the distinction. +- **P2-6 — a suffix match let `/tmp/elsewhere/src` cover `src`, and the exact fix is + UNAVAILABLE.** Canonicalising against the repository root is what this wants; Zig 0.16 + removed `std.fs.cwd()`, `std.Io.Dir` has no `realpath` and `std.process` no `getCwd` — the + same removal `M1.D.5` records. Rather than a heuristic wrong in the other direction, an + absolute path is no longer evidence of coverage at all: the control stays OFF, which costs a + full scan spelled absolutely and cannot switch it on for a tree nobody read. `zig build lint` + and the hook both pass relative roots. Pinned in both directions. +- **Counts, macOS aarch64.** Suite **1930** collected (1911 pass, 19 skip), down from 1933 and + then up: `−7` for the re-scope, `+4` for the linter and the election's second level. + `test-forge-3d` **575**, unchanged. Floor re-derived FROM THE SUITE at 1930 / 1928. + +**Sixth pass — a false unavailability, and the control that made the question moot.** +2026-08-23. + +- **P2-2 — MY JUSTIFICATION WAS FALSE, and that is the finding.** I wrote that canonicalising + a path was unavailable at Zig 0.16 and switched a control OFF on that basis. + `std.process.currentPathAlloc` and `std.Io.Dir.realPathFileAbsoluteAlloc` both exist — + verified in the standard library, which is where it should have been verified the first time. + What produced the error is named rather than glossed: I concluded by ANALOGY with `M1.D.5`'s + `std.fs.cwd()` removal, and my two greps were themselves wrong — one on + `lib/zig/Io/Dir.zig` instead of `lib/zig/std/Io/Dir.zig`, the other searching `getCwd` by + name rather than for a cwd API by function. **An absent API is measured like a test count.** +- **And the correct design asks no such question.** The stale half now follows VISITED FILES: a + declaration is stale when its file was READ and carried no marker, and a file nobody opened + says NOTHING. No completeness to establish, no `coversEveryRoot`, no `realpath`, and neither + a false positive nor a false negative whatever spelling a caller uses. Two earlier forms tried + to establish completeness — first from an empty argument list, then from a set of root names — + and each traded one wrong verdict for another across three rounds. The residual reasoning + leaves is a declaration whose file was DELETED, never visited hence immortal, closed by an + existence test on the declared path. +- **The new control is measured on FOUR combinations, on the shipped body over a fixture**: + read and unmarked → stale; read and marked → silent; not read → silent; file missing → + reported whatever the visit. Counter-factuals: reporting unvisited declarations fails that + test, and an existence test that always answers yes fails it too. The second probe's first + form measured NOTHING — removing the block left two unused bindings — and was rewritten + compilable before any failure list was read. +- **P2-3 — D17, FIFTH instance, and the most visible.** The file header still announced + "ECS ↔ solver synchronisation, both directions", a sync-in before step 1 and an authority on + reception, with no `syncIn` in the file; `registerSystems` still described two systems and a + `pre_update` phase. Rewritten publication-only throughout, with the absence of the inward + direction stated as a decision and its reason, so the next reader does not take the gap for + an oversight. +- **P2-4 — two test comments still cited the total island exclusion.** Aligned on the amended + §1.13.7: the CONSTRAINT island a trigger cannot enter, no pair reaching it, against the + INTEGRATION SINGLETON it does enter — without which a dynamic trigger could never stop being + integrated. The brief's two earlier statements of the old reading now carry the correction in + place rather than standing beside it. +- **P2-5 — a promise retracted rather than honoured.** "Independent of insertion order" was + false: `BodyId` is handed out in creation order, so the election is reproducible FOR A GIVEN + SEQUENCE of operations and not invariant under a permutation of creation. That is all the + publication needs — one stable answer per tick, not the same answer across two + differently-built worlds — and no authored identity is minted to hold a promise nobody reads. +- **Counts, macOS aarch64.** Suite **1927** collected (1908 pass, 19 skip), down from 1930 — + and the delta RECONSTRUCTS the number, which the first version of this line did not: **5 + removed, 2 added, net −3**. Removed: the three path-coverage tests, with the machinery they + measured, plus the two old stale tests. Added: two, replacing those two — one of them a + rename, which is why "three go, one arrives" read as −2 and did not reach 1927. A delta + narrative that does not rebuild its own figure leaves the next reader to find a silent loss + that is not there. `test-forge-3d` **575**, unchanged. Floor re-derived FROM THE SUITE at + 1927 / 1925. + +**Seventh pass — two undeclared effects and a state sweep.** 2026-08-23. + +- **P1-1 — `Writes(Sleeping)` was missing.** The publication adds and removes the marker and + declared only `Transform`, `Velocity` and the solver resource, so another writer of + `Sleeping` in `fixed_update` passed the preflight with no conflict — and `ARCH-030` makes the + declared set the TYPE of the view a system receives, where a body mutating an undeclared + component stops compiling at M1.A. Declared, with one line saying what the model cannot say: + the transitions are STRUCTURAL, so `Writes` is the closest expression available and not a + description of the effect. Conflict test written as the twin of `Transform`'s, non-vacuity + leg included. Counter-factual: dropping the declaration gives `1 failed`, that test. +- **P1-2 — `removeBody` accepted a character presence, and the sequence needs nothing + illegal.** `getCharacterInnerBody` is a public entry; its `BodyId` handed to `removeBody` + released the proxy, the registration and the body, after which `destroyCharacter` released a + proxy the broadphase had already freed and tripped `Broadphase.remove`'s assertion. A NO-OP + and not an error, because the frozen signature returns `void` and the repository already + answers an unhandleable handle that way (`setPresenceProxy`). **The filter sits before every + mutation, `wakeRetainedPartners` included** — a refused call that had still woken the scene + would be a side effect with no operation behind it. The test is the full sequence, and its + assertion is the PER-CLASS proxy count after the first call: a no-op that had still released + the proxy would satisfy a total and fail here. Counter-factual: removing the filter gives + `1 failed`, that test. +- **P2-3 — the STATE documentation swept, and the journal deliberately not.** `CLAUDE.md`'s + current-milestone row still described a bidirectional scope and a draft PR; the test file + header still announced "ECS ↔ solver, both directions"; `world.zig` still placed a sync-in + before step 1 and `sync.zig` still registered "two systems". These texts say what is + DELIVERED and now say the one system, step plus publication. Three further mentions were + checked and KEPT because they name something else: the marker's two transitions, the + deferral note that says where the reception half went, and `TransformSystem`'s two writers. +- **A delta narrative that did not rebuild its own number.** The sixth pass wrote "three go, + one arrives" for a move of −3. Measured: **5 removed, 2 added** — three path-coverage tests + plus two old stale tests out, two new ones in, one of those a rename. The count 1927 was + right and the story reached −2; a reader redoing the addition would look for a silent loss + that is not there. Corrected in place. +- **Counts, macOS aarch64.** Suite **1929** collected (1910 pass, 19 skip), up from 1927 — + `+2`, the two guards above, and nothing removed. `test-forge-3d` **575**, unchanged. Floor + re-derived FROM THE SUITE at 1929 / 1927. + +**Eighth pass — a retracted promise, a missing half, and a name-versus-body sweep.** +2026-08-23. + +- **P1-1 — the promise was false and is RETRACTED rather than made true.** "With one system + there is no partial state to leave behind" was wrong: moving to one system removed the + residue BETWEEN two calls and nothing inside one. `registerSystem` appends edges and several + tracker entries before an allocation can fail, and its `errdefer`s do not undo all of them — + a retry after OOM can report `WriteWriteConflict` against a `systemCount()` of zero. What + stands in its place says what the preflight covers — the deterministic failures, name and + write conflict — and that after an allocation failure the scheduler must be treated as + UNUSABLE. The rollback is Tier 0, is already recorded in `engine-ecs-internals.md` with its + owner, and is NOT touched here. **A promise of absent residue that no mechanism holds is + worse than silence, because it excuses the next reader from checking.** +- **P2-2 — the test did not measure what its name claimed, and the missing half was the one + the prescription forgot.** A per-class proxy count measures that nothing was RELEASED and + says nothing about the wake: with no sleeper retained with the presence, moving the filter + AFTER `wakeRetainedPartners` left the test entirely green while the refused call woke the + scene — precisely what the name promises is absent. Rewritten with both halves, and MOVED to + `world_test.zig`, where `removeBody` and the whole W4 apparatus already live. Preconditions + asserted so neither half passes vacuously — the pair exists, the box is asleep — and the + discrimination is IN THE SCENE: a legitimate W4 producer on the same presence does wake it. + Counter-factual: the filter placed after the wake gives `1 failed`, that test, where the + previous form stayed green. +- **P2-3 — the class swept on ONE criterion, and here is what the review COVERED.** The + criterion is not textual: *does a test's name still describe what its body measures after the + re-scope*. All nineteen tests of `transform_sync_test.zig` were read against it, not the + three sites reported. + - **One measured nothing in half of itself**: `a character presence never answers for its + entity, in either direction` — the inward half asserted a gameplay `Transform` write did + not teleport the presence, which holds for EVERY body now that nothing pushes anything in. + Half dropped, renamed `a character presence never publishes for its entity`. + - **Two carried assertions true for every body**: `publication authority per BodyType` + asserted twice that the SOLVER had not followed an ECS write. Dropped from there. + - **Three named systems in the plural or read ambiguously**: `the registered systems drive a + real frame`, `registering the systems twice`, and `Sleeping tag tracks island state in both + directions` — the last legitimate, meaning the marker's two TRANSITIONS, and renamed to say + so because "both directions" no longer reads that way beside the re-scope. + - **Thirteen were read and left alone**, and that is the negative half of the report: the + three sleeping/waking publication tests, the F-D1 signal test, the trigger and lone-trigger + tests, the three election tests, the two registration-refusal tests, the two conflict tests + and the resource-declaration test all still name what they measure. + - **The absence they half-measured is now ONE test that says so**: `no component write + reaches the solver: the inward direction is M1.1.26's`, with a non-vacuity leg proving the + API path does move the same scene. + - Two comments went with them: `sync.zig` claimed to convert "in both directions on every + tick", and "the two access sets" described a single constant. +- **An unplanned demonstration.** The counter-factual for the absence test injects a stand-in + inward push, and it spelled a `@floatCast` outside the boundary: `no_precision_crossing` + failed `production tree passes clean` alongside the intended test. The precision guard bites + on production code and not only on its own fixtures. +- **Counts, macOS aarch64.** Suite **1930** collected (1911 pass, 19 skip), up from 1929 — + **+2 −1**: the absence test and the relocated presence guard arrive, the presence guard's + former copy leaves. `test-forge-3d` **575** → **576**, the relocated guard. Floor re-derived + FROM THE SUITE at 1930 / 1928. + +**Ninth pass — a lifetime the resource outlived, and a test that missed its own path.** +2026-08-23. + +- **P1-1 — use-after-free on the published world, and it is the `removeBody` family again.** + `publishPhysicsWorld` writes raw pointers, `PhysicsWorld.deinit` frees and poisons, and + nothing cleared the resource: `hasPhysicsWorld` kept answering true and the next dispatch + dereferenced a dead address. **Two operations each correct whose SEQUENCE breaks** — the same + shape as `removeBody` accepting a character presence, one pass earlier. That the runtime's + ordinary order happens to be safe is not a defence: a lifetime contract nothing enforces is + not a contract. `unpublishPhysicsWorld` is the symmetric half and the caller that published + is the one that withdraws, so `PhysicsWorld` still imports nothing of the ECS — the invariant + this seam has held since it was written. The handle is ZEROED rather than the resource + removed: `resolve` already reads zero as absence, the registered id survives for a later + publication, and nothing has to be freed on a path a caller may run during teardown. The test + is the whole sequence — publish, dispatch, withdraw, destroy — with `hasPhysicsWorld` false + BEFORE the destruction and a further frame dispatched after it. Counter-factual: the + withdrawal made a no-op gives `1 failed`, that test. +- **P2-2 — the absence test did not guard the path that matters, and claimed a wider set than + it visited.** It ran through `stepAndPublish` while production runs `registerSystems` then + `dispatchFrame`, so an inward read reintroduced inside `stepAndPublishSystem` left it green; + and it wrote only `Transform.pos` and `Velocity.linear` while its name says "no component + write" and the departed `syncIn` also acted on rotation and angular velocity. Both closed: + the registered path, and all four fields on both simulated kinds. **This is the defect + corrected one pass earlier, in a test written during that same pass** — a name claiming a + wider set than the body walks. Counter-factual: an inward read placed inside the registered + system gives `2 failed`, this test and `production tree passes clean` — the precision rule + catching the probe's own narrowing, as it did last pass. +- **P2-3 — the debt pointer corrected in code AND in the brief.** `engine-ecs-internals.md` now + carries three Tier 0 debts, and the comment named the wrong one: what applies here is that + `registerSystem` is not transactional FOR ITSELF, distinct from the absence of a group + removal. The Closing-notes precondition said the same wrong thing and placed the residue + BETWEEN two registrations, where it lives inside one; corrected, with the superseded reading + marked at the earlier journal entry rather than rewritten there. +- **Counts, macOS aarch64.** Suite **1931** collected (1912 pass, 19 skip), up from 1930 — `+1`, + the lifetime guard. `test-forge-3d` **576**, unchanged. Floor re-derived FROM THE SUITE at + 1931 / 1929. + +**Tenth pass — the publication lifetime, closed from BOTH sides at once.** 2026-08-23. + +- **Why this arrived in three passes and why it stops here.** Round nine had no withdrawal at + all; round ten had one that did not know what it was clearing. The path kept returning a + finding because it was closed one side at a time, and each half looked complete on its own. + Both halves ship together. +- **A LATE withdrawal could erase the world published after it.** Publish A, publish B, then + run A's deferred teardown: a withdrawal that cleared whatever it found erased B, and every + frame afterwards was a silent no-op — B's owner having done nothing wrong and nobody having + withdrawn it. `unpublishPhysicsWorld` now takes the world it is withdrawing and clears only + its own handle. Finding someone else's is a NO-OP rather than a raise, matching the + repository's pattern for an unhandleable handle and keeping a teardown path from failing. +- **And PUBLICATION refuses to replace a live one**, which is the same defect from the other + side: a silent replacement makes A disappear without anyone having withdrawn it, and the + frames that follow drive a world its owner believes is still wired. + `error.PhysicsWorldAlreadyPublished`; the signature is not frozen — `PhysicsModule` does not + carry it — so the error channel costs nothing a later milestone must live with. The + "idempotent per world" claim the entry used to make is gone with the behaviour it described. +- **The accessor answers with the IDENTITY, and that is what makes the tests able to judge.** + A boolean cannot tell "B is still published" from "B was erased and something else answers", + so `hasPhysicsWorld` is replaced by `publishedPhysicsWorld`, returning the pointer. Both new + tests assert that identity and never a presence — a withdrawal that had erased B would leave + a boolean green. +- **Each half is guarded SEPARATELY, measured**: clearing blindly again fails the late- + withdrawal test alone; replacing silently again fails the refusal test alone. Neither probe + takes the other's test down, so the two guards are independent rather than one guard counted + twice. Both tests carry their own reverse leg — the rightful owner's withdrawal still clears, + and the ordered publish-after-withdraw is still accepted — without which each would pass + against an entry that refuses everyone. +- **Every publication in the test file is now paired with its withdrawal**, so the file + demonstrates the contract it guards rather than relying on `ecs.deinit` to sweep after it. +- **Counts, macOS aarch64.** Suite **1933** collected (1914 pass, 19 skip), up from 1931 — + `+2`, the two sequence tests. `test-forge-3d` **576**, unchanged. Floor re-derived FROM THE + SUITE at 1933 / 1931. + +**Eleventh pass — two bounds, no behaviour change, and closure.** 2026-08-24. + +- **The threshold applied, and the two findings failed it.** An address-reuse ABA needs a + world destroyed and a second allocated at the same address, which no path in this repository + produces; a refused publication marks a resource dirty that nothing observes. Neither is a + wrong behaviour nor a hollow test, so neither reopens the branch. **What was wrong was a + SENTENCE**: `unpublishPhysicsWorld` promised a late withdrawal "harmless however far behind + it runs", and the check is on the ADDRESS. The doc now states what the check separates — + two worlds alive at once — and what it does not — two generations at one address — with the + closing note carrying the token that would close it. The limit is written, not widened. +- **Both bounds recorded as ONE Closing-notes entry with owner M1.1.26**, because they are the + same surface and because landing one without the other is the mistake this path already cost + three passes. +- **Closing audits, replayed on the full branch diff** (7130 lines): zero French function + words and zero accented tokens on the added lines and on the whole brief. Drift sweep over + every symbol this milestone removed or renamed — `hasPhysicsWorld`, `stepSynchronised`, + `syncIn`, `widen`, `convVec3`, `convQuat`, `yieldsToASolidBody`, `coversEveryRoot` — found + ONE orphan, a comment naming `stepSynchronised` after the rename, patched. The remaining + mentions of the removed conversion helpers live in `precision.zig` and the lint rule, which + cite them as history on purpose. +- **`CLAUDE.md` still called the PR a draft in its Active-branch row** while the + current-milestone row already said ready for review and the PR itself was not a draft — one + row of the same table disagreeing with the other two. Corrected. +- **Counts, macOS aarch64.** Suite **1933** collected (1914 pass, 19 skip), unchanged: this + pass adds no test because it changes no behaviour. `test-forge-3d` **576**. Floor unchanged + at 1933 / 1931. +## Recorded deviations + +**RD — the frozen Scope is AMENDED by Claude.ai round-trip: the ECS → solver direction leaves +this milestone.** 2026-08-23. The Scope line reads "`Transform` synchronisation, both +directions" and "`Velocity` synchronisation"; the inward half of both, the authority rule on +reception, and the C1.1 guard move to **M1.1.26** with the Tier 1 Etch service. What stays is +the whole orchestration, the solver → ECS publication, the `Sleeping` marker, the publisher +election, the precision crossing point and the unfrozen interface file — with ONE registered +system instead of two. + +The reason is a property of the ECS and not of the implementation: **the tick says WHEN a write +happened and never WHO produced it**, and no comparison of change stamps can manufacture that +information. A solver-side provenance would not close it either — `moveKinematic` moves a +kinematic body whose pose the publication deliberately does not write, gameplay being declared +its authority, so the ECS `Transform` would stay stale indefinitely and the entity would render +at the old pose. Authority by `BodyType` cannot distinguish a kinematic driven by its component +from one driven by the API, and both are legal. The Tier 1 service is the only place that sees +both paths. C1.1 is not weakened: it is M1.1.26's exit gate, not this milestone's. + +- **Files touched outside the FROZEN list, with their justification** (Gate A). + `tests/solver_test.zig` — the composition being moved lived there, and leaving a + copy behind would produce the two owners the Scope forbids; it now holds a one-line + alias. `rigid/solver.zig` — the substep loop is where the warm-start application + happens, so it is the only place the cadence claim can be observed. `tests/sensor_test.zig` + and `tests/determinism/scenario.zig` — the `sensors_on` flag they set no longer + exists. `tools/weld_lint/dead_tests.zig` — the declared per-platform test floor, + which the guard requires be re-derived whenever the collected total moves. +- **`SolverStats.warm_start_injections` — placement ruled by Guy, Gate A review.** The + field is kept: without it "applied once per substep, every substep" is a claim no test + reaches, and `expected 16, found 4` is the oracle the Acceptance criteria ask for. What + moved is WHERE the exclusion lives. `engine-physics-solver.md` §1.8.2 arrests what + `get_solver_iterations_stats` reports and excludes the warm-start applications; carried + by a doc comment, that exclusion would fall to whoever later maps `SolverStats` onto the + reported surface, and the field would travel across with its siblings. A rule resting on + a future author's attention is an intention, not a guarantee — the wording of + `ARCH-031`'s conformance test. The counter now sits in a named `not_reported` sub-struct, + so the boundary is carried by the TYPE and visible at every read site, and it stays in + production code at the point where the injection happens, which is what gives it value. + Cost: two lines in `solveTick`, no call site, no signature. + +- **One test assertion REMOVED, declared here and at the gate signal.** + `tests/determinism/scenario.zig` carried `try testing.expect(s.world.sensors_on)`. + With step 10 bis unconditional there is no flag to assert, and the claim it stood + for splits in two: the COMPOSITION half — this scenario contains a trigger — is now + `expectEqual(@as(?bool, true), s.world.bm.isTrigger(s.trigger))` in the same test, + read off the body's role instead of a world flag; the CYCLE half — the pass runs + whether or not anyone asked — is `test "step 10 bis runs on a world that was never + told about sensors"` in `tests/world_test.zig`, which reads the STATE the pass + produces and is therefore obtained by a different mechanism from the flag it + replaces. + +- **Gate E — files touched outside the FROZEN list, each with its reason.** + `src/modules/forge/api/precision.zig` (create) — the Scope asks for "one named + precision-crossing point, public"; a point that is public and shared by the solver, the + ECS seam and the interface tier cannot live in any of the listed files, since `api/` is + the only one all three import. `tools/weld_lint/rules/no_precision_crossing.zig` (create) + plus `main.zig` and `tests.zig` (register) — the gate's GO states that the mechanical + verification is the deliverable and not the good intention; `weld_lint` is this + repository's mechanism for exactly that and carries a fresh precedent in `no_float_reduce`. + `build.zig` — a new module and two new test targets cannot exist without being declared + there. `tools/weld_lint/dead_tests.zig` — the declared floor, re-derived from the suite at + each step. +- **The world scalar is named in `api/precision.zig`, not in `config.zig`** as the Files + list expected. `config.zig` cannot hold it: `api/` needs the alias and `api/` must not + import `forge_3d`, which would invert the dependency. What `config.zig` holds instead is + the SINGLE instantiation of the crossing at `Real`, which is the half of the sentence it + can carry — "so that a call site can say which one it means" is satisfied either way. +- **The Scope names two false `struct_size` premises; measurement found FOUR** in the same + file (`api/types.zig` lines 551, 886, 1042, 1116). All four are corrected. Correcting the + two named ones and leaving their two siblings standing would reproduce, inside one file, + the motif this repository named at M1.1.11.1: corrected text added without deleting what + it replaced. The two CLOSED briefs carrying the same claim (M1.1.12, M1.1.13) are records + of what was believed then and are deliberately NOT retro-patched. +- **`src/interfaces/PhysicsModule.zig` created on the narrow reading of its Scope line.** + Both readings are stated in the Gate E log with the two measured facts that decided it — + surface guards belong to M1.1.26 by this brief's own Out of scope, and `ModuleContext` is + declared nowhere in the repository. Flagged for review rather than settled silently: the + file is the M1.1.26 freeze target, so its shape is the freeze's business as much as this + milestone's. + +## Notes + +- **Local counts are not gate verdicts, and the CI carries the bilateral control.** + Every figure this log reports from a local run — 561 against 555, 1875 collected — + was taken on macOS aarch64, which is NONE of the twelve cells. What confronts the + declared floor on the matrix is `ci.yml`'s own step, which passes each cell's OWN + reported total into `zig build dead-tests -Dexpect-collected=…`: measured green on + `ubuntu-24.04`, `ubuntu-24.04-arm` and `windows-2025` in run 32528263040, so the + `else => 1875` branch is confronted on both Linux architectures and `windows => 1873` + on Windows. If a platform ever disagrees, the repair is to re-derive on THAT + platform, never to adjust toward the expected number. + +## Blockers encountered + +**Gate A — two consecutive CI reds, neither in the tree. CI debt, measured here and NOT +repaired here.** 2026-08-22. + +Recorded because a red that disappears without an established cause is what comes back at +the worst moment. Neighbouring debt to `M1.D.8` (ARM cache collapse, cause not +established) and `M1.D.10` (`weldengine/setup-zig` purging a cache that overlaps +`.zig-cache`); its home is there, not in this milestone. + +- **Two reds, two DIFFERENT cells, two consecutive runs, neither repeating.** On + `2b26f7d`: `windows-2025 / ReleaseSafe / f32`, step 9 `zig build test`, signature + `'win32_thread_safety_test.test.concurrent createWindow + destroyWindow' failed without + output`. On `c549152`: `ubuntu-24.04 / ReleaseSafe / f32`, step 9 again, signature + `src/core/platform/window/wayland_protocols/core.zig:1819:106: error: expected ')', + found 'EOF'`. Zero failing assertions in both. +- **Both signatures are DISTINCT from the one M1.1.14 documented** — `file_hash + FileNotFound`, from the 2 GB purge deleting objects whose manifests survive. Filing + these under it would be the mis-classification that makes the next investigation start + from zero, so the signatures are recorded verbatim instead. +- **The tree cannot explain the second.** `wayland_protocols/core.zig` is TRACKED, 2140 + lines, line 1819 complete, and absent from this branch's diff. On that same cell step 6 + `zig build` compiled it successfully minutes before step 9 declared it ending at 1819. +- **A plain re-run is NOT a discriminant** — it restores a cache, so green proves nothing + about which of cache or tree, and red proves reproducibility without naming the + variable. What separates them is deleting the cache entries and re-running the single + cell COLD. +- **The cell restores TWO caches, and both had to go for "cold" to be cold.** The repo's + own `zig-ubuntu-24.04-ReleaseSafe-f64_false-baseline-…` (701 MB) and `setup-zig`'s + `weldengine-setup-zig-zigcache-v1-…-x86_64-linux-…`, whose sibling entries measure + **185 to 189 bytes** — purged archives, `M1.D.10`'s mechanism by its own signature. The + line the red cell logged names the SECOND, restored from the PREVIOUS run's key. +- **Result: cold, the cell is GREEN.** `Cache not found for input keys: …` on all three + restore-key levels, then `274/274 steps succeeded; 1863/1875 tests passed (12 skipped)`, + `dead-test conservation` green, `forge-determinism` green with `chain verdict : OK (1000 + frames)` gated. The cause of THIS red is in the cache and not in the tree. Nothing here + establishes the mechanism, and the first red is not covered by it: it was re-run, never + re-run COLD. +- **A premise correction that belongs with the signal.** `ci.yml`'s restore block carries + the E9 paragraph — "ReleaseSafe legs only… the Debug legs now run fully cold by design" + — and directly beneath it an M1.1.14/Gate E paragraph stating THE RELEASESAFE-ONLY + RESTRICTION IS LIFTED. The step carries no `if:`. Measured on this run's logs, + `ubuntu-24.04 / Debug / f32` and `windows-2025 / Debug / f64` both restore. So "both + reds are ReleaseSafe" is true and is NOT a variable separating them from the green + legs — all twelve restore. The superseded paragraph still stands above its own + correction, in the file that documents six runs lost to a cache that lies. + +**Gate C — `moveKinematic`: BLOCKED 2026-08-22, RESOLVED the same day by a Claude.ai +round-trip. Kept in full rather than deleted — what the entry established is what the +resolution is built on, and a journal keeps its fallen entries.** Everything else in gate C is delivered; this entry is the one part left out and +why. + +**The measurement that raises it.** `pipeline/integration.zig:183` reads +`if (body_types[i] != .dynamic) continue;` — **a kinematic body is never moved by its +velocity**. The reference's `BodyInterface::MoveKinematic` works because Jolt integrates +kinematic bodies; Weld does not, and M1.1.5 listed "kinematic position-from-velocity" +explicitly as an additive branch OUT of scope. + +**Three readings, materially different, and the Scope admits more than one.** The frozen +line is: "It derives **both** velocities from a target pose over a `dt`, on the shape of +`BodyInterface::MoveKinematic`." + +1. **Literal** — set the two velocity columns, write no pose. Coherent with the purpose + §1.12.5 gives the entry, since `ground_velocity` reads those columns. But then NOTHING + moves a kinematic platform except `setBodyTransform`, and a caller who wants a moving + platform that reports a truthful ground velocity must call BOTH entries — which §5.5 + names as a MISSING OPERATION rather than a documented recipe. It also produces the mirror + of the lie the entry exists to remove: a platform that reports a velocity while standing + still, against `setBodyTransform`'s platform that moves while reporting zero. +2. **Effective** — derive the velocities AND write the target pose, so the body is where it + was asked to be and the velocity it reports is the one that took it there over `dt`. One + call, truthful `ground_velocity`, no change to the integrator or to any compared path. + Diverges from the reference on WHEN the body arrives: Jolt's arrives at the end of the + step, so contacts resolved during that step see it at its old pose. +3. **Integrator** — advance kinematic bodies in `integratePositions`. Faithful to the + reference, and the reading `engine-tier-interfaces.md` points at when it says deriving a + velocity from a target pose "appartient au cycle de tick". It is a behavioural change on + the compared path, so it is the only one of the three that can move a witness. + +**A sub-question that rides on it.** The angular derivation from a delta quaternion is exact +only through an axis-angle extraction, which needs `acos` — an external transcendental +`ARCH-031` rule 4 forbids on a compared path. The trig-free alternative is the inverse of +the integrator's own first-order rule, which is exact against what the integrator will do +and approximate against the mathematics. Which is right depends on which reading above +wins, so it is not decided here either. + +**Not guessed.** Reading 2 is the one I would take, and I have not taken it: it changes the +observable contract of a frozen interface entry, and this milestone's whole discipline is +that a guess dressed as a decision is the costliest defect there is. A Claude.ai round-trip +is required before `moveKinematic` gets a body. + +**RESOLUTION — reading 2, and reading 3 excluded by the corpus rather than by preference.** +`engine-physics-solver.md` §1.8.5 states that a character's presence is a kinematic body +moved BY POSE WRITE, its velocity columns exactly zero as it crosses the scene, which is why +W3's true-zero test never sees it and why W4 exists at all. Integrating kinematics would give +a presence a non-zero velocity, W3 would start firing, and the reasoning that justifies W4 +would collapse — so `integration.zig:183` is not a gap inherited from M1.1.5, it is what the +whole wake system is built on, and fidelity to the reference does not outweigh an engine +invariant. Between 1 and 2, §1.12.5 decides: the case it names is a platform the gameplay +drives tick after tick, so the entry must MOVE it and publish the matching velocity, or it +does not replace `setBodyTransform` and the caller composes two entries. **The frozen Scope +does not move**: "derives both velocities from a target pose over a `dt`" stays true word for +word, and the clarification says what the entry does IN ADDITION, which the frozen text never +excluded — so the frozen hash is unchanged and the arbitration is recorded as such. + +**The `acos` sub-question DISSOLVED rather than being decided.** Under reading 2 there is no +integrator to invert — kinematics are never integrated, `ω` is never re-injected, and its one +consumer is `ground_velocity = v + ω × r`. So there is nothing an exact axis-angle extraction +would be exact AGAINST, while `acos` is an external transcendental `ARCH-031` rule 4 forbids +on a compared path. Shipped: `ω = 2 · vec(q_target · conj(q_current)) / dt`, sign normalised +for the short path. The corpus had already settled the twin case — §1.12.5 refuses an `acos` +per contact per frame and stores `cos_max_slope` computed once at creation; here the call does +not even have to be moved, because it is not needed. + +## Closing notes + +- **Brief-tooling hooks have no home, and this milestone is not the one to give them one.** + Two controls now guard this brief — the frozen-section hash and the heading list — and + both are run by hand. Mechanising them (`weld_lint`, a `pre-commit` step, a CI step) is + brief tooling, outside this milestone's scope, and where such a hook lives is decided by + a milestone that has a reason to open that surface. Named here as a signal so the next + one that does can find it, with what each control covers already bounded above: the hash + covers the frozen section completely, the heading list covers only the disappearance of a + living heading, and NOTHING covers the loss of content under a living heading that + survives. + +- **The determinism instrument does not exercise presence insertion, and the carrier is + M1.1.26.** There are now TWO character-creation paths — the store's, and the + orchestrator's, which additionally inserts the broadphase presence and registers it. The + canonical scenario takes the STORE path, measured: `setPresenceProxy` appears nowhere in + it. So the harness measures the path that is not production's, and the gap widened at gate + C, where a defect in the orchestrator path (an unregistered presence pruning every pair it + belonged to) was invisible to the frozen scenario by construction. **The scenario does not + move**: eight witnesses are not re-baselined to add coverage, since re-baselining is + reserved for an intentional behavioural change. The coverage is added by a SECOND + artefact, and the carrier is **M1.1.26**, whose Etch slice drives `forge_3d` through the + orchestrator and therefore through the production path. + +- **`precision.zig` is FORGE's single point, and the world scalar is not a forge property.** + Named here with an owner rather than left to be discovered. The file's header is honest + about what it is — the boundary of one module — but `WorldReal` describes the ENGINE: the + day `large_world` lands, Kinesis, Render and scene serialisation all need it, and none of + them can import `weld_forge`. There will therefore be either a SECOND point or a MOVE, and + a second point is the exact defect this gate spent itself removing. Not corrected here + because the cost of moving is a file relocation plus rerouting eleven internal call sites, + with no API change at all — cheap now, cheap later, and pointless to pay before a second + consumer exists. **Owner: the milestone that delivers `large_world`, or the first non-forge + module that needs the world scalar — Kinesis at M1.2.x will meet it first.** + +- **`ModuleContext` is a PRECONDITION of the freeze, not a curiosity.** + `engine-tier-interfaces.md` §1 types the interface's first entry + `fn (*core.ModuleContext) anyerror!Impl`, and **`ModuleContext` is declared nowhere in this + repository** — measured: the name occurs in three comments and in no declaration. The + freeze at M1.1.26 cannot write its comptime assert block without that type existing, and + minting it is a Tier 0 project reaching the scheduler and the asset loader, not a line of + glue. **It must be discovered BEFORE M1.1.26 begins and not during**, which is why it is + written as a precondition and carries no work in this milestone. + +- **The frozen pose setters cannot stay `void`, and the divergence is now MEASURED rather + than predicted.** M1.1.12 recorded that they would become allocation-fallible the day + bodies gained broadphase proxies. That day is this milestone: `setBodyTransform`, + `moveKinematic`, `setCharacterPosition`, `moveCharacter` and `resizeCharacter` all take a + `gpa` and return an error union on `PhysicsWorld`, because each refreshes a proxy and + `Broadphase.update` reserves. The frozen surface declares them `void` with no allocator. + `setLinearVelocity` and `setAngularVelocity` touch no proxy and remain `void`, matching. + Two ways out and both belong to the freeze — a reservation seam that makes `update` + infallible, or an error channel on the setters — so the decision lands where it is not + foreclosed. **Owner: M1.1.26.** + +- **`TransformSystem` cannot land before a Tier 0 access-model decision, and that is a + PRECONDITION rather than a remark.** `engine-coordinate-system.md` §4.3 runs it at the head + of `fixed_update` AND in `post_update`, writing `Transform` in both, while `scheduler.zig` + makes two writers of one component in one phase a hard registration error and offers no + declarative ordering to resolve it. Measured: `TransformSystem` is declared NOWHERE in this + repository, so nothing is broken today — and this milestone's `fixed_update` system already + writes `Transform`, so the collision is waiting. The conflict is DECLARATIVE and not + physical: §4.3 has dynamic bodies carry no parent by convention, so the two systems write + DISJOINT entity sets and the access model simply cannot say so. Closing it needs one of + three Tier 0 decisions — explicit ordering, sub-phases, or accesses refined by entity set — + and none of them belongs to a physics milestone. A test registers a competing `Transform` + writer in `fixed_update` and observes `error.WriteWriteConflict`, so the wall is a measured + fact that fails loudly at the attempt instead of being discovered during integration. + **A SECOND Tier 0 item sits beside it, and it is a DIFFERENT debt**: `registerSystem` is not + transactional FOR ITSELF — it appends edges and several tracker entries before an allocation + can fail, and its `errdefer`s do not undo all of them, so a scheduler that has suffered an + allocation failure is UNUSABLE and a retry can report a conflict against a count of zero. + That is distinct from the absence of a group removal recorded next to it, and + `engine-ecs-internals.md` now carries both. The preflight here covers the DETERMINISTIC + failures — name and write conflict — and covers OOM not at all; an earlier version of this + entry blamed the group-removal debt and described the residue as sitting between two + registrations, which was the wrong debt and the wrong place. **Owner: the milestone that + delivers `TransformSystem`, and it cannot begin without the decision.** + +- **`syncIn` AND ITS TESTS ARE WRITTEN, MEASURED, AND WAITING FOR M1.1.26.** Nothing is + thrown away, and the two findings of the fourth round are the measured evidence for why the + direction could not hold here. **What exists**: a `syncIn` walking the registration list with + authority per `BodyType` on reception; a WATERMARK in the published handle + (`last_in_tick`) with the exact change-stamp predicate — `changedTick >= watermark`, and the + `>=` is load-bearing, since a write in `update` carries the same tick number as the sync-in of + its own frame and a strict comparison drops it for good; the C1.1 guard writing `Velocity` + from an `update` system, which is the only position that exercises the defect; the + `moveKinematic`-from-`update` guard; the reception halves of the trigger and character-presence + tests; a `frame()` helper that opens the ECS frame because the stamp gate is vacuous while + `current_tick` stands still. All of it is in this branch's history at `e436c14`. + **What it cannot do, measured twice**: the tick answers WHEN and never WHO, and a + solver-side provenance leaves a kinematic's ECS pose stale forever because the publication + deliberately does not write it. **P2-4 goes with it** — the preflight's residual was the + watermark's second registration, and the watermark leaves. **Owner: M1.1.26**, with the Tier 1 + service that is the first place to see both the component path and the API path. + +- **Two bounds on the publication handle, neither a defect today, both owned by M1.1.26.** + Recorded together because they are the same surface, and pointed at the freeze because it is + the milestone that settles what that surface costs. + - **The withdrawal separates two worlds ALIVE AT ONCE, not two generations at one address.** + `unpublishPhysicsWorld` compares the stored pointer against the caller's, which is exact + for the case every path in this repository produces — publish A, publish B, late teardown + of A. It is address ABA otherwise: destroy a world, allocate a second at the same address, + and a late withdrawal aimed at the first clears the second. **Not a defect today, and the + reason is measured rather than assumed**: no path in the repository publishes two worlds in + succession, so the sequence needs an address reuse that nothing produces. Closing it needs + an OPAQUE PUBLICATION TOKEN — a counter handed out at publication and quoted at withdrawal + — which is a handle design, not a comparison change. + - **A refused publication still marks the resource dirty.** `publishPhysicsWorld` takes the + mutable view before deciding, so the refusal path stamps a resource it does not write. + Nothing observes that stamp today — the handle is physics-internal and no rule reads it — + which is why it is a bound and not the F-D1 defect it resembles. Reading through + `getResource` first and taking the mutable view only on the branch that writes is the fix, + and it belongs with whatever shape the token gives the entry. + **Owner: M1.1.26.** It freezes the surface, so it is the milestone that will know whether a + token earns its cost, and both items land or are refused together rather than one at a time — + which is the mistake this path already cost three passes. + +- **A lesson repeated word for word is a signal of METHOD, not of memory.** `git checkout --` + destroyed uncommitted work twice on this milestone, at gate B and again at F-D1, both + times while probing. The rule that holds is not "remember the file" but **probe only + against a committed tree** — commit first, then mutate, then restore. Every probe from + F-D1 onward was run that way, and the counter-factuals that followed were clean on the + first attempt. Recorded here as one standing rule rather than as two incident notes, + because the incidents are not the content. diff --git a/build.zig b/build.zig index ee512089..83d5802f 100644 --- a/build.zig +++ b/build.zig @@ -159,6 +159,31 @@ pub fn build(b: *std.Build) void { forge_3d_module.addImport("weld_forge", forge_api_module); forge_3d_module.addOptions("build_options", forge_build_options); + // M1.1.15 / gate D — `forge/sync.zig`, the ECS <-> solver seam. It is the ONE module + // that sees both sides: `weld_core` for the World and the `Transform`, `weld_forge` + // for the physics components, and `forge_3d` for `PhysicsWorld`. `forge_3d` itself + // keeps its two-import discipline and never learns about the ECS. + const forge_sync_module = b.createModule(.{ + .root_source_file = b.path("src/modules/forge/sync.zig"), + .target = target, + .optimize = optimize, + }); + forge_sync_module.addImport("weld_core", core_module); + forge_sync_module.addImport("weld_forge", forge_api_module); + forge_sync_module.addImport("forge_3d", forge_3d_module); + forge_sync_module.addImport("foundation", foundation_module); + + // M1.1.15 / gate E — `src/interfaces/PhysicsModule.zig`, the Tier 1 physics interface + // and the first file of `src/interfaces/`. NOT frozen: the freeze is M1.1.26. It holds + // the three body pose/velocity contracts moved out of `forge/api/types.zig`, so it needs + // `weld_forge` for `BodyId` and for the world-scalar aliases and nothing else. + const interfaces_physics_module = b.createModule(.{ + .root_source_file = b.path("src/interfaces/PhysicsModule.zig"), + .target = target, + .optimize = optimize, + }); + interfaces_physics_module.addImport("weld_forge", forge_api_module); + // M0.2 / E6 — plugin loader ABI module shared with the stub // plugin sub-projects under `tests/core/plugin_loader/stub_plugin/`. // Exposes the C ABI types from `desc.zig` (no `WeldAPI` itself, @@ -327,6 +352,14 @@ pub fn build(b: *std.Build) void { // inline tests in config/shape/body/body_manager + the acceptance suite // under forge_3d/tests/. root.zig pins them all. Added to // `zig build test`; `zig build test-forge-3d` runs just these. + const forge_sync_tests = b.addTest(.{ .root_module = forge_sync_module }); + test_step.dependOn(&b.addRunArtifact(forge_sync_tests).step); + + // M1.1.15 / gate E — the interface file's own tests: the attestation that no protocol + // version is declared yet, and that the three signatures follow the world scalar. + const interfaces_physics_tests = b.addTest(.{ .root_module = interfaces_physics_module }); + test_step.dependOn(&b.addRunArtifact(interfaces_physics_tests).step); + const forge_3d_tests = b.addTest(.{ .root_module = forge_3d_module }); const forge_3d_tests_run = b.addRunArtifact(forge_3d_tests); test_step.dependOn(&forge_3d_tests_run.step); @@ -542,6 +575,10 @@ pub fn build(b: *std.Build) void { render: bool = false, /// M0.6 — when set, imports the `weld_asset_pipeline` module. asset_pipeline: bool = false, + /// M1.1.15 — when set, imports the Forge synchronisation seam and the two + /// modules it joins, so a test can drive a `PhysicsWorld` against a real ECS + /// `World`. + forge: bool = false, /// M0.6 / E2 — when set, imports the `foundation` module (simd). foundation: bool = false, /// M1.0.4 — when set, imports `weld_etch` (the scene cook driver). A @@ -558,6 +595,7 @@ pub fn build(b: *std.Build) void { }; const test_specs = [_]TestSpec{ .{ .path = "tests/smoke_test.zig" }, + .{ .path = "tests/physics/transform_sync_test.zig", .forge = true }, .{ .path = "tests/ecs/world_test.zig" }, .{ .path = "tests/ecs/chunk_test.zig" }, .{ .path = "tests/ecs/query_test.zig" }, @@ -794,6 +832,12 @@ pub fn build(b: *std.Build) void { if (spec.asset_pipeline) { t_mod.addImport("weld_asset_pipeline", asset_pipeline_module); } + if (spec.forge) { + t_mod.addImport("weld_forge", forge_api_module); + t_mod.addImport("forge_3d", forge_3d_module); + t_mod.addImport("forge_sync", forge_sync_module); + t_mod.addImport("foundation", foundation_module); + } if (spec.foundation) { t_mod.addImport("foundation", foundation_module); } diff --git a/src/interfaces/PhysicsModule.zig b/src/interfaces/PhysicsModule.zig new file mode 100644 index 00000000..983ae5c4 --- /dev/null +++ b/src/interfaces/PhysicsModule.zig @@ -0,0 +1,130 @@ +//! `src/interfaces/PhysicsModule.zig` — the Tier 1 physics interface, and the first file of +//! `src/interfaces/`. +//! +//! **THIS FILE IS NOT FROZEN.** The freeze is M1.1.26 and it is what brings +//! `WELD_PHYSICS_PROTOCOL_VERSION`, the comptime surface guards, and the normative update of +//! `engine-tier-interfaces.md`. Until then this file may change freely, and the absence of +//! the protocol constant is asserted below so that nobody reads its silence as a freeze +//! already taken. +//! +//! **What it holds today, and why not more.** `engine-tier-interfaces.md` §1 declares the +//! interface as `pub fn PhysicsModule(comptime Impl: type) type` whose comptime block +//! `assertFn`s twenty-seven entries. That block is not written here, for two measured +//! reasons: +//! +//! - the assert block IS the surface guard, and surface guards are M1.1.26's by the +//! milestone's own scope. A guard that checked three of the twenty-seven entries would +//! be worse than no guard, because an implementation missing the other twenty-four would +//! pass it — a check that under-checks reads as a check. +//! - the first entry of that block is `init`, typed `fn (*core.ModuleContext) anyerror!Impl`, +//! and **`ModuleContext` does not exist in this repository**. Measured, not assumed: the +//! name appears in three comments and in no declaration. Minting it here would be +//! inventing a Tier 0 type that reaches the scheduler and the asset loader, which is a +//! project and not a line. +//! +//! What DOES land here is the thing the freeze cannot wait for: the contract of the three +//! body pose and velocity entries, which lived in `forge/api/types.zig` as a day-1 mirror +//! and named this file as its destination. It is MOVED and not copied — two copies of a +//! contract are two things that can disagree, which is the whole subject of the contract. +//! +//! **The scalar.** `engine-tier-interfaces.md` §1 states that the positions and poses of +//! this section are written at the WORLD scalar and are not literally `f32` — reading them +//! as `f32` in every circumstance would contradict `ARCH-022`. So the signatures below name +//! `WorldVec3` and `WorldQuat` from `forge/api/precision.zig`, and this file is that alias's +//! first consumer outside the module that defines it. Quantities carrying no length +//! dimension — masses, coefficients, ratios, durations — stay `f32` under both settings and +//! do not follow that scalar, which is why `dt` below is `f32` and the positions are not. + +const api = @import("weld_forge"); + +const BodyId = api.BodyId; +const WorldVec3 = api.precision.WorldVec3; +const WorldQuat = api.precision.WorldQuat; + +// --- Body pose and velocity entries — semantics frozen here --- +// +// Moved from `forge/api/types.zig`, which held them as a day-1 mirror while this file did +// not exist and which named this move as its destination. +// +// - `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 froze at M1.1.12; its body was a typed +// stub until M1.1.15, deriving a velocity belonging to the tick cycle and the wake +// composition, which arrive with `PhysicsWorld`. It is now realised +// (`forge_3d/world.zig`): `ω = 2 · vec(q_target · conj(q_current)) / dt`, sign +// normalised for the short path. +// +// - `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). + +/// Teleport a body: write the pose, derive no velocity. See the block above. +pub const SetBodyTransform = fn (BodyId, WorldVec3, WorldQuat) void; + +/// Move a kinematic body to a target pose over `dt`, deriving BOTH velocities from it. +/// `dt` is a duration and therefore `f32` under both scalar settings. +pub const MoveKinematic = fn (BodyId, WorldVec3, WorldQuat, f32) void; + +/// Set a body's angular velocity. The entry without which `ω` had no author at all. +pub const SetAngularVelocity = fn (BodyId, WorldVec3) void; + +// --- tests ------------------------------------------------------------------- + +const std = @import("std"); +const testing = std.testing; + +test "the interface is NOT frozen: no protocol version is declared here" { + // An ATTESTATION OF ABSENCE, and the form matters. `WELD_PHYSICS_PROTOCOL_VERSION` is + // what M1.1.26 adds when the surface freezes; declaring it early would make the surface + // irreversible a milestone ahead of the decision to make it so. `@hasDecl` on this + // file's own namespace is what states that, and it is a claim that can FAIL — adding + // the constant turns this test red, which is exactly the alarm it exists to raise. + try testing.expect(!@hasDecl(@This(), "WELD_PHYSICS_PROTOCOL_VERSION")); + + // NON-VACUITY: `@hasDecl` on this namespace does find what is really here, so the + // expectation above is not the vacuous truth of a predicate that never finds anything. + try testing.expect(@hasDecl(@This(), "SetBodyTransform")); + try testing.expect(@hasDecl(@This(), "MoveKinematic")); + try testing.expect(@hasDecl(@This(), "SetAngularVelocity")); +} + +test "the three signatures are written at the world scalar, not at a literal f32" { + // `engine-tier-interfaces.md` §1: the positions and poses of this section follow the + // world scalar, and reading them as `f32` in every circumstance contradicts `ARCH-022`. + // What is asserted is the LINK to the alias — that is what survives `large_world` — and + // not the alias's current value, which `forge/api/precision.zig` pins on its own. + const t = @typeInfo(SetBodyTransform).@"fn"; + try testing.expectEqual(WorldVec3, t.params[1].type.?); + try testing.expectEqual(WorldQuat, t.params[2].type.?); + + const m = @typeInfo(MoveKinematic).@"fn"; + try testing.expectEqual(WorldVec3, m.params[1].type.?); + try testing.expectEqual(WorldQuat, m.params[2].type.?); + // `dt` is a DURATION: no length dimension, so it does not follow the world scalar and + // stays `f32` under both settings. Asserted because the distinction is the one §1.11.8 + // says is the first cause of error on this subject. + try testing.expectEqual(f32, m.params[3].type.?); + + const a = @typeInfo(SetAngularVelocity).@"fn"; + try testing.expectEqual(WorldVec3, a.params[1].type.?); +} diff --git a/src/modules/forge/api/components.zig b/src/modules/forge/api/components.zig index 77abb830..5e1401a8 100644 --- a/src/modules/forge/api/components.zig +++ b/src/modules/forge/api/components.zig @@ -18,6 +18,21 @@ const ShapeType = types.ShapeType; /// dependency; Notes decision 2). All physics call sites use `api.Velocity`. pub const Velocity = core.ecs.components.Velocity; +/// A body whose island is ASLEEP — a zero-size marker the orchestrator adds and removes +/// as islands sleep and wake (`engine-physics-solver.md` §1.8.6). +/// +/// **It is the only way a rule can ask whether a body is asleep**, and it is what carries +/// the archetype-level skip `engine-physics-forge.md` §1.4 credits to native ECS +/// integration: a query that excludes it steps over a whole archetype of resting debris +/// instead of testing a flag per entity. The skip lives HERE, in the `Transform` +/// synchronisation, and not in the solver — whose body store is a SoA indexed by `BodyId` +/// and knows nothing of archetypes. +/// +/// ZERO-SIZE, deliberately: a marker carries no data, and giving it a payload byte would +/// invite one. It is `extern struct {}` so it stays POD under `ARCH-004` like every other +/// component here. +pub const Sleeping = extern struct {}; + /// A rigid body's material and simulation parameters /// (`engine-physics-forge.md` §2). Position/rotation live on the ECS /// `Transform`; velocity on `Velocity`; accumulated forces on `PhysicsForces`. @@ -198,3 +213,45 @@ test "CollisionShape mirrors the two authoring fields of the body descriptor" { // unchanged — every assert above passes and this one reports `expected 7, found 8`. try testing.expectEqual(@as(usize, 7), @typeInfo(CollisionShape).@"struct".fields.len); } + +test "Sleeping is a zero-size POD marker" { + // The size is the contract: a marker with a payload byte is a different thing, and the + // next reader would put a field in it. Pinned in both directions — the size AND the + // field count — because a single `u8` field would keep neither. + try testing.expectEqual(@as(usize, 0), @sizeOf(Sleeping)); + try testing.expectEqual(@as(usize, 0), @typeInfo(Sleeping).@"struct".fields.len); + try testing.expect(@typeInfo(Sleeping).@"struct".layout == .@"extern"); +} + +test "Sleeping survives registration, spawn, add and remove in a real World" { + // THE UNKNOWN THIS TEST EXISTS FOR. No zero-size component existed anywhere in the + // repository before this one — the ECS's own `Tag` fixture is a `u32` — so "the chunk + // layout tolerates a zero-size column" was an assumption and not a fact. The chunk's + // per-slot cost carries `EntityId` plus two ticks per component whatever the component + // measures, so the capacity divisor cannot reach zero; that is an argument, and this is + // the measurement. + const gpa = testing.allocator; + var world = core.ecs.World.init(); + defer world.deinit(gpa); + + // The entity carries what a physics body carries — `Transform` and `Velocity` — and + // the marker goes on top, which is exactly the shape the orchestrator drives. + const e = try world.spawn(gpa, .{}, .{}); + try testing.expect(world.get(Sleeping, e) == null); + + try world.addComponent(gpa, e, Sleeping, .{}); + try testing.expect(world.get(Sleeping, e) != null); + + // BOTH DIRECTIONS, because the orchestrator drives both: a body that wakes loses the + // marker and one that sleeps again regains it, each transition an archetype migration. + try world.removeComponent(gpa, e, Sleeping); + try testing.expect(world.get(Sleeping, e) == null); + try world.addComponent(gpa, e, Sleeping, .{}); + try testing.expect(world.get(Sleeping, e) != null); + + // And the entity's OTHER components survived the two migrations — a zero-size column + // must not disturb the ones beside it, which is the half of this that a size assert + // cannot reach. + try testing.expect(world.get(core.ecs.components.Transform, e) != null); + try testing.expect(world.get(Velocity, e) != null); +} diff --git a/src/modules/forge/api/precision.zig b/src/modules/forge/api/precision.zig new file mode 100644 index 00000000..555907fb --- /dev/null +++ b/src/modules/forge/api/precision.zig @@ -0,0 +1,162 @@ +//! `forge/api/precision.zig` — the forge module's SINGLE precision boundary. +//! +//! **Three distinct scalars meet in this engine, and confusing them is the first cause of +//! error on this subject** (`engine-physics-queries.md` §1.11.8): +//! +//! - the **world** scalar governs world-space positions — the ECS `Transform`, +//! `BodyDescriptor.position`, the pose the interface returns, the geometric inputs and +//! outputs of the queries. It is settled by `large_world` in `weld.toml`, read at +//! `comptime` and never at runtime (`ARCH-022`, whose conformity test states that large +//! worlds cannot be toggled without recompiling). +//! - the **solver** scalar governs internal accumulation — traversal, kernels, selection, +//! integration, constraints. It is settled by `-Dphysics_f64`. +//! - the **render** scalar is fixed `f32`, always camera-relative, and never appears here. +//! +//! The two flags COMPOSE, and not symmetrically: `large_world = true` implies the solver +//! scalar in `f64`, since a surface more precise than the solver serving it would return +//! low-order digits that mean nothing. The converse — `-Dphysics_f64` alone — stays +//! legitimate and distinct: it buys internal accumulation precision under an `f32` world +//! surface, and it is NOT a large-world mode. +//! +//! **What the repository actually carries, measured and not supposed.** `large_world` does +//! not exist here: only `-Dphysics_f64` is wired, and it switches `forge_3d` alone. So this +//! file's `WorldReal` is `f32` today, and the whole point of naming it is that a literal +//! `f32` at a crossing site would have to be found again, one site at a time, the day the +//! flag lands. The surface ADMITS the mode without delivering it. +//! +//! **Why the boundary is a single point.** Before this file the repository carried four +//! private helpers of identical semantics under two names — `widen` in `mesh.zig`, +//! `convVec3` in `body_manager.zig` and again in `character.zig`, `convQuat` in +//! `body_manager.zig`. Two writings of one conversion are two things that can diverge, and +//! these already had: `character.zig`'s carried an `if (Real == f32) return v;` short +//! circuit the other two did not, so one of the three took a different path at the default +//! precision. That is the failure mode a single point removes by construction. +//! +//! **Why it lives in `api/` and not in `src/interfaces/`.** §1.11.8 places the boundary at +//! the interface tier, "the only place that knows both scalars". `src/interfaces/` wraps an +//! implementation, so `forge_3d` cannot import it without inverting the dependency — and +//! `forge_3d` is exactly where three of the four helpers lived. `api/` is the module's +//! public surface, the mirror of `engine-tier-interfaces.md` §1, and it is imported by +//! `forge_3d`, by the ECS sync seam and by the interface tier alike. It is the only place +//! all three can reach. +//! +//! **One point, four faces, and the reading is deliberate.** A vector and a quaternion +//! cannot share a signature, and hiding both behind an `anytype` facade would erase the +//! very types the boundary exists to name. What "single point" buys is that there is ONE +//! place to edit and ONE place to audit — which the `no_precision_crossing` lint rule turns +//! from an intention into a check. + +const math = @import("foundation").math; + +/// **The world scalar.** `f32` today, `f64` under `large_world` (`ARCH-022`), and this +/// declaration is the one place that changes when the flag lands. Any site that means "a +/// world-space coordinate" spells it `WorldReal` and never `f32`. +pub const WorldReal = f32; + +/// A world-space 3-vector — the type of `BodyDescriptor.position` and of the ECS +/// `Transform`'s position once it is out of its raw array form. +pub const WorldVec3 = math.Vec(3, WorldReal); + +/// A world-space rotation. +pub const WorldQuat = math.Quat(WorldReal); + +/// **The precision boundary, instantiated at a solver scalar.** `forge_3d/root.zig` holds +/// the single instantiation as `cross`; nothing else should instantiate it, since a second +/// instantiation is a second place to look when the world scalar moves. +/// +/// The conversions are element-wise and carry no logic: widening is exact, narrowing rounds +/// once and at this site only. Neither direction short-circuits when the two scalars +/// coincide — the round trip through the component array is then the identity on the same +/// type, and a comptime special case would be a second code path for no gain, which is the +/// divergence this file exists to end. +pub fn Crossing(comptime Solver: type) type { + return struct { + /// 3-vector at the solver scalar. + pub const SolverVec3 = math.Vec(3, Solver); + /// Rotation at the solver scalar. + pub const SolverQuat = math.Quat(Solver); + + /// World → solver. Exact: the solver scalar is never narrower than the world one + /// (`large_world` implies `-Dphysics_f64`, §1.11.8). + pub fn vec3ToSolver(v: WorldVec3) SolverVec3 { + const a = v.toArray(); + return SolverVec3.fromArray(.{ a[0], a[1], a[2] }); + } + + /// World → solver, rotation. Component-wise, so a unit quaternion stays unit to + /// within the widening's exactness — which is nothing, widening being exact. + pub fn quatToSolver(q: WorldQuat) SolverQuat { + const a = q.toArray(); + return SolverQuat.fromArray(.{ a[0], a[1], a[2], a[3] }); + } + + /// Solver → world. This is the ONLY rounding in the module's public direction, and + /// the reason the lint rule flags `@floatCast` everywhere else: a narrowing spelled + /// somewhere else is a rounding nobody counted. + pub fn vec3ToWorld(v: SolverVec3) WorldVec3 { + const a = v.toArray(); + return WorldVec3.fromArray(.{ @floatCast(a[0]), @floatCast(a[1]), @floatCast(a[2]) }); + } + + /// Solver → world, rotation. A narrowed unit quaternion is unit only to the world + /// scalar's resolution; every consumer that inverts by conjugation re-normalises on + /// the way back in, which `vec3ToSolver`'s exactness then preserves. + pub fn quatToWorld(q: SolverQuat) WorldQuat { + const a = q.toArray(); + return WorldQuat.fromArray(.{ @floatCast(a[0]), @floatCast(a[1]), @floatCast(a[2]), @floatCast(a[3]) }); + } + }; +} + +// --- tests ------------------------------------------------------------------- + +const std = @import("std"); +const testing = std.testing; + +test "the world scalar is f32 in this build, and moving it is a deliberate act" { + // A PIN, not a tautology. M1.1.15 states that the world scalar stays `f32` and that + // `large_world` is a project of its own; this is what makes flipping it break a test + // that names the decision, instead of sliding through as an edit to one alias. + try testing.expectEqual(f32, WorldReal); + + // Nothing else is asserted here on purpose. `WorldVec3 = math.Vec(3, WorldReal)` is true + // BY DEFINITION, so a test comparing the two cannot fail, and a guard that cannot fail + // is not a guard — this repository has removed one for that exact reason. What actually + // holds the "no literal `f32` at a crossing" property is the `no_precision_crossing` + // lint rule and the `f64` build, not an assertion in this file. +} + +test "widening is exact and narrowing is the only rounding" { + const wide = Crossing(f64); + + // Widening: every f32 is an f64 exactly, so the round trip is the identity ON THE BITS + // and not merely close. A tolerance here would hide a lost component. + const v = WorldVec3.fromArray(.{ 1.0 / 3.0, -7.25e12, 5.9604645e-8 }); + const back = wide.vec3ToWorld(wide.vec3ToSolver(v)); + try testing.expectEqual(v.toArray(), back.toArray()); + + const q = WorldQuat.fromArray(.{ 0.5, -0.5, 0.5, 0.5 }); + const qback = wide.quatToWorld(wide.quatToSolver(q)); + try testing.expectEqual(q.toArray(), qback.toArray()); + + // Narrowing: a value that needs more than 24 bits of mantissa MUST lose something, or + // the test above proves nothing about direction. The two f64 neighbours below collapse + // onto the same f32, which is exactly what "the only rounding" means. + const a = wide.vec3ToWorld(wide.SolverVec3.fromArray(.{ 1.0000000000000002, 0, 0 })); + const b = wide.vec3ToWorld(wide.SolverVec3.fromArray(.{ 1.0, 0, 0 })); + try testing.expectEqual(a.toArray()[0], b.toArray()[0]); + try testing.expectEqual(@as(WorldReal, 1.0), a.toArray()[0]); +} + +test "at a coinciding scalar the crossing is the identity, with no second code path" { + // The helper this file replaced carried an `if (Real == f32) return v;` short circuit in + // one of its three copies. Removing it must not change the answer — measured here rather + // than argued, on the value a short circuit would have returned untouched. + const same = Crossing(WorldReal); + const v = WorldVec3.fromArray(.{ -0.0, 3.4028235e38, 1.1754944e-38 }); + try testing.expectEqual(v.toArray(), same.vec3ToSolver(v).toArray()); + try testing.expectEqual(v.toArray(), same.vec3ToWorld(same.vec3ToSolver(v)).toArray()); + + const q = WorldQuat.fromArray(.{ 0, 0, 0, 1 }); + try testing.expectEqual(q.toArray(), same.quatToWorld(same.quatToSolver(q)).toArray()); +} diff --git a/src/modules/forge/api/root.zig b/src/modules/forge/api/root.zig index 0dcdd512..afaa218c 100644 --- a/src/modules/forge/api/root.zig +++ b/src/modules/forge/api/root.zig @@ -10,10 +10,17 @@ const components = @import("components.zig"); const types = @import("types.zig"); +/// **The module's single precision boundary** — the world scalar, its aggregates, and the +/// one named crossing between it and a solver scalar (`engine-physics-queries.md` §1.11.8). +pub const precision = @import("precision.zig"); + // --- ECS components (extern POD) --- /// Rigid-body material + simulation parameters. pub const RigidBody = components.RigidBody; +/// A body whose island is ASLEEP — the zero-size marker the orchestrator adds and +/// removes, and the only way a rule can ask whether a body is sleeping (M1.1.15). +pub const Sleeping = components.Sleeping; /// A collision shape attached to an entity. pub const CollisionShape = components.CollisionShape; /// Per-shape parameter union overlaid by `CollisionShape.shape_type`. @@ -89,4 +96,5 @@ pub const ClosestPointResult = types.ClosestPointResult; comptime { _ = components; _ = types; + _ = precision; } diff --git a/src/modules/forge/api/types.zig b/src/modules/forge/api/types.zig index e02e1d34..c6478609 100644 --- a/src/modules/forge/api/types.zig +++ b/src/modules/forge/api/types.zig @@ -298,48 +298,13 @@ pub const BodyDescriptor = struct { trigger_layer_mask: u32 = 0xFFFFFFFF, }; -// --- Body pose and velocity entries — semantics frozen here --- +// --- Body pose and velocity entries --- // -// `PhysicsModule`'s function declarations live in `src/interfaces/PhysicsModule.zig`, -// which does not exist yet: it lands at M1.1.15 with `ModuleContext`, and this file is -// the day-1 mirror of `engine-tier-interfaces.md` §1 until then (see the file header). -// So the SEMANTICS of three body entries are recorded here, next to the frozen types -// they traffic in, and they move with the declarations when that file lands. -// -// DESTINATION: M1.1.15 MOVES this block onto those three declarations in -// `src/interfaces/PhysicsModule.zig`. It is not duplicated there — two copies of a -// contract are two things that can disagree, which is the whole subject of the block. -// -// - `setBodyTransform(id, position, rotation)` is a TELEPORTATION. It writes the pose -// and derives NO velocity: a kinematic body moved through it keeps velocity columns -// of exactly zero. That is not an oversight to be repaired — it is the same split the -// reference draws between `SetPositionAndRotation` and `MoveKinematic`. -// -// The consequence is load-bearing for the character controller and it is why this -// note exists: `CharacterMoveResult.ground_velocity` is measured AT THE CONTACT -// POINT, so it reads the support's `v + ω × r`. A platform teleported through this -// entry therefore reports a ground velocity of ZERO while visibly moving -// (`engine-physics-forge.md` §1.12.5). The fix is to drive such a platform with -// `moveKinematic`, never to make this entry guess a velocity from two poses it was -// not given a `dt` for. -// -// - `moveKinematic(id, target_position, target_rotation, dt)` is what DERIVES both -// velocities from a target pose over a `dt`, on the shape of -// `BodyInterface::MoveKinematic`. Its signature freezes at M1.1.12; its body is a -// typed stub until M1.1.15, deriving a velocity belonging to the tick cycle and the -// wake composition, which arrive with `PhysicsWorld`. Same pattern C1.1 authorises by -// name for `createJoint` and M1.1.9 already executed on five query entries. -// -// - `setAngularVelocity(id, ω)` closes a gap dating from M1.1.0: `PhysicsModule2D` -// carries `setAngularVelocity2D` and the reference carries both, while 3D carried only -// the linear setter — so `ω` was authorable by NO caller at all, and the rotational -// term of `ground_velocity` had no source. `BodyManager` has had the column setter -// since M1.1.8; what was missing is the interface entry. -// -// Write intent, unchanged from §1.8.4: a pose or velocity WRITE is non-activating (it is -// the solver's own path), while an external mutation — force, torque, impulse — wakes. The -// interface tier composes wake + write for every setter it exposes to gameplay, and a -// character presence moved by pose write is wake cause W4, never W3 (§1.12.10). +// The semantics of `setBodyTransform`, `moveKinematic` and `setAngularVelocity` were +// recorded here while `src/interfaces/PhysicsModule.zig` did not exist, and that block +// named this move as its destination. M1.1.15 CREATED the file and MOVED the block onto its +// three declarations. It is not duplicated: two copies of a contract are two things that +// can disagree, which was the whole subject of the block. /// Everything needed to create one character controller /// (`engine-physics-forge.md` §1.12). A controller is VIRTUAL: it takes part in no @@ -547,10 +512,11 @@ pub const CharacterMoveResult = struct { /// 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. + /// neighbouring one — and that coupling is invisible at the C ABI level: a Tier 3 caller + /// reading `ground_body` alone has no way to learn it was supposed to read + /// `ground_state` first. `struct_size` does not help here and never could + /// (`ARCH-018`): it tells a plugin which fields the host SENT, never what a sent value + /// means, and `0` is a perfectly well-formed handle to slot 0. ground_body: BodyId = PackedId.dead, /// Velocity AT THE CONTACT POINT, hence `v + ω × r` and not the support's linear @@ -882,9 +848,12 @@ test "BodyDescriptor defaults match the brief" { // Referencing every field by name makes a rename or a removal a COMPILE error; this // count is what makes an ADDITION visible, which no by-name reference can catch. Same - // shape as `CharacterDescriptor`'s below, and for the same reason: after the M1.1.15 - // freeze, `engine-c-api.md` carrying no `struct_size` and no minor version, appending - // one defaulted field here is an ABI break and not a source-compatible addition. + // shape as `CharacterDescriptor`'s below, and for the same reason — which is NOT the one + // written here before: `engine-c-api.md` §1.1 bis carries `struct_size` and a minor + // version, and `ARCH-018` states the contract, so a defaulted field appended AT THE END + // is a supported minor addition. What breaks the contract is an insertion anywhere else + // or a change to an existing field, and this count is what keeps either from happening + // unnoticed. // COUNTER-FACTUAL MEASURED: appending one defaulted field leaves every assert above // passing and reports `expected 16, found 17` here. try testing.expectEqual(@as(usize, 16), @typeInfo(BodyDescriptor).@"struct".fields.len); @@ -1038,10 +1007,20 @@ test "GroundState is the ternary verdict, u8-backed, in engine-movement.md's ord 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. + // ADDITION visible, which no by-name reference can catch. + // + // The reason previously given for the second half was FALSE and is corrected here: it + // argued that `engine-c-api.md` carries no `struct_size` and no minor version, so any + // added field would be an ABI break. It carries both — `engine-c-api.md` §1.1 bis — and + // `ARCH-018` states the evolution contract in full: every surface struct leads with a + // `struct_size` the HOST fills, the API table carries a minor version beside the major, + // a plugin reads `struct_size` before touching a field, and members are appended AT THE + // END and nowhere else. A defaulted field appended at the end is therefore exactly the + // source- and binary-compatible addition that contract exists to allow. + // + // What the count assert is really worth, then: an addition ANYWHERE BUT THE END, or a + // change to an existing field, breaks the contract, and this assert is what makes the + // difference a deliberate decision rather than a drift nobody saw. const d = CharacterDescriptor{ .entity = EntityId.dead }; try testing.expect(d.position.eql(Vec3.zero)); @@ -1112,9 +1091,14 @@ test "CharacterMoveResult mirrors engine-tier-interfaces.md §1 field for field" // `PackedId.dead`, and DIFFERENT FROM 0. The second half is the one that catches a // regression to the old default, which was `0` — a valid handle to slot 0 generation 0, // hence a field with no bit configuration meaning absence, readable only in company of - // `ground_state` and silently so across the C ABI. `engine-c-api.md` carries no - // `struct_size` and no minor version, so after the M1.1.15 freeze that default would - // have been frozen into the ABI. + // `ground_state` and silently so across the C ABI. + // + // The former justification — that `engine-c-api.md` carries no `struct_size` and no + // minor version — was FALSE; it carries both (§1.1 bis, contract in `ARCH-018`). The + // real reason is stronger and is what stands: `struct_size` protects a plugin against + // READING a field the host never sent. It says nothing whatever about the VALUE of a + // field the host did send, so a default that means "slot 0, generation 0" would be + // shipped, in bounds and well-formed, to every plugin that reads it. 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)); diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index fedc8562..fa390047 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -225,11 +225,13 @@ pub const BodyManager = struct { } // Normalised ONCE, here, and shared by both rotation fields below (see // `Body.rotation` for the invariant this establishes). Deliberately NOT - // folded into `convQuat`: that name says "convert", and hiding a semantic - // operation behind it would make the invariant invisible at the call site. - const rotation_r = convQuat(desc.rotation).normalize(); + // folded into the crossing: `quatToSolver` says "convert", and hiding a + // semantic operation behind it would make the invariant invisible at the + // call site — and the crossing is now SHARED, so folding it there would + // impose this normalisation on every other caller too. + const rotation_r = config.cross.quatToSolver(desc.rotation).normalize(); const body = Body{ - .position = convVec3(desc.position), + .position = config.cross.vec3ToSolver(desc.position), .rotation = rotation_r, .linear_velocity = Vec3r.zero, .angular_velocity = Vec3r.zero, @@ -254,7 +256,7 @@ pub const BodyManager = struct { // The sleep window opens at the creation pose, closed (`sleep_time` // zero) — a fresh body has not yet stood still for any length of time. .sleep_time = 0, - .sleep_ref_position = convVec3(desc.position), + .sleep_ref_position = config.cross.vec3ToSolver(desc.position), // The SAME normalised value as `.rotation`, not a second conversion. // Were the reference left un-normalised, the first window sweep would // read `Δq = q ⊗ conj(q_ref)` as a near-identity offset by the @@ -286,7 +288,7 @@ pub const BodyManager = struct { // candidate (see `Body.world_aabb` for the measurement). NaN elsewhere, so a // wrong read is loud rather than plausible. Exhaustive on the class, no `else`. .world_aabb = switch (shape.class()) { - .triangle_soup => worldAabb(shape, convVec3(desc.position), rotation_r), + .triangle_soup => worldAabb(shape, config.cross.vec3ToSolver(desc.position), rotation_r), .convex, .half_space => Aabbr.fromMinMax( Vec3r.splat(std.math.nan(Real)), Vec3r.splat(std.math.nan(Real)), @@ -2615,18 +2617,6 @@ pub fn worldAabb(shape: Shape, pos: Vec3r, rot: Quatr) Aabbr { } } -/// Widen the descriptor's f32 `Vec3` to solver precision. -fn convVec3(v: ApiVec3) Vec3r { - const a = v.toArray(); - return Vec3r.fromArray(.{ a[0], a[1], a[2] }); -} - -/// Widen the descriptor's f32 `Quatf` to solver precision. -fn convQuat(q: ApiQuat) Quatr { - const a = q.toArray(); - return Quatr.fromArray(.{ a[0], a[1], a[2], a[3] }); -} - // --- tests ------------------------------------------------------------------- // The bulk of the `BodyManager` acceptance suite lives in // `tests/body_manager_test.zig`; the pose mutators are covered inline here diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index 270c81e7..b9857f27 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -180,7 +180,8 @@ pub const Character = struct { /// 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. +/// widening is exact, so widening `h_f32 * 0.5` and halving the widened `h_f32` give 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); } @@ -1412,7 +1413,7 @@ pub const CharacterStore = struct { // Infallible from here. const record = Character{ .entity = desc.entity, - .position = convVec3(desc.position), + .position = config.cross.vec3ToSolver(desc.position), .radius = desc.radius, .height = desc.height, .step_height = desc.step_height, @@ -2040,12 +2041,3 @@ fn contactPointVelocity(bm: *const BodyManager, body: BodyId, point: Vec3r) Vec3 const centre_of_mass = bm.position(body) orelse return Vec3r.zero; return linear.add(angular.cross(point.sub(centre_of_mass))); } - -/// Widen a descriptor `f32` `Vec3` to solver precision. The public surface is `f32` -/// (§1.11.8, §1.12.11) and widening it is one grouped decision at M1.1.15; this is the -/// abstraction point, so that decision touches the conversions and no call site. -fn convVec3(v: math.Vec3) Vec3r { - if (Real == f32) return v; - const a = v.toArray(); - return Vec3r.fromArray(.{ a[0], a[1], a[2] }); -} diff --git a/src/modules/forge/forge_3d/config.zig b/src/modules/forge/forge_3d/config.zig index ba3781ed..824f3346 100644 --- a/src/modules/forge/forge_3d/config.zig +++ b/src/modules/forge/forge_3d/config.zig @@ -7,6 +7,7 @@ const math = @import("foundation").math; const build_options = @import("build_options"); +const api = @import("weld_forge"); /// The solver scalar type. `f32` by default; `f64` when built with /// `-Dphysics_f64=true`. @@ -20,3 +21,9 @@ pub const Quatr = math.Quat(Real); pub const Mat3r = math.Mat3(Real); /// Axis-aligned bounding box at solver precision. pub const Aabbr = math.Aabb(Real); + +/// **The one precision crossing of the module, instantiated at `Real`.** Every world ↔ +/// solver conversion goes through here and nowhere else; the `no_precision_crossing` lint +/// rule is what makes that a check rather than a convention +/// (`engine-physics-queries.md` §1.11.8). +pub const cross = api.precision.Crossing(Real); diff --git a/src/modules/forge/forge_3d/mesh.zig b/src/modules/forge/forge_3d/mesh.zig index bd69f0d6..d8bdd947 100644 --- a/src/modules/forge/forge_3d/mesh.zig +++ b/src/modules/forge/forge_3d/mesh.zig @@ -287,18 +287,18 @@ pub const MeshData = struct { { var t: usize = 0; while (t < indices.len) : (t += 3) { - const v0 = widen(vertices[indices[t]]); - const v1 = widen(vertices[indices[t + 1]]); - const v2 = widen(vertices[indices[t + 2]]); + const v0 = config.cross.vec3ToSolver(vertices[indices[t]]); + const v1 = config.cross.vec3ToSolver(vertices[indices[t + 1]]); + const v2 = config.cross.vec3ToSolver(vertices[indices[t + 2]]); if (isDegenerate(v0, v1, v2)) return error.MeshTriangleDegenerate; } } const owned_vertices = try gpa.alloc(Vec3r, vertices.len); errdefer gpa.free(owned_vertices); - var bound = Aabbr.fromMinMax(widen(vertices[0]), widen(vertices[0])); + var bound = Aabbr.fromMinMax(config.cross.vec3ToSolver(vertices[0]), config.cross.vec3ToSolver(vertices[0])); for (vertices, owned_vertices) |src, *dst| { - dst.* = widen(src); + dst.* = config.cross.vec3ToSolver(src); bound = bound.expand(dst.*); } const owned_indices = try gpa.dupe(u32, indices); @@ -1040,10 +1040,3 @@ pub fn isDegenerate(v0: Vec3r, v1: Vec3r, v2: Vec3r) bool { // already admitted. return math.triangleIsFlat(Real, v0, v1, v2); } - -/// Widen a descriptor vertex to solver precision. Exact: a per-component `f32` → `Real` -/// conversion. -fn widen(v: ApiVec3) Vec3r { - const c = v.toArray(); - return Vec3r.fromArray(.{ c[0], c[1], c[2] }); -} diff --git a/src/modules/forge/forge_3d/rigid/solver.zig b/src/modules/forge/forge_3d/rigid/solver.zig index c8c3f707..15752ff9 100644 --- a/src/modules/forge/forge_3d/rigid/solver.zig +++ b/src/modules/forge/forge_3d/rigid/solver.zig @@ -80,6 +80,28 @@ pub const SolverStats = struct { solve_sweeps: u32 = 0, /// Relax sweeps (one per substep). relax_sweeps: u32 = 0, + /// Counters the reported telemetry surface deliberately EXCLUDES. + /// + /// `engine-physics-solver.md` §1.8.2 arrests what `get_solver_iterations_stats` + /// reports — the solve/relax sweeps — and states that the warm-start applications + /// are not counted. **That exclusion is carried by the TYPE and not by this + /// paragraph.** Left to a doc comment, it would fall to whoever later maps + /// `SolverStats` onto the reported surface: the field would travel across with the + /// siblings it sits beside, and a rule that rests on a future author's attention is + /// an intention rather than a guarantee — the wording of `ARCH-031`'s own + /// conformance test, and it applies here. Nested, the boundary is visible at every + /// read site and a field crosses it only by being moved out, deliberately. + not_reported: struct { + /// Constraint POINTS the warm start injected this tick, summed over substeps. + /// + /// It exists so the APPLICATION half of warm start is observable where it + /// happens, since "applied once per substep, every substep" (§1.7 step 6) is + /// otherwise a claim no test can reach. It counts real injections rather than + /// loop turns: a `substep_count` of 4 over one 4-point manifold reads 16, and + /// hoisting the call out of the loop reads 4 — the counter-factual that gives + /// the number its meaning. + warm_start_injections: u32 = 0, + } = .{}, /// The smallest separation any biased sweep observed this tick, or `null` if no /// point was evaluated at all. Negative means overlap. min_separation: ?Real = null, @@ -269,16 +291,19 @@ fn solveFrictionPoint(bm: *BodyManager, c: *const ContactConstraint, pt: *Constr } /// Inject each point's CURRENT accumulated impulses into the body velocities — once -/// per substep, immediately after the velocity integration. +/// per substep, immediately after the velocity integration. Returns the number of +/// points it injected, which is what makes the per-substep cadence observable. /// /// The accumulators include everything earlier substeps of this tick solved, which is /// exactly the point: this is the APPLICATION half of warm start, and the SEEDING half /// ran once at `prepare` (`contact_constraint.seedWarmStart`). The two must stay /// distinct functions — re-seeding here would re-read the cache every substep and /// throw away the tick's own progress. -pub fn applyWarmStartRange(bm: *BodyManager, constraints: []ContactConstraint, from: usize, to: usize) void { +pub fn applyWarmStartRange(bm: *BodyManager, constraints: []ContactConstraint, from: usize, to: usize) u32 { + var injected: u32 = 0; for (constraints[from..to]) |*c| { for (0..c.count) |i| { + injected += 1; const pt = &c.points[i]; const impulse = c.normal.scale(pt.normal_impulse) .add(c.tangent1.scale(pt.tangent1_impulse)) @@ -287,6 +312,7 @@ pub fn applyWarmStartRange(bm: *BodyManager, constraints: []ContactConstraint, f applyImpulse(bm, c, pt.r_a, pt.r_b, impulse); } } + return injected; } /// The BIASED sweep over the constraint index range `[from, to)` — normal points @@ -432,7 +458,8 @@ pub fn solveTick( while (substep < cfg.substep_count) : (substep += 1) { integration.integrateVelocitiesNoReset(bm, h, gravity); - for (islands) |isl| applyWarmStartRange(bm, constraints, isl.constraint_from, isl.constraint_to); + for (islands) |isl| stats.not_reported.warm_start_injections += + applyWarmStartRange(bm, constraints, isl.constraint_from, isl.constraint_to); for (islands) |isl| { const range = solveRangeReport(bm, constraints, isl.constraint_from, isl.constraint_to, cfg, h); diff --git a/src/modules/forge/forge_3d/root.zig b/src/modules/forge/forge_3d/root.zig index 72d78887..a83d534a 100644 --- a/src/modules/forge/forge_3d/root.zig +++ b/src/modules/forge/forge_3d/root.zig @@ -52,6 +52,9 @@ const character_mod = @import("character.zig"); // M1.1.14 — the module's entry-point check on the floating-point execution // state (`ARCH-031` rule 5). Scalar-free; re-exported as two functions below. const determinism_mod = @import("determinism.zig"); +// M1.1.15 — `PhysicsWorld`, the sole owner of the per-tick cycle. Re-exported below; +// the comptime pin analyses its acceptance suite. +const world_mod = @import("world.zig"); // --- Solver scalar + math aliases --- @@ -61,6 +64,11 @@ pub const Real = config.Real; pub const Vec3r = config.Vec3r; /// Quaternion at solver precision. pub const Quatr = config.Quatr; + +/// **The module's one precision crossing**, instantiated at `Real` in `config.zig`. World ↔ +/// solver conversions go through here and through nothing else +/// (`engine-physics-queries.md` §1.11.8); `no_precision_crossing` checks it. +pub const cross = config.cross; /// 3×3 matrix at solver precision. pub const Mat3r = config.Mat3r; /// Axis-aligned bounding box at solver precision. @@ -252,7 +260,14 @@ 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; +/// +/// **Named `MoveResult` and not `CharacterMoveResult`**, which is what this alias used to be +/// called. `api.CharacterMoveResult` is a DIFFERENT type — flat, six fields, at the world +/// scalar — and this one is two fields nesting `GroundInfo` at solver precision. Both lived +/// in one import graph under one name, and one of the two is about to become irreversible. +/// The interface tier owes the flatten-and-narrow between them, at the same named crossing +/// as everything else that changes precision. +pub const MoveResult = 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. @@ -314,6 +329,31 @@ pub const checkFloatEnvironment = determinism_mod.checkFloatEnvironment; /// by whatever drives a tick; `PhysicsWorld.step()` inherits the call at M1.1.15. pub const assertFloatEnvironment = determinism_mod.assertFloatEnvironment; +// --- Orchestration (M1.1.15) --- + +/// The physics world: the SOLE OWNER of the per-tick cycle +/// (`engine-physics-solver.md` §1.7). It holds the shape store, the body store, the +/// broadphase, the warm-start cache and the island partition, executes the eleven +/// steps in their frozen order — step 10 bis included — and owns the substep cadence +/// and the per-tick scratches. Bound to `Real` through the module's own `config.zig`. +pub const PhysicsWorld = world_mod.PhysicsWorld; +/// One executed stage of the cycle. The enum carries exactly the anchors that RUN; +/// step 3 and step 5 bis own no code and step 8 is retired at a frozen number. +pub const Step = world_mod.Step; +/// A recorder for the ORDER `step()` entered its stages in — what turns "each stage +/// ran" into "the stages ran in this sequence". +pub const StepTrace = world_mod.StepTrace; + +/// A body registered in a `PhysicsWorld`, with its broadphase proxy and what it belongs to. +/// The ECS sync seam walks this list and needs `BodyKind` to tell a character presence from +/// the entity's own body — one entity can own both. +pub const BodyProxy = world_mod.BodyProxy; +/// What a registered proxy belongs to. +pub const BodyKind = world_mod.BodyKind; +/// How many anchors execute. Pinned so adding or removing a stage is a deliberate +/// edit of `world.zig` and of the order test together. +pub const executed_step_count = world_mod.executed_step_count; + // Pins so the inline tests + the acceptance suite are analysed when this module // is built as a test target (engine-zig-conventions.md §13). comptime { @@ -332,6 +372,7 @@ comptime { _ = sensor_mod; _ = query_mod; _ = character_mod; + _ = world_mod; _ = @import("tests/body_manager_test.zig"); _ = @import("tests/integration_test.zig"); _ = @import("tests/broadphase_test.zig"); @@ -350,6 +391,7 @@ comptime { _ = @import("tests/mesh_test.zig"); _ = @import("tests/character_test.zig"); _ = @import("tests/sensor_test.zig"); + _ = @import("tests/world_test.zig"); // M1.1.14 — the determinism instrument: canonical scenario + artifacts. _ = @import("tests/determinism/scenario.zig"); _ = @import("tests/determinism/trace.zig"); diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 0359bedd..c88288d1 100644 --- a/src/modules/forge/forge_3d/tests/character_test.zig +++ b/src/modules/forge/forge_3d/tests/character_test.zig @@ -839,8 +839,9 @@ test "baseToCentre is the one offset, and it agrees at both precisions" { // 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. + // floating point and widening is exact, so widening `h · 0.5` and halving the widened `h` + // give the same number. 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 diff --git a/src/modules/forge/forge_3d/tests/determinism/scenario.zig b/src/modules/forge/forge_3d/tests/determinism/scenario.zig index 07e94ef4..9a933caf 100644 --- a/src/modules/forge/forge_3d/tests/determinism/scenario.zig +++ b/src/modules/forge/forge_3d/tests/determinism/scenario.zig @@ -361,7 +361,9 @@ pub const Scenario = struct { self.trigger_visitor = try w.addBody(gpa, visitor); try self.mobile.append(gpa, self.trigger_visitor); w.bm.setLinearVelocity(self.trigger_visitor, vr(12, 0, 0)); - self.world.sensors_on = true; + // Nothing switches the sensor pass on: step 10 bis is UNCONDITIONAL from + // M1.1.15 (`../../world.zig`). The scenario only has to CONTAIN a trigger, + // which is what the composition test below asserts. // --- (7) a lone box that settles at once, x = 15 ----------------------- // @@ -652,7 +654,16 @@ test "scenario: builds, and every element is present" { // counted here for that reason. try testing.expectEqual(@as(u32, 21), s.world.bm.count()); try testing.expectEqual(@as(u32, 1), s.chars.count()); - try testing.expect(s.world.sensors_on); + // THE TRIGGER IS PRESENT, asserted on the body's ROLE rather than on a world + // flag. Until M1.1.15 this line read `expect(s.world.sensors_on)`, a claim about + // a switch the harness carried and production would not: step 10 bis is now + // unconditional, so that switch is gone and the claim it made — "this scenario + // exercises the sensor pass" — splits in two. The half that belongs here is + // COMPOSITION: the scene holds a trigger body, so the pass has something to + // enumerate. The half that belongs to the cycle — that the pass runs whether or + // not anyone asked — is asserted where the cycle lives, in + // `tests/world_test.zig`. + try testing.expectEqual(@as(?bool, true), s.world.bm.isTrigger(s.trigger)); } test "scenario: steps without error, and the character resolves its ground" { diff --git a/src/modules/forge/forge_3d/tests/sensor_test.zig b/src/modules/forge/forge_3d/tests/sensor_test.zig index 82f78d1b..62c9a6af 100644 --- a/src/modules/forge/forge_3d/tests/sensor_test.zig +++ b/src/modules/forge/forge_3d/tests/sensor_test.zig @@ -617,7 +617,6 @@ test "falling asleep inside a trigger never produces an exit" { // becomes eligible after `time_before_sleep` (0.5 s = 30 ticks at 60 Hz). var world = harness.World.init(Vec3r.zero, 1.0 / 60.0); defer world.deinit(gpa); - world.sensors_on = true; _ = try addBox(gpa, &world, av(2, 2, 2), av(0, 0, 0), 1, true, 0); const shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av(0.3, 0.3, 0.3) } }); @@ -662,7 +661,6 @@ test "a sleeping trigger still detects an arriving body" { const gpa = testing.allocator; var world = harness.World.init(Vec3r.zero, 1.0 / 60.0); defer world.deinit(gpa); - world.sensors_on = true; // A DYNAMIC trigger, so it can actually fall asleep — a static body never carries the // sleeping flag at all, and asserting on it would prove nothing. @@ -693,7 +691,6 @@ test "a detection wakes nobody" { const gpa = testing.allocator; var world = harness.World.init(Vec3r.zero, 1.0 / 60.0); defer world.deinit(gpa); - world.sensors_on = true; const tshape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av(2, 2, 2) } }); _ = try world.addBody(gpa, .{ @@ -938,7 +935,6 @@ test "clearing the role leaves an overlapping sleeper asleep until the caller co // ACTUALLY asleep, and a world that never sleeps cannot show it. var world = harness.World.init(Vec3r.zero, 1.0 / 60.0); defer world.deinit(gpa); - world.sensors_on = true; // A trigger volume with a DYNAMIC body resting inside it. While the role is on, the pair // is detected and the body sleeps: a trigger reaches no constraint, so the body is a @@ -1074,7 +1070,6 @@ test "the owed wake dispatches on the shape, and a half-space trigger needs the const gpa = testing.allocator; var world = harness.World.init(Vec3r.zero, 1.0 / 60.0); defer world.deinit(gpa); - world.sensors_on = true; // **THE SECOND SHAPE THE ROLE ADMITS, and the one a single recipe cannot serve.** A // half-space keeps the sensor role — it is a volume with an interior — and it is exactly @@ -1169,7 +1164,6 @@ test "the composition must wake the body whose role changed, not only what it ov const gpa = testing.allocator; var world = harness.World.init(Vec3r.zero, 1.0 / 60.0); defer world.deinit(gpa); - world.sensors_on = true; // **THE THIRD BRANCH, and it is the one the other two cases could not show.** There, the // sleeper was what the trigger OVERLAPPED, so waking the overlapped bodies was enough. diff --git a/src/modules/forge/forge_3d/tests/solver_test.zig b/src/modules/forge/forge_3d/tests/solver_test.zig index 6de6e95f..22265760 100644 --- a/src/modules/forge/forge_3d/tests/solver_test.zig +++ b/src/modules/forge/forge_3d/tests/solver_test.zig @@ -2,53 +2,22 @@ //! replacing the M1.1.6 Sequential Impulses suite and absorbing the M1.1.7 NGS //! suite, whose model no longer exists. //! -//! `World` composes the full per-tick pipeline IN TESTS ONLY (the production -//! `step()` orchestration is M1.1.15). The normative cycle -//! (`engine-physics-solver.md` §1.7), in order — step numbers are STABLE anchors and -//! step 8 is retired at a frozen number: -//! (1) `Broadphase.computePairs` on the current poses (moved-driven deltas) -//! (2) candidate-pair retention: merge the deltas into a PERSISTENT set -//! (3) external forces — READ-ONLY, and it owns no code. The force/torque -//! accumulators are constant for the whole of `step()` (nothing writes them -//! between ticks), so they ARE the tick's accelerations and every substep -//! reads them directly. The uniform §2 reset is not here: it runs once at the -//! END of step 6, because clearing an accumulator before anything consumes it -//! delivers `F/m·0` (blocker B1). -//! (4) `cache.beginTick` → `build` (narrowphase `collidePair` per candidate, -//! `prepare` capturing `v_n⁻` PRE-GRAVITY, the local anchors, the softness -//! selection and the warm-start SEEDING, plus the wake fixpoint of §1.8.5) -//! (5) island partition + activation (W2, W3) — never puts anything to sleep -//! (6) the SUBSTEP LOOP and (7) the restitution pass, both inside -//! `rigid.solveTick`: per substep `integrateVelocitiesNoReset(h)` → warm-start -//! APPLICATION → biased solve (normal points only) → `integratePositions(h)` → -//! relax (normal points unbiased, then friction); after the loop the uniform -//! accumulator reset, then restitution per island. -//! (8) retired — there is no position pass. Position error is corrected by the -//! bias inside step 6, and penetration recovery is PACED by -//! `contact_push_max_speed` rather than resorbed in one frame. -//! (9) `storeContacts` (harvest) → `cache.endTick` (sort + swap) -//! (10) broadphase proxy updates on the final poses — skips sleeping bodies -//! (10 bis) the sensor pass (§1.13.4), when `sensors_on` -//! (11) sleep window sweep on the POST-SOLVE state, then the sleep transition: -//! the only point in the cycle where a body falls asleep. +//! **The cycle this suite drives left this file at M1.1.15.** `World` below is an +//! alias for `forge_3d.PhysicsWorld` (`../world.zig`), which is now the sole owner +//! of the eleven steps of `engine-physics-solver.md` §1.7 — their order, the +//! candidate-pair retention rule, the substep cadence and the per-tick scratches. +//! Nothing here re-implements any of it, and that is the property that makes every +//! measurement below a measurement of the engine rather than of a second copy of +//! the cycle written for the tests. The step-by-step description of the cycle lives +//! in `../world.zig`'s header and NOT here, deliberately: two copies of a normative +//! order is how the second one goes stale — this header carried "keeps EVERY emitted +//! pair (never drops)" for a full milestone after the pruning landed. //! -//! Sleeping is ENABLED by default here. Every convergence measurement — resting box, -//! five-box stack, mass ratio, drift — sets `sleep_cfg.allow_sleeping = false`, which -//! is normative and not a convenience (§1.8.3): a displacement-bounded criterion lets -//! a slowly creeping body sleep, so a converging-or-not question must be asked with -//! sleeping off. -//! -//! `computePairs` is moved-driven with fat-AABB hysteresis (it reports a pair only -//! when a proxy moves enough to exit its fat AABB), so the consumer keeps a -//! PERSISTENT candidate set (`active`, a sorted-deduped key list) and merges each -//! tick's deltas into it. NORMATIVE retention rule for the M1.1.15 -//! `step()`/`PhysicsWorld` (b2ContactManager semantics): a pair is retained while the -//! two FAT broadphase AABBs overlap, dropped only when they separate. This harness -//! keeps EVERY emitted pair (never drops) — a conservative superset of that rule, -//! valid in test: the narrowphase filters non-touching pairs, so a retained separated -//! pair costs a redundant `collidePair` and never a wrong contact. Dropping a -//! contacting pair on transient separation would lose it until the body sank past the -//! margin. +//! Sleeping is ENABLED by default. Every convergence measurement — resting box, +//! five-box stack, mass ratio, drift — sets `sleep_cfg.allow_sleeping = false` (or +//! opens the world with `initNoSleep`), which is normative and not a convenience +//! (§1.8.3): a displacement-bounded criterion lets a slowly creeping body sleep, so +//! a converging-or-not question must be asked with sleeping off. const std = @import("std"); const config = @import("../config.zig"); @@ -59,6 +28,8 @@ const integration = @import("../pipeline/integration.zig"); const sleep = @import("../pipeline/sleep.zig"); const sensor = @import("../pipeline/sensor.zig"); const rigid = @import("../rigid/root.zig"); +// M1.1.15 — the per-tick cycle, now production. `World` below is this type. +const world_mod = @import("../world.zig"); // M1.1.14 — the module's float-environment check, asserted where a world opens. const determinism = @import("../determinism.zig"); const api = @import("weld_forge"); @@ -88,283 +59,16 @@ pub fn av3(x: f32, y: f32, z: f32) foundation.math.Vec3 { return foundation.math.Vec3.fromArray(.{ x, y, z }); } -// The harness does NOT compute a broad layer of its own. It calls -// `BodyManager.broadLayerFor`, the same derivation production will use, so a trigger -// lands in the `trigger` class BY THE RULE and not by a literal that happens to agree -// with it. - -fn sortDedup(list: *std.ArrayListUnmanaged(u64)) void { - std.mem.sort(u64, list.items, {}, std.sort.asc(u64)); - if (list.items.len == 0) return; - var w: usize = 1; - var i: usize = 1; - while (i < list.items.len) : (i += 1) { - if (list.items[i] != list.items[w - 1]) { - list.items[w] = list.items[i]; - w += 1; - } - } - list.shrinkRetainingCapacity(w); -} - -const BodyProxy = struct { id: BodyId, proxy: Bp.Proxy }; - -/// A minimal physics world composing the full contact-solver pipeline for tests. -/// The single definition of the normative per-tick cycle (see the file header). -pub const World = struct { - store: ShapeStore = .{}, - bm: BodyManager = .{}, - bp: Bp, - cache: ContactCache = .{}, - cfg: SolverConfig = .{}, - /// Sleep tuning. Enabled by default; convergence measurements switch it off. - sleep_cfg: sleep.SleepConfig = .{}, - gravity: Vec3r, - dt: Real, - bodies: std.ArrayListUnmanaged(BodyProxy) = .empty, - active: std.ArrayListUnmanaged(u64) = .empty, - constraints: std.ArrayListUnmanaged(ContactConstraint) = .empty, - scratch: std.ArrayListUnmanaged(Bp.Pair) = .empty, - /// The island partition of the last tick (step 5). - islands: rigid.IslandManager = .{}, - /// Last tick's solver telemetry (steps 6 and 7) — substeps executed, solve and - /// relax sweeps, and the minimum separation any biased sweep observed. - solver_stats: rigid.SolverStats = .{}, - /// Islands put to sleep at step 11 of the last tick. - slept_last_tick: u32 = 0, - /// The sensor state, updated at STEP 10 BIS when `sensors_on` (M1.1.13). - sensors: sensor.SensorState = .{}, - /// Whether step 10 bis runs. Set by the sensor suite before its first step. - sensors_on: bool = false, - - /// A world with the given gravity and fixed timestep. Default `SolverConfig`, - /// sleeping ENABLED. - pub fn init(gravity: Vec3r, dt: Real) World { - // M1.1.14 — THE physics entry point, until `PhysicsWorld` exists at - // M1.1.15 and inherits this call. Opening a world on a thread whose - // float environment is not the engine's makes every number this world - // produces incomparable with the same world opened elsewhere, so the - // state is checked once, here, where a world begins — and ASSERTED, not - // installed (`ARCH-031` rule 5; the reason the two verbs differ is in - // `../determinism.zig`). - determinism.assertFloatEnvironment(); - return .{ .bp = Bp.init(.{}), .gravity = gravity, .dt = dt }; - } - - /// `init` with sleeping switched off — the world every MEASUREMENT of the solver - /// uses: settling, penetration recovery, drift, friction decay, determinism. - /// - /// Normative, not a convenience (§1.8.3). The sleep criterion is a displacement - /// bound over a window, so a body creeping at 5 mm/s moves 2.5 mm per 0.5 s window - /// against a 15 mm bound and falls asleep while still creeping. Ask "does this - /// settle?" with sleeping on and the answer you measure is "it fell asleep", which - /// is not the same question. - pub fn initNoSleep(gravity: Vec3r, dt: Real) World { - var world = init(gravity, dt); - world.sleep_cfg.allow_sleeping = false; - return world; - } - - /// Release every owned buffer. - pub fn deinit(self: *World, gpa: std.mem.Allocator) void { - self.sensors.deinit(gpa); - self.store.deinit(gpa); - self.bm.deinit(gpa); - self.bp.deinit(gpa); - self.cache.deinit(gpa); - self.bodies.deinit(gpa); - self.active.deinit(gpa); - self.constraints.deinit(gpa); - self.scratch.deinit(gpa); - self.islands.deinit(gpa); - self.* = undefined; - } - - /// Create a body and insert its broadphase proxy on the matching layer. - /// Dispatches on the shape CLASS: an unbounded half-space has no world AABB and - /// goes into the layer's flat list (§1.11.15); a MESH is a finite surface, so it - /// takes the bounded arm. Exhaustive on the class, no `else`. - pub fn addBody(self: *World, gpa: std.mem.Allocator, desc: api.BodyDescriptor) !BodyId { - const id = try self.bm.addBody(gpa, &self.store, desc); - const layer = BodyManager.broadLayerFor(desc.is_trigger, desc.body_type); - const shape = self.store.get(desc.shape).?; - const proxy = switch (shape.class()) { - .convex, .triangle_soup => try self.bp.insert(gpa, layer, self.bm.bodyAabb(&self.store, id).?, id), - .half_space => blk: { - const world = shape_mod.halfSpace(shape).transformed( - self.bm.rotation(id).?, - self.bm.position(id).?, - ); - break :blk try self.bp.insertUnbounded(gpa, layer, .{ - .normal = world.normal, - .distance = world.distance, - }, id); - }, - }; - try self.bodies.append(gpa, .{ .id = id, .proxy = proxy }); - return id; - } - - /// Remove a body, applying wake cause W4 (§1.8.5) first: every sleeper retained - /// in a candidate pair with it is woken, because removing it changes what - /// supports them and a sleeper emits nothing in broadphase that could notice. - pub fn removeBody(self: *World, id: BodyId) void { - for (self.active.items) |key| { - const a: BodyId = @intCast(key >> 32); - const b: BodyId = @intCast(key & 0xFFFF_FFFF); - if (a != id and b != id) continue; - self.bm.wakeBody(if (a == id) b else a); - } - for (self.bodies.items, 0..) |entry, i| { - if (entry.id != id) continue; - self.bp.remove(entry.proxy); - _ = self.bodies.orderedRemove(i); // ordered: the sweep order stays stable - break; - } - self.bm.removeBody(id); - } +// --- the cycle under test ----------------------------------------------------- - /// The proxy of `id`, or `null` once the body has been removed. - fn proxyOf(self: *const World, id: BodyId) ?Bp.Proxy { - for (self.bodies.items) |b| { - if (b.id == id) return b.proxy; - } - return null; - } - - /// Whether a retained pair still satisfies §1.7 step 2 — "removal on FAT-AABB - /// separation only". - /// - /// Three cases, exhaustive on what a proxy can be, and the middle one is why - /// this is not a two-box test. A half-space has no box at all (§1.11.15), so a - /// pair with one on either side is tested by the SAME exact predicate the - /// traversal uses, `Aabb.overlapsHalfSpace` from `foundation/math` — never a - /// second copy of that formula. Two half-spaces both force static bodies and - /// can never separate, so such a pair is retained unconditionally. - /// - /// The FAT boxes are the ones compared, deliberately. Comparing the tight boxes - /// would purge on a transient sub-margin separation and lose the contact until - /// the body sank back past the margin — the defect `test "small hop within the - /// fat margin keeps the contact pair alive"` was written for at M1.1.6. The - /// margin exists precisely so that this test has hysteresis. - fn pairStillOverlaps(self: *const World, a: BodyId, b: BodyId) bool { - // A removed body's pair serves nothing: W4 has already woken whoever was - // retained with it, at `removeBody`, and there is no proxy left to test. - const pa = self.proxyOf(a) orelse return false; - const pb = self.proxyOf(b) orelse return false; - - const box_a = self.bp.proxyAabb(pa); - const box_b = self.bp.proxyAabb(pb); - if (box_a) |ba| { - if (box_b) |bb| return ba.overlaps(bb); - const hs = self.bp.unboundedShape(pb) orelse return false; - return ba.overlapsHalfSpace(hs.normal, hs.distance); - } - if (box_b) |bb| { - const hs = self.bp.unboundedShape(pa) orelse return false; - return bb.overlapsHalfSpace(hs.normal, hs.distance); - } - return true; // two half-spaces: both static, no separation is possible - } - - /// Advance one fixed tick through the normative cycle (file header). - pub fn step(self: *World, gpa: std.mem.Allocator) !void { - // (1) broadphase candidate deltas → (2) persistent active set. - // - // The set is PERSISTENT and its retention is a CORRECTNESS condition of - // sleep (§1.8.7), not merely warm-start persistence — a sleeper emits - // nothing in broadphase, so these retained pairs ARE the wake graph. - // - // M1.1.14 — it is also PRUNED, on the one condition §1.7 step 2 allows: - // the two FAT AABBs have separated. Until this milestone the harness kept - // every pair it had ever seen, a conservative superset of the normative - // rule; that is sound for the wake graph but makes the retained set a - // monotonically growing sequence, and a determinism trace over a set that - // can only grow passes by ACCUMULATION and proves nothing. The - // non-vacuity probe on this set is what turns it back into an oracle. - try self.bp.computePairs(gpa, &self.scratch); - for (self.scratch.items) |p| try self.active.append(gpa, (@as(u64, p.a) << 32) | p.b); - sortDedup(&self.active); - { - var w: usize = 0; - for (self.active.items) |key| { - const a: BodyId = @intCast(key >> 32); - const b: BodyId = @intCast(key & 0xFFFF_FFFF); - if (!self.pairStillOverlaps(a, b)) continue; - self.active.items[w] = key; - w += 1; - } - self.active.shrinkRetainingCapacity(w); - } - - // (3) external forces — read-only, no code. See the file header. - - // (4) build: narrowphase per candidate; `prepare` captures `v_n⁻` PRE-GRAVITY - // (the velocity integration has moved into the substep loop), selects the - // softness, SEEDS the warm start from the cache, and the wake fixpoint runs. - self.cache.beginTick(); - try rigid.build( - gpa, - &self.constraints, - &self.bm, - &self.store, - self.active.items, - rigid.prepareContext(self.cfg, self.dt, &self.cache), - ); - - // (5) partition into islands and arbitrate activation. Reorders the - // constraint array into one contiguous range per island. Wakes only. - try self.islands.partition(gpa, &self.bm, self.constraints.items); - - // (6) the substep loop and (7) the restitution pass. Islands advance in - // LOCKSTEP inside: every stage sweeps all intervals before the next begins. - self.solver_stats = rigid.solveTick( - &self.bm, - self.constraints.items, - self.islands.islandsSlice(), - self.cfg, - self.dt, - self.gravity, - ); - - // (9) harvest solved impulses into the cache, then finalize (sort + swap). - try rigid.storeContacts(gpa, &self.cache, self.constraints.items); - self.cache.endTick(); - - // (10) broadphase proxy updates to the final poses. - for (self.bodies.items) |b| { - const sleeping = self.bm.isSleeping(b.id) orelse continue; // stale handle - if (sleeping) continue; // a sleeper's AABB is unchanged by construction - // An UNBOUNDED proxy has no box to update and cannot move: a half-space - // forces a STATIC body, so its pairs are established once at insertion - // and then carried by the retention rule of step 2 (§1.11.15). - if (b.proxy.kind == .unbounded) continue; - if (self.bm.bodyAabb(&self.store, b.id)) |aabb| try self.bp.update(gpa, b.proxy, aabb); - } - - // (10 BIS) the sensor pass (§1.13.4). Placement rests on two claims of - // UNEQUAL rank: BEFORE step 11 is MEASURED (the sleep case asserts that - // falling asleep inside a trigger never produces an exit, and a sleep filter - // on the traversal makes it fail); AFTER step 10 is a DESIGN REASONING — the - // poses there are the ones the tick publishes, so an `enter` cannot announce - // a crossing the solver then undoes. - if (self.sensors_on) try self.sensors.update(gpa, &self.bp, &self.bm, &self.store); - - // (11) advance the sleep windows on the POST-SOLVE state, then put to sleep - // every island all of whose members are eligible. - sleep.updateWindows(&self.bm, self.dt, self.sleep_cfg); - self.slept_last_tick = self.islands.sleepEligibleIslands(&self.bm, self.sleep_cfg); - } - - /// Deepest penetration across the manifolds this world currently holds. - pub fn deepestPenetration(self: *const World) Real { - var deepest: Real = 0; - for (self.constraints.items) |c| { - for (0..c.count) |i| deepest = @max(deepest, c.points[i].penetration); - } - return deepest; - } -}; +/// The per-tick cycle, MOVED to production at M1.1.15. Every suite that drives a +/// world drives THIS type, so there is exactly one composition of the eleven steps +/// in the module — see `../world.zig` for the order and the retention rule. +/// +/// `PhysicsWorld.addBody` computes no broad layer of its own either: it calls +/// `BodyManager.broadLayerFor`, so a trigger lands in the `trigger` class BY THE +/// RULE and not by a literal that happens to agree with it. +pub const World = world_mod.PhysicsWorld; // --- named envelopes ---------------------------------------------------------- // @@ -1369,7 +1073,7 @@ test "per-feature warm start: surviving points keep impulses, a vanished one col // Applying it moves both bodies, and it credits `total_normal_impulse` with the // CURRENT accumulator — the first of the three contributions the restitution // predicate reads. - rigid.applyWarmStartRange(&bm, constraints.items, 0, 1); + _ = rigid.applyWarmStartRange(&bm, constraints.items, 0, 1); try testing.expect(!bm.linearVelocity(a).?.approxEql(Vec3r.zero, 0)); try testing.expectApproxEqAbs(@as(Real, 3), c.points[0].total_normal_impulse, 1e-5); } diff --git a/src/modules/forge/forge_3d/tests/world_test.zig b/src/modules/forge/forge_3d/tests/world_test.zig new file mode 100644 index 00000000..e77b276b --- /dev/null +++ b/src/modules/forge/forge_3d/tests/world_test.zig @@ -0,0 +1,954 @@ +//! M1.1.15 acceptance suite for `PhysicsWorld` — the owner of the per-tick cycle. +//! +//! What this file measures is the ORCHESTRATION and never the physics: the order the +//! stages of `engine-physics-solver.md` §1.7 run in, the substep cadence, the proxy +//! lifetime, the class assignment and the wake composition. What each stage COMPUTES +//! is measured by the suite of the stage — `solver_test.zig`, `sleep_test.zig`, +//! `sensor_test.zig`, `island_test.zig` — and re-measuring it here would only put a +//! second copy of those claims where nobody maintains them. +//! +//! Every counter-factual named in a test below was RUN, and its effect is written +//! next to the assertion it justifies. A control whose green alone has been seen is +//! indistinguishable from a control that judges nothing +//! (`engine-development-workflow.md` §5.5). + +const std = @import("std"); +const config = @import("../config.zig"); +const world_mod = @import("../world.zig"); +const api = @import("weld_forge"); +const foundation = @import("foundation"); + +const Real = config.Real; +const Vec3r = config.Vec3r; +const PhysicsWorld = world_mod.PhysicsWorld; +const Step = world_mod.Step; +const StepTrace = world_mod.StepTrace; +const BodyId = api.BodyId; +const testing = std.testing; + +const fixed_dt: Real = 1.0 / 60.0; +const gravity_y: Real = -9.81; + +fn vr(x: Real, y: Real, z: Real) Vec3r { + return Vec3r.fromArray(.{ x, y, z }); +} + +fn av3(x: f32, y: f32, z: f32) foundation.math.Vec3 { + return foundation.math.Vec3.fromArray(.{ x, y, z }); +} + +/// A dynamic unit box at `(x, y, z)` — the descriptor the transactionality sweeps use. +fn dynamicAt(shape: api.ShapeId, x: f32, y: f32, z: f32) api.BodyDescriptor { + var d = api.BodyDescriptor{ + .entity = .{ .index = 7, .generation = 0 }, + .body_type = .dynamic, + .shape = shape, + }; + d.mass = 1; + d.restitution = 0; + d.position = av3(x, y, z); + return d; +} + +/// A static ground box (half-extents 5 × 0.5 × 5) centred on the origin, so its top +/// face is at `y = 0.5`, plus a dynamic unit box resting FLUSH on it (centre at +/// `y = 1.0`, zero penetration). Contacts therefore exist from the first tick, which +/// is what every test below needs and none of them should have to arrange. +fn groundAndRestingBox(gpa: std.mem.Allocator, world: *PhysicsWorld) !BodyId { + const ground_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av3(5, 0.5, 5) } }); + const box_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } }); + + var ground = api.BodyDescriptor{ + .entity = .{ .index = 0, .generation = 0 }, + .body_type = .static, + .shape = ground_shape, + }; + ground.restitution = 0; + _ = try world.addBody(gpa, ground); + + var box = api.BodyDescriptor{ + .entity = .{ .index = 1, .generation = 0 }, + .body_type = .dynamic, + .shape = box_shape, + }; + box.mass = 1; + box.restitution = 0; + box.position = av3(0, 1.0, 0); + return world.addBody(gpa, box); +} + +/// Constraint points the world currently holds, summed over its manifolds — the +/// quantity the warm start injects once per substep. +fn totalConstraintPoints(world: *const PhysicsWorld) u32 { + var total: u32 = 0; + for (world.constraints.items) |c| total += c.count; + return total; +} + +// --- step order --------------------------------------------------------------- + +test "step executes the nine coded steps of the eleven-anchor cycle in order" { + const gpa = testing.allocator; + var world = PhysicsWorld.initNoSleep(vr(0, gravity_y, 0), fixed_dt); + defer world.deinit(gpa); + _ = try groundAndRestingBox(gpa, &world); + + var trace: StepTrace = .{}; + world.trace = &trace; + try world.step(gpa); + + // THE ORDER, read as an order. `expectEqualSlices` compares position by + // position, so this fails on a permutation that a "did each stage run?" check + // would pass — which is the whole difference between reading a sequence and + // reading a set. + // + // The nine entries are the anchors of §1.7 that EXECUTE. Three do not, and their + // absence is the contract: step 3 is read-only and owns no code, step 5 bis is + // the empty composite seam of §1.7.3, and step 8 is retired at a frozen number. + // Anchors 6 and 7 are one `rigid.solveTick` call, whose internal order (substep + // loop, then restitution) `rigid/solver.zig` pins where it can be seen. + const expected = [_]Step{ + .broadphase_pairs, // (1) + .pair_retention, // (2) + .build_constraints, // (4) + .island_partition, // (5) + .solve_tick, // (6) + (7) + .harvest_contacts, // (9) + .proxy_update, // (10) + .sensor_pass, // (10 bis) + .sleep_transition, // (11) + }; + try testing.expectEqualSlices(Step, &expected, trace.order()); + + // NON-VACUITY, both halves. A trace that recorded nothing would compare equal to + // an empty expectation, and a truncated one would compare equal to a short + // expectation: the length is pinned against the module's own count, and the + // recorder is required to have dropped nothing. + try testing.expectEqual(world_mod.executed_step_count, trace.order().len); + try testing.expectEqual(@as(u32, 0), trace.dropped); + + // COUNTER-FACTUALS, RUN — five, each naming the PAIR it moved, because a + // counter-factual that does not name its object does not bound its conclusion. + // + // Two pairs are used, and they differ in one property that decides everything + // below: whether inverting the two stages has a PHYSICAL consequence. + // - `(proxy_update, sensor_pass)` — stages 10 and 10 bis. Inverting them is + // physically harmless: the sensor pass takes a `*const BodyManager`, so it + // cannot alter one bit of body state, and reading proxies from before the + // update instead of after changes only what the sensor state reports. + // - `(build_constraints, island_partition)` — stages 4 and 5. Inverting them + // partitions LAST tick's constraint array and then rebuilds it, so the island + // ranges the solver consumes no longer describe the constraints it solves. + // + // (A) CALLS of 10 / 10 bis swapped: `1 failed` of 561, and it is this test. + // (D) CALLS of 4 / 5 swapped: `4 failed, 35 crashed`. + // (C) BODIES of 10 / 10 bis swapped, each `enter()` left in place: the whole suite + // GREEN, 560/561, this test included. + // (C') BODIES of 4 / 5 swapped, each `enter()` left in place: `3 failed, 35 + // crashed`, and this test PASSES — which is exactly (D) MINUS this test. + // (B) the warm start hoisted out of the substep loop: see the cadence test below. + // + // WHAT THAT MEASURES, in its bounded form. A record separated from its work is + // undetectable ONLY where inverting the two stages is physically harmless: (C') is + // the measurement that says so, since moving the work without its record produced + // the same 35 crashes as moving the calls. Everywhere the order has a physical + // consequence the physics guards fire on their own, loudly, and this test is not + // load-bearing there. + // + // WHICH IS WHY THIS TEST EXISTS, and it is the claim that was buried under the + // residual: the one adjacency where nothing else fires is precisely the harmless + // one — 10 / 10 bis, where (A) reports `1 failed` of 561 and every other guard in + // the suite stays green. Without this assertion that adjacency could be inverted + // in silence. The structural convention — each `enter()` the first statement of + // the stage method carrying its name — is what keeps the recorded order the + // executed one on the harmless pairs, and it is the only place it has to. +} + +test "the step trace is reset per tick and its order is stable across ticks" { + const gpa = testing.allocator; + var world = PhysicsWorld.initNoSleep(vr(0, gravity_y, 0), fixed_dt); + defer world.deinit(gpa); + _ = try groundAndRestingBox(gpa, &world); + + var trace: StepTrace = .{}; + world.trace = &trace; + + // The recorder saturates rather than wrapping, so a caller that forgets to reset + // sees a SHORT second tick and never a plausible one. Both readings are asserted: + // without the reset the second tick drops every stage it tried to record. + try world.step(gpa); + try world.step(gpa); + try testing.expectEqual(world_mod.executed_step_count, trace.order().len); + try testing.expectEqual(@as(u32, world_mod.executed_step_count), trace.dropped); + + trace.reset(); + try testing.expectEqual(@as(usize, 0), trace.order().len); + try world.step(gpa); + try testing.expectEqual(Step.broadphase_pairs, trace.order()[0]); + try testing.expectEqual(Step.sleep_transition, trace.order()[trace.order().len - 1]); + try testing.expectEqual(@as(u32, 0), trace.dropped); +} + +// --- substep cadence ---------------------------------------------------------- + +test "substep cadence: warm start is applied inside the substep loop, every substep" { + const gpa = testing.allocator; + var world = PhysicsWorld.initNoSleep(vr(0, gravity_y, 0), fixed_dt); + defer world.deinit(gpa); + _ = try groundAndRestingBox(gpa, &world); + + // FOUR substeps — the default, and the point of the test. At one substep a + // per-substep application and a once-per-tick application inject exactly the + // same number of points, so a single-substep run cannot tell them apart and + // measuring there would be measuring nothing. + try testing.expectEqual(@as(u32, 4), world.cfg.substep_count); + try world.step(gpa); + + const points = totalConstraintPoints(&world); + // NON-VACUITY: a box resting flush on a box gives a face-face manifold, so there + // is something to inject. Without this the equality below holds at zero. + try testing.expect(points > 0); + try testing.expectEqual(@as(u32, 4), world.solver_stats.substeps_executed); + try testing.expectEqual(points * 4, world.solver_stats.not_reported.warm_start_injections); + + // THE PAIRED NEGATIVE, at one substep on the same scene: the injections collapse + // to exactly one pass over the points. Together the two readings discriminate — + // an implementation that seeded once per tick would report `points` in BOTH, and + // one that applied per substep reports `4 · points` here and `points` there. + var single = PhysicsWorld.initNoSleep(vr(0, gravity_y, 0), fixed_dt); + defer single.deinit(gpa); + single.cfg.substep_count = 1; + _ = try groundAndRestingBox(gpa, &single); + try single.step(gpa); + + const single_points = totalConstraintPoints(&single); + try testing.expect(single_points > 0); + try testing.expectEqual(@as(u32, 1), single.solver_stats.substeps_executed); + try testing.expectEqual(single_points, single.solver_stats.not_reported.warm_start_injections); + + // COUNTER-FACTUAL, RUN: hoisting the `applyWarmStartRange` loop out of the substep + // loop in `rigid/solver.zig` makes this test report `expected 16, found 4` — the + // four-substep reading collapsing to `points` — while the one-substep reading + // above is untouched. Six physics tests fall with it, including the determinism + // witness; this is the only one of the seven whose message names the cause. +} + +test "the solve/relax sweep counts are one per substep" { + const gpa = testing.allocator; + var world = PhysicsWorld.initNoSleep(vr(0, gravity_y, 0), fixed_dt); + defer world.deinit(gpa); + _ = try groundAndRestingBox(gpa, &world); + try world.step(gpa); + + // `3·substep_count + 1` constraint sweeps per tick is the structural cost §1.8.2 + // states: solve, relax and warm start per substep, restitution once. Two of the + // three are counted here; the third is the injection count above. + try testing.expectEqual(world.solver_stats.substeps_executed, world.solver_stats.solve_sweeps); + try testing.expectEqual(world.solver_stats.substeps_executed, world.solver_stats.relax_sweeps); +} + +// --- step 10 bis -------------------------------------------------------------- + +test "step 10 bis runs on a world that was never told about sensors" { + const gpa = testing.allocator; + var world = PhysicsWorld.initNoSleep(Vec3r.zero, fixed_dt); + defer world.deinit(gpa); + + // A trigger and a body inside it. Nothing switches the sensor pass on, because + // there is nothing to switch: step 10 bis is unconditional. Until M1.1.15 the + // pass was gated on a `sensors_on` flag the harness carried and that defaulted to + // FALSE, which for a production world would mean sensors silently do not work — + // and the determinism scenario asserted that flag rather than this property. That + // assertion is gone; this is what replaced it, and it is obtained by a different + // mechanism: it reads the STATE the pass produces, not a switch feeding it. + const box_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av3(1, 1, 1) } }); + const sphere_shape = try world.store.createShape(gpa, .{ .sphere = .{ .radius = 0.25 } }); + + var trigger = api.BodyDescriptor{ + .entity = .{ .index = 7, .generation = 0 }, + .body_type = .static, + .shape = box_shape, + }; + trigger.is_trigger = true; + const trigger_id = try world.addBody(gpa, trigger); + + var visitor = api.BodyDescriptor{ + .entity = .{ .index = 9, .generation = 0 }, + .body_type = .dynamic, + .shape = sphere_shape, + }; + visitor.mass = 1; + visitor.gravity_factor = 0; + _ = try world.addBody(gpa, visitor); + + try world.step(gpa); + + try testing.expectEqual(@as(usize, 1), world.sensors.current.items.len); + try testing.expectEqual(@as(usize, 1), world.sensors.entered.items.len); + const pair = world.sensors.current.items[0]; + try testing.expectEqual(@as(u32, 7), pair.trigger.index); + try testing.expectEqual(@as(u32, 9), pair.other.index); + + // And the trigger reached NO constraint: `is_trigger` removes the physical + // response absolutely (§1.13.7), so the pass that saw it produced no contact. + // The positive above without this would be satisfied by a world in which a + // trigger both detects and collides. + try testing.expectEqual(@as(usize, 0), world.constraints.items.len); + try testing.expectEqual(@as(?bool, true), world.bm.isTrigger(trigger_id)); +} + +test "a world with no trigger produces an empty sensor state rather than skipping" { + const gpa = testing.allocator; + var world = PhysicsWorld.initNoSleep(vr(0, gravity_y, 0), fixed_dt); + defer world.deinit(gpa); + _ = try groundAndRestingBox(gpa, &world); + + var trace: StepTrace = .{}; + world.trace = &trace; + try world.step(gpa); + + // The pass RAN — the trace says so — and produced nothing, which is the correct + // answer for a scene with no trigger. The distinction matters: "the state is + // empty" is satisfied both by a pass that ran over nothing and by a pass that + // never ran, and only the first is the contract. + var saw_sensor_pass = false; + for (trace.order()) |s| { + if (s == .sensor_pass) saw_sensor_pass = true; + } + try testing.expect(saw_sensor_pass); + try testing.expectEqual(@as(usize, 0), world.sensors.current.items.len); + try testing.expectEqual(@as(usize, 0), world.sensors.entered.items.len); + try testing.expectEqual(@as(usize, 0), world.sensors.exited.items.len); +} + +// --- proxies and body lifetime (Gate B) --------------------------------------- + +const BroadphaseLayer = @import("../pipeline/broadphase.zig").BroadphaseLayer; + +/// The four per-class proxy counts, in enum order — the only shape a count of proxies +/// is allowed to take here. A TOTAL is satisfied by a body inserted into the wrong +/// class, which is the whole defect these tests exist to catch. +fn classCounts(world: *const PhysicsWorld) [4]u32 { + return .{ + world.proxyCountIn(.static), + world.proxyCountIn(.dynamic), + world.proxyCountIn(.debris), + world.proxyCountIn(.trigger), + }; +} + +fn addBoxBody( + gpa: std.mem.Allocator, + world: *PhysicsWorld, + body_type: api.BodyType, + is_trigger: bool, + entity_index: u32, + centre: [3]f32, +) !BodyId { + const shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } }); + var desc = api.BodyDescriptor{ + .entity = .{ .index = entity_index, .generation = 0 }, + .body_type = body_type, + .shape = shape, + }; + desc.position = av3(centre[0], centre[1], centre[2]); + desc.is_trigger = is_trigger; + if (body_type == .dynamic) desc.mass = 1; + return world.addBody(gpa, desc); +} + +test "a proxy exists for every live body and for every character presence, and none outlives its owner" { + const gpa = testing.allocator; + var world = PhysicsWorld.initNoSleep(Vec3r.zero, fixed_dt); + defer world.deinit(gpa); + + // Five bodies spread over three classes, plus a HALF-SPACE, which is the case a + // tree-only count would miss: it has no AABB at all and lives in the layer's + // unbounded list (`engine-physics-shapes.md` §1.11.15). `proxyCountIn` walks both. + const ground = try addBoxBody(gpa, &world, .static, false, 1, .{ 0, 0, 0 }); + const dyn = try addBoxBody(gpa, &world, .dynamic, false, 2, .{ 0, 5, 0 }); + const platform = try addBoxBody(gpa, &world, .kinematic, false, 3, .{ 10, 0, 0 }); + const trigger = try addBoxBody(gpa, &world, .static, true, 4, .{ 20, 0, 0 }); + + const plane_shape = try world.store.createShape(gpa, .{ .plane = .{ .normal = av3(0, 1, 0), .distance = -10 } }); + const plane = api.BodyDescriptor{ + .entity = .{ .index = 5, .generation = 0 }, + .body_type = .static, + .shape = plane_shape, + }; + _ = try world.addBody(gpa, plane); + + // AND A CHARACTER — trap named in the gate: the presence is inserted by the + // orchestrator and by nothing else, so a counting scene without one is green on the + // empty set of exactly what this test exists to guard. + const hero = try world.createCharacter(gpa, .{ .entity = .{ .index = 6, .generation = 0 } }); + try testing.expect((try world.chars.getCharacterInnerBody(hero)) != null); + + // static = ground box + half-space -> 2 + // dynamic = dynamic box + kinematic platform + presence -> 3 + // debris = nothing declares it -> 0 + // trigger = the sensor -> 1 + try testing.expectEqual([4]u32{ 2, 3, 0, 1 }, classCounts(&world)); + + // NOTHING OUTLIVES ITS OWNER, measured AFTER destruction and in a scene where an + // orphan WOULD be visible: five other proxies remain, so a leaked one shows up as an + // excess in its class rather than being hidden by an empty world. + world.destroyCharacter(gpa, hero); + try testing.expectEqual([4]u32{ 2, 2, 0, 1 }, classCounts(&world)); + + world.removeBody(dyn); + try testing.expectEqual([4]u32{ 2, 1, 0, 1 }, classCounts(&world)); + + world.removeBody(trigger); + try testing.expectEqual([4]u32{ 2, 1, 0, 0 }, classCounts(&world)); + + world.removeBody(platform); + world.removeBody(ground); + try testing.expectEqual([4]u32{ 1, 0, 0, 0 }, classCounts(&world)); +} + +test "trigger role wins over body type in class assignment" { + const gpa = testing.allocator; + var world = PhysicsWorld.initNoSleep(Vec3r.zero, fixed_dt); + defer world.deinit(gpa); + + // THE PAIR IS THE TEST. A kinematic sensor alone would be satisfied by an + // implementation that sends every KINEMATIC body to `trigger`; its twin without the + // role pins that the discriminant is `is_trigger` and not the body type. The two + // differ in exactly one field. + const sensor_body = try addBoxBody(gpa, &world, .kinematic, true, 1, .{ 0, 0, 0 }); + const plain = try addBoxBody(gpa, &world, .kinematic, false, 2, .{ 10, 0, 0 }); + + try testing.expectEqual([4]u32{ 0, 1, 0, 1 }, classCounts(&world)); + try testing.expectEqual(@as(?bool, true), world.bm.isTrigger(sensor_body)); + try testing.expectEqual(@as(?bool, false), world.bm.isTrigger(plain)); + try testing.expectEqual(@as(?api.BodyType, .kinematic), world.bm.bodyType(sensor_body)); + try testing.expectEqual(@as(?api.BodyType, .kinematic), world.bm.bodyType(plain)); + + // And the rule read directly, in both orders, so the priority is asserted and not + // merely exhibited by a scene that happens to agree with it. + const BM = @TypeOf(world.bm); + try testing.expectEqual(BroadphaseLayer.trigger, BM.broadLayerFor(true, .kinematic)); + try testing.expectEqual(BroadphaseLayer.trigger, BM.broadLayerFor(true, .static)); + try testing.expectEqual(BroadphaseLayer.trigger, BM.broadLayerFor(true, .dynamic)); + try testing.expectEqual(BroadphaseLayer.dynamic, BM.broadLayerFor(false, .kinematic)); + try testing.expectEqual(BroadphaseLayer.static, BM.broadLayerFor(false, .static)); + try testing.expectEqual(BroadphaseLayer.dynamic, BM.broadLayerFor(false, .dynamic)); +} + +test "a character created without a presence inserts no proxy" { + const gpa = testing.allocator; + var world = PhysicsWorld.initNoSleep(Vec3r.zero, fixed_dt); + defer world.deinit(gpa); + + // The paired negative of the counting test: `inner_body = false` is a legal choice, + // and it must leave every class empty. Without it, "the presence is inserted" is + // satisfied by an orchestrator that inserts a proxy for every character whatever the + // descriptor asked for. + const ghost = try world.createCharacter(gpa, .{ + .entity = .{ .index = 1, .generation = 0 }, + .inner_body = false, + }); + try testing.expectEqual(@as(?BodyId, null), try world.chars.getCharacterInnerBody(ghost)); + try testing.expectEqual([4]u32{ 0, 0, 0, 0 }, classCounts(&world)); + + const solid = try world.createCharacter(gpa, .{ .entity = .{ .index = 2, .generation = 0 } }); + try testing.expect((try world.chars.getCharacterInnerBody(solid)) != null); + try testing.expectEqual([4]u32{ 0, 1, 0, 0 }, classCounts(&world)); +} + +// --- wake composition (Gate C) ------------------------------------------------- + +/// Step until `id` is asleep, or fail. Sleeping is ON here, deliberately: these tests +/// ask "does this wake?", which is the one question that needs a sleeper. +fn stepUntilAsleep(gpa: std.mem.Allocator, world: *PhysicsWorld, id: BodyId, budget: u32) !u32 { + var t: u32 = 0; + while (t < budget) : (t += 1) { + try world.step(gpa); + if (world.bm.isSleeping(id).?) return t + 1; + } + return error.NeverFellAsleep; +} + +test "external mutation wakes and resets the window; a solver-internal write does neither" { + const gpa = testing.allocator; + var world = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer world.deinit(gpa); + const box = try groundAndRestingBox(gpa, &world); + + _ = try stepUntilAsleep(gpa, &world, box, 200); + try testing.expect(world.bm.isSleeping(box).?); + + // DIRECTION ONE — the solver's own write path does NOT wake. These are the setters + // the substep loop and the restitution pass drive on every body in contact, every + // tick; if they woke, nothing in contact could ever sleep, which is the whole reason + // §1.8.4 splits the two intentions. + // + // THE WINDOW IS READ AS A VALUE AND NOT AS A FLAG, and the value corrected this + // test: a sleeping body's `sleep_time` is the ACCUMULATED window that made it + // eligible — measured 0.5 s, which is `time_before_sleep` — and zero is what a WAKE + // writes. So the two directions read DIFFERENT values on the same field, which + // discriminates more sharply than the pair of zeroes first asserted here. + const window_asleep = world.bm.sleepTime(box).?; + try testing.expect(window_asleep > 0); + world.bm.setLinearVelocity(box, vr(3, 0, 0)); + world.bm.setPosition(box, vr(0, 1.5, 0)); + world.bm.setRotation(box, config.Quatr.identity); + try testing.expect(world.bm.isSleeping(box).?); + try testing.expectEqual(window_asleep, world.bm.sleepTime(box).?); + + // DIRECTION TWO — the same write through the gameplay-facing entry wakes AND rearms. + // Without direction one above, this assertion is satisfied by a rule that wakes on + // every write whatever its origin, which is not the rule. + world.setLinearVelocity(box, vr(3, 0, 0)); + try testing.expect(!world.bm.isSleeping(box).?); + try testing.expectEqual(@as(Real, 0), world.bm.sleepTime(box).?); + + // AND THE WINDOW IS REALLY REARMED, not merely zero-because-it-was-zero: let it + // accumulate, then wake again and read the drop. A wake that only cleared the + // sleeping flag would leave the window where it stood. + _ = try stepUntilAsleep(gpa, &world, box, 200); + var t: u32 = 0; + while (t < 5) : (t += 1) try world.step(gpa); + const accumulated = world.bm.sleepTime(box).?; + try testing.expect(accumulated > 0); + world.addImpulse(box, vr(0.5, 0, 0)); + try testing.expect(!world.bm.isSleeping(box).?); + try testing.expect(world.bm.sleepTime(box).? < accumulated); +} + +test "W4: removing a body wakes the sleepers retained in pair with it" { + const gpa = testing.allocator; + var world = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer world.deinit(gpa); + const box = try groundAndRestingBox(gpa, &world); + + // A SECOND, DISTANT sleeper that shares no pair with the ground. It is what makes + // this a test of the wake GRAPH rather than of a wake-everything: after the removal + // it must still be asleep, and a scene without it cannot tell the two apart. + const far_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av3(5, 0.5, 5) } }); + var far_ground = api.BodyDescriptor{ + .entity = .{ .index = 10, .generation = 0 }, + .body_type = .static, + .shape = far_shape, + }; + far_ground.position = av3(100, 0, 0); + _ = try world.addBody(gpa, far_ground); + const far_box = try addBoxBody(gpa, &world, .dynamic, false, 11, .{ 100, 1.0, 0 }); + + _ = try stepUntilAsleep(gpa, &world, box, 300); + _ = try stepUntilAsleep(gpa, &world, far_box, 300); + try testing.expect(world.bm.isSleeping(box).?); + try testing.expect(world.bm.isSleeping(far_box).?); + + // The ground under `box` goes. `box` is retained in a pair with it, `far_box` is not. + const ground = world.bodies.items[0].id; + world.removeBody(ground); + try testing.expect(!world.bm.isSleeping(box).?); + try testing.expect(world.bm.isSleeping(far_box).?); // the counter-factual, in scene +} + +test "W4: static teleportation wakes the sleepers retained in pair with it" { + const gpa = testing.allocator; + var world = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer world.deinit(gpa); + const box = try groundAndRestingBox(gpa, &world); + + const far_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av3(5, 0.5, 5) } }); + var far_ground = api.BodyDescriptor{ + .entity = .{ .index = 10, .generation = 0 }, + .body_type = .static, + .shape = far_shape, + }; + far_ground.position = av3(100, 0, 0); + const far_ground_id = try world.addBody(gpa, far_ground); + const far_box = try addBoxBody(gpa, &world, .dynamic, false, 11, .{ 100, 1.0, 0 }); + + _ = try stepUntilAsleep(gpa, &world, box, 300); + _ = try stepUntilAsleep(gpa, &world, far_box, 300); + + // Teleporting the DISTANT ground wakes the body above IT and leaves the other + // asleep — both halves in one scene, so "it wakes" and "it wakes only what it + // should" are read from the same act. + try world.setBodyTransform(gpa, far_ground_id, vr(100, -0.2, 0), config.Quatr.identity); + try testing.expect(!world.bm.isSleeping(far_box).?); + try testing.expect(world.bm.isSleeping(box).?); +} + +test "setBodyTransform derives no velocity" { + const gpa = testing.allocator; + var world = PhysicsWorld.initNoSleep(Vec3r.zero, fixed_dt); + defer world.deinit(gpa); + + const platform = try addBoxBody(gpa, &world, .kinematic, false, 1, .{ 0, 0, 0 }); + try testing.expectEqual(Vec3r.zero, world.bm.linearVelocity(platform).?); + try testing.expectEqual(Vec3r.zero, world.bm.angularVelocity(platform).?); + + // A teleport of one metre over one tick. If this entry derived a velocity the way + // `moveKinematic` is required to, the linear column would read 60 m/s; it reads + // zero, and that is the contract — the split between the two entries is what makes + // `ground_velocity` truthful for one and silent for the other (§1.12.5). + try world.setBodyTransform(gpa, platform, vr(1, 0, 0), config.Quatr.identity); + try testing.expectEqual(vr(1, 0, 0), world.bm.position(platform).?); + try testing.expectEqual(Vec3r.zero, world.bm.linearVelocity(platform).?); + try testing.expectEqual(Vec3r.zero, world.bm.angularVelocity(platform).?); +} + +/// Whether the retained candidate set holds the pair `(a, b)`, in either order. +fn retainsPair(world: *const PhysicsWorld, a: BodyId, b: BodyId) bool { + const lo = @min(a, b); + const hi = @max(a, b); + const key = (@as(u64, lo) << 32) | hi; + for (world.active.items) |k| { + if (k == key) return true; + } + return false; +} + +test "W4: moveCharacter wakes the sleeping bodies retained in pair with the presence" { + const gpa = testing.allocator; + var world = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer world.deinit(gpa); + const box = try groundAndRestingBox(gpa, &world); + + // A SECOND sleeper, far away and sharing no pair with the presence. It is the + // counter-factual, and it is IN THE SCENE rather than in a second test: the same act + // must wake one and not the other, which is the level the claim is made at. + const far_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av3(5, 0.5, 5) } }); + var far_ground = api.BodyDescriptor{ + .entity = .{ .index = 10, .generation = 0 }, + .body_type = .static, + .shape = far_shape, + }; + far_ground.position = av3(100, 0, 0); + _ = try world.addBody(gpa, far_ground); + const far_box = try addBoxBody(gpa, &world, .dynamic, false, 11, .{ 100, 1.0, 0 }); + + // The character stands BESIDE the box, inside the broadphase fat margin and out of + // contact: close enough that the pair is retained, far enough that no manifold is + // built. That band is the whole point — if the capsule touched, `build`'s wake + // fixpoint (§1.8.5) would wake the box every tick and this test would be measuring + // that instead of W4. + const hero = try world.createCharacter(gpa, .{ + .entity = .{ .index = 20, .generation = 0 }, + .position = av3(0.85, 0.5, 0), + }); + const presence = (try world.chars.getCharacterInnerBody(hero)).?; + + _ = try stepUntilAsleep(gpa, &world, box, 300); + _ = try stepUntilAsleep(gpa, &world, far_box, 300); + + // PRECONDITIONS, asserted so the test cannot pass vacuously: the pair exists, the + // distant one does not, both bodies are asleep, and nothing is in contact with the + // presence — the last is what separates W4 from the build fixpoint. + try testing.expect(retainsPair(&world, presence, box)); + try testing.expect(!retainsPair(&world, presence, far_box)); + try testing.expect(world.bm.isSleeping(box).?); + try testing.expect(world.bm.isSleeping(far_box).?); + for (world.constraints.items) |c| { + try testing.expect(c.body_a != presence and c.body_b != presence); + } + + // ONE ACT, TWO READINGS. + _ = try world.moveCharacter(gpa, hero, vr(0, 0, 0.01), fixed_dt); + try testing.expect(!world.bm.isSleeping(box).?); + try testing.expect(world.bm.isSleeping(far_box).?); +} + +test "W4: a character moving where it is retained with nobody wakes nobody" { + const gpa = testing.allocator; + var world = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer world.deinit(gpa); + const box = try groundAndRestingBox(gpa, &world); + + // The same scene as above MINUS the adjacency: the character stands far from the + // sleeper. The sleeper is present and asleep, so a wake WOULD be visible — which is + // what makes this a counter-factual at the right instant rather than a world in + // which nothing sleeps. + const hero = try world.createCharacter(gpa, .{ + .entity = .{ .index = 20, .generation = 0 }, + .position = av3(40, 0.5, 0), + }); + const presence = (try world.chars.getCharacterInnerBody(hero)).?; + + _ = try stepUntilAsleep(gpa, &world, box, 300); + try testing.expect(world.bm.isSleeping(box).?); + try testing.expect(!retainsPair(&world, presence, box)); + + _ = try world.moveCharacter(gpa, hero, vr(0, 0, 0.01), fixed_dt); + try testing.expect(world.bm.isSleeping(box).?); +} + +test "moveKinematic derives both velocities from the target pose over dt" { + const gpa = testing.allocator; + var world = PhysicsWorld.initNoSleep(Vec3r.zero, fixed_dt); + defer world.deinit(gpa); + const platform = try addBoxBody(gpa, &world, .kinematic, false, 1, .{ 0, 0, 0 }); + + // A ROTATION-ONLY MOVE, which is the case that discriminates: an implementation + // deriving only the linear half still passes a combined move, because its linear + // answer would be right and the angular error would hide behind it. Here the position + // does not change at all, so the linear column must read exactly zero and everything + // the test asserts is angular. + // + // The target is written as quaternion COMPONENTS rather than as an angle, so the + // expectation comes from the contract — `ω = 2·vec(dq)/dt` — and not from re-running + // the implementation's own path. `(0, 0.6, 0, 0.8)` is unit by construction. + const s: Real = 0.6; + const c: Real = 0.8; + const target_rot = config.Quatr{ .x = 0, .y = s, .z = 0, .w = c }; + try world.moveKinematic(gpa, platform, Vec3r.zero, target_rot, fixed_dt); + + const w1 = world.bm.angularVelocity(platform).?.toArray(); + const expected_wy = 2 * s / fixed_dt; + try testing.expect(std.math.approxEqAbs(Real, expected_wy, w1[1], 1e-4)); + // THE AXIS, asserted too: a formula that got the magnitude from the wrong components + // would still satisfy a magnitude-only check. + try testing.expectEqual(@as(Real, 0), w1[0]); + try testing.expectEqual(@as(Real, 0), w1[2]); + // And the angular half is genuinely NON-ZERO, which is what a linear-only + // implementation fails: it would report exactly zero here. + try testing.expect(@abs(w1[1]) > 1); + try testing.expectEqual(Vec3r.zero, world.bm.linearVelocity(platform).?); + // The pose was WRITTEN — this entry moves the body, unlike a velocity-only one. + try testing.expect(std.math.approxEqAbs(Real, s, world.bm.rotation(platform).?.y, 1e-6)); + + // THE SHORT-PATH TWIN. `q` and `−q` are the same rotation, so the same move written + // with the negated target must give the SAME angular velocity. Without the sign + // normalisation this reads as a near-full turn the other way — opposite sign and a + // much larger magnitude — so the pair is what makes the flip observable. + var twin = PhysicsWorld.initNoSleep(Vec3r.zero, fixed_dt); + defer twin.deinit(gpa); + const p2 = try addBoxBody(gpa, &twin, .kinematic, false, 1, .{ 0, 0, 0 }); + const negated = config.Quatr{ .x = 0, .y = -s, .z = 0, .w = -c }; + try twin.moveKinematic(gpa, p2, Vec3r.zero, negated, fixed_dt); + const w2 = twin.bm.angularVelocity(p2).?.toArray(); + try testing.expect(std.math.approxEqAbs(Real, w1[1], w2[1], 1e-4)); + + // THE LINEAR HALF, on a translation-only move of the same world. + const before = world.bm.position(platform).?; + try world.moveKinematic(gpa, platform, vr(0.5, 0, 0), target_rot, fixed_dt); + const lin = world.bm.linearVelocity(platform).?.toArray(); + try testing.expect(std.math.approxEqAbs(Real, 0.5 / fixed_dt, lin[0], 1e-3)); + try testing.expectEqual(@as(Real, 0), lin[1]); + try testing.expectEqual(@as(Real, 0), lin[2]); + // The rotation did not change this time, so the angular column falls back to zero — + // the mirror of the rotation-only case above, and what stops a stale `ω` from + // surviving a move that carried no rotation. + const w3 = world.bm.angularVelocity(platform).?.toArray(); + try testing.expect(@abs(w3[1]) < 1e-4); + try testing.expect(std.math.approxEqAbs(Real, 0.5, world.bm.position(platform).?.toArray()[0], 1e-6)); + try testing.expect(before.toArray()[0] == 0); +} + +test "addBody is transactional: no fail index leaves a body, a proxy or a registration" { + // EXHAUSTIVE FAIL-INDEX SWEEP, the shape M1.1.1-HF2 used on the ECS spawn path: a single + // chosen index proves one branch, and the branch that leaks is exactly the one nobody + // chose. Every index up to the successful call's allocation count is driven, and after + // each failure the world must be indistinguishable from one where nothing was attempted. + const gpa = testing.allocator; + + // Baseline: how many allocations does a successful `addBody` take? Counted rather than + // guessed, so the sweep covers the whole call and not a prefix of it. + var counting = std.testing.FailingAllocator.init(gpa, .{ .fail_index = std.math.maxInt(usize) }); + var base = PhysicsWorld.init(vr(0, -9.81, 0), 1.0 / 60.0); + defer base.deinit(counting.allocator()); + const base_shape = try base.store.createShape(counting.allocator(), .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } }); + const before = counting.allocations; + _ = try base.addBody(counting.allocator(), dynamicAt(base_shape, 0, 5, 0)); + const needed = counting.allocations - before; + try testing.expect(needed > 0); // otherwise the sweep below is vacuous + + var idx: usize = 0; + while (idx < needed) : (idx += 1) { + var fa = std.testing.FailingAllocator.init(gpa, .{ .fail_index = std.math.maxInt(usize) }); + var pw = PhysicsWorld.init(vr(0, -9.81, 0), 1.0 / 60.0); + defer pw.deinit(fa.allocator()); + const shape = try pw.store.createShape(fa.allocator(), .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } }); + + // Arm the failure only now, so the shape creation above is not what fails. + fa.fail_index = fa.allocations + idx; + const result = pw.addBody(fa.allocator(), dynamicAt(shape, 0, 5, 0)); + + if (result) |_| { + // This index was not an allocation site of `addBody`; nothing to assert. + continue; + } else |_| { + // THREE observables, because the three fallible steps fail differently: the body + // store, the broadphase, and the registration list. A single one of them would + // pass against two of the three leaks. + try testing.expectEqual(@as(usize, 0), pw.bodies.items.len); + try testing.expectEqual(@as(u32, 0), pw.proxyCountIn(.dynamic)); + try testing.expectEqual(@as(u32, 0), pw.bm.count()); + } + } +} + +test "a failed pose write leaves the store where the broadphase still says it is" { + // The proxy refresh reserves and can fail. Before the fix the pose was already committed + // when it did, so the broadphase described a body that had moved and nothing could + // notice. Asserted on the POSE BITS — the quantity that diverged — and not on the error, + // which was returned before the fix too. + // + // The failure is DRIVEN rather than assumed: `Broadphase.update` only allocates when the + // moved log has to grow, so one move is not enough to reach the error path. The loop + // keeps moving until an allocation is actually needed, and `failed` is what makes this a + // measurement instead of a test that never reached the branch it names. + const gpa = testing.allocator; + var pw = PhysicsWorld.init(vr(0, -9.81, 0), 1.0 / 60.0); + defer pw.deinit(gpa); + const shape = try pw.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } }); + const id = try pw.addBody(gpa, dynamicAt(shape, 0, 5, 0)); + + var fa = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + var failed = false; + var attempt: u32 = 0; + while (attempt < 64 and !failed) : (attempt += 1) { + const before_pos = pw.bm.position(id).?.toArray(); + const before_rot = pw.bm.rotation(id).?; + const target = vr(@as(Real, @floatFromInt(attempt)) * 100 + 100, 5, 0); + if (pw.setBodyTransform(fa.allocator(), id, target, before_rot)) { + continue; // this move needed no allocation + } else |_| { + failed = true; + try testing.expectEqual(before_pos, pw.bm.position(id).?.toArray()); + try testing.expectEqual(before_rot.x, pw.bm.rotation(id).?.x); + try testing.expectEqual(before_rot.w, pw.bm.rotation(id).?.w); + } + } + try testing.expect(failed); +} + +test "a failed moveKinematic leaves a retry able to derive the same velocity" { + // The velocities are derived FROM the current pose, so committing the pose before the + // fallible refresh made a retry compute `target - target` and publish ZERO for a move + // that had happened — the exact lie this entry exists to remove, arriving by the error + // path. What is asserted is the RETRY's answer, because that is where the lie appeared: + // asserting only the restored pose would pass against a fix that restored the pose and + // left the velocities at their derived values. + const gpa = testing.allocator; + var pw = PhysicsWorld.init(vr(0, -9.81, 0), 1.0 / 60.0); + defer pw.deinit(gpa); + const shape = try pw.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } }); + var desc = dynamicAt(shape, 0, 5, 0); + desc.body_type = .kinematic; + desc.mass = 0; + const id = try pw.addBody(gpa, desc); + + const dt: Real = 1.0 / 60.0; + var fa = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + var failed = false; + var attempt: u32 = 0; + while (attempt < 64 and !failed) : (attempt += 1) { + const from = pw.bm.position(id).?.toArray(); + const rot = pw.bm.rotation(id).?; + const target = vr(@as(Real, @floatFromInt(attempt)) * 100 + 100, 5, 0); + if (pw.moveKinematic(fa.allocator(), id, target, rot, dt)) { + continue; + } else |_| { + failed = true; + // The pose is back where it was, so the retry has a real distance to derive from. + try testing.expectEqual(from, pw.bm.position(id).?.toArray()); + try pw.moveKinematic(gpa, id, target, rot, dt); + const v = pw.bm.linearVelocity(id).?.toArray(); + const expected = (target.toArray()[0] - from[0]) / dt; + try testing.expectEqual(expected, v[0]); + try testing.expect(v[0] != 0); + } + } + try testing.expect(failed); +} + +test "W4: destroying a character wakes the sleepers retained in pair with its presence" { + // `removeBody` applies W4 in its FIRST instruction and `destroyCharacter` applied it + // nowhere — one property on one path and not on its twin. The cause does not care which + // entry removes the body: a sleeper retained against the presence loses what it was + // beside, and a sleeper emits nothing in broadphase that could notice. + // + // Same scene as the `moveCharacter` W4 test, and the same in-scene counter-factual: one + // act must wake the near sleeper and NOT the distant one, which is the level the claim is + // made at. A test with only the near body would pass against an implementation that woke + // the entire world. + const gpa = testing.allocator; + var world = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer world.deinit(gpa); + const box = try groundAndRestingBox(gpa, &world); + + const far_shape = try world.store.createShape(gpa, .{ .box = .{ .half_extents = av3(5, 0.5, 5) } }); + var far_ground = api.BodyDescriptor{ + .entity = .{ .index = 10, .generation = 0 }, + .body_type = .static, + .shape = far_shape, + }; + far_ground.position = av3(100, 0, 0); + _ = try world.addBody(gpa, far_ground); + const far_box = try addBoxBody(gpa, &world, .dynamic, false, 11, .{ 100, 1.0, 0 }); + + const hero = try world.createCharacter(gpa, .{ + .entity = .{ .index = 20, .generation = 0 }, + .position = av3(0.85, 0.5, 0), + }); + const presence = (try world.chars.getCharacterInnerBody(hero)).?; + + _ = try stepUntilAsleep(gpa, &world, box, 300); + _ = try stepUntilAsleep(gpa, &world, far_box, 300); + + try testing.expect(retainsPair(&world, presence, box)); + try testing.expect(!retainsPair(&world, presence, far_box)); + try testing.expect(world.bm.isSleeping(box).?); + try testing.expect(world.bm.isSleeping(far_box).?); + + world.destroyCharacter(gpa, hero); + try testing.expect(!world.bm.isSleeping(box).?); + try testing.expect(world.bm.isSleeping(far_box).?); +} + +test "removeBody refuses a character presence, and refuses it before any mutation" { + // THE SEQUENCE, reachable through public entries alone: `getCharacterInnerBody` hands out + // the presence's `BodyId`, `removeBody` used to release its proxy and its registration, and + // `destroyCharacter` then released a proxy the broadphase had already freed — tripping + // `Broadphase.remove`'s assertion. + // + // TWO HALVES, because the name claims both. A proxy count alone measures that nothing was + // RELEASED; it says nothing about the wake, and a scene with no retained sleeper would stay + // green with the filter moved after `wakeRetainedPartners` — exactly the side effect the + // name promises is absent. + const gpa = testing.allocator; + var world = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer world.deinit(gpa); + const box = try groundAndRestingBox(gpa, &world); + + // Beside the box, inside the fat margin and out of contact — the same band the W4 tests + // use, so the pair is retained without a manifold that would wake the box every tick. + const hero = try world.createCharacter(gpa, .{ + .entity = .{ .index = 20, .generation = 0 }, + .position = av3(0.85, 0.5, 0), + }); + const presence = (try world.chars.getCharacterInnerBody(hero)).?; + _ = try stepUntilAsleep(gpa, &world, box, 300); + + // PRECONDITIONS, so neither half can pass vacuously: the pair exists — without it "still + // asleep" is true for free — and the box is asleep. + try testing.expect(retainsPair(&world, presence, box)); + try testing.expect(world.bm.isSleeping(box).?); + const before = world.proxyCountIn(.dynamic); + + world.removeBody(presence); + + // (a) NOTHING WAS RELEASED. Per class and not a total: a refusal that had still freed the + // proxy would satisfy a total against another class's count and fail here. + try testing.expectEqual(before, world.proxyCountIn(.dynamic)); + try testing.expect((try world.chars.getCharacterInnerBody(hero)) != null); + // (b) AND NOTHING WOKE. This is the half a proxy count cannot reach. + try testing.expect(world.bm.isSleeping(box).?); + + // DISCRIMINATION, in the same scene: a legitimate W4 producer on the same presence DOES + // wake it, so (b) is measuring a refused act and not a scene where nothing ever wakes. + _ = try world.moveCharacter(gpa, hero, vr(0, 0, 0.01), fixed_dt); + try testing.expect(!world.bm.isSleeping(box).?); + + // And the real release path still works, which is what makes the refusal a no-op and not a + // leak. + world.destroyCharacter(gpa, hero); + try testing.expectEqual(before - 1, world.proxyCountIn(.dynamic)); +} diff --git a/src/modules/forge/forge_3d/world.zig b/src/modules/forge/forge_3d/world.zig new file mode 100644 index 00000000..820f43ca --- /dev/null +++ b/src/modules/forge/forge_3d/world.zig @@ -0,0 +1,885 @@ +//! `forge_3d/world.zig` — `PhysicsWorld`, the SOLE OWNER of the per-tick cycle +//! (`engine-physics-solver.md` §1.7). +//! +//! Until M1.1.15 the cycle existed only as a set of callable halves plus one +//! composition of them written inside an acceptance suite +//! (`tests/solver_test.zig`), and nine call sites across the module named a +//! `PhysicsWorld` that did not exist. This file is that owner. The composition is +//! MOVED here, not rewritten: the call sequence, the arguments and the arithmetic +//! are the ones the eight committed determinism witnesses were taken over, and the +//! reparenting changes who calls, never what is computed. +//! +//! **The normative cycle, in order.** Step numbers are STABLE ANCHORS — §1.8 and +//! §1.13 refer to them by number — and two of them carry no code: +//! +//! (1) `Broadphase.computePairs` on the current poses (moved-driven deltas) +//! (2) candidate-pair retention: merge the deltas into a PERSISTENT set, and +//! prune only on fat-AABB separation +//! (3) external forces — READ-ONLY, and it owns NO CODE. The force/torque +//! accumulators are constant for the whole of `step()` (nothing writes them +//! in-tick), so they ARE the tick's accelerations and every substep reads +//! them directly. The uniform §2 reset is not here: it runs once at the END +//! of step 6, because clearing an accumulator before anything consumes it +//! delivers `F/m·0` (deviation B1, M1.1.13.1). +//! (4) `cache.beginTick` → `build` (narrowphase `collidePair` per candidate, +//! `prepare` capturing `v_n⁻` PRE-GRAVITY, the local anchors, the softness +//! selection and the warm-start SEEDING, plus the wake fixpoint of §1.8.5) +//! (5) island partition + activation (W2, W3) — never puts anything to sleep +//! (5 bis) the composite pre-solve seam (§1.7.3) — EMPTY in Phase 1, by design: +//! no consumer, no call, no line executed. Its first occupant is the +//! powered ragdoll. It is an anchor, not a step this file runs. +//! (6) the SUBSTEP LOOP and (7) the restitution pass, both inside +//! `rigid.solveTick`: per substep `integrateVelocitiesNoReset(h)` → +//! warm-start APPLICATION → biased solve (normal points only) → +//! `integratePositions(h)` → relax (normals unbiased, then friction); after +//! the loop the uniform accumulator reset, then restitution per island. +//! (8) retired at a FROZEN NUMBER, never reassigned. There is no position pass: +//! position error is corrected by the bias inside step 6, and penetration +//! recovery is PACED by `contact_push_max_speed`. +//! (9) `storeContacts` (harvest) → `cache.endTick` (sort + swap) +//! (10) broadphase proxy updates on the final poses — skips sleeping bodies +//! (10 bis) the sensor pass (§1.13.4) +//! (11) sleep window sweep on the POST-SOLVE state, then the sleep transition: +//! the only point in the cycle where a body falls asleep. +//! +//! **Step 10 bis is UNCONDITIONAL, and that is a change from the harness it comes +//! from.** The harness gated it on a `sensors_on` flag defaulting to `false`, which +//! for a production world would mean sensors silently do not work until someone +//! remembers to switch them on — the silent-limitation class this module refuses +//! everywhere else. A world with no trigger proxy enumerates nothing and the pass +//! costs what enumerating nothing costs; a world with one gets its state whether or +//! not it asked. `sensor.SensorState.update` takes `*const BodyManager`, so the +//! pass cannot alter one bit of body state either way, which is why making it +//! unconditional leaves every committed witness byte-identical. +//! +//! **What this file does NOT own.** The ECS publication lives one tier up +//! (`../sync.zig`) and runs after step 11, so what reaches an entity is the pose +//! the tick resolved. The ECS → solver direction is M1.1.26's, with the Tier 1 +//! service; nothing here reads a component. The +//! resolution is SINGLE-WORKER: per-island parallel solving is M1.1.25, and the +//! scratch buffers below are therefore unique rather than per-island (§1.8.8). + +const std = @import("std"); +const config = @import("config.zig"); +const shape_mod = @import("shape.zig"); +const bm_mod = @import("body_manager.zig"); +const broadphase = @import("pipeline/broadphase.zig"); +const sleep = @import("pipeline/sleep.zig"); +const sensor = @import("pipeline/sensor.zig"); +const rigid = @import("rigid/root.zig"); +const character_mod = @import("character.zig"); +const determinism = @import("determinism.zig"); +const api = @import("weld_forge"); + +const Real = config.Real; +const Vec3r = config.Vec3r; +const ShapeStore = shape_mod.ShapeStore; +const BodyManager = bm_mod.BodyManager; +const BodyId = api.BodyId; +const Bp = broadphase.Broadphase(Real); +const ContactConstraint = rigid.ContactConstraint; +const ContactCache = rigid.ContactCache; +const SolverConfig = rigid.SolverConfig; +const CharacterStore = character_mod.CharacterStore; +const BroadphaseLayer = broadphase.BroadphaseLayer; + +/// One executed stage of the cycle, in the order `step()` runs them. +/// +/// The enum carries exactly the anchors that EXECUTE. Three do not, and their +/// absence here is the contract rather than an omission: step 3 is read-only and +/// owns no code, step 5 bis is the empty composite seam of §1.7.3, and step 8 is +/// retired at a frozen number. Anchors 6 and 7 are one `rigid.solveTick` call — +/// their internal order (substep loop first, restitution after) is pinned by +/// `rigid/solver.zig`'s own suite, not observable from out here. +pub const Step = enum(u8) { + /// (1) `computePairs` on the current poses. + broadphase_pairs, + /// (2) merge the deltas into the persistent candidate set, prune the separated. + pair_retention, + /// (4) `beginTick` → `build` (narrowphase + `prepare` + warm-start seeding). + build_constraints, + /// (5) island partition and activation. + island_partition, + /// (6) + (7) the substep loop, the accumulator reset, and restitution. + solve_tick, + /// (9) harvest into the cache, then `endTick`. + harvest_contacts, + /// (10) broadphase proxy updates on the final poses. + proxy_update, + /// (10 bis) the sensor pass. + sensor_pass, + /// (11) sleep windows, then the per-island transition. + sleep_transition, +}; + +/// The number of anchors that execute — pinned so a stage added or removed has to +/// be a deliberate edit of this file and of the order test together. +pub const executed_step_count: usize = @typeInfo(Step).@"enum".fields.len; + +comptime { + // The two claims the header makes about `Step`, checked rather than asserted in + // prose: the count, and that `solve_tick` sits between the partition and the + // harvest — the one adjacency the retired step 8 could silently reopen. + std.debug.assert(executed_step_count == 9); + std.debug.assert(@intFromEnum(Step.island_partition) + 1 == @intFromEnum(Step.solve_tick)); + std.debug.assert(@intFromEnum(Step.solve_tick) + 1 == @intFromEnum(Step.harvest_contacts)); +} + +/// A recorder for the ORDER in which `step()` entered its stages. +/// +/// It exists because the frozen sequence of §1.7 is a contract, and a test that +/// checks each stage RAN reads a set where the contract is an order. Attached +/// through `PhysicsWorld.trace`, `null` by default: one null check per stage, nine +/// per tick, no float, no allocation on the physics path. +pub const StepTrace = struct { + /// Entered stages, in order. Sized for one tick; `record` saturates rather + /// than wrapping, so an overrun shows up as a short trace and never as a + /// plausible one. + entries: [executed_step_count]Step = undefined, + len: usize = 0, + /// Stages a full buffer refused. Non-zero means the reader is looking at a + /// truncated order and must not read it as the whole one. + dropped: u32 = 0, + + /// Forget the previous tick. + pub fn reset(self: *StepTrace) void { + self.len = 0; + self.dropped = 0; + } + + fn record(self: *StepTrace, step_id: Step) void { + if (self.len == self.entries.len) { + self.dropped += 1; + return; + } + self.entries[self.len] = step_id; + self.len += 1; + } + + /// The order as a slice. + pub fn order(self: *const StepTrace) []const Step { + return self.entries[0..self.len]; + } +}; + +/// What a registered proxy belongs to. +/// +/// **A character presence carries the SAME `entity` as its character** (`character.zig` +/// creates it with `.entity = desc.entity`), so nothing in the body store distinguishes the +/// two: they are one entity with two bodies. Anything walking this list and keying on the +/// entity — the ECS publication seam does exactly that — needs the distinction +/// spelled out here, because it cannot be recovered downstream. +pub const BodyKind = enum { + /// A body created through `addBody`: the ECS entity's own rigid body. + rigid_body, + /// The virtual controller's inner body, created by the character store. It is driven + /// ONLY by `moveCharacter` and its siblings; it is not the entity's rigid body and does + /// not answer for it. + character_presence, +}; + +/// A body registered in this world, with the broadphase proxy that represents it. +pub const BodyProxy = struct { id: BodyId, proxy: Bp.Proxy, kind: BodyKind }; + +/// The physics world: the shape store, the body store, the broadphase, the warm-start +/// cache, the island partition, the per-tick scratches, and `step()`. +pub const PhysicsWorld = struct { + store: ShapeStore = .{}, + bm: BodyManager = .{}, + bp: Bp, + cache: ContactCache = .{}, + cfg: SolverConfig = .{}, + /// Sleep tuning. Enabled by default; convergence measurements switch it off. + sleep_cfg: sleep.SleepConfig = .{}, + gravity: Vec3r, + dt: Real, + bodies: std.ArrayListUnmanaged(BodyProxy) = .empty, + active: std.ArrayListUnmanaged(u64) = .empty, + constraints: std.ArrayListUnmanaged(ContactConstraint) = .empty, + scratch: std.ArrayListUnmanaged(Bp.Pair) = .empty, + /// The island partition of the last tick (step 5). + islands: rigid.IslandManager = .{}, + /// Last tick's solver telemetry (steps 6 and 7) — substeps executed, solve and + /// relax sweeps, and the minimum separation any biased sweep observed, plus the + /// `not_reported` counters §1.8.2 excludes from the telemetry surface. + solver_stats: rigid.SolverStats = .{}, + /// Islands put to sleep at step 11 of the last tick. + slept_last_tick: u32 = 0, + /// The character controllers. The orchestrator holds them because a controller's + /// broadphase PRESENCE has to be inserted, and the store that creates it cannot + /// choose a layer — the `BodyType` → `BroadphaseLayer` derivation lives here. + chars: CharacterStore = .{}, + /// The sensor state, rebuilt in full at STEP 10 BIS of every tick (M1.1.13). + sensors: sensor.SensorState = .{}, + /// Where `step()` records the order it entered its stages, when a caller wants + /// to read that order. `null` on a production world. + trace: ?*StepTrace = null, + + /// A world with the given gravity and fixed timestep. Default `SolverConfig`, + /// sleeping ENABLED. + pub fn init(gravity: Vec3r, dt: Real) PhysicsWorld { + // `ARCH-031` rule 5 — THE physics entry point. Opening a world on a thread + // whose float environment is not the engine's makes every number this world + // produces incomparable with the same world opened elsewhere, so the state is + // checked once, here, where a world begins — and ASSERTED, not installed (the + // reason the two verbs differ is in `determinism.zig`). + determinism.assertFloatEnvironment(); + return .{ .bp = Bp.init(.{}), .gravity = gravity, .dt = dt }; + } + + /// `init` with sleeping switched off — the world every MEASUREMENT of the solver + /// uses: settling, penetration recovery, drift, friction decay, determinism. + /// + /// Normative, not a convenience (§1.8.3). The sleep criterion is a displacement + /// bound over a window, so a body creeping at 5 mm/s moves 2.5 mm per 0.5 s window + /// against a 15 mm bound and falls asleep while still creeping. Ask "does this + /// settle?" with sleeping on and the answer you measure is "it fell asleep", which + /// is not the same question. + pub fn initNoSleep(gravity: Vec3r, dt: Real) PhysicsWorld { + var world = init(gravity, dt); + world.sleep_cfg.allow_sleeping = false; + return world; + } + + /// Release every owned buffer. + pub fn deinit(self: *PhysicsWorld, gpa: std.mem.Allocator) void { + self.chars.deinit(gpa); + self.sensors.deinit(gpa); + self.store.deinit(gpa); + self.bm.deinit(gpa); + self.bp.deinit(gpa); + self.cache.deinit(gpa); + self.bodies.deinit(gpa); + self.active.deinit(gpa); + self.constraints.deinit(gpa); + self.scratch.deinit(gpa); + self.islands.deinit(gpa); + self.* = undefined; + } + + /// Create a body and insert its broadphase proxy on the matching layer. + /// Dispatches on the shape CLASS: an unbounded half-space has no world AABB and + /// goes into the layer's flat list (`engine-physics-shapes.md` §1.11.15); a MESH + /// is a finite surface, so it takes the bounded arm. Exhaustive on the class, no + /// `else`. + pub fn addBody(self: *PhysicsWorld, gpa: std.mem.Allocator, desc: api.BodyDescriptor) !BodyId { + const id = try self.bm.addBody(gpa, &self.store, desc); + // TRANSACTIONAL, in the shape `createCharacter` already had: three fallible steps in + // sequence, so each one undoes what precedes it on the way out. Without these, a + // failing `bp.insert` left a body no proxy represents — invisible to every query and + // to pair generation — and a failing `bodies.append` left a body AND a leaked proxy + // that step 10 would go on updating for a registration that does not exist. + errdefer self.bm.removeBody(id); + + const layer = BodyManager.broadLayerFor(desc.is_trigger, desc.body_type); + const shape = self.store.get(desc.shape).?; + const proxy = switch (shape.class()) { + .convex, .triangle_soup => try self.bp.insert(gpa, layer, self.bm.bodyAabb(&self.store, id).?, id), + .half_space => blk: { + const world = shape_mod.halfSpace(shape).transformed( + self.bm.rotation(id).?, + self.bm.position(id).?, + ); + break :blk try self.bp.insertUnbounded(gpa, layer, .{ + .normal = world.normal, + .distance = world.distance, + }, id); + }, + }; + errdefer self.bp.remove(proxy); + + try self.bodies.append(gpa, .{ .id = id, .proxy = proxy, .kind = .rigid_body }); + return id; + } + + /// Remove a body, applying wake cause W4 (§1.8.5) first: every sleeper retained + /// in a candidate pair with it is woken, because removing it changes what + /// supports them and a sleeper emits nothing in broadphase that could notice. + pub fn removeBody(self: *PhysicsWorld, id: BodyId) void { + // A CHARACTER PRESENCE IS NOT REMOVABLE HERE, and the sequence is reachable without + // doing anything illegal: `getCharacterInnerBody` is a public entry, and its `BodyId` + // handed to this one released the proxy, the registration and the body — after which + // `destroyCharacter` released a proxy the broadphase had already freed and tripped + // `Broadphase.remove`'s assertion. A presence's lifetime runs through + // `destroyCharacter` and through nothing else. + // + // A NO-OP rather than an error: the frozen signature returns `void`, and the repository + // already answers an unhandleable handle this way (`setPresenceProxy`). The filter sits + // BEFORE every mutation, `wakeRetainedPartners` included — a refused call that had + // still woken the scene would be a side effect with no operation behind it. + for (self.bodies.items) |entry| { + if (entry.id == id and entry.kind == .character_presence) return; + } + + self.wakeRetainedPartners(id); + for (self.bodies.items, 0..) |entry, i| { + if (entry.id != id) continue; + self.bp.remove(entry.proxy); + _ = self.bodies.orderedRemove(i); // ordered: the sweep order stays stable + break; + } + self.bm.removeBody(id); + } + + /// Create a character controller AND insert its broadphase presence. + /// + /// **This is the half the store cannot do.** `CharacterStore.createCharacter` builds + /// the presence — a `.kinematic` body carrying the controller's own capsule + /// (`engine-physics-queries.md` §1.12.2) — but a proxy needs a LAYER, and the layer + /// comes from the `BodyType` → `BroadphaseLayer` derivation this file owns. Without + /// the insertion the presence exists in the body store and is invisible to every + /// query, which C1.8 refuses twice over: the player's attack goes through + /// raycast/overlap, and the follow camera's anti-wall sweep starts from the player. + /// + /// The layer is derived from the presence's OWN stored flags rather than from a + /// literal, so a presence follows the same fixed priority as any other body — + /// `is_trigger` first, then body type (`engine-physics-solver.md` §1.13.3). A + /// hard-coded `.dynamic` would agree with the rule today and stop agreeing the day + /// the rule moves. + /// + /// TRANSACTIONAL: if the insertion fails, the character is destroyed rather than left + /// half-built with a presence no tree holds. + pub fn createCharacter( + self: *PhysicsWorld, + gpa: std.mem.Allocator, + desc: api.CharacterDescriptor, + ) !api.CharacterId { + const id = try self.chars.createCharacter(gpa, &self.store, &self.bm, desc); + errdefer self.chars.destroyCharacter(gpa, &self.bp, &self.store, &self.bm, id); + + const presence = (try self.chars.getCharacterInnerBody(id)) orelse return id; + const layer = BodyManager.broadLayerFor( + self.bm.isTrigger(presence).?, + self.bm.bodyType(presence).?, + ); + const proxy = try self.bp.insert(gpa, layer, self.bm.bodyAabb(&self.store, presence).?, presence); + self.chars.setPresenceProxy(id, proxy); + + // AND REGISTERED IN THIS WORLD'S OWN BODY LIST, which is a SECOND fact and not a + // restatement of the insertion — the defect found by the W4 test at gate C lived + // exactly in the gap between the two. `pairStillOverlaps` resolves a retained + // pair's endpoints through `proxyOf`, which searches this list; an unregistered + // presence resolves to `null`, so step 2 pruned EVERY pair involving it on EVERY + // tick, and the retained set — which IS the wake graph (§1.8.7) — could never + // hold a character. W4 was structurally unable to fire for the one producer the + // engine has. The proxy handle survives a `resizeCharacter`, which updates it in + // place, so this registration stays valid for the character's whole life. + try self.bodies.append(gpa, .{ .id = presence, .proxy = proxy, .kind = .character_presence }); + return id; + } + + /// Destroy a character, releasing its presence body and the proxy inserted above. + /// The store owns that release — it holds the proxy handle — so this is a delegation + /// and not a second removal path. + pub fn destroyCharacter(self: *PhysicsWorld, gpa: std.mem.Allocator, id: api.CharacterId) void { + // Deregister BEFORE delegating: the store removes the proxy and the presence + // body, so an entry left here would hand step 10 a proxy the broadphase has + // freed. Ordered removal, for the same reason `removeBody` uses one — the sweep + // order of this list stays stable. + if (self.chars.getCharacterInnerBody(id) catch null) |presence| { + // W4 FIRST, which is what `removeBody` does in its own first instruction and + // what this path did not. The cause does not care which entry removes the body: + // a sleeper retained in a pair with the presence loses what it was resting + // against, and a sleeper emits nothing in broadphase that could notice. + self.wakeRetainedPartners(presence); + for (self.bodies.items, 0..) |entry, i| { + if (entry.id != presence) continue; + _ = self.bodies.orderedRemove(i); + break; + } + } + self.chars.destroyCharacter(gpa, &self.bp, &self.store, &self.bm, id); + } + + /// Move a character, then apply W4 for it. + /// + /// **W3 is structurally blind to a controller, and W4 is the only cause that covers + /// it** (`engine-physics-solver.md` §1.8.5). A presence is a kinematic body moved by + /// POSE WRITE, so its velocity columns stay exactly zero while it crosses the scene + /// and W3's true-zero test never sees it move. A character walking into a sleeping + /// stack would sink into it with no diagnostic. The controller is the engine's first + /// real producer of W4, and this is where that producer lives. + pub fn moveCharacter( + self: *PhysicsWorld, + gpa: std.mem.Allocator, + id: api.CharacterId, + displacement: Vec3r, + dt: Real, + ) !character_mod.MoveResult { + const result = try self.chars.moveCharacter(gpa, &self.bp, &self.bm, &self.store, id, displacement, dt); + self.wakePresencePartners(id); + return result; + } + + /// Teleport a character, then apply W4 for it — same cause, same reason as + /// `moveCharacter`: the presence's pose changed and its velocity columns did not. + pub fn setCharacterPosition( + self: *PhysicsWorld, + gpa: std.mem.Allocator, + id: api.CharacterId, + position: Vec3r, + ) !void { + try self.chars.setCharacterPosition(gpa, &self.bp, &self.bm, &self.store, id, position); + self.wakePresencePartners(id); + } + + /// Resize a character, and apply W4 only on SUCCESS. + /// + /// `false` means the target volume was occupied and NOTHING moved — a legitimate + /// gameplay answer and not an error (`engine-physics-queries.md` §1.12.7). Waking on + /// a refused resize would wake for a mutation that did not happen, which is the + /// always-wake rule wearing the shape of a correct one. + pub fn resizeCharacter( + self: *PhysicsWorld, + gpa: std.mem.Allocator, + id: api.CharacterId, + radius: f32, + height: f32, + ) !bool { + const ok = try self.chars.resizeCharacter(gpa, &self.bp, &self.bm, &self.store, id, radius, height); + if (ok) self.wakePresencePartners(id); + return ok; + } + + /// W4 for a character: wake the sleepers retained in a pair with its PRESENCE. A + /// character without a presence has no pair to be retained in, so there is nothing + /// to wake and the absence is the correct answer rather than a skipped case. + fn wakePresencePartners(self: *PhysicsWorld, id: api.CharacterId) void { + const presence = (self.chars.getCharacterInnerBody(id) catch return) orelse return; + self.wakeRetainedPartners(presence); + } + + /// How many proxies the broadphase holds in `layer` — tree leaves plus the layer's + /// live unbounded slots. + /// + /// PER CLASS, and never a total, because that is the only form that discriminates: a + /// body inserted into the wrong class satisfies a total and fails a per-class count. + pub fn proxyCountIn(self: *const PhysicsWorld, layer: BroadphaseLayer) u32 { + var counter: struct { + n: u32 = 0, + pub fn add(c: *@This(), user_data: u32) void { + _ = user_data; + c.n += 1; + } + } = .{}; + self.bp.forEachInLayer(layer, &counter); + return counter.n; + } + + // --- wake composition (§1.8.4, §1.8.5) ------------------------------------ + // + // The store keeps the two INTENTIONS apart and this tier composes them. A + // solver-internal write — what the substep loop and the restitution pass do every + // tick to every body in contact — must NOT rearm a sleep window, or nothing in + // contact would ever sleep; an EXTERNAL mutation must wake and rearm. So + // `setLinearVelocity`, `setAngularVelocity`, `setPosition` and `setRotation` are + // non-activating in the store BY CONTRACT, and the entries below are the ones that + // add the wake. `addForce`, `addTorque` and `addImpulse` are external by + // construction — the solver has zero call sites on them — and activate in the store + // itself, so this tier delegates them unchanged rather than waking twice. + + /// Wake every sleeper RETAINED IN A PAIR with `id` — wake cause W4 (§1.8.5). + /// + /// The retained candidate set IS the wake graph (§1.8.7): a sleeper emits nothing in + /// broadphase, so nothing else could notice that what supported it has moved or gone. + /// One helper and not a copy per producer, because the five producers of W4 — + /// body removal, static/kinematic teleportation, and the controller's three — differ + /// in what they do to their own body and not at all in what they owe their partners. + fn wakeRetainedPartners(self: *PhysicsWorld, id: BodyId) void { + for (self.active.items) |key| { + const a: BodyId = @intCast(key >> 32); + const b: BodyId = @intCast(key & 0xFFFF_FFFF); + if (a != id and b != id) continue; + self.bm.wakeBody(if (a == id) b else a); + } + } + + /// Refresh `id`'s broadphase proxy from its current pose, so a query issued between + /// two ticks finds the body where it now is and not where step 10 last left it. An + /// UNBOUNDED proxy has no box to refresh. + fn refreshProxy(self: *PhysicsWorld, gpa: std.mem.Allocator, id: BodyId) !void { + const proxy = self.proxyOf(id) orelse return; + if (proxy.kind == .unbounded) return; + if (self.bm.bodyAabb(&self.store, id)) |aabb| try self.bp.update(gpa, proxy, aabb); + } + + /// TELEPORT `id` to a pose. Writes the pose and derives NO velocity — the split + /// against `moveKinematic` is contractual and not an oversight + /// (`engine-physics-queries.md` §1.12.5): a platform moved by this entry keeps + /// velocity columns at zero, which is exactly why a character standing on one needs + /// the other entry to report a truthful `ground_velocity`. + /// + /// Composes the wake: the body itself, because a teleport is an external mutation + /// (§1.8.4), and W4 on its retained partners, because a body that moves changes what + /// supports the sleepers around it and they cannot see it happen. No-op on a stale + /// handle. + /// + /// **W4 IS APPLIED TO A DYNAMIC BODY TOO, and §1.8.5 now says so.** The reasoning that + /// carried the decision, kept because it is what the amended text rests on: a sleeper + /// emits nothing in broadphase that could notice a change, and that holds identically for + /// a teleported DYNAMIC body — its own island wakes through W1 then W2, but the sleepers + /// of ANOTHER island retained in a pair with it wake through nothing at all. An earlier + /// wording named only removal and static/kinematic teleportation, which would have + /// manufactured a silent false negative on a legal configuration — the thing §1.13.6 + /// refuses in as many words. + /// + /// **TRANSACTIONAL ON THE POSE.** The proxy refresh reserves and can fail. Before this + /// was written the pose had already been committed when it did, leaving the broadphase + /// describing a body that had moved — and a caller who retried would be retrying against + /// a store that already held the target. The pose is restored on failure, so the + /// broadphase and the store agree again and a retry is a retry. The WAKES are not rolled + /// back and that is a decision, not an omission: a spurious wake costs simulation time + /// and never an answer, while a stale proxy is a wrong answer. + pub fn setBodyTransform( + self: *PhysicsWorld, + gpa: std.mem.Allocator, + id: BodyId, + position: Vec3r, + rotation: config.Quatr, + ) !void { + const prev_position = self.bm.position(id) orelse return; // stale handle + const prev_rotation = self.bm.rotation(id).?; + errdefer { + self.bm.setPosition(id, prev_position); + self.bm.setRotation(id, prev_rotation); + } + + self.wakeRetainedPartners(id); + self.bm.wakeBody(id); + self.bm.setPosition(id, position); + self.bm.setRotation(id, rotation); + try self.refreshProxy(gpa, id); + } + + /// Move a KINEMATIC body to a target pose over `dt`, deriving both velocities from + /// the move — the entry `setBodyTransform` is deliberately not. + /// + /// **It writes the pose AND publishes the velocities**, and that pairing is the whole + /// point (`engine-physics-queries.md` §1.12.5). The problem the two entries exist to + /// separate is a platform the gameplay drives tick after tick: moved by + /// `setBodyTransform` its velocity columns stay at zero and `ground_velocity` reports + /// 0 for something visibly in motion; moved by this entry the columns carry the motion + /// that actually happened. An entry that published a velocity WITHOUT moving the body + /// would only mirror the same lie the other way round, and one that moved the body + /// without publishing would be `setBodyTransform` under a second name — either way the + /// caller would have to compose the two, which is a missing operation and not a + /// documented recipe. + /// + /// **Kinematic bodies are never integrated, and that is load-bearing rather than a + /// gap.** `integratePositions` skips every non-dynamic body, so a presence crosses the + /// scene with velocity columns at exactly zero and W3's true-zero test never sees it — + /// which is precisely why W4 exists at all (`engine-physics-solver.md` §1.8.5). Making + /// the integrator advance kinematics would give a presence a non-zero velocity, W3 + /// would start firing, and the reasoning that justifies W4 would collapse. So the pose + /// is written here, not integrated from `ω` later. + /// + /// **The angular derivation carries no trigonometry, and it needs none.** With no + /// integrator consuming `ω`, its single consumer is `ground_velocity = v + ω × r`, so + /// there is nothing an exact axis-angle extraction would be exact AGAINST — while + /// `acos` is an external transcendental `ARCH-031` rule 4 forbids on a compared path. + /// `ω = 2 · vec(q_target · conj(q_current)) / dt`, with the sign normalised so the + /// SHORT path is taken: `q` and `−q` are the same rotation, and without the flip a + /// small turn expressed by the negated quaternion would read as a near-full turn the + /// other way. + /// + /// Composes the wake like any external pose write: the body, and W4 on its retained + /// partners. No-op on a stale handle. + /// + /// **TRANSACTIONAL ON POSE AND VELOCITIES, and the second half is what makes a retry + /// honest.** Both are derived FROM the current pose, so committing them before the + /// fallible proxy refresh left a failed call with the target already stored — and a + /// retry then computed `target − current` over a difference of zero, publishing a null + /// velocity for a move that had happened. That is exactly the lie this entry exists to + /// remove, arriving through the error path. Restoring pose and velocities makes the + /// retry recompute the same non-zero derivation. + pub fn moveKinematic( + self: *PhysicsWorld, + gpa: std.mem.Allocator, + id: BodyId, + target_position: Vec3r, + target_rotation: config.Quatr, + dt: Real, + ) !void { + std.debug.assert(std.math.isFinite(dt) and dt > 0); + const current_position = self.bm.position(id) orelse return; // stale handle + const current_rotation = self.bm.rotation(id).?; + + const inv_dt = 1.0 / dt; + const linear = target_position.sub(current_position).scale(inv_dt); + + var dq = target_rotation.mul(current_rotation.conjugate()); + if (dq.w < 0) dq = dq.scale(-1); // short path: q and −q are one rotation + const angular = Vec3r.fromArray(.{ dq.x, dq.y, dq.z }).scale(2 * inv_dt); + + const prev_linear = self.bm.linearVelocity(id).?; + const prev_angular = self.bm.angularVelocity(id).?; + errdefer { + self.bm.setPosition(id, current_position); + self.bm.setRotation(id, current_rotation); + self.bm.setLinearVelocity(id, prev_linear); + self.bm.setAngularVelocity(id, prev_angular); + } + + self.wakeRetainedPartners(id); + self.bm.wakeBody(id); + self.bm.setLinearVelocity(id, linear); + self.bm.setAngularVelocity(id, angular); + self.bm.setPosition(id, target_position); + self.bm.setRotation(id, target_rotation); + try self.refreshProxy(gpa, id); + } + + /// Set the linear velocity from gameplay: wake, then write. The store's setter is + /// non-activating because the solver drives it every substep; this entry is the one + /// an external caller reaches, and it is where the wake belongs. + pub fn setLinearVelocity(self: *PhysicsWorld, id: BodyId, velocity: Vec3r) void { + self.bm.wakeBody(id); + self.bm.setLinearVelocity(id, velocity); + } + + /// Set the angular velocity from gameplay — same composition, same reason. + pub fn setAngularVelocity(self: *PhysicsWorld, id: BodyId, velocity: Vec3r) void { + self.bm.wakeBody(id); + self.bm.setAngularVelocity(id, velocity); + } + + /// Accumulate a world-space force. ALREADY activating in the store, which is where + /// it belongs: a force is external by construction and the solver has no call site + /// on it. Delegated rather than re-woken here, so the wake happens once. + pub fn addForce(self: *PhysicsWorld, id: BodyId, force: Vec3r) void { + self.bm.addForce(id, force); + } + + /// Accumulate a world-space torque — same reasoning as `addForce`. + pub fn addTorque(self: *PhysicsWorld, id: BodyId, torque: Vec3r) void { + self.bm.addTorque(id, torque); + } + + /// Apply a world-space impulse — same reasoning as `addForce`. + pub fn addImpulse(self: *PhysicsWorld, id: BodyId, impulse: Vec3r) void { + self.bm.addImpulse(id, impulse); + } + + /// The proxy of `id`, or `null` once the body has been removed. + pub fn proxyOf(self: *const PhysicsWorld, id: BodyId) ?Bp.Proxy { + for (self.bodies.items) |b| { + if (b.id == id) return b.proxy; + } + return null; + } + + /// Whether a retained pair still satisfies §1.7 step 2 — "removal on FAT-AABB + /// separation only". + /// + /// Three cases, exhaustive on what a proxy can be, and the middle one is why + /// this is not a two-box test. A half-space has no box at all + /// (`engine-physics-shapes.md` §1.11.15), so a pair with one on either side is + /// tested by the SAME exact predicate the traversal uses, + /// `Aabb.overlapsHalfSpace` from `foundation/math` — never a second copy of that + /// formula. Two half-spaces both force static bodies and can never separate, so + /// such a pair is retained unconditionally. + /// + /// The FAT boxes are the ones compared, deliberately. Comparing the tight boxes + /// would purge on a transient sub-margin separation and lose the contact until + /// the body sank back past the margin — the defect `test "small hop within the + /// fat margin keeps the contact pair alive"` was written for at M1.1.6. The + /// margin exists precisely so that this test has hysteresis. + fn pairStillOverlaps(self: *const PhysicsWorld, a: BodyId, b: BodyId) bool { + // A removed body's pair serves nothing: W4 has already woken whoever was + // retained with it, at `removeBody`, and there is no proxy left to test. + const pa = self.proxyOf(a) orelse return false; + const pb = self.proxyOf(b) orelse return false; + + const box_a = self.bp.proxyAabb(pa); + const box_b = self.bp.proxyAabb(pb); + if (box_a) |ba| { + if (box_b) |bb| return ba.overlaps(bb); + const hs = self.bp.unboundedShape(pb) orelse return false; + return ba.overlapsHalfSpace(hs.normal, hs.distance); + } + if (box_b) |bb| { + const hs = self.bp.unboundedShape(pa) orelse return false; + return bb.overlapsHalfSpace(hs.normal, hs.distance); + } + return true; // two half-spaces: both static, no separation is possible + } + + /// Record entry into a stage. Called as the FIRST statement of each stage + /// method below and nowhere else, which is what binds the record to the work: + /// a stage cannot be moved in `step()` without its record moving with it. A + /// recorder wired at the call site instead would be blind to exactly the + /// mutation it exists to catch — the work reordered while the records stay put. + /// + /// **The bound is structural, and it is MEASURED — with the scope the measurement + /// actually supports.** Swapping the BODIES of two stage methods while leaving each + /// `enter()` in place is invisible to every test ONLY where inverting those two + /// stages is physically harmless. Measured both ways: on `(proxy_update, + /// sensor_pass)`, whose inversion cannot alter body state, the whole forge suite + /// stays green; on `(build_constraints, island_partition)`, whose inversion + /// partitions last tick's constraints, the same mutation gives `3 failed, 35 + /// crashed` — the same yield as swapping the CALLS, minus the order test. So the + /// physical guards cover every consequential pair on their own, and this convention + /// is what covers the harmless ones. The note lives here and not only in + /// `tests/world_test.zig` because this is the file where it can be broken. + fn enter(self: *PhysicsWorld, step_id: Step) void { + if (self.trace) |t| t.record(step_id); + } + + /// (1) Broadphase candidate deltas on the current poses — moved-driven, with + /// fat-AABB hysteresis: a pair is emitted only when a proxy leaves its fat box. + fn stepBroadphasePairs(self: *PhysicsWorld, gpa: std.mem.Allocator) !void { + self.enter(.broadphase_pairs); + try self.bp.computePairs(gpa, &self.scratch); + } + + /// (2) Merge the deltas into the PERSISTENT candidate set, then prune it on the + /// one condition §1.7 step 2 allows: the two FAT AABBs have separated. + /// + /// The set's retention is a CORRECTNESS condition of sleep (§1.8.7), not merely + /// warm-start persistence — a sleeper emits nothing in broadphase, so these + /// retained pairs ARE the wake graph. The pruning is equally load-bearing in the + /// other direction: a set that can only grow makes a determinism trace over it + /// pass by ACCUMULATION and prove nothing. + fn stepPairRetention(self: *PhysicsWorld, gpa: std.mem.Allocator) !void { + self.enter(.pair_retention); + for (self.scratch.items) |p| try self.active.append(gpa, (@as(u64, p.a) << 32) | p.b); + sortDedup(&self.active); + var w: usize = 0; + for (self.active.items) |key| { + const a: BodyId = @intCast(key >> 32); + const b: BodyId = @intCast(key & 0xFFFF_FFFF); + if (!self.pairStillOverlaps(a, b)) continue; + self.active.items[w] = key; + w += 1; + } + self.active.shrinkRetainingCapacity(w); + } + + /// (4) Build the constraint array: narrowphase per candidate, then `prepare` — + /// which captures `v_n⁻` PRE-GRAVITY (the velocity integration lives in the + /// substep loop), selects the softness, SEEDS the warm start from the cache, and + /// runs the wake fixpoint of §1.8.5. + fn stepBuildConstraints(self: *PhysicsWorld, gpa: std.mem.Allocator) !void { + self.enter(.build_constraints); + self.cache.beginTick(); + try rigid.build( + gpa, + &self.constraints, + &self.bm, + &self.store, + self.active.items, + rigid.prepareContext(self.cfg, self.dt, &self.cache), + ); + } + + /// (5) Partition into islands and arbitrate activation (W2, W3). Reorders the + /// constraint array into one contiguous range per island. WAKES ONLY — nothing + /// falls asleep here. + fn stepIslandPartition(self: *PhysicsWorld, gpa: std.mem.Allocator) !void { + self.enter(.island_partition); + try self.islands.partition(gpa, &self.bm, self.constraints.items); + } + + /// (6) The substep loop and (7) the restitution pass. Islands advance in + /// LOCKSTEP inside: every stage sweeps all intervals before the next begins. + fn stepSolveTick(self: *PhysicsWorld) void { + self.enter(.solve_tick); + self.solver_stats = rigid.solveTick( + &self.bm, + self.constraints.items, + self.islands.islandsSlice(), + self.cfg, + self.dt, + self.gravity, + ); + } + + /// (9) Harvest the solved impulses into the cache, then finalize it (sort + swap, + /// which is also what evicts whatever this tick did not rewrite). + fn stepHarvestContacts(self: *PhysicsWorld, gpa: std.mem.Allocator) !void { + self.enter(.harvest_contacts); + try rigid.storeContacts(gpa, &self.cache, self.constraints.items); + self.cache.endTick(); + } + + /// (10) Broadphase proxy updates on the final poses — skipping sleepers, whose + /// AABB is unchanged by construction. + fn stepProxyUpdate(self: *PhysicsWorld, gpa: std.mem.Allocator) !void { + self.enter(.proxy_update); + for (self.bodies.items) |b| { + const sleeping = self.bm.isSleeping(b.id) orelse continue; // stale handle + if (sleeping) continue; // a sleeper's AABB is unchanged by construction + // An UNBOUNDED proxy has no box to update and cannot move: a half-space + // forces a STATIC body, so its pairs are established once at insertion + // and then carried by the retention rule of step 2 (§1.11.15). + if (b.proxy.kind == .unbounded) continue; + if (self.bm.bodyAabb(&self.store, b.id)) |aabb| try self.bp.update(gpa, b.proxy, aabb); + } + } + + /// (10 bis) The sensor pass (§1.13.4). + /// + /// Placement rests on two claims of UNEQUAL rank: BEFORE step 11 is MEASURED + /// (falling asleep inside a trigger never produces an exit, and a sleep filter on + /// the traversal makes that test fail); AFTER step 10 is a DESIGN REASONING — the + /// poses there are the ones the tick publishes, so an `enter` cannot announce a + /// crossing the solver then undoes. Unconditional: see the file header. + fn stepSensorPass(self: *PhysicsWorld, gpa: std.mem.Allocator) !void { + self.enter(.sensor_pass); + try self.sensors.update(gpa, &self.bp, &self.bm, &self.store); + } + + /// (11) Advance the sleep windows on the POST-SOLVE state, then put to sleep every + /// island all of whose members are eligible. The ONLY point in the cycle where a + /// body falls asleep, and the only one where velocities are zeroed exactly. + fn stepSleepTransition(self: *PhysicsWorld) void { + self.enter(.sleep_transition); + sleep.updateWindows(&self.bm, self.dt, self.sleep_cfg); + self.slept_last_tick = self.islands.sleepEligibleIslands(&self.bm, self.sleep_cfg); + } + + /// Advance one fixed tick through the normative cycle (file header). + /// + /// The body IS the cycle: nine calls in the frozen order, and the two anchors + /// that carry no code appear as comments where they would run. Step 3 is + /// read-only — the force accumulators are constant for the whole tick and every + /// substep reads them directly — and step 5 bis is the empty composite seam of + /// §1.7.3, which costs nothing precisely because it has no call. + pub fn step(self: *PhysicsWorld, gpa: std.mem.Allocator) !void { + try self.stepBroadphasePairs(gpa); // (1) + try self.stepPairRetention(gpa); // (2) + // (3) external forces — read-only, no code + try self.stepBuildConstraints(gpa); // (4) + try self.stepIslandPartition(gpa); // (5) + // (5 bis) composite pre-solve seam — empty + self.stepSolveTick(); // (6) + (7) + // (8) retired at a frozen number + try self.stepHarvestContacts(gpa); // (9) + try self.stepProxyUpdate(gpa); // (10) + try self.stepSensorPass(gpa); // (10 bis) + self.stepSleepTransition(); // (11) + } + + /// Deepest penetration across the manifolds this world currently holds. + pub fn deepestPenetration(self: *const PhysicsWorld) Real { + var deepest: Real = 0; + for (self.constraints.items) |c| { + for (0..c.count) |i| deepest = @max(deepest, c.points[i].penetration); + } + return deepest; + } +}; + +fn sortDedup(list: *std.ArrayListUnmanaged(u64)) void { + std.mem.sort(u64, list.items, {}, std.sort.asc(u64)); + if (list.items.len == 0) return; + var w: usize = 1; + var i: usize = 1; + while (i < list.items.len) : (i += 1) { + if (list.items[i] != list.items[w - 1]) { + list.items[w] = list.items[i]; + w += 1; + } + } + list.shrinkRetainingCapacity(w); +} diff --git a/src/modules/forge/sync.zig b/src/modules/forge/sync.zig new file mode 100644 index 00000000..c15832d3 --- /dev/null +++ b/src/modules/forge/sync.zig @@ -0,0 +1,559 @@ +//! `forge/sync.zig` — the solver → ECS publication. +//! +//! `PhysicsWorld` owns the tick and knows nothing of the ECS; this file is the seam between +//! them. **It publishes AFTER step 11**, so what reaches an entity is the pose the tick +//! resolved and never an intermediate one. +//! +//! **THE INWARD DIRECTION IS NOT HERE, and its absence is a decision.** ECS → solver — the +//! `Transform` and `Velocity` a rule writes reaching the solver — belongs to **M1.1.26**, with +//! the Tier 1 Etch service. The reason is a property of the ECS: the tick says WHEN a write +//! happened and never WHO produced it, and no comparison of change stamps can manufacture +//! that. A solver-side provenance does not close it either, since `moveKinematic` moves a +//! kinematic body whose pose this file deliberately does not publish — gameplay being its +//! authority — so the ECS copy would stay stale and the entity would render at the old pose. +//! Authority by `BodyType` cannot tell a kinematic driven by its component from one driven by +//! the API, and both are legal. The service is the first place that sees both paths. +//! +//! **What is published, per `BodyType`.** +//! +//! - `dynamic` — the SOLVER is the authority over the pose, so the pose goes out. A gameplay +//! write to `Transform` on a dynamic body is overwritten, and that is the contract rather +//! than a bug: the legitimate ways to move one are `setBodyTransform`, a force or an +//! impulse. +//! - `kinematic` — GAMEPLAY owns the pose, so the pose is NOT written back; the velocity is, +//! because `moveKinematic` derives it and a character standing on the platform reads it. +//! - `static` — nothing is published. A static body that moves is a teleportation through +//! the interface, which wakes by W4. +//! +//! **A value is written only when it CHANGED.** `World.getMut` marks `changed_tick` +//! UNCONDITIONALLY and the `Changed` filter is built on that mark, so republishing a +//! bit-identical value would make every awake body report a change every tick: a rule gated on +//! `changed Velocity` would run for nothing, and `Velocity` being +//! `@replicated(strategy: .rollback)`, the false delta leaves on the wire. The `Sleeping` +//! marker covers the sleepers; the awake-and-immobile are the dominant case in an arena scene +//! and the marker says nothing about them. The guard asserts the SIGNAL — `changed_tick` +//! against the world's current tick — and never the value, because the value is already +//! correct under the defect; it is the signal that lies. +//! +//! **THE ORDER OF THE `Sleeping` MARKER AGAINST PUBLICATION, and why it is this way.** An +//! island falls asleep at step 11, AFTER steps 6 and 7 wrote its final pose, and this pass +//! runs after step 11 and skips marked bodies. Mark first and that final pose is NEVER +//! published: the entity keeps the pose of tick N−1 and holds it until the body wakes, so the +//! object rests at a slightly wrong place forever and jumps when woken. A test that only +//! checks "a sleeper's pose is bit-frozen" PASSES on that defect — the pose is frozen, on the +//! wrong value — which is why the order is written here and guarded by an assertion on the +//! VALUE. So: the marker is REMOVED before publication and ADDED after it. Both transition +//! ticks publish, and every tick in between is skipped. +//! +//! **A CHARACTER PRESENCE IS NEVER A PUBLISHER**, because it shares its character's ECS +//! entity: `character.zig` creates it with `.entity = desc.entity`, so one entity owns two +//! bodies and nothing in the body store separates them. Walked as an ordinary body it would +//! publish its own velocity — exactly zero forever, a presence being kinematic and moved by +//! pose write — over the entity's own, every tick. It is driven by `moveCharacter` and its +//! siblings and by nothing else. The distinction is carried on the registration record +//! (`world.BodyKind`) rather than inferred here, because it cannot be recovered from the +//! entity or from the body type. +//! +//! **ONE ELECTED PUBLISHER PER ENTITY.** +//! `engine-physics-solver.md` §1.13.1 states that a MIXED BODY DOES NOT EXIST: an entity that +//! must both collide and detect carries TWO bodies, and §1.13.8 rule 2 restates it when it +//! drops the reflexive pair, "without which an entity carrying a trigger body and a solid +//! body would detect itself". Two bodies on one entity is the normal shape here, and the +//! descriptor admits every combination — two solids and two triggers included — so the seam +//! must answer WHICH ONE speaks for the entity, for every combination and not just the +//! documented one. +//! +//! `electPublishers` answers it with one criterion at two levels, and everything else in this +//! file's outward direction defers to it. An earlier version excluded triggers outright and +//! was WRONG TWICE OVER. +//! +//! First, on the corpus. §1.13.7 denies a trigger a manifold, a constraint and an impulse, and +//! the amended text distinguishes TWO kinds of island entry: a CONSTRAINT island, which a +//! trigger cannot enter by construction since no pair reaches it, and an INTEGRATION +//! SINGLETON, which it enters explicitly — the singleton comes from the enumeration of +//! dynamic bodies and not from any pair, and without one a dynamic trigger could never sleep +//! and would be integrated forever. So a `.dynamic` trigger falls under gravity and its pose +//! IS a fact the solver resolved. An earlier version of this comment read the exclusion as +//! total and was corrected with the corpus. +//! +//! Second, on the arbitration itself: excluding triggers arbitrates only +//! solid-against-trigger, leaving two triggers or two solids in silent last-write-wins. +//! **Do not reintroduce a blanket skip here**: it is what this election replaced, and both +//! reasons above are why. +//! +//! **What the tag buys, stated at the size the measurement supports.** `Sleeping` is what +//! lets a gameplay query step over a whole archetype of resting bodies +//! (`engine-physics-solver.md` §1.8.6) — that is the archetype-level skip +//! `engine-physics-forge.md` §1.4 credits to native ECS integration. This file itself +//! walks the SOLVER's body list, which is a SoA indexed by `BodyId` and has no archetypes; +//! the skip it delivers is to the queries downstream, not to its own loop. + +const std = @import("std"); +const core = @import("weld_core"); +const api = @import("weld_forge"); +const forge_3d = @import("forge_3d"); + +const World = core.ecs.World; +const EntityId = core.ecs.EntityId; +const Transform = core.ecs.components.Transform; +const Velocity = api.Velocity; +const Sleeping = api.Sleeping; +const PhysicsWorld = forge_3d.PhysicsWorld; +const CommandBuffer = core.ecs.CommandBuffer; +const Vec3r = forge_3d.Vec3r; + +/// THE precision crossing — see `forge/api/precision.zig`. This file narrows solver values to +/// the world scalar on every tick, so it is the seam most able to grow a second conversion; it +/// spells none of its own, and `no_precision_crossing` is what enforces that. +const cross = forge_3d.cross; +const WorldReal = api.precision.WorldReal; +const WorldVec3 = api.precision.WorldVec3; +const WorldQuat = api.precision.WorldQuat; + +/// The solver pose of `body`, in the ECS `Transform`'s own layout, or null on a stale +/// handle. One conversion site, so the two representations cannot drift apart in two +/// places. +fn solverPose(pw: *const PhysicsWorld, body: api.BodyId) ?struct { pos: [3]WorldReal, rot: [4]WorldReal } { + const p = pw.bm.position(body) orelse return null; + const r = pw.bm.rotation(body).?; + return .{ .pos = cross.vec3ToWorld(p).toArray(), .rot = cross.quatToWorld(r).toArray() }; +} + +/// The solver's two velocity columns for `body`, in world-scalar array form — the shape the +/// ECS `Velocity` carries, so the comparison and the write read the same bytes. +fn solverVelocity(pw: *const PhysicsWorld, body: api.BodyId) struct { linear: [3]WorldReal, angular: [3]WorldReal } { + return .{ + .linear = cross.vec3ToWorld(pw.bm.linearVelocity(body).?).toArray(), + .angular = cross.vec3ToWorld(pw.bm.angularVelocity(body).?).toArray(), + }; +} + +/// Which registrations publish, decided ONCE per tick. +/// +/// **A PRE-PASS, because the per-body form was quadratic.** The first version asked +/// "who is my entity's publisher?" from inside each of the three publication passes, and each +/// answer swept the whole registration list: 3·N² comparisons, 363 million of them at C1.1's +/// 11 000 bodies, paid in full by a scene with no trigger and no multi-body entity at all. +/// The question is per ENTITY and its answer does not change during a tick, so it is answered +/// once. +/// +/// **Deterministic and without a hashed container.** Candidates are sorted on the composite +/// key `(entity, is_trigger, body)` — total, since body handles are unique — and the FIRST +/// entry of each entity run wins: `is_trigger = false` sorts ahead, so the run's head is the +/// non-trigger of smallest identity when one exists and the trigger of smallest identity +/// otherwise. That is `electedPublisher`'s two-level criterion expressed as an ordering. +/// §1.13.11's discipline governs anything that could become a compared path, and a sort +/// answers it at no extra cost. +const PublisherTable = struct { + /// One flag per registration, in registration order. + publishes: []bool, + + fn deinit(self: PublisherTable, gpa: std.mem.Allocator) void { + gpa.free(self.publishes); + } +}; + +const Candidate = struct { + entity: u64, + /// `0` for a solid body, `1` for a trigger — the second level of the criterion, carried + /// in the sort key rather than in a branch. + trigger: u8, + body: api.BodyId, + registration: u32, + + fn lessThan(_: void, a: Candidate, b: Candidate) bool { + if (a.entity != b.entity) return a.entity < b.entity; + if (a.trigger != b.trigger) return a.trigger < b.trigger; + return a.body < b.body; + } +}; + +/// Build the table. A character presence is never a candidate — it is the controller's inner +/// body and answers for nobody. +fn electPublishers(gpa: std.mem.Allocator, pw: *const PhysicsWorld) !PublisherTable { + const n = pw.bodies.items.len; + const flags = try gpa.alloc(bool, n); + errdefer gpa.free(flags); + @memset(flags, false); + if (n == 0) return .{ .publishes = flags }; + + const cands = try gpa.alloc(Candidate, n); + defer gpa.free(cands); + + var count: usize = 0; + for (pw.bodies.items, 0..) |entry, reg| { + if (entry.kind != .rigid_body) continue; + const owner = pw.bm.entity(entry.id) orelse continue; + cands[count] = .{ + // Index AND generation, as §1.13.11's ordering key is: the complete identity, so + // a recycled slot never inherits the previous entity's election. + .entity = (@as(u64, owner.generation) << 32) | owner.index, + .trigger = if (pw.bm.isTrigger(entry.id) orelse false) 1 else 0, + .body = entry.id, + .registration = @intCast(reg), + }; + count += 1; + } + const live = cands[0..count]; + std.mem.sort(Candidate, live, {}, Candidate.lessThan); + + var i: usize = 0; + while (i < live.len) { + flags[live[i].registration] = true; // head of the run: the elected body + const key = live[i].entity; + i += 1; + while (i < live.len and live[i].entity == key) i += 1; + } + return .{ .publishes = flags }; +} + +/// Publish what the solver owns OUT to the ECS — after step 11 of the cycle. +/// +/// `cmd` is the per-system command buffer when this runs as a registered system, and `null` +/// on the direct path. The two `Sleeping` transitions are STRUCTURAL — they migrate the +/// entity between archetypes — so inside a scheduler they must be recorded and applied at the +/// phase flush: a system in the same topological level may be iterating archetypes +/// concurrently, and a migration under it is the defect `engine-ecs-internals.md` §6 defers +/// structural changes to prevent. Nothing else in this function is structural. +/// +/// Three passes, and the order between them is the contract this file's header argues: +/// untag the woken, publish everything untagged, tag the newly asleep. +pub fn syncOut(gpa: std.mem.Allocator, pw: *PhysicsWorld, ecs: *World, cmd: ?*CommandBuffer) !void { + const table = try electPublishers(gpa, pw); + defer table.deinit(gpa); + + // (1) UNTAG THE WOKEN, BEFORE publishing — so the first pose a waking body moved to + // is published on the very tick it moved, instead of a tick later. + for (pw.bodies.items, 0..) |entry, reg| { + if (!table.publishes[reg]) continue; + const entity = pw.bm.entity(entry.id) orelse continue; + if (pw.bm.isSleeping(entry.id).?) continue; + if (ecs.get(Sleeping, entity) == null) continue; + if (cmd) |c| try c.removeComponent(entity, Sleeping) else try ecs.removeComponent(gpa, entity, Sleeping); + } + + // (2) PUBLISH everything not tagged. A body that fell asleep at step 11 of THIS tick + // is not tagged yet, so its final pose is published here — the whole reason pass (3) + // comes after this one. + for (pw.bodies.items, 0..) |entry, reg| { + if (!table.publishes[reg]) continue; + const body = entry.id; + const entity = pw.bm.entity(body) orelse continue; + const body_type = pw.bm.bodyType(body).?; + if (body_type == .static) continue; + // SKIP IFF TAGGED **AND** STILL ASLEEP. The tag alone was the predicate until the + // systems landed, and it was correct only because pass (1) removed it immediately, + // in this same call, before this loop read it. Inside a scheduler a structural change + // is DEFERRED to the phase flush, so that removal is no longer visible here — and the + // tag alone would then skip a body that woke this tick, which is the waking half of + // the very trap this file's ordering exists to avoid. Conjoining the solver's own + // state removes the dependency on when the untag applies: a woken body still carries + // the tag and is no longer asleep, so it publishes either way. + // + // The solver state alone is NOT a substitute — that is the sleeping half of the trap, + // measured: a body that falls asleep at step 11 is asleep here and would never + // publish its final pose. + if (ecs.get(Sleeping, entity) != null and pw.bm.isSleeping(body).?) continue; + + // The POSE goes out for a DYNAMIC body only: gameplay owns a kinematic pose, and + // publishing it back would be this seam overwriting the authority it just read. + // READ FIRST, `getMut` ONLY ON A REAL DIFFERENCE. `World.getMut` marks + // `changed_tick` UNCONDITIONALLY and + // `Changed` is built on that mark, so republishing a bit-identical pose would + // report a change that did not happen, every tick, for every awake body. + if (body_type == .dynamic) { + if (ecs.get(Transform, entity)) |t| { + const pose = solverPose(pw, body).?; + if (!std.mem.eql(WorldReal, &t.pos, &pose.pos) or + !std.mem.eql(WorldReal, &t.rot, &pose.rot)) + { + const w = ecs.getMut(Transform, entity).?; + w.pos = pose.pos; + w.rot = pose.rot; + } + } + } + + // The VELOCITY goes out for both simulated kinds — resolved by the solver for a + // dynamic body, derived by `moveKinematic` for a kinematic one. Same read-first + // rule, and it is the channel that bites hardest: an immobile kinematic platform + // held awake by a character standing on it republishes a constant zero forever, and + // `Velocity` is `@replicated(strategy: .rollback)` — a false mark ships a delta. + if (ecs.get(Velocity, entity)) |v| { + const out = solverVelocity(pw, body); + if (!std.mem.eql(WorldReal, &v.linear, &out.linear) or + !std.mem.eql(WorldReal, &v.angular, &out.angular)) + { + const w = ecs.getMut(Velocity, entity).?; + w.linear = out.linear; + w.angular = out.angular; + } + } + } + + // (3) TAG THE NEWLY ASLEEP, AFTER publishing. From the next tick on, pass (2) skips + // them and their `Transform` holds the last pose the solver computed. + for (pw.bodies.items, 0..) |entry, reg| { + if (!table.publishes[reg]) continue; + const entity = pw.bm.entity(entry.id) orelse continue; + if (!pw.bm.isSleeping(entry.id).?) continue; + if (ecs.get(Sleeping, entity) != null) continue; + if (cmd) |c| try c.addComponent(entity, Sleeping, .{}) else try ecs.addComponent(gpa, entity, Sleeping, .{}); + } +} + +/// One full tick followed by its publication — the shape the registered system drives, and +/// the one the tests exercise so the order is measured rather than left to each call site to +/// remember. There is no inward half here any more: see the header. +pub fn stepAndPublish(gpa: std.mem.Allocator, pw: *PhysicsWorld, ecs: *World) !void { + try pw.step(gpa); + try syncOut(gpa, pw, ecs, null); +} + +// --- registration ------------------------------------------------------------ + +const SystemScheduler = core.ecs.SystemScheduler; +const SystemContext = core.ecs.SystemContext; +const Writes = core.ecs.Writes; +const WritesResource = core.ecs.WritesResource; + +/// The handle the registered systems reach the solver through, held as an ECS resource. +/// +/// **A `SystemFn` receives only a `SystemContext`**, which carries the `World` and no channel +/// for a module's own state, so the pointer has to live somewhere the context can reach. +/// `FrameContext.user` exists and was REFUSED: it is ONE `?*anyopaque` slot shared by the +/// whole engine, so the first module to claim it wins and the second silently loses. A +/// resource keyed on this type collides with nothing by construction. +/// +/// **It carries the allocator too, and that is not a convenience.** `ctx.gpa` is the +/// PER-FRAME allocator, while `refreshProxy` reaches `Broadphase.update`, which RESERVES +/// memory the broadphase keeps across ticks. Handing a frame allocator to it would free that +/// memory out from under the tree at the end of the frame. +/// Stored as three raw words rather than as typed fields, because the ECS registry builds a +/// resource's default bytes from a default-constructed value and a pointer has no meaningful +/// default. Zero means "nothing published", which `resolve` reports as absence. +pub const PhysicsWorldRef = extern struct { + world: usize = 0, + /// The two halves of a `std.mem.Allocator`, which is `{ ptr, vtable }`. + alloc_ptr: usize = 0, + alloc_vtable: usize = 0, + + fn pack(world: *PhysicsWorld, gpa: std.mem.Allocator) PhysicsWorldRef { + return .{ + .world = @intFromPtr(world), + .alloc_ptr = @intFromPtr(gpa.ptr), + .alloc_vtable = @intFromPtr(gpa.vtable), + }; + } + + fn worldPtr(self: PhysicsWorldRef) ?*PhysicsWorld { + if (self.world == 0) return null; + return @ptrFromInt(self.world); + } + + fn allocator(self: PhysicsWorldRef) std.mem.Allocator { + return .{ + .ptr = @ptrFromInt(self.alloc_ptr), + .vtable = @ptrFromInt(self.alloc_vtable), + }; + } +}; + +/// Publish `pw` into `ecs` so the registered system can find it. +/// +/// **REFUSES to replace a live publication**, with `error.PhysicsWorldAlreadyPublished`. A +/// silent replacement is the exact counterpart of a blind withdrawal: it makes the first world +/// disappear without anyone having withdrawn it, and the frames that follow drive a world its +/// owner believes is still wired. Withdraw first, then publish — the two entries are a pair. +/// +/// The signature is not frozen: `PhysicsModule` does not carry it, so an error channel costs +/// nothing that a later milestone would have to live with. +pub fn publishPhysicsWorld(gpa: std.mem.Allocator, ecs: *World, pw: *PhysicsWorld) !void { + const id = try ecs.ensureComponentRegistered(gpa, PhysicsWorldRef); + const ref = PhysicsWorldRef.pack(pw, gpa); + if (ecs.resources.getMutResource(id)) |slot| { + if (slot.len != @sizeOf(PhysicsWorldRef)) return error.PhysicsWorldAlreadyPublished; + var current: PhysicsWorldRef = undefined; + @memcpy(std.mem.asBytes(¤t), slot); + if (current.world != 0) return error.PhysicsWorldAlreadyPublished; + @memcpy(slot, std.mem.asBytes(&ref)); + return; + } + try ecs.addResource(gpa, id, std.mem.asBytes(&ref)); +} + +/// Withdraw the published handle. The SYMMETRIC half of `publishPhysicsWorld`, and the caller +/// that published is the one that withdraws. +/// +/// **Without it the resource outlives the world it names.** `publishPhysicsWorld` writes raw +/// pointers, `PhysicsWorld.deinit` frees and poisons, and nothing cleared the resource — the +/// accessor kept answering with a dead address and the next dispatch dereferenced it. That the +/// runtime's ordinary order happens to be safe changes nothing: a lifetime contract nothing +/// enforces is not a contract, and this is the same shape as `removeBody` accepting a +/// character presence — two operations each correct, whose sequence breaks. +/// +/// **`expected` is checked, and that is the second half of the same defect.** A withdrawal +/// that cleared whatever it found would let a LATE teardown erase a world published after it: +/// publish A, publish B, then run A's deferred cleanup, and every frame afterwards is a silent +/// no-op. A withdrawal that finds someone else's handle is a NO-OP, matching the repository's +/// pattern for an unhandleable handle rather than raising on a teardown path. +/// +/// **THE CHECK IS ON THE ADDRESS, and that bounds what it protects.** It separates two worlds +/// ALIVE AT THE SAME TIME, which is the case above and the only one any path in this +/// repository produces. It does NOT separate two GENERATIONS occupying the same storage: a +/// world destroyed, a second one allocated at the same address, and a late withdrawal aimed at +/// the first would clear the second. Closing that needs an opaque publication token rather +/// than a pointer comparison, which is recorded with its owner in the brief's Closing notes. +/// The limit is written here rather than widened. +/// +/// The handle is ZEROED rather than the resource removed: the accessor already reads zero as +/// absence, the slot keeps its registered id so a later publication reuses it, and nothing has +/// to be freed. `PhysicsWorld` still knows nothing of the ECS, which is the invariant this +/// seam has held since it was written. +pub fn unpublishPhysicsWorld(ecs: *World, expected: *PhysicsWorld) void { + const id = ecs.componentId(@typeName(PhysicsWorldRef)) orelse return; + const slot = ecs.resources.getMutResource(id) orelse return; + if (slot.len != @sizeOf(PhysicsWorldRef)) return; + var current: PhysicsWorldRef = undefined; + @memcpy(std.mem.asBytes(¤t), slot); + if (current.world != @intFromPtr(expected)) return; // someone else's publication + const cleared = PhysicsWorldRef{}; + @memcpy(slot, std.mem.asBytes(&cleared)); +} + +/// Read the published handle back, or null if nothing was published. +/// +/// **A PURE LOOKUP, and that is the point.** The first version obtained the id by REGISTERING +/// the type here, on `ctx.gpa` — the FRAME allocator — and a registration keeps the type's +/// name for the world's whole life. A frame allocator handing out memory the registry retains +/// is the same defect this file avoids one level up, arriving through the lookup instead of +/// through the physics. `componentId` resolves an already-registered name and allocates +/// nothing; a world where nothing was published has no such name, which is absence and not +/// failure. `publishPhysicsWorld` is the only site that registers, and it takes the +/// persistent allocator. +fn resolve(ecs: *World) ?PhysicsWorldRef { + const id = ecs.componentId(@typeName(PhysicsWorldRef)) orelse return null; + const bytes = ecs.resources.getResource(id) orelse return null; + if (bytes.len != @sizeOf(PhysicsWorldRef)) return null; + var ref: PhysicsWorldRef = undefined; + // Copied out rather than pointer-cast: the resource store hands back a byte slice whose + // alignment is the store's, not this type's. + @memcpy(std.mem.asBytes(&ref), bytes); + if (ref.world == 0) return null; + return ref; +} + +/// The world published into `ecs`, or null if none is. +/// +/// Answers with the IDENTITY and not a boolean, because a boolean cannot tell "B is still +/// published" from "B was erased and something else answers": the tests that guard this +/// lifetime need the difference, and a caller asserting its own wiring does too. +pub fn publishedPhysicsWorld(ecs: *World) ?*PhysicsWorld { + const ref = resolve(ecs) orelse return null; + return ref.worldPtr(); +} + +fn stepAndPublishSystem(ctx: SystemContext) anyerror!void { + const ref = resolve(ctx.world) orelse return; + try ref.worldPtr().?.step(ref.allocator()); + try syncOut(ref.allocator(), ref.worldPtr().?, ctx.world, ctx.cmd); +} + +const step_name = "forge_step_and_publish"; + +/// The system's access set, as a FILE-SCOPE constant and not as a `&.{ ... }` temporary inside +/// `registerSystems`. +/// +/// **This is a defect fixed, not a style choice, and it was measured.** A `&.{ ... }` literal +/// in the struct passed to `registerSystem` is a temporary whose lifetime ends with the +/// enclosing block. `SystemScheduler` stores the descriptor — slice included — so once +/// `registerSystems` returns, every `type_name` in it points at dead stack: reading them +/// printed an empty string and then took a FAULT. Nothing crashed earlier because the DAG +/// edges are computed AT registration and no later path reads the accesses; the first thing +/// that did was the test asserting they are declared. The tree's other call sites survive by +/// accident — they register inside the same function that consumes the scheduler — so the API +/// invites this, and the safe form is the one that does not depend on where the caller lives. +/// It was two sets when the seam registered two systems; it is one now, and the reason to keep +/// it at file scope is unchanged. +const step_accesses = [_]core.ecs.AccessDescriptor{ + Writes(Transform), + Writes(Velocity), + // The `Sleeping` transitions are STRUCTURAL — they migrate the entity between archetypes — + // so `Writes` is the closest the access model can express, not a description of the effect. + Writes(Sleeping), + WritesResource(PhysicsWorldRef), +}; + +fn isRegistered(sched: *const SystemScheduler, phase: core.ecs.Phase, wanted: []const u8) bool { + for (sched.systemsInPhase(phase)) |d| { + if (std.mem.eql(u8, d.name, wanted)) return true; + } + return false; +} + +/// Whether any write in `accesses` already has a writer in `phase`. +/// +/// **The preflight checks DESCRIPTORS and not just names, because that is the failure that +/// actually happens.** A `WriteWriteConflict` is ordinary and deterministic — some other +/// system already writes `Transform` in `fixed_update` — and checking only for a duplicate +/// name would let the first registration land and the second be refused, which is the partial +/// state the preflight exists to prevent. Matched on `type_name`, which is what +/// `registerSystem` itself reports the conflict on. +fn wouldConflict( + sched: *const SystemScheduler, + phase: core.ecs.Phase, + accesses: []const core.ecs.AccessDescriptor, +) bool { + for (accesses) |mine| { + if (mine.kind != .writes and mine.kind != .writes_resource) continue; + for (sched.systemsInPhase(phase)) |d| { + for (d.accesses) |theirs| { + if (theirs.kind != .writes and theirs.kind != .writes_resource) continue; + if (std.mem.eql(u8, theirs.type_name, mine.type_name)) return true; + } + } + } + return false; +} + +/// Register the ONE system that drives the physics frame. +/// +/// `fixed_update` advances the tick and publishes what the solver resolved — the phase +/// `ARCH-031` names as inside the float discipline's perimeter, and where a fixed-timestep +/// tick belongs. The publication rides the tick rather than sitting in a later phase, so +/// `update` observes the poses of the tick that just ran instead of the previous one. The +/// ECS → solver direction is M1.1.26's and registers nothing here. +/// +/// The declared accesses are exactly what the system does, which is what a future `ARCH-030` +/// enforcement will check — `Transform`, `Velocity` and the `Sleeping` marker it migrates, +/// plus the solver resource it mutates through the published pointer. Undeclared, two physics +/// modules could sit in one phase with no edge between them and the enforcement would have +/// nothing to catch them on. +/// +/// **PREFLIGHT AND NOT IDEMPOTENCE, because of which failure is real.** Calling this twice on +/// one scheduler is the failure a caller can actually produce, and it is deterministic: the +/// name and the write conflicts are checked BEFORE the registration, so a second call mutates +/// nothing and reports `error.SystemAlreadyRegistered`. Idempotence would accept it in +/// silence, hiding a double wiring rather than naming it. +/// +/// **The preflight covers the DETERMINISTIC failures — the name and the write conflicts — and +/// covers OOM not at all.** `registerSystem` appends edges and several tracker entries before +/// any allocation can fail, and its `errdefer`s do not undo all of them: after an allocation +/// failure the scheduler must be treated as UNUSABLE, and a retry can report +/// `WriteWriteConflict` against a `systemCount()` of zero. Moving to one system removed the +/// residual BETWEEN two calls and nothing inside one. The real fix is that `registerSystem` be +/// TRANSACTIONAL FOR ITSELF — a Tier 0 debt recorded in `engine-ecs-internals.md`, and a +/// different one from the absence of a group removal recorded beside it. Promising an absence +/// of residue that no mechanism holds would be worse than saying nothing, because it excuses +/// the next reader from checking. +pub fn registerSystems(gpa: std.mem.Allocator, sched: *SystemScheduler, ecs: *World) !void { + if (isRegistered(sched, .fixed_update, step_name)) return error.SystemAlreadyRegistered; + if (wouldConflict(sched, .fixed_update, &step_accesses)) return error.WriteWriteConflict; + + try sched.registerSystem(gpa, ecs, .{ + .phase = .fixed_update, + .name = step_name, + .run = stepAndPublishSystem, + .accesses = &step_accesses, + }); +} diff --git a/tests/physics/transform_sync_test.zig b/tests/physics/transform_sync_test.zig new file mode 100644 index 00000000..04cfc061 --- /dev/null +++ b/tests/physics/transform_sync_test.zig @@ -0,0 +1,953 @@ +//! M1.1.15 — the solver → ECS publication. +//! +//! What this file measures is the SEAM: which body speaks for an entity, what is published per +//! `BodyType`, and what the `Sleeping` marker does to publication. The ECS → solver direction +//! is M1.1.26's, with the Tier 1 Etch service — the tests that measured it left with it, and +//! two more kept only their publication half. The physics itself is measured by `forge_3d`'s +//! own suite and is not re-measured here. +//! +//! Every assertion names an ENTITY and reads that entity's own components. An aggregate +//! another entity could satisfy is not an assertion about the one named. + +const std = @import("std"); +const core = @import("weld_core"); +const api = @import("weld_forge"); +const forge_3d = @import("forge_3d"); +const sync = @import("forge_sync"); +const foundation = @import("foundation"); + +const World = core.ecs.World; +const EntityId = core.ecs.EntityId; +const Transform = core.ecs.components.Transform; +const Velocity = api.Velocity; +const Sleeping = api.Sleeping; +const PhysicsWorld = forge_3d.PhysicsWorld; +const Vec3r = forge_3d.Vec3r; +const Real = forge_3d.Real; +const testing = std.testing; + +const fixed_dt: Real = 1.0 / 60.0; +const gravity_y: Real = -9.81; + +/// The same timestep as `fixed_dt`, in the `f32` the frame context carries. +const fixed_dt_f32: f32 = 1.0 / 60.0; + +fn vr(x: Real, y: Real, z: Real) Vec3r { + return Vec3r.fromArray(.{ x, y, z }); +} + +fn av3(x: f32, y: f32, z: f32) foundation.math.Vec3 { + return foundation.math.Vec3.fromArray(.{ x, y, z }); +} + +/// An ECS entity carrying `Transform` and `Velocity`, plus a physics body bound to it by +/// entity identity — the binding the publication seam walks. +fn spawnLinked( + gpa: std.mem.Allocator, + ecs: *World, + pw: *PhysicsWorld, + body_type: api.BodyType, + half: [3]f32, + centre: [3]f32, +) !struct { entity: EntityId, body: api.BodyId } { + const entity = try ecs.spawn(gpa, .{ + .pos = .{ centre[0], centre[1], centre[2] }, + }, .{}); + const shape = try pw.store.createShape(gpa, .{ .box = .{ .half_extents = av3(half[0], half[1], half[2]) } }); + var desc = api.BodyDescriptor{ + .entity = entity, + .body_type = body_type, + .shape = shape, + }; + desc.position = av3(centre[0], centre[1], centre[2]); + desc.restitution = 0; + if (body_type == .dynamic) desc.mass = 1; + const body = try pw.addBody(gpa, desc); + return .{ .entity = entity, .body = body }; +} + +/// Bind a SECOND body to an entity that already has one — the shape +/// `engine-physics-solver.md` §1.13.1 makes normal: "a mixed body does not exist; an entity +/// that must both collide and detect carries two bodies". +fn addTriggerBody( + gpa: std.mem.Allocator, + pw: *PhysicsWorld, + entity: EntityId, + centre: [3]f32, +) !api.BodyId { + const shape = try pw.store.createShape(gpa, .{ .box = .{ .half_extents = av3(1, 1, 1) } }); + var desc = api.BodyDescriptor{ + .entity = entity, + .body_type = .kinematic, + .shape = shape, + }; + desc.position = av3(centre[0], centre[1], centre[2]); + desc.is_trigger = true; + return try pw.addBody(gpa, desc); +} + +/// One driven frame on the DIRECT path: open the ECS frame, then run the tick and its +/// publication. +/// +/// **The frame boundary is the caller's and never `stepAndPublish`'s.** An app loop and +/// `dispatchFrame` both open the frame themselves, so modelling it here keeps the two paths +/// identical in shape — and the change-detection guard below needs the tick to advance to say +/// anything at all. +fn frame(gpa: std.mem.Allocator, pw: *PhysicsWorld, ecs: *World) !void { + ecs.beginFrame(); + try sync.stepAndPublish(gpa, pw, ecs); +} + +/// Ground at the origin (top face y = 0.5) plus a dynamic unit box resting flush on it. +fn restingScene(gpa: std.mem.Allocator, ecs: *World, pw: *PhysicsWorld) !struct { + ground: EntityId, + box_entity: EntityId, + box: api.BodyId, +} { + const g = try spawnLinked(gpa, ecs, pw, .static, .{ 5, 0.5, 5 }, .{ 0, 0, 0 }); + const b = try spawnLinked(gpa, ecs, pw, .dynamic, .{ 0.5, 0.5, 0.5 }, .{ 0, 1.0, 0 }); + return .{ .ground = g.entity, .box_entity = b.entity, .box = b.body }; +} + +test "solver pose reaches Transform for every awake body" { + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var pw = PhysicsWorld.initNoSleep(vr(0, gravity_y, 0), fixed_dt); + defer pw.deinit(gpa); + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + + // TWO dynamic bodies at different heights, so an assertion that read the wrong one — + // or an aggregate over both — could not pass by accident: they fall to different + // places and each is checked against ITS OWN solver pose, by entity. + const a = try spawnLinked(gpa, &ecs, &pw, .static, .{ 5, 0.5, 5 }, .{ 0, 0, 0 }); + const high = try spawnLinked(gpa, &ecs, &pw, .dynamic, .{ 0.5, 0.5, 0.5 }, .{ 0, 4.0, 0 }); + const low = try spawnLinked(gpa, &ecs, &pw, .dynamic, .{ 0.5, 0.5, 0.5 }, .{ 2, 1.6, 0 }); + _ = a; + + var t: u32 = 0; + while (t < 20) : (t += 1) try frame(gpa, &pw, &ecs); + + for ([_]struct { e: EntityId, b: api.BodyId }{ + .{ .e = high.entity, .b = high.body }, + .{ .e = low.entity, .b = low.body }, + }) |link| { + const solver = pw.bm.position(link.b).?.toArray(); + const published = ecs.get(Transform, link.e).?.pos; + try testing.expectEqual(@as(f32, @floatCast(solver[0])), published[0]); + try testing.expectEqual(@as(f32, @floatCast(solver[1])), published[1]); + try testing.expectEqual(@as(f32, @floatCast(solver[2])), published[2]); + } + + // NON-VACUITY: the bodies actually moved, so the equality above is not two identical + // spawn poses agreeing with themselves. + try testing.expect(ecs.get(Transform, high.entity).?.pos[1] < 4.0 - 0.1); + try testing.expect(ecs.get(Transform, low.entity).?.pos[1] < 1.6 - 0.001); + // And the two are DIFFERENT, which is what an aggregate could have hidden. + try testing.expect(ecs.get(Transform, high.entity).?.pos[0] != ecs.get(Transform, low.entity).?.pos[0]); +} + +test "a sleeping island's Transform is not rewritten and its pose is bit-frozen" { + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var pw = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer pw.deinit(gpa); + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + const scene = try restingScene(gpa, &ecs, &pw); + + var t: u32 = 0; + while (t < 400 and !pw.bm.isSleeping(scene.box).?) : (t += 1) { + try frame(gpa, &pw, &ecs); + } + try testing.expect(pw.bm.isSleeping(scene.box).?); + + // (b) THE VALUE — and this is the half that catches the ordering trap. The island + // falls asleep at step 11, AFTER steps 6 and 7 wrote its last pose, and sync-out runs + // after step 11. Tag before publishing and that last pose is never published: the + // entity keeps the pose of the previous tick, for as long as it sleeps. The body IS + // frozen either way, so an immobility check alone passes on the defect — it is frozen + // on the wrong value. This compares the published `Transform` against the pose the + // SOLVER holds at the sleeping tick. + const solver = pw.bm.position(scene.box).?.toArray(); + const published = ecs.get(Transform, scene.box_entity).?.pos; + try testing.expectEqual(@as(f32, @floatCast(solver[0])), published[0]); + try testing.expectEqual(@as(f32, @floatCast(solver[1])), published[1]); + try testing.expectEqual(@as(f32, @floatCast(solver[2])), published[2]); + + // (a) THE IMMOBILITY — bit equality across many further ticks, not an epsilon. The + // marker is on, so publication skips this entity entirely. + try testing.expect(ecs.get(Sleeping, scene.box_entity) != null); + const frozen = ecs.get(Transform, scene.box_entity).?.*; + var k: u32 = 0; + while (k < 30) : (k += 1) try frame(gpa, &pw, &ecs); + const after = ecs.get(Transform, scene.box_entity).?.*; + try testing.expectEqualSlices(f32, &frozen.pos, &after.pos); + try testing.expectEqualSlices(f32, &frozen.rot, &after.rot); +} + +test "the waking tick publishes the pose it moved to" { + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var pw = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer pw.deinit(gpa); + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + const scene = try restingScene(gpa, &ecs, &pw); + + var t: u32 = 0; + while (t < 400 and !pw.bm.isSleeping(scene.box).?) : (t += 1) { + try frame(gpa, &pw, &ecs); + } + try testing.expect(ecs.get(Sleeping, scene.box_entity) != null); + + // THE MIRROR OF THE ORDERING TRAP. Untagging AFTER publication instead of before + // would lose the first tick a woken body moves — the same class of defect at the + // other end of the cycle — so the wake side is asserted as well as the sleep side. + pw.addImpulse(scene.box, vr(2, 0, 0)); + try frame(gpa, &pw, &ecs); + try testing.expect(ecs.get(Sleeping, scene.box_entity) == null); + const solver = pw.bm.position(scene.box).?.toArray(); + const published = ecs.get(Transform, scene.box_entity).?.pos; + try testing.expectEqual(@as(f32, @floatCast(solver[0])), published[0]); + // And it really moved on that very tick, so the equality is not two stale values. + try testing.expect(@abs(published[0]) > 1e-5); +} + +test "Sleeping tag tracks island state through both transitions" { + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var pw = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer pw.deinit(gpa); + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + const scene = try restingScene(gpa, &ecs, &pw); + + // A FULL CYCLE EACH WAY: absent while awake, present once asleep, absent again on + // wake, present again once it settles. One direction alone is satisfied by a rule + // that only ever adds, or only ever removes. + try testing.expect(ecs.get(Sleeping, scene.box_entity) == null); + + var t: u32 = 0; + while (t < 400 and !pw.bm.isSleeping(scene.box).?) : (t += 1) { + try frame(gpa, &pw, &ecs); + } + try testing.expect(ecs.get(Sleeping, scene.box_entity) != null); + + pw.addImpulse(scene.box, vr(2, 0, 0)); + try frame(gpa, &pw, &ecs); + try testing.expect(ecs.get(Sleeping, scene.box_entity) == null); + + t = 0; + while (t < 600 and !pw.bm.isSleeping(scene.box).?) : (t += 1) { + try frame(gpa, &pw, &ecs); + } + try testing.expect(pw.bm.isSleeping(scene.box).?); + try testing.expect(ecs.get(Sleeping, scene.box_entity) != null); + + // The entity kept its other components across the four archetype migrations the two + // round trips caused — a zero-size column must not disturb its neighbours. + try testing.expect(ecs.get(Transform, scene.box_entity) != null); + try testing.expect(ecs.get(Velocity, scene.box_entity) != null); +} + +test "publication authority per BodyType" { + // The PUBLICATION half only. The reception half — a kinematic `Transform` written by + // gameplay reaching the solver — moved to M1.1.26 with `syncIn`; see Closing notes. Two + // assertions that the SOLVER had not moved were dropped with it: with no inward direction + // they held for every body and named nothing about authority. + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var pw = PhysicsWorld.initNoSleep(vr(0, gravity_y, 0), fixed_dt); + defer pw.deinit(gpa); + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + + const dyn = try spawnLinked(gpa, &ecs, &pw, .dynamic, .{ 0.5, 0.5, 0.5 }, .{ 0, 10, 0 }); + const kin = try spawnLinked(gpa, &ecs, &pw, .kinematic, .{ 0.5, 0.5, 0.5 }, .{ 20, 0, 0 }); + const sta = try spawnLinked(gpa, &ecs, &pw, .static, .{ 0.5, 0.5, 0.5 }, .{ 40, 0, 0 }); + + // DYNAMIC — the SOLVER is the authority over the pose, so a gameplay write to `Transform` + // is overwritten by the publication. That is the contract and not a bug: the legitimate + // ways to move a dynamic body are `setBodyTransform`, a force or an impulse. + ecs.getMut(Transform, dyn.entity).?.pos = .{ 99, 99, 99 }; + try frame(gpa, &pw, &ecs); + try testing.expect(ecs.get(Transform, dyn.entity).?.pos[0] != 99); + try testing.expectEqual( + @as(f32, @floatCast(pw.bm.position(dyn.body).?.toArray()[1])), + ecs.get(Transform, dyn.entity).?.pos[1], + ); + + // KINEMATIC — gameplay owns the pose, so the publication does NOT write it back and the + // ECS value stands. + ecs.getMut(Transform, kin.entity).?.pos = .{ 25, 3, 0 }; + try frame(gpa, &pw, &ecs); + try testing.expectEqual(@as(f32, 25), ecs.get(Transform, kin.entity).?.pos[0]); + + // STATIC — no publication at all, so the ECS write stands. A static body that moves is a + // teleportation through `setBodyTransform`, never a `Transform` write. + ecs.getMut(Transform, sta.entity).?.pos = .{ 77, 0, 0 }; + try frame(gpa, &pw, &ecs); + try testing.expectEqual(@as(f32, 77), ecs.get(Transform, sta.entity).?.pos[0]); +} + +/// Was `T`'s slot for `entity` stamped at the world's CURRENT tick? This reads the SIGNAL +/// `Changed` is built on (`core/ecs/world.zig` `getMut` → `archetype.markChanged`), and +/// deliberately not the value: under the defect these guards refuse, the value is already +/// correct and only the signal lies. +fn markedThisTick(ecs: *World, comptime T: type, entity: EntityId) bool { + const loc = ecs.dynamicLocation(entity).?; + const arch = ecs.dynamicArchetype(loc.archetype_idx); + const chunk = arch.chunks.items[loc.chunk_idx]; + const col = arch.componentIndex(ecs.componentId(@typeName(T)).?).?; + return arch.changedTick(chunk, col, loc.slot) == ecs.current_tick; +} + +test "publication does not mark a component whose value did not change" { + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + + // ZERO gravity and sleeping OFF: the body is awake forever and exactly immobile, which + // is the awake-and-immobile case the `Sleeping` marker says nothing about. Immobility + // is EXACT and not approximate here — `v = 0` damped is `0`, and `x + 0 · dt` is `x` — + // so a mark can only come from an unconditional write, never from a settling residue. + var pw = PhysicsWorld.initNoSleep(vr(0, 0, 0), fixed_dt); + defer pw.deinit(gpa); + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + const b = try spawnLinked(gpa, &ecs, &pw, .dynamic, .{ 0.5, 0.5, 0.5 }, .{ 0, 2, 0 }); + + // One tick to let publication write whatever it wants to write once. + try frame(gpa, &pw, &ecs); + const settled_pos = ecs.get(Transform, b.entity).?.pos; + + // From here on, nothing in the scene changes. Every further tick opens a NEW ECS frame, + // so a stamp at `current_tick` can only have been made during that tick — and the test + // touches no component itself, so publication is the only possible author. + var k: u32 = 0; + while (k < 8) : (k += 1) { + try frame(gpa, &pw, &ecs); + try testing.expect(!markedThisTick(&ecs, Transform, b.entity)); + try testing.expect(!markedThisTick(&ecs, Velocity, b.entity)); + } + // The body genuinely did not move, so the quiet signal is not hiding a lost write. + try testing.expectEqualSlices(f32, &settled_pos, &ecs.get(Transform, b.entity).?.pos); + + // NON-VACUITY, and it is what separates this from a publication that writes NOTHING. The + // body is set moving through the INTERFACE — `syncIn` left for M1.1.26, so the ECS is no + // longer a way in — and the marks must then FIRE. + pw.setLinearVelocity(b.body, vr(3, 0, 0)); + + // A fresh tick the test does not touch: any stamp below is publication's own. + try frame(gpa, &pw, &ecs); + try testing.expect(markedThisTick(&ecs, Transform, b.entity)); + try testing.expect(ecs.get(Transform, b.entity).?.pos[0] > settled_pos[0]); +} + +test "a character presence never publishes for its entity" { + // ONE ENTITY, TWO BODIES. `character.zig` creates the presence with + // `.entity = desc.entity`, so the body store cannot tell the controller's inner body from + // the entity's own. Walked as an ordinary body it would publish its own velocity — exactly + // zero forever, a presence being kinematic and moved by pose write — over the entity's. + // + // This test measured a second half until the re-scope: that a gameplay `Transform` write + // did not teleport the presence. There is no inward direction left here, so that assertion + // held for EVERY body and discriminated nothing. The absence itself is pinned once, by + // name, in `no component write reaches the solver` below. + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var pw = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer pw.deinit(gpa); + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + + // Ground, so the character has something to stand on and the scene is not degenerate. + _ = try spawnLinked(gpa, &ecs, &pw, .static, .{ 5, 0.5, 5 }, .{ 0, 0, 0 }); + + const hero = try ecs.spawn(gpa, .{ .pos = .{ 0, 0.5, 0 } }, .{}); + const character = try pw.createCharacter(gpa, .{ .entity = hero, .position = av3(0, 0.5, 0) }); + const presence = (try pw.chars.getCharacterInnerBody(character)).?; + try testing.expectEqual(hero, pw.bm.entity(presence).?); + + // PRECONDITION, so the test cannot pass because the presence was never registered: it IS + // in the world's body list, which is what makes it reachable by the seam at all. + var registered = false; + for (pw.bodies.items) |e| { + if (e.id == presence) registered = true; + } + try testing.expect(registered); + + // OUTWARD. The presence is moved by pose write, so its velocity columns are zero forever. + // Published into the entity they would overwrite the entity's own `Velocity` with a + // constant zero, every tick. The value written here survives because the seam skips it. + ecs.getMut(Velocity, hero).?.linear = .{ 7, 0, 0 }; + try frame(gpa, &pw, &ecs); + try testing.expectEqual(@as(f32, 7), ecs.get(Velocity, hero).?.linear[0]); + try testing.expectEqual(@as(Real, 0), pw.bm.linearVelocity(presence).?.toArray()[0]); +} + +test "the registered system drives a real frame: the solver's pose reaches the ECS" { + // THE DELIVERABLE F5 NAMES. Before this, the composition entry had no caller outside this + // file: the seam existed only in its own tests, and the milestone shipped a mechanism + // nothing executed. What is measured here is the SCHEDULER path — `registerSystems` plus + // `dispatchFrame` — and not the direct composition the tests above drive. + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var ecs = World.init(); + defer ecs.deinit(gpa); + var pw = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer pw.deinit(gpa); + + _ = try spawnLinked(gpa, &ecs, &pw, .static, .{ 5, 0.5, 5 }, .{ 0, 0, 0 }); + const faller = try spawnLinked(gpa, &ecs, &pw, .dynamic, .{ 0.5, 0.5, 0.5 }, .{ 0, 4, 0 }); + + var jobs = try core.jobs.scheduler.Scheduler.init(gpa, io); + try jobs.start(); + defer jobs.deinit(gpa); + + var sched = core.ecs.SystemScheduler.init(); + defer sched.deinit(gpa); + + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + try sync.registerSystems(gpa, &sched, &ecs); + + // ONE system: the publication rides the tick in `fixed_update`. Splitting it into a tick + // and a `post_update` publication is what lost a gameplay `Velocity` write, and the + // inward direction left for M1.1.26 — see Closing notes. + try testing.expectEqual(@as(usize, 1), sched.systemCount()); + + const start = ecs.get(Transform, faller.entity).?.pos[1]; + var f: u32 = 0; + while (f < 30) : (f += 1) try sched.dispatchFrame(&ecs, gpa, io, &jobs, fixed_dt_f32, null); + + // The body fell in the SOLVER and the ECS learned about it, per entity and by identity. + const published = ecs.get(Transform, faller.entity).?.pos[1]; + const solver = pw.bm.position(faller.body).?.toArray()[1]; + try testing.expect(published < start - 0.05); // NON-VACUITY: it really moved + try testing.expectEqual(@as(f32, @floatCast(solver)), published); +} + +test "a trigger sharing an entity with a solid body does not publish" { + // §1.13.7 denies a trigger a manifold, a constraint and an impulse, and the amended text + // distinguishes TWO kinds of island entry: the CONSTRAINT island it cannot enter, no pair + // reaching it, and the INTEGRATION SINGLETON it does enter — which is why a dynamic trigger + // can ever stop being integrated. §1.13.1 makes two-bodies-one-entity the normal shape, and + // the election settles which of the two speaks. The RECEPTION half of this test — sync-in pushing the entity's pose into the + // trigger — left for M1.1.26 with `syncIn`; see Closing notes. + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var pw = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer pw.deinit(gpa); + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + + _ = try spawnLinked(gpa, &ecs, &pw, .static, .{ 5, 0.5, 5 }, .{ 0, 0, 0 }); + const solid = try spawnLinked(gpa, &ecs, &pw, .dynamic, .{ 0.5, 0.5, 0.5 }, .{ 0, 4, 0 }); + + // Registered AFTER the solid, so under a last-write-wins seam it is the trigger that wins + // the tick — the order that makes the defect visible rather than benign. + _ = try addTriggerBody(gpa, &pw, solid.entity, .{ 9, 9, 9 }); + + var t: u32 = 0; + while (t < 20) : (t += 1) try frame(gpa, &pw, &ecs); + + // The entity's Transform is the SOLID's pose, to the bit. Under the defect it is the + // trigger's, nine metres away — not a tolerance question. + const published = ecs.get(Transform, solid.entity).?.pos; + const solid_pose = pw.bm.position(solid.body).?.toArray(); + try testing.expectEqual(@as(f32, @floatCast(solid_pose[1])), published[1]); + try testing.expect(published[1] < 3.9); // NON-VACUITY: the solid really fell +} + +test "resolving an unpublished world registers nothing" { + // P1-3, and the object is `resolve` ITSELF. It used to obtain its id by REGISTERING the + // handle type, and inside a system that meant `ctx.gpa` — the per-frame allocator — while + // a registration keeps the type's name for the world's whole life. + // + // Isolated here rather than measured through a dispatch: since `registerSystems` declares + // `WritesResource(PhysicsWorldRef)`, registration now interns that name legitimately, with + // the PERSISTENT allocator, before any frame runs. A test that dispatched first would find + // the name already there and could not tell the two paths apart. + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + + try testing.expect(ecs.componentId(@typeName(sync.PhysicsWorldRef)) == null); + try testing.expect(sync.publishedPhysicsWorld(&ecs) == null); + // THE ASSERTION: the lookup left the registry untouched. Under the defect this call is + // what interns the name. + try testing.expect(ecs.componentId(@typeName(sync.PhysicsWorldRef)) == null); +} + +test "a frame dispatched before publication does nothing" { + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var ecs = World.init(); + defer ecs.deinit(gpa); + var jobs = try core.jobs.scheduler.Scheduler.init(gpa, io); + try jobs.start(); + defer jobs.deinit(gpa); + var sched = core.ecs.SystemScheduler.init(); + defer sched.deinit(gpa); + + try sync.registerSystems(gpa, &sched, &ecs); + try testing.expect(sync.publishedPhysicsWorld(&ecs) == null); + + var f: u32 = 0; + while (f < 3) : (f += 1) try sched.dispatchFrame(&ecs, gpa, io, &jobs, fixed_dt_f32, null); + + try testing.expect(sync.publishedPhysicsWorld(&ecs) == null); +} + +test "registering twice is refused, and refused before anything is registered" { + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var sched = core.ecs.SystemScheduler.init(); + defer sched.deinit(gpa); + + try sync.registerSystems(gpa, &sched, &ecs); + try testing.expectEqual(@as(usize, 1), sched.systemCount()); + + // PREFLIGHT: refused before anything is registered. The count is what discriminates — an + // implementation that registered then failed would leave two. + try testing.expectError(error.SystemAlreadyRegistered, sync.registerSystems(gpa, &sched, &ecs)); + try testing.expectEqual(@as(usize, 1), sched.systemCount()); +} + +/// A trigger body ALONE on its own entity, of a chosen type. +fn spawnLoneTrigger( + gpa: std.mem.Allocator, + ecs: *World, + pw: *PhysicsWorld, + body_type: api.BodyType, + centre: [3]f32, +) !struct { entity: EntityId, body: api.BodyId } { + const entity = try ecs.spawn(gpa, .{ .pos = .{ centre[0], centre[1], centre[2] } }, .{}); + const shape = try pw.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } }); + var desc = api.BodyDescriptor{ .entity = entity, .body_type = body_type, .shape = shape }; + desc.position = av3(centre[0], centre[1], centre[2]); + desc.is_trigger = true; + if (body_type == .dynamic) desc.mass = 1; + return .{ .entity = entity, .body = try pw.addBody(gpa, desc) }; +} + +test "a lone dynamic trigger is integrated, so it publishes its own pose" { + // THE CASE AN EXCLUSION BY NATURE GOT WRONG. §1.13.7 says a trigger has no manifold, no + // constraint and no impulse, and its island exclusion is the CONSTRAINT island only: the + // amended text grants it the INTEGRATION SINGLETON, without which it could never sleep and + // would be integrated forever. `integration.zig` filters on `body_type != .dynamic` and + // never on the trigger flag, so a dynamic trigger falls under gravity: its pose IS a fact + // the solver resolved. Excluded from publication it would drift away from its entity in + // silence, and its detection volume with it. + // + // The rule is exclusion by CONCURRENCY, not by nature: a trigger yields only when another + // body on the same entity publishes for it. Alone, it publishes per its type. + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var pw = PhysicsWorld.init(vr(0, gravity_y, 0), fixed_dt); + defer pw.deinit(gpa); + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + + const t = try spawnLoneTrigger(gpa, &ecs, &pw, .dynamic, .{ 0, 4, 0 }); + + var k: u32 = 0; + while (k < 20) : (k += 1) try frame(gpa, &pw, &ecs); + + const solver = pw.bm.position(t.body).?.toArray()[1]; + const published = ecs.get(Transform, t.entity).?.pos[1]; + try testing.expect(solver < 4.0 - 0.1); // NON-VACUITY: it really fell in the solver + try testing.expectEqual(@as(f32, @floatCast(solver)), published); +} + +test "a competing writer of Transform in fixed_update is refused at registration" { + // P1-2, and this is a FUTURE conflict written down rather than a defect here. + // `engine-coordinate-system.md` §4.3 runs `TransformSystem` at the head of `fixed_update` + // AND in `post_update`, writing `Transform` in both, while `scheduler.zig` makes two + // writers of one component in one phase a hard registration error with no declarative + // ordering to resolve it. `TransformSystem` exists NOWHERE in this repository — measured + // — so nothing is broken today. + // + // The conflict is DECLARATIVE and not physical: §4.3 has dynamic bodies carry no parent by + // convention, so the two systems write DISJOINT entity sets. The access model cannot + // express that disjunction, and that is the real gap. This test makes the wall a measured + // fact instead of an invisible incompatibility, and it will fail loudly on the day someone + // attempts the assembly rather than during the integration of `TransformSystem`. + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var sched = core.ecs.SystemScheduler.init(); + defer sched.deinit(gpa); + + try sync.registerSystems(gpa, &sched, &ecs); + + const Competitor = struct { + fn run(_: core.ecs.SystemContext) anyerror!void {} + }; + try testing.expectError(error.WriteWriteConflict, sched.registerSystem(gpa, &ecs, .{ + .phase = .fixed_update, + .name = "transform_system_stand_in", + .run = Competitor.run, + .accesses = &.{core.ecs.Writes(Transform)}, + })); + + // NON-VACUITY: the same registration in a phase forge does not write is accepted, so what + // the assertion above measures is the conflict and not a registration that always fails. + try sched.registerSystem(gpa, &ecs, .{ + .phase = .late_update, + .name = "transform_system_stand_in_elsewhere", + .run = Competitor.run, + .accesses = &.{core.ecs.Writes(Transform)}, + }); +} + +test "the registered system declares the solver resource it mutates through the pointer" { + // This test exists because its counter-factual first measured NOTHING: dropping the + // declaration left every test green, which is a declaration nobody checks — and writing it + // is what surfaced the dangling access slice. `ARCH-030` makes the declared set the TYPE + // of the view a system receives, so an undeclared mutation lets two physics modules sit in + // one phase with no edge between them. + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var sched = core.ecs.SystemScheduler.init(); + defer sched.deinit(gpa); + try sync.registerSystems(gpa, &sched, &ecs); + + const systems = sched.systemsInPhase(.fixed_update); + try testing.expectEqual(@as(usize, 1), systems.len); + var declared = false; + for (systems[0].accesses) |a| { + if (a.kind == .writes_resource and + std.mem.eql(u8, a.type_name, @typeName(sync.PhysicsWorldRef))) declared = true; + } + try testing.expect(declared); + + // And nothing is registered in `pre_update` any more: the inward direction left with + // `syncIn`. Asserted rather than assumed, so a re-registration there is visible. + try testing.expectEqual(@as(usize, 0), sched.systemsInPhase(.pre_update).len); +} + +/// Bind another body of a chosen role to an entity that already has one. +fn addSibling( + gpa: std.mem.Allocator, + pw: *PhysicsWorld, + entity: EntityId, + is_trigger: bool, + centre: [3]f32, +) !api.BodyId { + const shape = try pw.store.createShape(gpa, .{ .box = .{ .half_extents = av3(0.5, 0.5, 0.5) } }); + var desc = api.BodyDescriptor{ .entity = entity, .body_type = .dynamic, .shape = shape }; + desc.position = av3(centre[0], centre[1], centre[2]); + desc.is_trigger = is_trigger; + desc.mass = 1; + return try pw.addBody(gpa, desc); +} + +test "two triggers on one entity elect the smaller identity, not the last writer" { + // Exclusion by concurrency arbitrated solid-against-trigger and nothing else: two triggers + // do not exclude each other, so both wrote and the entity kept whichever came last in the + // registration list — silent last-write-wins, which §1.13.6 refuses. + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var pw = PhysicsWorld.initNoSleep(vr(0, 0, 0), fixed_dt); + defer pw.deinit(gpa); + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + + const entity = try ecs.spawn(gpa, .{ .pos = .{ 0, 0, 0 } }, .{}); + const first = try addSibling(gpa, &pw, entity, true, .{ 0, 1, 0 }); + const second = try addSibling(gpa, &pw, entity, true, .{ 0, 9, 0 }); + try testing.expect(first < second); // the premise the assertion below rests on + + var k: u32 = 0; + while (k < 5) : (k += 1) try frame(gpa, &pw, &ecs); + + // THE VALUE, not merely "one write happened": the published pose is the ELECTED body's, + // and the loser's differs by eight metres — so an election that picked the other one + // cannot satisfy this. + const published = ecs.get(Transform, entity).?.pos[1]; + try testing.expectEqual(@as(f32, @floatCast(pw.bm.position(first).?.toArray()[1])), published); + try testing.expect(@abs(pw.bm.position(second).?.toArray()[1] - published) > 7); +} + +test "two solid bodies on one entity elect the smaller identity" { + // The other half the concurrency rule never reached: two non-triggers never entered the + // arbitration at all, so both published every tick. + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var pw = PhysicsWorld.initNoSleep(vr(0, 0, 0), fixed_dt); + defer pw.deinit(gpa); + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + + const entity = try ecs.spawn(gpa, .{ .pos = .{ 0, 0, 0 } }, .{}); + const first = try addSibling(gpa, &pw, entity, false, .{ 0, 2, 0 }); + const second = try addSibling(gpa, &pw, entity, false, .{ 0, 11, 0 }); + try testing.expect(first < second); + + var k: u32 = 0; + while (k < 5) : (k += 1) try frame(gpa, &pw, &ecs); + + const published = ecs.get(Transform, entity).?.pos[1]; + try testing.expectEqual(@as(f32, @floatCast(pw.bm.position(first).?.toArray()[1])), published); + try testing.expect(@abs(pw.bm.position(second).?.toArray()[1] - published) > 7); +} + +test "a solid body wins over a trigger of SMALLER identity" { + // NON-VACUITY for the criterion's SECOND level, and it exists because the counter-factual + // that removes that level measured NOTHING against the tests above: in each of them the + // solid happens to be created first, so identity alone already elects it. Here the trigger + // is created FIRST and therefore has the smaller handle — only the solid-before-trigger + // level can still elect the solid. + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var pw = PhysicsWorld.initNoSleep(vr(0, 0, 0), fixed_dt); + defer pw.deinit(gpa); + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + + const entity = try ecs.spawn(gpa, .{ .pos = .{ 0, 0, 0 } }, .{}); + const trigger = try addSibling(gpa, &pw, entity, true, .{ 0, 9, 0 }); + const solid = try addSibling(gpa, &pw, entity, false, .{ 0, 1, 0 }); + try testing.expect(trigger < solid); // the premise: the loser has the SMALLER identity + + var k: u32 = 0; + while (k < 5) : (k += 1) try frame(gpa, &pw, &ecs); + + const published = ecs.get(Transform, entity).?.pos[1]; + try testing.expectEqual(@as(f32, @floatCast(pw.bm.position(solid).?.toArray()[1])), published); + try testing.expect(@abs(pw.bm.position(trigger).?.toArray()[1] - published) > 7); +} + +test "a competing writer of Sleeping in fixed_update is refused at registration" { + // The twin of the `Transform` conflict test. The publication adds and removes the marker, + // so a second writer of it in the same phase must be refused — without the declaration it + // would pass the preflight, and `ARCH-030` will make the declared set the TYPE of the view + // a system receives at M1.A. + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + var sched = core.ecs.SystemScheduler.init(); + defer sched.deinit(gpa); + try sync.registerSystems(gpa, &sched, &ecs); + + const Competitor = struct { + fn run(_: core.ecs.SystemContext) anyerror!void {} + }; + try testing.expectError(error.WriteWriteConflict, sched.registerSystem(gpa, &ecs, .{ + .phase = .fixed_update, + .name = "sleeping_writer_stand_in", + .run = Competitor.run, + .accesses = &.{core.ecs.Writes(Sleeping)}, + })); + + // NON-VACUITY: the same writer in a phase forge does not write is accepted, so what the + // assertion above measures is the conflict and not a registration that always fails. + try sched.registerSystem(gpa, &ecs, .{ + .phase = .late_update, + .name = "sleeping_writer_elsewhere", + .run = Competitor.run, + .accesses = &.{core.ecs.Writes(Sleeping)}, + }); +} + +test "no component write reaches the solver: the inward direction is M1.1.26's" { + // THE ABSENCE, pinned ONCE and by name. Several tests used to carry an assertion of this + // shape as a second half — "the solver did not follow the ECS write" — and after the + // re-scope each held for every body and discriminated nothing, which is a test counted and + // half empty. + // + // Driven through `registerSystems` and `dispatchFrame`, which is the PRODUCTION path: the + // first version went through `stepAndPublish`, so an inward read reintroduced inside + // `stepAndPublishSystem` would have left it green. And every field the departed `syncIn` + // acted on is written here — position AND rotation, linear AND angular — because the name + // says "no component write" and a body that walked only two of the four would claim a + // wider set than it visits. + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var ecs = World.init(); + defer ecs.deinit(gpa); + var pw = PhysicsWorld.initNoSleep(vr(0, 0, 0), fixed_dt); + defer pw.deinit(gpa); + + const dyn = try spawnLinked(gpa, &ecs, &pw, .dynamic, .{ 0.5, 0.5, 0.5 }, .{ 0, 2, 0 }); + const kin = try spawnLinked(gpa, &ecs, &pw, .kinematic, .{ 0.5, 0.5, 0.5 }, .{ 20, 0, 0 }); + + var jobs = try core.jobs.scheduler.Scheduler.init(gpa, io); + try jobs.start(); + defer jobs.deinit(gpa); + var sched = core.ecs.SystemScheduler.init(); + defer sched.deinit(gpa); + + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + try sync.registerSystems(gpa, &sched, &ecs); + + const dyn_pose = pw.bm.position(dyn.body).?.toArray(); + const kin_pose = pw.bm.position(kin.body).?.toArray(); + const dyn_rot = pw.bm.rotation(dyn.body).?; + const kin_rot = pw.bm.rotation(kin.body).?; + + // EVERY field the inward direction acted on, on both simulated kinds. + for ([_]EntityId{ dyn.entity, kin.entity }) |e| { + const t = ecs.getMut(Transform, e).?; + t.pos = .{ 90, 91, 92 }; + t.rot = .{ 0.5, 0.5, 0.5, 0.5 }; // a unit quaternion, and not the identity + const v = ecs.getMut(Velocity, e).?; + v.linear = .{ 5, 0, 0 }; + v.angular = .{ 0, 3, 0 }; + } + + var f: u32 = 0; + while (f < 5) : (f += 1) try sched.dispatchFrame(&ecs, gpa, io, &jobs, fixed_dt_f32, null); + + // ZERO gravity and no contact, so the solver has no reason of its own to move either body: + // any movement here is a component write that got through. + try testing.expectEqual(kin_pose[0], pw.bm.position(kin.body).?.toArray()[0]); + try testing.expectEqual(kin_pose[1], pw.bm.position(kin.body).?.toArray()[1]); + try testing.expectEqual(dyn_pose[0], pw.bm.position(dyn.body).?.toArray()[0]); + try testing.expectEqual(dyn_pose[1], pw.bm.position(dyn.body).?.toArray()[1]); + try testing.expectEqual(dyn_rot.w, pw.bm.rotation(dyn.body).?.w); + try testing.expectEqual(kin_rot.w, pw.bm.rotation(kin.body).?.w); + try testing.expectEqual(@as(Real, 0), pw.bm.linearVelocity(dyn.body).?.toArray()[0]); + try testing.expectEqual(@as(Real, 0), pw.bm.linearVelocity(kin.body).?.toArray()[0]); + try testing.expectEqual(@as(Real, 0), pw.bm.angularVelocity(dyn.body).?.toArray()[1]); + try testing.expectEqual(@as(Real, 0), pw.bm.angularVelocity(kin.body).?.toArray()[1]); + + // NON-VACUITY: the API path DOES move the solver on this very scene, so the assertions + // above are about the component path and not about a world where nothing ever moves. + pw.setLinearVelocity(dyn.body, vr(5, 0, 0)); + try sched.dispatchFrame(&ecs, gpa, io, &jobs, fixed_dt_f32, null); + try testing.expect(pw.bm.position(dyn.body).?.toArray()[0] > dyn_pose[0]); +} + +test "the published handle is withdrawn before the world it names is destroyed" { + // P1-1, and the sequence is the whole test. `publishPhysicsWorld` writes raw pointers, + // `PhysicsWorld.deinit` frees and poisons, and nothing used to clear the resource: + // the accessor kept answering with a dead address and the next dispatch dereferenced it. + // The ordinary runtime order being safe is not a contract. + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var ecs = World.init(); + defer ecs.deinit(gpa); + var jobs = try core.jobs.scheduler.Scheduler.init(gpa, io); + try jobs.start(); + defer jobs.deinit(gpa); + var sched = core.ecs.SystemScheduler.init(); + defer sched.deinit(gpa); + + { + var pw = PhysicsWorld.initNoSleep(vr(0, gravity_y, 0), fixed_dt); + _ = try spawnLinked(gpa, &ecs, &pw, .dynamic, .{ 0.5, 0.5, 0.5 }, .{ 0, 4, 0 }); + try sync.publishPhysicsWorld(gpa, &ecs, &pw); + defer sync.unpublishPhysicsWorld(&ecs, &pw); + try sync.registerSystems(gpa, &sched, &ecs); + try testing.expect(sync.publishedPhysicsWorld(&ecs) != null); + + try sched.dispatchFrame(&ecs, gpa, io, &jobs, fixed_dt_f32, null); + + // WITHDRAWN BEFORE DESTROYED, and asserted in that order: after this line nothing can + // reach the world, which is what makes the destruction below safe. + sync.unpublishPhysicsWorld(&ecs, &pw); + try testing.expect(sync.publishedPhysicsWorld(&ecs) == null); + pw.deinit(gpa); + } + + // And a frame dispatched afterwards is a no-op rather than a dereference of freed memory. + // Under the defect this is where the world would be read back. + try sched.dispatchFrame(&ecs, gpa, io, &jobs, fixed_dt_f32, null); + try testing.expect(sync.publishedPhysicsWorld(&ecs) == null); +} + +test "a late withdrawal cannot erase the world published after it" { + // THE SEQUENCE: publish A, publish B, then run A's deferred teardown. A withdrawal that + // cleared whatever it found would erase B, and every frame afterwards would be a silent + // no-op — the owner of B having done nothing wrong and nobody having withdrawn it. + // + // The assertion is on the IDENTITY of the resolved handle and never on a boolean: a + // withdrawal that had erased B and left something else resolvable would satisfy + // "something is published" and fail only here. + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + + var a = PhysicsWorld.initNoSleep(vr(0, 0, 0), fixed_dt); + defer a.deinit(gpa); + var b = PhysicsWorld.initNoSleep(vr(0, 0, 0), fixed_dt); + defer b.deinit(gpa); + + try sync.publishPhysicsWorld(gpa, &ecs, &a); + try testing.expectEqual(@as(?*PhysicsWorld, &a), sync.publishedPhysicsWorld(&ecs)); + + // A withdraws itself, then B takes the slot — the ordered form the contract asks for. + sync.unpublishPhysicsWorld(&ecs, &a); + try sync.publishPhysicsWorld(gpa, &ecs, &b); + try testing.expectEqual(@as(?*PhysicsWorld, &b), sync.publishedPhysicsWorld(&ecs)); + + // A's LATE teardown, however far behind it runs. It must find someone else's handle and + // leave it alone. + sync.unpublishPhysicsWorld(&ecs, &a); + try testing.expectEqual(@as(?*PhysicsWorld, &b), sync.publishedPhysicsWorld(&ecs)); + + // And B's own withdrawal still works, so the guard refuses the wrong caller and not every + // caller — without this the test would pass against a withdrawal that never clears. + sync.unpublishPhysicsWorld(&ecs, &b); + try testing.expectEqual(@as(?*PhysicsWorld, null), sync.publishedPhysicsWorld(&ecs)); +} + +test "publishing over a live publication is refused, and leaves the first in place" { + // The twin: a silent replacement is the exact counterpart of a blind withdrawal — it makes + // A disappear without anyone having withdrawn it. Refused, not replaced. + const gpa = testing.allocator; + var ecs = World.init(); + defer ecs.deinit(gpa); + + var a = PhysicsWorld.initNoSleep(vr(0, 0, 0), fixed_dt); + defer a.deinit(gpa); + var b = PhysicsWorld.initNoSleep(vr(0, 0, 0), fixed_dt); + defer b.deinit(gpa); + + try sync.publishPhysicsWorld(gpa, &ecs, &a); + defer sync.unpublishPhysicsWorld(&ecs, &a); + + try testing.expectError( + error.PhysicsWorldAlreadyPublished, + sync.publishPhysicsWorld(gpa, &ecs, &b), + ); + // IDENTITY again: a refusal that had still written B would leave a boolean green. + try testing.expectEqual(@as(?*PhysicsWorld, &a), sync.publishedPhysicsWorld(&ecs)); + + // And the ordered form is accepted, so the refusal is about a LIVE publication and not + // about publishing twice ever. + sync.unpublishPhysicsWorld(&ecs, &a); + try sync.publishPhysicsWorld(gpa, &ecs, &b); + try testing.expectEqual(@as(?*PhysicsWorld, &b), sync.publishedPhysicsWorld(&ecs)); + sync.unpublishPhysicsWorld(&ecs, &b); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 24963be8..e6b2de8f 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -246,17 +246,81 @@ pub const uncollected = [_]Uncollected{ /// above — `shm_posix.zig` and `transport_posix.zig`. That is arithmetic on this /// same table and not a second measurement, which is why the CI layer matters. pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { - // Reconciled against `zig build test --summary all`, which reported 1869 - // collected — NOT bumped to match the closure's own arithmetic, which is the - // repair the failure message forbids. Windows is two lower, the two - // `only_on = .windows` entries above. + // Reconciled against `zig build test --summary all`, which reported **1875 + // collected on macOS** (1856 passed + 19 skipped) at M1.1.15 — NOT bumped to + // match the closure's own arithmetic, which is the repair the failure message + // forbids. The two numbers agree here, and they were produced independently: + // 1875 is what the suite ran, and the closure separately arrives at 1875 from + // this table. Windows is two lower, the two `only_on = .windows` entries above. // - // This control has now stopped two commits in a row on its first real uses, each - // time on a genuine test addition, and each time the number was re-derived from - // the suite rather than from the closure. That is the whole point of it. + // This control has now stopped three commits in a row on its first real uses, + // each time on a genuine test addition, and each time the number was re-derived + // from the suite rather than from the closure. That is the whole point of it. + // M1.1.15 bumps this twice, once per gate that adds blocks, and each time the number + // is re-derived from the SUITE: gate A added six blocks in + // `forge_3d/tests/world_test.zig` (1869 → 1875, suite reported 1875), gate B added + // three more to the same file (1875 → 1878, suite reported 1878 — 1859 passed + 19 + // skipped, macOS aarch64). The CI layer confronts both branches on the matrix + // platforms themselves: measured 1875 on `ubuntu-24.04` and 1873 on `windows-2025` + // at gate A, through `-Dexpect-collected` fed by each cell's own total. + // Gate C added six more to `world_test.zig` (1878 → 1884, suite reported 1884 — + // 1865 passed + 19 skipped, macOS aarch64). + // `moveKinematic` added one more at the gate C round-trip (1884 → 1885, suite + // reported 1885 — 1866 passed + 19 skipped, macOS aarch64). + // Gate D added two to `forge/api/components.zig` with the `Sleeping` marker + // (1885 → 1887, suite reported 1887 — 1868 passed + 19 skipped, macOS aarch64). + // Gate D added six in `tests/physics/transform_sync_test.zig` (1887 → 1893, suite + // reported 1893 — 1874 passed + 19 skipped, macOS aarch64). + // F-D1 added one signal test to `tests/physics/transform_sync_test.zig` (1893 → 1894, + // suite reported 1894 — 1875 passed + 19 skipped, macOS aarch64). + // Gate E added three to `forge/api/precision.zig` (1894 → 1897, suite reported 1897 — + // 1878 passed + 19 skipped, macOS aarch64) and seven to the new + // `weld_lint/rules/no_precision_crossing.zig` (1897 → 1904, suite reported 1904 — + // 1885 passed + 19 skipped, macOS aarch64). + // Gate E added two to the new `src/interfaces/PhysicsModule.zig` (1904 → 1906, suite + // reported 1906 — 1887 passed + 19 skipped, macOS aarch64). + // The F-F1/F-F2 closing pass on `no_precision_crossing` added three (1906 → 1909, suite + // reported 1909 — 1890 passed + 19 skipped, macOS aarch64). + // The closing NO-GO pass added eight: three transactionality tests plus a W4 test in + // `world_test.zig`, a presence test and the registration test in `transform_sync_test.zig`, + // and two Windows-spelling tests on the lint rule (1909 → 1917, suite reported 1917 — + // 1898 passed + 19 skipped, macOS aarch64). + // The NO-GO pass merged the tick and the publication and added the C1.1 gameplay-write + // guard (1918 -> 1919, suite reported 1919 - 1900 passed + 19 skipped, macOS aarch64). + // The NO-GO pass merged the tick and the publication, then added the C1.1 gameplay-write + // guard, the trigger pair, the pre-publication dispatch and the double registration + // (1918 -> 1922, suite reported 1922 - 1903 passed + 19 skipped, macOS aarch64). + // The third NO-GO pass added the two lone-trigger cases, the write-conflict guard and the + // isolated resolver test (1922 -> 1926, suite reported 1926 - 1907 passed + 19 skipped, + // macOS aarch64). + // The third NO-GO pass added the two lone-trigger cases, the write-conflict guard, the + // isolated resolver test and the declared-access guard (1922 -> 1927, suite reported 1927 + // - 1908 passed + 19 skipped, macOS aarch64). + // The fourth NO-GO pass added the two election tests, the update-issued moveKinematic + // guard and three on the linter's path coverage, and wired `weld_lint/main.zig` into the + // closure (1927 -> 1933, suite reported 1933 - 1914 passed + 19 skipped, macOS aarch64). + // The fifth pass RE-SCOPED `syncIn` out of the milestone: four reception tests removed, + // two reduced to their publication half, and the linter's path coverage re-tested + // (1933 -> 1929, suite reported 1929 - 1910 passed + 19 skipped, macOS aarch64). + // The fifth pass RE-SCOPED `syncIn` out of the milestone: four reception tests removed, two + // reduced to their publication half, the linter's path coverage re-tested, and one added + // for the election criterion's second level (1933 -> 1930, suite reported 1930 - 1911 + // passed + 19 skipped, macOS aarch64). + // The sixth pass replaced the lint completeness machinery with visited declarations: the + // three path-coverage tests go, one four-combination stale test arrives (1930 -> 1927, + // suite reported 1927 - 1908 passed + 19 skipped, macOS aarch64). + // The seventh pass added the `Sleeping` conflict twin and the presence-removal guard + // (1927 -> 1929, suite reported 1929 - 1910 passed + 19 skipped, macOS aarch64). + // The eighth pass moved the presence-removal guard into `world_test.zig` with its wake + // half, and pinned the absent inward direction as one named test (1929 -> 1930, suite + // reported 1930 - 1911 passed + 19 skipped, macOS aarch64). + // The ninth pass added the publish/withdraw lifetime guard (1930 -> 1931, suite reported + // 1931 - 1912 passed + 19 skipped, macOS aarch64). + // The tenth pass closed the publication lifetime from both sides (1931 -> 1933, suite + // reported 1933 - 1914 passed + 19 skipped, macOS aarch64). return switch (os) { - .windows => 1867, - else => 1869, + .windows => 1931, + else => 1933, }; } diff --git a/tools/weld_lint/main.zig b/tools/weld_lint/main.zig index 82395aab..100420a1 100644 --- a/tools/weld_lint/main.zig +++ b/tools/weld_lint/main.zig @@ -22,6 +22,7 @@ const c_module_isolation = @import("rules/c_module_isolation.zig"); const conventional_commit = @import("rules/conventional_commit.zig"); const no_device_dispatch_outside_gal = @import("rules/no_device_dispatch_outside_gal.zig"); const no_float_reduce = @import("rules/no_float_reduce.zig"); +const no_precision_crossing = @import("rules/no_precision_crossing.zig"); const dead_tests = @import("dead_tests.zig"); const default_lint_paths = [_][]const u8{ "src", "bench", "tests", "tools" }; @@ -70,6 +71,11 @@ fn runLint(arena: std.mem.Allocator, io: std.Io, paths: []const [:0]const u8, ou var diags: std.ArrayList(diag.Diagnostic) = .empty; defer diags.deinit(arena); + // Owned here rather than inside the rule: the aggregate half of + // `no_precision_crossing`'s bilateral control needs state across files, and module-level + // state would survive between runs and contaminate that rule's own unit tests. + var crossing_tally: no_precision_crossing.Tally = .{}; + for (files.items) |file| { const source = scan.readSourceZ(arena, io, file) catch |err| { try out.print("warn: cannot read {s}: {t}\n", .{ file, err }); @@ -81,8 +87,13 @@ fn runLint(arena: std.mem.Allocator, io: std.Io, paths: []const [:0]const u8, ou try c_module_isolation.check(arena, file, source, &diags); try no_device_dispatch_outside_gal.check(arena, file, source, &diags); try no_float_reduce.check(arena, file, source, &diags); + try no_precision_crossing.check(arena, file, source, &diags, &crossing_tally); } + // The second half of the bilateral control. It needs no notion of a "full scan": it + // follows the files this invocation actually READ, so a partial list simply says less. + try no_precision_crossing.checkDeclarations(arena, io, &crossing_tally, &diags); + std.mem.sort(diag.Diagnostic, diags.items, {}, diag.Diagnostic.lessThan); for (diags.items) |d| { try out.print("{s}:{d}:{d}: {s}: {s}\n", .{ d.file, d.line, d.col, d.rule, d.message }); @@ -319,7 +330,7 @@ const usage_text = \\ Walk the given paths (default `src bench tests tools`) and \\ apply rules: no_cimport, no_usingnamespace, doc_comments, \\ c_module_isolation, no_device_dispatch_outside_gal, - \\ no_float_reduce. Exits 0 + \\ no_float_reduce, no_precision_crossing. Exits 0 \\ if clean, 1 if any rule fires. \\ \\ weld_lint dead-tests [--list] [--per-root] diff --git a/tools/weld_lint/rules/no_precision_crossing.zig b/tools/weld_lint/rules/no_precision_crossing.zig new file mode 100644 index 00000000..bab6fef6 --- /dev/null +++ b/tools/weld_lint/rules/no_precision_crossing.zig @@ -0,0 +1,590 @@ +//! Rule `no_precision_crossing` — a float may be narrowed at the precision boundary and +//! nowhere else in the perimeter this rule governs. +//! +//! `engine-physics-queries.md` §1.11.8 states that the world/solver precision boundary is +//! unique and is crossed by ONE named conversion point, and by it alone. Before M1.1.15 the +//! forge module carried four private helpers of identical semantics under two names — +//! `widen`, `convVec3` twice, `convQuat` — and two of the three vector copies had already +//! diverged: one short-circuited when the two scalars coincided and the others did not. They +//! now all route through `forge/api/precision.zig`. +//! +//! **WHY THE RULE EXISTS AT ALL, rather than the unification alone.** The four sites were +//! collapsed in one pass and nothing would stop the fifth. That is the same argument +//! `no_float_reduce` makes about its thirteenth site, and it is the reason a rule written +//! down without a check is an intention. +//! +//! **WHAT IS FLAGGED, and the asymmetry is the whole design.** `@floatCast` — the narrowing +//! direction, solver → world. The widening direction has NO token to flag: `f32` coerces to +//! `f64` implicitly, so a tokenizer sees nothing. What guards that half is the TYPE SYSTEM +//! under `-Dphysics_f64`: `Vec(3, f32)` and `Vec(3, f64)` are distinct struct types, so an +//! unrouted aggregate crossing does not compile at all on the six `f64` cells of the CI +//! matrix. The two halves together are the verification; neither alone is. +//! +//! At the default precision the world and solver scalars coincide, so the type system +//! proves nothing there — which is precisely why the `f64` leg is a matrix axis and not an +//! occasional local run. +//! +//! **THE PERIMETER, and why it is two directories and not one.** The diagnostic this rule +//! emits makes a statement about the ENGINE — that §1.11.8's boundary is unique — so a +//! control that looked at one module would be claiming more than it checks. §1.11.8 places +//! the boundary AT THE INTERFACE TIER, and `src/interfaces/` is where M1.1.26 writes the +//! adapter, which is conversion code by definition. It is governed here from the day the +//! directory exists rather than the day the freeze discovers the gap. The crossing POINT +//! lives in `forge/api/` for a dependency reason — `forge_3d` cannot import the interface +//! tier without inverting — and that reason says nothing about where the guard must look. +//! +//! Everything else in `src/` is out: `core`, `etch`, `foundation` and `asset_pipeline` carry +//! narrowings of their own that this milestone made no claim about, and widening the rule to +//! them would be a different rule with a different argument. +//! +//! **TEST FILES ARE OUT, and the residual is named.** A deliberate exception to +//! `no_float_reduce`'s own argument against path allowlists. The reason they differ: a float +//! reduction in a bench CORRUPTS the measurement, so exempting a file there would hide a +//! defect; a `@floatCast` in a test is the assertion's own arithmetic — a test comparing a +//! solver value against a published `Transform` must narrow one of them to compare them at +//! all, and it is measuring the boundary rather than breaching it. Twenty-one such sites +//! exist across seven files. The residual: a production-grade helper written inside a test +//! file escapes this rule. +//! +//! **THE ESCAPE IS DECLARED, NEVER SILENT.** `WELD_NOT_A_WORLD_CROSSING` on the site's own +//! line exempts that site — but only if the FILE also appears in `declared_escapes` below, +//! with a reason and an owner. A marker alone silences nothing. Without that pairing a guard +//! converges towards zero coverage through additions each of which is locally justified and +//! collectively invisible, which is precisely the failure `dead_tests` answers, in this same +//! binary, with a declared exclusion list and a bilateral control. +//! +//! The control is bilateral in the same sense: an escape used without a declaration is +//! reported at its site, and a declaration whose file was READ and carried no marker is +//! reported as stale. The list is EMPTY today — measured, not assumed: zero `@floatCast` +//! survives in the perimeter and the marker occurs nowhere in the tree outside this file's own +//! test sources. Zero is the cheapest moment in the project's life to install the mechanism, +//! and both directions are exercised by this file's tests rather than by the tree's silence. +//! +//! **THE STALE HALF FOLLOWS VISITED FILES, and that is what makes it correct under a partial +//! scan.** `lint` also accepts an explicit path list — the `pre-commit` hook passes staged +//! files — and a declaration whose file was not read says NOTHING: it is neither used nor +//! stale, because nobody looked. Two earlier forms tried to establish COMPLETENESS of the scan +//! instead, first from an empty argument list and then from a set of root names, and each +//! traded one wrong verdict for another; the second was defended with a claim that +//! canonicalising the paths was impossible at Zig 0.16, which was FALSE — +//! `std.process.currentPathAlloc` and `std.Io.Dir.realPathFileAbsoluteAlloc` both exist. The +//! question does not need asking: a per-file fact answers it with neither a false positive nor +//! a false negative, whatever the caller's spelling. +//! +//! The one residual that reasoning leaves is a declaration whose file has been DELETED — never +//! visited, hence never stale, hence immortal. Closed by testing that the declared path +//! exists. +const std = @import("std"); +const diag = @import("../diagnostic.zig"); + +const name = "no_precision_crossing"; + +/// A directory this rule speaks about, in both separator spellings — the runner may pass +/// absolute or relative paths (`no_device_dispatch_outside_gal` precedent). +const GovernedPrefix = struct { + posix: []const u8, + win: []const u8, + /// Why this directory is in the perimeter, so a future reader can judge an addition. + why: []const u8, +}; + +/// The perimeter. Adding an entry is a claim that the boundary's uniqueness is checked +/// there, so each carries its reason. +const governed = [_]GovernedPrefix{ + .{ + .posix = "src/modules/forge/", + .win = "src\\modules\\forge\\", + .why = "the module that owns the crossing and held all four of the helpers it replaced", + }, + .{ + .posix = "src/interfaces/", + .win = "src\\interfaces\\", + .why = "where `engine-physics-queries.md` §1.11.8 places the boundary, and where M1.1.26 " ++ + "writes the adapter — conversion code by definition", + }, +}; + +/// The one file allowed to narrow: the boundary itself. +const crossing_posix = "forge/api/precision.zig"; +const crossing_win = "forge\\api\\precision.zig"; + +/// The per-site claim that a narrowing is not a world ↔ solver crossing. It only takes +/// effect in a file listed in `declared_escapes`. +const not_a_crossing_marker = "WELD_NOT_A_WORLD_CROSSING"; + +/// A site that deliberately narrows without crossing the world boundary, declared with its +/// reason and the milestone that owns it. +/// +/// DECLARED, never silent — the distinction `dead_tests.Exclusion` draws between a known +/// debt and a hidden one, applied to the exemption of a guard rather than to a subtree. +pub const DeclaredEscape = struct { + /// Path suffix identifying the file, in POSIX spelling. + file: []const u8, + reason: []const u8, + owner: []const u8, +}; + +/// The tree's declared escapes. EMPTY, and that emptiness is the current state of the +/// perimeter rather than an unfinished list. +pub const declared_escapes = [_]DeclaredEscape{}; + +/// What the per-file pass observed, owned by the caller so no state survives between runs +/// and so the unit tests below cannot contaminate one another. +pub const Tally = struct { + /// Whether the declaration's file was READ during this scan. A file nobody looked at + /// cannot make its declaration stale. + visited: [declared_escapes.len]bool = @splat(false), + /// How many marked narrowings the declaration exempted. + marked: [declared_escapes.len]u32 = @splat(0), +}; + +/// Hook called by `main.runLint` once per `.zig` file. +/// +/// Tokenizes and flags every `@floatCast` builtin in a governed production file. Tokenizing +/// rather than substring matching is what keeps the rule off its own prose: this file names +/// `@floatCast` in the doc comment above, and a doc comment is one token. +pub fn check( + arena: std.mem.Allocator, + file: []const u8, + source: [:0]const u8, + out: *std.ArrayList(diag.Diagnostic), + tally: *Tally, +) !void { + // The visit is recorded BEFORE the perimeter test, deliberately: a declaration pointing at + // a file this rule does not govern would otherwise never be visited, never be stale, and + // sit there for good. Recorded, it is read and unmarked, hence reported. + if (declaredIndexIn(&declared_escapes, file)) |i| { + const seen: []bool = &tally.visited; + seen[i] = true; + } + if (!governs(file)) return; + + var tokenizer = std.zig.Tokenizer.init(source); + while (true) { + const tok = tokenizer.next(); + if (tok.tag == .eof) break; + if (tok.tag != .builtin) continue; + if (!std.mem.eql(u8, source[tok.loc.start..tok.loc.end], "@floatCast")) continue; + + const pos = diag.lineColFromOffset(source, tok.loc.start); + + if (hasMarkerOnItsLine(source, tok.loc.start)) { + if (declaredIndexFor(file)) |i| { + // Through a slice, not the array: `declared_escapes` is empty today, and + // indexing a zero-length ARRAY is a compile error even on a branch that + // cannot be reached. A slice moves the bound to run time, so the mechanism + // compiles at a count of zero — which is the count it must be installed at. + const hits: []u32 = &tally.marked; + hits[i] += 1; + continue; + } + // The marker is present and the file is not declared: the site is NOT exempt, + // and saying so here rather than silently flagging it as an ordinary crossing is + // what tells the author which of the two things to fix. + try out.append(arena, .{ + .file = file, + .line = pos.line, + .col = pos.col, + .rule = name, + .message = "a `WELD_NOT_A_WORLD_CROSSING` marker exempts nothing on its own — add this file to `declared_escapes` in `tools/weld_lint/rules/no_precision_crossing.zig`, with a reason and an owning milestone, or route the narrowing through `forge/api/precision.zig`", + }); + continue; + } + + try out.append(arena, .{ + .file = file, + .line = pos.line, + .col = pos.col, + .rule = name, + .message = "narrowing a float here spells a second precision boundary, which `engine-physics-queries.md` §1.11.8 makes unique — convert through `forge/api/precision.zig` (`cross.vec3ToWorld` / `cross.quatToWorld`), or declare a non-world narrowing with a `WELD_NOT_A_WORLD_CROSSING` comment on this line AND an entry in `declared_escapes`", + }); + } +} + +/// The second half of the bilateral control, called once after the scan. +/// +/// A declaration is stale when its file was READ and carried no marker, or when the file does +/// not exist at all. A file the scan never opened says nothing either way. +pub fn checkDeclarations( + arena: std.mem.Allocator, + io: std.Io, + tally: *const Tally, + out: *std.ArrayList(diag.Diagnostic), +) !void { + try reportStale(arena, io, &declared_escapes, &tally.visited, &tally.marked, out); +} + +/// The body of the control, over an explicit list. Split out for ONE reason: the tree's list +/// is empty, so a test driving `checkDeclarations` alone would pass against a function that +/// returned immediately. The tests below drive THIS — the shipped code — over a non-empty +/// fixture, instead of a mirror of it written in the test, which is a motif this repository +/// has already paid for once. +/// +/// `checkDeclarations` is the only binding to the real list, so no caller can point the +/// tree's control at a fixture by accident. +fn reportStale( + arena: std.mem.Allocator, + io: std.Io, + declared: []const DeclaredEscape, + visited: []const bool, + marked: []const u32, + out: *std.ArrayList(diag.Diagnostic), +) !void { + std.debug.assert(declared.len == visited.len and declared.len == marked.len); + for (declared, visited, marked) |e, was_read, hits| { + // THE DELETED-FILE RESIDUAL, and it is the reason this needs `io` at all. A declaration + // whose file is gone is never visited, hence never stale by the rule above, hence + // immortal. The path is repo-relative and the linter runs from the repository root — + // the same assumption `scan.zig` makes with `Dir.cwd()`. + const exists = blk: { + _ = std.Io.Dir.cwd().statFile(io, e.file, .{}) catch break :blk false; + break :blk true; + }; + if (!exists) { + try out.append(arena, .{ + .file = e.file, + .line = 1, + .col = 1, + .rule = name, + .message = "this `declared_escapes` entry names a file that does not exist — the declaration outlived what it exempted and should be removed", + }); + continue; + } + // NOT VISITED SAYS NOTHING. `lint` accepts a partial file list, so a declaration whose + // file this scan never opened is neither used nor stale: reporting it would be a guard + // failing on correct input, and the fix a reader would reach for is deleting a + // declaration that was doing its job. + if (!was_read) continue; + if (hits != 0) continue; + try out.append(arena, .{ + .file = e.file, + .line = 1, + .col = 1, + .rule = name, + .message = "this `declared_escapes` entry exempts nothing — its file was read and carried no marked narrowing, so the declaration is stale and should be removed", + }); + } +} + +/// Whether this rule speaks about `file`: inside the perimeter, in production, and not the +/// boundary file itself. +fn governs(file: []const u8) bool { + var inside = false; + for (governed) |g| { + if (std.mem.indexOf(u8, file, g.posix) != null or + std.mem.indexOf(u8, file, g.win) != null) + { + inside = true; + break; + } + } + if (!inside) return false; + + if (std.mem.indexOf(u8, file, crossing_posix) != null) return false; + if (std.mem.indexOf(u8, file, crossing_win) != null) return false; + + return !isTest(file); +} + +/// The index of `file` in `declared_escapes`, or null. Matched on a path SUFFIX so an +/// absolute and a relative spelling of the same file agree, and SEPARATOR-INSENSITIVELY so a +/// POSIX-spelled declaration matches the `\\`-spelled path the runner passes on Windows. +/// +/// The first version compared with `std.mem.endsWith` against a POSIX-spelled entry, which on +/// Windows can never match: the escape would silently stop exempting, the site would be +/// reported as an undeclared marker, and the tree would go red on one platform only. The +/// awareness already existed one function below — `isTest` handles `\\tests\\` beside +/// `/tests/` — and this one had lost it. +fn declaredIndexFor(file: []const u8) ?usize { + return declaredIndexIn(&declared_escapes, file); +} + +/// The lookup itself, over an explicit list. Split for the same reason as `reportStale`: the +/// tree's list is EMPTY, so this function never iterates and a test driving `declaredIndexFor` +/// could not tell a separator-aware match from a POSIX-only one. Measured — the first +/// counter-factual written for the separator fix reverted the comparison here and failed no +/// test at all, because the only test on it drove `endsWithPath` directly and proved the +/// helper correct without proving the call site used it. +fn declaredIndexIn(declared: []const DeclaredEscape, file: []const u8) ?usize { + for (declared, 0..) |e, i| { + if (endsWithPath(file, e.file)) return i; + } + return null; +} + +/// Whether `path` ends with `suffix`, treating `/` and `\\` as the same separator. Compared +/// from the end so a longer absolute path matches a relative declaration. +fn endsWithPath(path: []const u8, suffix: []const u8) bool { + if (suffix.len > path.len) return false; + const tail = path[path.len - suffix.len ..]; + for (tail, suffix) |a, b| { + if (a == b) continue; + if (isSep(a) and isSep(b)) continue; + return false; + } + return true; +} + +fn isSep(c: u8) bool { + return c == '/' or c == '\\'; +} + +/// Whether `file` is a test file — a `/tests/` directory component, or a `_test.zig` name. +/// Both forms occur in the tree and neither implies the other. +fn isTest(file: []const u8) bool { + if (std.mem.indexOf(u8, file, "/tests/") != null) return true; + if (std.mem.indexOf(u8, file, "\\tests\\") != null) return true; + return std.mem.endsWith(u8, file, "_test.zig"); +} + +/// Whether the site at `offset` carries the escape marker on ITS OWN line. The line above is +/// deliberately not consulted: a shorter reach cannot leak a claim onto a neighbouring +/// statement, which is a failure `no_float_reduce` had to fix after the fact. +fn hasMarkerOnItsLine(source: []const u8, offset: usize) bool { + const line_start = if (std.mem.lastIndexOfScalar(u8, source[0..offset], '\n')) |i| i + 1 else 0; + const line_end = std.mem.indexOfScalarPos(u8, source, offset, '\n') orelse source.len; + return std.mem.indexOf(u8, source[line_start..line_end], not_a_crossing_marker) != null; +} + +// --- tests ------------------------------------------------------------------- + +/// Runs the rule over `source` as if it were `path`, and returns how many diagnostics it +/// produced. The path is a parameter because this rule is scoped by path, and a helper that +/// hard-coded one would leave the scoping itself unmeasured. +fn countOn(path: []const u8, source: [:0]const u8) !usize { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var diags: std.ArrayList(diag.Diagnostic) = .empty; + var tally: Tally = .{}; + try check(arena_state.allocator(), path, source, &diags, &tally); + defer diags.deinit(arena_state.allocator()); + return diags.items.len; +} + +const forge_prod = "src/modules/forge/forge_3d/body_manager.zig"; +const interface_prod = "src/interfaces/PhysicsModule.zig"; + +test "a narrowing in a governed production file is flagged" { + try std.testing.expectEqual(@as(usize, 1), try countOn(forge_prod, "const x: f32 = @floatCast(y);\n")); + // Several on one line are each reported — a per-line count would under-report a + // three-component conversion, which is the exact shape this rule meets in practice. + try std.testing.expectEqual(@as(usize, 3), try countOn( + forge_prod, + "const v = .{ @floatCast(a[0]), @floatCast(a[1]), @floatCast(a[2]) };\n", + )); +} + +test "the perimeter is the forge module AND the interface tier, and nothing else" { + // THE INTERFACE TIER, which §1.11.8 names as the boundary's home and where M1.1.26 + // writes the adapter. The rule ignored it until F-F1: the directory was one milestone + // old and held declarations only, so the gap would have been found by the freeze. + try std.testing.expectEqual(@as(usize, 1), try countOn(interface_prod, "const x: f32 = @floatCast(y);\n")); + try std.testing.expectEqual(@as(usize, 1), try countOn(forge_prod, "const x: f32 = @floatCast(y);\n")); + + // And NOT the rest of `src/`. These carry narrowings of their own that this rule makes + // no claim about; flagging them would be a different rule with a different argument. + // The identical source above is flagged, so what is measured here is the perimeter and + // not a source the rule never reports. + try std.testing.expectEqual(@as(usize, 0), try countOn("src/core/ecs/world.zig", "const x: f32 = @floatCast(y);\n")); + try std.testing.expectEqual(@as(usize, 0), try countOn("src/foundation/math/vec.zig", "const x: f32 = @floatCast(y);\n")); + try std.testing.expectEqual(@as(usize, 0), try countOn("src/etch/ecs_bridge.zig", "const x: f32 = @floatCast(y);\n")); +} + +test "every path predicate holds in the Windows spelling too" { + // THE MISSING HALF. The rule's perimeter, its boundary exemption and its test exclusion + // were each measured in the POSIX spelling only, while the runner passes `\\`-separated + // paths on Windows — so a rule that worked on two platforms and not on the third would + // have passed every test above. + try std.testing.expectEqual(@as(usize, 1), try countOn( + "src\\modules\\forge\\forge_3d\\body_manager.zig", + "const x: f32 = @floatCast(y);\n", + )); + try std.testing.expectEqual(@as(usize, 1), try countOn( + "src\\interfaces\\PhysicsModule.zig", + "const x: f32 = @floatCast(y);\n", + )); + try std.testing.expectEqual(@as(usize, 0), try countOn( + "src\\modules\\forge\\api\\precision.zig", + "const x: f32 = @floatCast(y);\n", + )); + try std.testing.expectEqual(@as(usize, 0), try countOn( + "src\\modules\\forge\\forge_3d\\tests\\mesh_test.zig", + "const x: f32 = @floatCast(y);\n", + )); + try std.testing.expectEqual(@as(usize, 0), try countOn( + "src\\core\\ecs\\world.zig", + "const x: f32 = @floatCast(y);\n", + )); +} + +test "the lookup itself is separator-aware, not merely the helper it calls" { + // NON-VACUITY for the test below. `declared_escapes` is empty, so `declaredIndexFor` + // never iterates and a POSIX-only comparison inside it is unreachable — a probe that + // reverted it failed nothing. This drives the shipped lookup over a fixture, which is + // what makes the separator awareness observable AT THE CALL SITE. + const fixture = [_]DeclaredEscape{ + .{ .file = "forge_3d/body_manager.zig", .reason = "probe", .owner = "probe" }, + }; + try std.testing.expectEqual(@as(?usize, 0), declaredIndexIn(&fixture, "src/modules/forge/forge_3d/body_manager.zig")); + try std.testing.expectEqual(@as(?usize, 0), declaredIndexIn(&fixture, "src\\modules\\forge\\forge_3d\\body_manager.zig")); + try std.testing.expectEqual(@as(?usize, null), declaredIndexIn(&fixture, "src/modules/forge/forge_3d/mesh.zig")); +} + +test "a declared escape matches whichever separator the runner used" { + // The declaration is written in POSIX spelling by convention; the path is not under this + // rule's control. Both directions are measured, and the mixed case is what a naive + // `endsWith` fails. + try std.testing.expect(endsWithPath("src/modules/forge/forge_3d/body_manager.zig", "forge_3d/body_manager.zig")); + try std.testing.expect(endsWithPath("src\\modules\\forge\\forge_3d\\body_manager.zig", "forge_3d/body_manager.zig")); + try std.testing.expect(endsWithPath("src/modules/forge/forge_3d/body_manager.zig", "forge_3d\\body_manager.zig")); + // NON-VACUITY: it is a suffix match on path components and not a substring test that + // says yes to everything. + try std.testing.expect(!endsWithPath("src/modules/forge/forge_3d/mesh.zig", "forge_3d/body_manager.zig")); + try std.testing.expect(!endsWithPath("body_manager.zig", "forge_3d/body_manager.zig")); +} + +test "the boundary file itself is allowed to narrow" { + try std.testing.expectEqual(@as(usize, 0), try countOn( + "src/modules/forge/api/precision.zig", + "const x: f32 = @floatCast(y);\n", + )); +} + +test "test files are out of scope, in both spellings and in both governed directories" { + try std.testing.expectEqual(@as(usize, 0), try countOn( + "src/modules/forge/forge_3d/tests/mesh_test.zig", + "const x: f32 = @floatCast(y);\n", + )); + // `_test.zig` outside a `tests/` directory — the two forms both occur in the tree and + // neither implies the other, so both are measured. + try std.testing.expectEqual(@as(usize, 0), try countOn( + "tests/physics/transform_sync_test.zig", + "const x: f32 = @floatCast(y);\n", + )); + try std.testing.expectEqual(@as(usize, 0), try countOn( + "src/interfaces/PhysicsModule_test.zig", + "const x: f32 = @floatCast(y);\n", + )); +} + +test "an undeclared marker exempts nothing, and says which of the two things to fix" { + // The site is still reported — with the OTHER message, because the author who wrote a + // marker has already decided the narrowing is legitimate and needs to be told that the + // declaration is what is missing, not the conversion. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var diags: std.ArrayList(diag.Diagnostic) = .empty; + defer diags.deinit(arena_state.allocator()); + var tally: Tally = .{}; + try check( + arena_state.allocator(), + forge_prod, + "const x: f32 = @floatCast(y); // WELD_NOT_A_WORLD_CROSSING: two solver widths\n", + &diags, + &tally, + ); + try std.testing.expectEqual(@as(usize, 1), diags.items.len); + try std.testing.expect(std.mem.indexOf(u8, diags.items[0].message, "declared_escapes") != null); + try std.testing.expect(std.mem.indexOf(u8, diags.items[0].message, "exempts nothing on its own") != null); +} + +test "the marker's per-site reach is unchanged, and it does not reach down a line" { + // Two sites, one marker: with the escape undeclared BOTH are now reported, so what this + // measures is the reach and not the exemption. The counts differ by exactly the marked + // site's message, checked above. + try std.testing.expectEqual(@as(usize, 2), try countOn(forge_prod, + \\const a: f32 = @floatCast(y); // WELD_NOT_A_WORLD_CROSSING + \\const b: f32 = @floatCast(z); + \\ + )); + // The line above is deliberately out of reach — narrower than `no_float_reduce`'s + // allowance, so a claim cannot drift onto a neighbour. + try std.testing.expectEqual(@as(usize, 1), try countOn(forge_prod, + \\// WELD_NOT_A_WORLD_CROSSING + \\const b: f32 = @floatCast(z); + \\ + )); +} + +test "the declaration list is empty, and the control runs on it" { + try std.testing.expectEqual(@as(usize, 0), declared_escapes.len); + + const gpa = std.testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + var diags: std.ArrayList(diag.Diagnostic) = .empty; + defer diags.deinit(arena_state.allocator()); + const tally: Tally = .{}; + try checkDeclarations(arena_state.allocator(), threaded.io(), &tally, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); +} + +test "stale follows the VISITED file, and a missing file is reported whatever the visit" { + // NON-VACUITY for the test above, which runs on an EMPTY list and would pass against a + // control that returned immediately. This drives `reportStale` — the shipped body, not a + // mirror of it — over a non-empty fixture, on all four combinations that matter. + const gpa = std.testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + // `build.zig` exists at the repository root, where the linter runs; the second path does + // not exist anywhere, which is the point of it. + const present = [_]DeclaredEscape{.{ .file = "build.zig", .reason = "a probe", .owner = "none" }}; + const missing = [_]DeclaredEscape{.{ .file = "no_such_file_xyz.zig", .reason = "a probe", .owner = "none" }}; + + const Case = struct { + fn count( + alloc: std.mem.Allocator, + handle: std.Io, + declared: []const DeclaredEscape, + visited: bool, + marked: u32, + ) !usize { + var arena_state = std.heap.ArenaAllocator.init(alloc); + defer arena_state.deinit(); + var diags: std.ArrayList(diag.Diagnostic) = .empty; + defer diags.deinit(arena_state.allocator()); + try reportStale( + arena_state.allocator(), + handle, + declared, + &.{visited}, + &.{marked}, + &diags, + ); + return diags.items.len; + } + }; + + // READ and unmarked: stale. This is the case the control exists for. + try std.testing.expectEqual(@as(usize, 1), try Case.count(gpa, io, &present, true, 0)); + // READ and marked: silent, the declaration is doing its job. + try std.testing.expectEqual(@as(usize, 0), try Case.count(gpa, io, &present, true, 3)); + // NOT READ: silent. A partial scan says less, never something false — this is the whole + // reason the control follows visits instead of trying to establish completeness. + try std.testing.expectEqual(@as(usize, 0), try Case.count(gpa, io, &present, false, 0)); + // MISSING FILE: reported even unvisited, which is the residual that rule leaves — a + // declaration whose file is gone is never visited, hence would be immortal. + try std.testing.expectEqual(@as(usize, 1), try Case.count(gpa, io, &missing, false, 0)); +} + +test "the rule is written on tokens, so prose naming the builtin is not a site" { + try std.testing.expectEqual(@as(usize, 0), try countOn( + forge_prod, + "/// Never write `@floatCast` outside the boundary.\nconst x = 1;\n", + )); + try std.testing.expectEqual(@as(usize, 0), try countOn( + forge_prod, + "// @floatCast(a[0]) is what this replaces.\nconst x = 1;\n", + )); +} + +test "a different cast builtin is not a site" { + // `@intCast` and `@as` narrow nothing across the precision boundary, and flagging them + // would make the rule about casts in general rather than about that boundary. + try std.testing.expectEqual(@as(usize, 0), try countOn(forge_prod, "const x: u8 = @intCast(y);\n")); + try std.testing.expectEqual(@as(usize, 0), try countOn(forge_prod, "const x = @as(f32, y);\n")); +} diff --git a/tools/weld_lint/tests.zig b/tools/weld_lint/tests.zig index 2f52f782..c904d9d7 100644 --- a/tools/weld_lint/tests.zig +++ b/tools/weld_lint/tests.zig @@ -32,8 +32,12 @@ comptime { _ = @import("rules/c_module_isolation.zig"); _ = @import("rules/no_device_dispatch_outside_gal.zig"); _ = @import("rules/no_float_reduce.zig"); + _ = @import("rules/no_precision_crossing.zig"); _ = @import("rules/conventional_commit.zig"); // Shared machinery. + // `main.zig` too: the lint subcommand's own path-coverage logic lives there, and a + // helper nobody elaborates is a helper nobody tests. + _ = @import("main.zig"); _ = @import("dead_tests.zig"); _ = @import("scan.zig"); _ = @import("diagnostic.zig");