import { assert } from "../../../core/assert.js";
import Quaternion from "../../../core/geom/Quaternion.js";
import Vector3 from "../../../core/geom/Vector3.js";
import { clamp } from "../../../core/math/clamp.js";
import { DEG_TO_RAD } from "../../../core/math/DEG_TO_RAD.js";
import { lerp } from "../../../core/math/lerp.js";
import { ResourceAccessKind } from "../../../core/model/ResourceAccessKind.js";
import { ResourceAccessSpecification } from "../../../core/model/ResourceAccessSpecification.js";
import { SerializationMetadata } from "../../ecs/components/SerializationMetadata.js";
import Entity from "../../ecs/Entity.js";
import { System } from "../../ecs/System.js";
import { Transform } from "../../ecs/transform/Transform.js";
import { Camera } from "../../graphics/ecs/camera/Camera.js";
import { EyeOffsetStack } from "./composer/EyeOffsetStack.js";
import { Ray3 } from "../../../core/geom/3d/ray/Ray3.js";
import { CapsuleShape3D } from "../../../core/geom/3d/shape/CapsuleShape3D.js";
import { TransformedShape3D } from "../../../core/geom/3d/shape/TransformedShape3D.js";
import { BodyKind } from "../../physics/ecs/BodyKind.js";
import { Collider } from "../../physics/ecs/Collider.js";
import { PhysicsSystem } from "../../physics/ecs/PhysicsSystem.js";
import { RigidBody } from "../../physics/ecs/RigidBody.js";
import { PhysicsSurfacePoint } from "../../physics/queries/PhysicsSurfacePoint.js";
import { FirstPersonPlayerController } from "./FirstPersonPlayerController.js";
import { DecisionPoint } from "./mastery/DecisionPoint.js";
import { computeJumpFromApex } from "./math/computeJumpFromApex.js";
import { computeLRCBreathRate } from "./math/computeLRCBreathRate.js";
import { computeMassRatios } from "./math/computeMassRatios.js";
import { Spring } from "./math/Spring.js";
import { stepTowards } from "./math/stepTowards.js";
import { FirstPersonActionState, FirstPersonLocomotionMode } from "./pose/FirstPersonPose.js";
import { FirstPersonPosture } from "./pose/FirstPersonPosture.js";
import { FirstPersonSensors } from "./sensors/FirstPersonSensors.js";
import { KinematicMover } from "./collision/KinematicMover.js";

// ---------------------------------------------------------------------------
// Scratch allocations — reused per frame to avoid GC pressure
// ---------------------------------------------------------------------------
const SCRATCH_V3_A = new Vector3();
const SCRATCH_V3_B = new Vector3();
const SCRATCH_V3_C = new Vector3();
const SCRATCH_Q_A = new Quaternion();
const SCRATCH_Q_B = new Quaternion();
const SCRATCH_Q_C = new Quaternion();

const TWO_PI = Math.PI * 2;
const LN2 = Math.log(2);

/**
 * Build a posture-sized player capsule: a {@link CapsuleShape3D} of
 * `radius` and the appropriate cylinder height, wrapped in a
 * {@link TransformedShape3D} whose Y offset puts the capsule's bottom
 * exactly at the wrapped shape's local origin. The entity's
 * `transform.position` then represents the player's feet — and a
 * posture-driven shrink doesn't yank the feet up the way a centred
 * capsule would, nor dip them below the floor.
 *
 * The capsule's lowest point in its own local frame is at
 *   `-(cylinderHeight/2 + radius) = -max(totalHeight/2, radius)`.
 * Offsetting the wrapper by the magnitude of that puts the bottom at
 * Y = 0:
 *   - Stand (`H = 1.8`, `r = 0.34`): cylHeight = 1.12, offset = 0.9.
 *     Bottom = -0.9 + 0.9 = 0. Top = +0.9 + 0.9 = 1.8.
 *   - Crouch (`H = 0.8`, `r = 0.34`): cylHeight = 0.12, offset = 0.4.
 *     Bottom = -0.4 + 0.4 = 0. Top = +0.4 + 0.4 = 0.8.
 *   - Prone (`H = 0.4`, `r = 0.34`): cylHeight = 0 (capsule collapses
 *     to a sphere of radius), offset = max(0.2, 0.34) = 0.34.
 *     Bottom = -0.34 + 0.34 = 0. Top = +0.34 + 0.34 = 0.68. The
 *     `totalHeight = 0.4` value is honoured for the offset budget
 *     but the actual Y extent floors at `2·radius`.
 *
 * Picking `totalHeight/2` blindly (the obvious choice) would put the
 * Prone capsule's bottom at `0.2 - 0.34 = -0.14` — dipping below the
 * feet, and into any physics floor that's flush with feet level. On
 * a physics ground slab, every horizontal shape_cast from inside the
 * floor returns t = 0, `advance = max(0, t - SKIN) = 0`, and the
 * slide freezes in place — see SlideMotion.spec.js for the
 * regression test that pins this.
 *
 * @param {number} radius — capsule radius in metres
 * @param {number} totalHeight — desired full Y extent; ignored below
 *   `2·radius` (the capsule's intrinsic minimum extent)
 * @returns {TransformedShape3D}
 */
function makePostureCapsule(radius, totalHeight) {
    const cylinderHeight = Math.max(0, totalHeight - 2 * radius);
    const yOffset = Math.max(totalHeight / 2, radius);
    return TransformedShape3D.from_translation(
        CapsuleShape3D.from(radius, cylinderHeight),
        [0, yOffset, 0],
    );
}

/**
 * Per-entity runtime state the system maintains internally — too transient
 * even for {@link FirstPersonPlayerController}'s `state` member, because it
 * encodes input-edge bookkeeping and timer values the public surface should
 * never see directly.
 */
class PerEntityRuntime {
    constructor() {
        /**
         * Co-attached kinematic body. Set by {@link FirstPersonPlayerControllerSystem.link}
         * after asserting it's present. The controller writes Transform.position
         * directly (existing motion logic); physics derives the body's velocity
         * from the per-step delta. Other physics systems (raycasts, contact
         * events) see the player through this body.
         * @type {RigidBody|null}
         */
        this.rigidBody = null;

        /**
         * Co-attached collider, cached at link. Same source the physics
         * narrowphase uses, so move-and-slide casts the player's
         * actual collision shape against the world.
         * @type {Collider|null}
         */
        this.collider = null;

        /**
         * Pre-built capsule colliders, one per posture. Cached at link
         * from `config.body.{height, crouchHeight, proneHeight, radius}`
         * so {@link _syncColliderShape} can swap the collider's shape on
         * a posture change with zero per-tick allocation. Hang reuses
         * Stand (the player's body is full-extent, just hanging below
         * the ledge — the rig animates the arms-up pose). Sentinel
         * `lastPosture = -1` forces a sync on the first tick after
         * link, so the initial shape always matches Stand.
         * @private
         * @type {TransformedShape3D|null}
         */
        this.colliderShapeStand = null;
        /** @private @type {TransformedShape3D|null} */
        this.colliderShapeCrouch = null;
        /** @private @type {TransformedShape3D|null} */
        this.colliderShapeProne = null;
        /** @private */
        this.lastPosture = -1;

        /** Eye pitch in radians, clamped to config.look limits. */
        this.eyePitch = 0;
        /** Body yaw in radians (around world up). */
        this.bodyYaw = 0;
        /** Yaw rate (rad/s) computed in look consumption — for evaluators. */
        this.yawRateRadPerSec = 0;

        /** Horizontal+vertical velocity. We integrate these inside the system
         *  when no external physics layer is attached. */
        this.velocityX = 0;
        this.velocityY = 0;
        this.velocityZ = 0;

        /** Previous-tick jump intent — for rising/falling edge detection. */
        this.prevJumpHeld = false;
        /** Previous-tick crouch intent — for toggle-mode edge detection. */
        this.prevCrouchHeld = false;
        /** True while crouch toggle is latched on (used only in toggle mode). */
        this.crouchLatched = false;

        /** Remaining time in jump anticipation, or <= 0 if not anticipating. */
        this.anticipationRemaining = 0;
        /** Cached derived gravity (m/s^2) from peakHeight + timeToApex. */
        this.gravity = 9.81;
        /** Cached derived jump impulse (m/s upward), post-mass-scaling. */
        this.jumpInitialVy = 5.0;
        /**
         * Cached mass scaling factors — computed once at link. See
         * {@link computeMassRatios}. Heavier ⇒ lower jumpV0Scale, lower
         * groundAccelScale, higher landingDipScale + exertionRiseScale.
         */
        this.massRatios = null;

        /** Spring for landing dip (under-damped → rings after impact). */
        this.landSpring = new Spring();
        /** Spring for FOV (critically damped). */
        this.fovSpring = new Spring(70);
        /** Spring for eye height (crouch transition). */
        this.eyeHeightSpring = new Spring(1.80);
        /** Spring for lean roll (radians) — banks into lateral acceleration. */
        this.leanSpring = new Spring();
        /**
         * Lean target this tick (radians). Always set; L2.f spring-steps
         * toward this value. Whoever owned motion this tick wrote it:
         * base writes the lat-accel + look-lean derived value at the end
         * of {@link _runBaseLocomotion}; abilities that want to override
         * (WallRun → tilt-into-wall, Slide/Mantle/LedgeGrab → zero) write
         * their own value in tick. Uniform channel — no null sentinel.
         */
        this.leanTargetRad = 0;

        /** Previous horizontal velocity — for lateral acceleration → lean. */
        this.prevVelocityX = 0;
        this.prevVelocityZ = 0;

        /** Previous-tick grounded for edge detection. */
        this.prevGrounded = true;
        /** Vertical speed at moment of last "leave ground". */
        this.takeoffVy = 0;
        /** Max vertical position since last takeoff — for jump apex detection. */
        this.peakAltitude = 0;
        /** Set true once a jump has been launched; cleared on land. */
        this.midJump = false;
        /** Apex already fired for this airborne segment? */
        this.apexFired = false;

        /** Stride phase from previous fixed step — for footstep edge detection. */
        this.prevStridePhase = 0;
        /** Breath phase from previous fixed step — for inhale/exhale edge detection. */
        this.prevBreathPhase = 0;
        /** Which foot fires next — flipped on each footstep signal. */
        this.nextFootSide = "R";
        /**
         * Which foot is currently bearing the body's weight (the foot that
         * most recently landed). Drives the lateral-bob direction: at R
         * midstance the COM is over the right foot, so the head shifts
         * laterally toward screen-right; at L midstance the opposite.
         * Coupled to the same signal the footstep emits, so anything that
         * listens to onFootStep.side will see the bob agree.
         * Initialized "L" so the very first footstep fires "R" and the
         * standingFoot updates to "R" — putting the head laterally right
         * during the first half-stride, as expected.
         */
        this.standingFoot = "L";

        /**
         * [0..1] How "backward" the player is currently moving. Derived in
         * fixedUpdate from velocity · screen-forward, normalized to sprint
         * speed. Drives the gait wobble amplifier on the L3 camera-composition
         * pass. Stored on runtime (rather than state) because it's a render-
         * side input — downstream observers should look at velocity directly.
         */
        this.backwardness = 0;

        /**
         * Smoothed bob amplitude envelope. Target = max(speedNormalized,
         * backwardness) when grounded, 0 airborne. Spring decay prevents
         * the whiplash where stopping motion would snap the bob to neutral.
         */
        this.bobIntensitySpring = new Spring();

        /**
         * Vertical impact spring — kicked downward at each footfall, decays
         * with a slight under-damped overshoot. Produces the impact-arrest +
         * leg-push curve. value units: meters (added directly to eyeLocal.y).
         */
        this.verticalImpactSpring = new Spring();

        /**
         * Sprint-posture spring — eye pitches forward as the player commits
         * to a sprint, returns to neutral when they slow. Value is in
         * radians; slower half-life than other springs so it feels like
         * a posture change rather than an input twitch. See cfg.posture.
         */
        this.sprintPostureSpring = new Spring();

        /**
         * Head-droop spring — additional forward pitch as exertion rises.
         * Sells fatigue subtly. Target tracks exertion-driven max droop
         * angle; spring lag keeps the transition slow and physical.
         */
        this.headDroopSpring = new Spring();

        /**
         * [0..1] sprintness — how much of the walk→sprint speed range the
         * body is currently in. Computed in fixedUpdate, read by L3 for FOV
         * and the sprint-posture pitch / forward-shift offset.
         */
        this.sprintness = 0;

        /**
         * Cached sin/cos of current body yaw — written once per fixedUpdate
         * after look intent is consumed, read by every downstream step
         * (locomotion, backwardness, lean look-rate, pose channels). Avoids
         * recomputing the trig 3+ times per tick.
         */
        this.sinYaw = 0;
        this.cosYaw = 1;

        /** Cached horizontal speed (m/s) for this tick — written in derived-state. */
        this.horizSpeed = 0;

        /** Cached stride frequency (Hz) for this tick — written in breath block, read by stride. */
        this.strideFreqHz = 0;

        /**
         * Additive accumulator for body-local eye-position offsets. The
         * system pushes its own contributions (bob, breath, landing,
         * sprint posture) each render frame; external systems can push
         * recoil/shake/knockback contributions via the same interface.
         */
        this.eyeOffsetStack = new EyeOffsetStack();

        /**
         * Spatial-query results populated by {@link FirstPersonSensorsSystem}
         * (when present). Abilities and the locomotion FSM read this.
         * Lives on runtime so other systems can populate it without
         * touching the controller component's public surface.
         */
        this.sensors = new FirstPersonSensors();

        /** Cached eye entity ID. -1 until link assigns it. */
        this.eyeEntity = -1;
    }
}

