Audio/SoundCommands.cs

Console command collection for audio debugging and control in the game. Provides commands to play/inspect cues, adjust volumes, probe sound asset properties, stress-test the mixer, trace and count sound events, and tweak related settings like music and footsteps.

NetworkingFile Access
using Sandbox;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace NZombies;

/// <summary>
/// Console access to every cue, so audio can be checked without playing the
/// game into the situation that triggers it — the standing rule that anything
/// clickable also has a command.
/// </summary>
public static class SoundCommands
{
	/// <summary>
	/// Test the UI cues without clicking anything: nz_ui_sound [click|hover].
	///
	/// ⚠️ EXISTS SO "I HEAR NOTHING" HAS AN ANSWER. A missing cue is silent, so
	/// without a way to fire one deliberately there is no telling a broken hover
	/// handler from a missing asset — and those have nothing in common as bugs.
	/// </summary>
	[ConCmd( "nz_ui_sound" )]
	public static void UiSound( string which = "click" )
	{
		var cue = which.ToLowerInvariant() == "hover"
			? NZSound.UiHover : NZSound.UiClick;

		Log.Info( $"[nz] firing '{cue}' — exists: {NZSound.Exists( cue )}" );
		NZSound.PlayUi( cue );
	}

	/// <summary>Start, stop or report the music: nz_music [cue|stop].</summary>
	[ConCmd( "nz_music" )]
	public static void Music( string cue = "" )
	{
		if ( string.IsNullOrWhiteSpace( cue ) )
		{
			Log.Info( $"[nz] music: {NZMusic.Current ?? "(none)"}"
				+ $"   volume {NZMusic.Volume:0.00}"
				+ $"   lobby cue '{NZSound.MusicLobby}' exists: "
				+ NZSound.Exists( NZSound.MusicLobby ) );
			return;
		}

		if ( cue.ToLowerInvariant() == "stop" )
		{
			NZMusic.Stop();
			Log.Info( "[nz] music stopped" );
			return;
		}

		NZMusic.Play( cue );
	}

	/// <summary>Music volume, independent of effects: nz_music_vol 0.55.</summary>
	[ConCmd( "nz_music_vol" )]
	public static void MusicVolume( float v = -1f )
	{
		if ( v < 0f ) { Log.Info( $"[nz] music volume {NZMusic.Volume:0.00}" ); return; }

		NZMusic.Volume = v;
		Log.Info( $"[nz] music volume {v:0.00}"
			+ (NZMusic.Current is null ? "" : " — restart the track to hear it") );
	}

	/// <summary>
	/// Balance one cue by ear: nz_vol &lt;cue&gt; &lt;multiplier&gt;.
	///
	/// 1.0 is the asset as authored; 0.5 halves it, 2.0 doubles it. Bare command
	/// lists every cue with its current setting and marks the ones moved.
	///
	/// ⚠️ RUNTIME ONLY — nothing here survives a restart, deliberately. This is
	/// for FINDING the numbers; `nz_vol_bake` prints what to write into the .sound
	/// assets so they become permanent.
	///
	/// ⚠️ Takes effect on the NEXT play of a cue, not on one already sounding —
	/// the multiplier is applied to a voice as it starts. For music that means
	/// restarting the track (`nz_music stop` then reopen the lobby).
	/// </summary>
	[ConCmd( "nz_vol" )]
	public static void Volume( string cue = "", float mult = -1f )
	{
		if ( string.IsNullOrWhiteSpace( cue ) )
		{
			Log.Info( "[nz-audio] cue volumes — 1.00 is the asset as authored:" );

			foreach ( var c in All )
			{
				float v = NZSound.VolumeOf( c );
				Log.Info( $"[nz-audio]   {c,-22} {v:0.00}"
					+ (v == 1f ? "" : "   <- adjusted") );
			}

			Log.Info( "[nz-audio] nz_vol <cue> <mult> to change, "
				+ "nz_vol_bake when it sounds right" );
			return;
		}

		if ( !All.Contains( cue ) )
		{
			Log.Warning( $"[nz-audio] no cue '{cue}' — bare nz_vol lists them" );
			return;
		}

		if ( mult < 0f )
		{
			Log.Info( $"[nz-audio] {cue} {NZSound.VolumeOf( cue ):0.00}" );
			return;
		}

		NZSound.SetVolume( cue, mult );
		Log.Info( $"[nz-audio] {cue} -> {mult:0.00}"
			+ "   (plays at the new level from the next trigger)" );
	}

