Audio/RoomSoundscape.cs

A game audio system for the Basalt map that manages per-room soundscapes: room-specific DSP echo presets applied on the Game mixer, a looping room tone, and occasional placed ambient one-shot sounds positioned around the listener. It selects profiles by HUD room name, fades DSP processors in and out, places spatial sounds by casting/reaching into the world, and exposes console commands to inspect and tweak behaviour.

Native InteropFile AccessNetworking
using Sandbox;
using Sandbox.Audio;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// BASALT'S ROOMS, HEARD — each named room with its own echo, its own tone under the bed, and sounds that happen somewhere in
/// it: lava breaking below the bridge, a stone settling in the wall, water dripping in the Reliquary, the engine's metal ticking.
/// Asked for as *"let's start with sound / i love / 8, 9, 10"* (2026-09-28): an echo per room, a tone per room, sounds with a
/// place. The sounds are made by `Tools/basalt_soundscape.py`.
///
/// ⛔ THE ECHO IS OFF (2026-09-28): it lies over everything under Game, the guns and the zombies too, and the user wants those
/// dry — *"i wanted the tone, drips creaks and lava gloops / i just did not want to affect the gun and zombie sounds etc with the
/// echo"*. The tones and the placed sounds play as before. `nz_room_echo auto` still auditions the room table's echo, over
/// everything, for the session. (The whole soundscape was taken out at 21:03 on a misreading of that, and put back at 21:40.)
///
/// ⛔ THE ECHO IS THE ENGINE'S OWN DSP, ON THE GAME MIXER, the way `DspVolumeGameSystem` applies a `DspVolume`'s: a
/// `DspProcessor` per preset, faded by its `Mix` (the mixer blends `dry × (1 − Mix) + processed × Mix`), added with
/// `Mixer.AddProcessor`, and taken off once it has faded out. A room change fades the new preset in while the old one fades out;
/// both sit on the mixer for that moment. A mixer's processors cover its whole subtree (`Mixer.ApplyProcessors`): weapons,
/// zombies, impacts, the voice chat, the world, and the footsteps the engine plays straight onto Game. Music, the announcer and
/// the UI are Game's siblings and stay dry.
///
/// ⚠️ NO VOLUMES IN THE SCENE, BECAUSE THE ROOMS ARE ALREADY KNOWN. `RoomNames.CurrentName` is the room this machine's player is
/// in: a drawn zone, or else the last door walked through. It is the name on the HUD, so the echo changes where the name does.
///
/// ⚠️ EACH MACHINE, FOR ITS OWN LISTENER. Nothing crosses the wire. The tone is 2D and a one-shot is placed around this machine's
/// ear, so two players in two rooms each hear their own.
///
/// ⚠️ BASALT ONLY, AND ONLY IN A GAME, by the bed's rule (`MapAmbience`): not in the lobby, not under the loading screen, not in
/// creative. `nz_soundscape` reports what it is doing.
/// </summary>
public sealed class RoomSoundscape : GameObjectSystem<RoomSoundscape>
{
	public RoomSoundscape( Scene scene ) : base( scene )
	{
		Listen( Stage.StartUpdate, 0, Tick, "nz.roomsoundscape" );
	}

	/// <summary>
	/// ⛔ THE PROCESSORS COME OFF THE MIXER WITH THE SCENE. The mixers are the project's, not the scene's, so an echo left on
	/// Game would go on colouring every sound after play stops, including in the editor.
	/// </summary>
	public override void Dispose()
	{
		Silence();
		base.Dispose();
	}

	// ══ tuning ═══════════════════════════════════════════════════════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED — INSTRUCTIONS.md §1: a static keeps its value through a hotload and its initialiser does not run again.

	static bool? _on;
	/// <summary>The whole soundscape. `nz_soundscape 0` turns it off for this session.</summary>
	public static bool Enabled { get => _on ?? true; set => _on = value; }

	static bool? _echoOn;
	/// <summary>The rooms' echo — ⛔ OFF BY DEFAULT, see the class notes: it would reach the guns and the zombies. `nz_room_echo auto` / `off`.</summary>
	public static bool EchoOn { get => _echoOn ?? false; set => _echoOn = value; }

	static string _echoPreset;
	/// <summary>A preset heard in every room instead of each room's own, to audition one (`nz_room_echo cavern.large`). Blank:
	/// each room's own.</summary>
	public static string EchoPreset { get => _echoPreset ?? ""; set => _echoPreset = value; }