/**
 * Drives a first-person camera + body from intent fields. See sibling
 * DESIGN.md for goals, architecture, and the five processing layers (L0..L4).
 *
 * - fixedUpdate runs L1 (locomotion), L2 (pose state), and L4 (events) so
 *   the simulation remains deterministic.
 * - update runs L3 (camera composition) at render rate so the eye is never
 *   smoother than the screen.
 *
 * The system itself integrates a simple flat-floor at y = `config.gravity.magnitude > 0
 * ? state.groundY : -Infinity` for the prototype. A real physics layer should
 * write `state.grounded`/`state.groundNormal` from outside instead; the
 * built-in resolver is just a convenience to keep the controller usable
 * without dependencies.
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class FirstPersonPlayerControllerSystem extends System {
    constructor() {
        super();

        // Dependencies kept to (controller, transform) so we can ASSERT on
        // RigidBody at link time and emit a clear error if missing. If
        // RigidBody were a hard dep, entities lacking one would silently
        // never link — the controller would appear inert with no
        // diagnostic. The assert below catches the missing-body case
        // explicitly.
        this.dependencies = [FirstPersonPlayerController, Transform];

        this.components_used = [
            ResourceAccessSpecification.from(Transform, ResourceAccessKind.Write),
            ResourceAccessSpecification.from(Camera, ResourceAccessKind.Write),
            ResourceAccessSpecification.from(RigidBody, ResourceAccessKind.Write),
        ];

        /**
         * Per-entity runtime, keyed by entity id.
         * @type {Map<number, PerEntityRuntime>}
         */
        this.runtime = new Map();

        /**
         * If true, the system clamps body y >= groundY and writes
         * state.grounded itself. Turn off when wiring a real physics layer.
         * @type {boolean}
         */
        this.useBuiltInFlatGround = true;

        /**
         * The flat-ground y for the built-in resolver. Ignored when
         * useBuiltInFlatGround is false.
         * @type {number}
         */
        this.groundY = 0;

        /**
         * Optional callback that returns the surface Y under the player
         * for ground resolution. Called each tick with the player's
         * current (x, y, z); returns the world-Y of the ground below,
         * or null if no ground is below (gap / void).
         *
         * Combines with `useBuiltInFlatGround`: the effective ground for
         * the tick is `max(this.groundY when enabled, resolver(...))`.
         * Set both off (`useBuiltInFlatGround=false`, `groundResolver=null`)
         * to defer to external physics entirely.
         *
         * Designed for prototypes / gyms that need elevated platforms
         * without a full physics layer. Production should wire a real
         * physics system instead.
         *
         * @type {((x:number, y:number, z:number) => number|null) | null}
         */
        this.groundResolver = null;

        /**
         * PhysicsSystem reference. Auto-acquired at startup; can be
         * overridden by the caller. When present, collision is resolved
         * by the {@link KinematicMover}; when null, the controller uses
         * the flat-ground integrator ({@link _moveFlatGround}) — useful
         * for spec setups that don't wire physics.
         * @type {PhysicsSystem|null}
         */
        this.physicsSystem = null;

        /**
         * The collision solver — the authoritative move resolver whenever
         * a {@link PhysicsSystem} is present (recover + unified
         * sweep-and-slide + ground-categorize; see DESIGN_COLLISION.md).
         * Lazily built on first use. With no physics the controller falls
         * back to a flat-ground integrator ({@link _moveFlatGround}).
         * @private
         * @type {KinematicMover|null}
         */
        this._mover = null;
        /** @private Scratch velocity handed to the mover each tick. */
        this._moverVelocity = new Vector3();
        /** @private Scratch for the posture-headroom up-cast. */
        this._postureRay = new Ray3();
        /** @private @type {PhysicsSurfacePoint} */
        this._postureHit = new PhysicsSurfacePoint();
    }

    async startup(entityManager) {
        this.entityManager = entityManager;
        if (this.physicsSystem === null) {
            const ps = entityManager.getSystem(PhysicsSystem);
            if (ps !== null) this.physicsSystem = ps;
        }
    }

    /**
     * @param {FirstPersonPlayerController} controller
     * @param {Transform} bodyTransform
     * @param {number} entity
     */
    link(controller, bodyTransform, entity) {
        const ecd = this.entityManager.dataset;

        // The controller assumes a kinematic-position RigidBody is co-
        // attached on this entity. The body is the spatial proxy used
        // for sensor raycasts and physics-side observers (other entities
        // raycasting against the player, dynamic bodies colliding with
        // the capsule, etc.). The controller writes Transform directly,
        // physics derives velocity from the per-step delta. If a body is
        // missing the controller could still drive the camera, but the
        // physics integration silently breaks — assert here so the
        // misconfiguration is caught at link time.
        const rigidBody = ecd.getComponent(entity, RigidBody);
        assert.ok(rigidBody !== undefined,
            "FirstPersonPlayerController entity must have a co-attached RigidBody "
            + "(kinematic capsule). See prototype_first_person_controller.js for setup.");
        assert.equal(rigidBody.kind, BodyKind.KinematicPosition,
            "FirstPersonPlayerController RigidBody must be BodyKind.KinematicPosition; "
            + "the controller owns the Transform and physics derives velocity.");
        // Collider is also required — the KinematicMover casts this shape
        // against the world (recover / sweep / ground probe). Asserted
        // here so a missing collider surfaces at link rather than
        // producing a null-deref at the first cast attempt.
        const collider = ecd.getComponent(entity, Collider);
        assert.ok(collider !== undefined,
            "FirstPersonPlayerController entity must have a co-attached Collider. "
            + "The controller's move-and-slide casts this shape to detect blockers.");

        const runtime = new PerEntityRuntime();
        runtime.rigidBody = rigidBody;
        runtime.collider = collider;

        // Pre-build one capsule per posture from cfg.body. Eye-height
        // doubles as collider-top by convention here — the prototype's
        // `buildPlayerEntity` uses the same approximation (`totalHeight =
        // bodyCfg.height`). The +Y offset puts the capsule bottom at
        // transform.position so the player's "feet" stay anchored across
        // posture changes; only the head drops/rises.
        const radius = controller.config.body.radius;
        runtime.colliderShapeStand = makePostureCapsule(radius, controller.config.body.height);
        runtime.colliderShapeCrouch = makePostureCapsule(radius, controller.config.body.crouchHeight);
        runtime.colliderShapeProne = makePostureCapsule(radius, controller.config.body.proneHeight);
        // Cache each posture capsule's ACTUAL top (bbox maxY) — not the
        // nominal config height. A capsule's height floors at 2·radius,
        // so e.g. a 0.4 m "prone" height actually yields a 0.68 m
        // capsule. The headroom up-cast sweeps by the difference of real
        // tops; using config heights would over-/under-estimate the grow
        // and clamp to the wrong posture (Prone instead of Crouch).
        const bbox = new Float64Array(6);
        runtime.colliderShapeStand.compute_bounding_box(bbox);
        runtime.standTop = bbox[4];
        runtime.colliderShapeCrouch.compute_bounding_box(bbox);
        runtime.crouchTop = bbox[4];
        runtime.colliderShapeProne.compute_bounding_box(bbox);
        runtime.proneTop = bbox[4];
        // Force a shape sync on the first tick: even though the caller
        // built a Stand-sized collider, we rebuild it from cfg here so a
        // post-link config tweak (e.g. crouchHeight changed for a unit
        // test) is reflected on the live collider without a relink.
        runtime.lastPosture = -1;

        this.runtime.set(entity, runtime);

        // Derive gravity + jump impulse from designer-friendly params, then
        // mass-scale the initial velocity (heavier ⇒ lower jump).
        runtime.massRatios = computeMassRatios(
            controller.config.body.mass,
            controller.config.body.referenceMass,
            controller.config.body.massCouplingStrength,
        );
        const derived = { gravity: 0, initialVelocity: 0 };
        computeJumpFromApex(controller.config.jump.peakHeight, controller.config.jump.timeToApex, derived);
        runtime.gravity = derived.gravity;
        runtime.jumpInitialVy = derived.initialVelocity * runtime.massRatios.jumpV0Scale;

        // Seed yaw from the starting body rotation. `toEulerAnglesYXZ`
        // returns (pitch, yaw, roll) — we only care about y.
        bodyTransform.rotation.toEulerAnglesYXZ(SCRATCH_V3_A);
        runtime.bodyYaw = SCRATCH_V3_A.y;
        runtime.eyePitch = 0;

        // Initialize springs to standing-eye-height baseline
        runtime.eyeHeightSpring.settle(controller.config.body.height);
        runtime.fovSpring.settle(controller.config.fov.base);
        controller.state.eyeHeight = controller.config.body.height;

        // Create eye entity if one wasn't supplied
        if (controller.eyeEntity === -1 || !ecd.entityExists(controller.eyeEntity)) {
            const eye = new Entity();

            const eyeTransform = new Transform();
            const baseEyePos = SCRATCH_V3_A.copy(bodyTransform.position);
            baseEyePos.y += controller.config.body.height;
            eyeTransform.position.copy(baseEyePos);

            const camera = new Camera();
            camera.active.set(true);
            camera.fov.set(controller.config.fov.base);
            camera.clip_near = 0.05;
            camera.clip_far = 1000;
            camera.autoClip = false;

            eye.add(eyeTransform);
            eye.add(camera);
            eye.add(SerializationMetadata.Transient);

            eye.build(ecd);

            controller.eyeEntity = eye.id;
        }

        runtime.eyeEntity = controller.eyeEntity;
    }

    /**
     * @param {FirstPersonPlayerController} controller
     * @param {Transform} bodyTransform
     * @param {number} entity
     */
    unlink(controller, bodyTransform, entity) {
        const ecd = this.entityManager.dataset;

        if (controller.eyeEntity !== -1 && ecd.entityExists(controller.eyeEntity)) {
            ecd.removeEntity(controller.eyeEntity);
            controller.eyeEntity = -1;
        }

        this.runtime.delete(entity);
    }

    /**
     * Look up the per-entity runtime for an entity that has this
     * controller. Used by cross-system code (sensors system, future
     * ability-driven systems) to reach internal state without leaking
     * it onto the controller component itself.
     *
     * @param {number} entity
     * @returns {PerEntityRuntime|undefined}  undefined if entity is not linked
     */
    getRuntime(entity) {
        return this.runtime.get(entity);
    }

    /**
     * Deterministic simulation step — L1 + L2 + L4.
     * @param {number} dt
     */
    fixedUpdate(dt) {
        const ecd = this.entityManager.dataset;
        if (ecd === null) return;

        this._currentDt = dt;
        ecd.traverseComponents(FirstPersonPlayerController, this._tickEntity, this);
    }

    /**
     * Variable-rate camera composition — L3.
     * @param {number} dt
     */
    update(dt) {
        const ecd = this.entityManager.dataset;
        if (ecd === null) return;

        this._currentRenderDt = dt;
        ecd.traverseComponents(FirstPersonPlayerController, this._composeEye, this);
    }

    /**
     * @private
     * @param {FirstPersonPlayerController} controller
     * @param {number} entity
     */
    _tickEntity(controller, entity) {
        const ecd = this.entityManager.dataset;
        const runtime = this.runtime.get(entity);
        if (runtime === undefined) return;

        const dt = this._currentDt;
        const cfg = controller.config;
        const intent = controller.intent;
        const state = controller.state;
        const sig = controller.signals;

        const bodyTransform = ecd.getComponent(entity, Transform);
        if (bodyTransform === undefined) return;

        // Decay the mastery score's EMA. Doing this once per tick keeps the
        // score's time-window characteristic stable regardless of how many
        // evaluators fire (they each *record* a sample, the decay
        // independently ages all samples).
        controller.mastery.tick(dt);

        // -- L1.a: Consume look delta -----------------------------------
        // intent.look is zeroed after consume so accumulated input doesn't
        // re-apply on the next fixed step.
        //
        // Conventions (with raw mouse delta as the source — movementX/Y both
        // positive when moving right/down):
        //   look.x > 0 ("mouse right")  → turn right
        //   look.y > 0 ("mouse down")   → look down  (flipped by invertY)
        //
        // The yaw sign is negated because the engine uses left-handed
        // coordinates with +Z as forward; a positive Y-axis rotation takes
        // +Z toward +X, which presents to the player as a LEFT turn through
        // the Three.js camera (`quaternion_invert_orientation`). Negating
        // here gives the player-intuitive "mouse right → turn right".
        const yawDelta = -intent.look.x;
        const pitchSign = cfg.look.invertY ? -1 : 1;
        const pitchDelta = intent.look.y * pitchSign;
        intent.look.set(0, 0);

        // Cache yaw rate for mastery evaluators (look-lean, foot-asymmetry-
        // turn, etc.). Rad/s, signed (negative = turning right in our
        // convention — matches yawDelta).
        runtime.yawRateRadPerSec = yawDelta / Math.max(dt, 1e-4);

        runtime.bodyYaw += yawDelta;
        // keep yaw bounded (purely cosmetic — sin/cos handle wraparound fine)
        if (runtime.bodyYaw > Math.PI) runtime.bodyYaw -= TWO_PI;
        else if (runtime.bodyYaw < -Math.PI) runtime.bodyYaw += TWO_PI;

        runtime.eyePitch = clamp(
            runtime.eyePitch + pitchDelta,
            cfg.look.pitchMinDeg * DEG_TO_RAD,
            cfg.look.pitchMaxDeg * DEG_TO_RAD,
        );

        // Write body yaw back to transform (pure yaw, no pitch on body)
        bodyTransform.rotation.fromAxisAngle(Vector3.up, runtime.bodyYaw);

        // -- Shared flags. Computed BEFORE the ability tick so abilities
        //    can read them. `isCrouchActive` is deliberately computed
        //    AFTER the ability tick because `_resolveCrouchHeld` mutates
        //    `runtime.prevCrouchHeld` — abilities like Slide need to see
        //    the previous-tick value to detect a rising edge on the
        //    crouch press.
        const isSprintIntent = intent.sprint && intent.move.y > 0.5 && state.grounded;
        const isBackwardIntent = intent.move.y < 0;
        runtime.sinYaw = Math.sin(runtime.bodyYaw);
        runtime.cosYaw = Math.cos(runtime.bodyYaw);
        // L2 observers read sinYaw/cosYaw as locals — destructure once.
        const { sinYaw, cosYaw } = runtime;

        // -- Ability layer: at most one active ability owns motion. The
        //    set returns true when no ability owned the tick, in which
        //    case base L1.b-h runs below; false means an ability fully
        //    handled this tick (it called the system's helpers for any
        //    standard work it wanted to keep, e.g. gravity).
        const runBaseLocomotion = controller.abilities.tick(
            controller, runtime, bodyTransform, runtime.sensors, dt, this,
        );

        // Now resolve crouch (updates prevCrouchHeld) — used by base and L2.
        // Headroom-aware: force-keeps the player crouched under an overhang.
        const isCrouchActive = this._resolveCrouchHeld(controller, runtime, bodyTransform);

        if (runBaseLocomotion) {
            this._runBaseLocomotion(
                controller, runtime, bodyTransform, dt,
                isCrouchActive, isSprintIntent, isBackwardIntent,
            );
        }

        // (everything below this line runs every tick — L2 observers don't
        //  care who owned motion)

        // -- L2.a: speed / moveMode ------------------------------------
        // -- L2.a: speed / moveMode ------------------------------------
        const horizSpeed = Math.hypot(runtime.velocityX, runtime.velocityZ);
        runtime.horizSpeed = horizSpeed;
        state.speed = horizSpeed;
        state.speedNormalized = clamp(horizSpeed / Math.max(cfg.motion.sprintSpeed, 1e-3), 0, 1);

        // Backwardness: 0 = moving forward (or sideways), 1 = moving directly
        // backward at the back-pedal speed ceiling. Derived from the actual
        // velocity (not the intent) so external knockback or stuck states
        // also register as "moving backward" and the gait wobble reflects it.
        //
        // Reference speed is the *achievable* backward max — walkSpeed ×
        // backwardSpeedFactor — NOT the sprint speed. Backward can never
        // reach sprint, so normalizing against sprint would cap backwardness
        // at ~0.3 and the wobble multipliers below would barely apply.
        const screenFwdVel = runtime.velocityX * sinYaw + runtime.velocityZ * cosYaw;
        const maxBackwardSpeed = Math.max(cfg.motion.walkSpeed * cfg.motion.backwardSpeedFactor, 1e-3);
        runtime.backwardness = clamp(-screenFwdVel / maxBackwardSpeed, 0, 1);

        // Locomotion mode is the *intent-driven* horizontal mode. Airborne
        // state is tracked separately on pose.actionState — they're
        // orthogonal facets (you can be Sprint+Airborne after a jump).
        const prevLocomotionMode = state.locomotionMode;
        if (isCrouchActive) {
            state.locomotionMode = FirstPersonLocomotionMode.Crouch;
        } else if (isSprintIntent && horizSpeed > 0.1) {
            state.locomotionMode = FirstPersonLocomotionMode.Sprint;
        } else if (horizSpeed > 0.1) {
            state.locomotionMode = FirstPersonLocomotionMode.Walk;
        } else {
            state.locomotionMode = FirstPersonLocomotionMode.Idle;
        }

        if (state.locomotionMode === FirstPersonLocomotionMode.Sprint
            && prevLocomotionMode !== FirstPersonLocomotionMode.Sprint) {
            sig.onSprintStart.send0();
        } else if (prevLocomotionMode === FirstPersonLocomotionMode.Sprint
            && state.locomotionMode !== FirstPersonLocomotionMode.Sprint) {
            sig.onSprintStop.send0();
        }

        // -- L2.b: Exertion --------------------------------------------
        // Heavier bodies tire faster — sprint rise scales with massRatios.exertionRiseScale.
        const exertionRise = isSprintIntent
            ? cfg.exertion.sprintRiseRate * runtime.massRatios.exertionRiseScale
            : 0;
        const exertionFall = exertionRise > 0 ? 0 : cfg.exertion.idleDecayRate;
        state.exertion = clamp(state.exertion + (exertionRise - exertionFall) * dt, 0, 1);

        // -- L2.c: Breath ----------------------------------------------
        // breathRate and breathAmplitude lag exertion through separate
        // exponential decays. Rate hangs around longer than amplitude.
        const metabolicRate = lerp(cfg.breath.rateRestHz, cfg.breath.rateMaxHz, state.exertion);
        const targetAmp = lerp(cfg.breath.amplitudeRestM, cfg.breath.amplitudeMaxM, state.exertion);

        // Locomotor-respiratory coupling — see math/computeLRCBreathRate.
        // The pure function is unit-tested; this site just provides inputs.
        //
        // Gait is gated on a "feet strike the ground" posture (Stand /
        // Crouch). Prone (slide) and Hang (ledge-grab) have no stride —
        // the body's feet are not making contact in a walking pattern,
        // so stride frequency drops to zero and downstream gait
        // signals (footsteps, bob intensity) go quiet.
        const feetStriking = state.posture === FirstPersonPosture.Stand
            || state.posture === FirstPersonPosture.Crouch;
        const strideFreqHz = feetStriking && state.grounded && horizSpeed > cfg.bob.minStepSpeed
            ? cfg.bob.stepFreqAtWalk * Math.pow(
                Math.max(horizSpeed, 1e-3) / Math.max(cfg.motion.walkSpeed, 1e-3),
                cfg.bob.stepFreqExp,
            )
            : 0;
        const targetRate = computeLRCBreathRate(
            metabolicRate,
            strideFreqHz,
            state.exertion,
            cfg.breath.locomotorCouplingMax,
            cfg.breath.couplingMinStrideFreqHz,
        );
        state.breathRateHz = exponentialApproach(state.breathRateHz, targetRate, cfg.exertion.rateDecayHalfLife, dt);
        state.breathAmplitudeM = exponentialApproach(state.breathAmplitudeM, targetAmp, cfg.exertion.ampDecayHalfLife, dt);

        runtime.prevBreathPhase = state.breathPhase;
        state.breathPhase += state.breathRateHz * dt;
        state.breathPhase -= Math.floor(state.breathPhase); // wrap [0,1)

        // Breath edge detection — inhale at 0.25, exhale at 0.75
        if (phaseCrossed(runtime.prevBreathPhase, state.breathPhase, 0.25)) {
            sig.onBreathIn.send1({ amplitude: state.breathAmplitudeM, rateHz: state.breathRateHz });
        }
        if (phaseCrossed(runtime.prevBreathPhase, state.breathPhase, 0.75)) {
            sig.onBreathOut.send1({ amplitude: state.breathAmplitudeM, rateHz: state.breathRateHz });
        }

        // -- L2.d: Stride ----------------------------------------------
        // strideFreqHz computed above in the breath block; reused here.
        runtime.prevStridePhase = state.stridePhase;
        if (strideFreqHz > 0) {
            // 1 full stride cycle = 2 footfalls; phase advances at freq/2 of cycle
            state.stridePhase += (strideFreqHz * 0.5) * dt;
            state.stridePhase -= Math.floor(state.stridePhase);
        }
        // Footstep on phase wraparound past 0 (R) or past 0.5 (L). Same
        // posture gate as stride advance — feet must be striking.
        if (feetStriking && state.grounded && horizSpeed > cfg.bob.minStepSpeed) {
            const fireFootstep = () => {
                state.stepCount++;
                const side = runtime.nextFootSide;
                runtime.nextFootSide = side === "R" ? "L" : "R";
                // The foot that just fired is now the one bearing weight
                // through the upcoming half-stride. Drives lateral-bob sign.
                runtime.standingFoot = side;
                sig.onFootStep.send1({ side, speed: horizSpeed, surfaceTag: state.surfaceTag });
                // Kick the vertical impact spring DOWNWARD. The kick magnitude
                // is the per-step desired peak dip × impactKickMultiplier; the
                // multiplier is empirical (depends on impact spring params) so
                // that "verticalAmpAtWalk" still corresponds approximately to
                // the visible peak dip depth. Scaled by bobIntensity so a
                // mid-deceleration footstep doesn't deliver a full-strength
                // impulse.
                const massBoost = (cfg.body.mass - 80) * cfg.bob.ampMassScale;
                const ampVMult = 1 + (cfg.bob.backwardVerticalAmpFactor - 1) * runtime.backwardness;
                const peakDip = (cfg.bob.verticalAmpAtWalk + massBoost) * runtime.bobIntensitySpring.value * ampVMult;
                runtime.verticalImpactSpring.kick(-peakDip * cfg.bob.impactKickMultiplier);
            };
            if (phaseCrossed(runtime.prevStridePhase, state.stridePhase, 0)) {
                fireFootstep();
            }
            if (phaseCrossed(runtime.prevStridePhase, state.stridePhase, 0.5)) {
                fireFootstep();
            }
        }

        // -- L2.d.bob-intensity & impact -------------------------------
        // Smoothed bob amplitude envelope: when the player starts/stops
        // moving the visible bob fades in/out rather than cutting on/off.
        // Target = the "natural" amp scale (max of speed and backwardness)
        // while grounded, zero while airborne so the bob disappears mid-jump.
        const naturalBobIntensity = Math.max(state.speedNormalized, runtime.backwardness);
        // Bob fades to zero whenever feet aren't striking (airborne, or
        // Prone/Hang posture). The verticalImpactSpring (separate
        // channel) still carries any entry/landing kicks through to the
        // camera, but no recurring step bob.
        const targetBobIntensity = (state.grounded && feetStriking) ? naturalBobIntensity : 0;
        runtime.bobIntensitySpring.stepTo(targetBobIntensity, cfg.bob.intensityHalfLife, 1.0, dt);

        // Vertical impact spring — damped decay toward 0, with the under-
        // damped overshoot that produces the recovery + leg-push curve.
        runtime.verticalImpactSpring.stepTo(0, cfg.bob.impactSpringHalfLife, cfg.bob.impactSpringZeta, dt);

        // Sprint posture — head pitches forward as commitment to sprint
        // builds. Driven by "sprintness" — how much of the gap between
        // walk and sprint speed the player is *currently* in (0..1). The
        // pitch target is multiplied by sprintness, then critically damped.
        // Only applies while grounded — pitching into airborne motion looks weird.
        const sprintness = clamp(
            (state.speed - cfg.motion.walkSpeed)
            / Math.max(cfg.motion.sprintSpeed - cfg.motion.walkSpeed, 1e-3),
            0, 1,
        );
        const targetSprintPitch = state.grounded
            ? cfg.posture.sprintForwardPitchDeg * DEG_TO_RAD * sprintness
            : 0;
        runtime.sprintPostureSpring.stepTo(
            targetSprintPitch,
            cfg.posture.sprintForwardPitchHalfLife,
            1.0, dt,
        );
        runtime.sprintness = sprintness;

        // Head droop — exertion drives a subtle additional forward pitch.
        // Combines with sprintPostureSpring (sprint = head down to commit)
        // so a fatigued sprinter has BOTH effects layered.
        const targetDroopRad = cfg.exertion.headDroopAtMaxDeg * DEG_TO_RAD * state.exertion;
        runtime.headDroopSpring.stepTo(targetDroopRad, cfg.exertion.headDroopHalfLife, 1.0, dt);

        // -- L2.e: Posture → eye height --------------------------------
        // Posture is set by whichever layer owned motion this tick: base
        // writes Stand / Crouch from isCrouchActive (see end of
        // _runBaseLocomotion); active abilities write Prone (Slide) or
        // Hang (LedgeGrab) in their tick. Mapping is one switch — adding
        // a new posture is one enum value + one case.
        let targetEyeH;
        switch (state.posture) {
            case FirstPersonPosture.Prone:  targetEyeH = cfg.body.proneHeight; break;
            case FirstPersonPosture.Crouch: targetEyeH = cfg.body.crouchHeight; break;
            case FirstPersonPosture.Hang:   targetEyeH = cfg.body.height; break;
            case FirstPersonPosture.Stand:
            default:                        targetEyeH = cfg.body.height; break;
        }
        const crouchHalfLife = cfg.crouch.transitionTime / 4; // halfLife is ~quarter of full transition
        runtime.eyeHeightSpring.stepTo(targetEyeH, crouchHalfLife, 1.0, dt);
        state.eyeHeight = runtime.eyeHeightSpring.value;

        if (isCrouchActive !== state.crouchActive) {
            state.crouchActive = isCrouchActive;
            if (isCrouchActive) {
                sig.onCrouchEnter.send0();
                // Impulse: dropping into a crouch grips the knees. Small
                // bump — we don't want crouch-spamming to instantly tire.
                state.exertion = clamp(
                    state.exertion + cfg.exertion.crouchEnterRise * runtime.massRatios.exertionRiseScale,
                    0, 1,
                );
            } else {
                sig.onCrouchExit.send0();
            }
        }

        // -- L2.f: Lean spring → camera roll ---------------------------
        // The TARGET for this tick was written by whichever layer owned
        // motion: base writes the lat-accel + look-lean derived value at
        // the end of _runBaseLocomotion; abilities override (WallRun
        // tilts toward the wall; Slide / LedgeGrab / Mantle force zero).
        // L2.f is now a flat spring-step + commit — no branching, no
        // null sentinel.
        runtime.prevVelocityX = runtime.velocityX;
        runtime.prevVelocityZ = runtime.velocityZ;
        runtime.leanSpring.stepTo(runtime.leanTargetRad, cfg.lean.spring.halfLife, cfg.lean.spring.zeta, dt);
        state.leanRollRad = runtime.leanSpring.value;

        // -- L2.g: Land spring decay (drives the landing recovery dip) -
        // Target is 0; under-damped (cfg zeta < 1) so it rings.
        runtime.landSpring.stepTo(0, cfg.landing.recovery.spring.halfLife, cfg.landing.recovery.spring.zeta, dt);

        // -- L2.h: Publish pose channels --------------------------------
        this._publishPose(controller, runtime, bodyTransform);

        // -- L2.i: Sync collider shape to posture -----------------------
        // All posture-writers (base locomotion + any active ability)
        // have run for this tick. Swap the collider's shape to the
        // pre-built capsule matching the final posture so downstream
        // physics queries (move-and-slide cast, sensors, overlap from
        // outside) see the right volume. No-op when posture is
        // unchanged.
        this._syncColliderShape(runtime, state.posture);
    }

    /**
     * @private
     * @param {FirstPersonPlayerController} controller
     * @param {PerEntityRuntime} runtime
     * @returns {boolean}
     */
    /**
     * Swap {@link Collider.shape} to the pre-built capsule that matches
     * the player's current posture. Cheap — just a reference swap when
     * the posture changed, no-op otherwise. The pre-built shapes live
     * on the runtime (see {@link PerEntityRuntime.colliderShapeStand}
     * etc.) so this method allocates nothing per tick.
     *
     * Hang posture reuses Stand: the player's body is full-extent,
     * hanging below the ledge — the rig handles the arms-up animation,
     * but the collision volume is unchanged. If a game ever wants a
     * narrower hang silhouette (e.g. wedging into a chimney) it can
     * add a `colliderShapeHang` and route here.
     *
     * @private
     */
    _syncColliderShape(runtime, posture) {
        if (posture === runtime.lastPosture) return;
        let next;
        if (posture === FirstPersonPosture.Crouch) {
            next = runtime.colliderShapeCrouch;
        } else if (posture === FirstPersonPosture.Prone) {
            next = runtime.colliderShapeProne;
        } else {
            // Stand and Hang share the full-extent capsule.
            next = runtime.colliderShapeStand;
        }
        runtime.collider.shape = next;
        runtime.lastPosture = posture;
    }

    _resolveCrouchHeld(controller, runtime, bodyTransform) {
        const cfg = controller.config;
        const intent = controller.intent;
        const state = controller.state;

        let wantsCrouch;
        if (cfg.crouch.mode === "toggle") {
            // Edge: rising press flips the latch
            if (intent.crouch && !runtime.prevCrouchHeld) {
                runtime.crouchLatched = !runtime.crouchLatched;
            }
            runtime.prevCrouchHeld = intent.crouch;
            wantsCrouch = runtime.crouchLatched;
        } else {
            // "hold" mode
            runtime.prevCrouchHeld = intent.crouch;
            wantsCrouch = intent.crouch;
        }

        // Headroom override: a player who wants to STAND but has an
        // overhang above (the Stand collider won't fit) is force-kept
        // crouched until they clear it. Without this, growing the
        // collider into the overhang makes the next tick's
        // depenetration shove the player out the bottom — through the
        // floor. `crouchBlocked` records the override for HUD / camera.
        if (!wantsCrouch) {
            const growDelta = runtime.standTop - runtime.crouchTop;
            if (!this._hasHeadroomToGrow(runtime, bodyTransform, runtime.colliderShapeCrouch, growDelta)) {
                state.crouchBlocked = true;
                return true;
            }
        }
        state.crouchBlocked = false;
        return wantsCrouch;
    }

    /**
     * Headroom probe for a posture change that GROWS the collider.
     * Sweeps `fromShape` (the shorter posture's capsule) straight up by
     * `growDelta` (the height the collider would gain). A hit means an
     * overhang occupies the space the taller posture needs, so the grow
     * is blocked. Returns true (clear) when no physics is wired or the
     * grow is non-positive.
     *
     * Why an up-cast rather than an overlap-test of the taller shape:
     * overlap would false-positive on a wall *beside* the player. The
     * up-cast sweeps parallel to (and, per the mover's skin clearance,
     * clear of) lateral walls so they aren't hit; the floor is below
     * the rising probe; the player's own body is filtered out. Only
     * genuine overhead geometry blocks the grow. Relies on the player
     * resting at `floor+skin` (the mover's stick-to-ground), so the
     * probe's bottom starts clear of the floor.
     *
     * @private
     */
    _hasHeadroomToGrow(runtime, bodyTransform, fromShape, growDelta) {
        if (this.physicsSystem === null || growDelta <= 1e-4) return true;
        const ownCollider = runtime.collider;
        const filter = (_e, c) => c !== ownCollider;
        const ray = this._postureRay;
        ray.setOrigin(bodyTransform.position.x, bodyTransform.position.y, bodyTransform.position.z);
        ray.setDirection(0, 1, 0);
        ray.tMax = growDelta;
        return !this.physicsSystem.shapeCast(ray, fromShape, bodyTransform.rotation, this._postureHit, filter);
    }

    /**
     * Jump finite-state-machine: button-edge detection, buffer + coyote
     * grace, anticipation timer, impulse on completion. Variable-height
     * cut is captured here as a `state.isVariableJumpCut` flag that the
     * gravity step in `_integrateVerticalAndResolveGround` consumes.
     *
     * @private
     * @param {FirstPersonPlayerController} controller
     * @param {PerEntityRuntime} runtime
     * @param {Transform} bodyTransform
     * @param {number} dt
     */
    _advanceJumpFsm(controller, runtime, bodyTransform, dt) {
        const cfg = controller.config;
        const intent = controller.intent;
        const state = controller.state;
        const sig = controller.signals;

        const jumpPressedEdge = intent.jump && !runtime.prevJumpHeld;
        const jumpReleasedEdge = !intent.jump && runtime.prevJumpHeld;
        runtime.prevJumpHeld = intent.jump;

        if (jumpPressedEdge) {
            state.jumpBufferRemaining = cfg.jump.bufferTime;
        }
        state.jumpBufferRemaining = Math.max(0, state.jumpBufferRemaining - dt);

        const canJumpNow =
            (state.grounded || state.timeSinceGrounded < cfg.jump.coyoteTime)
            && state.jumpBufferRemaining > 0
            && !state.inJumpAnticipation
            && !runtime.midJump;

        if (canJumpNow) {
            // Begin anticipation — squash; impulse fires after duration elapses
            state.inJumpAnticipation = true;
            runtime.anticipationRemaining = cfg.jump.anticipation.duration;
            state.jumpBufferRemaining = 0; // claimed
        }

        // Variable-height cut: only valid during ascent, post-launch.
        if (jumpReleasedEdge && runtime.midJump && runtime.velocityY > 0) {
            state.isVariableJumpCut = true;
        }

        // Anticipation timer; impulse on completion.
        //
        // Anticipation completes regardless of grounded state. The reason
        // we DON'T cancel on `!grounded`: the canonical coyote-jump path
        // depends on it. The player walks off a ledge (grounded → false),
        // presses jump within the coyote window, canJumpNow accepts on
        // the coyote branch and starts anticipation. If we cancelled
        // anticipation here on !grounded, the impulse would never fire
        // and "coyote time" would be silently dead — the FSM's own next-
        // statement contradicting the canJumpNow gate three lines up.
        //
        // The same logic handles the rug-pull case (player on a moving
        // platform that slides out mid-anticipation): the player
        // committed to the jump, they get the jump. A future
        // knockback / stagger system can explicitly clear
        // inJumpAnticipation if it wants to override that commitment.
        if (state.inJumpAnticipation) {
            runtime.anticipationRemaining -= dt;
            if (runtime.anticipationRemaining <= 0) {
                // Mastery: gather a multiplier from all evaluators
                // registered for JumpImpulse. Default (no evaluators)
                // returns 1.0 → unchanged behaviour.
                const masteryMul = controller.mastery.evaluate(
                    DecisionPoint.JumpImpulse, controller, runtime,
                );
                runtime.velocityY = runtime.jumpInitialVy * masteryMul;
                runtime.midJump = true;
                runtime.apexFired = false;
                runtime.peakAltitude = bodyTransform.position.y;
                state.inJumpAnticipation = false;
                state.isVariableJumpCut = false;
                state.isAscending = true;
                state.exertion = clamp(
                    state.exertion + cfg.exertion.jumpRise * runtime.massRatios.exertionRiseScale,
                    0, 1,
                );

                sig.onJumpStart.send1({ peakHeight: cfg.jump.peakHeight });
                sig.onLeaveGround.send1({ reason: "jump" });
            }
        }
    }

    /**
     * The vertical + collision phase of one fixed step: apply gravity
     * (the motor), resolve the move against the world, then detect the
     * jump apex. Collision resolution is the {@link KinematicMover} when
     * a physics layer is present; otherwise a lightweight flat-ground
     * integrator for headless / unit-test scenes with no collidable
     * geometry. Land / leave-ground events flow identically from either.
     *
     * @private
     * @param {FirstPersonPlayerController} controller
     * @param {PerEntityRuntime} runtime
     * @param {Transform} bodyTransform
     * @param {number} dt
     */
    _integrateVerticalAndResolveGround(controller, runtime, bodyTransform, dt) {
        this._applyGravity(controller, runtime, dt);
        this._resolveMotion(controller, runtime, bodyTransform, dt);
        this._detectJumpApex(controller, runtime, bodyTransform);
    }

    /**
     * Resolve the current `runtime.velocity*` against the world — the
     * physics-backed {@link KinematicMover} (recover + sweep-and-slide +
     * ground-categorize) when a PhysicsSystem is present, else the no-physics
     * flat-ground fallback. Gravity is NOT applied here: the standard path
     * applies it in {@link _applyGravity} just before, and abilities with a
     * non-standard vertical model (WallRun's reduced gravity) apply their own
     * and then call this directly — so motion routes through one motor for
     * everyone (sweep-and-slide, anti-tunnel, land/leave events) rather than
     * a bespoke `position._add` + hand-rolled ground-catch.
     * @private
     */
    _resolveMotion(controller, runtime, bodyTransform, dt) {
        if (this.physicsSystem !== null) {
            this._moveViaMover(controller, runtime, bodyTransform, dt);
        } else {
            this._moveFlatGround(controller, runtime, bodyTransform, dt);
        }
    }

    /**
     * Motor: integrate gravity into `velocityY` with the fall / variable-
     * cut multipliers. The mover never invents motion, so gravity lives
     * here (callable by abilities that want standard gravity too).
     * @private
     */
    _applyGravity(controller, runtime, dt) {
        const cfg = controller.config;
        const state = controller.state;
        let gMag = runtime.gravity;
        if (runtime.velocityY <= 0) {
            gMag *= cfg.jump.fallGravityMult;
            state.isAscending = false;
        } else if (state.isVariableJumpCut) {
            gMag *= cfg.jump.cutGravityMult;
        }
        runtime.velocityY -= gMag * dt;
    }

    /**
     * Resolve the move against the world via {@link KinematicMover}
     * (recover + unified sweep-and-slide + ground-categorize), then map
     * the result back to controller state and land / leave events.
     * @private
     */
    _moveViaMover(controller, runtime, bodyTransform, dt) {
        const state = controller.state;

        if (this._mover === null) {
            this._mover = new KinematicMover(this.physicsSystem, this.entityManager.dataset);
        }

        // Fall speed captured BEFORE the move — the mover clips velocityY
        // to ~0 on contact, so the land event needs the pre-move value.
        const impactVy = -runtime.velocityY;

        const v = this._moverVelocity;
        v.set(runtime.velocityX, runtime.velocityY, runtime.velocityZ);

        const ownCollider = runtime.collider;
        const filter = (_e, c) => c !== ownCollider;

        const result = this._mover.move(
            bodyTransform.position,
            bodyTransform.rotation,
            runtime.collider.shape,
            v, dt, filter,
        );

        // Read back corrected horizontal velocity. Vertical follows the
        // control policy: zero on ground (the slope-climb the mover
        // produced is a position effect, not accumulated speed), keep the
        // mover-clipped value in the air (captures ceiling bonks).
        runtime.velocityX = v.x;
        runtime.velocityZ = v.z;
        runtime.velocityY = result.grounded ? 0 : v.y;

        const wasGrounded = state.grounded;
        if (result.grounded) {
            if (!wasGrounded) this._onLand(controller, runtime, impactVy);
            state.grounded = true;
            state.groundNormal.copy(result.groundNormal);
            state.verticalSpeed = 0;
            state.airborneTime = 0;
            state.timeSinceGrounded = 0;
        } else {
            if (wasGrounded) this._onLeaveGround(controller, runtime, bodyTransform);
            state.grounded = false;
            state.verticalSpeed = runtime.velocityY;
            state.airborneTime += dt;
            state.timeSinceGrounded += dt;
            state.fallDistance += Math.max(0, -runtime.velocityY * dt);
        }
    }

    /**
     * No-physics fallback: integrate the velocity directly (there's no
     * collidable geometry to sweep against) and resolve the floor from
     * the built-in flat ground and/or the host `groundResolver`. For
     * headless scenes and control-layer unit tests. With both ground
     * sources off, an external physics layer is expected to own
     * `state.grounded`; we only advance timers.
     * @private
     */
    _moveFlatGround(controller, runtime, bodyTransform, dt) {
        const state = controller.state;

        bodyTransform.position._add(
            runtime.velocityX * dt, runtime.velocityY * dt, runtime.velocityZ * dt,
        );

        if (this.useBuiltInFlatGround || this.groundResolver !== null) {
            // Effective ground = max(built-in flat ground, resolver).
            let testY = this.useBuiltInFlatGround ? this.groundY : Number.NEGATIVE_INFINITY;
            if (this.groundResolver !== null) {
                const resolved = this.groundResolver(
                    bodyTransform.position.x, bodyTransform.position.y, bodyTransform.position.z,
                );
                if (resolved !== null && resolved > testY) testY = resolved;
            }
            const haveGround = testY !== Number.NEGATIVE_INFINITY;
            if (haveGround && bodyTransform.position.y <= testY) {
                bodyTransform.position.setY(testY);
                if (!state.grounded) this._onLand(controller, runtime, -runtime.velocityY);
                state.grounded = true;
                state.groundNormal.set(0, 1, 0);
                state.verticalSpeed = 0;
                runtime.velocityY = 0;
                state.airborneTime = 0;
                state.timeSinceGrounded = 0;
            } else {
                if (state.grounded) this._onLeaveGround(controller, runtime, bodyTransform);
                state.grounded = false;
                state.verticalSpeed = runtime.velocityY;
                state.airborneTime += dt;
                state.timeSinceGrounded += dt;
                state.fallDistance += Math.max(0, -runtime.velocityY * dt);
            }
        } else {
            // External physics maintains state.grounded; just track timers.
            if (state.grounded) {
                state.timeSinceGrounded = 0;
                state.airborneTime = 0;
            } else {
                state.timeSinceGrounded += dt;
                state.airborneTime += dt;
            }
        }
    }

    /**
     * Landing transition: dip / impact springs, exertion impulse, flag
     * resets, and the `onLand` signal (fired LAST so handlers see fully-
     * reacted state). Shared by both move paths. `impactVy` is the
     * downward speed at touchdown (positive).
     * @private
     */
    _onLand(controller, runtime, impactVy) {
        const cfg = controller.config;
        const state = controller.state;
        const kind = impactVy >= cfg.landing.hardThreshold ? "hard" : "soft";

        const massScaledDip = impactVy * cfg.landing.recovery.dipPerVy
            * runtime.massRatios.landingDipScale;
        const dip = clamp(massScaledDip, 0, cfg.landing.recovery.dipMax);
        runtime.landSpring.settle(-dip);

        const landImpulse = clamp(
            impactVy * cfg.exertion.landImpulsePerVy * runtime.massRatios.exertionRiseScale,
            0, cfg.exertion.landImpulseMax,
        );
        state.exertion = clamp(state.exertion + landImpulse, 0, 1);

        runtime.midJump = false;
        state.isAscending = false;
        state.isVariableJumpCut = false;
        state.fallDistance = 0;

        controller.signals.onLand.send1({ verticalSpeed: impactVy, kind });
    }

    /**
     * Leave-ground transition: `onLeaveGround` signal + takeoff bookkeeping.
     * Shared by both move paths.
     * @private
     */
    _onLeaveGround(controller, runtime, bodyTransform) {
        controller.signals.onLeaveGround.send1({ reason: runtime.midJump ? "jump" : "fall" });
        runtime.takeoffVy = runtime.velocityY;
        runtime.peakAltitude = bodyTransform.position.y;
    }

    /**
     * Fire `onJumpApex` once, when an in-progress jump stops rising.
     * @private
     */
    _detectJumpApex(controller, runtime, bodyTransform) {
        if (runtime.midJump && !runtime.apexFired) {
            if (bodyTransform.position.y > runtime.peakAltitude) {
                runtime.peakAltitude = bodyTransform.position.y;
            } else if (runtime.velocityY <= 0) {
                controller.signals.onJumpApex.send0();
                runtime.apexFired = true;
            }
        }
    }

    /**
     * Run the base (no-ability) L1 locomotion phases: speed selection,
     * desired-velocity computation, accel/decel, jump FSM, gravity, body
     * integration, ground resolution. Only invoked when no ability owns
     * the tick (see {@link AbilitySet.tick}).
     *
     * @private
     * @param {FirstPersonPlayerController} controller
     * @param {PerEntityRuntime} runtime
     * @param {Transform} bodyTransform
     * @param {number} dt
     * @param {boolean} isCrouchActive
     * @param {boolean} isSprintIntent
     * @param {boolean} isBackwardIntent
     */
    _runBaseLocomotion(controller, runtime, bodyTransform, dt,
                       isCrouchActive, isSprintIntent, isBackwardIntent) {
        const cfg = controller.config;
        const intent = controller.intent;
        const state = controller.state;

        // -- L1.b: Speed selection ------------------------------------
        let targetSpeed;
        if (isCrouchActive) {
            targetSpeed = cfg.motion.crouchSpeed;
        } else if (isSprintIntent) {
            targetSpeed = cfg.motion.sprintSpeed;
        } else {
            targetSpeed = cfg.motion.walkSpeed;
        }
        if (isBackwardIntent) {
            targetSpeed *= cfg.motion.backwardSpeedFactor;
        }

        // Airborne momentum floor — preserve whatever horizontal speed
        // the player carried into the jump. Without this, a sprint
        // jump (9 m/s) decays toward walkSpeed (4.5 m/s) at
        // airAccel = 14 m/s², losing all sprint momentum in ~0.32 s —
        // well before the apex of a `peakHeight = 1.8 m` jump arc. The
        // air-control band (Mirror's Edge, Titanfall, modern CoD) and
        // the long-jump biomechanics literature both say the same
        // thing: there's no thrust source in flight, so horizontal
        // velocity is conserved across the arc and air "control" is
        // for steering (direction) — not for changing speed magnitude.
        // Raising the target to the current speed makes `stepTowards`
        // a no-op when the player keeps pressing forward, while
        // releasing the stick still lets `airAccel` decelerate to
        // `walkSpeed` (the user CAN bleed off speed, just not have it
        // bled off for them).
        if (!state.grounded) {
            const horizSpeed = Math.hypot(runtime.velocityX, runtime.velocityZ);
            if (horizSpeed > targetSpeed) targetSpeed = horizSpeed;
        }

        // -- L1.c: Move intent → desired horizontal velocity ----------
        //   screen_forward(θ) = ( sin θ, 0,  cos θ )
        //   screen_right  (θ) = (-cos θ, 0,  sin θ )
        const { sinYaw, cosYaw } = runtime;
        const mvX = intent.move.x;
        const mvY = intent.move.y;
        const mvMag = Math.hypot(mvX, mvY);
        const nmvX = mvMag > 1 ? mvX / mvMag : mvX;
        const nmvY = mvMag > 1 ? mvY / mvMag : mvY;
        const desiredVx = sinYaw * nmvY + -cosYaw * nmvX;
        const desiredVz = cosYaw * nmvY + sinYaw * nmvX;
        const desiredHorizontalVx = desiredVx * targetSpeed;
        const desiredHorizontalVz = desiredVz * targetSpeed;

        // -- L1.d: Accel/decel toward desired velocity ----------------
        //
        // Three regimes — air control, grounded decel-to-stop, grounded
        // accel-to-target — each with its own model:
        //
        //   • Air control: constant-rate `stepTowards`. No ground
        //     reaction force in flight; air control is a steering
        //     budget, not a thrust curve. Constant accel matches the
        //     player mental model of "fixed mid-air authority".
        //
        //   • Grounded decel (no intent): constant-rate `stepTowards`
        //     toward zero. Friction is approximately constant for a
        //     biped on level ground — Coulomb friction. Faster than
        //     accel because the body's own resistance + active
        //     decel-foot-plants combine into a sharper deceleration.
        //
        //   • Grounded accel (intent active): mono-exponential
        //     approach (Hill 1927; Furusawa-Hill 1928). dv/dt is
        //     proportional to (v_target − v), so accel is highest at
        //     low speed and tapers as v approaches v_target. Matches
        //     human sprint biomechanics — modern sprint-profiling
        //     work (Morin & Samozino 2016) fits this same mono-exp
        //     curve to empirical force-plate data.
        //
        // The mass + mastery + backward scalars compose multiplicatively
        // on the EFFECTIVE half-life (heavier ⇒ longer half-life ⇒
        // slower ramp; mastery accel-bonus ⇒ shorter half-life ⇒
        // faster ramp). See FirstPersonPlayerControllerConfig.js's
        // `groundAccelHalfLife` doc for the literature and the
        // SprintAcceleration.spec.js for the model assertions.
        const intentLen = Math.hypot(nmvX, nmvY);
        if (!state.grounded) {
            const maxStep = cfg.motion.airAccel * dt;
            runtime.velocityX = stepTowards(runtime.velocityX, desiredHorizontalVx, maxStep);
            runtime.velocityZ = stepTowards(runtime.velocityZ, desiredHorizontalVz, maxStep);
        } else if (intentLen < 1e-4) {
            let decel = cfg.motion.groundDecel * runtime.massRatios.groundAccelScale;
            decel *= controller.mastery.evaluate(DecisionPoint.GroundAccel, controller, runtime);
            const maxStep = decel * dt;
            runtime.velocityX = stepTowards(runtime.velocityX, 0, maxStep);
            runtime.velocityZ = stepTowards(runtime.velocityZ, 0, maxStep);
        } else {
            // Mono-exponential approach. Scale half-life by the
            // inverse of the accel scalars so that "more accel" (large
            // groundAccelScale, mastery > 1.0) translates to a shorter
            // half-life (faster ramp). Backward intent slows things
            // down — backwardAccelFactor < 1 ⇒ longer half-life.
            let halfLife = cfg.motion.groundAccelHalfLife
                / runtime.massRatios.groundAccelScale
                / controller.mastery.evaluate(DecisionPoint.GroundAccel, controller, runtime);
            if (isBackwardIntent) halfLife /= cfg.motion.backwardAccelFactor;
            runtime.velocityX = exponentialApproach(runtime.velocityX, desiredHorizontalVx, halfLife, dt);
            runtime.velocityZ = exponentialApproach(runtime.velocityZ, desiredHorizontalVz, halfLife, dt);
        }

        // -- L1.e/f/g/h: jump FSM + vertical integration --------------
        this._advanceJumpFsm(controller, runtime, bodyTransform, dt);
        this._integrateVerticalAndResolveGround(controller, runtime, bodyTransform, dt);

        // -- Publish posture for L2 consumers (eye height, gait gating).
        //   Base owns posture when no ability is active: Crouch if the
        //   crouch intent is resolved active, otherwise Stand. Abilities
        //   that need a different posture (slide → Prone, ledge-grab →
        //   Hang) set state.posture themselves in their tick.
        let posture = isCrouchActive
            ? FirstPersonPosture.Crouch
            : FirstPersonPosture.Stand;
        // Headroom floor: if even the Crouch collider can't fit under the
        // overhang (a space lower than crouch height — e.g. a slide
        // tunnel the player crawled / slid into), drop one more to Prone.
        // Uses the post-move position, so it reflects where the player
        // actually ended up this tick.
        if (posture === FirstPersonPosture.Crouch) {
            const proneGrow = runtime.crouchTop - runtime.proneTop;
            if (!this._hasHeadroomToGrow(runtime, bodyTransform, runtime.colliderShapeProne, proneGrow)) {
                posture = FirstPersonPosture.Prone;
            }
        }
        controller.state.posture = posture;

        // -- Publish lean target for L2.f. Base writes the natural
        //   (lat-accel + look-lean) value; abilities override in their
        //   own tick. L2.f spring-steps toward whatever's here.
        runtime.leanTargetRad = this._computeNaturalLeanTarget(controller, runtime, dt);
    }

    /**
     * Compute the natural camera lean for this tick: lat-accel-driven
     * roll into a turn, plus a yaw-rate look-lean contribution, both
     * clamped. The result is the target the lean spring chases each
     * tick when no ability has opinions.
     *
     * Pure-ish helper — reads `controller`, `runtime`, `dt`; returns a
     * number. Extracted so both base and any future ability that wants
     * to compose its lean on top of the natural value can call it.
     *
     * @private
     * @param {FirstPersonPlayerController} controller
     * @param {PerEntityRuntime} runtime
     * @param {number} dt
     * @returns {number} target roll in radians
     */
    _computeNaturalLeanTarget(controller, runtime, dt) {
        const cfg = controller.config;
        const state = controller.state;
        if (!cfg.lean.enabled) return 0;

        const sinYaw = runtime.sinYaw;
        const cosYaw = runtime.cosYaw;

        // Lateral acceleration projected onto screen-right.
        // accel_world = (vel - prevVel) / dt;  screen_right = (-cos θ, 0, sin θ).
        const accWorldX = (runtime.velocityX - runtime.prevVelocityX) / Math.max(dt, 1e-4);
        const accWorldZ = (runtime.velocityZ - runtime.prevVelocityZ) / Math.max(dt, 1e-4);
        const latAccel = accWorldX * (-cosYaw) + accWorldZ * sinYaw;
        const normalized = clamp(latAccel / 9.81, -2, 2);
        //
        // Sign convention for the roll (the eye composes the rotation
        // as qYaw * qPitch * qRoll, where qRoll is around (0,0,1)).
        // After the engine's camera-invert pipeline:
        //   φ > 0  →  camera-up tilts toward screen-right (−X)  →  HEAD TILTS RIGHT
        //   φ < 0  →  camera-up tilts toward screen-left  (+X)  →  HEAD TILTS LEFT
        //
        // For the "bank into the turn" feel (Apex / Titanfall / Mirror's
        // Edge): accelerating right (latAccel > 0) should tilt the head
        // RIGHT, i.e. positive φ. So leanTargetRad has the SAME sign
        // as latAccel.
        let leanTargetRad = normalized * cfg.lean.maxRollDeg * DEG_TO_RAD;

        // Look-lean: yaw-rate-driven banking. runtime.yawRateRadPerSec
        // was cached at L1.a — negative is the "turn right" convention.
        // For "bank into the turn": turning right → head tilts right →
        // positive engine roll. So lookLean = -yawRate * scale matches
        // sign.
        //
        // Crouched players are in a low, stable, low-momentum stance —
        // banking the head from a mouse turn reads as unmotivated. We
        // scale the contribution down (default to 0) while crouched.
        // Lat-accel lean is left alone: its magnitude naturally tracks
        // the (lower) crouch acceleration, so it stays motivated.
        if (cfg.lean.lookLeanEnabled) {
            const yawRate = clamp(
                runtime.yawRateRadPerSec,
                -cfg.lean.lookLeanYawRateClamp,
                cfg.lean.lookLeanYawRateClamp,
            );
            const crouchFactor = state.crouchActive ? cfg.lean.crouchLookLeanFactor : 1.0;
            leanTargetRad += -yawRate * cfg.lean.lookLeanDegPerRadPerSec * DEG_TO_RAD * crouchFactor;
        }

        // Final clamp on the sum: cap the combined target to ±2 ×
        // maxRollDeg (matches the latAccel normalized clamp range) so
        // even simultaneous max-strafe-accel + max-yaw-flick produces a
        // sane upper bound.
        const maxTotal = cfg.lean.maxRollDeg * DEG_TO_RAD * 2;
        return clamp(leanTargetRad, -maxTotal, maxTotal);
    }

    /**
     * Snapshot the per-tick "what is the body doing" information into the
     * pose channels for downstream consumption (skeleton, sound, AI).
     * Read-only with respect to controller state — this is purely a publish
     * step.
     *
     * @private
     * @param {FirstPersonPlayerController} controller
     * @param {PerEntityRuntime} runtime
     * @param {Transform} bodyTransform
     */
    _publishPose(controller, runtime, bodyTransform) {
        const cfg = controller.config;
        const state = controller.state;
        const pose = controller.pose;

        pose.rootPosition.copy(bodyTransform.position);
        pose.rootYawRad = runtime.bodyYaw;
        pose.headYawRad = runtime.bodyYaw;
        pose.headPitchRad = runtime.eyePitch;
        pose.headRollRad = state.leanRollRad;
        pose.locomotionPhase = state.stridePhase;
        pose.locomotionSpeed = runtime.horizSpeed;
        // Strafe component: project velocity onto screen-right (-cos θ, 0, sin θ).
        // Positive = moving to the player's right.
        pose.locomotionStrafe = (runtime.velocityX * (-runtime.cosYaw) + runtime.velocityZ * runtime.sinYaw)
            / Math.max(cfg.motion.sprintSpeed, 1e-3);
        pose.actionState =
            state.inJumpAnticipation ? FirstPersonActionState.Anticipating
            : !state.grounded ? FirstPersonActionState.Airborne
            : (Math.abs(runtime.landSpring.value) > 0.01 ? FirstPersonActionState.Landing
            : FirstPersonActionState.Grounded);
        pose.locomotionMode = state.locomotionMode;
        const crouchSpan = Math.max(cfg.body.height - cfg.body.crouchHeight, 1e-3);
        pose.crouchAmount = clamp((cfg.body.height - state.eyeHeight) / crouchSpan, 0, 1);

        // Posture channel for downstream animation: which body shape +
        // how far the body is into it from the standing neutral.
        //
        // `posture` is the enum (Stand / Crouch / Prone / Hang) — picks
        // the animation track. `postureAmount` is the [0..1] blend
        // weight from standing toward that posture, derived from the
        // eye-height spring so the value transitions smoothly across
        // changes (matches the visible camera motion).
        pose.posture = state.posture;
        let postureTargetH;
        switch (state.posture) {
            case FirstPersonPosture.Prone:  postureTargetH = cfg.body.proneHeight; break;
            case FirstPersonPosture.Crouch: postureTargetH = cfg.body.crouchHeight; break;
            case FirstPersonPosture.Hang:   postureTargetH = cfg.body.height; break;
            case FirstPersonPosture.Stand:
            default:                        postureTargetH = cfg.body.height; break;
        }
        const postureSpan = Math.max(cfg.body.height - postureTargetH, 1e-3);
        pose.postureAmount = clamp((cfg.body.height - state.eyeHeight) / postureSpan, 0, 1);

        pose.aimPitch = runtime.eyePitch;
    }

    /**
     * Compose the eye transform from body + state-driven offsets.
     * @private
     * @param {FirstPersonPlayerController} controller
     * @param {number} entity
     */
    _composeEye(controller, entity) {
        const ecd = this.entityManager.dataset;
        const runtime = this.runtime.get(entity);
        if (runtime === undefined) return;

        const dt = this._currentRenderDt;
        const cfg = controller.config;
        const state = controller.state;

        const bodyTransform = ecd.getComponent(entity, Transform);
        if (bodyTransform === undefined) return;

        if (controller.eyeEntity === -1) return;
        const eyeTransform = ecd.getComponent(controller.eyeEntity, Transform);
        const camera = ecd.getComponent(controller.eyeEntity, Camera);
        if (eyeTransform === undefined || camera === undefined) return;

        // -- Body-local eye offset, composed via the additive stack ----
        // The base (0, eyeHeight, 0) is the standing/crouched neutral; each
        // additional contribution (bob, breath, landing, anticipation,
        // sprint posture) goes through the stack so external systems can
        // push their own contributions on the same channel.
        const stack = runtime.eyeOffsetStack;
        stack.clear();
        stack.push("eyeHeight", 0, state.eyeHeight, 0);

        // Bob — gated on grounded only (the impact spring decays naturally
        // even at rest, so the bob fade-out is smooth; lateral amp uses the
        // bob-intensity envelope which spring-decays after stopping).
        if (state.grounded) {
            const phase = state.stridePhase * TWO_PI;
            const massBoost = (cfg.body.mass - 80) * cfg.bob.ampMassScale;
            const intensity = runtime.bobIntensitySpring.value;

            // Back-pedal amp boost — lateral grows more than vertical because
            // backward gait has worse side-to-side balance than vertical compression.
            // Exertion adds a smaller boost on top: tired = wobbly gait.
            const ampLMult = 1 + (cfg.bob.backwardLateralAmpFactor - 1) * runtime.backwardness;
            const exertionBoost = 1 + cfg.exertion.bobLateralBoostAtMax * state.exertion;
            const ampL = (cfg.bob.lateralAmpAtWalk + massBoost) * intensity * ampLMult * exertionBoost;

            // Vertical: read directly from the impact spring (footfall kicks,
            // under-damped recovery → trough + leg-push overshoot).
            stack.push("bob.impact", 0, runtime.verticalImpactSpring.value, 0);

            // Lateral: head shifts toward the foot bearing weight. Polarity
            // sourced from runtime.standingFoot — the same signal the
            // footstep emits — so bob direction and footstep side agree.
            // |sin(phase)| is the non-negative "midstance envelope".
            const lateralPolarity = runtime.standingFoot === "R" ? -1 : 1;
            stack.push("bob.lateral", ampL * lateralPolarity * Math.abs(Math.sin(phase)), 0, 0);
        }

        // Breath — sine + tiny noise riding the rate spring.
        const breathOffset = -state.breathAmplitudeM
            * Math.sin(state.breathPhase * TWO_PI)
            * (1 + cfg.breath.noiseAmount * (Math.sin(state.breathPhase * 13.7) * 0.5));
        stack.push("breath", 0, breathOffset, 0);

        // Landing spring dip (under-damped — overshoots once on recovery).
        stack.push("landing", 0, runtime.landSpring.value, 0);

        // Jump anticipation dip (eased ramp during the squash window).
        if (state.inJumpAnticipation) {
            const t = 1 - clamp(runtime.anticipationRemaining / Math.max(cfg.jump.anticipation.duration, 1e-3), 0, 1);
            const eased = t * (2 - t); // ease-out quad
            stack.push("anticipation", 0, -cfg.jump.anticipation.dipAmount * eased, 0);
        }

        // Sprint posture: head leans slightly forward as commitment builds.
        // Pitch part is in the rotation block below; the +Z position shift
        // sells "head leading the hips" (Mirror's Edge), tied to the same
        // spring envelope so they move together.
        const sprintPitch = runtime.sprintPostureSpring.value;
        const sprintShiftFraction =
            cfg.posture.sprintForwardPitchDeg > 0
                ? sprintPitch / (cfg.posture.sprintForwardPitchDeg * DEG_TO_RAD)
                : 0;
        stack.push("posture.sprintShift", 0, 0, cfg.posture.sprintForwardShiftM * sprintShiftFraction);

        // Transform body-local accumulated offset into world space.
        const worldOffset = SCRATCH_V3_B.copy(stack.offset);
        worldOffset.applyQuaternion(bodyTransform.rotation);

        eyeTransform.position.copy(bodyTransform.position);
        eyeTransform.position._add(worldOffset.x, worldOffset.y, worldOffset.z);

        // -- Eye rotation: body yaw × eye pitch × roll -------------------
        // Bob roll mixes in for a subtle head sway (in phase with lateral bob).
        // Breath pitch is a small extra nod 90° out of phase with vertical
        // breath; merged into the main pitch so we don't pay an extra quat
        // multiply and the composition stays trivially correct.
        let rollTotal = state.leanRollRad;
        if (state.grounded) {
            // Roll: head tilts toward the standing foot, in phase with the
            // lateral sway. Polarity sourced from runtime.standingFoot for
            // consistency with the lateral bob. Positive engine roll = head
            // tilts RIGHT (camera-invert convention), so R-foot midstance =
            // positive roll, L-foot midstance = negative roll.
            const phase = state.stridePhase * TWO_PI;
            const rollBackMult = 1 + (cfg.bob.backwardRollFactor - 1) * runtime.backwardness;
            const ampRoll = cfg.bob.rollAtWalkDeg * DEG_TO_RAD * runtime.bobIntensitySpring.value * rollBackMult;
            const rollPolarity = runtime.standingFoot === "R" ? 1 : -1;
            const rollEnvelope = Math.abs(Math.sin(phase));
            const bobRollSigned = ampRoll * rollPolarity * rollEnvelope;

            // Lean × bob coupling: excursions in the lean direction get
            // amplified, opposite excursions attenuated. Lean is normalized
            // against maxRollDeg so the coupling magnitude stays bounded
            // regardless of how aggressively lean is configured.
            const maxLeanRad = Math.max(cfg.lean.maxRollDeg * DEG_TO_RAD, 1e-6);
            const leanFraction = clamp(state.leanRollRad / maxLeanRad, -1, 1);
            // sign(bobRollSigned) matches lean? amplify; else attenuate.
            const sameSign = (bobRollSigned * leanFraction) >= 0;
            const couplingMag = cfg.bob.leanCouplingFactor * Math.abs(leanFraction);
            const couplingScale = sameSign ? (1 + couplingMag) : (1 - couplingMag);
            rollTotal += bobRollSigned * couplingScale;
        }

        const breathPitch = lerp(cfg.breath.pitchAmpRestDeg, cfg.breath.pitchAmpMaxDeg, state.exertion)
            * DEG_TO_RAD
            * Math.cos(state.breathPhase * TWO_PI);
        // Combined pitch contributions: player input + breath nod + sprint
        // commitment + fatigue droop. All in the same "positive = look-down"
        // convention so they sum cleanly.
        const pitchTotal = runtime.eyePitch
            + breathPitch
            + runtime.sprintPostureSpring.value
            + runtime.headDroopSpring.value;

        // composition: yaw * pitch * roll
        //   pitch around world X — yaw applied after, so effective axis is camera-local right
        //   roll  around world Z — yaw and pitch applied after, so effective axis is camera-local forward
        const qYaw = SCRATCH_Q_A.fromAxisAngle(Vector3.up, runtime.bodyYaw);
        const qPitch = SCRATCH_Q_B.fromAxisAngle(Vector3.right, pitchTotal);
        const qRoll = SCRATCH_Q_C.fromAxisAngle(Vector3.forward, rollTotal);

        eyeTransform.rotation.multiplyQuaternions(qYaw, qPitch);
        eyeTransform.rotation.multiply(qRoll);

        // -- FOV ---------------------------------------------------------
        let fovTarget = cfg.fov.base;
        if (cfg.fov.sprintAdd !== 0) {
            fovTarget += cfg.fov.sprintAdd * runtime.sprintness;
        }
        if (state.crouchActive) fovTarget += cfg.fov.crouchAdd;

        runtime.fovSpring.stepTo(fovTarget, cfg.fov.smoothHalfLife, 1.0, dt);
        // Write directly to the underlying Three.js camera. Going through
        // camera.fov.set() fires onChanged which triggers a full camera
        // rebuild in CameraSystem — far too expensive to do per frame.
        // The CameraSystem's visibility-construction hook calls
        // updateProjectionMatrix() each frame anyway.
        if (camera.object !== null) {
            camera.object.fov = runtime.fovSpring.value;
        }
    }
}

// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------

/**
 * Exponential approach with half-life parameterization.
 * @param {number} current
 * @param {number} target
 * @param {number} halfLife
 * @param {number} dt
 * @returns {number}
 */
function exponentialApproach(current, target, halfLife, dt) {
    if (halfLife <= 0) return target;
    const alpha = 1 - Math.exp(-LN2 * dt / halfLife);
    return current + (target - current) * alpha;
}

/**
 * Detect that phase value crossed a boundary in [0,1) between two ticks.
 * Handles the wraparound case where phase jumps from e.g. 0.95 to 0.05.
 *
 * @param {number} prev previous phase in [0,1)
 * @param {number} next current phase in [0,1)
 * @param {number} boundary in [0,1)
 * @returns {boolean}
 */
function phaseCrossed(prev, next, boundary) {
    if (next >= prev) {
        // no wrap
        return prev < boundary && next >= boundary;
    } else {
        // wrapped past 1.0
        return prev < boundary || next >= boundary;
    }
}