	/// <summary>Everything back to the authored levels: nz_vol_reset.</summary>
	[ConCmd( "nz_vol_reset" )]
	public static void VolumeReset()
	{
		NZSound.ResetVolumes();
		Log.Info( "[nz-audio] all cues back to 1.00" );
	}

	/// <summary>
	/// Print what to write into the assets: nz_vol_bake.
	///
	/// ⚠️ REPORTS, DOES NOT WRITE. The multiplier is relative to each .sound's own
	/// Volume, so baking means reading that file and multiplying — and quietly
	/// rewriting a dozen assets from a console command is not something that
	/// should happen without the change being visible first.
	/// </summary>
	[ConCmd( "nz_vol_bake" )]
	public static void VolumeBake()
	{
		var moved = NZSound.Adjusted.ToList();

		if ( moved.Count == 0 )
		{
			Log.Info( "[nz-audio] nothing adjusted — every cue is at 1.00" );
			return;
		}

		Log.Info( $"[nz-audio] {moved.Count} cue(s) adjusted. Multiply each "
			+ "asset's Volume by this and the setting becomes permanent:" );

		foreach ( var kv in moved )
			Log.Info( $"[nz-audio]   Assets/sounds/nz/{kv.Key}.sound   x {kv.Value:0.00}" );
	}

	/// <summary>Play a cue: nz_sound &lt;name&gt;. Bare command lists them.</summary>
	[ConCmd( "nz_sound" )]
	public static void PlayCue( string cue = "" )
	{
		if ( string.IsNullOrWhiteSpace( cue ) )
		{
			Log.Info( "[nz-audio] cues:" );
			foreach ( var c in All ) Log.Info( $"[nz-audio]   {c}" );
			return;
		}

		// Play at the player so 3D cues are actually audible — at the world
		// origin most of them are outside their own falloff distance and the
		// command looks broken.
		var p = NZPlayer.Local;
		var at = p.IsValid() ? p.WorldPosition : Vector3.Zero;

		NZSound.Play( cue, at );
		Log.Info( $"[nz-audio] played '{cue}' at {at}" );
	}

	/// <summary>Play every cue back to back: nz_sound_all. 1.2s apart is enough
	/// to tell them apart without sitting through the round stings.</summary>
	[ConCmd( "nz_sound_all" )]
	public static void PlayAll()
	{
		// ⚠️ There is no Scene.DispatchDelayed in s&box. Spacing the cues needs
		// either a ticking component or the task scheduler; the scheduler is the
		// one that does not outlive the command.
		_ = PlayAllAsync();

		Log.Info( $"[nz-audio] playing {All.Length} cues over {All.Length * 1.2f:0.0}s" );
	}

	static async Task PlayAllAsync()
	{
		foreach ( var cue in All )
		{
			var p = NZPlayer.Local;
			var at = p.IsValid() ? p.WorldPosition : Vector3.Zero;

			Log.Info( $"[nz-audio] {cue}" );
			NZSound.Play( cue, at );

			await GameTask.Delay( 1200 );
		}
	}

	/// <summary>
	/// Verify every cue resolves: nz_sound_check.
	///
	/// ⚠️ THE ONLY WAY TO CATCH A BROKEN CUE. A missing sound event does not
	/// throw and does not warn — it prints one Info line and plays nothing, so a
	/// renamed asset or a typo'd constant is silent in exactly the way that looks
	/// like "the audio just isn't wired up yet". Run this after regenerating.
	/// </summary>
	/// <summary>
	/// SWB's own per-shot logging: `nz_wep_debug 1`.
	///
	/// ⛔ THE ONLY WAY TO SEE A WEAPON SOUND. `nz_sound_trace` watches NZSound, and
	/// weapons play through `Weapon.PlaySound` — so a shot, its reload and the
	/// Pack-a-Punch report are all invisible to the audio trace no matter how loud
	/// they are. This prints the cue, the origin it was emitted at, and whether the
	/// handle came back valid, which is exactly the set of facts that separates
	/// "silent" from "playing a million units away".
	/// </summary>
	[ConCmd( "nz_wep_debug" )]
	public static void WeaponDebug( int on = -1 )
	{
		if ( on >= 0 ) SWB.Base.Weapon.WeaponDebug = on != 0;

		Log.Info( $"[nz-audio] weapon debug {(SWB.Base.Weapon.WeaponDebug ? "ON" : "off")}"
			+ " — fires print origin and handle validity" );
	}

