matiaszanolli

Audio Subsystem Audit (M44)

"Deep audit of the M44 audio subsystem — kira backend, spatial sub-tracks, listener/unit seam, reverb send, underwater filter, streaming music — plus its engine callers (footsteps, water audio, reverb zones, REGN ambient music)"

matiaszanolli 28 3 Actualitzat fa 1 setm.
GitHub

Instal·la

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

Instal·la des del registre de SkillsCat.

SKILL.md

Audio Subsystem Audit (M44)

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

Audits crates/audio/ (kira 0.12, default-features = false with an explicit feature list in the workspace Cargo.toml — cpal, wav+pcm, ogg+vorbis, mp3, flac; 0.12 split container from codec features, so dropping a codec silently stops decoding; src/lib.rs + src/tests.rs) and the engine systems that are the ONLY callers of its API. Orchestrator; one Task agent per dimension (max 3 concurrent). Verify the shipped surface against the lib.rs module docstring and docs/feature-matrix.md (Audio); if lib.rs splits, re-derive each dimension's paths first.

Scope

  • Crate: AudioWorld (Option<AudioManager> resource; headless() is the no-device constructor), AudioListener / AudioEmitter / OneShotSound components, Attenuation, audio_system, SoundCache, VoiceSoundCache, load_*sound*, with_start_delay.
  • Engine side: byroredux/src/systems/audio.rs (footstep_system, impact_audio_system, reverb_zone_system, water_audio_system), the combat-feedback sound half of byroredux/src/systems/combat_anim.rs (play_oneshot_cached; take/animation half is /audit-gameplay), byroredux/src/systems/dialogue_voice.rs (voice consumer), SoundCache / VoiceSoundCache boot inserts (boot/world.rs) + telemetry (ownership_sample.rs), the footstep / impact / water-audio / RegionAmbientRes resources in byroredux/src/components.rs (FootstepConfig, ImpactConfig, WaterAudioConfig), byroredux/src/asset_provider/{audio,texture}.rs (dispatch_region_ambient_music, SoundArchiveProvider, sound_is_folder, try_load_default_footstep), wired from cell_loader/load.rs and scene::apply_cell_region_ambient.
  • Handoffs: water state (SubmersionState, splash/ripple production) is /audit-physics Dim 5; stage/lock shape is /audit-ecs / /audit-concurrency (report audio-specific ordering only).

