description: "Deep audit of the ECS — storage backends, queries, world, systems, resources"
ECS Audit
Read _audit-common.md and _audit-severity.md for shared protocol.
The ECS core is crates/core/src/ecs/. Since Session 34's split the module
is one-file-per-concern: storage.rs holds only the Component /
ComponentStorage / DynStorage traits + EntityId; the two backends live in
packed.rs (PackedStorage) and sparse_set.rs (SparseSetStorage).
world.rs owns the RwLock-per-storage World; query.rs the guard-owning
query wrappers; resource.rs the resource guards; scheduler.rs the stage
scheduler; access.rs the declared-access conflict analyzer; lock_tracker.rs
the deadlock / ABBA detector; systems.rs the transform-propagation system. Dimension 10 (added 2026-08-13)
extends this skill past ecs/ into the sibling crates/core/src/animation/
runtime, which had no owner dimension anywhere.
Dimensions are ordered by ECS blast radius: lock ordering / deadlock first, then storage correctness, query borrow safety, scheduler declarations, resource lifetimes, then the cross-cutting lifecycle and hot-path guards.
Dimensions
1. Lock Ordering & Deadlock (HIGHEST blast radius)
A wrong lock order is a HIGH (per _audit-severity: "ECS deadlock potential").
- Same-thread reentrancy:
lock_tracker(lock_tracker.rs) panics with a clear message when a thread takeswriteon a type it already holds (read or write), orreadwhile holdingwrite. The thread-local check runs in BOTH debug and release; the global lock-order graph (ABBA, #313) is debug-only AND opt-in viaBYRO_LOCK_ORDER_CHECK=1. Verify everyquery/query_mut/resource/resource_mutsite inworld.rsarms aTrackedRead/TrackedWritescope, defuses it only AFTER the real lock is acquired, and that the wrapper'sDropuntracks. - TypeId-sorted multi-lock acquisition:
query_2_mut/query_2_mut_mut(world.rs) andresource_2_mut/try_resource_2_mut(world.rs) acquire inid_a < id_border — and set up the tracker scopes in the same order (the #313 fix: pre-fix the scopes were armed in generic-parameter order, which looked like ABBA to the graph when the caller spelled<B, A>). A regression that arms scopes in parameter order instead of TypeId order re-opens #313. - Same-type double-lock panics, never deadlocks:
query_2_mut/query_2_mut_mut/resource_2_mutassert_ne!onA == Bwith a clear message. A silent self-deadlock is the regression. - ABBA across rayon workers: the global graph generalizes the pair guarantee
to any N-lock hold pattern across the parallel scheduler. Two single-type
queries acquired in opposite orders on two workers must trip the graph (when
run under
BYRO_LOCK_ORDER_CHECK=1), not deadlock. Pin: this is the only protection for ad-hoc N>2 lock holds. - Poison-on-panic resolution: every lock acquisition resolves
PoisonErrorthroughstorage_lock_poisoned::<T>()/storage_lock_poisoned_erased()/resource_lock_poisoned::<R>()(world.rs) — a post-panic access re-panics loud with the type name, never silently reads torn state.despawnuses the type-erased variant fed by thetype_namesside-table (#466). Removing a poison-resolve site is a finding.
2. Storage Correctness
- SparseSetStorage (
sparse_set.rs): swap-remove fixes the sparse pointer for the entity moved into the gap (self.sparse[moved_entity] = Some(dense_idx)); removing the last element takes the no-swap path; insert into an existing entity overwrites in place (no duplicate, len unchanged). Pinned byswap_remove,remove_last,overwritein the file's test module. - PackedStorage (
packed.rs):binary_searchmaintains the sorted-by-entity invariant on every insert/remove;insert_bulkuses the append + single-sort fast path (#467) instead of O(n) per-insert shift; a bulk insert that re-sorts must keep the set sorted AND deduplicated. - Change tracking (
Component::TRACK_CHANGES): opt-in per-entity dirty set (PackedStorage, viamark_dirtyon insert/get_mut/remove) and a monotonicstructural_gencounter (SparseSetStorage::structural_generation, bumped on insert/remove incl. reparent overwrite). The const isfalseby default so non-tracked components pay nothing (branch folds away). Enabled forTransform/GlobalTransform. Audit:drain_dirty_intoclearsoutthen drains while preservingself.dirtycapacity (#1371);take_dirtyhands capacity away (0-cap regrow). The dirty set MAY contain duplicates — consumers must tolerate that. A storage that forgets tomark_dirtyon a mutation path silently breaks transform propagation's fast path (dim 8). insert_bulkdebug guard:World::insert_batch(world.rs) wraps the iterator so theentity < next_entitydebug_assertstill fires per item — a bulk path that skips it lets unspawned IDs in.
3. Query Borrow Safety
- Guard-owning wrappers:
QueryRead/QueryWrite/ComponentRef(query.rs) hold theRwLock*Guardfor the wrapper's lifetime and cache a raw pointer downcast ONCE innew()(#1367 hot-path fix). The SAFETY argument: the cached*const/*mut T::Storagepoints into the box the guard keeps locked + pinned; no writer can move it while the lock is held. Re-verify eachunsafe { &*self.storage }/&mut *self.storagestill has the guard field alive (the#[allow(dead_code)] guardmust not be dropped early). ComponentRefis the sound replacement for the unsound #35 pattern — it retains the guard rather than returning a raw pointer to dropped storage. A regression that drops the guard and hands back a pointer is CRITICAL (UAF).- Deref soundness:
QueryWrite'sDeref/DerefMutroute throughstorage()/storage_mut();DerefMutrequires&mut self, so the borrow checker forbids a live&and&mutinto the same storage simultaneously. query/query_mutreturnNonefor never-created storage (no lazy empty-storage creation on the read path).register::<T>()is the way to guarantee a query succeeds before first insert.
4. Resource Lifetimes
resource()/resource_mut()panic with the type name when the resource was never inserted;try_resource()/try_resource_mut()returnNone.ResourceRead/ResourceWrite(resource.rs) downcast through the guard on eachDeref(NOT cached — these are not the #1367 hot path); verify the downcastexpectcan't fire (TypeId keys the map).- Resources are usable from systems via
&selfinterior mutability. insert_resourcereturns the prior value (downcast back out of the old lock);remove_resourceresolves poison viaresource_lock_poisoned.try_resource_2_mutdoes BOTH existence checks before acquiring EITHER lock (#465) — a regression that checks-then-locks-then-checks reintroduces a partial-acquire deadlock window.
5. System & Scheduler Wiring
- Blanket
Systemimpl forFn(&World, f32)(system.rs); closures and bare fns can't overrideSystem::access, so they declare via the scheduler's registration-site override (dim 5b). - Mutations from a system are visible to later systems in the same
run()(pinned bymutation_visible_across_stages). - Empty scheduler and empty intermediate stages run without panic
(
empty_scheduler_runs_cleanly,empty_stages_skipped). system_names()returns stage-order then within-stage (parallel first, then exclusive); duplicate names warn onadd_*buttry_add_*rejects withErr(name)across the flat name space (#312).- Panic policy is fail-fast by design (TS-08 / #1412): a panicking system
aborts the frame and the process; do NOT report "missing
catch_unwind" as a bug — see theScheduler::rundoc comment.runtakes&mut selfandScheduleris intentionally NOT aResource(re-entry is structurally impossible, #868).
5b. Scheduler Access Declarations (R7 / M27, closed 2026-05-23)
The stages are Early → Update → PostUpdate → Physics → Late
(Stage enum, scheduler.rs, discriminants 0..=4, iterated via
BTreeMap<Stage, _> Ord). There is no ParallelUpdate or LateExclusive
stage — "exclusive" is a phase within every stage (StageData.exclusive),
not a stage. Exclusive systems run serially after the stage's parallel batch.
Access(notSystemAccess) is the declaration type (access.rs):Access::new().reads::<T>().writes::<U>().reads_resource::<R>()…. A system's declaration isSome(Access)orNone(undeclared). Three states: declared- empty ("touches no ECS state"), declared-with-claims, or undeclared (None). The default for bothSystem::access()and the per-entry override isNone.- M27 Phase 1+2 (
a9810d40): every parallel-stage system on the engine binary declares reads/writes viaScheduler::add_to_with_accessat the registration site inbyroredux/src/boot.rs(build_scheduler; 13 such calls as of 2026-08-16 — closures can't implSystem::access). Any parallel system registered via plainadd_to(no declared access) is a regression. Count this fresh rather than quoting the number — it has drifted twice (10 → 13) between skill refreshes. - M27 Phase 3 (
05fe2bac): 4 analyzer-visible conflicts were resolved two ways — one dispatcher merge plus two exclusive re-stages.player_controller_system(Stage::Early) stays parallel and declares the union offly_camera+character_controlleraccesses because it branches onPlayerModeper frame;audio_system(Late) andspin_system(Update) were the two moved to exclusive.sys.accessesreports 0 unknown / 0 conflicts. AccessConflictlives inaccess.rs(re-exported viaecs::mod) and has EXACTLY three variants:None,Unknown { left_undeclared, right_undeclared },Conflict { pairs }. There is noParallelvariant (the #1521 wording fix).analyze_pairreturnsUnknownwhen one/both sides are undeclared. #1394 (a7e1502b) added theundeclared_parallel_count()accessor onAccessReport— the migration KPI counting parallel-stage systems still atNone— NOT a reclassification. Drivingundeclared_parallel_count() == 0drivesunknown_pair_count()to 0 because every parallel pair then has both sides declared. Pin:undeclared_closure_pairs_show_as_unknown(scheduler.rs) asserts two undeclared closures yieldunknown_pair_count() == 1.- Exclusive declarations are OPTIONAL and mostly absent (#1236/#1237,
94e78b9f):add_exclusive_with_access/try_add_exclusive_with_accessEXIST so closures/fns can declare on the exclusive phase, but the live schedule still registers most exclusives via plainadd_exclusive(e.g.event_cleanup_system,audio_system,spin_system, the DLC dispatchers), soundeclared_exclusive_count()is non-zero by design. The analyzer (access_report) only pairs parallel-stage systems — exclusives are listed but never paired (exclusive_systems_are_listed_but_not_paired). Do NOT report undeclared exclusives as a conflict; flag only a regression where a parallel system loses its declaration. - #1238 stage-order chain (
54ea11c0):all_five_stages_run_in_order(scheduler.rs) registers out of order and asserts theBTreeMapOrdrunsEarly..=Lateexactly once. Reordering / merging / inserting a stage without updating this test is the regression pattern. (Correct chain:Early → Update → PostUpdate → Physics → Late.) - Regression guard:
byroredux/src/boot.rs(install_runtime_registries) runsdebug_assert_eq!(scheduler.access_report().undeclared_parallel_count(), 0)after building the schedule (#1394) — this is the boot guard, NOT a log line. #1602 added two sibling asserts on the same snapshot:known_conflict_count()andunknown_pair_count()must also be 0 (the old undeclared-only guard let a declared WriteWrite conflict through — #1601). Operators inspect contention at runtime via thesys.accessesconsole command (reads theSchedulerAccessReportresource). A non-zeroundeclared_parallel_count/known_conflict_countis an audit finding.
6. Unsafe Code Review
- The only
unsafein the ECS core is the four cached-pointer derefs inquery.rs(QueryRead::storage,QueryWrite::storage/storage_mut,ComponentRef::Deref) — all #1367. Each MUST have a SAFETY comment tying validity to the live guard. Verify no new unsafe block lacks one (MEDIUM min per_audit-severity). World::spawnuseschecked_addand panics onEntityIdoverflow (#36);despawndoes NOT reclaim IDs (no generational tagging — #372) — document, do not "fix" by reusing IDs (silent corruption on danglingParentrefs).
7. Component Lifecycles (load/unload, transient, idempotency)
- M40 streaming (
byroredux/src/streaming.rs): cell-load attaches components, cell-unload removes them — verify no orphaned components after a load/unload cycle. - M41 NPC spawn (
byroredux/src/npc_spawn.rs): ACHR/REFR → entity dispatch is idempotent (same REFR FormId never spawns twice). - M42 AI-package behavior components (
byroredux/src/systems/{sandbox,wander,travel,follow,escort,guard,patrol}.rs,crates/core/src/ecs/components/{sandbox,furniture,wander,travel,follow,escort,guard,patrol}.rs): seven procedure runtimes now exist —SandboxBehavior/Seated(M42),WanderBehavior/WanderState(M42.3),TravelBehavior/TravelState/Traveled(M42.4),FollowBehavior/FollowState(M42.5),EscortBehavior/EscortState/Escorted(M42.6),GuardBehavior/GuardState(M42.7),PatrolBehavior/PatrolState(M42.8) — ALLSparseSetStorage(only actors running that procedure carry them). Verify a growing actor population doesn't force any of them ontoPackedStorage. An NPC's active package is always a single winningPackRecord(active_package'sfindincrates/plugin/src/esm/records/misc/pack.rs), so at most one Behavior component lands per actor at spawn — a regression that lets two of these seven land on the same entity is a correctness bug in thenpc_spawn.rsspawn-tail'sif runs_*chain, not a storage issue.- One-shot terminal markers:
Seated(Sandbox) andTraveled/Escorted(Travel/Escort) are one-shot gates — once tagged, the corresponding system must skip the entity on every later frame (never re-enter seat search / re-walk to an already-reached destination). - Indefinite, non-terminal state:
WanderState/PatrolState(oscillate forever) andGuardState(holds a post, walking back if the actor drifts pastradius— no terminal marker, since guarding never ends) are read and written every tick by their system, unlike the one-shot markers above. - Live vs. frozen resolution:
FollowState/EscortState(mid-collect) re-read their target'sGlobalTransformfresh every tick;TravelState/EscortState(once leading)/GuardStatefreeze a resolved-or-picked position exactly once and never re-track it, even if the resolvedNearReferenceentity later moves. A system that blurs this line (freezes a Follow target, or re-tracks a Travel destination) is a finding. - Shared logic, separate storage:
patrol_systemcallswander_system'sstep_oscillating_wander(a plain-value, component-agnostic function insystems/wander.rs) directly rather than duplicating the phase-transition state machine — verify a future edit to one path doesn't silently diverge from the other without updating bothwander_systemandpatrol_system's call sites.PatrolStatereusesWanderPhasedirectly (not a second enum).travel_system::resolve_destination(pub(crate), generic over primitive fields) is the second instance of this pattern —escort_system's lead phase calls straight into it.guard_system::resolve_anchordoes NOT: it reaches the sameNearReferenceFormID resolution through the sharedresolve_entity_by_global_form_idprimitive, because its no-target fallback is deliberately the actor's own position, NOT Travel's hash-picked point — reusing Travel's fallback here was tried and reverted because it trivially satisfies Guard's own leash check on the first tick). - Seat claims in
SeatReservationsmap each(furniture entity, marker index)to its claimant actor.prune_seat_reservations(cell_loader/references/mod.rs) runs per cell-reference load and keeps a claim only while the furniture is live and the claimant still carries aSeatedcomponent naming that furniture. Verify both liveness halves stay intact: dropping the furniture check leaks unloaded seats; dropping the claimant/Seatedcheck strands a live cross-cell seat after its actor despawns. Entity IDs are monotonic and never recycled, so do not justify cleanup with an ID-reset premise. - All seven systems are opt-in and NOT in the default scheduler — gated by
BYRO_SANDBOX_SIT/BYRO_WANDER/BYRO_TRAVEL/BYRO_FOLLOW/BYRO_ESCORT/BYRO_GUARD/BYRO_PATROLrespectively (boot.rs). A regression that registers one unconditionally (or drops its env-var check) changes default engine behavior silently.
- One-shot terminal markers:
- Scripting transient markers (
crates/scripting/src/events.rs):ActivateEvent/HitEvent/TimerExpiredare removed byevent_cleanup_system(registeredadd_exclusive(Stage::Late, …)) — verify single-frame lifetime. - Gameplay slice (P2, added 2026-08-15/16 — no owner audit, so it is in scope
here): three
add_exclusive(Stage::Update, …)registrations inbyroredux/src/boot.rs, in this order —interaction::interaction_system, thencombat::combat_input_system, thencombat::combat_damage_system. Ordering is load-bearing:interaction_systemis the canonical producer of the action edges (ActionState/InputAction) both combat systems consume, and it must stay ahead of everyOnActivateconsumer. Check:combat_damage_systememits the canonicalHitEvent(crates/scripting/src/events.rs) and relies on the Late-stageevent_cleanup_systemabove for teardown — a combat-local cleanup would double-free the marker, and a missed Late registration leaks it. Do not report the Late-stage cleanup as combat's leak.- The alive→dead transition inserts
Deadand tears down the AI-behavior component set (SandboxBehavior/WanderBehavior/TravelBehavior/FollowBehavior/EscortBehavior/GuardBehavior/PatrolBehavior+ their*State/Seated/Traveled/Escortedsiblings). A behavior component surviving death re-animates a corpse — verify the teardown list against the live seven-procedure roster above, since it must grow with it. CombatState(aResource) holds cooldown plus aCombatTraceEntrytrace used as smoke evidence bydocs/smoke-tests/p2-melee-core.sh— unbounded trace growth across a long session is a real leak.inventory.rsmust not become a second source of truth: canonical state isInventory+EquipmentSlots(crates/core), andInventoryCatalogis a rebuilt-on-plugin-install metadata cache keyed by form id. Stale catalog entries after a load-order change are the failure mode to look for.
- ScriptTimer (
crates/scripting/src/timer.rs):timer_tick_systemdecrements per-frame, firesTimerExpiredon hit — verify no negative-time accumulation. - Animation controller (
crates/core/src/animation/controller.rs): controller vsAnimationPlayerlifecycle — no dangling clip refs after unload. - AnimationClipRegistry (
crates/core/src/animation/registry.rs): #790 dedupes by lowercased path so cell streaming doesn't grow it unboundedly — losing case-folding interning leaks one keyframe set per cell load (steady RAM growth across exterior streaming). - DebugDrainSystem (
crates/debug-server/src/system.rs): registeredadd_exclusive(Stage::Late, …)(crates/debug-server/src/lib.rs) — verify no World mutation outside the drain (per-client TCP threads enqueue commands, never mutate). - AudioWorld (
crates/audio/src/lib.rs, M44):audio_systemrunsadd_exclusive(Stage::Late, …);OneShotSoundmarkers are pruned once kira reachesPlaybackState::Stopped— verify no infinite-marker leak. Spatial sub-track handle drop must precede listener handle drop (kira invariant). - Particle emitter (NIFAL typed-block path):
byroredux/src/systems/particle.rs::apply_emitter_params(registeredadd_exclusive(Stage::PostUpdate, particle_system)) populatesParticleEmitter(crates/core/src/ecs/components/particle.rs) fromImportedEmitterParams(crates/nif/src/import/types.rs, built byextract_emitter_params/extract_emitter_rateincrates/nif/src/import/walk/mod.rsfrom the typedNiPSysEmitter/…Ctlr/…CtlrData/NiPSysGrowFadeModifierblocks incrates/nif/src/blocks/particle.rs). Pin the override semantics: authored size isinitial_radius × base_scale.unwrap_or(1.0)(Oblivion has nobase_scale) and color is NOT clobbered — seeapply_emitter_params_size_defaults_base_scale_to_oneandapply_emitter_params_overrides_kinematics_and_size_not_color. Regression: zero-sizing the emitter or overwriting the preset color. See/audit-nifal. - Character / light-anim (
byroredux/src/systems/character.rs,byroredux/src/systems/light_anim.rs):character.rsowns KCC state viabyroredux_physics::CharacterController(+RapierHandles);animate_lights_systemreadsLightFlicker(crates/core/src/ecs/components/light.rs) againstLightSource. Verify no orphanedCharacterController/LightFlickerafter a cell load/unload cycle, matching thestreaming.rsorphan invariant.
8. Hot-Path Performance Invariants (regression guards)
- Lock-tracker held-set collection is
cfg(debug_assertions)-gated (#823): theheld_others: Vecbuilt beforerecord_and_checkinlock_tracker.rs(track_read) is gated as one block — release builds skip the alloc entirely. Re-enabling for release rebuilds ~100 small allocs/frame for a no-op. NameIndex.mapin-place refill (#824):animation_system(byroredux/src/systems/animation.rs, theidx.map.clear()block) refills theHashMapin place (clear+reserve+ reinsert) instead ofnew()+swap. The fresh-map pattern costs a ~3 ms cell-stream-in spike.- Transform-propagation change detection (#825 + #1371):
make_transform_propagation_system(crates/core/src/ecs/systems.rs) keys a cachedrootsset on(Transform::len(), Parent-len-or-0, next_entity_id())AND tracksParent/Childrenstructural_generation()plus the drainedTransformdirty set. The FAST PATH skips the whole BFS when the dirty set is empty and the full state is unchanged — a static cell with a moving camera touches ~1 subtree, not all entities (~250 µs/frame regression at Megaton if recomputed every frame). Usesdrain_dirty_into(&mut transform_dirty)to keep the scratch capacity across frames (#1371), NOTtake_dirty. Any path that stops bumpingstructural_gen/mark_dirtysilently breaks this fast path (escalate — wrongGlobalTransformis a correctness bug, not just perf). animation_systemscratch hoisting (#828):events/seen_labelsscratches are hoisted out of the per-entity loop and useclone(notmem::take) so capacity persists; helpersensure_subtree_cache/write_root_motion/apply_bool_channels+ thewrite_lazy!macro (5 color-target arms) were factored out by2bdbc36— DRY-undo drift there is a finding.footstep_systemscratch (#932):byroredux/src/systems/audio.rswrites aFootstepScratch: Resourceviamem::take+ restore to preserve Vec capacity; per-frameVec::newis the regression. (Registeredadd_exclusive(Stage::PostUpdate, footstep_system).)- Poison side-table (#466):
World::despawnnames the offending component via thetype_namesside-table; removing it loses the type name in panic messages (10× harder bisects).
9. NIFAL Canonical Material in the Component Layer
The NIFAL tier resolves PBR scalars once, at the single ImportedMesh → Material
boundary, so the renderer never re-classifies per draw. The ECS-owned Material
component is the landing zone for that contract. See /audit-nifal for the
upstream boundary.
- Plain-
f32contract:Material(crates/core/src/ecs/components/material.rs) carriesmetalness: f32/roughness: f32— fully resolved, NOTOption<f32>. A regression toOption/Nonere-introduces per-draw classification (HIGH). - Single mutation site:
byroredux/src/material_translate.rs::translate_materialis the SOLEImportedMesh → Materialboundary;Material::resolve_pbr(crates/core/src/ecs/components/material.rs) is the only fill-the-gap helper (runs the sharedclassify_pbr_keyword, fills only the unset slot). No per-drawclassify_pbrfallback survives inbyroredux/src/render/static_meshes.rs. resolve_pbridempotent + preserves translator values: pinned byresolve_pbr_is_idempotent,resolve_pbr_preserves_upstream_translator_values,resolve_pbr_fills_only_missing_slot,resolve_pbr_clamps_authored_out_of_rangein thematerial.rstest module. Clobbering authored scalars or breaking idempotency is a finding.- ECS-adjacent producers: Starfield CDB output (
crates/sfmaterial/) must flow throughtranslate_material/resolve_pbr;crates/debug-ui/(egui overlay) must not register or mutate gameplay components.
10. Animation Runtime (crates/core/src/animation/, added 2026-08-13)
crates/core/src/animation/ is a byroredux-core subsystem with no owner
dimension anywhere: /audit-nif owns the NIF/KF import, /audit-nifal
Dim 7 owns the NIF→AnimationClip translation boundary, and nothing owns
what happens after — sampling, layer blending, root-motion split, text-key
dispatch. It lands here because AnimationPlayer / AnimationStack are ECS
components driven by an ECS system (byroredux/src/systems/animation.rs), and
AnimationClipRegistry is a Resource.
- Clip-handle validity:
AnimationPlayer.clip_handle/AnimationLayerindex intoAnimationClipRegistry(crates/core/src/animation/registry.rs). A stale handle after a cell unload must be a no-op, never a panic or an out-of-bounds read. Verify unload clears or invalidates players alongside the registry (same lifecycle class as Dimension 7). - Time advance:
advance_time(crates/core/src/animation/player.rs) andadvance_stack(crates/core/src/animation/stack.rs) must handledt == 0, a negative/NaNdt, and a zero-length clip without dividing by zero or looping forever. VerifyCycleType(loop / clamp / reverse) is applied per clip, not globally. - Blend weights:
AnimationLayer::effective_weight+play+cleanup_finisheddefine the crossfade. Verify weights are normalized (or documented as additive), thatcleanup_finishedcannot remove a layer still contributing weight, and that an unbounded layer stack cannot grow per frame —playon every tick with a nonzero blend time is the leak shape. sample_blended_transformis the hot path (per bone, per skinned entity, per frame). Verify it allocates nothing and short-circuits the single-layer case; cross-reference/audit-performanceDim 1 for cost, report the allocation here.- Root motion:
split_root_motion(crates/core/src/animation/root_motion.rs) separates the delta applied to the entity from the residual left on the bone. Verify the split is applied exactly once per tick and drained — an undrainedRootMotionDeltaintegrates every frame (the same failure mode/audit-scriptingDim 8 checks on the cinematic path). - Text keys:
visit_stack_text_events/collect_stack_text_events(crates/core/src/animation/stack.rs) must not emit an event twice when a clip loops across the frame boundary, and must emit it at all when a single frame spans multiple key times (a largedtafter a stall). - Interpolation (
crates/core/src/animation/interpolation.rs):find_key_pairboundary behaviour at t < first key and t > last key, plus quaternion shortest-path (a missing dot-sign flip is a bone spinning the long way). B-splines reach FNV/FO3 too — do not assume Skyrim+ (feedback_bspline_not_skyrim_only).
Process
- Read each file in
crates/core/src/ecs/(paginate the >1000-line ones:world_tests.rs,scheduler.rs,resources/mod.rs). - Run
cargo test -p byroredux-coreandcargo test -p byroredux— verify the scheduler/storage/query suites are green (test counts live in ROADMAP, not here; do not pin a number). - Check each dimension top-down (lock ordering first).
- Save report to
docs/audits/AUDIT_ECS_<TODAY>.md.
TDD Red-Green-Refactor
Testing
Skill qui guide Claude a travers le cycle TDD complet.
Audit d'Accessibilité Web
Testing
Réalise un audit d'accessibilité web complet selon les normes WCAG.
Générateur de Tests UAT
Testing
Génère des cas de test d'acceptation utilisateur structurés et complets.