	[ConCmd( "nz_sound_check" )]
	public static void Check()
	{
		int ok = 0;
		foreach ( var cue in All )
		{
			if ( NZSound.Exists( cue ) ) { ok++; continue; }
			Log.Warning( $"[nz-audio] MISSING  {cue}  (sounds/nz/{cue}.sound)" );
		}

		Log.Info( $"[nz-audio] {ok}/{All.Length} cues resolve" );
	}

	/// <summary>
	/// Dump each cue's 3D settings AS THE ENGINE LOADED THEM: nz_sound_probe.
	///
	/// ⚠️ NOT the same as reading the .sound file off disk. The generator wrote
	/// that json; this reads back what the compiler produced and the resource
	/// system actually holds, which is the thing the mixer will obey. A field
	/// renamed between schema versions would be silently dropped on compile and
	/// look perfect in the source file.
	///
	/// ⚠️ NO REFLECTION. s&box runs a whitelist: Type.GetProperty, GetField and
	/// PropertyInfo.GetValue are all rejected at compile, so a generic property
	/// dump is not available here however convenient it would be. Name the
	/// properties directly and accept that a renamed one breaks the build.
	/// </summary>
	[ConCmd( "nz_sound_probe" )]
	public static void Probe( string filter = "" )
	{
		Log.Info( $"[nz-audio] {"cue",-22} {"2D",-4} {"attenuates",-11} "
			+ $"{"distance",-9} {"occludes",-9} samples" );

		foreach ( var cue in All )
		{
			if ( !string.IsNullOrWhiteSpace( filter ) && !cue.Contains( filter ) ) continue;

			if ( !ResourceLibrary.TryGet<SoundEvent>( $"sounds/nz/{cue}.sound", out var ev ) )
			{
				Log.Warning( $"[nz-audio] {cue,-22} MISSING" );
				continue;
			}

			Log.Info( $"[nz-audio] {cue,-22} {ev.UI,-4} {ev.DistanceAttenuation,-11} "
				+ $"{ev.Distance,-9:0} {ev.OcclusionEnabled,-9} {ev.Sounds?.Count ?? 0}" );
		}
	}

	/// <summary>
	/// Play a cue at a zombie and read the HANDLE back: nz_sound_at.
	///
	/// Proves the sound is genuinely positioned rather than merely configured to
	/// be — the event can say DistanceAttenuation all it likes, but if the handle
	/// comes back at the origin it is playing flat in the middle of the map.
	/// </summary>
	[ConCmd( "nz_sound_at" )]
	public static void PlayAtZombie( string cue = NZSound.ZombieAttack )
	{
		var z = ZombieAI.All.FirstOrDefault();
		if ( !z.IsValid() ) { Log.Warning( "[nz-audio] no zombies — nz_spawn first" ); return; }

		var handle = NZSound.Play( cue, z.WorldPosition );

		var ear = NZSound.Ear;
		var cam = Game.ActiveScene?.Camera?.WorldPosition ?? Vector3.Zero;

		Log.Info( $"[nz-audio] cue      {cue}" );
		Log.Info( $"[nz-audio] zombie   {z.WorldPosition}" );
		Log.Info( $"[nz-audio] handle   {handle.Position}  playing={handle.IsPlaying}" );
		Log.Info( $"[nz-audio] ear      {ear}   camera {cam}   apart {ear.Distance( cam ):0}u" );
		Log.Info( $"[nz-audio] distance {z.WorldPosition.Distance( ear ):0}u" );

		var placed = handle.Position.Distance( z.WorldPosition ) < 1f;
		Log.Info( placed
			? "[nz-audio] POSITIONED — the handle sits on the zombie"
			: "[nz-audio] FLAT — the handle is NOT at the zombie, so it will not attenuate" );

		// ⚠️ Position alone does not give you STEREO. Panning is computed from the
		// listener's ROTATION — an emitter 500u away with an identity listener
		// rotation attenuates correctly and still comes out dead centre. So the
		// rotation has to be checked separately from the position.
		var rot = Sound.Listener.Rotation;
		var local = rot.Inverse * (z.WorldPosition - ear).Normal;

		var side = local.y > 0.25f ? "LEFT" : local.y < -0.25f ? "RIGHT" : "centre";
		var front = local.x >= 0f ? "front" : "BEHIND";

		Log.Info( $"[nz-audio] ear yaw  {rot.Yaw():0}°  "
			+ $"-> bearing {side} / {front}  (local {local.x:0.00},{local.y:0.00},{local.z:0.00})" );
	}

