# Body Stage 1 failure brief — chest collapse in-headset (2026-10-01) **STATUS: RESOLVED — root cause = NaN in the torso orientation servo math (not the solver). See the RESOLUTION section at the end. The hypotheses below are kept as the record.** SELF-CONTAINED brief for an external agent (zero project context assumed). Godot 4.7.2 mono, C#, PCVR at 90 Hz (physics tick = 90), physics engine = **Box3D** (bearlikelion's `godot-box3d` GDExtension — a full PhysicsServer3D replacement; locally patched so Godot's ConeTwistJoint3D maps to Box3D's `b3WeldJoint` with rigid stiffness at bias 0). Everything below is verified code or log evidence unless marked [H] (hypothesis). ## What was just built (body-physics Stage 1) The player's virtual chest became a physical body. `PhysicsTorso.cs` (Node) spawns: - One RigidBody3D, box 0.22×0.5×0.13 m, **Mass 30**, GravityScale 0, CCD on, contact monitor, CollisionLayer = layer 3 (player body), CollisionMask = layers 1|3 (world+props | body). - Every physics tick (90 Hz), target = head position (from the HMD camera node, kinematic) minus 6 cm along flattened camera forward, then offset by the torso-yaw basis (a yaw-only basis smoothed toward a blend of HMD yaw and hands-midpoint yaw) by (0, −0.42, +0.06), plus a small "reach offset" fed by the visual rig (≤ 0.25 m horizontal clamp). - Linear drive — force-clamped PD, the same pattern the hand bodies use: ```csharp Vector3 desiredVelocity = (target - _body.GlobalPosition) / dt; // dt = 1/90 if (desiredVelocity.Length() > MaxSpeed) desiredVelocity = desiredVelocity.Normalized() * MaxSpeed; // 6 Vector3 dv = desiredVelocity - _body.LinearVelocity; Vector3 force = dv / dt * TorsoMass; // 30 kg if (force.Length() > MaxForce) force = force.Normalized() * MaxForce; // 2400 N _body.LinearVelocity += force / TorsoMass * dt; // DIRECT velocity write ``` - Orientation drive — slewed angular velocity servo toward the yaw target: ```csharp Quaternion qErr = torsoBasisQ * _body.GlobalTransform.Basis.GetRotationQuaternion().Inverse(); if (qErr.W < 0f) qErr = -qErr; // shortest arc Vector3 spinDesired = axis(qErr) * min(angle(qErr) * OrientKp /*20*/, OrientMaxSpin /*10*/); Vector3 wErr = spinDesired - _body.AngularVelocity; if (wErr.Length() > OrientMaxAlpha * dt /*150/90*/) wErr = wErr.Normalized() * OrientMaxAlpha * dt; _body.AngularVelocity += wErr; // DIRECT angular velocity write ``` - Snap safety: if body-to-target distance > 2 m → teleport body to target, zero velocities. - Head/camera stays kinematic; the torso chases the head, never the reverse. Context bodies: - **Hands**: two RigidBody3D palms (0.4 kg, layer 4, mask 1|4), same velocity-write chase at 90 N, rotation hard-locked via angular velocity sets (cap 120 rad/s). They chase XR controllers. Stuck-snap and teleport-snap safeties are SUSPENDED for a hand while it grips. - **Grab**: grabbing a prop creates a ConeTwistJoint3D (= b3WeldJoint, bias 0 = RIGID, locks relative pose) between hand body and prop. Two-handed grab = prop welded to BOTH hands rigidly. Props: layer 1, mask 1|3|4. Statics (floor/table): layer 1. - **No self-collision**: hands (4) and chest (3) exclude each other both ways. Chest DOES collide with props (layer 1) and static world (layer 1) — deliberate, chest is the contact surface for hugs/leaning. - Headless regression (body spike: track/yaw/5kg-impact/teleport/self-right) PASSES 3× — but the spike has NO static table and NO welded heavy prop touching the chest. ## The failure (owner playtest, first session with the chest) Owner report: "it quickly collapsed with my head detaching from the body". Screenshot shows the prop table in chaos (props piled/thrown, panel knobs displaced). Log (`godot.log`, ~45.7k lines, 22.8k of them the error below): - Calibration line: **head height 1.01 m** → the player was SEATED/crouching (standing calibration would be ~1.6-1.7 m). Chest target therefore ≈ 0.59 m height; chest box spans ≈ 0.34-0.84 m; the prop table top is at 0.70-0.75 m → **the seated chest overlaps the table geometry** (chest mask includes statics). [H] constant penetration + 2400 N servo. - Normal play: grabbed/released bowling ball, brick, apple — all fine. - Then: `grabbed 'Anvil · 50 kg' (50 kg, left)`, released, then **`grabbed 'Anvil · 50 kg' (50 kg, left, two-handed)`** — and within ticks the log floods: ``` ERROR: Condition "!v.is_finite()" is true. at: instance_set_transform (servers/rendering/renderer_scene_cull.cpp:1000) ``` ×22,829 — some node's transform went NaN/Inf (rendering rejects it). The visual result matches the chest body (and/or anvil) transforming to garbage → the torso mesh vanishes/ teleports → "head detached from the body" (head cube rides the camera, body went away). - Owner kept grabbing/releasing the anvil afterwards (releases logged at 0.00-0.01 m/s — the anvil was braced/nearly immobile), then removed the headset (focus lost). ## Hypotheses (ranked) - **H1 — solver blowup from stacked authorities [most likely]**: the chest (velocity-write servo, 2400 N) pressed against the STATIC table (seated overlap) while the 50 kg anvil rigid-welded to both 90 N velocity-write hand bodies was grabbed/moved into that mess. Three script-side velocity servos + rigid welds + persistent static contact in one constraint island. Box3D's solver gets conflicting infinite-stiffness demands → energy injection → NaN. This project has PRIOR ART for exactly this class: documented in `research/vr-two-handed-grab.md` — "script velocity SETs + reaction forces = limit cycle" (grab v5.0-era instability). The hands survive the same pattern only because they're 0.4 kg, rarely braced, and have stuck-snap escape hatches. The chest is 30 kg, overlaps the table whenever the owner sits at it, and has no escape. - **H2 — chest vs table penetration alone**: even without the anvil, seated chest-in-table = constant deep penetration vs a static body; the velocity servo never yields (position error persists → max force every tick) → pressure + friction against statics pumps the solver. The anvil grab was just the straw. - **H3 — CCD tunneling/spike**: 30 kg body with CCD in deep contact + welded 50 kg chain — CCD contact generation bug under stress. [H] least likely but cheap to test. ## Candidate mitigations (evaluate, don't just pick one) - **M1 — ApplyForce instead of velocity writes for the chest** (solver-owned integration; keeps the 2400 N clamp). The hands keep velocity writes (they have escape hatches). Boneworks' design rule "no infinite forces" supports solver-integrated forces. - **M2 — collision-aware target or compliance**: if the chest is in persistent contact (contact monitor reports statics) with saturated force, soften (reduce effective budget) or stop pushing (target clamps out of penetration). Generic "yield when braced". - **M3 — seated reality**: the chest target height derives purely from HMD (−0.42). When seated at a table the body legitimately overlaps world geometry. Options: chest collision disabled vs statics (mask 3 only — loses leaning on tables), or per-body contact disabling when penetration is self-inflicted (chase target inside a static), or pushing the TABLE (chest becomes heavy enough to matter? 30 kg vs static = never). - **M4 — saturation watchdog**: like the hands' stuck-snap: if force saturated AND position error persistent AND in contact for > N ms → log + temporarily reduce budget / exclude the offending pair. Keeps M1's solver honesty while adding the escape hatch. - **M5 — reproduce headless first**: extend the body spike with the exact cocktail — chest target set so the box overlaps a static table slab; a 50 kg box welded rigid to two 0.4 kg "hand" bodies (90 N chases) pressed into the chest; then sweep. A deterministic NaN repro beats every hypothesis debate. The full grab spike already emulates hands+welds headless (GrabSpike.cs pattern) — compose it with the chest. ## Questions to answer 1. Can a deterministic headless repro be built (M5)? If yes → does M1 alone stop the NaN? 2. Is the velocity-write chase on a contact-rich 30 kg body categorically unsafe on Box3D, or only under simultaneous weld+static stacking? (Box3D source available in .vendor/.) 3. What does the solver do with direct LinearVelocity writes on a body that is ALSO in an active contact manifold — are the writes applied pre- or post-solve, and can the contact impulse + next-tick write loop escalate? 4. Seated-at-table is a first-class scenario for this product (scene viewer). What's the right long-term chest-vs-world story (M2/M3) that still allows leaning on tables? Owner directive: painlessness bar = no multi-iteration fundamental sagas. Deliver the analysis, the repro, and the smallest honest fix — the owner playtests feel afterwards. ## RESOLUTION (2026-10-01, same day) — root cause found, deterministic repro, fixed **The NaN was script math, not the solver.** H1–H3 were all tested headless and did NOT reproduce (`scenes/collapse_spike.tscn`, stacking family: chest 5/15 cm into the static table, 50 kg anvil rigid-welded to two 90 N chase hands, the exact logged grab sequence, the real PropShop with the chest plowing through props — all finite). Root cause, verified step by step: 1. The orientation servo computed `qErr.GetAngle()` = `2*acos(W)`. Godot C#'s `Mathf.Acos` is UNCLAMPED (docs: input "must be" in [-1, 1]); when float rounding puts the error quaternion's W at **1.0000001**, the angle is NaN. `Mathf.Min(NaN, cap)` passes NaN through, the slew clamp's `wErr.Length() > max` is false for NaN, and `AngularVelocity += NaN` goes straight into Box3D (`b3Body_SetAngularVelocity` only asserts validity in debug builds). The chest transform goes NaN → renderer flood → arm visuals (derived from the chest pose) vanish with it → "head detached from body". 2. Why it needed a seated, still player: `Quaternion.GetAxis()` returns the RAW un-normalized xyz once W > 1 − 1e-6 (angle < ~0.16°), so the servo's gain collapsed ~1000× inside that band — the body parked a fraction of a degree off target. The rounding hazard (error < ~3e-4 rad) is only reached when the yaw target then DRIFTS SLOWLY across that parked yaw — exactly a seated player holding still (the 50 kg anvil welded to both hands can't move: 2×90 N < 0.95×490 N friction, hence the logged 0.01 m/s releases). Normal motion crosses the band too fast. The hands and anvil stayed finite (they kept grabbing after the flood), consistent with a torso-only NaN. 3. Deterministic repro: head still at 40 yaws, target drifting 0.2°/s → **7/40 scenarios NaN the chest** on the old code (W = 1.0000001 at the crossing tick, body w = 0). Fix (`scripts/BodyMath.cs`): `RotationError()` = shortest-arc axis·angle via `2*atan2(|xyz|, W)` with a properly normalized axis — exact near zero, can't leave its domain. Used by the torso servo AND the hand rotation lock (hands were not observed to NaN — their Basis-product path measured 0/200k — but shared the gain collapse). Escape hatch: `BodyMath.ResetIfNonFinite()` on every driven body (torso, hands) — any future non-finite state resets that body onto its target with a warning instead of poisoning the renderer. After the fix: collapse spike 49/49 clean ×3, yaw error under slow drift 0.026° (was a 0.16° stall); physics/weld/grab/body spikes all PASS ×3. Mitigations M1–M4 were NOT needed for the NaN. Side observations kept for the owner: - The prop "table chaos" is real and separate: a seated chest sweeping the table plows props off at up to ~3.8 m/s (2400 N body) — honest physics, tune by feel if unwanted. - Seated calibration: head height 1.01 m clamps arm length to the 0.50 m minimum, so reaching saturates `ReachOffset` (0.25 m) and leans the chest INTO the table. The chest then rests saturated on the table edge — stable in every repro, but a calibration that assumes standing height is the deeper issue (see HANDOFF known issues).