diff --git a/Content.Client/_Scp/Audio/AudioEchoSystem.cs b/Content.Client/_Scp/Audio/AudioEchoSystem.cs
new file mode 100644
index 00000000000..dac4dd88885
--- /dev/null
+++ b/Content.Client/_Scp/Audio/AudioEchoSystem.cs
@@ -0,0 +1,508 @@
+// SPDX-FileCopyrightText: 2025 LaCumbiaDelCoronavirus
+// SPDX-FileCopyrightText: 2025 ark1368
+//
+// SPDX-License-Identifier: MPL-2.0
+
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.Contracts;
+using System.Linq;
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using Content.Client.Light.EntitySystems;
+using Content.Shared._Scp.ScpCCVars;
+using Content.Shared.Light.Components;
+using Content.Shared.Physics;
+using Robust.Client.GameObjects;
+using Robust.Shared.Audio;
+using Robust.Shared.Audio.Components;
+using Robust.Shared.Configuration;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Physics;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+using DependencyAttribute = Robust.Shared.IoC.DependencyAttribute;
+
+namespace Content.Client._Scp.Audio;
+
+///
+/// Handles making sounds 'echo' in large, open spaces. Uses simplified raytracing.
+///
+// could use RaycastSystem but the api it has isn't very amazing
+public sealed class AreaEchoSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+ [Dependency] private readonly MapSystem _map = default!;
+ [Dependency] private readonly Robust.Client.Physics.PhysicsSystem _physics = default!;
+ [Dependency] private readonly TransformSystem _transform = default!;
+ [Dependency] private readonly AudioEffectSystem _audioEffect = default!;
+ [Dependency] private readonly RoofSystem _roof = default!;
+
+ ///
+ /// The directions that are raycasted to determine size for echo.
+ /// Used relative to the grid.
+ ///
+ private Angle[] _calculatedDirections =
+ [
+ Direction.North.ToAngle(),
+ Direction.West.ToAngle(),
+ Direction.South.ToAngle(),
+ Direction.East.ToAngle(),
+ ];
+
+ ///
+ /// Values for the minimum arbitrary size at which a certain audio preset
+ /// is picked for sounds. The higher the highest distance here is,
+ /// the generally more calculations it has to do.
+ ///
+ ///
+ /// Keep in descending order.
+ ///
+ private static readonly (float Distance, ProtoId Preset)[] DistancePresets =
+ [
+ (14f, "Hangar"),
+ (12f, "ConcertHall"),
+ (8f, "Auditorium"),
+ (3f, "Hallway"),
+ ];
+
+ private const float BounceDampening = 0.1f;
+
+ ///
+ /// When is the next time we should check all audio entities and see if they are eligible to be updated.
+ ///
+ private TimeSpan _nextExistingUpdate = TimeSpan.Zero;
+
+ ///
+ /// Collision mask for echoes.
+ ///
+ private const int EchoLayer = (int) (CollisionGroup.Opaque | CollisionGroup.Impassable); // this could be better but whatever
+
+ private int _echoMaxReflections;
+ private bool _echoEnabled = true;
+ private TimeSpan _calculationInterval; // how often we should check existing audio re-apply or remove echo from them when necessary
+ private float _calculationalFidelity;
+
+ private ConfigurationMultiSubscriptionBuilder _configSub = default!;
+
+ private EntityQuery _gridQuery;
+ private EntityQuery _roofQuery;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ UpdatesBefore.Add(typeof(AudioMuffleSystem));
+
+ _configSub = _cfg.SubscribeMultiple()
+ .OnValueChanged(ScpCCVars.EchoReflectionCount, x => _echoMaxReflections = x, invokeImmediately: true)
+ .OnValueChanged(ScpCCVars.EchoEnabled, x => _echoEnabled = x, invokeImmediately: true)
+ .OnValueChanged(ScpCCVars.EchoHighResolution,
+ x => _calculatedDirections = GetEffectiveDirections(x),
+ invokeImmediately: true)
+ .OnValueChanged(ScpCCVars.EchoRecalculationInterval,
+ x => _calculationInterval = TimeSpan.FromSeconds(x),
+ invokeImmediately: true)
+ .OnValueChanged(ScpCCVars.EchoStepFidelity, x => _calculationalFidelity = x, invokeImmediately: true);
+
+ _gridQuery = GetEntityQuery();
+ _roofQuery = GetEntityQuery();
+
+ SubscribeLocalEvent(OnAudioParentChanged);
+
+ Log.Level = LogLevel.Verbose;
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+
+ _configSub.Dispose();
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ if (!_echoEnabled)
+ return;
+
+ if (_timing.CurTime < _nextExistingUpdate)
+ return;
+
+ _nextExistingUpdate = _timing.CurTime + _calculationInterval;
+
+ var (minimumMagnitude, maximumMagnitude) = GetMinMax();
+
+ var audioEnumerator = AllEntityQuery();
+ while (audioEnumerator.MoveNext(out var uid, out var audio, out var xform))
+ {
+ if (audio.Global)
+ continue;
+
+ if (!audio.Playing)
+ continue;
+
+ ProcessAudioEntity((uid, audio), xform, minimumMagnitude, maximumMagnitude);
+ }
+ }
+
+ ///
+ /// Returns all four cardinal directions when is false.
+ /// Otherwise, returns all eight intercardinal and cardinal directions as listed in
+ /// .
+ ///
+ [Pure]
+ public static Angle[] GetEffectiveDirections(bool highResolution)
+ {
+ if (!highResolution)
+ return [Direction.North.ToAngle(), Direction.West.ToAngle(), Direction.South.ToAngle(), Direction.East.ToAngle()];
+
+ var allDirections = DirectionExtensions.AllDirections;
+ var directions = new Angle[allDirections.Length];
+
+ for (var i = 0; i < allDirections.Length; i++)
+ {
+ directions[i] = allDirections[i].ToAngle();
+ }
+
+ return directions;
+ }
+
+ ///
+ /// Takes an entity's . Goes through every parent it
+ /// has before reaching one that is a map. Returns the hierarchy
+ /// discovered, which includes the given .
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private List> TryGetHierarchyBeforeMap(Entity originEntity)
+ {
+ var hierarchy = new List>() { originEntity };
+
+ ref var currentEntity = ref originEntity;
+ ref var currentTransformComponent = ref currentEntity.Comp;
+
+ var mapUid = currentEntity.Comp.MapUid;
+
+ while (currentTransformComponent.ParentUid != mapUid /* break when the next entity is a map... */ &&
+ currentTransformComponent.ParentUid.IsValid() /* ...or invalid */ )
+ {
+ // iterate to next entity
+ var nextUid = currentTransformComponent.ParentUid;
+ currentEntity.Owner = nextUid;
+ currentTransformComponent = Transform(nextUid);
+
+ hierarchy.Add(currentEntity);
+ }
+
+ DebugTools.Assert(hierarchy.Count >= 1, "Malformed entity hierarchy! Hierarchy must always contain one element, but it doesn't. How did this happen?");
+ return hierarchy;
+ }
+
+ ///
+ /// Gets the length of the direction that reaches the furthest unobstructed
+ /// distance, in an attempt to get the size of the area. Aborts early
+ /// if either grid is missing or the tile isnt rooved.
+ ///
+ /// Returned magnitude is the longest valid length of the ray in each direction,
+ /// divided by the number of total processed angles.
+ ///
+ /// Whether anything was actually processed.
+ // i am the total overengineering guy... and this, is my code.
+ /*
+ This works under a few assumptions:
+ - An entity in space is invalid
+ - Any spaced tile is invalid
+ - Rays end on invalid tiles (space) or unrooved tiles, and dont process on separate grids.
+ - - This checked every `_calculationalFidelity`-ish tiles. Not precisely. But somewhere around that. Its moreso just proportional to that.
+ - Rays bounce.
+ */
+ public bool TryProcessAreaSpaceMagnitude(Entity entity, float maximumMagnitude, out float magnitude)
+ {
+ magnitude = 0f;
+ var transformComponent = entity.Comp;
+
+ // get either the grid or other parent entity this entity is on, and it's rotation
+ var entityHierarchy = TryGetHierarchyBeforeMap(entity);
+ if (entityHierarchy.Count <= 1) // hierarchy always starts with our entity. if it only has our entity, it means the next parent was the map, which we don't want
+ return false; // means this entity is in space/otherwise not on a grid
+
+ // at this point, we know that we are somewhere on a grid
+
+ // e.g.: if a sound is inside a crate, this will now be the grid the crate is on; if the sound is just on the grid, this will be the grid that the sound is on.
+ var entityGrid = entityHierarchy.Last();
+
+ // this is the last entity, or this entity itself, that this entity has, before the parent is a grid/map. e.g.: if a sound is inside a crate, this will be the crate; if the sound is just on the grid, this will be the sound
+ var lastEntityBeforeGrid = entityHierarchy[^2]; // `l[^x]` is analogous to `l[l.Count - x]`
+ // `lastEntityBeforeGrid` is obviously directly before `entityGrid`
+ // the earlier guard clause makes sure this will always be valid
+
+ if (!_gridQuery.TryGetComponent(entityGrid, out var gridComponent))
+ return false;
+
+ var checkRoof = _roofQuery.TryGetComponent(entityGrid, out var roofComponent);
+ var tileRef = _map.GetTileRef(entityGrid, gridComponent, lastEntityBeforeGrid.Comp.Coordinates);
+
+ if (tileRef.Tile.IsEmpty)
+ return false;
+
+ var gridRoofEntity = new Entity(entityGrid, gridComponent, roofComponent);
+ if (checkRoof && !_roof.IsRooved(gridRoofEntity!, tileRef.GridIndices))
+ return false;
+
+ var originTileIndices = tileRef.GridIndices;
+ var worldPosition = _transform.GetWorldPosition(transformComponent);
+
+ // At this point, we are ready for war against the client's pc.
+ foreach (var direction in _calculatedDirections)
+ {
+ var currentDirectionVector = direction.ToVec();
+ var currentTargetEntityUid = lastEntityBeforeGrid.Owner;
+
+ var totalDistance = 0f;
+ var remainingDistance = maximumMagnitude;
+
+ var currentOriginWorldPosition = worldPosition;
+ var currentOriginTileIndices = originTileIndices;
+
+ var currentAcousticEnergy = 1.0f;
+
+ for (var reflectIteration = 0; reflectIteration <= _echoMaxReflections /* if maxreflections is 0 we still cast atleast once */; reflectIteration++)
+ {
+ var (distanceCovered, raycastResults) = CastEchoRay(
+ currentOriginWorldPosition,
+ currentOriginTileIndices,
+ currentDirectionVector,
+ transformComponent.MapID,
+ currentTargetEntityUid,
+ gridRoofEntity,
+ checkRoof,
+ remainingDistance
+ );
+
+ // Добавляем пройденную дистанцию с учетом оставшейся "энергии" звука
+ totalDistance += distanceCovered * currentAcousticEnergy;
+
+ // Физический лимит луча отнимаем по-настоящему
+ remainingDistance -= distanceCovered;
+
+ // we don't need further logic anyway if we just finished the last iteration
+ if (reflectIteration == _echoMaxReflections)
+ break;
+
+ if (raycastResults is null) // means we didnt hit anything
+ break;
+
+ currentAcousticEnergy *= BounceDampening;
+
+ // i think cross-grid would actually be pretty easy here? but the tile-marching doesnt often account for that at fidelities above 1 so whatever.
+
+ var previousRayWorldOriginPosition = currentOriginWorldPosition;
+ currentOriginWorldPosition = raycastResults.Value.HitPos; // it's now where we hit
+ currentTargetEntityUid = raycastResults.Value.HitEntity;
+
+ // means tile that ray hit is invalid, just assume the ray ends here
+ if (!_map.TryGetTileRef(entityGrid, gridComponent, currentOriginWorldPosition, out var hitTileRef))
+ break;
+
+ currentOriginTileIndices = hitTileRef.GridIndices;
+
+ var worldMatrix = _transform.GetInvWorldMatrix(gridRoofEntity);
+ var previousRayOriginLocalPosition = Vector2.Transform(previousRayWorldOriginPosition, worldMatrix);
+ var currentOriginLocalPosition = Vector2.Transform(currentOriginWorldPosition, worldMatrix);
+
+ var delta = currentOriginLocalPosition - previousRayOriginLocalPosition;
+ if (delta.LengthSquared() <= float.Epsilon * 2)
+ break;
+
+ var normalVector = GetTileHitNormal(currentOriginLocalPosition, _map.TileToVector(gridRoofEntity, currentOriginTileIndices), gridRoofEntity.Comp1.TileSize);
+ currentDirectionVector = Reflect(currentDirectionVector, normalVector);
+ }
+
+ if (totalDistance > magnitude)
+ magnitude = totalDistance;
+ }
+
+ return true;
+ }
+
+ private static Vector2 GetTileHitNormal(Vector2 rayHitPos, Vector2 tileOrigin, float tileSize)
+ {
+ // Position inside the tile (0..tileSize)
+ var local = rayHitPos - tileOrigin;
+
+ // Distances to each side
+ var left = local.X;
+ var right = tileSize - local.X;
+ var bottom = local.Y;
+ var top = tileSize - local.Y;
+
+ // Find smallest distance
+ var minDist = MathF.Min(MathF.Min(left, right), MathF.Min(bottom, top));
+
+ if (MathHelper.CloseTo(minDist, left))
+ return new Vector2(-1, 0);
+ if (MathHelper.CloseTo(minDist, right))
+ return new Vector2(1, 0);
+ if (MathHelper.CloseTo(minDist, bottom))
+ return new Vector2(0, -1);
+
+ return new Vector2(0, 1); // must be top
+ }
+
+ ///
+ /// should be normalised upon calling.
+ ///
+ [Pure]
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static Vector2 Reflect(in Vector2 direction, in Vector2 normal)
+ => direction - 2 * Vector2.Dot(direction, normal) * normal;
+
+ // this caused vsc to spike to 28gb memory usage
+ ///
+ /// Casts a ray and marches it. See .
+ ///
+ private (float, RayCastResults?) CastEchoRay(
+ in Vector2 originWorldPosition,
+ in Vector2i originTileIndices,
+ in Vector2 directionVector,
+ in MapId mapId,
+ in EntityUid ignoredEntity,
+ in Entity gridRoofEntity,
+ bool checkRoof,
+ float maximumDistance
+ )
+ {
+ var directionFidelityStep = directionVector * _calculationalFidelity;
+
+ var ray = new CollisionRay(originWorldPosition, directionVector, EchoLayer);
+ var rayResults = _physics.IntersectRay(mapId, ray, maxLength: maximumDistance, ignoredEnt: ignoredEntity, returnOnFirstHit: true);
+
+ // if we hit something, distance to that is magnitude but it must be lower than maximum. if we didnt hit anything, it's maximum magnitude
+ var rayMagnitude = rayResults.TryFirstOrNull(out var firstResult)
+ ? MathF.Min(firstResult.Value.Distance, maximumDistance)
+ : maximumDistance;
+
+ var nextCheckedPosition = new Vector2(originTileIndices.X, originTileIndices.Y) * gridRoofEntity.Comp1.TileSize + directionFidelityStep;
+ var incrementedRayMagnitude = MarchRayByTiles(
+ rayMagnitude,
+ gridRoofEntity,
+ directionFidelityStep,
+ ref nextCheckedPosition,
+ gridRoofEntity.Comp1.TileSize,
+ checkRoof
+ );
+
+ return (incrementedRayMagnitude, firstResult);
+ }
+
+ ///
+ /// Advances a ray, in intervals of `_calculationalFidelity`, by tiles until
+ /// reaching an unrooved tile (if checking roofs) or space.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private float MarchRayByTiles(
+ in float rayMagnitude,
+ in Entity gridRoofEntity,
+ in Vector2 directionFidelityStep,
+ ref Vector2 nextCheckedPosition,
+ ushort gridTileSize,
+ bool checkRoof
+ )
+ {
+ // find the furthest distance this ray reaches until its on an unrooved/dataless (space) tile
+
+ var fidelityStepLength = directionFidelityStep.Length();
+ var incrementedRayMagnitude = 0f;
+
+ for (; incrementedRayMagnitude < rayMagnitude;)
+ {
+ var nextCheckedTilePosition = new Vector2i(
+ (int) MathF.Floor(nextCheckedPosition.X / gridTileSize),
+ (int) MathF.Floor(nextCheckedPosition.Y / gridTileSize)
+ );
+
+ if (checkRoof)
+ {
+ // if we're checking roofs, end this ray if this tile is unrooved or dataless (latter is inherent of this method)
+ if (!_roof.IsRooved(gridRoofEntity!, nextCheckedTilePosition))
+ break;
+ }
+ // if we're not checking roofs, end this ray if this tile is empty/space
+ else if (!_map.TryGetTileRef(gridRoofEntity, gridRoofEntity, nextCheckedTilePosition, out var tile) ||
+ tile.Tile.IsEmpty)
+ break;
+
+ nextCheckedPosition += directionFidelityStep;
+ incrementedRayMagnitude += fidelityStepLength;
+ }
+
+ return MathF.Min(incrementedRayMagnitude, rayMagnitude);
+ }
+
+ private void ProcessAudioEntity(Entity entity, TransformComponent transformComponent, float minimumMagnitude, float maximumMagnitude)
+ {
+ TryProcessAreaSpaceMagnitude((entity, transformComponent), maximumMagnitude, out var echoMagnitude);
+
+ if (echoMagnitude <= minimumMagnitude)
+ {
+ _audioEffect.TryRemoveEffect(entity);
+ return;
+ }
+
+ if (!TryGetPreset(echoMagnitude, out var bestPreset))
+ {
+ _audioEffect.TryRemoveEffect(entity);
+ return;
+ }
+
+ Log.Debug($"Used preset {bestPreset.Value}");
+ _audioEffect.TryAddEffect(entity, bestPreset.Value);
+ }
+
+ private bool TryGetPreset(float echoMagnitude, [NotNullWhen(true)] out ProtoId? bestPreset)
+ {
+ bestPreset = null;
+
+ foreach (var (distance, preset) in DistancePresets)
+ {
+ if (echoMagnitude < distance)
+ continue;
+
+ bestPreset = preset;
+ return true;
+ }
+
+ return false;
+ }
+
+ // Maybe TODO: defer this onto ticks? but whatever its just clientside
+ private void OnAudioParentChanged(Entity ent, ref EntParentChangedMessage args)
+ {
+ if (!_echoEnabled)
+ return;
+
+ if (ent.Comp.Global)
+ return;
+
+ if (args.Transform.MapID == MapId.Nullspace)
+ return;
+
+ var (minimumMagnitude, maximumMagnitude) = GetMinMax();
+
+ ProcessAudioEntity(ent, args.Transform, minimumMagnitude, maximumMagnitude);
+ }
+
+ private static (float Min, float Max) GetMinMax()
+ {
+ var minimumMagnitude = DistancePresets.Last().Distance;
+ DebugTools.Assert(minimumMagnitude > 0f, "Minimum distance must be greater than zero!");
+
+ var maximumMagnitude = DistancePresets.First().Distance;
+ DebugTools.Assert(maximumMagnitude > 0f, "Maximum distance must be greater than zero!");
+
+ return (minimumMagnitude, maximumMagnitude);
+ }
+}
diff --git a/Content.Client/_Scp/Audio/AudioEffectSystem.cs b/Content.Client/_Scp/Audio/AudioEffectSystem.cs
new file mode 100644
index 00000000000..5b73e7e8d95
--- /dev/null
+++ b/Content.Client/_Scp/Audio/AudioEffectSystem.cs
@@ -0,0 +1,260 @@
+// SPDX-FileCopyrightText: 2025 LaCumbiaDelCoronavirus
+// SPDX-FileCopyrightText: 2025 ark1368
+//
+// SPDX-License-Identifier: MPL-2.0
+
+// some parts taken and modified from https://github.com/TornadoTechnology/finster/blob/1af5daf6270477a512ee9d515371311443e97878/Content.Shared/_Finster/Audio/SharedAudioEffectsSystem.cs#L13 , credit to docnite
+// they're under WTFPL so its quite allowed
+
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using Content.Shared.GameTicking;
+using Robust.Client.Audio;
+using Robust.Shared.Audio;
+using Robust.Shared.Audio.Components;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Utility;
+using DependencyAttribute = Robust.Shared.IoC.DependencyAttribute;
+
+namespace Content.Client._Scp.Audio;
+
+///
+/// Handler for client-side audio effects.
+///
+public sealed class AudioEffectSystem : EntitySystem
+{
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+ [Dependency] private readonly AudioSystem _audioSystem = default!;
+
+ ///
+ /// Whether creating new auxiliaries is safe.
+ /// This is done because integration tests
+ /// apparently can't handle them.
+ ///
+ /// Null means this value wasn't determined yet.
+ ///
+ /// Any not-null value here is the final result,
+ /// and no more attempts to determine this
+ /// will be made afterwards.
+ ///
+ // actually this problem applies for effects too
+ private bool? _auxiliariesSafe = null;
+
+ private static readonly Dictionary, (EntityUid AuxiliaryUid, EntityUid EffectUid)> CachedEffects = new();
+
+ ///
+ /// An auxiliary with no effect; for removing effects.
+ ///
+ // TODO: remove this when an rt method to actually remove effects gets added
+ private EntityUid _cachedBlankAuxiliaryUid;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ // You can't keep references to this past round-end so it must be cleaned up.
+ SubscribeNetworkEvent(_ => Cleanup()); // its not raised on client
+ SubscribeLocalEvent(OnPrototypeReload);
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+ Cleanup();
+ }
+
+ private void OnPrototypeReload(PrototypesReloadedEventArgs args)
+ {
+ if (!args.WasModified())
+ return;
+
+ // get rid of all old cached entities, and replace them with new ones
+ var oldPresets = new List>();
+ foreach (var cache in CachedEffects)
+ {
+ oldPresets.Add(cache.Key);
+
+ TryQueueDel(cache.Value.AuxiliaryUid);
+ TryQueueDel(cache.Value.EffectUid);
+ }
+ CachedEffects.Clear();
+
+ foreach (var oldPreset in oldPresets)
+ {
+ if (!ResolveCachedEffect(oldPreset, out var cachedAuxiliaryUid, out var cachedEffectUid))
+ continue;
+
+ CachedEffects[oldPreset] = (cachedAuxiliaryUid.Value, cachedEffectUid.Value);
+ }
+ }
+
+ private void Cleanup()
+ {
+ foreach (var cache in CachedEffects)
+ {
+ TryQueueDel(cache.Value.AuxiliaryUid);
+ TryQueueDel(cache.Value.EffectUid);
+ }
+ CachedEffects.Clear();
+
+ if (_cachedBlankAuxiliaryUid.IsValid())
+ TryQueueDel(_cachedBlankAuxiliaryUid);
+
+ _cachedBlankAuxiliaryUid = EntityUid.Invalid;
+ }
+
+
+ ///
+ /// Figures out whether auxiliaries are safe to use. Returns
+ /// whether a safe auxiliary pair has been outputted
+ /// for use.
+ ///
+ private bool DetermineAuxiliarySafety([NotNullWhen(true)] out (EntityUid Entity, AudioAuxiliaryComponent Component)? auxiliaryPair, bool destroyPairAfterUse = true)
+ {
+ (EntityUid Entity, AudioAuxiliaryComponent Component)? maybeAuxiliaryPair = null;
+ try
+ {
+ maybeAuxiliaryPair = _audioSystem.CreateAuxiliary();
+ _auxiliariesSafe = true;
+ }
+ catch (Exception ex)
+ {
+ Log.Info($"Determined audio auxiliaries are unsafe in this run! If this is not an integration test, report this immediately. Exception: {ex}");
+ _auxiliariesSafe = false;
+
+ TryQueueDel(maybeAuxiliaryPair?.Entity);
+
+ auxiliaryPair = null;
+ return false;
+ }
+
+ if (destroyPairAfterUse)
+ {
+ QueueDel(maybeAuxiliaryPair.Value.Entity);
+
+ auxiliaryPair = null;
+ return false;
+ }
+
+ auxiliaryPair = maybeAuxiliaryPair.Value;
+ return true;
+ }
+
+ ///
+ /// Returns whether auxiliaries are definitely safe to use.
+ /// Determines auxiliary safety if not already.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool AuxiliariesAreDefinitelySafe()
+ {
+ if (_auxiliariesSafe == null)
+ DetermineAuxiliarySafety(out _, destroyPairAfterUse: true);
+
+ return _auxiliariesSafe == true;
+ }
+
+ ///
+ /// Tries to resolve a cached audio auxiliary entity corresponding to the prototype to apply
+ /// to the given entity.
+ ///
+ public bool TryAddEffect(in Entity entity, in ProtoId preset)
+ {
+ if (!AuxiliariesAreDefinitelySafe() ||
+ !ResolveCachedEffect(preset, out var auxiliaryUid, out _))
+ return false;
+
+ _audioSystem.SetAuxiliary(entity, entity.Comp, auxiliaryUid);
+ return true;
+ }
+
+ ///
+ /// Tries to remove effects from the given audio. Returns whether the attempt was successful,
+ /// or no auxiliary is applied to the audio.
+ ///
+ public bool TryRemoveEffect(in Entity entity)
+ {
+ if (!AuxiliariesAreDefinitelySafe())
+ return false;
+
+ if (entity.Comp.Auxiliary is not { } existingAuxiliaryUid ||
+ !existingAuxiliaryUid.IsValid())
+ return true;
+
+ // resolve the cached auxiliary
+ if (!_cachedBlankAuxiliaryUid.IsValid())
+ _cachedBlankAuxiliaryUid = _audioSystem.CreateAuxiliary().Entity;
+
+ _audioSystem.SetAuxiliary(entity, entity.Comp, _cachedBlankAuxiliaryUid);
+ return true;
+ }
+
+ ///
+ /// Tries to resolve an audio auxiliary and effect entity, creating and caching one if one doesn't already exist,
+ /// for a prototype. Do not modify it in any way.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool ResolveCachedEffect(in ProtoId preset, [NotNullWhen(true)] out EntityUid? auxiliaryUid, [NotNullWhen(true)] out EntityUid? effectUid)
+ {
+ if (_auxiliariesSafe == false)
+ {
+ auxiliaryUid = null;
+ effectUid = null;
+ return false;
+ }
+
+ if (CachedEffects.TryGetValue(preset, out var cached))
+ {
+ auxiliaryUid = cached.AuxiliaryUid;
+ effectUid = cached.EffectUid;
+ return true;
+ }
+
+ return TryCacheEffect(preset, out auxiliaryUid, out effectUid);
+ }
+
+ ///
+ /// Tries to initialise and cache effect and auxiliary entities corresponding to a prototype,
+ /// in the system's internal cache.
+ ///
+ /// Does nothing if the entity already exists in the cache.
+ ///
+ /// Whether the entity was successfully initialised, and it did not previously exist in the cache.
+ public bool TryCacheEffect(in ProtoId preset, [NotNullWhen(true)] out EntityUid? auxiliaryUid, [NotNullWhen(true)] out EntityUid? effectUid)
+ {
+
+ effectUid = null;
+ auxiliaryUid = null;
+
+ if (_auxiliariesSafe == false ||
+ !_prototypeManager.TryIndex(preset, out var presetPrototype))
+ return false;
+
+ // i cant `??=` it
+ (EntityUid Entity, AudioAuxiliaryComponent Component)? maybeAuxiliaryPair = null;
+
+ // if undetermined, determine and keep the pair if confirmed safe
+ if (_auxiliariesSafe == null)
+ {
+ // if determined unsafe, cleanup the pair
+ if (!DetermineAuxiliarySafety(out maybeAuxiliaryPair, destroyPairAfterUse: false))
+ return false;
+ }
+
+ // now, auxiliaries are known to be safe.
+ // only when initially determining if auxiliaries are safe will we have a pair to use. in future attempts, we won't so just make one if necessary
+ var auxiliaryPair = maybeAuxiliaryPair ?? _audioSystem.CreateAuxiliary();
+
+ DebugTools.Assert(Exists(auxiliaryPair.Entity), "Audio auxiliary pair's entity does not exist!");
+ if (!Exists(auxiliaryPair.Entity))
+ return false;
+
+ var effectPair = _audioSystem.CreateEffect();
+ _audioSystem.SetEffectPreset(effectPair.Entity, effectPair.Component, presetPrototype);
+ _audioSystem.SetEffect(auxiliaryPair.Entity, auxiliaryPair.Component, effectPair.Entity);
+
+ effectUid = effectPair.Entity;
+ auxiliaryUid = auxiliaryPair.Entity;
+
+ return CachedEffects.TryAdd(preset, (auxiliaryPair.Entity, effectPair.Entity));
+ }
+}
diff --git a/Content.Client/_Scp/Audio/EchoEffectSystem.cs b/Content.Client/_Scp/Audio/EchoEffectSystem.cs
deleted file mode 100644
index c35337ec66a..00000000000
--- a/Content.Client/_Scp/Audio/EchoEffectSystem.cs
+++ /dev/null
@@ -1,158 +0,0 @@
-using Content.Shared._Scp.Audio;
-using Content.Shared._Scp.Audio.Components;
-using Content.Shared._Scp.ScpCCVars;
-using Content.Client._Scp.Audio.Components;
-using Robust.Shared.Audio;
-using Robust.Shared.Audio.Components;
-using Robust.Shared.Audio.Systems;
-using Robust.Shared.Configuration;
-using Robust.Shared.Prototypes;
-using Robust.Shared.Timing;
-
-namespace Content.Client._Scp.Audio;
-
-///
-/// Система, накладывающая эффект эхо каждому неглобальному звуку.
-/// Эффект может быть отключен игроком в настройках
-///
-public sealed class EchoEffectSystem : EntitySystem
-{
- [Dependency] private readonly AudioEffectsManagerSystem _effectsManager = default!;
- [Dependency] private readonly IGameTiming _timing = default!;
- [Dependency] private readonly IConfigurationManager _cfg = default!;
-
- private static readonly ProtoId StandardEchoEffectPreset = "Bathroom";
- private static readonly ProtoId StrongEchoEffectPreset = "SewerPipe";
-
- private bool _isClientSideEnabled;
- private bool _strongPresetPreferred;
-
- public override void Initialize()
- {
- base.Initialize();
-
- SubscribeLocalEvent(OnAudioAdd);
- SubscribeLocalEvent(OnEffectedAudioStartup, after: [typeof(SharedAudioSystem)]);
-
- _isClientSideEnabled = _cfg.GetCVar(ScpCCVars.EchoEnabled);
- _strongPresetPreferred = _cfg.GetCVar(ScpCCVars.EchoStrongPresetPreferred);
-
- _cfg.OnValueChanged(ScpCCVars.EchoEnabled, OnEnabledToggled);
- _cfg.OnValueChanged(ScpCCVars.EchoStrongPresetPreferred, OnPreferredPresetToggled);
- }
-
- public override void Shutdown()
- {
- base.Shutdown();
-
- _cfg.UnsubValueChanged(ScpCCVars.EchoEnabled, OnEnabledToggled);
- _cfg.UnsubValueChanged(ScpCCVars.EchoStrongPresetPreferred, OnPreferredPresetToggled);
- }
-
- private void OnAudioAdd(Entity ent, ref ComponentAdd args)
- {
- if (!_isClientSideEnabled)
- return;
-
- EnsureComp(ent);
- }
-
- private void OnEffectedAudioStartup(Entity ent, ref ComponentStartup args)
- {
- if (!_isClientSideEnabled)
- return;
-
- if (!TryComp(ent.Owner, out var audio))
- return;
-
- TryApplyEcho((ent.Owner, audio));
- }
-
- ///
- /// Пытается применить эхо к данном звуку
- ///
- /// Звук, к которому будет применен эффект
- /// Пресет, если нужно выставить какой-то особенный
- /// Получилось или не получилось применить эффект
- public bool TryApplyEcho(Entity sound, ProtoId? preset = null)
- {
- if (TerminatingOrDeleted(sound) || Paused(sound))
- return false;
-
- // Фоновая музыка не должна подвергаться эффектам эха
- if (sound.Comp.Global)
- return false;
-
- // Выбираем пресет для эха исходя из настроек игрока и возможного приоритетного эффекта при вызове извне системы
- var clientPreferredPreset = _strongPresetPreferred ? StrongEchoEffectPreset : StandardEchoEffectPreset;
- var targetPreset = preset ?? clientPreferredPreset;
-
- _effectsManager.TryAddEffect(sound, targetPreset);
-
- // Добавляем компонент-маркер к звуку, который будет хранить эффект эха
- var echoComp = EnsureComp(sound);
- echoComp.Preset = targetPreset;
-
- return true;
- }
-
- ///
- /// Пытается убрать эффект эхо у выбранного звука
- ///
- public bool TryRemoveEcho(Entity sound, AudioEchoEffectAffectedComponent? echoComp = null)
- {
- if (!Resolve(sound, ref echoComp))
- return false;
-
- if (!_effectsManager.TryRemoveEffect(sound, echoComp.Preset))
- return false;
-
- RemComp(sound);
- RemComp(sound);
-
- return true;
- }
-
- private void OnEnabledToggled(bool enabled)
- {
- _isClientSideEnabled = enabled;
-
- if (!enabled)
- RevertChanges();
- }
-
- private void OnPreferredPresetToggled(bool useStrong)
- {
- _strongPresetPreferred = useStrong;
- var newPreferredPreset = useStrong ? StrongEchoEffectPreset : StandardEchoEffectPreset;
-
- TogglePreset(newPreferredPreset);
- }
-
- ///
- /// Убирает эффекты эхо у всех звуков, что имеют его.
- /// Вызывается при выключении эффекта эха игроком.
- ///
- private void RevertChanges()
- {
- var query = AllEntityQuery();
-
- while (query.MoveNext(out var uid, out var echoComp, out var audio))
- {
- TryRemoveEcho((uid, audio), echoComp);
- }
- }
-
- private void TogglePreset(ProtoId newPreferredPreset)
- {
- var query = AllEntityQuery();
-
- while (query.MoveNext(out var uid, out var echoComp, out var audio))
- {
- if (!TryRemoveEcho((uid, audio), echoComp))
- continue;
-
- TryApplyEcho((uid, audio), newPreferredPreset);
- }
- }
-}
diff --git a/Content.Client/_Scp/Options/UI/Tabs/ScpTab.xaml b/Content.Client/_Scp/Options/UI/Tabs/ScpTab.xaml
index d7b97d9a7c1..770c343069a 100644
--- a/Content.Client/_Scp/Options/UI/Tabs/ScpTab.xaml
+++ b/Content.Client/_Scp/Options/UI/Tabs/ScpTab.xaml
@@ -61,9 +61,16 @@
-
+
+
+
+
- /// Будет ли использован эффект эхо?
+ /// Whether to render sounds with echo when they are in 'large' open, rooved areas.
///
+ ///
public static readonly CVarDef EchoEnabled =
- CVarDef.Create("scp.echo_enabled", true, CVar.CLIENTONLY | CVar.ARCHIVE);
+ CVarDef.Create("scp.audio.area_echo.enabled", true, CVar.ARCHIVE | CVar.CLIENTONLY);
///
- /// Будет ли использован пресет сильного эха?
+ /// If false, area echos calculate with 4 directions (NSEW).
+ /// Otherwise, area echos calculate with all 8 directions.
///
- public static readonly CVarDef EchoStrongPresetPreferred =
- CVarDef.Create("scp.echo_strong_preset_preferred", false, CVar.CLIENTONLY | CVar.ARCHIVE);
+ ///
+ public static readonly CVarDef EchoHighResolution =
+ CVarDef.Create("scp.audio.area_echo.alldirections", false, CVar.ARCHIVE | CVar.CLIENTONLY);
- /**
- * Подавление звуков
- */
+
+ ///
+ /// How many times a ray can bounce off a surface for an echo calculation.
+ ///
+ ///
+ public static readonly CVarDef EchoReflectionCount =
+ CVarDef.Create("scp.audio.area_echo.max_reflections", 1, CVar.ARCHIVE | CVar.CLIENTONLY);
+
+ ///
+ /// Distantial interval, in tiles, in the rays used to calculate the roofs of an open area for echos,
+ /// or the ray's distance to space, at which the tile at that point of the ray is processed.
+ ///
+ /// The lower this is, the more 'predictable' and computationally heavy the echoes are.
+ ///
+ ///
+ public static readonly CVarDef EchoStepFidelity =
+ CVarDef.Create("scp.audio.area_echo.step_fidelity", 5f, CVar.CLIENTONLY);
+
+ ///
+ /// Interval between updates for every audio entity.
+ ///
+ ///
+ public static readonly CVarDef EchoRecalculationInterval =
+ CVarDef.Create("scp.audio.area_echo.recalculation_interval", 1, CVar.ARCHIVE | CVar.CLIENTONLY);
+
+ #endregion
+
+ #region Muffle
///
/// Будет ли подавление звуков в зависимости от видимости работать?
@@ -35,4 +61,6 @@ public sealed partial class ScpCCVars
///
public static readonly CVarDef AudioMufflingHighFrequencyUpdate =
CVarDef.Create("scp.audio_muffling_use_high_frequency_update", false, CVar.CLIENTONLY | CVar.ARCHIVE);
+
+ #endregion
}