	static float? _echoMix;
	/// <summary>A mix used in every room instead of each room's own; below 0, each room's own.</summary>
	public static float EchoMix { get => _echoMix ?? -1f; set => _echoMix = value; }

	static float? _echoScale;
	/// <summary>Every room's echo, scaled. 1 = as the table sets it (`nz_room_echo_scale`).</summary>
	public static float EchoScale { get => _echoScale ?? 1f; set => _echoScale = value; }

	static float? _echoFade;
	/// <summary>How long a room's echo takes to come in, and the last one to go, in seconds.</summary>
	public static float EchoFadeSeconds { get => _echoFade ?? 1.5f; set => _echoFade = value; }

	static float? _toneScale;
	/// <summary>The room tones' volume. 1 = as the sound events set it; 0 = none (`nz_room_tone`).</summary>
	public static float ToneScale { get => _toneScale ?? 1f; set => _toneScale = value; }

	static float? _toneFade;
	/// <summary>How long one room's tone takes to give way to the next, in seconds.</summary>
	public static float ToneFadeSeconds { get => _toneFade ?? 3f; set => _toneFade = value; }

	static float? _soundsScale;
	/// <summary>How often the rooms' sounds come. 1 = as the table sets it, 2 = twice as often, 0 = none (`nz_room_sounds`).</summary>
	public static float SoundsScale { get => _soundsScale ?? 1f; set => _soundsScale = value; }

	static string _as;
	/// <summary>Heard as if standing in this room, wherever the player is (`nz_room_as`). Blank: where the player is.</summary>
	public static string HearAs { get => _as ?? ""; set => _as = value; }

	/// <summary>A one-shot comes no nearer than this to the ear, in units. Closer, it would sound as if it were on the player.</summary>
	const float MinDistance = 250f;

	/// <summary>What a trace for a one-shot's place passes through.</summary>
	static readonly string[] PassThrough = { "player", "zombie", "trigger", "corpse", "ragdoll" };

	// ══ the rooms ════════════════════════════════════════════════════════════════════════════════════════════════════════════

	/// <summary>Where a room's one-shot is placed.</summary>
	public enum Where
	{
		/// <summary>At one of the config's placed lava loops, 300 to 2,200 units off; below the ear if there is none.</summary>
		Lava,
		/// <summary>Under the floor, near the ear: the Conduit's river.</summary>
		Below,
		/// <summary>In the stone of a wall, where a level-ish ray from the ear meets it.</summary>
		Wall,
		/// <summary>In the ceiling or high on a wall, where a rising ray meets it.</summary>
		Ceiling,
		/// <summary>On the floor, a few hundred units off.</summary>
		Floor,
		/// <summary>Far away, 1,800 to 2,600 units, through whatever rock lies between.</summary>
		Far,
		/// <summary>Behind the listener, a few hundred units off.</summary>
		Behind,
		/// <summary>In the open air of the room, 600 to 1,400 units off.</summary>
		Air,
	}

	/// <summary>One room as heard: its echo and the mix of it, and what happens in it — every `GapMin`..`GapMax` seconds, one of
	/// `Sounds` by weight, at its `Where`. `Key` names its tone (`nz.basalt.roomtone.` + the key); blank has none.</summary>
	public sealed record Profile( string Key, string Echo, float Mix, float GapMin, float GapMax,
		(string Kind, float Weight, Where At)[] Sounds );