	/// <summary>
	/// Prove a voice emitter tracks its zombie: nz_sound_follow [seconds].
	///
	/// Samples the same handle every 250ms and prints where the SOUND is versus
	/// where the ZOMBIE is. Static emitters drift apart; followed ones do not.
	/// This is the only way to see the difference without ears — the sound plays
	/// identically either way, it just plays somewhere else.
	/// </summary>
	[ConCmd( "nz_sound_follow" )]
	public static void FollowTest( float seconds = 4f )
	{
		// ⚠️ THE FASTEST ZOMBIE, NOT THE FIRST. A static emitter on a stationary
		// zombie also reports zero drift, so picking arbitrarily lets the test
		// pass without measuring anything. The first run of this did exactly
		// that — twenty samples of "0u" against a zombie that never moved.
		var z = ZombieAI.All
			.Where( x => x.State != ZombieState.Dead && x.State != ZombieState.Spawning )
			.OrderByDescending( x => x.Velocity.WithZ( 0 ).Length )
			.FirstOrDefault();

		if ( !z.IsValid() ) { Log.Warning( "[nz-audio] no live zombies" ); return; }

		_ = FollowAsync( z, seconds );
	}

	static async Task FollowAsync( ZombieAI z, float seconds )
	{
		// A long cue, so it is still playing across the whole sample window.
		var handle = NZSound.Play( NZSound.ZombieSprint, z.WorldPosition + Vector3.Up * 55f );
		z.TrackVoice( handle );

		Log.Info( $"[nz-audio] {"t",-6} {"sound",-24} {"zombie",-24} {"drift",-7} {"moved",-7} state" );

		var origin = z.WorldPosition;
		float worstDrift = 0f, travelled = 0f, audibleFor = 0f;

		for ( float t = 0f; t <= seconds; t += 0.25f )
		{
			if ( !z.IsValid() ) return;

			var sound = handle.Position;
			var zom = z.WorldPosition + Vector3.Up * 55f;
			var drift = sound.Distance( zom );
			var playing = handle.IsValid() && !handle.IsStopped;

			travelled = z.WorldPosition.Distance( origin );

			// ⚠️ ONLY COUNT DRIFT WHILE THE SOUND IS PLAYING. A stopped handle
			// keeps reporting the last position it was given, so a finished cue
			// looks exactly like a broken follow — the first version of this
			// scored a perfect follow as "NOT following" for 124 units of drift
			// accumulated after the clip had already ended.
			if ( playing )
			{
				worstDrift = MathF.Max( worstDrift, drift );
				audibleFor = t;
			}

			Log.Info( $"[nz-audio] {t,-6:0.00} "
				+ $"{$"{sound.x:0},{sound.y:0},{sound.z:0}",-24} "
				+ $"{$"{zom.x:0},{zom.y:0},{zom.z:0}",-24} "
				+ $"{$"{drift:0}u",-7} {$"{travelled:0}u",-7} "
				+ (playing ? "playing" : "ended") );

			if ( !playing && t > 0f ) break;

			await GameTask.Delay( 250 );
		}

		// The verdict has to account for how far the zombie actually went — a
		// small drift over a small distance is not evidence of anything.
		var movedWhileAudible = travelled * (seconds > 0f ? audibleFor / seconds : 0f);

		if ( movedWhileAudible < 30f )
			Log.Warning( $"[nz-audio] INCONCLUSIVE — only ~{movedWhileAudible:0}u of travel while audible. "
				+ "A static emitter would look identical." );
		else
			Log.Info( $"[nz-audio] audible {audibleFor:0.00}s, ~{movedWhileAudible:0}u travelled in that time, "
				+ $"worst drift {worstDrift:0}u — {(worstDrift < 10f ? "FOLLOWING" : "NOT following")}" );
	}

