"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"
Instal·la
npx skillscat add matiaszanolli/byroredux/claude-commands-audit-physics Instal·la des del registre de SkillsCat.
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=PhysicsWorldstruct/lifecycle/stepwith the per-substep body inrun_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 inmod.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 canonicalproduction_text, #5100 655b317c9). Backend: rapier 0.36 / parry 0.31 on the workspace glam 0.33 (rapier'sVector/RotationARE the engine'sVec3/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 sidecrates/nif/src/import/collision/. - Handoffs: NPC behaviour on top of locomotion (Wander/Travel/…, stuck re-pick, walk speed, gait) →
/audit-gameplay; WATR/XCWT→WaterMaterialtranslation →/audit-exterior; water shading →/audit-rendererDim 8;bhk*byte parsing →/audit-nif;CollisionShapeextraction →/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 viacell_loader/interior_spawn.rs's ladder:COCMarkerHeadingREFR, then the linked door partner'sXTEL, then the door/bbox fallback (interior_spawn_point_fix). - Open (verified 2026-10-10): none from the capsule clamp — #4134 is fixed (002fbd6ef:
character_capsuleinworld/queries.rsroutes throughclamp_shape_extent; guardcharacter_capsule_sanitises_non_finite_and_absurd_extents). #3477 closed (regression guard:registered_shape_generationsstructural-generation cache,newcomer_cache_detects_membership_changes_even_when_counts_match).BhkPlaneShapeproduces 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_ragdolldeclines them likeOther(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
mkdir -p /tmp/audit/physics; dedup per_audit-common.md. Read the latestdocs/audits/AUDIT_PHYSICS_*.md(date D = delta baseline).cargo test -p byroredux-physicsandcargo test -p byroredux -- ragdoll/character/locomotion/water(bin crate, no--lib); record counts. Note thatbyroredux/src/ragdoll_installed_tests.rs(6 tests) is entirely#[ignore]behind FO3 game data: only-- --ignoredwithBYROREDUX_FO3_DATAruns it. Likewisebyroredux/src/ragdoll_skeever_probe_tests.rs(skeever_ragdoll_survives_six_hundred_floor_ticks, the #5161 live-spec replay; needsBYRO_SKEEVER_NIF, floor penetration viaBYRO_PROBE_PENETRATION; on rapier 0.36 it pins containment counters, not the old panic) is#[ignore]d.- No windowed engine launches (feedback_no_parallel_engine_launch): use
cargo test,BYRO_PROFILE=1on 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
CollisionShapevariant 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, ceilingMAX_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_materialis a u32 end to end (Skyrim's are CRC32 of the CK material name — a narrower column collided 8 pairs, #5542 c173147da,FORMAT_MAJORbumped); the importer'sresolve_shape_with_materialsreturns the per-leaf sequence from the SAME walk as the shape (#5550), soRigidBodyData::part_surface_materials(serde-skip, re-derived on load) is 1:1 withcollision_shape_to_parts' part order;0is authored STONE only on bsver < 83.register_newcomerspacks each part's own material into its collider user-data (collider_surface_materialreads it back);build_ragdollpacks nothing, so bone colliders read 0/STONE (#5557). A newCollisionShapevariant that flattens differently from the importer's leaf order silently misaligns materials. - Contact combine rule: both collider producers (
register_newcomers, the ragdoll bone builders) setconfig::CONTACT_COEFFICIENT_COMBINE_RULE=GeometricMeanon friction AND restitution (Havok'ssqrt(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 byevery_registered_collider_carries_the_havok_contact_combine_rule/every_ragdoll_collider_carries_the_havok_contact_combine_rule(#5552) plusrestitution_zero_body_does_not_bounce_on_restitution_floor. - The release-only Compound-child backstop is factored into
sanitize_child_poseand 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_DTprecedes the loop;frame_dt.max(0.0)guards NaN/negative. The catch-up loop stops atSUBSTEP_TIME_BUDGETand drops backlog (slow-motion, never a jump); the budget timer is the one documented non-determinism source, no wall-clock orHashMaporder 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 andactive_island_counts().1is an ACTIVE count, not a live one:live_kinematic_countis the on-demand total, #5563 — and!pending_wake) zeroes the accumulator. Every motion starter except spawn callswake():set_linear_velocity, kinematic push, ragdoll activation, buoyancy. Spawn is the deliberate exemption (#3969;wake()'s doc):register_newcomersbuilds dynamics asleep and only callsqueue_query_refresh(), and first-frame consumers taken_new > 0/had_newcomersexplicitly. Awake()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, sostepre-sleeps everyspawn_sleep_pendingnewcomer 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_newcomersrunscollision_shape_to_partsover the batch on the rayon pool before takingPhysicsWorld, 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+'sBroadPhaseBvhwithholds fixed-on-fixed pairs itself (theActiveCollisionTypesfilter 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 withqueue_query_refresh(register_newcomers,build_ragdollsince #3968), andstepinserts 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_centrepins thatstep,run_substepandrefresh_query_geometry_after_restoredo 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
Veccost on a body-heavy exterior);restore_invalid_dynamic_bodiesreverts any body that is non-finite or moved more thanMAX_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 inphys.stats/ragdoll.statuswith 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; nowBroadPhaseBvh::set_aabbper touched collider — the rapier 0.22 form needed update_incremental(…, true); the guardrestored_pose_is_visible_to_ray_queries_same_framedrives a realsteprecovery), 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 vianormalized_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_bodiestakespipeline.quarantine().bodies()and parks them, andrun_substepre-enables them after the NEXT step viaquarantine_cooldown(52de1bc0a;a_quarantined_body_is_re_enabled_and_parked). A substep-armed wake (cooldown re-enable, fresh detection) ridessubstep_wake_requestedand re-armspending_wakeAFTERstep's post-loop clear — without it, in a quiescent scene the re-enabled body's colliders stayedDisabledByParentand 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_velocitiesruns 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 atVELOCITY_SANITY_CAP_BU_PER_S(20k) / spin 100 rad/s and articulation DOF velocities atARTICULATION_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 isclamp_articulation_dofs(one pass per multibody, keyed by its root body, returning(root, clamped DOFs)so the warning names the rig throughbody_labels, #5531; guardthe_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, 3remove_multibody_articulations) — a count reset on clean substeps is the #5246 regression.explosive_detaches_totalcounts only a third-offence burst whoserigid_body_linkfinds a live articulation, so clutter and already-freed rigs park without a count (#5356);clamp_explosive_velocitiesskips handles in the (stale-tolerant) dynamic index that are no longer Dynamic, so a scriptedSetMotionType(Keyframed)teleport is not counted as an explosion (#5555 aedd31a43);articulation_jointsis swept against liveness at the top of every clamp, andremove_bodyprunesexplosion_offences/keyframe_refusals_loggedbesidebody_labels(#5355/#5272; both 26a1277bc) — a per-body map that outlives its body is the regression.push_kinematicrefuses non-finite or >KEYFRAME_TARGET_SANE_BOUND_BUtargets viaaccept_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 inphys.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#4687edge tests. - Contact-force events (Wave 4 impacts): the step runs a
ChannelEventCollectorwhose collision/tear receivers are dropped on purpose; dynamic bodies' colliders carryActiveEvents::CONTACT_FORCE_EVENTSand only thestartedcrossings drain intopending_force_events(cleared ONCE atstepentry, substeps APPEND — a per-substep clear loses all but the last, #5557), taken bytake_contact_force_events(impact_audio_system,/audit-audioDim 5).run_substeptruncates 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 aMutex(the narrow phase calls it from rayon workers;PhysicsWorldmust staySync).phys.statsprintscontact_counts()by scope —pairsis the whole narrow-phase graph,contacts/constraintsthe awake island only, andconstraintsnever counts a multibody joint — besidemultibody_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-concurrencyowns the rule). Phase order is collect/register → push kinematic → buoyancy → step → pull dynamic (buoyancy must precede the step). An entity withRapierHandlesis never registered twice. Pull-dynamic writesTransform, notGlobalTransform. AbsentPhysicsWorldearly-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) forGameKind/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;
bhkGenericConstraintand unknown malleable inners stayOther. 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+12made 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 bya_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_dampingstays 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_BUfrom the actor root) runs in both the bin activator andbuild_ragdoll's backstop; a rejected spec builds nothing and counts one refusal (guardsactivation_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
RigidBodyDatarecipe even when no follower handles exist yet (restored corpse activated before its first sync), so no duplicate kinematic bodies feed the solver.remove_ragdollreleases every body/collider/joint (body_count/awake_countsbefore/after are the observable). - Writeback drives
GlobalTransformfrombody_pose(notTransform— re-verified #4692); the Z-up→Y-up conversion happens upstream, not again here. FO4+ is blocked on the opaqueBhkSystemBinary;summarize_collision_authoringmust 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_detaileddrives XZ throughPhysicsWorld::move_characterwithfilter_groups: Some(actor_move_interaction_groups()), which equalsground_probe_groups()(masksACTOR_BONE_GROUPwholesale — 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-downcast_ray_downmeasures the support gap (the capsule shape-cast misreads convex floors from inside the contact-offset band) and the request carries exactlyclamp(-(feet - support - offset), -step_height, +offset);LOCOMOTION_NPC_SNAP_TO_GROUNDis 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 (guardgrounded_npc_does_not_sink_through_convex_floors_across_absolute_y). That correction is also why thedt = 0boot tick is an ordinary slide: rapier 0.36'smove_shapedepenetrates whenever|desired| < 1e-5and 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 usestep_along_waypoints_detailed'sblockedflag (#5452). The airborne fallbackcast_ray_downis origin-lifted byLOCOMOTION_GROUND_RAY_UP_OFFSET(256) and clamped toLOCOMOTION_MAX_DROP(256 BU) so a ledge cannot teleport the actor under the shell; itsNoneexclusion is correct only because the group mask covers the actor's ~18 bone bodies (a bone is a separate body, so a singleexclude_collidercan never cover them). Any new NPC cast without the mask self-hits.forcegreet_system(Update) andeat_sleep_system(PostUpdate) also step throughstep_toward; both write the step throughTransform(aGlobalTransformwrite is erased by propagation, #5373) withPhysicsWorldheld only for the step (#5371; both f0c683ef1). - The
LOCOMOTION_NPC_*constants are DERIVED from their sources (#4689): KCC offset readsContactConfig::DEFAULT, the step trio readsCharacterController::HUMAN, the capsule pair shares one const withinstall_fallback_actor_collider; pinned bynpc_locomotion_constants_match_their_sources(which also restates the> 2 * default_contact_skin_buinvariant). Verify the derivation wasn't reverted to literals. capsule_overlaps_solidtests the final placement (spawn ladder, doors) withsolid_probe_filter(kinematic architecture in; dynamics, sensors, actor bones out); the query pipeline must be current.- Player:
kcc_offset_buapplied 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 answersgrounded = falseon ~80/600 frames walking +X; pinned bywalking_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 themove_charactercall as an unreferenced gameplay choice (#5559);integrate_verticalterminal clamp after accumulation andMAX_FRAME_DTso a stalled frame cannot tunnel the floor; diagonal input normalised before the speed multiply;player_accepts_movement_inputhonoursPlayerControlStateandActorControlState.restrained(UI focus is enforced upstream:/audit-uiDim 6);snap_character_body_to_cameratakes&Worldby documented design andtoggle_player_modeis reachable only from console / exclusive paths. The player'sActorValuescarry evaluated PlayerOnly rows (#4674), re-evaluated every frame byrefresh_player_only_basesinplayer_derived_stats_system(#5039),CharacterLevel/Backgroundare stamped (#4678), and the HUD bars resolve AVIF editor ids throughPlayerVitalsreading the player only (#4675). - Third-person player body (
byroredux/src/player_body.rs, a070baaad): the body is visual-only.attach_player_bodystripsCollisionShape/RigidBodyDatafrom its imported nodes before the first physics sync (guardattach_reparents_offsets_retargets_and_strips_collision), because player casts exclude one body handle, not a group. TheTHIRD_PERSON_BOOM_BUcamera offset incharacter.rsis unswept (no camera-collision probe).player_camera_boomis its single source:camera_follow_systemplaces the camera with it andinteraction::camera_rayadds 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_fractionclamps 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'sWaterLiftandPhysicsWorld::refresh_water_liftrecomputes 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 at0.9/PHYSICS_DTfor stability — #5618; the lift spring itself loses stability below ~0.08 BU). A sleeping float re-wakes against the depth it SETTLED at (latched inWaterContactScratch, 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 > 0escape (a body streaming in already submerged, spawned asleep) still exists. - Two target sources (#3492): the
RapierHandles+RigidBodyData{Dynamic}scan and theRagdollscan (RagdollBuoyancycaches first collider + effective damping atbuild_ragdollbecause activation strips the bones' rows). Both feed the same targets/writes pipeline andclear_stale_water_contactshas the matchingRagdollarm, else a corpse leaving water keeps a latchedWaterContact. - 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. WaterContactemits 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'splayer_water_stateincharacter.rs. Every water input added to one (plane flow, placedWaterCurrentVolumemarker, wave height, damage) must exist in the other (every CPU wave sampler takesWaterKind::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 asWATER_WAVE_*/WaterMaterial::wave_scrolls, whichwater.vert, the GPU upload and the CPU crest sampler all read — #5582 7cf2bd086; authored hazard damage is applied ONLY by Latewater_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 entersPlayerWaterState(itsflowand the publishedWaterContactcarry the plane column's flow only, matching the dynamic path'sflow: s.flow);player_current_driftadds it in both the swim and wade arms (#4691 fixed plane-winsor_else; #5357, dd3a506e9, fixed the swim arm scaling the folded sum by the fraction). Surface height for mesh water without a phantom isWaterVolume::surface_y_atover itsWaterSurfaceMeshtriangles (17c01a4e5; a stream can descend ~1800 BU); buoyancy,player_water_stateandsubmersion_systemmust all sample it, never the flatmax.y. The player sampler tolerates a missingWaterCurrentVolumestorage (#4791). WaterContactcarries an optionalwaterline(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 itNoneand 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::Lateexclusive,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::Lateexclusives: submersion →water_damage_system→ death reconcile →make_water_interaction_system→ water audio; every drowning death also raises the KILL story event, #5434):submersion_systemreads the camera pose aftercamera_follow_system(guardscheduler_access_tests.rs::submersion_runs_after_camera_follow_and_before_water_audio); damage and splash/ripple markers derive fromWaterContactedges, keepingcrates/physicsfree 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
XCLWdecode (/audit-esm), render half (/audit-rendererDim 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 passSome(player_body).cast_ray_corridor(#5160: a ball shape-cast on the same query surface ascast_ray, sensors excluded, used by the melee swing and thecombat.approachLOS check incommands/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-gameplayDim 4). Guardcorridor_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 likecast_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; guardsurface_material_under_skips_the_walkers_own_capsule). Its one live caller isfootstep_system(/audit-audioDim 5).phys.stats(commands/physics.rs) labels every counter by scope: recovery events/bodies/parked, contactpairs/contacts/constraints,multibody_joint_count, and active-beside-live kinematic counts (#5563/#5564).- The census (
dump_spawn_collider_census,phys.stats/phys.censusviabyro-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_xzallocates per call: check caller frequency (a per-frame call on a radius-12 exterior is a hot-path allocation;/audit-performanceowns 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.stepmaintains 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_centrepins 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).