	/// <summary>
	/// The room that a HUD name is.
	///
	/// ⚠️ A SWITCH THAT BUILDS, NOT A TABLE THAT HOLDS (INSTRUCTIONS.md §1): a static table keeps its old values through a hotload.
	/// This one is asked when the name changes and once a second, so an edit here is heard within a second.
	///
	/// ⚠️ BOTH SPELLINGS OF A ROOM. The HUD shows a drawn zone's name where one is drawn, and a door's where none is. Two of basalt's
	/// zones are named apart from their doors: "Lava Bridge" is the Causeway, and "Colapsed Stairway" (the config's spelling) is
	/// the Ember Stair.
	///
	/// ⚠️ THE PRESETS ARE THE ENGINE'S (`Sound.DspNames`, `nz_room_echo_list`), chosen from what `DspFactory` builds each one of:
	/// the caverns carry a real delay (150, 200 and 300 ms, with feedback) under their reverb, the chambers a modulated reverb,
	/// concrete a diffuse one that darkens with size, `room.empty.huge` the longest tail there is. The mixes are first guesses,
	/// meant to be set by ear with `nz_room_echo`.
	/// </summary>
	public static Profile ProfileFor( string name ) => (name ?? "").Trim().ToLowerInvariant() switch
	{
		"waygate" => new( "waygate", "room.empty.huge", 0.30f, 9f, 17f, new[]
		{
			("creak", 2f, Where.Wall), ("gust", 2f, Where.Air), ("rockfall", 1f, Where.Ceiling), ("rumble", 1f, Where.Far),
			("ember", 1f, Where.Floor),
		} ),
		"crucible" => new( "crucible", "cavern.medium", 0.40f, 4f, 10f, new[]
		{
			("lava_gloop", 4f, Where.Lava), ("ember", 3f, Where.Floor), ("steam", 2f, Where.Wall), ("lava_surge", 1f, Where.Lava),
			("rockfall", 1f, Where.Ceiling),
		} ),
		"causeway" or "lava bridge" or "lavabridge" => new( "causeway", "cavern.large", 0.50f, 4f, 10f, new[]
		{
			("lava_gloop", 3f, Where.Lava), ("lava_surge", 2f, Where.Lava), ("rumble", 2f, Where.Far), ("rockfall", 2f, Where.Ceiling),
			("gust", 2f, Where.Air), ("steam", 1f, Where.Lava),
		} ),
		"nexus" => new( "nexus", "concrete.medium", 0.40f, 7f, 15f, new[]
		{
			("gust", 3f, Where.Air), ("creak", 2f, Where.Wall), ("rockfall", 1f, Where.Ceiling), ("rumble", 1f, Where.Far),
			("drip", 1f, Where.Floor),
		} ),
		"ember stair" or "emberstair" or "colapsed stairway" or "collapsed stairway" => new( "emberstair", "chamber.medium", 0.40f, 5f, 11f, new[]
		{
			("rockfall", 4f, Where.Ceiling), ("creak", 2f, Where.Wall), ("ember", 2f, Where.Floor), ("gust", 1f, Where.Air),
		} ),
		"reliquary" => new( "reliquary", "concrete.large", 0.40f, 3f, 8f, new[]
		{
			("drip", 5f, Where.Floor), ("creak", 2f, Where.Wall), ("rockfall", 1f, Where.Ceiling), ("whisper", 1f, Where.Behind),
		} ),
		"magma engine" or "engine" => new( "engine", "metallic.medium", 0.35f, 4f, 9f, new[]
		{
			("clank", 4f, Where.Wall), ("steam", 3f, Where.Wall), ("creak", 1f, Where.Wall), ("lava_gloop", 1f, Where.Lava),
			("arc", 1f, Where.Wall),
		} ),
		"conduit" => new( "conduit", "tunnel.medium", 0.40f, 5f, 11f, new[]
		{
			("lava_gloop", 2f, Where.Below), ("steam", 2f, Where.Wall), ("arc", 2f, Where.Wall), ("clank", 1f, Where.Wall),
			("rumble", 1f, Where.Far),
		} ),
		"sanctum" => new( "sanctum", "chamber.large", 0.45f, 8f, 16f, new[]
		{
			("whisper", 3f, Where.Behind), ("creak", 2f, Where.Wall), ("drip", 1f, Where.Floor), ("rumble", 1f, Where.Far),
		} ),
		"relay" => new( "relay", "cavern.small", 0.40f, 6f, 12f, new[]
		{
			("arc", 3f, Where.Wall), ("clank", 2f, Where.Wall), ("creak", 1f, Where.Wall), ("rockfall", 1f, Where.Ceiling),
		} ),

		// ⚠️ A NAME THE TABLE DOES NOT KNOW — a room added later, or renamed: the mountain's echo and its settling stone, no tone
		_ => new( "", "cavern.medium", 0.35f, 9f, 18f, new[]
		{
			("rumble", 1f, Where.Far), ("creak", 1f, Where.Wall), ("rockfall", 1f, Where.Ceiling),
		} ),
	};

	// ══ state (this scene's) ═════════════════════════════════════════════════════════════════════════════════════════════════

	/// <summary>The echo's processors on the Game mixer, newest last: the one coming in, and any still fading out.</summary>
	readonly List<DspProcessor> _stages = new();
	Mixer _mixer;

	string _roomName;
	Profile _room;
	RealTimeSince _sinceRoom;