	/// <summary>
	/// Find the ENGINE's concurrent voice ceiling: nz_sound_stress [n].
	///
	/// Fires n cues in one frame and counts how many handles are still alive a
	/// moment later. Our own budget is a rate limit we chose; this is the limit
	/// the mixer imposes whether we like it or not, and the two are easy to
	/// confuse when zombies go quiet.
	/// </summary>
	[ConCmd( "nz_sound_stress" )]
	public static void Stress( int n = 64 )
	{
		_ = StressAsync( n );
	}

	static async Task StressAsync( int n )
	{
		var p = NZPlayer.Local;
		var at = p.IsValid() ? p.WorldPosition : Vector3.Zero;

		// Spread them so distance culling and occlusion cannot be the reason one
		// goes quiet — all within earshot, none stacked in the same spot.
		var handles = new List<SoundHandle>();
		for ( int i = 0; i < n; i++ )
		{
			var angle = i / (float)n * MathF.PI * 2f;
			var pos = at + new Vector3( MathF.Cos( angle ) * 200f, MathF.Sin( angle ) * 200f, 40f );

			var h = Sound.Play( NZSound.ZombieIdle, pos );
			if ( h.IsValid() ) handles.Add( h );
		}

		Log.Info( $"[nz-audio] asked for {n}, got {handles.Count} valid handles" );

		// One frame later: anything the mixer refused or stole should have gone.
		await GameTask.Delay( 100 );

		int alive = handles.Count( h => h.IsValid() && !h.IsStopped );
		Log.Info( $"[nz-audio] {alive} still playing after 100ms" );

		await GameTask.Delay( 400 );

		alive = handles.Count( h => h.IsValid() && !h.IsStopped );
		Log.Info( $"[nz-audio] {alive} still playing after 500ms" );

		foreach ( var h in handles ) if ( h.IsValid() ) h.Stop();
		Log.Info( "[nz-audio] stopped all" );
	}

	/// <summary>
	/// `nz_player_steps [volume]` — how loud the PLAYER's own footsteps are.
	///
	/// ⚠️ A DIFFERENT SYSTEM FROM `nz_steps`, which is the zombies'. Theirs are ours: clips fired
	/// from authored animation-event times (WalkerFootsteps). The player's belong to the engine --
	/// PlayerController reads the surface under each foot and plays that surface's own sound -- so
	/// the only thing there is to change is the volume it plays at.
	///
	/// ⛔ EVERY NUMBER GETS A COMMAND, and loudness especially: it can only be judged by listening,
	/// and the alternative is a rebuild per guess.
	/// </summary>
	[ConCmd( "nz_player_steps" )]
	public static void PlayerSteps( float volume = -1f )
	{
		if ( volume >= 0f ) NZPlayer.FootstepVolume = volume;

		var p = NZPlayer.Local;
		var c = p?.Components.Get<PlayerController>();

		Log.Info( $"[nz-audio] player footsteps volume {NZPlayer.FootstepVolume:0.##}"
			+ " (engine default 1)"
			+ (volume >= 0f ? "" : "  — nz_player_steps <volume> to change") );

		// ⚠️ THE LIVE CONTROLLER TOO, because the static is only the REQUEST. NZPlayer pushes it
		// on its next frame, so a mismatch here means no player is running that push — which is
		// the difference between "the setting is wrong" and "the setting is not being applied".
		if ( c.IsValid() )
			Log.Info( $"[nz-audio]   live controller: enabled {c.EnableFootstepSounds}"
				+ $" · volume {c.FootstepVolume:0.##}" );
		else
			Log.Info( "[nz-audio]   no player in the scene — applies when one spawns" );
	}

