Instal·la
npx skillscat add matiaszanolli/byroredux/claude-commands-audit-ecs Instal·la des del registre de SkillsCat.
ECS Audit
Read _audit-common.md and _audit-severity.md for shared protocol.
Scope: crates/core/src/ecs/ (one file per concern: storage.rs traits, packed.rs / sparse_set.rs
backends, world.rs, query.rs, resource.rs, scheduler.rs, access.rs, lock_tracker.rs,systems.rs transform propagation, hierarchy.rs), its components/ + resources/ trees, thecrates/core/src/animation/ runtime (Dim 9), and scheduler registration in byroredux/src/boot/schedule/.
Owner map: _audit-owners.md. Gameplay-system logic (combat, inventory, AI-package behaviors,
containers/loot, notifications) belongs to /audit-gameplay; this audit keeps only ECS shape — storage
class, lifecycle, lock/access declaration.
Dimensions run in order of blast radius. Per-dimension Paths: / First step: let you skip a
dimension whose Paths have no commits since the last report
(git log --since=<last-report-date> --format='%h %s' -- <Paths>).
Dimensions
1. Lock Ordering & Deadlock Machinery (HIGHEST blast radius)
Paths: crates/core/src/ecs/{world,lock_tracker,query,resource}.rs
First step: cargo test -p byroredux-core lock_tracker then BYRO_LOCK_ORDER_CHECK=1 cargo test -p byroredux-core
AND BYRO_LOCK_ORDER_CHECK=1 cargo test -p byroredux-scripting AND BYRO_LOCK_ORDER_CHECK=1 cargo test -p byroredux --bin byroredux
(rustc ≥ 1.96 toolchain — wasmtime 49's MSRV since 15a6b1d2d) — or read the last main run's ABBA lock-order detector
job conclusion via gh run view <id> --json jobs. Never skip this step on a delta-scoped run, even when
this dimension's Paths are unchanged: the process-wide graph is built by every caller, and the cycles live in
the binary's graph, not core's — core stayed green through the whole 2026-09-22..28 red streak (#4985, #4982).
A &mut World function still records edges the detector cannot tell apart from &World ones, so hoisting
several read guards into one block for speed (#4616) reddens the lane. The -p byroredux lane went red again
on three production inversions — a gather taking GlobalTransform before Transform (every mover takesTransform first), a hair head-mount holding MeshHandle + Children across a Parent walk, a seated
restore holding Furniture across a GlobalTransform read — and was re-greened by #5535 1cb12fb3d; a new
multi-storage gather is the regression shape.
A wrong lock order is HIGH. This dimension owns the machinery; system-level cycles, canonical-order
adherence and CI detector coverage are /audit-concurrency Dim 3 and Dim 5.
- Reentrancy: the thread-local check (write on a type already held, read while holding write)
panics in debug AND release. The global ABBA graph is debug-only and opt-in viaBYRO_LOCK_ORDER_CHECK=1(#313). Everyquery/query_mut/resource/resource_mutsite inworld.rsmust arm aTrackedRead/TrackedWritescope, defuse it only AFTER the real lock is
taken, and untrack inDrop. - TypeId-sorted pairs:
query_2_mut,query_2_mut_mut,resource_2_mut,try_resource_2_mut
acquire inTypeIdorder AND arm tracker scopes in the same order (arming in parameter order
re-opens #313 — a caller spelling<B, A>then looks like ABBA). Eachassert_ne!sA == B; a
silent self-deadlock is the regression.try_resource_2_mutchecks BOTH existences before taking
EITHER lock (#465). - Tracker internals (each pinned by tests in
lock_tracker.rs— read them, do not re-derive):record_and_checkruns BEFORE the incomingLockStaterow is inserted, on the fresh-acquire AND
recursive-read paths (#2384/#3696;is_clean()true after a caught ABBA panic);GRAPHlock
poison is recovered withunwrap_or_else(|poison| poison.into_inner()), never.expect(#2385 —
an.expectblinds the detector after its first correct catch); a recursive read is a deduplicatedlog::warn!, not a reject (#2386/#3249:recursive_read_warns_once_and_continues); theheld_others
snapshot is gated oncfg(debug_assertions)ANDglobal_order::is_enabled()(#823/#3680) so a
detector-off debug build allocates nothing. - Poison: every acquisition resolves
PoisonErrorthroughstorage_lock_poisoned/storage_lock_poisoned_erased/resource_lock_poisoned(world.rs) — re-panic loud with the type
name.despawnuses the erased variant fed by thetype_namesside-table (#466); dropping either
loses the type name in every panic.
2. Storage Correctness & Change Tracking
Paths: crates/core/src/ecs/{packed,sparse_set,storage}.rs, crates/core/src/ecs/components/{transform,global_transform,hierarchy,scene_flags,world_bound}.rs
First step: grep -rn 'type Storage = PackedStorage' crates byroredux --include='*.rs' | grep -v test
- PackedStorage census: production users are exactly
Transform,GlobalTransform,WorldBound,SceneFlags. Any otherPackedStoragecomponent is a finding unless it is read every frame by
renderer/physics/animation. Everything sparse (actors, markers, events) isSparseSetStorage. - SparseSetStorage: swap-remove repoints the sparse slot of the entity moved into the gap;
removing the last element takes the no-swap path; re-insert overwrites in place (swap_remove,remove_last,overwrite). - PackedStorage:
binary_searchkeeps the sorted-by-entity invariant on insert/remove;insert_bulkis append + one sort and the result stays sorted AND deduplicated (#467);World::insert_batchstill fires the per-itementity < next_entitydebug_assert. - Change tracking (
Component::TRACK_CHANGES, defaultfalse): ON for NINE
components —Transform,GlobalTransform,Parent,Children, plusLocalBound
(since ad012f9d6),MaterialandParticleEmitter(both since 1d56758ba /
#3836), andCollisionShape(crates/core/src/ecs/components/collision.rs) +RapierHandles
(crates/physics), both since 88c23887b. The sparse ones get only astructural_generationbump and no dirty set — #3836'sSceneEffectSoftCache,
the incremental world-bound propagation, andphysics_sync_system's unregistered-shape absence
cache (sync.rs) consume them.PackedStoragekeeps a dirty set (may hold duplicates —
consumers tolerate that);SparseSetStorage::structural_generationbumps on insert/remove.drain_dirty_intopreserves capacity (#1371);take_dirtyhands it away. TheGlobalTransform
dirty set has ONE destructive drainer (make_world_bound_propagation_system,byroredux/src/systems/bounds.rs) — a secondtake_dirty/drain_dirty_intoconsumer steals its
work; other systems must useget_mutonly where marking dirty is intended (billboards call this
out). A mutation path that forgetsmark_dirtysilently breaks Dim 6's fast path. - Erased removal (per cell unload):
clear_erasedreleases capacity;remove_entities_erased
early-outs on an empty victim set and, inPackedStorage, compacts in place with zero allocations
(remove_entities_erased_does_not_reallocate, #3689) while keeping sort order + dirty marks (#2396).
A regression is a per-streaming-cycle cost. EntityIdis monotonic and never recycled (World::spawnchecked_addpanic #36;despawn
reclaims nothing, #372/#3375). Do not describe it as generational or "fix" it by reuse (danglingParentrefs go silent).
3. Query Borrow Safety & the ECS unsafe
Paths: crates/core/src/ecs/query.rs, .github/workflows/ci.yml (job ecs-query-miri)
First step: grep -n 'unsafe' crates/core/src/ecs/query.rs (expect exactly 4 derefs)
- The only
unsafeinecs/is the four cached-pointer derefs inquery.rs(QueryRead::storage,QueryWrite::storage/storage_mut,ComponentRef::Deref; #1367). Each needs a SAFETY comment
tying the pointer to the live guard field; the#[allow(dead_code)] guardmust not drop early;&mut *self.storagestays gated by&mut self. A new unsafe block without a comment is MEDIUM. World::getreturnsComponentRef(owns its guard), never a raw pointer to dropped storage (the
unsound #35 pattern = CRITICAL UAF if it returns).- Guard: CI job
ecs-query-mirirunscargo miri test -p byroredux-core --lib ecs::world::tests
skipping onlyresource_visible_to_system_via_scheduler(crossbeam-epoch TLS vs Miri). Confirm the
skip list has not widened and that any new cached-pointer site has a test underecs::world::tests. query/query_mutreturnNonefor never-created storage (no lazy creation);register::<T>()
guarantees success before first insert.HierarchyTraversalGuard(crates/core/src/ecs/hierarchy.rs) bounds every parent/children walk toentities + child refs + 1steps; transform propagation and bounds use it. A new hierarchy walk
without it, or without a visited set, is an unbounded-loop hazard on a cyclicParentgraph.
The shared guarded collectorcollect_subtree(same file; visited set +HierarchyTraversalGuardbudget,collect_subtree_terminates_on_a_cycle) landed with #55139da80470d:retry_cinematic_readoption,loading_screen.rs,cinematic_retained_entities/ the unload purge (cell_loader/unload.rs),release_finished_tethersandsubtree_entities_underall route through it. Nothing mechanical forces a new
whole-subtree walk to use it — grep for hand-rolledChildrenstacks.
4. Resources & World-Level State
Paths: crates/core/src/ecs/{resource.rs,resources/,game_profiles.rs,debug_load.rs,metrics.rs}, world.rs resource half
First step: git log --since=<last-report-date> --format='%h %s' -- crates/core/src/ecs/resources
resource()/resource_mut()panic with the type name when never inserted;try_*returnNone.ResourceRead/ResourceWritedowncast perDeref(not cached — not the #1367 hot path);insert_resourcereturns the prior value;remove_resourceresolves poison.- Resource vs. component choice: mode flags and singleton state are
Resources (HardcoreMode,SelectedRef, the*Bridge/*Telemetrystructs); per-actor state is aSparseSetStorage
component (ActorVitals,TimedRestorations). A new per-entity fact modelled as a global map keyed
byEntityIdis the drift (it defeatsdespawncleanup and save capture). Every newResource/Componentis also subject to the save registry gate
(every_component_or_resource_impl_is_saved_or_explicitly_allowlisted,byroredux/src/save_io/,/audit-save) and the debug-server registry. SkinSlotPool(resources/skin_slot_pool.rs): every collection isFxHashMap/FxHashSet
(_audit-common.mdhot-path hashing rule);pose_dirtyanddrain_pendingare the renderer
hand-off. Overflow/retry semantics are/audit-safetyDim 7.OwnershipTracker/ReclaimPolicy(resources/ownership.rs): the EX-08 soak-gate accounting.
Check each owner class's policy is honest —Monotoniconly for identity watermarks (entity ids,
mesh/texture slot-vector lengths),Exactfor anything that must return to baseline after unload,Boundedfor documented caches. A leaking class reclassifiedBounded/Monotonicto turn the gate
green is the finding.DeltaTimeis stamped once per frame by the main loop (byroredux/src/app_events.rs); a system that
writes it orTotalTimeis a finding.
5. System & Scheduler Wiring, Declared Access
Paths: crates/core/src/ecs/{scheduler,access,system}.rs, byroredux/src/boot/schedule/, byroredux/src/boot/registries.rs, byroredux/src/scheduler_access_tests.rs
First step: cargo test -p byroredux -- system_access_declaration_tests scheduler_access then rg -n '^\s*scheduler\.add_to\(' byroredux/src/boot/schedule/ (must be empty)
Stages: Early → Update → PostUpdate → Physics → Late (Stage, discriminants 0..=4, ordered byBTreeMap); "exclusive" is a phase inside every stage (StageData.exclusive, serial after that
stage's parallel batch), not a stage. Registered per stage in register_{early,update,post_update,physics,late}_systems.
- The
Accessmodel:Access::new().reads::<T>().writes::<U>().reads_resource::<R>()…;
declared / declared-empty / undeclared (None).AccessConflicthas exactlyNone,Unknown { left_undeclared, right_undeclared },Conflict { pairs }(noParallelvariant);analyze_pairreturnsUnknownwhen either side is undeclared. Closures/bare fns cannot overrideSystem::access, so they declare at registration viaadd_to_with_access/add_exclusive_with_access. Pins:undeclared_closure_pairs_show_as_unknown,exclusive_systems_are_listed_but_not_paired,all_five_stages_run_in_order(reordering or
inserting a stage without updating it is the regression). - Guard (mechanical declaration completeness):
byroredux/src/boot/schedule/mod.rs::system_access_declaration_tests—every_parallel_system_declares_everything_it_acquiresscans each parallel system's body (same-file
callees to depth 3, plus the explicit cross-file hops inPARALLEL_SYSTEMS) and fails if an acquired
type is missing from itsAccess;the_parallel_system_table_covers_every_parallel_registration
fails when anadd_to_with_accesslands outside the table; four exclusive fns are covered too
(npc_combat_ai_system,papyrus_provider_system,legacy_obscript_load_order_system,npc_dialogue_selection_system— #5307, plus itsplay_line_voice/raise_hello_story_event
cross-file hops since #54149d8fd4cc9and the cross-crateevaluate_functioncondition-evaluator hop since
#5649197a9e245, which added 18 acquisitions to its row),footstep_system'sPhysicsWorld/RapierHandlesreads (footstep_declares_its_physics_reads, #5556ac710964e), andcross_file_hops_and_get_forms_reach_their_acquisitionspins that the table's cross-file hops and thegetforms actually surface types (#4994).
Sibling gates inscheduler_access_tests.rs:scheduler_access_invariants_hold_on_the_real_schedule
(non-vacuous floors: ≥9 parallel systems, ≥7 analysed pairs, then 0 undeclared / 0 conflicts / 0
unknown),contract_bearing_exclusives_declare_their_access,p2_gameplay_exclusives_declare_non_empty_access,late_telemetry_declarations_read_all_their_resources. Boot enforces the same three counts as
RELEASEassert_eq!s ininstall_runtime_registries(#1394/#1602/#2690) — notdebug_assert!, not a
log line. Confirm the guards are live:rg -n '#\[ignore' byroredux/src/scheduler_access_tests.rs byroredux/src/boot/schedule/mod.rsreturns nothing, andPARALLEL_SYSTEMS.len()equals theadd_to_with_access(count.
What the guard cannot see (audit these by hand for every parallel system and any exclusive being
promoted to parallel): it matches only single-type turbofishquery/query_mut/resource/resource_mut(+try_),.get/.get_mut/.has(#4994) and the genericremove_component/insert_componenthelpers (#4821) — NOTquery_2_mut::<A, B>,resource_2_mut,
or types inferred without a turbofish (world.get(e)into a typed binding); it does not follow hops into
a different file unless listed in the table; closures and macro bodies are opaque.
(#4573 closed the mode/substring/comment blind spots: read-vs-write IS compared, names match
whole declared types, and//lines in the registration block no longer satisfy the scan. The
forms gap above remains.) A same-session precedent: an
undeclared same-frameGlobalTransformwrite added tofly_camera_systemmade the bootknown_conflict_count() == 0proof unsound until the registration declared it (commit ac1d44f5c). - Exclusive declarations are optional: most
add_exclusiveregistrations are undeclared by design
(undeclared_exclusive_count()non-zero); the analyzer never pairs exclusives. Flag only a parallel
system that lost its declaration, or an exclusive that a test above says must declare. Enumerate live
counts withrg -con the fouradd_*forms; never quote a total. - Panic policy is fail-fast (#1412): do not report a missing
catch_unwind.Scheduler::runtakes&mut selfandScheduleris deliberately not aResource(re-entry impossible, #868). The one other
entry point isScheduler::run_exclusive_named(#5141,00115f7a5): the engine's paused frame path runs
exactlydebug_drain_systemthrough it (its name is a shared constant asserted against the live schedule, #52928a9989070) while the pause menu gatesrun(pinned byrun_exclusive_named_runs_only_the_named_system). It bypasses stage order and per-system timing, so any
new caller must name a system with no same-frame producer/consumer dependency.add_*warns on duplicate names;try_add_*returnsErr(name)across the flat name space (#312). - Cross-stage sequencing (a consumer in an earlier stage than its producer) is invisible to the
analyzer — see/audit-concurrencyDim 4 for the pinned cases and the by-hand rule.
6. Hot-Path Performance Invariants (regression guards)
Paths: crates/core/src/ecs/systems.rs, byroredux/src/systems/{animation,audio,bounds}.rs, byroredux/src/components.rs
First step: cargo test -p byroredux-core ecs::systems
- Transform propagation fast path (#825/#1371,
make_transform_propagation_system): the cachedrootsset is keyed on(Transform::len(), Parent len-or-0, next_entity_id())plus theParent/Childrenstructural_generation()values and the drainedTransformdirty set; the BFS is skipped
when nothing changed. Usesdrain_dirty_into, nottake_dirty. A path that stops bumpingstructural_generation/mark_dirtyyields a wrongGlobalTransform— a correctness bug, escalate. - PostUpdate/Late ordering contract (comments in
boot/schedule/post_update.rs): transform
propagation, then bound propagation (drains theGlobalTransformdirty set), and noStage::Late
system may writeGlobalTransformon aLocalBound-bearing entity (itsWorldBoundlags a frame;
billboards are the one accepted exception). A new LateGlobalTransformwriter must make that call.
Same rule forTransformwriters feeding propagation:player_body_facing_systemmoved Late → Update
(#4995,player_body_facing_runs_in_update_before_propagation). A mover writesTransform, neverGlobalTransformalone, on a propagation root — the next propagation rebuilds the global from the unmoved
local and erases the step (#5373f0c683ef1: the force-greet and eat/sleep walk arms turned in place
forever; pinned per system byforcegreet_walk_survives_transform_propagation/eat_walk_survives_transform_propagation). Nothing mechanical guards a new mover — check it by hand. - Animation scratch (
byroredux/src/systems/animation.rs): theNameIndex.maprefill is in place
(clear+ reserve + reinsert; a fresh map costs a ~3 ms stream-in spike, #824);SubtreeCacheclears
only when theNamecount changes (#278);events/seen_labelsscratch is hoisted andcloned notmem::taken (#828). Lock order inside channel apply is content-determined
(#2399) — do not reintroduce a macro that hides the acquisition order. FootstepScratch(byroredux/src/components.rs):mem::take+ restore soVeccapacity
survives; a per-frameVec::newregresses (#932). Placement (Late exclusive aftercamera_follow_system) is pinned byfootstep_runs_after_camera_follow_in_late.
7. Component Lifecycles (load/unload, transient, idempotency)
Paths: byroredux/src/{streaming,npc_spawn}*, byroredux/src/cell_loader/{unload,load,exterior}.rs, byroredux/src/systems/cinematic.rs, crates/core/src/ecs/components/, crates/scripting/src/{events,timer,cleanup}.rs, crates/core/src/animation/registry.rs
First step: cargo test -p byroredux --bin byroredux -- rapier_release persistent_cell_stamp_tests detach_tests cinematic_retention_tests (rustc ≥ 1.96) then read git log --since=<last-report-date> -- crates/core/src/ecs/components
- Cell load/unload symmetry (
streaming/,cell_loader/unload.rs): every component/resource row a
cell load attaches is removed on unload (no orphanCharacterController,LightFlicker,RapierHandles, animation players,SeatReservationsclaims whose furniture or claimant is gone).
Spawn dispatch is idempotent — one REFR FormId never spawns twice. CellRootstamp discipline (stamp_cell_root_range,cell_loader/load.rs): every resumable applier
stamps only the[first, last)range captured inside its ownadvance; a cursor that spans frames claims
whatever spawned in between. #54835a68e983e: the persistent-CELL job seeded once and gave the camera,
player rig and foreground cell to the persistent root — it now reseeds per advance (begin_advance,entities_spawned_between_advances_are_not_claimed). Defence in depth:unload_cell_innerrefuses any
victim inplayer_owned_entities(PlayerEntity,ActiveCamera, thePlayerBodyRootEntitysubtree),
stripping the strayCellRootwith a warn — that warn is always a producer bug.- Cinematic re-adoption (
retry_cinematic_readoption): only parentless released-convoy members queue onCinematicReAdoption; a root's adoption stamps its wholeChildrensubtree with the sameCellRoot
(#5384faf8e5682,parented_subtree_rides_the_roots_adoption), and the player subtree is never queued or
adopted (#5379203be9ed4,player_rider_is_never_queued_or_cell_adopted). The session-replace purge
(purge_cinematic_retention_state(world, ctx)) collects each pending root'sChildrenclosure (minus the
player rig) and releases it throughrelease_entities, so no subtree orphans and GPU/Rapier handles go with
it (#549099cd9fd6c). - Detach pass:
detach_victims_from_surviving_parentsreads every victim'sParentunder one guard
(dropped before theChildrenwrite) and tests membership by binary search over a sorted slice (#54189bbe9304e, source-pinned bydetach_victims_takes_one_parent_guard_for_the_whole_sweep); a per-victimworld.get::<Parent>is the regression. - Behavior components stay sparse: every AI-procedure
*Behavior/*State/ terminal marker isSparseSetStorage. The roster is pinned in the debug registry byroster_tests::every_ai_procedure_behavior_component_is_registered
(crates/debug-server/src/registration.rs, #4063); teardown completeness
(clear_ambient_behavior,npc_spawn/ai_package.rs) and behavior semantics are/audit-gameplay. - Transient markers:
ActivateEvent/HitEvent/TimerExpiredare removed byevent_cleanup_system(Late exclusive, registered after every consumer — pinned bytransient_cleanup_is_the_last_late_exclusive; a second cleanup site would double-free). Late readers
now includeequipment_appearance_systemandnpc_dialogue_selection_system.timer_tick_systemnever accumulates negative time. AnimationClipRegistry(animation/registry.rs): interns by ASCII-lowercased path (#790) so
streaming does not grow it;release()clears a slot but never returns it — no free list, by design
(#2689), because a released handle may still sit on anAnimationPlayer/AnimationLayer. Every
evict/reload strands one empty stub (stub_slot_count(), visible not closed). Do not propose a
free list without addressing that aliasing hazard.- No animation controller layer:
AnimationStackis the whole sequencing surface; audit its
lifecycle (no dangling clip refs after unload) rather than looking for a controller above it. - Retained
EntityIdfields (never recycled, so a stale id is a failed lookup, not aliasing): new
per-actor carriersNpcSkeletonBones(bone-name → entity map, retained on living actors and mirrored onto
the process-lifetime player capsule for mid-life gear import),AmbientEngagement.target,PlayerBodyRootEntity. Check every consumer tolerates a despawned target, and that a cell unload which
despawns a skeleton also drops or invalidates the map holding it. - Emitters:
apply_emitter_params(byroredux/src/systems/particle.rs) fillsParticleEmitter
fromImportedEmitterParams; size isinitial_radius × base_scale.unwrap_or(1.0)and colour is not
clobbered (apply_emitter_params_size_defaults_base_scale_to_one,apply_emitter_params_overrides_kinematics_and_size_not_color). Mapping correctness is/audit-nifal. - Physics/audio handle lifetimes: Rapier release on unload is
/audit-safetyDim 3; kira
spatial sub-track handles drop before the listener;OneShotSoundmarkers are pruned once kira
reportsStopped(/audit-audio).
8. NIFAL Canonical Material in the Component Layer
Paths: crates/core/src/ecs/components/material.rs, byroredux/src/material_translate.rs
First step: cargo test -p byroredux-core resolve_pbr
Material is the landing zone of the NIFAL boundary (upstream is /audit-nifal).
- Plain-
f32contract:metalness/roughnessare resolvedf32, notOption<f32>(a regression
re-introduces per-draw classification — HIGH). - Single mutation site:
material_translate.rs::translate_materialis the soleImportedMesh → Materialboundary;Material::resolve_pbris the only fill-the-gap helper (sharedclassify_pbr_keyword, fills only the unset slot). No per-drawclassify_pbrfallback survives inbyroredux/src/render/static_meshes.rs. - Pinned by
resolve_pbr_is_idempotent,resolve_pbr_preserves_upstream_translator_values,resolve_pbr_fills_only_missing_slot,resolve_pbr_clamps_authored_out_of_range; clobbering authored
scalars or breaking idempotency is a finding. - Other producers (Starfield CDB via
crates/sfmaterial/) go throughtranslate_material/resolve_pbr;crates/debug-ui/must not register or mutate gameplay components.
9. Animation Runtime (crates/core/src/animation/)
Paths: crates/core/src/animation/, byroredux/src/systems/animation.rs, byroredux/src/anim_convert.rs
First step: cargo test -p byroredux-core --features inspect animation
Import is /audit-nif, the NIF→clip boundary is /audit-nifal Dim 7; this dimension owns sampling,
blending, root-motion split and text-key dispatch, driven by an ECS system.
- Clip-handle validity:
AnimationPlayer.clip_handle/AnimationLayerindex the registry; a stale
handle after unload is a no-op, never a panic or OOB read. - Time advance:
advance_time(player.rs) /advance_stack(stack.rs) handledt == 0,
negative/NaNdtand a zero-length clip without divide-by-zero or an endless loop;CycleTypeis
applied per clip. - Blend weights:
AnimationLayer::effective_weight,play,cleanup_finished, with the live ramp
written byadvance_stack(weight = blend_in_target * progressevery tick, #3701). Blend timers
tick ahead of theplayinggate so a paused blend-out layer still retires (#3702,advance_stack_ticks_blend_out_on_a_paused_layer_so_it_can_be_retired). Check thatcleanup_finishednever removes a layer still contributing weight and thatplayper tick with a
nonzero blend time cannot grow the stack unboundedly. sample_blended_transformis the per-bone-per-frame hot path: no allocation, single-layer
short-circuit bit-identical to the general path (#3706,single_layer_short_circuit_matches_two_pass_output). Cost belongs to/audit-performance.- Root motion:
split_root_motionapplies once per tick and is drained — an undrainedRootMotionDeltaintegrates every frame. - Text keys:
visit_stack_text_events(stack.rs) emits each key exactly once across a loop
wrap and still emits when one largedtspans several key times. - Interpolation (
interpolation.rs):find_key_pairat t < first / t > last key; quaternion
shortest-path (missing dot-sign flip = bone spinning the long way). B-splines occur on FNV/FO3 too.
Process
- Scope: run each dimension's
First step:; skim dimensions whose Paths are unchanged. cargo test -p byroredux-core --features inspectandcargo test -p byroredux(counts live in
ROADMAP.md; do not pin a number). Every-p byroreduxinvocation here needs the rustc ≥ 1.96
toolchain recipe in CLAUDE.md (#4466); the default toolchain gives no feedback.- Save the report to
docs/audits/AUDIT_ECS_<TODAY>.md.