	SoundHandle _tone;
	string _toneCue;
	float _toneLevel, _toneVol;

	/// <summary>The tones fading out: the room before, and the one before that if the player crossed two rooms inside a fade.</summary>
	readonly List<(SoundHandle Handle, string Cue, float Level, float Vol)> _fading = new();

	RealTimeUntil _nextShot;
	string _shotRoom;
	int _shots;
	string _lastShot = "";

	bool _swept;

	// ══ every frame ══════════════════════════════════════════════════════════════════════════════════════════════════════════

	/// <summary>Is this the moment for the rooms: basalt, a game on, the lobby closed, no loading screen.</summary>
	static bool Wanted => Enabled && NZGame.Mode == GameMode.Survival && LobbyState.IsOpen?.Invoke() != true
		&& !MapLoading.Active && OnBasalt;

	/// <summary>
	/// Basalt or not, asked every two seconds. ⚠️ NOT EVERY FRAME: `NZMap.Current` finds the scene's `MapInstance` by searching
	/// every component in the scene, each time it is asked.
	/// </summary>
	static bool OnBasalt
	{
		get
		{
			if ( _basaltAsked is not { } asked || asked > 2f )
			{
				_onBasalt = HexPlatforms.OnBasalt;
				_basaltAsked = 0f;
			}

			return _onBasalt;
		}
	}

	static bool _onBasalt;
	static RealTimeSince? _basaltAsked;

	void Tick()
	{
		if ( Scene is null || Scene.IsEditor ) return;

		var on = Wanted;
		if ( on && !_swept ) SweepStrays();
		_swept = on;

		var name = string.IsNullOrWhiteSpace( HearAs ) ? RoomNames.CurrentName : HearAs;
		if ( _room is null || name != _roomName || _sinceRoom > 1f )
		{
			_roomName = name;
			_room = ProfileFor( name );
			_sinceRoom = 0f;
		}

		TickEcho( on ? _room : null );
		TickTone( on ? _room : null );
		if ( on ) TickShots( _room );
		else _shotRoom = null;
	}

	// ── the echo ─────────────────────────────────────────────────────────────────────────────────────────────────────────────

	void TickEcho( Profile room )
	{
		// ⚠️ LOOKED UP EVERY FRAME, NOT HELD (`MixerBus.Find`): a mixer outlives its graph when the project settings reload, and a
		// stale one takes processors that nothing hears. A new Game mixer takes the stages over from the old one.
		var m = MixerBus.Find( MixerBus.Game );
		if ( !ReferenceEquals( m, _mixer ) )
		{
			if ( _mixer is not null ) foreach ( var p in _stages ) Detach( _mixer, p );
			_mixer = m;
			if ( m is not null ) foreach ( var p in _stages ) m.AddProcessor( p );
		}
		if ( m is null ) return;

		string want = null;
		var mix = 0f;
		if ( room is not null && EchoOn )
		{
			want = string.IsNullOrWhiteSpace( EchoPreset ) ? room.Echo : EchoPreset.Trim();
			mix = Math.Clamp( (EchoMix >= 0f ? EchoMix : room.Mix) * EchoScale, 0f, 1f );
		}

		DspProcessor current = null;
		if ( !string.IsNullOrEmpty( want ) && mix > 0f )
		{
			// ⚠️ A STAGE STILL FADING OUT IS TAKEN BACK, not doubled: stepping back over a doorway fades the same echo up again
			current = _stages.LastOrDefault( p => p.Effect.Name == want );
			if ( current is null )
			{
				current = new DspProcessor( want ) { Mix = 0f };
				_stages.Add( current );
				m.AddProcessor( current );
			}
		}

		// ⚠️ EVERY FADE TAKES `EchoFadeSeconds`, WHATEVER THE MIX: the step is a share of the larger of the two mixes
		var step = RealTime.Delta / MathF.Max( 0.05f, EchoFadeSeconds );
		for ( var i = _stages.Count - 1; i >= 0; i-- )
		{
			var p = _stages[i];
			var target = ReferenceEquals( p, current ) ? mix : 0f;
			p.Mix = Toward( p.Mix, target, step * MathF.Max( 0.2f, MathF.Max( mix, p.Mix ) ) );

			if ( !ReferenceEquals( p, current ) && p.Mix <= 0f )
			{
				Detach( m, p );
				_stages.RemoveAt( i );
			}
		}
	}