	/// <summary>
	/// Why footsteps are or are not firing: nz_steps.
	///
	/// The join between the generated table and the runtime is a STRING — the
	/// sequence name as the vmdl reports it versus the animation name as the QC
	/// declared it. If those ever diverge, footsteps go silent with no error
	/// anywhere, so print both sides rather than guessing.
	/// </summary>
	[ConCmd( "nz_steps" )]
	public static void Steps()
	{
		Log.Info( $"[nz-audio] footstep table holds {WalkerFootsteps.ClipCount} clips" );

		var live = ZombieAI.All.Where( z => z.State != ZombieState.Dead ).Take( 6 ).ToList();
		if ( live.Count == 0 ) { Log.Warning( "[nz-audio] no zombies" ); return; }

		foreach ( var z in live )
		{
			var r = z.Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
			var seq = r?.Sequence;

			if ( seq is null ) { Log.Info( "[nz-audio]   <no sequence>" ); continue; }

			var has = WalkerFootsteps.Has( seq.Name );
			Log.Info( $"[nz-audio]   playing '{seq.Name}'  t={seq.TimeNormalized:0.00}  "
				+ $"rate={r.PlaybackRate:0.00}  table={(has ? $"{WalkerFootsteps.For( seq.Name ).Length} steps" : "NO MATCH")}" );
		}
	}

	/// <summary>Log every cue as it fires: nz_sound_trace [0/1].</summary>
	[ConCmd( "nz_sound_trace" )]
	public static void SetTrace( int on = -1 )
	{
		NZSound.Trace = on < 0 ? !NZSound.Trace : on != 0;
		Log.Info( $"[nz-audio] trace {(NZSound.Trace ? "ON" : "off")}" );
	}

	/// <summary>What fired and how often: nz_sound_counts [reset].</summary>
	[ConCmd( "nz_sound_counts" )]
	public static void Counts( string arg = "" )
	{
		if ( arg == "reset" )
		{
			NZSound.ResetStats();
			_sinceReset = 0f;
			Log.Info( "[nz-audio] counts reset" );
			return;
		}

		if ( NZSound.Counts.Count == 0 ) { Log.Info( "[nz-audio] nothing has fired" ); return; }

		var secs = MathF.Max( 0.1f, _sinceReset );
		Log.Info( $"[nz-audio] over {secs:0.0}s, {ZombieAI.All.Count( z => z.State != ZombieState.Dead )} alive" );

		foreach ( var kv in NZSound.Counts.OrderByDescending( k => k.Value ) )
			Log.Info( $"[nz-audio] {kv.Value,5}  {kv.Value / secs,5:0.0}/s  {kv.Key}" );

		Log.Info( $"[nz-audio] {NZSound.CulledOutOfRange,5}  {NZSound.CulledOutOfRange / secs,5:0.0}/s  (dropped — out of earshot)" );
		Log.Info( $"[nz-audio] {NZSound.DroppedOverBudget,5}  {NZSound.DroppedOverBudget / secs,5:0.0}/s  (dropped — over budget)" );
	}

	/// <summary>When the counters were last cleared, so they can be reported as a
	/// RATE. Totals alone say nothing — six cues is a lot in one second and
	/// nothing at all in a minute.</summary>
	static TimeSince _sinceReset;

	/// <summary>
	/// Mute or unmute everything: `nz_sound_enabled 0/1`. No argument REPORTS.
	///
	/// ⛔ IT USED TO TOGGLE ON NO ARGUMENT, AND THAT MUTED THE GAME DURING A DIAGNOSIS. Every other
	/// reporting command in this project prints state when given nothing — `nz_dmgwall_enable`,
	/// `nz_light_volume`, `nz_atmos_report` — so `nz_sound_enabled` was run to ASK whether audio was
	/// on, and silently turned it off. The reply, "[nz-audio] muted", then read as the answer to the
	/// question rather than as the effect of asking it, and cost a chunk of a sound bug hunt.
	///
	/// ⚠️ A QUERY MUST NOT HAVE A SIDE EFFECT. Toggling is still available, explicitly, as
	/// `nz_sound_enabled 2`.
	/// </summary>
	[ConCmd( "nz_sound_enabled" )]
	public static void Enabled( int on = -1 )
	{
		if ( on == 2 ) NZSound.Enabled = !NZSound.Enabled;
		else if ( on >= 0 ) NZSound.Enabled = on != 0;

		Log.Info( $"[nz-audio] {(NZSound.Enabled ? "ON" : "muted")}"
			+ ( on < 0 ? "   (nz_sound_enabled 0/1 to set, 2 to toggle)" : "" ) );
	}

