Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ad7fb84
docs(brief): add M1.1.15 milestone brief
guysenpai Aug 21, 2026
ae63cf4
docs(brief): confirm specs read for M1.1.15
guysenpai Aug 21, 2026
f09aa52
docs(brief): activate M1.1.15
guysenpai Aug 21, 2026
17f6451
feat(forge): add PhysicsWorld, owner of the per-tick cycle
guysenpai Aug 21, 2026
2b26f7d
docs(brief): journal gate A and its counter-factuals
guysenpai Aug 21, 2026
c549152
fix(forge): bound the step-trace residual to its measured pair
guysenpai Aug 21, 2026
2e8e9f6
docs(brief): record the CI reds and restore two lost sections
guysenpai Aug 22, 2026
8d7d61b
docs(brief): hash the frozen section and bound the heading check
guysenpai Aug 22, 2026
8c92d65
docs(brief): publish the frozen check as a replayable recipe
guysenpai Aug 22, 2026
9da06ef
feat(forge): insert character presences and count proxies per class
guysenpai Aug 22, 2026
0de043f
docs(brief): journal gate B and its three counter-factuals
guysenpai Aug 22, 2026
eb829ff
feat(forge): compose wake with write and orchestrate cause W4
guysenpai Aug 22, 2026
7676065
feat(forge): move a kinematic body and derive its velocities
guysenpai Aug 22, 2026
2e5b633
docs(brief): journal the moveKinematic resolution
guysenpai Aug 22, 2026
1381f19
feat(forge): add the Sleeping marker and prove zero-size in the ECS
guysenpai Aug 22, 2026
fb0b5ed
feat(forge): synchronise Transform and Velocity with the ECS
guysenpai Aug 22, 2026
107f673
docs(brief): journal gate D and its measured ordering trap
guysenpai Aug 22, 2026
0dec3bc
docs(brief): re-measure every forge count at its own gate head
guysenpai Aug 22, 2026
ccf7842
fix(forge): mark a component changed only when its value changed
guysenpai Aug 22, 2026
d8ec2d1
feat(forge): unify the precision boundary into one named crossing
guysenpai Aug 22, 2026
fd4f456
feat(interfaces): create PhysicsModule and move its contract onto it
guysenpai Aug 23, 2026
582c3cd
docs(brief): journal gate E and its two-halved verification
guysenpai Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
828 changes: 828 additions & 0 deletions briefs/m1.1.15-physics-world-orchestration.md

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,31 @@ pub fn build(b: *std.Build) void {
forge_3d_module.addImport("weld_forge", forge_api_module);
forge_3d_module.addOptions("build_options", forge_build_options);

// M1.1.15 / gate D — `forge/sync.zig`, the ECS <-> solver seam. It is the ONE module
// that sees both sides: `weld_core` for the World and the `Transform`, `weld_forge`
// for the physics components, and `forge_3d` for `PhysicsWorld`. `forge_3d` itself
// keeps its two-import discipline and never learns about the ECS.
const forge_sync_module = b.createModule(.{
.root_source_file = b.path("src/modules/forge/sync.zig"),
.target = target,
.optimize = optimize,
});
forge_sync_module.addImport("weld_core", core_module);
forge_sync_module.addImport("weld_forge", forge_api_module);
forge_sync_module.addImport("forge_3d", forge_3d_module);
forge_sync_module.addImport("foundation", foundation_module);

// M1.1.15 / gate E — `src/interfaces/PhysicsModule.zig`, the Tier 1 physics interface
// and the first file of `src/interfaces/`. NOT frozen: the freeze is M1.1.26. It holds
// the three body pose/velocity contracts moved out of `forge/api/types.zig`, so it needs
// `weld_forge` for `BodyId` and for the world-scalar aliases and nothing else.
const interfaces_physics_module = b.createModule(.{
.root_source_file = b.path("src/interfaces/PhysicsModule.zig"),
.target = target,
.optimize = optimize,
});
interfaces_physics_module.addImport("weld_forge", forge_api_module);

// M0.2 / E6 — plugin loader ABI module shared with the stub
// plugin sub-projects under `tests/core/plugin_loader/stub_plugin/`.
// Exposes the C ABI types from `desc.zig` (no `WeldAPI` itself,
Expand Down Expand Up @@ -327,6 +352,14 @@ pub fn build(b: *std.Build) void {
// inline tests in config/shape/body/body_manager + the acceptance suite
// under forge_3d/tests/. root.zig pins them all. Added to
// `zig build test`; `zig build test-forge-3d` runs just these.
const forge_sync_tests = b.addTest(.{ .root_module = forge_sync_module });
test_step.dependOn(&b.addRunArtifact(forge_sync_tests).step);

// M1.1.15 / gate E — the interface file's own tests: the attestation that no protocol
// version is declared yet, and that the three signatures follow the world scalar.
const interfaces_physics_tests = b.addTest(.{ .root_module = interfaces_physics_module });
test_step.dependOn(&b.addRunArtifact(interfaces_physics_tests).step);

const forge_3d_tests = b.addTest(.{ .root_module = forge_3d_module });
const forge_3d_tests_run = b.addRunArtifact(forge_3d_tests);
test_step.dependOn(&forge_3d_tests_run.step);
Expand Down Expand Up @@ -542,6 +575,10 @@ pub fn build(b: *std.Build) void {
render: bool = false,
/// M0.6 — when set, imports the `weld_asset_pipeline` module.
asset_pipeline: bool = false,
/// M1.1.15 — when set, imports the Forge synchronisation seam and the two
/// modules it joins, so a test can drive a `PhysicsWorld` against a real ECS
/// `World`.
forge: bool = false,
/// M0.6 / E2 — when set, imports the `foundation` module (simd).
foundation: bool = false,
/// M1.0.4 — when set, imports `weld_etch` (the scene cook driver). A
Expand All @@ -558,6 +595,7 @@ pub fn build(b: *std.Build) void {
};
const test_specs = [_]TestSpec{
.{ .path = "tests/smoke_test.zig" },
.{ .path = "tests/physics/transform_sync_test.zig", .forge = true },
.{ .path = "tests/ecs/world_test.zig" },
.{ .path = "tests/ecs/chunk_test.zig" },
.{ .path = "tests/ecs/query_test.zig" },
Expand Down Expand Up @@ -794,6 +832,12 @@ pub fn build(b: *std.Build) void {
if (spec.asset_pipeline) {
t_mod.addImport("weld_asset_pipeline", asset_pipeline_module);
}
if (spec.forge) {
t_mod.addImport("weld_forge", forge_api_module);
t_mod.addImport("forge_3d", forge_3d_module);
t_mod.addImport("forge_sync", forge_sync_module);
t_mod.addImport("foundation", foundation_module);
}
if (spec.foundation) {
t_mod.addImport("foundation", foundation_module);
}
Expand Down
130 changes: 130 additions & 0 deletions src/interfaces/PhysicsModule.zig
Original file line number Diff line number Diff line change
@@ -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.?);
}
57 changes: 57 additions & 0 deletions src/modules/forge/api/components.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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);
}
Loading