	static void Detach( Mixer m, DspProcessor p )
	{
		try { m.RemoveProcessor( p ); }
		catch ( Exception e ) { Log.Warning( $"[nz-soundscape] could not take '{p.Effect.Name}' off {m.Name}: {e.Message}" ); }
	}

	/// <summary>
	/// ⚠️ ONCE, AS THE ROOMS COME ON: any DSP left on Game by a scene that never disposed (a crash, a hotload that lost this
	/// system's list) comes off, so an old echo cannot sit under the new one for the rest of the session. Left alone if the scene
	/// has a `DspVolume`, since that DSP is the engine's own.
	/// </summary>
	void SweepStrays()
	{
		var m = MixerBus.Find( MixerBus.Game );
		if ( m is null ) return;
		if ( Scene.GetAllComponents<DspVolume>().Any() ) return;

		foreach ( var p in m.GetProcessors().OfType<DspProcessor>().ToArray() )
		{
			if ( _stages.Contains( p ) ) continue;
			Detach( m, p );
			Log.Info( $"[nz-soundscape] took a stray '{p.Effect.Name}' echo off Game" );
		}
	}

	// ── the tone ─────────────────────────────────────────────────────────────────────────────────────────────────────────────

	void TickTone( Profile room )
	{
		var want = room is not null && !string.IsNullOrEmpty( room.Key ) && ToneScale > 0f ? "nz.basalt.roomtone." + room.Key : null;

		if ( want != _toneCue )
		{
			// the tone playing now fades out on its own; ⚠️ ONE STILL FADING THAT IS WANTED AGAIN IS TAKEN BACK where its fade has
			// got to — stepping back over a doorway brings the room's tone back up, it does not start it again from silence
			if ( _tone.IsValid() ) _fading.Add( (_tone, _toneCue, _toneLevel, _toneVol) );
			_tone = default;
			_toneCue = want;
			_toneLevel = 0f;

			var back = _fading.FindLastIndex( f => f.Cue == want );
			if ( back >= 0 )
			{
				(_tone, _, _toneLevel, _toneVol) = _fading[back];
				_fading.RemoveAt( back );
			}
		}

		var step = RealTime.Delta / MathF.Max( 0.05f, ToneFadeSeconds );

		for ( var i = _fading.Count - 1; i >= 0; i-- )
		{
			var f = _fading[i];
			f.Level = Toward( f.Level, 0f, step );
			if ( f.Level <= 0f || !f.Handle.IsValid() || !f.Handle.IsPlaying )
			{
				var h = f.Handle;
				Stop( ref h );
				_fading.RemoveAt( i );
				continue;
			}

			f.Handle.Volume = f.Vol * f.Level * ToneScale * NZSound.VolumeOf( f.Cue );
			_fading[i] = f;
		}

		if ( want is null )
		{
			Stop( ref _tone );
			return;
		}

		_toneLevel = Toward( _toneLevel, 1f, step );

		// ⚠️ IT RESTARTS ITSELF WHEN IT ENDS, as the bed does (there is no loop flag). The file is made to meet its own start.
		if ( !_tone.IsValid() || !_tone.IsPlaying )
		{
			if ( !NZSound.Enabled || !NZSound.Exists( want ) ) return;

			_tone = Sound.Play( want );
			if ( !_tone.IsValid() ) return;
			_toneVol = _tone.Volume;
		}

		_tone.Volume = _toneVol * _toneLevel * ToneScale * NZSound.VolumeOf( want );
	}

	static void Stop( ref SoundHandle h )
	{
		if ( h.IsValid() ) h.Stop();
		h = default;
	}

	// ── the sounds with a place ──────────────────────────────────────────────────────────────────────────────────────────────

	void TickShots( Profile room )
	{
		if ( SoundsScale <= 0f || room.Sounds.Length == 0 ) return;

		// ⚠️ A NEW ROOM IS HEARD SOON: the first of its sounds comes within a few seconds of walking in, not after a full gap
		if ( room.Key != _shotRoom )
		{
			_shotRoom = room.Key;
			_nextShot = Game.Random.Float( 2f, 5f ) / SoundsScale;
			return;
		}

		if ( _nextShot > 0f ) return;

		_nextShot = Game.Random.Float( room.GapMin, room.GapMax ) / SoundsScale;
		PlayOne( room, null );
	}