Not shipped as of 2026-10-12 (re-verify against docs/feature-matrix.md; audit the mechanism that will carry them): FOOT/IPDS/IPCT records → per-material footstep and clutter-impact content (the Wave-4 mechanism is live — Havok surface_material u32 → FootstepConfig/ImpactConfig surface_sounds — but both maps and ImpactConfig::default_sound ship empty, so impact_audio_system is a production no-op and footsteps always take the default), CELL-level MUSC routing (default_music/music_type_form are parsed; every construction site still hardcodes music_type_form: None), per-cell acoustics beyond binary interior/exterior reverb, occlusion, REGN incidental/ambient-loop selection (#3301 open), Skyrim+ .fuz dialogue voice and Oblivion voice (#5464, #5526).
Known-open (verified 2026-10-12): #3816 (MUSC/MUST/MSET/RDMD decode: REGN music_form never resolves as a SOUN on any supported game, so the dispatch mechanism is live and tested but plays nothing in production; audit the mechanism, cite #3816 for the gap). #3086 (entity-path spatial position frozen at dispatch) is closed (b4f08089b): sync_emitter_positions now follows the source GlobalTransform (Dim 1) — a frozen position is a regression. #4146 (reverb-zone ordering comment drift) is closed — a third drift is a regression. #5432 is closed (40b77828b): AudioWorld::stop_sounds_for (#5410) now has production callers through dialogue_voice::stop_line_voice — topic change, conversation close and speaker death cut the line (Dim 1/5); a new line-ending path that skips it is the regression. #5148 is closed (7208af67c): combat.status reports per-kind request counts (CombatSoundRequests: swing / impact / death_voice, counted only when play_oneshot_cached actually requested a one-shot) and the Skyrim P2 gate requires >= 1 impact + >= 1 death-voice, so oneshots_requested is no longer the gate's only signal. #4743 (combat-sound keying to take-state transitions) is still open.

Parameters

--focus <dims> (default all 5) · --depth shallow|deep (deep traces per-frame data flow + lifecycle).
Extra field: Dimension: Spatial Dispatch & Listener | Send Graph (Reverb + Underwater) | Music & SoundCache | Manager & ECS Lifecycle | Engine Consumers.

Phase 1: Setup

  1. mkdir -p /tmp/audit/audio; dedup per _audit-common.md; read the latest docs/audits/AUDIT_AUDIO_*.md (date D = delta baseline). A re-flag of a closed finding is a regression claim: check its guard is gone before reporting.
  2. cargo test -p byroredux-audio and cargo test -p byroredux -- audio dialogue_voice; record counts. The lifecycle guards are #[ignore]d (need an audio device, some FNV data): looping_emitter_survives_natural_duration_and_stops_on_emitter_remove, non_looping_emitter_stops_on_emitter_remove_regression_858, play_music_looping_survives_track_end, play_music_drives_streaming_playback_on_real_ogg, emitter_position_follows_the_source_entity_regression_3086, anchored_voice_follows_survives_prune_and_stops, plus the playback/decode trio play_oneshot_queue_drives_real_playback, audio_system_full_lifecycle_on_real_fnv_sound, real_fnv_sounds_decode_through_kira. Despawn truncation, follow, anchored-voice stop and music looping are unguarded in the default lane: run -- --ignored or say so.

Phase 2: Dimensions

Dim 1: Spatial Dispatch, Listener & the Unit Seam

Paths: crates/audio/src/lib.rs (dispatch_new_oneshots, drain_pending_oneshots, prune_stopped_sounds, sync_listener_pose, sync_emitter_positions, bu_to_audio_space, linear_volume_to_db, ActiveSound, SoundSource, play_oneshot_following, stop_sounds_for)
First step: git log --since=D -- crates/audio/src/lib.rs
Guards (default lane): tests.rs::{every_kira_position_site_goes_through_the_unit_seam, emitter_at_max_distance_in_bu_lands_on_max_distance_in_audio_space, drain_pending_oneshots_drains_in_place_instead_of_stranding_capacity, play_oneshot_queue_caps_at_max_pending_when_active, oneshot_marker_is_consumed_on_both_dispatch_failure_arms, with_start_delay_sets_the_start_time_and_shares_frames, play_oneshot_following_drops_on_inactive_audio, stop_sounds_for_is_a_no_op_on_a_headless_world}; #[ignore]d device-gated emitter_position_follows_the_source_entity_regression_3086 (#3086) and anchored_voice_follows_survives_prune_and_stops (#5410).

  • Two dispatch paths stay observably equivalent: entity path (OneShotSound+AudioEmitter → per-emitter SpatialTrackHandle, SoundSource::Emitter, marker removed, AudioEmitter kept) and queue path (PendingOneShot, never loops: play_oneshot → SoundSource::Detached, or play_oneshot_following → SoundSource::Anchored(entity), #5410 d1a7ee8c6). Both early-return without a listener id. stop_sounds_for(entity, fade_ms) fades every playing Emitter/Anchored sound of the entity and drops its queued segments; its production caller is dialogue_voice::stop_line_voice (150 ms fade; apply_selection, end_open_conversation, reconcile_dead_actor; #5432). The entity path has NO engine producer — spawn_oneshot_at is test/tooling-only, so its dispatch is exercised by tests.rs alone (#5612 81207d34d).
  • ActiveSound::track is held for its Drop side effect AND carries the #3086 position pushes (renamed from _track; the track must still be kept alive): it must land in active_sounds before the helper returns. Arc<StaticSoundData> is shared, never deep-cloned; volume applies via (*sound).clone().volume(db).
  • oneshots_requested is a monotonic count of play_oneshot / play_oneshot_following calls including headless discards (incremented before the manager gate; read by combat.status in commands/view.rs for device-less smoke gates) — it proves a request, never audible playback.
  • Producer queue caps at 256 (in both play_oneshot* entry points, each with its own MAX_PENDING) with O(1) pop_front and drops up front when the manager is None; the manager-active gate precedes the drain; a drain of more than 32 warns once per tick (footstep tempo gone wrong). linear_volume_to_db is the single dB conversion (floor SILENCE_DB -60).
  • Entity path reads post-propagation GlobalTransform, not Transform. World is Bethesda units (70/m), kira is metres: bu_to_audio_space is the only seam, applied to the listener pose and both dispatch paths; Attenuation is authored in metres (min <= max, RangeInclusive) and callers must never pre-scale by 70. A missed site is silent (inaudible past ~43 cm, no log); every_kira_position_site_goes_through_the_unit_seam scans the listener sites, both dispatch paths and (since #5411, bb31c5fb3) the follow site, with a negative on raw set_position.
  • Emitter follow (#3086, b4f08089b): sync_emitter_positions (pass 2, after the listener sync) pushes the sub-track of every ActiveSound whose SoundSource::followed_entity() is set (Emitter and Anchored) to its source's current GlobalTransform through the same bu_to_audio_space; change-gated on ActiveSound::last_position (seeded at dispatch on both paths) so a stationary emitter costs one compare and zero kira commands; Detached queue sounds and music are never touched; a despawned/transform-less entity is skipped (prune owns Emitter, stop_sounds_for owns Anchored). AudioWorld::emitter_position_updates() is the observable (kira has no position readback). The behaviour guards are #[ignore]d (device) — trace the code in the default lane.
  • Listener: created lazily on the first frame an AudioListener has a resolved GlobalTransform; the handle is sticky (never cleared on despawn: kira listener capacity is 8, a clear/re-add loop exhausts it); a failed add_listener retries next frame; multi-listener warns once and uses the first. Pose uses Tween::default() (smooth follow); orientation is already renderer-space (no second Z-up conversion).
    Output: /tmp/audit/audio/dim_1.md

Dim 2: Send Graph: Reverb Send & Underwater Filter

Paths: crates/audio/src/lib.rs (AudioWorld::new, apply_reverb_send, reverb_send_gate_open, set_reverb_send_db, apply_underwater_filter, update_underwater_filters, set_underwater)
First step: git log --since=D -- crates/audio/src/lib.rs (send/filter regions)
Guards: tests.rs::{reverb_send_defaults_to_silent, reverb_send_gate_matches_silence_db_boundary, above_water_filter_state_is_a_dry_bypass, above_water_cutoff_never_reaches_the_kira_clamp_ceiling, both_dispatch_paths_build_the_filter_through_one_call_site}.

  • One global reverb send is created in AudioWorld::new from REVERB_FEEDBACK/REVERB_DAMPING/REVERB_STEREO_WIDTH (0.85/0.6/1.0). They have no recorded provenance and are not kira's defaults: flag the doc gap, never invent a rationale. reverb_send: None never cascades into an unwrap().
  • Send opt-in is build-time only (with_send at track construction, gated is_finite() && > -60 dB) and both paths use the shared helper. Default level NEG_INFINITY (dry). Playing sounds keep their construction-time level: the documented "next-dispatch knob" contract, not a bug.
  • Every spatial sub-track carries a low-pass built by ONE shared apply_underwater_filter; update_underwater_filters retargets live tracks when the listener's submersion changes. Above water is a genuine bypass via Mix::DRY (a default FilterBuilder is fully wet and colours the signal); the parked 20 kHz cutoff stays below kira's Nyquist clamp (beyond it the filter degenerates rather than turning transparent). set_underwater is written each frame by water_audio_system (Dim 5), before audio_system.
    Output: /tmp/audit/audio/dim_2.md

Dim 3: Streaming Music & SoundCache

Paths: crates/audio/src/lib.rs (play_music, stop_music, is_music_active, load_streaming_sound_from_*, SoundCache, VoiceSoundCache)
First step: git log --since=D -- crates/audio/src/lib.rs
Guards: tests.rs::{play_music_no_op_when_inactive, sound_cache_hits_are_case_insensitive_and_share_arc, sound_cache_get_or_load_invokes_loader_only_on_miss, sound_cache_clear_drops_entries_and_bytes_estimate_tracks_pcm_size, voice_cache_evicts_lru_under_byte_budget, voice_cache_negatives_are_capped_and_counted}.

  • Single music slot: play_music(streaming_sound, volume, fade_in_secs, looping) (4 parameters, looping since #3775; re-verify the arity before citing) crossfades any existing handle. Music routes through the main track, never a spatial sub-track. It uses StreamingSoundData (from_cursor/from_file), never StaticSoundData (full PCM decode = OOM on long tracks).
  • stop_music fades for fade_out_secs.max(0.0) then drops the handle (an instant stop clicks); is_music_active goes false the moment stop_music returns (the slot is cleared while kira renders the fade tail): documented, so a caller must not read it as "audibly occupied".
  • SoundCache keys lowercase once per entry point; clear() never invalidates Arcs held by live ActiveSounds. It is live since 2026-09-27: inserted at boot (boot/world.rs), sampled by ownership_sample.rs (len() + bytes_estimate()), and its only consumer is combat_anim.rs::play_oneshot_cached (three fixed combat paths; voice moved to VoiceSoundCache). Footstep/splash loaders still bypass it into FootstepConfig/WaterAudioConfig. The map is Option<Arc<_>>: None is a negative-cache entry (loader returned no bytes, or decode failed) that get_or_load never re-probes and len()/bytes_estimate() exclude — so a sound missing at first use stays missing until clear(), which nothing in production calls (check whether the --sounds-bsa set can change at runtime). Still no eviction: a consumer keyed on open-ended paths (FOOT, REGN, per-NPC SFX) that lands on SoundCache is the finding.
  • VoiceSoundCache (#5382, 8410ad1e5) is the open-ended-key cache, used only by dialogue voice: same get_or_load contract, LRU-evicted past DEFAULT_BUDGET_BYTES (64 MiB; the just-inserted entry is never its own victim), negatives capped at VOICE_NEGATIVE_CAP (4096; the set clears and re-probes past it) and counted (negative_len). Telemetry reports voice_cache_{entries,bytes,negatives} through ownership_sample.rs; the save registry allowlists it. A new open-ended consumer belongs here (or in its own bounded cache), not in SoundCache.
    Output: /tmp/audit/audio/dim_3.md

Dim 4: Manager, ECS Lifecycle & Schedule

Paths: crates/audio/src/lib.rs (struct, AudioWorld::new/headless, audio_system, prune_stopped_sounds), byroredux/src/boot/schedule/late.rs, byroredux/src/cell_loader/{load,unload}.rs
First step: git log --since=D -- crates/audio/src/lib.rs byroredux/src/boot/schedule/late.rs
Guards: tests.rs::{audio_world_constructs_without_panic_on_any_environment, explicit_headless_world_discards_playback_without_retaining_sound, manager_capacities_exceed_kira_defaults}; scheduler_access_tests.rs::{footstep_runs_after_camera_follow_in_late, reverb_zone_is_parallel_and_audio_is_exclusive_in_late}.

  • Graceful degradation: AudioManager::new failure leaves manager = None; every public API gates on it with no unwrap(); headless/CI boot must succeed (report as PASS/FAIL in the summary). AudioWorld::headless() never contacts a device; tests use it, not the device-dependent default().
  • SUB_TRACK_CAPACITY (512) and SEND_TRACK_CAPACITY (32) exceed kira's defaults and are applied in new().
  • Field-drop order (single source of truth): active_sounds → pending_oneshots → (oneshots_requested) → music → reverb_send → reverb_send_db → listener → manager, then the trailing multi_listener_warned / underwater flags and the emitter_position_updates counter. A reorder that moves manager up asserts in kira's Drop. AudioWorld::new() is boot-only, never on cell transition or resize.
  • Schedule (all Stage::Late): audio_system is an exclusive registered after footstep_system (exclusive, pinned to Late by #4185's test) and water_audio_system (exclusive, after submersion_system); reverb_zone_system is a parallel registration, and the parallel batch finishing before any exclusive (not registration order) is what sets the send level before new tracks are built. audio_system body order: sync_listener_pose → sync_emitter_positions → update_underwater_filters → drain_pending_oneshots → dispatch_new_oneshots → prune_stopped_sounds. The voice producer (Late npc_dialogue_selection_system) registers AFTER audio_system, so a line's segments dispatch on the next frame — one frame of latency, not a loss.
  • Despawn truncation: only SoundSource::Emitter entries are emitter-coupled (emitter_coupled()): an entity that loses AudioEmitter gets a tweened stop() over ActiveSound.unload_fade_ms (10 ms default, looping and not); stop_issued debounces (and is set by stop_sounds_for too); retain drops only on Stopped. Detached runs to natural end; Anchored survives SFX emitter churn on the same NPC by design and stops only via stop_sounds_for — death goes through reconcile_dead_actor, but an entity that is merely despawned (cell unload mid-line) is not routed there, so its voice keeps playing at its last position until the segment ends (check before filing: #5432 covers topic change, close and death only). Guards are #[ignore]d (Phase 1): trace the code.
  • Cross-cell continuity: dispatch_region_ambient_music runs only when music_form changed (both cell_loader/load.rs paths and scene::apply_cell_region_ambient compare against the prior RegionAmbientRes); an unconditional redispatch audibly restarts the track on every connected-cell crossing. A future CELL-level MUSC caller needs its own gate.
    Output: /tmp/audit/audio/dim_4.md

Dim 5: Engine-Side Consumers

Paths: byroredux/src/systems/{audio,dialogue_voice}.rs, byroredux/src/components.rs (footstep/water-audio/region resources), byroredux/src/asset_provider/{audio,texture}.rs, byroredux/src/scene.rs
First step: git log --since=D -- byroredux/src/systems/audio.rs byroredux/src/systems/dialogue_voice.rs byroredux/src/asset_provider/audio.rs
Guards (systems/audio.rs tests): first_tick_seeds_last_position_without_firing, stride_threshold_is_bethesda_units_not_metres, walking_for_one_second_fires_a_walking_cadence_not_one_per_frame, single_large_jump_fires_one_footstep_only, water_splash_event_reaches_audio_dispatcher, interior_to_exterior_transition_resets_send_to_dry, the #5146 trio orbiting_camera_with_stationary_body_fires_nothing / swimming_body_is_silent_and_exit_does_not_replay_the_swim_distance / airborne_body_is_silent_and_landing_does_not_replay_the_air_distance, plus the no_*_is_safe_noop pair. Highest-yield dimension in the 2026-08/09 reports.

  • Callers: footstep_system, impact_audio_system (Late exclusive registered right after footstep_system: drains PhysicsWorld::take_contact_force_events — the started force crossings, below ImpactConfig::min_force_magnitude skipped — and plays one {1.0, 24.0} m one-shot per impact at the pair midpoint; material = resolve_impact_material (collider 1's non-zero, else collider 2's, else 0 while either handle lives, #5549); impact_sounds_to_play skips only an unmapped impact, never breaks the frame, #5554; the whole system returns early with no default_sound and no surface_sounds), water_audio_system and combat_anim.rs::play_oneshot_cached (PostUpdate, enqueued before Late audio_system) are live play_oneshot callers, with byroredux/src/systems/dialogue_voice.rs as a fifth (#5367 Phase V: FO3/FNV per-line voice through play_oneshot_following, anchored to the speaker and skipped when it has no GlobalTransform (#5410) and cut by stop_line_voice on topic change / close / speaker death (#5432); segments chained via byroredux_audio::with_start_delay, each named by its authored response_number; Attenuation::default(), volume 1.0; gated on AudioWorld::is_active before any archive work; decoded through VoiceSoundCache with the provider read inside the loader (#5382/#5383); file name and owning plugin per docs/engine/dialogue-trees.md (#5393/#5395, 5b68792b7; names truncate only when quest + topic exceed 25 characters, one candidate, #5521); missing voice is a silent fallback to the subtitle estimate); reverb_zone_system the only set_reverb_send_db caller, dispatch_region_ambient_music the only play_music/stop_music caller. Re-grep for any new caller (git grep -n 'play_oneshot\|play_music\|set_underwater\|stop_sounds_for\|take_contact_force_events' -- byroredux/src) and audit it.
  • Footsteps: XZ-only stride accumulation against FootstepEmitter.stride_threshold, authored in Bethesda units (0.75 * BETHESDA_UNITS_PER_METER), compared to GlobalTransform deltas with no conversion at the compare site; reset to 0 on fire (subtract-remainder multiplies footsteps on a teleport); first tick seeds last_position and fires nothing. Attenuation {0.5, 12.0} in metres (no pre-scaling by 70, no widening). The FootstepScratch Vec (capacity 32) is restored on the success and the AudioWorld-absent path; no component lock is held across play_oneshot and the scratch lock drops before AudioWorld is taken. The player-body gate (CharacterController/WaterContact) is resolved as an owned (body, gated) pair BEFORE any GlobalTransform/FootstepEmitter/scratch guard (#5305, 6915fe783: taking it inside closed three lock-order cycles) — a per-entity get back inside that scope is the regression. Every missing resource is a silent no-op (no per-frame log). Opt-in is component-driven. Surface selection (Wave 4): each trigger resolves its material at footstep cadence through PhysicsWorld::surface_material_under, which takes a MANDATORY excluded_body (the player's RapierHandles.body, else the capsule centre self-hits at toi 0 and every player step reads material 0, #5545 3909778eb); select_footstep_sound is a pure function of a FootstepConfig.surface_sounds snapshot cloned once, after the no-trigger early return and BEFORE the AudioWorld guard (no AudioWorld → FootstepConfig lock edge, #5556 ac710964e); the Access row declares the PhysicsWorld/RapierHandles reads (scheduler_access_tests.rs::footstep_declares_its_physics_reads). Material keys are u32 end to end (Skyrim's are CRC32s; 0 is STONE on Oblivion/FO3/FNV and the value of every unauthored body, #5542). Which entity walks (#5146, ccc743160): FlyCam keeps the camera's emitter (PlayerEntity is None); character mode adds an emitter on the player body, and while a body exists camera-borne emitters are re-seeded, never accumulated (the 180 BU third-person boom turned mouse-look into footsteps). The body accumulates only while CharacterController.is_grounded and not swimming (WaterContact depth vs depth_reaches_swimlevel, the same predicate as character.rs); gated frames re-seed so a landing or water exit never replays as a burst. A runtime FlyCam toggle that leaves PlayerEntity set keeps the camera silent — check that is intended. The Access row declares the PlayerEntity/CharacterController/WaterContact reads.
  • Water audio: water_audio_system writes AudioWorld::set_underwater from the active camera's SubmersionState.head_submerged every frame (before the config early-return), then plays SplashEvent one-shots (range 1-24 m) and, when no splash fired, one cooldown-limited RippleEvent (per-surface cooldown, 0.45 damping). The range and the 0.45 factor are uncited (no authored SOUN range): note as unsourced, never invent one. WaterAudioConfig.splash_sound is None without an archive (valid headless state); try_load_default_footstep and try_load_default_water_splash (asset_provider/texture.rs) no-op cleanly when the arg or archive is absent.
  • Combat feedback sounds (play_oneshot_cached): registered outside the BYRO_NO_AI_LOCOMOTION block since #4709 (70d9896fb) — a move back inside silently mutes them; hardcoded Skyrim paths per docs/engine/p2-combat-anim-sound-fixture.md, each request counted per kind in CombatSoundRequests (swing / impact / death_voice; incremented only when the one-shot was actually requested, surfaced by combat.status, #5148 7208af67c), attenuation {0.5, 24.0} m (uncited, like the water range); lookup goes through SoundCache::get_or_load whose loader reads SoundArchiveProvider while the SoundCache write guard is held (resource-in-resource nesting). Dialogue voice uses the same cache → provider order on VoiceSoundCache (#5383, 080b35f35; the old provider → cache order closed a lock-order cycle with this path); dialogue_voice.rs::voice_and_combat_sound_paths_share_one_cache_provider_order drives both consumers in one process — a third consumer taking the provider first is the regression. Headless / archive-less paths skip silently.
  • reverb_zone_system: -12 dB interior / NEG_INFINITY exterior; the transition is bit-equality gated (to_bits), no-ops without CellLightingRes (pre-load) or AudioWorld (headless).
  • dispatch_region_ambient_music: stops outstanding playback on every failure layer (no provider, no --sounds-bsa, unresolved FormID, folder FNAM, archive miss, decode failure, music_form: None) because what is audible belonged to the previous directive; the authored-but-unresolved log fires once per process (std::sync::Once); looping threads from sound_loops (SOUN Loop bit) into play_music. SounRecord::sound_path is a file or a trailing-separator folder (about half of FNV's FNAM-bearing records): sound_is_folder fails closed before any archive lookup, and any future resolve_sound_path consumer must gate on it (variant selection is an unmade policy, so no "extract any file under this folder" fallback exists).
  • Archive precedence: SoundArchiveProvider::extract iterates .rev() (last-listed wins, as mesh/texture/material; FNV's Fallout - Sound.bsa + Update.bsa overlap with differing bytes): forward iteration is the regression. ScriptProvider is the deliberate first-wins exception.
    Output: /tmp/audit/audio/dim_5.md

Phase 3: Merge

Combine /tmp/audit/audio/dim_*.md into docs/audits/AUDIT_AUDIO_<TODAY>.md (header per _audit-common.md Report finalization): Executive Summary (severity counts, headless-boot PASS/FAIL), Lifecycle Invariant Matrix (field-drop order, sticky listener, despawn truncation, schedule order: verified or drifted), Findings (deduplicated), Future-Phase Readiness (FOOT, MUSC/MUST/MSET, occlusion). Then rm -rf /tmp/audit/audio; suggest /audit-publish docs/audits/AUDIT_AUDIO_<TODAY>.md (labels audio, plus game:* when one title's sound data is the cause).