matiaszanolli

Physics / PHYSAL Audit

"Deep audit of PHYSAL — the Havok→Rapier physics layer: collider classification, fixed-step determinism + explosion recovery, 4-phase ECS sync, ragdoll articulation, character/NPC kinematic controller, WATAL buoyancy sink"

matiaszanolli 28 3 Updated 1w ago
GitHub

Install

npx skillscat add matiaszanolli/byroredux/claude-commands-audit-physics

Install via the SkillsCat registry.

SKILL.md

Physics / PHYSAL Audit

Read .claude/commands/_audit-common.md (delta-first scoping, dedup, finding format) and _audit-severity.md for shared protocol.

Audits whether the simulation is correct, not just locked/unsafe: crates/physics/ + byroredux/src/ragdoll.rs + the character/NPC drivers that call the solver. Orchestrator; one Task agent per dimension (max 3 concurrent).

Scope

  • crates/physics/src/: world/ (split by #5311, 12ca34a70: mod.rs = PhysicsWorld struct/lifecycle/step with the per-substep body in run_substep; queries.rs = casts, capsule probes, move_character, CharacterMove*; recovery.rs = DynamicBodySnapshot, restore/clamp, counters, accept_keyframe_target, refresh_query_geometry_after_restore; tests live in mod.rs + broad_phase_tests.rs), sync.rs (physics_sync_system, spawn census), convert.rs (CollisionShape → Rapier, pose_from_trs, rotation_scaled_axis), config.rs (ContactConfig, TriMeshFlagBits), components.rs, ragdoll.rs, water.rs (WATAL sink), source_scan.rs (re-exports core's canonical production_text, #5100 655b317c9). Backend: rapier 0.36 / parry 0.31 on the workspace glam 0.33 (rapier's Vector/Rotation ARE the engine's Vec3/Quat; no nalgebra in the crate).
  • Engine side: byroredux/src/ragdoll.rs (+ ragdoll_installed_tests.rs), byroredux/src/systems/character.rs (player controller, swim, breath), byroredux/src/systems/locomotion.rs (NPC KCC step), byroredux/src/commands/{physics,water}.rs, byroredux/src/boot/schedule/physics.rs; parse side crates/nif/src/import/collision/.
  • Handoffs: NPC behaviour on top of locomotion (Wander/Travel/…, stuck re-pick, walk speed, gait) → /audit-gameplay; WATR/XCWT→WaterMaterial translation → /audit-exterior; water shading → /audit-renderer Dim 8; bhk* byte parsing → /audit-nif; CollisionShape extraction → /audit-nifal; lock order → /audit-concurrency.

Ground truth: docs/engine/physal.md (layer spec), docs/engine/physics.md, docs/engine/watal.md (water half; re-read its open list rather than trusting a copy here).

Known-open register (dated 2026-10-12; check the owning doc/issue before re-filing):

  • Skyrim mass=0 Dynamic bodies are reclassified Static (tes_grounding_zero_mass_dynamic_fix, #1832 closed). Remaining door-threshold spawn gap is a content/collision-import question, unchanged in AUDIT_PHYSICS_2026-09-11.md. A direct interior load spawns via cell_loader/interior_spawn.rs's ladder: COCMarkerHeading REFR, then the linked door partner's XTEL, then the door/bbox fallback (interior_spawn_point_fix).
  • Open (verified 2026-10-10): none from the capsule clamp — #4134 is fixed (002fbd6ef: character_capsule in world/queries.rs routes through clamp_shape_extent; guard character_capsule_sanitises_non_finite_and_absurd_extents). #3477 closed (regression guard: registered_shape_generations structural-generation cache, newcomer_cache_detects_membership_changes_even_when_counts_match). BhkPlaneShape produces no collider by design and is counted in the spawn census (#4407, #4408 both closed).
  • Water-walking and freezing are unbuilt (docs/engine/watal.md).
  • #5161/#5246 contain ragdoll solver explosions at the physics boundary (Dim 2); the upstream instability of mass-inverted rigs (palms heavier than the spine, authored restitution 0.8) stays open as root cause with no issue of its own (5ae7f8ad4). Two of its drivers are fixed and measured (c6327cc6b, #5536/#5538, Dim 3): the FO3 real-data articulation gate (ragdoll::installed_tests, six environments x two yaws) is green again — re-measure it before re-opening the claim.
  • Ball-and-socket / stiff-spring / chain constraints are decoded (#4212) but have no canonical joint kind, so extract_ragdoll declines them like Other (docs/engine/physal.md §3 table). By-design gap; a finding needs occupancy-census evidence that shipped content uses them.

Parameters

--focus <dims> (default all 6) · --game <name> restricts Dim 3's per-game seam checks · --depth shallow|deep (deep traces a cell's colliders from NIF bhk* to solver; default deep).

Extra finding fields: Dimension: Shape Translation | Step & Sync | Ragdoll & Constraint Seam | Character & NPC Controller | Water / Buoyancy | Queries & Diagnostics. Trigger Conditions: what a cell must contain for the bug to fire.

Phase 1: Setup

  1. mkdir -p /tmp/audit/physics; dedup per _audit-common.md. Read the latest docs/audits/AUDIT_PHYSICS_*.md (date D = delta baseline).
  2. cargo test -p byroredux-physics and cargo test -p byroredux -- ragdoll / character / locomotion / water (bin crate, no --lib); record counts. Note that byroredux/src/ragdoll_installed_tests.rs (6 tests) is entirely #[ignore] behind FO3 game data: only -- --ignored with BYROREDUX_FO3_DATA runs it. Likewise byroredux/src/ragdoll_skeever_probe_tests.rs (skeever_ragdoll_survives_six_hundred_floor_ticks, the #5161 live-spec replay; needs BYRO_SKEEVER_NIF, floor penetration via BYRO_PROBE_PENETRATION; on rapier 0.36 it pins containment counters, not the old panic) is #[ignore]d.
  3. No windowed engine launches (feedback_no_parallel_engine_launch): use cargo test, BYRO_PROFILE=1 on an existing headless run, or read only.

Phase 2: Dimensions

Dim 1: Shape Translation

Paths: crates/physics/src/{convert,config}.rs, crates/nif/src/import/collision/shape.rs
First step: git log --since=D -- crates/physics/src/convert.rs crates/physics/src/config.rs crates/nif/src/import/collision/
Guards (default lane): config.rs::trimesh_flag_bits_match_rapier_definitions (Rapier flag pin), convert.rs::huge_finite_*_clamps_to_sane_ceiling (extent clamp; release-only), non_finite_cuboid_extent_clamps_instead_of_reaching_rapier (release-only — the #4686 debug-lane twin non_finite_extents_clamp_in_the_debug_lane_too covers the default lane). Aim at what they cannot see:

  • Every CollisionShape variant the importer emits has a translation arm (list variants vs arms, report the delta; same trap as nif_shape_dispatch_resolve_parity). A silently dropped shape is an invisible hole.
  • Compound/transform children compose in authored order; non-uniform parent scale is rejected, converted to TriMesh, or documented, never averaged.
  • Scale is applied exactly once, at the Rapier sink (docs/engine/physal.md: producers must not bake scale into vertices/primitives, else scale²).
  • Every primitive routes through clamp_shape_extent (floor 1e-3, ceiling MAX_SANE_SHAPE_EXTENT, non-finite → floor); a new variant with a local .max(1e-3) regresses this — #4134 was exactly that in the controller/probe capsules (character_capsule), now routed through the shared clamp.
  • Surface material (Wave 4): RigidBodyData.surface_material is a u32 end to end (Skyrim's are CRC32 of the CK material name — a narrower column collided 8 pairs, #5542 c173147da, FORMAT_MAJOR bumped); the importer's resolve_shape_with_materials returns the per-leaf sequence from the SAME walk as the shape (#5550), so RigidBodyData::part_surface_materials (serde-skip, re-derived on load) is 1:1 with collision_shape_to_parts' part order; 0 is authored STONE only on bsver < 83. register_newcomers packs each part's own material into its collider user-data (collider_surface_material reads it back); build_ragdoll packs nothing, so bone colliders read 0/STONE (#5557). A new CollisionShape variant that flattens differently from the importer's leaf order silently misaligns materials.
  • Contact combine rule: both collider producers (register_newcomers, the ragdoll bone builders) set config::CONTACT_COEFFICIENT_COMBINE_RULE = GeometricMean on friction AND restitution (Havok's sqrt(a*b); rapier's default Average made a restitution-0 body bounce off a 0.8 floor at 0.4; 04cb6333d). It wins rapier's combine priority, so dropping it from either producer reverts every pair with an engine collider; pinned per production path by every_registered_collider_carries_the_havok_contact_combine_rule / every_ragdoll_collider_carries_the_havok_contact_combine_rule (#5552) plus restitution_zero_body_does_not_bounce_on_restitution_floor.
  • The release-only Compound-child backstop is factored into sanitize_child_pose and tested in every profile (#5551).
  • default_contact_skin_bu (1.0 BU) is applied to every collider kind, not just TriMesh. Degenerate input (zero-area triangles, empty vertex sets, NaN transforms) is rejected before Rapier builds a BVH.
    Output: /tmp/audit/physics/dim_1.md

Dim 2: Step, Wake & the 4(+1)-Phase Sync Tick

Paths: crates/physics/src/world/ (incl. recovery.rs, broad_phase_tests.rs), crates/physics/src/{sync,components}.rs, byroredux/src/boot/schedule/physics.rs, byroredux/src/cell_loader/rapier_release_tests.rs
First step: git log --since=D -- crates/physics/src/world/ crates/physics/src/sync.rs byroredux/src/boot/schedule/physics.rs
Guards: byroredux/src/scheduler_access_tests.rs::physics_sync_declaration_reads_all_phase_and_diagnostic_types + contact_config_read_is_declared_on_both_physics_systems (access declaration; it has been forgotten four times), cell_loader/rapier_release_tests.rs (cell-unload handle release). Beyond them:

  • Accumulator clamp to MAX_SUBSTEPS * PHYSICS_DT precedes the loop; frame_dt.max(0.0) guards NaN/negative. The catch-up loop stops at SUBSTEP_TIME_BUDGET and drops backlog (slow-motion, never a jump); the budget timer is the one documented non-determinism source, no wall-clock or HashMap order elsewhere in the solver path.
  • Static-scene fast path (no dynamic body in islands.active_bodies() — the active set also holds kinematics, but rapier 0.36 gives them a real sleep test, so an idle keyframed body leaves it after ≈0.5 s and active_island_counts().1 is an ACTIVE count, not a live one: live_kinematic_count is the on-demand total, #5563 — and !pending_wake) zeroes the accumulator. Every motion starter except spawn calls wake(): set_linear_velocity, kinematic push, ragdoll activation, buoyancy. Spawn is the deliberate exemption (#3969; wake()'s doc): register_newcomers builds dynamics asleep and only calls queue_query_refresh(), and first-frame consumers take n_new > 0 / had_newcomers explicitly. A wake() added to spawn is the regression (the measured multi-second streaming stall). The contract is ASLEEP-UNTIL-PROCESSED (#5544 f454e0abc): rapier 0.36's begin-touch strong-wake wakes a brand-new collider on its first stepping frame, so step re-sleeps every spawn_sleep_pending newcomer still within 4 BU of its spawn pose after that frame and drops its settle force-crossings; the water pass's deliberate wakes are exempt (water_force_targets). Confirm a same-frame wake cannot be swallowed.
  • Registration: register_newcomers runs collision_shape_to_parts over the batch on the rayon pool before taking PhysicsWorld, then inserts serially in newcomer order (ad1d53a11), so Rapier sees the serial sequence. Guard: register_newcomers_parallel_section_holds_no_world_guard. Rapier 0.35+'s BroadPhaseBvh withholds fixed-on-fixed pairs itself (the ActiveCollisionTypes filter at pair creation) and re-inserts a body's colliders on a type change, so pairs denied while both were fixed are reported again — the job the engine's FixedPairFilterBroadPhase (fe80f4d76, deleted with broad_phase.rs in ac81a09f9) did on rapier 0.22. Guards: world/broad_phase_tests.rs::{two_fixed_bodies_never_reach_the_narrow_phase, a_fixed_body_turned_dynamic_stays_on_what_it_rested_on, removing_a_fixed_body_with_withheld_pairs_is_a_no_op_for_them}.
  • wake() does not guarantee a substep. Scene queries borrow the broad phase's BVH (PhysicsWorld::queries), which only the step updates; colliders inserted on a no-substep frame must be queued with queue_query_refresh (register_newcomers, build_ragdoll since #3968), and step inserts exactly those leaves (BroadPhaseBvh::set_aabb) before it returns. update_query_pipeline() is the O(all colliders) cold-start/test refresh — it must never run per frame (step_cost_rationale_is_scoped_to_history_and_names_the_real_cost_centre pins that step, run_substep and refresh_query_geometry_after_restore do not call it, each scan window checked to be the function it names, #5558).
  • Explosion recovery: each substep snapshots ALL dynamic-body poses (not just active islands, so a freshly activated ragdoll's first solve is covered; check the per-substep Vec cost on a body-heavy exterior); restore_invalid_dynamic_bodies reverts any body that is non-finite or moved more than MAX_DYNAMIC_SUBSTEP_DISPLACEMENT (2,048 BU) in one substep, sleeps it, logs at error, and zeroes the accumulator. Verify the snapshot precedes every substep, the bound is a delta not a coordinate (far exteriors), and the zeroed accumulator does not eat the frame's wake. #4683: recovery is COUNTED (recovery_counts() = lifetime events, bodies restored last step, bodies parked — distinct units since #5127; surfaced in phys.stats/ragdoll.status with live joint counts) — assert on the counter, not just post-recovery state. #4687: already-non-finite bodies are parked (not left awake), restored poses are propagated to colliders + the query BVH same-frame (#5126; now BroadPhaseBvh::set_aabb per touched collider — the rapier 0.22 form needed update_incremental(…, true); the guard restored_pose_is_visible_to_ray_queries_same_frame drives a real step recovery), and EVERY invalidated articulation is detached, not just the first. Rapier 0.36 layers (#5488): rapier caps rigid-body speed INSIDE each internal substep (IN_STEP_LINEAR_SPEED_CAP_BU_PER_S = 28 000 BU/s via normalized_max_linear_velocity; deliberately ABOVE the 20 000 sanity cap so the ladder below still sees a capped burst — in_step_speed_cap_bounds_an_explosion_inside_one_step), but NOT multibody links (forward kinematics integrates outside the solver's per-body cap), so the displacement restore and DOF clamp remain the articulation guards. Rapier quarantines non-finite bodies by DISABLING them; restore_invalid_dynamic_bodies takes pipeline.quarantine().bodies() and parks them, and run_substep re-enables them after the NEXT step via quarantine_cooldown (52de1bc0a; a_quarantined_body_is_re_enabled_and_parked). A substep-armed wake (cooldown re-enable, fresh detection) rides substep_wake_requested and re-arms pending_wake AFTER step's post-loop clear — without it, in a quiescent scene the re-enabled body's colliders stayed DisabledByParent and invisible to rays/KCC until unrelated motion (#5543 b0a86c6aa). Both directions are regressions: a quarantined body left disabled, and one re-enabled in the same frame — the disable is then never processed, the body keeps its stale solver state and is re-quarantined every woken step (ragdoll.rs::a_quarantined_rig_resting_on_a_floor_recovers_once; live: 253 restores on a Whiterun corpse in the P1 smoke). The rapier 0.22 sap_axis.rs multi-SAP panic class (#5161/#5488, closed by the ac81a09f9 migration) is gone with the BVH broad phase; do not re-derive caps from its ≈2.68e11 grid boundary. Escalation (#5161, 5ae7f8ad4; #5246, e8de9f8c8): clamp_explosive_velocities runs at the end of every substep — including a recovery substep, for the bodies the restore did not claim (#5352; a_restore_substep_still_clamps_the_bodies_it_did_not_claim): caps body speed at VELOCITY_SANITY_CAP_BU_PER_S (20k) / spin 100 rad/s and articulation DOF velocities at ARTICULATION_DOF_SANITY_CAP (DOFs integrate through forward kinematics before any body clamp) — except a dynamic root's six free-joint DOFs (linear BU/s first, then angular rad/s; FREE_ROOT_DOFS), which take the body caps: the joint-axis cap of 100 held every falling ragdoll to 100 BU/s against 686.7 BU/s² of gravity (#5353); the DOF walk is clamp_articulation_dofs (one pass per multibody, keyed by its root body, returning (root, clamped DOFs) so the warning names the rig through body_labels, #5531; guard the_dof_clamp_attributes_each_rig_by_its_root_body); non-finite velocity is zeroed, never passed through (one step later it is a quarantined, disabled body); escalation is a LIFETIME per-body offence count (1 clamp, 2 park, 3 remove_multibody_articulations) — a count reset on clean substeps is the #5246 regression. explosive_detaches_total counts only a third-offence burst whose rigid_body_link finds a live articulation, so clutter and already-freed rigs park without a count (#5356); clamp_explosive_velocities skips handles in the (stale-tolerant) dynamic index that are no longer Dynamic, so a scripted SetMotionType(Keyframed) teleport is not counted as an explosion (#5555 aedd31a43); articulation_joints is swept against liveness at the top of every clamp, and remove_body prunes explosion_offences / keyframe_refusals_logged beside body_labels (#5355/#5272; both 26a1277bc) — a per-body map that outlives its body is the regression. push_kinematic refuses non-finite or > KEYFRAME_TARGET_SANE_BOUND_BU targets via accept_keyframe_target (the only guard for keyframed live-actor bones, which the Dynamic-only snapshot skips). Counters (velocity clamps, explosive detaches, keyframe refusals, seed refusals) surface in phys.stats. Guards: explosive_velocities_are_capped_and_persistent_exploders_are_parked, nan_velocities_are_zeroed_on_the_first_clamp, a_second_burst_parks_even_after_a_clean_substep, the_third_burst_detaches_a_live_articulation, the_third_burst_parks_a_jointless_body_without_counting_a_detach, articulation_joints_are_swept_when_their_articulation_dies, remove_body_prunes_the_offence_ladder_and_refusal_set, accept_keyframe_target_refuses_insane_but_finite_poses, ragdoll.rs::exploding_articulation_dofs_are_capped_before_forward_kinematics, ragdoll.rs::a_free_ragdoll_falls_at_gravity_without_tripping_the_dof_clamp, ragdoll.rs::an_exploding_free_root_dof_is_capped_at_the_body_speed_cap. Note: multibody (ragdoll) joints are DETACHED on restore by design (remove_multibody_articulations; playable-vertical-slice.md § P5, "FO3 restored-ragdoll recovery") — a joint-restoration claim is the pre-#4687 error. Tests: world/mod.rs::invalid_dynamic_body_reverts_to_its_last_valid_substep_pose, finite_but_impossible_dynamic_jump_reverts_to_its_last_valid_pose, recovery_counter_counts_a_real_substep_explosion, recovery_snapshot_index_tracks_dynamics_not_the_arena, the three #4687 edge tests.
  • Contact-force events (Wave 4 impacts): the step runs a ChannelEventCollector whose collision/tear receivers are dropped on purpose; dynamic bodies' colliders carry ActiveEvents::CONTACT_FORCE_EVENTS and only the started crossings drain into pending_force_events (cleared ONCE at step entry, substeps APPEND — a per-substep clear loses all but the last, #5557), taken by take_contact_force_events (impact_audio_system, /audit-audio Dim 5). run_substep truncates the buffer back to its pre-substep length when the substep is rolled back (restored > 0), so an explosion's insane forces never become an impact burst (#5554). The receiver sits in a Mutex (the narrow phase calls it from rayon workers; PhysicsWorld must stay Sync). phys.stats prints contact_counts() by scope — pairs is the whole narrow-phase graph, contacts/constraints the awake island only, and constraints never counts a multibody joint — beside multibody_joint_count (#5564).
  • Kill plane (KILL_PLANE_Y): active dynamic bodies below it are zeroed and slept so unsupported clutter cannot keep the fast path off forever. Verify the constant sits below any real geometry (deep water/exterior lows) and only bodies that actually stepped are scanned.
  • Lock discipline: newcomers are collected under read locks that are released before write locks on PhysicsWorld + RapierHandles (report the trace here; /audit-concurrency owns the rule). Phase order is collect/register → push kinematic → buoyancy → step → pull dynamic (buoyancy must precede the step). An entity with RapierHandles is never registered twice. Pull-dynamic writes Transform, not GlobalTransform. Absent PhysicsWorld early-returns without panic.
    Output: /tmp/audit/physics/dim_2.md

Dim 3: Ragdoll Articulation & the Per-Game Constraint Seam

Paths: crates/physics/src/ragdoll.rs, byroredux/src/ragdoll.rs, byroredux/src/ragdoll_installed_tests.rs, crates/nif/src/import/collision/{ragdoll,mod}.rs, crates/nif/src/blocks/collision/constraints.rs
First step: git log --since=D -- crates/physics/src/ragdoll.rs byroredux/src/ragdoll.rs crates/nif/src/import/collision/ragdoll.rs
Guards: ragdoll.rs::{ragdoll_solver_budget_preserves_the_world_default_mass_and_limits, a_ragdoll_does_not_change_an_unrelated_bodys_solve}, build_ragdoll_is_queryable_on_a_zero_substep_frame, byroredux/src/ragdoll.rs::activation_before_first_physics_sync_prevents_duplicate_followers; the FO3 finite-pose tests only run with data (Phase 1) — the gate command is -- --ignored ragdoll::installed_tests (the module path; a ragdoll_installed filter matches zero tests and exits green).

  • PHYSAL doctrine. docs/engine/physal.md §3 names three source-boundary seams, all at parse→canonical: constraint CInfo decode, havok_scale_for (crates/nif/src/lib.rs), collision-object-kind dispatch. Grep the solver side (crates/physics/, byroredux/src/ragdoll.rs) for GameKind / bsver / version constants: each hit is either parse-side material that belongs upstream or a stale doc; both are findings with different fixes.
  • Constraint coverage matches the doc table: Ragdoll / LimitedHinge / Hinge (synthesised ±π) / Prismatic have importers; the breakable wrapper decodes all four; bhkGenericConstraint and unknown malleable inners stay Other. Byte consumption is exact for the decoded types, so drift telemetry is live for them.
  • Solver budget: RAGDOLL_EXTRA_SOLVER_ITERATIONS = 0 (#5546 9da0cf9f8): rapier 0.36 shares one island across every awake body and splits per-body budgets only while no multibody exists, so the old +12 made every awake dynamic body solve 16 substeps while any ragdoll bone was awake (~2x step cost, unrelated trajectories changed); re-measured +0/+4/+12 all bounded on the FO3 gate. Pinned by a_ragdoll_does_not_change_an_unrelated_bodys_solve; confirm nothing raised the global budget or the constant. Authored masses/limits are preserved.
  • Joint limits follow Havok's convention (#5536 c6327cc6b): Havok measures the hinge angle of A (attached) relative to B (reference), rapier's coordinate is the CHILD relative to the parent — non-flip (A parent) negates+swaps, flip (A child) passes through; the ragdoll twist and prismatic travel carry the same derivation (the mirrored version enforced hyperextension and forbade flexion on every vanilla elbow/knee/ankle/wrist). Joints carry RAGDOLL_JOINT_SOFTNESS (30 Hz, ζ 1) so a bind pose outside its limits is pulled in over frames, not corrected in one substep (#5538; rapier's 1e6 Hz default produced 617 BU/s at tick 0). Multibody links get drag and authored damping as FORCE/TORQUE, since rapier applies body damping only on the rigid-body writeback path that skips links (#5540 b4accad7e).
  • Bone→body index mapping (off-by-one explodes on activation); authored limits use consistent units with a documented fallback for degenerate limits; ragdoll_extra_angular_damping stays inert (0.0) and is added once per body (for links it now also reaches the solver through the force/torque path above).
  • Seeds are validated before any articulation exists (#5161): seed_pose_is_sane (finite, ≤ SEED_SANE_ABS_BOUND_BU, ≤ SEED_SANE_MAX_ROOT_OFFSET_BU from the actor root) runs in both the bin activator and build_ragdoll's backstop; a rejected spec builds nothing and counts one refusal (guards activation_refuses_a_seed_beyond_actor_reach, activation_refuses_a_non_finite_seed). Ragdoll bodies carry evidence labels (actor <id> bone <name>) so recovery logs name the owner.
  • Activation removes every spec body's RigidBodyData recipe even when no follower handles exist yet (restored corpse activated before its first sync), so no duplicate kinematic bodies feed the solver. remove_ragdoll releases every body/collider/joint (body_count/awake_counts before/after are the observable).
  • Writeback drives GlobalTransform from body_pose (not Transform — re-verified #4692); the Z-up→Y-up conversion happens upstream, not again here. FO4+ is blocked on the opaque BhkSystemBinary; summarize_collision_authoring must let the loader report "authored but opaque" rather than "no collision".
    Output: /tmp/audit/physics/dim_3.md

Dim 4: Character Controller, Grounding & the NPC KCC (M42.10)

Paths: crates/physics/src/world/queries.rs (move_character), crates/physics/src/components.rs, byroredux/src/systems/{character,locomotion}.rs
First step: git log --since=D -- byroredux/src/systems/character.rs byroredux/src/systems/locomotion.rs crates/physics/src/world/
Guards: world/mod.rs::kcc_filter_groups_mask_actor_bone_colliders (non-vacuous: the unmasked sweep must be blocked), capsule_clearance_rejects_door_overlap_and_can_exclude_self, capsule_clearance_ignores_sensors_and_live_actor_bones; character.rs tests for integrate_vertical/horizontal_motion; locomotion.rs::stuck_tests. Physics-side contract only; NPC behaviour is /audit-gameplay.

  • NPC step: step_toward_detailed drives XZ through PhysicsWorld::move_character with filter_groups: Some(actor_move_interaction_groups()), which equals ground_probe_groups() (masks ACTOR_BONE_GROUP wholesale — so NPCs ghost through NPCs, and clutter blocks but is never shoved: no collision impulses, autostep refuses dynamic bodies; #4690). The step OWNS its vertical (#5539 2917ab372): a straight-down cast_ray_down measures the support gap (the capsule shape-cast misreads convex floors from inside the contact-offset band) and the request carries exactly clamp(-(feet - support - offset), -step_height, +offset); LOCOMOTION_NPC_SNAP_TO_GROUND is 0 because rapier 0.36's snap fires on every horizontal move and its unchecked cast sank NPCs knee- to waist-deep into box/hull floors (guard grounded_npc_does_not_sink_through_convex_floors_across_absolute_y). That correction is also why the dt = 0 boot tick is an ordinary slide: rapier 0.36's move_shape depenetrates whenever |desired| < 1e-5 and production never sends a zero request (a_zero_dt_boot_step_matches_a_normal_first_step_for_embedded_npcs, #5559; removing the correction is the regression). Movers that must notice an obstacle use step_along_waypoints_detailed's blocked flag (#5452). The airborne fallback cast_ray_down is origin-lifted by LOCOMOTION_GROUND_RAY_UP_OFFSET (256) and clamped to LOCOMOTION_MAX_DROP (256 BU) so a ledge cannot teleport the actor under the shell; its None exclusion is correct only because the group mask covers the actor's ~18 bone bodies (a bone is a separate body, so a single exclude_collider can never cover them). Any new NPC cast without the mask self-hits. forcegreet_system (Update) and eat_sleep_system (PostUpdate) also step through step_toward; both write the step through Transform (a GlobalTransform write is erased by propagation, #5373) with PhysicsWorld held only for the step (#5371; both f0c683ef1).
  • The LOCOMOTION_NPC_* constants are DERIVED from their sources (#4689): KCC offset reads ContactConfig::DEFAULT, the step trio reads CharacterController::HUMAN, the capsule pair shares one const with install_fallback_actor_collider; pinned by npc_locomotion_constants_match_their_sources (which also restates the > 2 * default_contact_skin_bu invariant). Verify the derivation wasn't reverted to literals.
  • capsule_overlaps_solid tests the final placement (spawn ladder, doors) with solid_probe_filter (kinematic architecture in; dynamics, sensors, actor bones out); the query pipeline must be current.
  • Player: kcc_offset_bu applied and above the contact-skin pair; walkable-slope threshold is a named constant with rationale; grounding mechanism (collider present → cast hits → grounded) traced without re-filing the closed mass=0 case — resolve_ground_contact's OR of the support probe into the KCC verdict is load-bearing while WALKING (parry 0.31's support cast returns a tilted GJK normal at the offset gap and the KCC alone answers grounded = false on ~80/600 frames walking +X; pinned by walking_capsule_loses_kcc_grounded_on_frames_the_support_probe_covers, #5560 f89802955); the idle case is covered by the KCC's own explicit grounded check (738ab7391). The player passes no actor-bone mask, so an idle player overlapped by an NPC limb or clutter is depenetrated while a walking one is not — recorded at the move_character call as an unreferenced gameplay choice (#5559); integrate_vertical terminal clamp after accumulation and MAX_FRAME_DT so a stalled frame cannot tunnel the floor; diagonal input normalised before the speed multiply; player_accepts_movement_input honours PlayerControlState and ActorControlState.restrained (UI focus is enforced upstream: /audit-ui Dim 6); snap_character_body_to_camera takes &World by documented design and toggle_player_mode is reachable only from console / exclusive paths. The player's ActorValues carry evaluated PlayerOnly rows (#4674), re-evaluated every frame by refresh_player_only_bases in player_derived_stats_system (#5039), CharacterLevel/Background are stamped (#4678), and the HUD bars resolve AVIF editor ids through PlayerVitals reading the player only (#4675).
  • Third-person player body (byroredux/src/player_body.rs, a070baaad): the body is visual-only. attach_player_body strips CollisionShape/RigidBodyData from its imported nodes before the first physics sync (guard attach_reparents_offsets_retargets_and_strips_collision), because player casts exclude one body handle, not a group. The THIRD_PERSON_BOOM_BU camera offset in character.rs is unswept (no camera-collision probe). player_camera_boom is its single source: camera_follow_system places the camera with it and interaction::camera_ray adds it back, so every player gameplay ray (activation, melee, studio host, view commands) starts at the eye (#5124, 69157c7cd); a caller casting from the raw camera pose is the regression.
    Output: /tmp/audit/physics/dim_4.md

Dim 5: WATAL Physics Sink: Buoyancy, Damping, Current, Swim

Paths: crates/physics/src/water.rs, crates/core/src/ecs/components/water.rs (+ water/surface_grid.rs), byroredux/src/systems/{character,water}.rs, byroredux/src/commands/water.rs, byroredux/src/boot/schedule/late.rs
First step: git log --since=D -- crates/physics/src/water.rs byroredux/src/systems/water.rs byroredux/src/systems/character.rs
Guards: scheduler_access_tests.rs::water_and_animation_parallel_accesses_are_complete; character.rs::{swim_damping_is_frame_rate_independent, zero_dt_tick_while_submerged_does_not_refill_breath, swimming_drift_scales_the_plane_by_fraction_and_the_marker_at_full_strength, the_marker_reaches_the_swimmer_through_drift_not_the_column_state, a_marker_outside_every_plane_still_drifts_the_player, swimlevel_predicate_agrees_between_pose_and_depth_forms}; water.rs::placed_current_volume_is_collected_without_becoming_a_water_surface.

  • submerged_fraction clamps to [0,1] and survives a zero-height AABB. Lift is proportional to submerged volume and opposes gravity in the renderer's Y-up frame (units BU). Lift is re-derived at the top of EVERY fixed substep: the scan registers each awake wet body's WaterLift and PhysicsWorld::refresh_water_lift recomputes it from the current AABB and velocity, adding only the change to rapier's persistent user force (a lift held over 5 substeps x the solver's internal steps was a limit cycle whose threshold scaled with render fps, #5547 c5bf46e59); thin floats additionally get a vertical heave damping (heave_hold_damping, ∝ 1/h, rescaled by rho*|g| over the measured reference stiffness and capped at 0.9/PHYSICS_DT for stability — #5618; the lift spring itself loses stability below ~0.08 BU). A sleeping float re-wakes against the depth it SETTLED at (latched in WaterContactScratch, dropped on wake/exit/despawn), not the per-scan delta (#5561). Jointed links take buoyancy drag as force/torque (#5540).
  • Wake discipline: buoyancy never pins the static-scene fast path; it wakes only on submersion change, and the n_new > 0 escape (a body streaming in already submerged, spawned asleep) still exists.
  • Two target sources (#3492): the RapierHandles+RigidBodyData{Dynamic} scan and the Ragdoll scan (RagdollBuoyancy caches first collider + effective damping at build_ragdoll because activation strips the bones' rows). Both feed the same targets/writes pipeline and clear_stale_water_contacts has the matching Ragdoll arm, else a corpse leaving water keeps a latched WaterContact.
  • Current drag is bounded (clamp + named constant). One reference_point (collider AABB centre) feeds the union prefilter, current-volume containment and surface XZ containment; a revert to the raw body origin on any axis is the regression.
  • WaterContact emits one transition frame at zero so FX/audio consumers see the edge.
  • Two samplers must agree: the dynamic path (apply_buoyancy_with_scratch) and the kinematic player's player_water_state in character.rs. Every water input added to one (plane flow, placed WaterCurrentVolume marker, wave height, damage) must exist in the other (every CPU wave sampler takes WaterKind::weather_scroll_for, the one policy that damps the atmospheric scroll on flowing kinds, and buoyancy samples at the collider AABB centre — #5354 cff1f9c42; the wave tuning and scroll prep live once in core as WATER_WAVE_* / WaterMaterial::wave_scrolls, which water.vert, the GPU upload and the CPU crest sampler all read — #5582 7cf2bd086; authored hazard damage is applied ONLY by Late water_damage_system, including to the swimming player, whose controller arm was a second 1x, #5548), and both COMPOSE plane + marker ADDITIVELY with the same per-source scale: plane drag × submerged fraction, marker at full strength (frac = 1.0). On the player the marker never enters PlayerWaterState (its flow and the published WaterContact carry the plane column's flow only, matching the dynamic path's flow: s.flow); player_current_drift adds it in both the swim and wade arms (#4691 fixed plane-wins or_else; #5357, dd3a506e9, fixed the swim arm scaling the folded sum by the fraction). Surface height for mesh water without a phantom is WaterVolume::surface_y_at over its WaterSurfaceMesh triangles (17c01a4e5; a stream can descend ~1800 BU); buoyancy, player_water_state and submersion_system must all sample it, never the flat max.y. The player sampler tolerates a missing WaterCurrentVolume storage (#4791).
  • WaterContact carries an optional waterline (AABB-centre XZ at the sampled surface height, waves included) written by the buoyancy phase; the splash/ripple adapter places events on it, and a producer with no collider AABB (the player capsule) leaves it None and keeps origin + depth (#5562; not saved).
  • Swim/drown (swimlevel_reached, swim_vertical_velocity, advance_breath, apply_player_drowning_damage) are in scope like any code: damping is dt-correct, zero-dt never credits breath. Water-hazard and drowning deaths reconcile in ONE place, reconcile_pending_dead_actors_system (Stage::Late exclusive, byroredux/src/boot/schedule/late.rs); a second inline teardown site is the regression (#3119).
  • Contact→event adapters in byroredux/src/systems/water.rs (Stage::Late exclusives: submersion → water_damage_system → death reconcile → make_water_interaction_system → water audio; every drowning death also raises the KILL story event, #5434): submersion_system reads the camera pose after camera_follow_system (guard scheduler_access_tests.rs::submersion_runs_after_camera_follow_and_before_water_audio); damage and splash/ripple markers derive from WaterContact edges, keeping crates/physics free of scripting/presentation deps (#4183 closed: wave parameters are snapshotted before the water storage guards). compute_underwater_params (tint) belongs to /audit-renderer / /audit-exterior.
  • Seams reported once, here, with pointers: tri-state XCLW decode (/audit-esm), render half (/audit-renderer Dim 8).
    Output: /tmp/audit/physics/dim_5.md

Dim 6: Queries, Diagnostics & Cost

Paths: crates/physics/src/world/{queries,mod}.rs (casts, census), crates/physics/src/sync.rs, byroredux/src/commands/physics.rs
First step: git log --since=D -- crates/physics/src/sync.rs byroredux/src/commands/physics.rs
Guards: sync.rs::census_excluded_body_prevents_a_self_hit_on_the_players_own_capsule, census_reports_full_extent_and_scale_not_just_the_y_range.

  • Every cast the controllers use excludes the caster (self-hit = permanently grounded on your own capsule): the player passes its body handle, actors use the group mask (Dim 4). A live census caller (phys.census) must pass Some(player_body). cast_ray_corridor (#5160: a ball shape-cast on the same query surface as cast_ray, sensors excluded, used by the melee swing and the combat.approach LOS check in commands/view.rs) excludes one body only — a live actor's own keyframed bones are separate bodies and stay hittable (combat treats a self-root hit as a miss; /audit-gameplay Dim 4). Guard corridor_cast_hits_a_bone_the_zero_width_ray_threads.
  • surface_material_under(..., excluded_body) (the footstep surface probe, world/queries.rs) takes the walker's body handle as a MANDATORY argument like cast_ray_down: the player capsule's centre is the footstep trigger, so an unexcluded solid ray hits it at toi 0 and every step reads material 0 (#5545 3909778eb; guard surface_material_under_skips_the_walkers_own_capsule). Its one live caller is footstep_system (/audit-audio Dim 5). phys.stats (commands/physics.rs) labels every counter by scope: recovery events/bodies/parked, contact pairs/contacts/constraints, multibody_joint_count, and active-beside-live kinematic counts (#5563/#5564).
  • The census (dump_spawn_collider_census, phys.stats/phys.census via byro-dbg) distinguishes no collider authored from dropped in translation from present but not walkable, and reports full AABB extent + owning placement scale so a scale-drift collider is distinguishable.
  • colliders_near_xz allocates per call: check caller frequency (a per-frame call on a radius-12 exterior is a hot-path allocation; /audit-performance owns the fix).
  • Cost model (#4685/#4682, re-stated for rapier 0.36): there is no separate query tree — queries borrow the broad phase's BVH, which pipeline.step maintains incrementally; a frame that steps nothing inserts only the queued leaves; the recovery snapshot reads a maintained dynamic-body index rather than the arena. The rapier-0.22-era 'budget from the rebuild' and 'snapshot walks the arena' attributions are both historical. step_cost_rationale_is_scoped_to_history_and_names_the_real_cost_centre pins the in-code comment; re-measure with real TriMesh-heavy content before citing a number.
    Output: /tmp/audit/physics/dim_6.md

Phase 3: Merge

Combine /tmp/audit/physics/dim_*.md into docs/audits/AUDIT_PHYSICS_<TODAY>.md (header per _audit-common.md Report finalization): Executive Summary (findings by severity; PHYSAL verdict = are the three named seams the only per-game branches; which games' collision data was traced), Solver Invariant Matrix (fixed step / wake / lock order / phase order / explosion recovery / teardown, each verified or drifted), Findings (deduplicated), Known-Open Register (what this pass changed). Then rm -rf /tmp/audit/physics; suggest /audit-publish docs/audits/AUDIT_PHYSICS_<TODAY>.md (labels physics, plus water for buoyancy-sink findings and game:* when Havok-data-specific).