	/// <summary>One of the room's sounds — by weight, or the kind named — at its place. What played and where, for the console.</summary>
	public string PlayOne( Profile room, string kind )
	{
		(string Kind, float Weight, Where At) pick;
		if ( !string.IsNullOrWhiteSpace( kind ) )
		{
			kind = kind.Trim().ToLowerInvariant();
			pick = room.Sounds.FirstOrDefault( s => s.Kind == kind );
			if ( pick.Kind is null ) pick = (kind, 1f, Where.Wall);
		}
		else pick = ByWeight( room.Sounds );

		var cue = "nz.basalt.sfx." + pick.Kind;
		if ( !NZSound.Exists( cue ) ) return $"no asset for '{cue}' (Tools/basalt_soundscape.py makes them)";

		var at = PlaceFor( pick.At );
		if ( at is null ) return $"nowhere to put '{cue}'";

		NZSound.Play( cue, at.Value );
		_shots++;
		_lastShot = $"{pick.Kind} ({pick.At}) {at.Value.Distance( NZSound.Ear ):0}u away";
		return _lastShot;
	}

	static (string Kind, float Weight, Where At) ByWeight( (string Kind, float Weight, Where At)[] list )
	{
		var total = 0f;
		foreach ( var s in list ) total += MathF.Max( 0f, s.Weight );

		var r = Game.Random.Float( 0f, total );
		foreach ( var s in list )
		{
			r -= MathF.Max( 0f, s.Weight );
			if ( r <= 0f ) return s;
		}

		return list[^1];
	}

	Vector3? PlaceFor( Where where )
	{
		var ear = Sound.Listener.Position;

		switch ( where )
		{
			case Where.Lava:
				{
					// ⚠️ THE CONFIG'S OWN LAVA LOOPS SAY WHERE THE LAVA IS — basalt places them over it, 79 of them
					var spots = ActiveConfig.Current?.Sounds;
					if ( spots is not null )
					{
						var near = new List<Vector3>();
						foreach ( var s in spots )
						{
							if ( s is null || !(s.Sound ?? "").Contains( "lava", StringComparison.OrdinalIgnoreCase ) ) continue;
							var d = s.Position.Distance( ear );
							if ( d > 300f && d < 2200f ) near.Add( s.Position );
						}

						if ( near.Count > 0 ) return near[Game.Random.Next( near.Count )] + Around( 0f, 150f );
					}

					return ear + Around( 600f, 1400f ) + Vector3.Down * 250f;
				}

			case Where.Below:
				return ear + Around( 60f, 300f ) + Vector3.Down * 200f;

			case Where.Wall:
				return Cast( ear, -0.05f, 0.3f, 1500f ) ?? Reach( ear, ear + Around( 700f, 1100f ) );

			case Where.Ceiling:
				return Cast( ear, 0.45f, 0.9f, 1400f ) ?? Reach( ear, ear + Around( 200f, 600f ) + Vector3.Up * 400f );

			case Where.Floor:
				{
					var p = Reach( ear, ear + Around( 300f, 900f ) );
					var tr = Scene.Trace.Ray( p, p + Vector3.Down * 700f ).WithoutTags( PassThrough ).Run();
					return tr.Hit ? tr.HitPosition + Vector3.Up * 8f : p + Vector3.Down * 150f;
				}

			case Where.Far:
				return ear + Around( 1800f, 2600f ) + Vector3.Up * Game.Random.Float( -100f, 300f );

			case Where.Behind:
				{
					var fwd = Sound.Listener.Rotation.Forward.WithZ( 0f );
					fwd = fwd.Length < 0.01f ? Vector3.Forward : fwd.Normal;
					var right = new Vector3( fwd.y, -fwd.x, 0f );
					var p = ear - fwd * Game.Random.Float( 250f, 450f ) + right * Game.Random.Float( -180f, 180f );
					return Reach( ear, p );
				}

			default:
				return Reach( ear, ear + Around( 600f, 1400f ) + Vector3.Up * Game.Random.Float( 50f, 200f ) );
		}
	}

	/// <summary>
	/// Where a ray out from the ear, at a random bearing and a rise between `zMin` and `zMax`, meets stone within `reach`: pulled
	/// back 24 units off the face, so the sound is IN the room and not inside the rock. Four tries; null if every ray found open
	/// air, or stone nearer than `MinDistance`.
	/// </summary>
	Vector3? Cast( Vector3 ear, float zMin, float zMax, float reach )
	{
		for ( var i = 0; i < 4; i++ )
		{
			var yaw = Game.Random.Float( 0f, MathF.PI * 2f );
			var dir = new Vector3( MathF.Cos( yaw ), MathF.Sin( yaw ), Game.Random.Float( zMin, zMax ) ).Normal;
			var tr = Scene.Trace.Ray( ear, ear + dir * reach ).WithoutTags( PassThrough ).Run();
			if ( !tr.Hit || tr.Distance < MinDistance ) continue;

			return tr.HitPosition - dir * 24f;
		}

		return null;
	}