	/// <summary>
	/// `nz_sound_play &lt;event&gt; [volume]` — play a sound EVENT once, at the listener.
	///
	/// ⛔ THE TEST THAT SEPARATES "THE EVENT IS SILENT" FROM "THE PLACEABLE IS BROKEN". `nz_sound`
	/// only plays NZSound's 45 named cues, so an event authored for a map — a lava loop, a vent hum —
	/// could not be auditioned at all, and every attempt to test one had to go through placing it,
	/// standing next to it and hoping. This plays the asset itself with nothing else in the path.
	///
	/// ⚠️ 2D, ON PURPOSE. No position, no distance attenuation, no occlusion: if this is silent
	/// the problem is the asset or the mixer, and if it is audible the problem is where the emitter
	/// is or how far away you are.
	/// </summary>
	[ConCmd( "nz_sound_play" )]
	public static void PlayEvent( string path = "", float volume = 1f )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
		{
			Log.Info( "[nz-audio] nz_sound_play <event path> [volume]"
				+ "   e.g. sounds/nz/ambience/nz.lava.loop.sound" );
			return;
		}

		var evt = ResourceLibrary.Get<SoundEvent>( path );

		if ( evt is null )
		{
			Log.Warning( $"[nz-audio] '{path}' did not load — check the path" );
			return;
		}

		if ( !NZSound.Enabled )
			Log.Warning( "[nz-audio] ⚠ sound is MUTED (nz_sound_enabled 1) — playing anyway, "
				+ "but you will not hear it" );

		var h = Sound.Play( evt );

		if ( !h.IsValid() )
		{
			Log.Warning( "[nz-audio] the engine returned no handle — nothing is playing" );
			return;
		}

		h.Volume = volume;

		// ⚠️ FORCED 2D so distance cannot be the reason it is inaudible.
		h.ListenLocal = true;

