From ad7fb8416420a745b5f4ae0238e7e8658a0daefa Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 21 Aug 2026 22:59:04 +0200 Subject: [PATCH 01/23] docs(brief): add M1.1.15 milestone brief --- briefs/m1.1.15-physics-world-orchestration.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 briefs/m1.1.15-physics-world-orchestration.md 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 0000000..441e31c --- /dev/null +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -0,0 +1,183 @@ +# M1.1.15 — forge_3d orchestration: `PhysicsWorld`, tick cycle, and ECS `Transform` synchronisation + +> **Status:** PLANNED +> **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:** — + +--- + +# 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 + +## Execution log + +## Recorded deviations + +## Blockers encountered + +## Closing notes From ae63cf4d3bbc70fd7f7190d58e7126d9a025aa85 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 21 Aug 2026 23:02:53 +0200 Subject: [PATCH 02/23] docs(brief): confirm specs read for M1.1.15 --- briefs/m1.1.15-physics-world-orchestration.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index 441e31c..05c3306 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -174,6 +174,18 @@ Sync-in runs before step 1; sync-out runs after step 11. Sensors' step 10 bis th ## 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 ## Recorded deviations From f09aa52d72bcde34dffcf00b43939a24c66994da Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 21 Aug 2026 23:03:04 +0200 Subject: [PATCH 03/23] docs(brief): activate M1.1.15 --- briefs/m1.1.15-physics-world-orchestration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index 05c3306..5d50ad4 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -1,6 +1,6 @@ # M1.1.15 — forge_3d orchestration: `PhysicsWorld`, tick cycle, and ECS `Transform` synchronisation -> **Status:** PLANNED +> **Status:** ACTIVE > **Phase:** 1.1 > **Branch:** `phase-1/forge/physics-world-orchestration` > **Planned tag:** `v0.11.15-orchestration` From 17f6451f2055810ed424d42fe4947f677c239498 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 21 Aug 2026 23:16:50 +0200 Subject: [PATCH 04/23] feat(forge): add PhysicsWorld, owner of the per-tick cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the eleven-step cycle of `engine-physics-solver.md` §1.7 out of the acceptance suite that held the only composition of it and into `forge_3d/world.zig`. The call sequence, the arguments and the arithmetic are the ones the eight committed witnesses were taken over: the reparenting changes who calls, never what is computed. Cycle ownership: - `PhysicsWorld` holds the shape store, the body store, the broadphase, the warm-start cache, the island partition and the per-tick scratches - `step()` reads as the cycle: nine stage methods in the frozen order, with the two code-free anchors (3 read-only, 5 bis empty) as comments where they run - each stage method begins with its own `enter()`, so a stage cannot be moved without its record moving — a recorder wired at the call site would be blind to the one mutation it exists to catch - `tests/solver_test.zig` keeps the name `World` as an alias, so the ten suites that drive a world all drive the same owner Step 10 bis is UNCONDITIONAL: - the harness gated it on a `sensors_on` flag defaulting to false, which for a production world means sensors silently do not work - `SensorState.update` takes a `*const BodyManager`, so the pass cannot alter a bit of body state and making it unconditional leaves every witness identical Telemetry: - `SolverStats.warm_start_injections` counts constraint POINTS injected, summed over substeps — not part of what §1.8.2 reports, and the only place the application half of warm start is observable - `applyWarmStartRange` returns the count it injected Tests: - `tests/world_test.zig`: the step order read as an order, the substep cadence with its paired one-substep negative, and step 10 bis on a world that was never told about sensors - the declared per-platform test floor re-derived from the suite: 1869 -> 1875 --- src/modules/forge/forge_3d/rigid/solver.zig | 21 +- src/modules/forge/forge_3d/root.zig | 23 + .../forge_3d/tests/determinism/scenario.zig | 15 +- .../forge/forge_3d/tests/sensor_test.zig | 6 - .../forge/forge_3d/tests/solver_test.zig | 350 +------------ .../forge/forge_3d/tests/world_test.zig | 278 ++++++++++ src/modules/forge/forge_3d/world.zig | 489 ++++++++++++++++++ tools/weld_lint/dead_tests.zig | 22 +- 8 files changed, 861 insertions(+), 343 deletions(-) create mode 100644 src/modules/forge/forge_3d/tests/world_test.zig create mode 100644 src/modules/forge/forge_3d/world.zig diff --git a/src/modules/forge/forge_3d/rigid/solver.zig b/src/modules/forge/forge_3d/rigid/solver.zig index c8c3f70..b5803d6 100644 --- a/src/modules/forge/forge_3d/rigid/solver.zig +++ b/src/modules/forge/forge_3d/rigid/solver.zig @@ -80,6 +80,17 @@ pub const SolverStats = struct { solve_sweeps: u32 = 0, /// Relax sweeps (one per substep). relax_sweeps: u32 = 0, + /// Constraint POINTS the warm start injected this tick, summed over substeps. + /// + /// NOT part of what `get_solver_iterations_stats` reports — `engine-physics-solver.md` + /// §1.8.2 states the reported set as the solve/relax sweeps and explicitly excludes + /// the warm-start applications. This field is not that surface: 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 — which is 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 +280,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 +301,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 +447,7 @@ 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.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 72d7888..ff570c0 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 --- @@ -314,6 +317,24 @@ 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; +/// 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 +353,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 +372,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/determinism/scenario.zig b/src/modules/forge/forge_3d/tests/determinism/scenario.zig index 07e94ef..9a933ca 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 82f78d1..62c9a6a 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 6de6e95..2226576 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 0000000..d48c841 --- /dev/null +++ b/src/modules/forge/forge_3d/tests/world_test.zig @@ -0,0 +1,278 @@ +//! 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 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 eleven cycle steps in the frozen 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-FACTUAL, run: swapping the `proxy_update` and `sensor_pass` calls in + // `world.zig` — a permutation with no effect on any body, since the sensor pass + // takes a `*const BodyManager` — fails on THIS assertion and on nothing else in + // the suite. That is the property being claimed: the order is asserted here, and + // here only, so it cannot move in silence. +} + +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.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.warm_start_injections); + + // COUNTER-FACTUAL, run: hoisting the `applyWarmStartRange` loop out of the + // substep loop in `rigid/solver.zig` makes the four-substep reading fall to + // `points` and leaves the one-substep reading untouched — the asymmetry is what + // this test is for. +} + +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); +} diff --git a/src/modules/forge/forge_3d/world.zig b/src/modules/forge/forge_3d/world.zig new file mode 100644 index 0000000..f0863d6 --- /dev/null +++ b/src/modules/forge/forge_3d/world.zig @@ -0,0 +1,489 @@ +//! `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.** ECS synchronisation lives one tier up +//! (`../sync.zig`): sync-in runs before step 1, sync-out after step 11, so step +//! 10 bis sees the poses the tick publishes, which is its stated premise. 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 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; + +/// 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]; + } +}; + +const BodyProxy = struct { id: BodyId, proxy: Bp.Proxy }; + +/// 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, warm-start injections, 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, 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.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); + 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: *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); + } + 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 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. + 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/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 24963be..f47c61c 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -246,17 +246,21 @@ 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 added six blocks in `forge_3d/tests/world_test.zig`, which is the whole + // of this bump: 1869 → 1875. return switch (os) { - .windows => 1867, - else => 1869, + .windows => 1873, + else => 1875, }; } From 2b26f7d52f99200ba5681887d7334e46f0531416 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 21 Aug 2026 23:21:49 +0200 Subject: [PATCH 05/23] docs(brief): journal gate A and its counter-factuals --- briefs/m1.1.15-physics-world-orchestration.md | 84 +++++++++++++++++++ .../forge/forge_3d/tests/world_test.zig | 37 ++++++-- src/modules/forge/forge_3d/world.zig | 7 ++ 3 files changed, 119 insertions(+), 9 deletions(-) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index 5d50ad4..ce93d25 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -188,8 +188,92 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se ## 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. +- **Four counter-factuals RUN**, and the fourth is a residual rather than a + confirmation. (A) swapping the calls of stages 10 and 10 bis: `1 failed` of 561, and + it is the order test — that adjacency's only guard, and it holds. (B) hoisting the + warm start out of the substep loop: the cadence test reports `expected 16, found 4` + and six physics tests fall with it; one of the seven names the cause. (D) swapping + the calls of stages 4 and 5, an order with physical consequence: `4 failed, 35 + crashed`. (C) swapping the BODIES of two stage methods while leaving each `enter()` + in place: the whole suite stays GREEN. A record separated from its work is + undetectable by any test here; the bound is structural and is now written in both + `world.zig` and the test. +- **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. + ## Recorded deviations +- **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. +- **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. + +## Notes + +- **`SolverStats.warm_start_injections`, and why it does not contradict §1.8.2.** + `engine-physics-solver.md` §1.8.2 states what `get_solver_iterations_stats` REPORTS + — the solve/relax sweeps — and says the warm-start applications are not counted. + The brief's own acceptance criterion asks for a cadence test "measured on the count + of applications for `substep_count > 1`". The two are reconciled by keeping them + distinct objects: the field counts real injections (constraint POINTS, so 16 for + four substeps over a four-point manifold, and 4 if the call leaves the loop), it is + documented at its declaration as NOT part of the reported surface, and no reporting + entry reads it — `get_solver_iterations_stats` does not exist yet in any case, it is + a `PhysicsDebugProvider` entry. Flagged for Guy at the Gate A signal: if this needs + a Claude.ai round-trip rather than a Note, the field is one line to move. + ## Blockers encountered +None at Gate A. + ## Closing notes diff --git a/src/modules/forge/forge_3d/tests/world_test.zig b/src/modules/forge/forge_3d/tests/world_test.zig index d48c841..ba4f615 100644 --- a/src/modules/forge/forge_3d/tests/world_test.zig +++ b/src/modules/forge/forge_3d/tests/world_test.zig @@ -114,11 +114,29 @@ test "step executes the eleven cycle steps in the frozen order" { try testing.expectEqual(world_mod.executed_step_count, trace.order().len); try testing.expectEqual(@as(u32, 0), trace.dropped); - // COUNTER-FACTUAL, run: swapping the `proxy_update` and `sensor_pass` calls in - // `world.zig` — a permutation with no effect on any body, since the sensor pass - // takes a `*const BodyManager` — fails on THIS assertion and on nothing else in - // the suite. That is the property being claimed: the order is asserted here, and - // here only, so it cannot move in silence. + // COUNTER-FACTUALS, RUN, with their measured yield — four of them, because the + // recorder's own blind spot is one of the things they had to measure. + // + // (A) Swapping the `stepProxyUpdate` and `stepSensorPass` CALLS: `1 failed` out + // of 561, and it is this test. That adjacency has no physical consequence — + // the sensor pass takes a `*const BodyManager` — so THIS assertion is its + // only guard, and it holds. + // (B) Hoisting the warm start out of the substep loop: the cadence test below + // fails with `expected 16, found 4`, and six physics tests fail with it. One + // of the seven NAMES the cause; the six report the symptom. + // (D) Swapping the `stepBuildConstraints` and `stepIslandPartition` CALLS — an + // order that does have physical consequence: `4 failed, 35 crashed`. Where + // the order matters physically, dozens of guards fire and this one is not + // load-bearing. + // (C) THE RESIDUAL, and it is written down rather than implied: swapping the + // BODIES of two stage methods while leaving each `enter()` in place leaves + // the WHOLE suite green — 560/561, this test included. A record moved away + // from its work is undetectable by any test here. What bounds it is + // STRUCTURAL and not a test: each `enter()` is the first statement of the + // stage method that carries its name, so moving a stage in `step()` moves + // its record, and separating the two is a visible edit inside a named method + // rather than a reordering of a sequence. That is a smaller claim than "the + // order is verified", and it is the true one. } test "the step trace is reset per tick and its order is stable across ticks" { @@ -183,10 +201,11 @@ test "substep cadence: warm start is applied inside the substep loop, every subs try testing.expectEqual(@as(u32, 1), single.solver_stats.substeps_executed); try testing.expectEqual(single_points, single.solver_stats.warm_start_injections); - // COUNTER-FACTUAL, run: hoisting the `applyWarmStartRange` loop out of the - // substep loop in `rigid/solver.zig` makes the four-substep reading fall to - // `points` and leaves the one-substep reading untouched — the asymmetry is what - // this test is for. + // 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" { diff --git a/src/modules/forge/forge_3d/world.zig b/src/modules/forge/forge_3d/world.zig index f0863d6..7b24a29 100644 --- a/src/modules/forge/forge_3d/world.zig +++ b/src/modules/forge/forge_3d/world.zig @@ -325,6 +325,13 @@ pub const PhysicsWorld = struct { /// 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 MEASURED rather than assumed.** Swapping the + /// BODIES of two stage methods while leaving each `enter()` in place leaves the + /// whole forge suite green, order test included. So this convention is what makes + /// the recorded order the executed one; no test enforces it, and the note is here + /// rather than only in `tests/world_test.zig` because this is the file where the + /// convention can be broken. fn enter(self: *PhysicsWorld, step_id: Step) void { if (self.trace) |t| t.record(step_id); } From c549152a848d76e7a0959da9a8b8857544a8ed8c Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Fri, 21 Aug 2026 23:45:05 +0200 Subject: [PATCH 06/23] fix(forge): bound the step-trace residual to its measured pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 — probe C claimed "a record moved away from its work is undetectable by any test here", which its own batch refutes: swapping two stage BODIES executes the work in the same order as swapping their CALLS, so on a consequential pair the physics guards fire identically. Measured, not deduced — probe C' on (build_constraints, island_partition) gives 3 failed, 35 crashed with the order test passing, i.e. probe D minus the order test. Both pairs are now named, the claim is bounded to the physically harmless case, and what the test guards ALONE is stated: the 10 / 10 bis adjacency, where probe A reports 1 failed of 561. F2 — `warm_start_injections` moves into a named `not_reported` sub-struct of `SolverStats`. §1.8.2's exclusion was carried by a doc comment, so it rested on whoever later maps the struct onto the reported telemetry surface; it is now carried by the type and visible at every read site. The counter stays where the injection happens. F3 — the order test is renamed to say what it measures: the nine CODED steps of an eleven-anchor cycle, not eleven steps. --- briefs/m1.1.15-physics-world-orchestration.md | 87 +++++++++++++------ src/modules/forge/forge_3d/rigid/solver.zig | 34 +++++--- .../forge/forge_3d/tests/world_test.zig | 62 +++++++------ src/modules/forge/forge_3d/world.zig | 21 +++-- 4 files changed, 133 insertions(+), 71 deletions(-) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index ce93d25..5d890e1 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -210,16 +210,17 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se `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. -- **Four counter-factuals RUN**, and the fourth is a residual rather than a - confirmation. (A) swapping the calls of stages 10 and 10 bis: `1 failed` of 561, and - it is the order test — that adjacency's only guard, and it holds. (B) hoisting the - warm start out of the substep loop: the cadence test reports `expected 16, found 4` - and six physics tests fall with it; one of the seven names the cause. (D) swapping - the calls of stages 4 and 5, an order with physical consequence: `4 failed, 35 - crashed`. (C) swapping the BODIES of two stage methods while leaving each `enter()` - in place: the whole suite stays GREEN. A record separated from its work is - undetectable by any test here; the bound is structural and is now written in both - `world.zig` and the test. +- **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 @@ -237,6 +238,33 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se 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. + ## Recorded deviations - **Files touched outside the FROZEN list, with their justification** (Gate A). @@ -247,6 +275,19 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se 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 @@ -260,20 +301,12 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se ## Notes -- **`SolverStats.warm_start_injections`, and why it does not contradict §1.8.2.** - `engine-physics-solver.md` §1.8.2 states what `get_solver_iterations_stats` REPORTS - — the solve/relax sweeps — and says the warm-start applications are not counted. - The brief's own acceptance criterion asks for a cadence test "measured on the count - of applications for `substep_count > 1`". The two are reconciled by keeping them - distinct objects: the field counts real injections (constraint POINTS, so 16 for - four substeps over a four-point manifold, and 4 if the call leaves the loop), it is - documented at its declaration as NOT part of the reported surface, and no reporting - entry reads it — `get_solver_iterations_stats` does not exist yet in any case, it is - a `PhysicsDebugProvider` entry. Flagged for Guy at the Gate A signal: if this needs - a Claude.ai round-trip rather than a Note, the field is one line to move. - -## Blockers encountered - -None at Gate A. - -## Closing 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. diff --git a/src/modules/forge/forge_3d/rigid/solver.zig b/src/modules/forge/forge_3d/rigid/solver.zig index b5803d6..15752ff 100644 --- a/src/modules/forge/forge_3d/rigid/solver.zig +++ b/src/modules/forge/forge_3d/rigid/solver.zig @@ -80,17 +80,28 @@ pub const SolverStats = struct { solve_sweeps: u32 = 0, /// Relax sweeps (one per substep). relax_sweeps: u32 = 0, - /// Constraint POINTS the warm start injected this tick, summed over substeps. + /// Counters the reported telemetry surface deliberately EXCLUDES. /// - /// NOT part of what `get_solver_iterations_stats` reports — `engine-physics-solver.md` - /// §1.8.2 states the reported set as the solve/relax sweeps and explicitly excludes - /// the warm-start applications. This field is not that surface: 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 — which is the counter-factual that gives the number its meaning. - warm_start_injections: u32 = 0, + /// `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, @@ -447,7 +458,8 @@ pub fn solveTick( while (substep < cfg.substep_count) : (substep += 1) { integration.integrateVelocitiesNoReset(bm, h, gravity); - for (islands) |isl| stats.warm_start_injections += 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/tests/world_test.zig b/src/modules/forge/forge_3d/tests/world_test.zig index ba4f615..5a4701d 100644 --- a/src/modules/forge/forge_3d/tests/world_test.zig +++ b/src/modules/forge/forge_3d/tests/world_test.zig @@ -74,7 +74,7 @@ fn totalConstraintPoints(world: *const PhysicsWorld) u32 { // --- step order --------------------------------------------------------------- -test "step executes the eleven cycle steps in the frozen 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); @@ -114,29 +114,41 @@ test "step executes the eleven cycle steps in the frozen order" { try testing.expectEqual(world_mod.executed_step_count, trace.order().len); try testing.expectEqual(@as(u32, 0), trace.dropped); - // COUNTER-FACTUALS, RUN, with their measured yield — four of them, because the - // recorder's own blind spot is one of the things they had to measure. + // 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. // - // (A) Swapping the `stepProxyUpdate` and `stepSensorPass` CALLS: `1 failed` out - // of 561, and it is this test. That adjacency has no physical consequence — - // the sensor pass takes a `*const BodyManager` — so THIS assertion is its - // only guard, and it holds. - // (B) Hoisting the warm start out of the substep loop: the cadence test below - // fails with `expected 16, found 4`, and six physics tests fail with it. One - // of the seven NAMES the cause; the six report the symptom. - // (D) Swapping the `stepBuildConstraints` and `stepIslandPartition` CALLS — an - // order that does have physical consequence: `4 failed, 35 crashed`. Where - // the order matters physically, dozens of guards fire and this one is not - // load-bearing. - // (C) THE RESIDUAL, and it is written down rather than implied: swapping the - // BODIES of two stage methods while leaving each `enter()` in place leaves - // the WHOLE suite green — 560/561, this test included. A record moved away - // from its work is undetectable by any test here. What bounds it is - // STRUCTURAL and not a test: each `enter()` is the first statement of the - // stage method that carries its name, so moving a stage in `step()` moves - // its record, and separating the two is a visible edit inside a named method - // rather than a reordering of a sequence. That is a smaller claim than "the - // order is verified", and it is the true one. + // 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" { @@ -184,7 +196,7 @@ test "substep cadence: warm start is applied inside the substep loop, every subs // 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.warm_start_injections); + 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 — @@ -199,7 +211,7 @@ test "substep cadence: warm start is applied inside the substep loop, every subs 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.warm_start_injections); + 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 diff --git a/src/modules/forge/forge_3d/world.zig b/src/modules/forge/forge_3d/world.zig index 7b24a29..0fc09ac 100644 --- a/src/modules/forge/forge_3d/world.zig +++ b/src/modules/forge/forge_3d/world.zig @@ -179,8 +179,8 @@ pub const PhysicsWorld = struct { /// 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, warm-start injections, and the minimum separation any biased - /// sweep observed. + /// 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, @@ -326,12 +326,17 @@ pub const PhysicsWorld = struct { /// 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 MEASURED rather than assumed.** Swapping the - /// BODIES of two stage methods while leaving each `enter()` in place leaves the - /// whole forge suite green, order test included. So this convention is what makes - /// the recorded order the executed one; no test enforces it, and the note is here - /// rather than only in `tests/world_test.zig` because this is the file where the - /// convention can be broken. + /// **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); } From 2e8e9f6155c0abd157738479c6c52be1a86707ab Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 22 Aug 2026 02:45:21 +0200 Subject: [PATCH 07/23] docs(brief): record the CI reds and restore two lost sections --- briefs/m1.1.15-physics-world-orchestration.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index 5d890e1..8e6a8ef 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -265,6 +265,20 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se `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, and + the check that would have caught it is now run after any structural edit of the brief: + compare the heading list against the attached original and require zero missing. It + reports `original 14, repo 15, MISSING none`, the one addition being the living + `## Notes`. + ## Recorded deviations - **Files touched outside the FROZEN list, with their justification** (Gate A). @@ -310,3 +324,52 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se `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. + +## Closing notes From 8d7d61b99976e6ce8393935b15942aa22608be41 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 22 Aug 2026 03:05:57 +0200 Subject: [PATCH 08/23] docs(brief): hash the frozen section and bound the heading check --- briefs/m1.1.15-physics-world-orchestration.md | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index 8e6a8ef..a370b44 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -273,11 +273,28 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se 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, and - the check that would have caught it is now run after any structural edit of the brief: - compare the heading list against the attached original and require zero missing. It - reports `original 14, repo 15, MISSING none`, the one addition being the living - `## Notes`. + 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. + Slice: from `# FROZEN SECTION` up to and excluding `# LIVING SECTION`, 158 lines, + `sha256 = 2a2a3b4367006452af7a976916b06c2eb4b27128a6784ee1859f0176cb884aef`, identical + between the attached original and this file. **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 — and flipping `Status: ACTIVE` to `CLOSED` leaves the digest + bit-identical. Counter-factuals RUN, three: one character changed inside the slice moves + the digest; the gutted-body case above moves the digest where the heading list does not; + a protocol-legal header edit does not move it. ## Recorded deviations @@ -373,3 +390,13 @@ established) and `M1.D.10` (`weldengine/setup-zig` purging a cache that overlaps correction, in the file that documents six runs lost to a cache that lies. ## 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. From 8c92d65cc4ec00f254f6db6e31a2d9c4e1c297f8 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 22 Aug 2026 12:32:00 +0200 Subject: [PATCH 09/23] docs(brief): publish the frozen check as a replayable recipe --- briefs/m1.1.15-physics-world-orchestration.md | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index a370b44..927d508 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -286,15 +286,25 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se **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. - Slice: from `# FROZEN SECTION` up to and excluding `# LIVING SECTION`, 158 lines, - `sha256 = 2a2a3b4367006452af7a976916b06c2eb4b27128a6784ee1859f0176cb884aef`, identical - between the attached original and this file. **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 — and flipping `Status: ACTIVE` to `CLOSED` leaves the digest - bit-identical. Counter-factuals RUN, three: one character changed inside the slice moves - the digest; the gutted-body case above moves the digest where the heading list does not; - a protocol-legal header edit does not move it. + **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 + + **158 lines, `sha256 = 2251576287d24b3b9fe5152c8f7f3888d2c4c622f332a953c1bf81e1656d4c78`**, + identical between the attached original and this file, and replayable in a shell with no + script. **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…`. ## Recorded deviations From 9da06eff90d7b45cf12c257f39513e55ffa65f82 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 22 Aug 2026 12:40:23 +0200 Subject: [PATCH 10/23] feat(forge): insert character presences and count proxies per class --- .../forge/forge_3d/tests/world_test.zig | 136 ++++++++++++++++++ src/modules/forge/forge_3d/world.zig | 68 +++++++++ tools/weld_lint/dead_tests.zig | 13 +- 3 files changed, 213 insertions(+), 4 deletions(-) diff --git a/src/modules/forge/forge_3d/tests/world_test.zig b/src/modules/forge/forge_3d/tests/world_test.zig index 5a4701d..4d95570 100644 --- a/src/modules/forge/forge_3d/tests/world_test.zig +++ b/src/modules/forge/forge_3d/tests/world_test.zig @@ -307,3 +307,139 @@ test "a world with no trigger produces an empty sensor state rather than skippin 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)); +} diff --git a/src/modules/forge/forge_3d/world.zig b/src/modules/forge/forge_3d/world.zig index 0fc09ac..bb62060 100644 --- a/src/modules/forge/forge_3d/world.zig +++ b/src/modules/forge/forge_3d/world.zig @@ -66,6 +66,7 @@ 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"); @@ -78,6 +79,8 @@ 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. /// @@ -184,6 +187,10 @@ pub const PhysicsWorld = struct { 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 @@ -218,6 +225,7 @@ pub const PhysicsWorld = struct { /// 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); @@ -276,6 +284,66 @@ pub const PhysicsWorld = struct { 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); + 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 { + self.chars.destroyCharacter(gpa, &self.bp, &self.store, &self.bm, id); + } + + /// 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; + } + /// 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| { diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index f47c61c..59fd908 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -256,11 +256,16 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // 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 added six blocks in `forge_3d/tests/world_test.zig`, which is the whole - // of this bump: 1869 → 1875. + // 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. return switch (os) { - .windows => 1873, - else => 1875, + .windows => 1876, + else => 1878, }; } From 0de043fd0fa25045e44592755c87ceecebb47af2 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 22 Aug 2026 12:44:46 +0200 Subject: [PATCH 11/23] docs(brief): journal gate B and its three counter-factuals --- briefs/m1.1.15-physics-world-orchestration.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index 927d508..92eba5f 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -306,6 +306,63 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se 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. + ## Recorded deviations - **Files touched outside the FROZEN list, with their justification** (Gate A). From eb829ffc2522d7ee26d4d67aaac5e3dca9de65a7 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 22 Aug 2026 15:28:07 +0200 Subject: [PATCH 12/23] feat(forge): compose wake with write and orchestrate cause W4 --- briefs/m1.1.15-physics-world-orchestration.md | 103 ++++++++ .../forge/forge_3d/tests/world_test.zig | 222 ++++++++++++++++++ src/modules/forge/forge_3d/world.zig | 180 +++++++++++++- tools/weld_lint/dead_tests.zig | 6 +- 4 files changed, 503 insertions(+), 8 deletions(-) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index 92eba5f..09c2805 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -363,6 +363,54 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se - **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. + + ## Recorded deviations - **Files touched outside the FROZEN list, with their justification** (Gate A). @@ -456,6 +504,49 @@ established) and `M1.D.10` (`weldengine/setup-zig` purging a cache that overlaps 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` is BLOCKED on a design question the brief does not settle.** +2026-08-22. 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. + ## Closing notes - **Brief-tooling hooks have no home, and this milestone is not the one to give them one.** @@ -467,3 +558,15 @@ established) and `M1.D.10` (`weldengine/setup-zig` purging a cache that overlaps 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. diff --git a/src/modules/forge/forge_3d/tests/world_test.zig b/src/modules/forge/forge_3d/tests/world_test.zig index 4d95570..f193f0e 100644 --- a/src/modules/forge/forge_3d/tests/world_test.zig +++ b/src/modules/forge/forge_3d/tests/world_test.zig @@ -443,3 +443,225 @@ test "a character created without a presence inserts no proxy" { 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).?); +} diff --git a/src/modules/forge/forge_3d/world.zig b/src/modules/forge/forge_3d/world.zig index bb62060..0cbf797 100644 --- a/src/modules/forge/forge_3d/world.zig +++ b/src/modules/forge/forge_3d/world.zig @@ -269,12 +269,7 @@ pub const PhysicsWorld = struct { /// 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 { - 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); - } + self.wakeRetainedPartners(id); for (self.bodies.items, 0..) |entry, i| { if (entry.id != id) continue; self.bp.remove(entry.proxy); @@ -317,6 +312,17 @@ pub const PhysicsWorld = struct { ); 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 }); return id; } @@ -324,9 +330,78 @@ pub const PhysicsWorld = struct { /// 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| { + 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. /// @@ -344,6 +419,99 @@ pub const PhysicsWorld = struct { 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 static or kinematic body that + /// moves changes what supports the sleepers around it and they cannot see it happen. + /// No-op on a stale handle. + pub fn setBodyTransform( + self: *PhysicsWorld, + gpa: std.mem.Allocator, + id: BodyId, + position: Vec3r, + rotation: config.Quatr, + ) !void { + if (self.bm.position(id) == null) return; // stale handle + self.wakeRetainedPartners(id); + self.bm.wakeBody(id); + self.bm.setPosition(id, position); + self.bm.setRotation(id, 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| { diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 59fd908..141fc2d 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -263,9 +263,11 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // 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). return switch (os) { - .windows => 1876, - else => 1878, + .windows => 1882, + else => 1884, }; } From 7676065df580e8a3e88f26085712b2525ccb7cde Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 22 Aug 2026 17:30:09 +0200 Subject: [PATCH 13/23] feat(forge): move a kinematic body and derive its velocities --- briefs/m1.1.15-physics-world-orchestration.md | 28 ++++++++- .../forge/forge_3d/tests/world_test.zig | 62 +++++++++++++++++++ src/modules/forge/forge_3d/world.zig | 61 ++++++++++++++++++ tools/weld_lint/dead_tests.zig | 6 +- 4 files changed, 153 insertions(+), 4 deletions(-) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index 09c2805..aa2d528 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -504,8 +504,9 @@ established) and `M1.D.10` (`weldengine/setup-zig` purging a cache that overlaps 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` is BLOCKED on a design question the brief does not settle.** -2026-08-22. Everything else in gate C is delivered; this entry is the one part left out and +**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 @@ -547,6 +548,29 @@ observable contract of a frozen interface entry, and this milestone's whole disc 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.** diff --git a/src/modules/forge/forge_3d/tests/world_test.zig b/src/modules/forge/forge_3d/tests/world_test.zig index f193f0e..0ba5dea 100644 --- a/src/modules/forge/forge_3d/tests/world_test.zig +++ b/src/modules/forge/forge_3d/tests/world_test.zig @@ -665,3 +665,65 @@ test "W4: a character moving where it is retained with nobody wakes nobody" { _ = 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); +} diff --git a/src/modules/forge/forge_3d/world.zig b/src/modules/forge/forge_3d/world.zig index 0cbf797..ec7791e 100644 --- a/src/modules/forge/forge_3d/world.zig +++ b/src/modules/forge/forge_3d/world.zig @@ -481,6 +481,67 @@ pub const PhysicsWorld = struct { 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. + 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); + + 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. diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 141fc2d..3ad52d7 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -265,9 +265,11 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // 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). return switch (os) { - .windows => 1882, - else => 1884, + .windows => 1883, + else => 1885, }; } From 2e5b633042e61ae27105756238392f32ef9e62df Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 22 Aug 2026 17:32:53 +0200 Subject: [PATCH 14/23] docs(brief): journal the moveKinematic resolution --- briefs/m1.1.15-physics-world-orchestration.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index aa2d528..5e1f916 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -411,6 +411,27 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se 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. + ## Recorded deviations - **Files touched outside the FROZEN list, with their justification** (Gate A). From 1381f193c043d2be8a8045fed19c7927f6f4b8ce Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 22 Aug 2026 17:38:14 +0200 Subject: [PATCH 15/23] feat(forge): add the Sleeping marker and prove zero-size in the ECS --- src/modules/forge/api/components.zig | 57 ++++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 6 ++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/modules/forge/api/components.zig b/src/modules/forge/api/components.zig index 77abb83..5e1401a 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/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 3ad52d7..330643c 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -267,9 +267,11 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // 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). return switch (os) { - .windows => 1883, - else => 1885, + .windows => 1885, + else => 1887, }; } From fb0b5ed16e13a95c49f7e1a75ac55ffa6f2db876 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 22 Aug 2026 18:02:50 +0200 Subject: [PATCH 16/23] feat(forge): synchronise Transform and Velocity with the ECS --- build.zig | 28 +++ src/modules/forge/api/root.zig | 3 + src/modules/forge/sync.zig | 197 ++++++++++++++++++ tests/physics/transform_sync_test.zig | 278 ++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 6 +- 5 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 src/modules/forge/sync.zig create mode 100644 tests/physics/transform_sync_test.zig diff --git a/build.zig b/build.zig index ee51208..bdbb6d0 100644 --- a/build.zig +++ b/build.zig @@ -159,6 +159,20 @@ 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); + // 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 +341,9 @@ 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); + 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 +559,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 +579,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 +816,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/modules/forge/api/root.zig b/src/modules/forge/api/root.zig index 0dcdd51..4104fc5 100644 --- a/src/modules/forge/api/root.zig +++ b/src/modules/forge/api/root.zig @@ -14,6 +14,9 @@ const types = @import("types.zig"); /// 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`. diff --git a/src/modules/forge/sync.zig b/src/modules/forge/sync.zig new file mode 100644 index 0000000..c9d5e03 --- /dev/null +++ b/src/modules/forge/sync.zig @@ -0,0 +1,197 @@ +//! `forge/sync.zig` — ECS ↔ solver synchronisation, both directions. +//! +//! `PhysicsWorld` owns the tick and knows nothing of the ECS; this file is the seam +//! between them. **Sync-in runs before step 1 and sync-out after step 11**, so the +//! sensor pass at step 10 bis sees the poses the tick publishes, which is its stated +//! premise (`engine-physics-solver.md` §1.13.4). +//! +//! **Authority per `BodyType`, and one authority per fact.** +//! +//! - `dynamic` — the SOLVER is the authority over the pose. 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 ways to move a dynamic body are +//! `setBodyTransform`, a force or an 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 everywhere. +//! - `kinematic` — GAMEPLAY is the authority over the pose. A `Transform` written by a +//! rule is pushed in at sync-in; `moveKinematic` is what derives velocities from a +//! target pose when the caller wants a platform 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. +//! +//! **A write is pushed only when it CHANGED.** Sync-in compares the ECS value against +//! the solver's before writing, because 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 whole separation would be undone by the seam +//! meant to respect it. +//! +//! **THE ORDER OF THE `Sleeping` TAG AGAINST PUBLICATION, and why it is this way.** An +//! island falls asleep at step 11, AFTER steps 6 and 7 wrote its final pose. Sync-out +//! runs after step 11 and skips tagged bodies. Tag first and that final pose is NEVER +//! published: the entity's `Transform` 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. The test that "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 and not only on its immobility. +//! +//! So: the tag is REMOVED before publication and ADDED after it. Both transition ticks +//! publish — the sleeping tick publishes the last pose the solver computed, the waking +//! tick publishes the first pose it moved to — and every tick in between is skipped. The +//! mirror ordering (add before, remove after) trades the first defect for its twin on +//! wake, where the first moved pose would go unpublished. +//! +//! **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 Vec3r = forge_3d.Vec3r; +const Quatr = forge_3d.Quatr; +const Real = forge_3d.Real; + +/// 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]f32, rot: [4]f32 } { + const p = pw.bm.position(body) orelse return null; + const r = pw.bm.rotation(body).?; + const pa = p.toArray(); + return .{ + .pos = .{ @floatCast(pa[0]), @floatCast(pa[1]), @floatCast(pa[2]) }, + .rot = .{ @floatCast(r.x), @floatCast(r.y), @floatCast(r.z), @floatCast(r.w) }, + }; +} + +fn vecFrom(a: [3]f32) Vec3r { + return Vec3r.fromArray(.{ @floatCast(a[0]), @floatCast(a[1]), @floatCast(a[2]) }); +} + +/// Push what gameplay owns INTO the solver — before step 1 of the cycle. +/// +/// `kinematic` poses and `Velocity` writes on any simulated body, each only when it +/// differs from what the solver already holds. An entity that has lost its `Transform`, +/// or died, is skipped rather than guessed at. +pub fn syncIn(gpa: std.mem.Allocator, pw: *PhysicsWorld, ecs: *World) !void { + for (pw.bodies.items) |entry| { + const body = entry.id; + const entity = pw.bm.entity(body) orelse continue; + const body_type = pw.bm.bodyType(body).?; + if (body_type == .static) continue; // no per-tick sync in either direction + + if (body_type == .kinematic) { + // GAMEPLAY IS THE AUTHORITY over a kinematic pose. Pushed only on a real + // change: an unchanged pose is not a mutation, and composing a wake for it + // every tick would keep everything resting on the platform permanently awake. + if (ecs.get(Transform, entity)) |t| { + const current = solverPose(pw, body).?; + if (!std.mem.eql(f32, ¤t.pos, &t.pos) or !std.mem.eql(f32, ¤t.rot, &t.rot)) { + // Through `setBodyTransform` and NOT through a hand-rolled sequence: + // that entry already composes the wake, W4 on the retained partners + // and the proxy refresh, and a second composition here would be the + // one that drifts. + try pw.setBodyTransform(gpa, body, vecFrom(t.pos), Quatr{ + .x = @floatCast(t.rot[0]), + .y = @floatCast(t.rot[1]), + .z = @floatCast(t.rot[2]), + .w = @floatCast(t.rot[3]), + }); + } + } + } + + // `Velocity` written by a rule reaches the solver BEFORE step 3, so the tick that + // follows integrates it. C1.1 requires exactly this — an Etch system can write + // `Velocity` and the solver applies it — and the write composes wake + write like + // any other external mutation. + if (ecs.get(Velocity, entity)) |v| { + const lin = pw.bm.linearVelocity(body).?.toArray(); + const ang = pw.bm.angularVelocity(body).?.toArray(); + const same_lin = @as(f32, @floatCast(lin[0])) == v.linear[0] and + @as(f32, @floatCast(lin[1])) == v.linear[1] and + @as(f32, @floatCast(lin[2])) == v.linear[2]; + const same_ang = @as(f32, @floatCast(ang[0])) == v.angular[0] and + @as(f32, @floatCast(ang[1])) == v.angular[1] and + @as(f32, @floatCast(ang[2])) == v.angular[2]; + if (!same_lin or !same_ang) { + pw.setLinearVelocity(body, vecFrom(v.linear)); + pw.setAngularVelocity(body, vecFrom(v.angular)); + } + } + } +} + +/// Publish what the solver owns OUT to the ECS — after step 11 of the cycle. +/// +/// 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) !void { + // (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) |entry| { + const entity = pw.bm.entity(entry.id) orelse continue; + if (pw.bm.isSleeping(entry.id).?) continue; + if (ecs.get(Sleeping, entity) == null) continue; + 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) |entry| { + const body = entry.id; + const entity = pw.bm.entity(body) orelse continue; + const body_type = pw.bm.bodyType(body).?; + if (body_type == .static) continue; + if (ecs.get(Sleeping, entity) != null) 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. + if (body_type == .dynamic) { + if (ecs.getMut(Transform, entity)) |t| { + const pose = solverPose(pw, body).?; + t.pos = pose.pos; + t.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. + if (ecs.getMut(Velocity, entity)) |v| { + const lin = pw.bm.linearVelocity(body).?.toArray(); + const ang = pw.bm.angularVelocity(body).?.toArray(); + v.linear = .{ @floatCast(lin[0]), @floatCast(lin[1]), @floatCast(lin[2]) }; + v.angular = .{ @floatCast(ang[0]), @floatCast(ang[1]), @floatCast(ang[2]) }; + } + } + + // (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) |entry| { + const entity = pw.bm.entity(entry.id) orelse continue; + if (!pw.bm.isSleeping(entry.id).?) continue; + if (ecs.get(Sleeping, entity) != null) continue; + try ecs.addComponent(gpa, entity, Sleeping, .{}); + } +} + +/// One full tick with both halves of the synchronisation around it — the shape a +/// registered system pair will drive, and the one the tests exercise so the ORDER is +/// measured rather than left to each call site to remember. +pub fn stepSynchronised(gpa: std.mem.Allocator, pw: *PhysicsWorld, ecs: *World) !void { + try syncIn(gpa, pw, ecs); + try pw.step(gpa); + try syncOut(gpa, pw, ecs); +} diff --git a/tests/physics/transform_sync_test.zig b/tests/physics/transform_sync_test.zig new file mode 100644 index 0000000..87a3200 --- /dev/null +++ b/tests/physics/transform_sync_test.zig @@ -0,0 +1,278 @@ +//! M1.1.15 gate D — ECS ↔ solver synchronisation, both directions. +//! +//! What this file measures is the SEAM: who owns which fact, when each side reads the +//! other, and what the `Sleeping` marker does to publication. 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; + +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 seam walks in both directions. +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 }; +} + +/// 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); + + // 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 sync.stepSynchronised(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); + const scene = try restingScene(gpa, &ecs, &pw); + + var t: u32 = 0; + while (t < 400 and !pw.bm.isSleeping(scene.box).?) : (t += 1) { + try sync.stepSynchronised(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 sync.stepSynchronised(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); + const scene = try restingScene(gpa, &ecs, &pw); + + var t: u32 = 0; + while (t < 400 and !pw.bm.isSleeping(scene.box).?) : (t += 1) { + try sync.stepSynchronised(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 sync.stepSynchronised(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 in both directions" { + 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); + 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 sync.stepSynchronised(gpa, &pw, &ecs); + } + try testing.expect(ecs.get(Sleeping, scene.box_entity) != null); + + pw.addImpulse(scene.box, vr(2, 0, 0)); + try sync.stepSynchronised(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 sync.stepSynchronised(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 "gameplay Velocity write is applied by the solver and wakes the body" { + 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); + const scene = try restingScene(gpa, &ecs, &pw); + + var t: u32 = 0; + while (t < 400 and !pw.bm.isSleeping(scene.box).?) : (t += 1) { + try sync.stepSynchronised(gpa, &pw, &ecs); + } + try testing.expect(pw.bm.isSleeping(scene.box).?); + + // C1.1's own wording: an Etch system can write `Velocity` and the solver applies it. + // Written on the ECS component, which is the only surface a rule has. + ecs.getMut(Velocity, scene.box_entity).?.linear = .{ 3, 0, 0 }; + try sync.stepSynchronised(gpa, &pw, &ecs); + + // APPLIED — the solver carries it — and the body WOKE, because a gameplay write is an + // external mutation. Both halves: a seam that pushed the value without waking would + // hand it to a body the solver skips. + try testing.expect(!pw.bm.isSleeping(scene.box).?); + try testing.expect(pw.bm.linearVelocity(scene.box).?.toArray()[0] > 0.5); + try testing.expect(ecs.get(Transform, scene.box_entity).?.pos[0] > 1e-4); +} + +test "authority per BodyType" { + 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); + + 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. A gameplay write to `Transform` is + // overwritten at the next sync-out, and that is the contract: 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 sync.stepSynchronised(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 is the authority. A `Transform` written by a rule is pushed in + // at sync-in and survives the tick, and the solver's own pose follows it. + ecs.getMut(Transform, kin.entity).?.pos = .{ 25, 3, 0 }; + try sync.stepSynchronised(gpa, &pw, &ecs); + try testing.expectEqual(@as(f32, 25), ecs.get(Transform, kin.entity).?.pos[0]); + try testing.expectEqual(@as(f32, 3), ecs.get(Transform, kin.entity).?.pos[1]); + try testing.expectEqual(@as(Real, 25), pw.bm.position(kin.body).?.toArray()[0]); + + // STATIC — no per-tick synchronisation in EITHER direction. The ECS write stands + // because nothing publishes over it, and the solver pose does NOT follow it, because + // nothing pushes it in: a static body that moves is a teleportation through + // `setBodyTransform`, not a `Transform` write. + ecs.getMut(Transform, sta.entity).?.pos = .{ 77, 0, 0 }; + try sync.stepSynchronised(gpa, &pw, &ecs); + try testing.expectEqual(@as(f32, 77), ecs.get(Transform, sta.entity).?.pos[0]); + try testing.expectEqual(@as(Real, 40), pw.bm.position(sta.body).?.toArray()[0]); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 330643c..0b47ea3 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -269,9 +269,11 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // 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). return switch (os) { - .windows => 1885, - else => 1887, + .windows => 1891, + else => 1893, }; } From 107f6734d91e75bc227a8cabf9dbd85d909b3e40 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 22 Aug 2026 18:17:21 +0200 Subject: [PATCH 17/23] docs(brief): journal gate D and its measured ordering trap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counter-factual refuted its own prediction and had to be read further: under the "tag before publishing" defect BOTH halves of the sleeping test 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 the structural claim credits it with. Also records the review's 157-line correction with its instrument, so the figure and the recipe above it stop naming different byte strings. Co-Authored-By: Claude Opus 5 --- briefs/m1.1.15-physics-world-orchestration.md | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index 5e1f916..9bdb3ed 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -296,9 +296,12 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se sed -n '/^# FROZEN SECTION$/,/^# LIVING SECTION$/p' | sed '$d' | shasum -a 256 - **158 lines, `sha256 = 2251576287d24b3b9fe5152c8f7f3888d2c4c622f332a953c1bf81e1656d4c78`**, - identical between the attached original and this file, and replayable in a shell with no - script. **No tolerance is needed, and that is a measurement and not an assumption**: + **`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 @@ -432,6 +435,66 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se - Witnesses unchanged at both precisions, as expected: kinematic bodies are not integrated, so nothing on a compared path moved. + +**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.** `test-forge-3d` **570** collected (569 pass, 1 skip), unchanged — + this gate adds no forge-internal test. 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. + ## Recorded deviations - **Files touched outside the FROZEN list, with their justification** (Gate A). From 0dec3bc54b7cd69afb377be72bfe9ee919fce319 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sat, 22 Aug 2026 18:44:27 +0200 Subject: [PATCH 18/23] docs(brief): re-measure every forge count at its own gate head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate D's count was copied from Gate C and read "unchanged" while the true value had moved: Gate C bis added the moveKinematic test and journaled no count, so the comparison ran against a baseline one gate stale. Measured in a worktree at each of the four heads, the series is 561 / 564 / 570 / 571 / 571, and the missing Gate C bis line is added — leaving a gate without its count is the mechanism, not the arithmetic. Co-Authored-By: Claude Opus 5 --- briefs/m1.1.15-physics-world-orchestration.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index 9bdb3ed..44ea8fa 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -434,6 +434,10 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se 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. @@ -488,8 +492,13 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se 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.** `test-forge-3d` **570** collected (569 pass, 1 skip), unchanged — - this gate adds no forge-internal test. Full suite **1893** collected (1874 pass, 19 skip), +- **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 From ccf7842ac5f6073ba003a60a260fb43fb20f5a08 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 23 Aug 2026 01:28:13 +0200 Subject: [PATCH 19/23] fix(forge): mark a component changed only when its value changed `World.getMut` stamps `changed_tick` unconditionally and the `Changed` filter is built on that stamp, so publishing a bit-identical pose or velocity reported a change that never happened, every tick, for every awake body. 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. Sync-out now reads before it writes, symmetrically with sync-in, which already had the rule for its own motive. The guard asserts `changed_tick` against the world's current tick and never the value: under the defect the value is already correct, and only the signal lies. Co-Authored-By: Claude Opus 5 --- src/modules/forge/sync.zig | 51 ++++++++++++++++++------ tests/physics/transform_sync_test.zig | 56 +++++++++++++++++++++++++++ tools/weld_lint/dead_tests.zig | 6 ++- 3 files changed, 99 insertions(+), 14 deletions(-) diff --git a/src/modules/forge/sync.zig b/src/modules/forge/sync.zig index c9d5e03..e0510d9 100644 --- a/src/modules/forge/sync.zig +++ b/src/modules/forge/sync.zig @@ -19,11 +19,23 @@ //! - `static` — no per-tick synchronisation in either direction. A static body that //! moves is a teleportation through the interface, which wakes by W4. //! -//! **A write is pushed only when it CHANGED.** Sync-in compares the ECS value against -//! the solver's before writing, because 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 whole separation would be undone by the seam -//! meant to respect it. +//! **A write is pushed only when it CHANGED, IN BOTH DIRECTIONS.** The rule has one motive +//! per side and neither side may skip it. +//! +//! - INWARD, because 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 whole separation would be undone by the seam meant to respect it. +//! - OUTWARD, because `World.getMut` marks `changed_tick` UNCONDITIONALLY and the +//! `Changed` filter is built on that mark. Publishing 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. +//! +//! Both halves read before they write. The guard on the outward half 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` TAG AGAINST PUBLICATION, and why it is this way.** An //! island falls asleep at step 11, AFTER steps 6 and 7 wrote its final pose. Sync-out @@ -159,21 +171,36 @@ pub fn syncOut(gpa: std.mem.Allocator, pw: *PhysicsWorld, ecs: *World) !void { // 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 — the symmetric half of the rule + // sync-in applies. `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.getMut(Transform, entity)) |t| { + if (ecs.get(Transform, entity)) |t| { const pose = solverPose(pw, body).?; - t.pos = pose.pos; - t.rot = pose.rot; + if (!std.mem.eql(f32, &t.pos, &pose.pos) or !std.mem.eql(f32, &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. - if (ecs.getMut(Velocity, entity)) |v| { + // 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 lin = pw.bm.linearVelocity(body).?.toArray(); const ang = pw.bm.angularVelocity(body).?.toArray(); - v.linear = .{ @floatCast(lin[0]), @floatCast(lin[1]), @floatCast(lin[2]) }; - v.angular = .{ @floatCast(ang[0]), @floatCast(ang[1]), @floatCast(ang[2]) }; + const out_lin: [3]f32 = .{ @floatCast(lin[0]), @floatCast(lin[1]), @floatCast(lin[2]) }; + const out_ang: [3]f32 = .{ @floatCast(ang[0]), @floatCast(ang[1]), @floatCast(ang[2]) }; + if (!std.mem.eql(f32, &v.linear, &out_lin) or !std.mem.eql(f32, &v.angular, &out_ang)) { + const w = ecs.getMut(Velocity, entity).?; + w.linear = out_lin; + w.angular = out_ang; + } } } diff --git a/tests/physics/transform_sync_test.zig b/tests/physics/transform_sync_test.zig index 87a3200..0204d48 100644 --- a/tests/physics/transform_sync_test.zig +++ b/tests/physics/transform_sync_test.zig @@ -276,3 +276,59 @@ test "authority per BodyType" { try testing.expectEqual(@as(f32, 77), ecs.get(Transform, sta.entity).?.pos[0]); try testing.expectEqual(@as(Real, 40), pw.bm.position(sta.body).?.toArray()[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); + 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. + ecs.beginFrame(); + try sync.stepSynchronised(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) { + ecs.beginFrame(); + try sync.stepSynchronised(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 `syncOut` that publishes NOTHING. + // A gameplay `Velocity` write goes in, the body moves, and the marks must FIRE. + ecs.beginFrame(); + ecs.getMut(Velocity, b.entity).?.linear = .{ 3, 0, 0 }; + try sync.stepSynchronised(gpa, &pw, &ecs); + + // A fresh tick the test does not touch: any stamp below is publication's own. + ecs.beginFrame(); + try sync.stepSynchronised(gpa, &pw, &ecs); + try testing.expect(markedThisTick(&ecs, Transform, b.entity)); + try testing.expect(ecs.get(Transform, b.entity).?.pos[0] > settled_pos[0]); +} diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 0b47ea3..118d828 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -271,9 +271,11 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // (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). return switch (os) { - .windows => 1891, - else => 1893, + .windows => 1892, + else => 1894, }; } From d8ec2d1d31bf90827da89b8a10d63c575340446d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 23 Aug 2026 01:40:43 +0200 Subject: [PATCH 20/23] feat(forge): unify the precision boundary into one named crossing The four private helpers of identical semantics under two names -- widen in mesh.zig, convVec3 in body_manager.zig and again in character.zig, convQuat -- collapse into forge/api/precision.zig. Two of the three vector copies had already diverged: one short-circuited when the scalars coincided and the others did not, which is what two writings of one conversion do. The boundary is written against a named world scalar and never a literal f32, so the day large_world lands the edit is one declaration and not a hunt through call sites. The ECS sync seam is routed through it too: its fourteen narrowing sites were a second boundary in all but name. Verification is the deliverable, not the intention. A new lint rule flags any narrowing in a forge production file outside the boundary, and the widening half -- which has no token to flag -- is caught by the type system on the six f64 cells, where Vec(3,f32) and Vec(3,f64) are distinct types. Co-Authored-By: Claude Opus 5 --- src/modules/forge/api/precision.zig | 162 +++++++++++++ src/modules/forge/api/root.zig | 5 + src/modules/forge/forge_3d/body_manager.zig | 20 +- src/modules/forge/forge_3d/character.zig | 11 +- src/modules/forge/forge_3d/config.zig | 7 + src/modules/forge/forge_3d/mesh.zig | 17 +- src/modules/forge/forge_3d/root.zig | 5 + src/modules/forge/sync.zig | 75 +++--- tools/weld_lint/dead_tests.zig | 8 +- tools/weld_lint/main.zig | 4 +- .../weld_lint/rules/no_precision_crossing.zig | 214 ++++++++++++++++++ tools/weld_lint/tests.zig | 1 + 12 files changed, 455 insertions(+), 74 deletions(-) create mode 100644 src/modules/forge/api/precision.zig create mode 100644 tools/weld_lint/rules/no_precision_crossing.zig diff --git a/src/modules/forge/api/precision.zig b/src/modules/forge/api/precision.zig new file mode 100644 index 0000000..de0a0d7 --- /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` façade 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 4104fc5..afaa218 100644 --- a/src/modules/forge/api/root.zig +++ b/src/modules/forge/api/root.zig @@ -10,6 +10,10 @@ 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. @@ -92,4 +96,5 @@ pub const ClosestPointResult = types.ClosestPointResult; comptime { _ = components; _ = types; + _ = precision; } diff --git a/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index fedc856..da44f71 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -227,9 +227,9 @@ pub const BodyManager = struct { // `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(); + 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 +254,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 +286,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 +2615,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 270c81e..bbc8f39 100644 --- a/src/modules/forge/forge_3d/character.zig +++ b/src/modules/forge/forge_3d/character.zig @@ -1412,7 +1412,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 +2040,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 ba3781e..824f334 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 bd69f0d..d8bdd94 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/root.zig b/src/modules/forge/forge_3d/root.zig index ff570c0..e6cc0ac 100644 --- a/src/modules/forge/forge_3d/root.zig +++ b/src/modules/forge/forge_3d/root.zig @@ -64,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. diff --git a/src/modules/forge/sync.zig b/src/modules/forge/sync.zig index e0510d9..98b6c19 100644 --- a/src/modules/forge/sync.zig +++ b/src/modules/forge/sync.zig @@ -71,24 +71,35 @@ const Velocity = api.Velocity; const Sleeping = api.Sleeping; const PhysicsWorld = forge_3d.PhysicsWorld; const Vec3r = forge_3d.Vec3r; -const Quatr = forge_3d.Quatr; -const Real = forge_3d.Real; + +/// THE precision crossing — see `forge/api/precision.zig`. This file converts in both +/// directions 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]f32, rot: [4]f32 } { +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).?; - const pa = p.toArray(); - return .{ - .pos = .{ @floatCast(pa[0]), @floatCast(pa[1]), @floatCast(pa[2]) }, - .rot = .{ @floatCast(r.x), @floatCast(r.y), @floatCast(r.z), @floatCast(r.w) }, - }; + return .{ .pos = cross.vec3ToWorld(p).toArray(), .rot = cross.quatToWorld(r).toArray() }; } -fn vecFrom(a: [3]f32) Vec3r { - return Vec3r.fromArray(.{ @floatCast(a[0]), @floatCast(a[1]), @floatCast(a[2]) }); +fn vecFrom(a: [3]WorldReal) Vec3r { + return cross.vec3ToSolver(WorldVec3.fromArray(a)); +} + +/// 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(), + }; } /// Push what gameplay owns INTO the solver — before step 1 of the cycle. @@ -109,17 +120,19 @@ pub fn syncIn(gpa: std.mem.Allocator, pw: *PhysicsWorld, ecs: *World) !void { // every tick would keep everything resting on the platform permanently awake. if (ecs.get(Transform, entity)) |t| { const current = solverPose(pw, body).?; - if (!std.mem.eql(f32, ¤t.pos, &t.pos) or !std.mem.eql(f32, ¤t.rot, &t.rot)) { + if (!std.mem.eql(WorldReal, ¤t.pos, &t.pos) or + !std.mem.eql(WorldReal, ¤t.rot, &t.rot)) + { // Through `setBodyTransform` and NOT through a hand-rolled sequence: // that entry already composes the wake, W4 on the retained partners // and the proxy refresh, and a second composition here would be the // one that drifts. - try pw.setBodyTransform(gpa, body, vecFrom(t.pos), Quatr{ - .x = @floatCast(t.rot[0]), - .y = @floatCast(t.rot[1]), - .z = @floatCast(t.rot[2]), - .w = @floatCast(t.rot[3]), - }); + try pw.setBodyTransform( + gpa, + body, + vecFrom(t.pos), + cross.quatToSolver(WorldQuat.fromArray(t.rot)), + ); } } } @@ -129,14 +142,9 @@ pub fn syncIn(gpa: std.mem.Allocator, pw: *PhysicsWorld, ecs: *World) !void { // `Velocity` and the solver applies it — and the write composes wake + write like // any other external mutation. if (ecs.get(Velocity, entity)) |v| { - const lin = pw.bm.linearVelocity(body).?.toArray(); - const ang = pw.bm.angularVelocity(body).?.toArray(); - const same_lin = @as(f32, @floatCast(lin[0])) == v.linear[0] and - @as(f32, @floatCast(lin[1])) == v.linear[1] and - @as(f32, @floatCast(lin[2])) == v.linear[2]; - const same_ang = @as(f32, @floatCast(ang[0])) == v.angular[0] and - @as(f32, @floatCast(ang[1])) == v.angular[1] and - @as(f32, @floatCast(ang[2])) == v.angular[2]; + const held = solverVelocity(pw, body); + const same_lin = std.mem.eql(WorldReal, &held.linear, &v.linear); + const same_ang = std.mem.eql(WorldReal, &held.angular, &v.angular); if (!same_lin or !same_ang) { pw.setLinearVelocity(body, vecFrom(v.linear)); pw.setAngularVelocity(body, vecFrom(v.angular)); @@ -178,7 +186,9 @@ pub fn syncOut(gpa: std.mem.Allocator, pw: *PhysicsWorld, ecs: *World) !void { if (body_type == .dynamic) { if (ecs.get(Transform, entity)) |t| { const pose = solverPose(pw, body).?; - if (!std.mem.eql(f32, &t.pos, &pose.pos) or !std.mem.eql(f32, &t.rot, &pose.rot)) { + 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; @@ -192,14 +202,13 @@ pub fn syncOut(gpa: std.mem.Allocator, pw: *PhysicsWorld, ecs: *World) !void { // 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 lin = pw.bm.linearVelocity(body).?.toArray(); - const ang = pw.bm.angularVelocity(body).?.toArray(); - const out_lin: [3]f32 = .{ @floatCast(lin[0]), @floatCast(lin[1]), @floatCast(lin[2]) }; - const out_ang: [3]f32 = .{ @floatCast(ang[0]), @floatCast(ang[1]), @floatCast(ang[2]) }; - if (!std.mem.eql(f32, &v.linear, &out_lin) or !std.mem.eql(f32, &v.angular, &out_ang)) { + 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_lin; - w.angular = out_ang; + w.linear = out.linear; + w.angular = out.angular; } } } diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index 118d828..c578b34 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -273,9 +273,13 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // 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). return switch (os) { - .windows => 1892, - else => 1894, + .windows => 1902, + else => 1904, }; } diff --git a/tools/weld_lint/main.zig b/tools/weld_lint/main.zig index 82395aa..3cbedfc 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" }; @@ -81,6 +82,7 @@ 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); } std.mem.sort(diag.Diagnostic, diags.items, {}, diag.Diagnostic.lessThan); @@ -319,7 +321,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 0000000..d2f9fe2 --- /dev/null +++ b/tools/weld_lint/rules/no_precision_crossing.zig @@ -0,0 +1,214 @@ +//! Rule `no_precision_crossing` — a forge production file may not narrow a float. +//! +//! `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 +//! 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. +//! +//! **SCOPE, and the residual it leaves.** Production files under `src/modules/forge/`. Test +//! files are excluded, by path, and that is 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 is named and not hidden: a production-grade helper written +//! inside a test file escapes this rule. +//! +//! **THE ESCAPE.** `WELD_NOT_A_WORLD_CROSSING` on the site's own line, for a narrowing that +//! is genuinely not world ↔ solver. It has ZERO users today — measured, not assumed — so its +//! behaviour is established by this file's own tests and by nothing else. The marker is +//! accepted on the site's line only, deliberately narrower than `no_float_reduce`'s +//! line-above allowance: a `@floatCast` is a short expression, the claim fits beside it, and +//! a narrower escape cannot leak onto a neighbour. + +const std = @import("std"); +const diag = @import("../diagnostic.zig"); + +const name = "no_precision_crossing"; + +/// The module this rule governs, on both separators — the runner may pass absolute or +/// relative paths (`no_device_dispatch_outside_gal` precedent). +const module_posix = "src/modules/forge/"; +const module_win = "src\\modules\\forge\\"; + +/// 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. +const not_a_crossing_marker = "WELD_NOT_A_WORLD_CROSSING"; + +/// Hook called by `main.runLint` once per `.zig` file. +/// +/// Tokenizes and flags every `@floatCast` builtin in a forge 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), +) !void { + 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; + if (hasMarkerOnItsLine(source, tok.loc.start)) continue; + + const pos = diag.lineColFromOffset(source, tok.loc.start); + 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", + }); + } +} + +/// Whether this rule speaks about `file`: a forge file, in production, that is not the +/// boundary itself. +fn governs(file: []const u8) bool { + const in_module = std.mem.indexOf(u8, file, module_posix) != null or + std.mem.indexOf(u8, file, module_win) != null; + if (!in_module) 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); +} + +/// 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; + try check(arena_state.allocator(), path, source, &diags); + defer diags.deinit(arena_state.allocator()); + return diags.items.len; +} + +const prod = "src/modules/forge/forge_3d/body_manager.zig"; + +test "a narrowing in a forge production file is flagged" { + try std.testing.expectEqual(@as(usize, 1), try countOn(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( + prod, + "const v = .{ @floatCast(a[0]), @floatCast(a[1]), @floatCast(a[2]) };\n", + )); +} + +test "the boundary file itself is allowed to narrow" { + // Non-vacuity: the SAME source is flagged above under a production path, so what is + // measured here is the path exemption and not a source the rule never flags. + 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" { + 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", + )); +} + +test "the rule speaks only about the forge module" { + try std.testing.expectEqual(@as(usize, 0), try countOn( + "src/core/ecs/world.zig", + "const x: f32 = @floatCast(y);\n", + )); +} + +test "the escape exempts the site it sits on, and only that one" { + try std.testing.expectEqual(@as(usize, 0), try countOn( + prod, + "const x: f32 = @floatCast(y); // WELD_NOT_A_WORLD_CROSSING: two solver widths\n", + )); + // NON-VACUITY: two sites, one marker — exactly one must survive. A rule that exempted + // the whole FILE would report zero here and would still pass the single-site test above. + try std.testing.expectEqual(@as(usize, 1), try countOn(prod, + \\const a: f32 = @floatCast(y); // WELD_NOT_A_WORLD_CROSSING + \\const b: f32 = @floatCast(z); + \\ + )); + // And the marker does NOT reach down from the line above — deliberately narrower than + // `no_float_reduce`'s allowance, so a claim cannot drift onto a neighbour. + try std.testing.expectEqual(@as(usize, 1), try countOn(prod, + \\// WELD_NOT_A_WORLD_CROSSING + \\const b: f32 = @floatCast(z); + \\ + )); +} + +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( + prod, + "/// Never write `@floatCast` outside the boundary.\nconst x = 1;\n", + )); + try std.testing.expectEqual(@as(usize, 0), try countOn( + 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(prod, "const x: u8 = @intCast(y);\n")); + try std.testing.expectEqual(@as(usize, 0), try countOn(prod, "const x = @as(f32, y);\n")); +} diff --git a/tools/weld_lint/tests.zig b/tools/weld_lint/tests.zig index 2f52f78..97ee816 100644 --- a/tools/weld_lint/tests.zig +++ b/tools/weld_lint/tests.zig @@ -32,6 +32,7 @@ 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. _ = @import("dead_tests.zig"); From fd4f4561ae15968b186fff475532b76e5bcd378c Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 23 Aug 2026 02:06:19 +0200 Subject: [PATCH 21/23] feat(interfaces): create PhysicsModule and move its contract onto it src/interfaces/PhysicsModule.zig is the first file of that directory. It is NOT frozen -- the freeze is M1.1.26 -- and it attests that absence rather than leaving it to be read from a silence: a test fails the day WELD_PHYSICS_PROTOCOL_VERSION appears. The comptime assert block is deliberately absent. Surface guards belong to the freeze, a guard covering three of twenty-seven entries would pass an implementation missing the other twenty-four, and the block's first entry needs ModuleContext, which this repository does not declare anywhere -- measured, the name appears in three comments and no declaration. What does land is the contract of the three body pose and velocity entries, moved out of forge/api/types.zig, which had named this file as its destination. Moved and not copied, with a pointer left where it stood. The signatures are written at the world scalar, which makes this file the first consumer of the alias gate E introduced; dt stays f32, carrying no length dimension. Also corrects two more instances of the false struct_size premise than the two the scope named: the class had four members in one file, and leaving two standing beside two corrections is the motif this repository already named. Co-Authored-By: Claude Opus 5 --- build.zig | 16 ++++ src/interfaces/PhysicsModule.zig | 130 ++++++++++++++++++++++++++++ src/modules/forge/api/precision.zig | 2 +- src/modules/forge/api/types.zig | 94 +++++++++----------- src/modules/forge/forge_3d/root.zig | 9 +- tools/weld_lint/dead_tests.zig | 6 +- 6 files changed, 198 insertions(+), 59 deletions(-) create mode 100644 src/interfaces/PhysicsModule.zig diff --git a/build.zig b/build.zig index bdbb6d0..83d5802 100644 --- a/build.zig +++ b/build.zig @@ -173,6 +173,17 @@ pub fn build(b: *std.Build) void { 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, @@ -344,6 +355,11 @@ pub fn build(b: *std.Build) void { 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); diff --git a/src/interfaces/PhysicsModule.zig b/src/interfaces/PhysicsModule.zig new file mode 100644 index 0000000..983ae5c --- /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/precision.zig b/src/modules/forge/api/precision.zig index de0a0d7..555907f 100644 --- a/src/modules/forge/api/precision.zig +++ b/src/modules/forge/api/precision.zig @@ -41,7 +41,7 @@ //! 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` façade would erase the +//! 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. diff --git a/src/modules/forge/api/types.zig b/src/modules/forge/api/types.zig index e02e1d3..c647860 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/root.zig b/src/modules/forge/forge_3d/root.zig index e6cc0ac..5a0dc4b 100644 --- a/src/modules/forge/forge_3d/root.zig +++ b/src/modules/forge/forge_3d/root.zig @@ -260,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. diff --git a/tools/weld_lint/dead_tests.zig b/tools/weld_lint/dead_tests.zig index c578b34..ddda4c7 100644 --- a/tools/weld_lint/dead_tests.zig +++ b/tools/weld_lint/dead_tests.zig @@ -277,9 +277,11 @@ pub fn expectedCollectedOn(os: std.Target.Os.Tag) usize { // 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). return switch (os) { - .windows => 1902, - else => 1904, + .windows => 1904, + else => 1906, }; } From 582c3cd05fbd493fc38d06881933655f48614038 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 23 Aug 2026 02:09:59 +0200 Subject: [PATCH 22/23] docs(brief): journal gate E and its two-halved verification The scope named two false struct_size premises; measurement found four in one file, so the class was swept rather than its two named members. The interface file is created on the narrow reading of its scope line, with both readings and the two measured facts that decided it recorded for review: surface guards belong to the freeze, and ModuleContext is declared nowhere in this repository. Co-Authored-By: Claude Opus 5 --- briefs/m1.1.15-physics-world-orchestration.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index 44ea8fa..89c04ab 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -504,6 +504,116 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se - 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. + ## Recorded deviations - **Files touched outside the FROZEN list, with their justification** (Gate A). @@ -538,6 +648,35 @@ Read in full, in the order the FROZEN SECTION lists them. No skim, no keyword se 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.** From 55410876994f4aeb5e565acf4f8c5d477fbad556 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 23 Aug 2026 07:48:16 +0200 Subject: [PATCH 23/23] docs(claude-md): update for M1.1.15 and close the brief The state table was stale on three lines: PR #71 is merged and v0.11.14-determinism is posted, so the last released tag, the active branch and the current milestone all move. The test floor is re-derived from the suite at 1906 / 1904 and says so, since re-deriving it from the closure is what the dead-tests guard refused once during this milestone. Two open decisions are added as preconditions of the M1.1.26 freeze -- ModuleContext, declared nowhere while the interface types init with it, and the pose setters that cannot stay void now that bodies carry proxies -- and the M1.1.9 precision entry is re-pointed onto the rewritten section 1.11.8. Closing audits: zero French function words and zero accented tokens on the full branch diff and on the brief; the drift sweep found three orphaned references to deleted symbols and patched them in place. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 17 +++++--- briefs/m1.1.15-physics-world-orchestration.md | 43 ++++++++++++++++++- src/modules/forge/forge_3d/body_manager.zig | 6 ++- src/modules/forge/forge_3d/character.zig | 3 +- .../forge/forge_3d/tests/character_test.zig | 5 ++- 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 68808d6..7e73989 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`, tick cycle, ECS `Transform` synchronisation — PR #72 open (draft). Delivers the eleven-step cycle as production code, proxy lifetime including character presences, wake + write composition with cause W4, `moveKinematic`, the `Sleeping` marker, both sync directions, ONE named precision crossing replacing four diverged helpers, and `src/interfaces/PhysicsModule.zig` created UNFROZEN. **The freeze is M1.1.26, not here** — 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, draft, 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 forge production file outside the boundary, 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. 1869 → 1906 collected on `ubuntu-24.04` and macOS (1867 → 1904 on `windows-2025`); `test-forge-3d` 552 → 571. 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,9 @@ 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.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 +356,4 @@ line, and never on a `tail`. --- -Last updated: 2026-08-17 +Last updated: 2026-08-23 diff --git a/briefs/m1.1.15-physics-world-orchestration.md b/briefs/m1.1.15-physics-world-orchestration.md index 89c04ab..c59f8c1 100644 --- a/briefs/m1.1.15-physics-world-orchestration.md +++ b/briefs/m1.1.15-physics-world-orchestration.md @@ -1,12 +1,12 @@ # M1.1.15 — forge_3d orchestration: `PhysicsWorld`, tick cycle, and ECS `Transform` synchronisation -> **Status:** ACTIVE +> **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:** — +> **Closed:** 2026-08-23 --- @@ -826,3 +826,42 @@ not even have to be moved, because it is not needed. 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.** + +- **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/src/modules/forge/forge_3d/body_manager.zig b/src/modules/forge/forge_3d/body_manager.zig index da44f71..fa39004 100644 --- a/src/modules/forge/forge_3d/body_manager.zig +++ b/src/modules/forge/forge_3d/body_manager.zig @@ -225,8 +225,10 @@ 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. + // 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 = config.cross.vec3ToSolver(desc.position), diff --git a/src/modules/forge/forge_3d/character.zig b/src/modules/forge/forge_3d/character.zig index bbc8f39..b9857f2 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); } diff --git a/src/modules/forge/forge_3d/tests/character_test.zig b/src/modules/forge/forge_3d/tests/character_test.zig index 0359bed..c88288d 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