	/// <summary>
	/// `to`, or where the way there is first blocked (32 units short of it), so a place does not end inside a wall. ⚠️ NEVER NEARER
	/// THAN `MinDistance`: a wall close by leaves the sound beyond it, where the occlusion muffles it as rock would.
	/// </summary>
	Vector3 Reach( Vector3 from, Vector3 to )
	{
		var tr = Scene.Trace.Ray( from, to ).WithoutTags( PassThrough ).Run();
		if ( !tr.Hit ) return to;

		var dir = (to - from).Normal;
		var d = MathF.Max( tr.Distance - 32f, MathF.Min( MinDistance, (to - from).Length ) );
		return from + dir * d;
	}

	/// <summary>A level offset at a random bearing, between `min` and `max` long.</summary>
	static Vector3 Around( float min, float max )
	{
		var yaw = Game.Random.Float( 0f, MathF.PI * 2f );
		var d = Game.Random.Float( min, max );
		return new Vector3( MathF.Cos( yaw ) * d, MathF.Sin( yaw ) * d, 0f );
	}

	static float Toward( float v, float target, float step )
		=> v < target ? MathF.Min( target, v + step ) : MathF.Max( target, v - step );

	/// <summary>Everything off, now: the echo off the mixer, the tones stopped.</summary>
	void Silence()
	{
		var m = _mixer ?? MixerBus.Find( MixerBus.Game );
		if ( m is not null ) foreach ( var p in _stages ) Detach( m, p );
		_stages.Clear();
		_mixer = null;
		Stop( ref _tone );
		foreach ( var f in _fading )
		{
			var h = f.Handle;
			Stop( ref h );
		}
		_fading.Clear();
		_toneCue = null;
		_toneLevel = 0f;
	}

	// ══ the console ══════════════════════════════════════════════════════════════════════════════════════════════════════════

	/// <summary>`nz_soundscape [0|1]` — the rooms on or off for this session; bare, what they are doing.</summary>
	[ConCmd( "nz_soundscape" )]
	public static void Cmd( int on = -1 )
	{
		if ( on >= 0 ) Enabled = on != 0;

		var s = Current;
		if ( s is null ) { Log.Info( "[nz-soundscape] no scene" ); return; }

		var room = s._room ?? ProfileFor( RoomNames.CurrentName );
		Log.Info( $"[nz-soundscape] {(Enabled ? "on" : "OFF")} · {(Wanted ? "playing" : "idle (basalt, in a game only)")}"
			+ $" · room '{s._roomName}' -> {(string.IsNullOrEmpty( room.Key ) ? "(default)" : room.Key)}"
			+ (string.IsNullOrWhiteSpace( HearAs ) ? "" : $" (heard as '{HearAs}': nz_room_as auto to stop)") );

		var m = MixerBus.Find( MixerBus.Game );
		var stages = s._stages.Count == 0 ? "none" : string.Join( ", ", s._stages.Select( p => $"{p.Effect.Name} at {p.Mix:0.00}" ) );
		Log.Info( $"[nz-soundscape]   echo {(EchoOn ? "on" : "OFF")} · {stages} · Game mixer has {m?.ProcessorCount ?? -1} processor(s)"
			+ $" · room's own {room.Echo} at {room.Mix:0.00}, scale {EchoScale:0.00}"
			+ (string.IsNullOrWhiteSpace( EchoPreset ) ? "" : $", FORCED {EchoPreset}") + (EchoMix >= 0f ? $", mix forced {EchoMix:0.00}" : "") );
		Log.Info( $"[nz-soundscape]   tone {(s._tone.IsValid() && s._tone.IsPlaying ? $"'{s._toneCue}' at {s._toneLevel:0.00}" : "none")}"
			+ $" · x{ToneScale:0.00}" + string.Concat( s._fading.Select( f => $" · '{f.Cue}' fading at {f.Level:0.00}" ) ) );
		Log.Info( $"[nz-soundscape]   sounds x{SoundsScale:0.00} · every {room.GapMin:0}-{room.GapMax:0} s · next in {MathF.Max( 0f, s._nextShot ):0.0} s"
			+ $" · {s._shots} played · last: {(s._lastShot.Length == 0 ? "-" : s._lastShot)}" );
	}