		Log.Info( $"[nz-audio] playing '{path}' at volume {volume:0.##}"
			+ $"   mixer '{evt.DefaultMixer.Name}'"
			// NAMES THE MIXER, because an unrouted event lands on the implicit master and loses
			// the 64-voice priority race - the exact failure MixerBus was written to fix.
			+ ( string.Equals( evt.DefaultMixer.Name, "unknown", System.StringComparison.OrdinalIgnoreCase )
				? "  ⚠ UNROUTED - see MixerBus" : "" ) );
	}

	/// <summary>
	/// Cap ambient voice starts per second: nz_sound_budget [n]. **0 = unlimited,
	/// and that is the default.**
	///
	/// ⚠️ A PERFORMANCE INSTRUMENT, NOT A MIX CONTROL. There is deliberately no
	/// voice limit — a horde should sound like a horde, and the engine happily
	/// played 1024 concurrent sounds. Only reach for this if a big wave costs
	/// frames, and put it back to 0 afterwards.
	/// </summary>
	[ConCmd( "nz_sound_budget" )]
	public static void Budget( int n = -1 )
	{
		if ( n >= 0 ) NZSound.AmbientBudget = n;

		Log.Info( NZSound.AmbientBudget > 0
			? $"[nz-audio] ambient CAPPED at {NZSound.AmbientBudget} starts/sec "
				+ "— set 0 to remove the limit"
			: "[nz-audio] ambient UNLIMITED (default)" );
	}

	/// <summary>Skip cues emitted past their own falloff: nz_sound_cull [0/1].
	/// On by default; those are silent either way, so this exists to rule it out
	/// rather than to shape the mix.</summary>
	[ConCmd( "nz_sound_cull" )]
	public static void Cull( int on = -1 )
	{
		NZSound.CullOutOfEarshot = on < 0 ? !NZSound.CullOutOfEarshot : on != 0;

		Log.Info( NZSound.CullOutOfEarshot
			? "[nz-audio] out-of-earshot cues skipped (no audible difference)"
			: "[nz-audio] out-of-earshot cues PLAYED — nothing is filtered" );
	}

	static readonly string[] All =
	{
		NZSound.ZombieSpawn,
		NZSound.ZombieIdle,
		NZSound.ZombieAttack,
		NZSound.ZombieHit,
		NZSound.ZombieDeath,
		NZSound.ZombieSprint,
		NZSound.ZombieTaunt,
		NZSound.ZombieBehind,
		NZSound.ZombieStep,
		NZSound.ZombieStepRun,
		NZSound.ZombieGoreLimb,
		NZSound.ZombieGoreHead,
		NZSound.ZombieGoreGush,
		NZSound.RoundStart,
		NZSound.RoundEnd,
		NZSound.GameOver,
		NZSound.Knife,
		NZSound.PowerupPickup,
		NZSound.PerkVend,
		NZSound.TeleporterOut,
		NZSound.TeleporterIn,
		NZSound.TeleporterWarmup,
		NZSound.TeleporterCharge,
		NZSound.SoulCatch,
		NZSound.SoulFull,

		// ⚠️ ADDED LATE, AND THE OMISSION HAD TEETH. This list is what nz_sound,
		// nz_sound_all and nz_vol all iterate, so a cue missing from it is
		// invisible to every audio command — `nz_vol nz.ui.click 0.5` was refused
		// outright with "no cue". A new cue in NZSound is not finished until it
		// appears here.
		NZSound.UiClick,
		NZSound.UiHover,
		NZSound.UiCountdown,
		NZSound.MusicLobby,

		NZSound.Purchase,
		NZSound.PurchaseDeny,
		NZSound.DebrisClear,
		NZSound.BasaltRoundStart,
		NZSound.BasaltRoundEnd,
		NZSound.BasaltGameOver,
		NZSound.BasaltDebris,
		NZSound.BasaltRoom,
		NZSound.BasaltBoxSpin,
		NZSound.BasaltLobby,
		NZSound.BasaltAmbience,
		NZSound.BasaltPower,
		NZSound.WallBuyFlame,
		NZSound.BasaltMusicBoss,
		NZSound.BasaltMusicBossTakeo,
		NZSound.BasaltMusicBossDarkTech,
		NZSound.BasaltMusicDefend,
		NZSound.BasaltMusicDefendTrial,
		NZSound.BarricadeBreak,
		NZSound.BarricadeRepair,

		NZSound.PowerOn,
		NZSound.PowerOff,

		// ⚠️ THE WARNING ABOVE CAUGHT ME ANYWAY. All four of these shipped before
		// they were listed here — `nz_sound_check` kept reporting a confident
		// "27/27 cues resolve" while four real cues were outside the set it checks.
		// A checker that counts only what it was told about reads exactly like a
		// clean bill of health, which is worse than no checker at all.
		NZSound.BoxJingle,
		NZSound.BoxTeddy,
		NZSound.BoxBye,
		NZSound.BoxPoof,

		NZSound.PapWork,
		NZSound.PapReady,
		NZSound.PapLoop,
		NZSound.PapJingle,
		NZSound.PapSting,
		NZSound.PapShoot,
		NZSound.MachineBump,
		NZSound.KnifeFlesh,

		NZSound.NapalmIdle,
		NZSound.NapalmClose,
		NZSound.NapalmBehind,
		NZSound.NapalmHit,
		NZSound.NapalmSpawn,
		NZSound.NapalmStep,
		NZSound.NapalmCharge,
		NZSound.NapalmExplode,
		NZSound.NapalmFlare,
		NZSound.NapalmLoop,

		NZSound.ShriekerIdle,
		NZSound.ShriekerClose,
		NZSound.ShriekerSpawn,
		NZSound.ShriekerDeath,
		NZSound.ShriekerCharge,
		NZSound.ShriekerScream,
		NZSound.ShriekerExplode,

		NZSound.PestIdle,
		NZSound.PestSprint,
		NZSound.PestAttack,
		NZSound.PestHit,
		NZSound.PestDeath,
	};
}