	/// <summary>`nz_room_echo [preset|auto|off|on] [mix]` — audition an echo in every room, or go back to each room's own.</summary>
	[ConCmd( "nz_room_echo" )]
	public static void CmdEcho( string preset = "", float mix = -1f )
	{
		switch ( (preset ?? "").Trim().ToLowerInvariant() )
		{
			case "": break;
			case "off" or "0": EchoOn = false; break;
			case "on" or "1": EchoOn = true; break;
			case "auto": EchoOn = true; EchoPreset = ""; EchoMix = -1f; break;
			default:
				if ( !Sound.DspNames.Contains( preset.Trim() ) )
				{
					Log.Warning( $"[nz-soundscape] no preset '{preset}' — nz_room_echo_list lists them" );
					return;
				}
				EchoOn = true;
				EchoPreset = preset.Trim();
				break;
		}

		if ( mix >= 0f ) EchoMix = Math.Clamp( mix, 0f, 1f );
		Cmd();
	}

	/// <summary>`nz_room_echo_scale &lt;mult&gt;` — every room's echo mix, scaled (0.5 halves it, 1 is the table's).</summary>
	[ConCmd( "nz_room_echo_scale" )]
	public static void CmdEchoScale( float mult = 1f )
	{
		EchoScale = MathF.Max( 0f, mult );
		Cmd();
	}

	/// <summary>`nz_room_echo_list` — the engine's DSP presets, and which room uses which.</summary>
	[ConCmd( "nz_room_echo_list" )]
	public static void CmdEchoList()
	{
		Log.Info( $"[nz-soundscape] presets: {string.Join( ", ", Sound.DspNames )}" );
		foreach ( var n in new[] { "Waygate", "Crucible", "Causeway", "Nexus", "Ember Stair", "Reliquary", "Magma Engine", "Conduit", "Sanctum", "Relay", "" } )
		{
			var p = ProfileFor( n );
			Log.Info( $"[nz-soundscape]   {(n.Length == 0 ? "(any other)" : n),-14} {p.Echo,-16} {p.Mix:0.00}   tone {(p.Key.Length == 0 ? "-" : p.Key),-11}"
				+ $" sounds every {p.GapMin:0}-{p.GapMax:0} s: {string.Join( ", ", p.Sounds.Select( s => $"{s.Kind} {s.Weight:0}" ) )}" );
		}
	}

	/// <summary>`nz_room_tone &lt;mult&gt;` — the room tones' volume (0 = none, 1 = as authored).</summary>
	[ConCmd( "nz_room_tone" )]
	public static void CmdTone( float mult = 1f )
	{
		ToneScale = MathF.Max( 0f, mult );
		Cmd();
	}

	/// <summary>`nz_room_sounds &lt;mult&gt;` — how often the rooms' sounds come (0 = none, 2 = twice as often).</summary>
	[ConCmd( "nz_room_sounds" )]
	public static void CmdSounds( float mult = 1f )
	{
		SoundsScale = MathF.Max( 0f, mult );
		Cmd();
	}

	/// <summary>`nz_room_sound [kind]` — one of this room's sounds now, or the kind named (lava_gloop, lava_surge, steam, rockfall,
	/// creak, rumble, drip, clank, arc, ember, whisper, gust).</summary>
	[ConCmd( "nz_room_sound" )]
	public static void CmdSound( string kind = "" )
	{
		var s = Current;
		if ( s is null ) { Log.Info( "[nz-soundscape] no scene" ); return; }

		var room = s._room ?? ProfileFor( RoomNames.CurrentName );
		Log.Info( $"[nz-soundscape] {s.PlayOne( room, kind )}" );
	}

	/// <summary>`nz_room_as &lt;room|auto&gt;` — hear the rooms as if standing in that one ("Sanctum", "Lava Bridge"...), to compare
	/// them without walking; `auto` goes back to where the player is.</summary>
	[ConCmd( "nz_room_as" )]
	public static void CmdAs( string room = "auto" )
	{
		HearAs = (room ?? "").Trim().Equals( "auto", StringComparison.OrdinalIgnoreCase ) ? "" : room.Trim();
		Cmd();
	}
}