using System; using System.Collections.Generic; using System.Numerics; namespace AcDream.Core.Audio; /// /// Retail's LandDefs::Direction — where a contributing land cell sits /// relative to the viewer's landblock. The jump table at 0x5A9A7C gives /// each one a compass heading in radians. /// public enum AmbientDirection { InViewerBlock = 0, North = 1, South = 2, East = 3, West = 4, Northwest = 5, Southwest = 6, Northeast = 7, Southeast = 8, } /// /// One authored ambient entry — retail's AmbientSoundDesc (0x14 packed). /// IsContinuous is DERIVED at unpack from BaseChance == 0, not /// stored; Binary Ninja renders that compare inverted, and porting its version /// yields silence rather than a wrong sound. /// /// The slot in the referenced SoundTable. /// Authored linear gain. /// 0 ⇒ continuous; otherwise the per-fire probability base. /// Re-fire interval floor, seconds. /// Re-fire interval ceiling, seconds (continuous ignores it). public readonly record struct AmbientSoundDescriptor( SoundId Sound, float Volume, float BaseChance, float MinRate, float MaxRate) { /// /// base_chance == 0ConstantSound (a crossfaded, /// non-positional bed re-fired every seconds); /// non-zero ⇒ IntermitSound. /// public bool IsContinuous => BaseChance == 0f; } /// /// Retail's ambient constants, byte-read from the PDB-paired binary. Every one /// of these is elided or mis-rendered somewhere in the pseudo-C. /// public static class AmbientSoundConstants { /// Full-weight radius, metres (0x81F148). public const float MinDistance = 20.0f; /// squared (0x81F14C). public const float MinDistanceSq = 400.0f; /// Cull radius, metres (0x81F150). public const float MaxDistance = 120.0f; /// squared (0x81F154). public const float MaxDistanceSq = 14400.0f; /// /// Audibility floor for a continuous bed's crossfaded volume /// (0x81F158) — about −30.5 dB. /// public const float MinVolume = 0.03f; /// /// Total jitter cone applied to an intermittent sound's bearing /// (0x81F1B0): π/8 radians = 22.5°, so ±11.25°. /// public const float HeadingSpread = 0.392699093f; /// /// The "close enough to be anywhere around you" threshold in /// Ambient::CalcDir: × 0.5 = 200 m², /// i.e. 14.142 m. This is the ONLY place the squared value is halved — /// reading it as would widen the omnidirectional /// zone from 14 m to 20 m. /// public const float InViewerBlockDistanceSq = MinDistanceSq * 0.5f; /// Half of — the shell half-thickness, metres. public const float ShellHalfThickness = MinDistance * 0.5f; /// Near bound used for the omnidirectional spread, metres (5.0f − 1.0f). public const float InBlockNearDistance = 4.0f; /// Metres per land cell (LandDefs::square_length, 0x799128). public const float LandCellLength = 24.0f; /// Retail's per-direction compass heading, radians (jump table 0x5A9A7C). public static float Heading(AmbientDirection direction) => direction switch { AmbientDirection.North => 0.0f, AmbientDirection.South => 3.14159274f, AmbientDirection.East => 1.57079637f, AmbientDirection.West => 4.71238899f, AmbientDirection.Northwest => 5.49778700f, AmbientDirection.Southwest => 3.92699075f, AmbientDirection.Northeast => 0.78539819f, AmbientDirection.Southeast => 2.35619450f, // IN_VIEWER_BLOCK and anything out of range fall to 0.0. _ => 0.0f, }; /// /// Ambient::CalcWeight @ 0x550DD0: full weight inside 20 m, /// inverse-square out to 120 m, nothing beyond. Binary Ninja dropped the /// arithmetic entirely. /// public static float CalcWeight(Vector3 offset) { float distanceSq = offset.LengthSquared(); if (distanceSq > MaxDistanceSq) return 0f; if (distanceSq < MinDistanceSq) return 1f; return MinDistanceSq / distanceSq; } /// /// Ambient::CalcDir @ 0x550E40: which compass sector a /// contributing cell falls in, or /// when it is inside . A sector counts as /// diagonal when neither axis dominates the other by more than 2× /// (0x7C5E24). /// public static AmbientDirection CalcDirection(Vector3 offset) { float x = offset.X; float y = offset.Y; // CalcDir squares X and Y only — Z is deliberately absent here, where // CalcWeight deliberately includes it. The two functions differ on // purpose; collapsing them would widen or narrow the omnidirectional // zone by the height difference. if (((x * x) + (y * y)) < InViewerBlockDistanceSq) return AmbientDirection.InViewerBlock; float ax = MathF.Abs(x); float ay = MathF.Abs(y); const float diagonalRatio = 2.0f; const float epsilon = 0.0002f; // F_EPSILON @ 0x7CB0A0 bool diagonal = ax > epsilon && ay > epsilon && ay / ax <= diagonalRatio && ax / ay <= diagonalRatio; if (diagonal) { return y >= 0f ? (x >= 0f ? AmbientDirection.Northeast : AmbientDirection.Northwest) : (x >= 0f ? AmbientDirection.Southeast : AmbientDirection.Southwest); } if (ay >= ax) return y >= 0f ? AmbientDirection.North : AmbientDirection.South; return x >= 0f ? AmbientDirection.East : AmbientDirection.West; } } /// /// One live ambient instance — retail's ConstantSound or /// IntermitSound. Accumulates weight (and, for the intermittent kind, /// bearings) during a rebuild, then answers the four questions the scheduler /// asks: can it be heard, should it fire now, how loud, and when again. /// public sealed class AmbientSoundInstance { private readonly List _directions = []; public AmbientSoundInstance(AmbientSoundDescriptor descriptor, uint soundTableDid) { Descriptor = descriptor; SoundTableDid = soundTableDid; } public AmbientSoundDescriptor Descriptor { get; } /// The SoundTable the descriptor's slot is looked up in. public uint SoundTableDid { get; } /// Accumulated weight from every contributing land cell. public float SoundCount { get; private set; } /// Crossfaded volume — continuous instances only. public float CurrentVolume { get; private set; } /// Per-fire probability — intermittent instances only. public float PlayChance { get; private set; } /// True while this instance holds a slot in the deadline queue. public bool OnQueue { get; set; } public IReadOnlyList Directions => _directions; /// /// ResetCount (0x550CD0 intermittent, 0x550D70 /// continuous). Must run for EVERY instance before a rebuild accumulates: /// IntermitSound::UpdateSound never clears , /// so a skipped reset leaves a stale bearing and probability alive forever. /// Note retail does NOT reset a continuous instance's /// here. /// public void ResetCount() { SoundCount = 0f; _directions.Clear(); if (!Descriptor.IsContinuous) PlayChance = 0f; } /// /// AddTo @ 0x551450. A cell outside the viewer's own block /// contributes a 20 m-thick shell at its bearing; a cell inside /// could be /// anywhere around the listener, so it contributes 4–10 m in all eight /// directions. /// public void AddTo(float weight, Vector3 offset, AmbientDirection direction) { SoundCount += weight; if (Descriptor.IsContinuous) return; // continuous beds track weight only, never bearings float distance = MathF.Sqrt(offset.LengthSquared()); float half = AmbientSoundConstants.ShellHalfThickness; if (direction != AmbientDirection.InViewerBlock) { AddDirection(direction, distance - half, distance + half); return; } AddDirection(AmbientDirection.North, AmbientSoundConstants.InBlockNearDistance, half); AddDirection(AmbientDirection.South, AmbientSoundConstants.InBlockNearDistance, half); AddDirection(AmbientDirection.East, AmbientSoundConstants.InBlockNearDistance, half); AddDirection(AmbientDirection.West, AmbientSoundConstants.InBlockNearDistance, half); AddDirection(AmbientDirection.Northwest, AmbientSoundConstants.InBlockNearDistance, half); AddDirection(AmbientDirection.Southwest, AmbientSoundConstants.InBlockNearDistance, half); AddDirection(AmbientDirection.Northeast, AmbientSoundConstants.InBlockNearDistance, half); AddDirection(AmbientDirection.Southeast, AmbientSoundConstants.InBlockNearDistance, half); } /// /// AddDir @ 0x550CF0: widen an existing shell for this bearing /// or append a new one. Retail keeps at most eight. /// private void AddDirection(AmbientDirection direction, float min, float max) { for (int i = 0; i < _directions.Count; i++) { if (_directions[i].Direction != direction) continue; AmbientDirectionShell existing = _directions[i]; _directions[i] = new AmbientDirectionShell( direction, MathF.Min(existing.MinDistance, min), MathF.Max(existing.MaxDistance, max)); return; } if (_directions.Count >= 8) return; _directions.Add(new AmbientDirectionShell(direction, min, max)); } /// /// UpdateSound (0x551540 continuous, 0x551310 /// intermittent). is the sum over ALL /// ambients, not per-descriptor — that denominator is what makes the mix a /// terrain-share crossfade. /// public void UpdateSound(float totalSoundCount) { if (Descriptor.IsContinuous) { if (SoundCount == 0f) { CurrentVolume = 0f; return; } CurrentVolume = Descriptor.Volume / totalSoundCount * SoundCount; return; } // Intermittent: note the asymmetry — a zero weight leaves PlayChance // ALONE rather than zeroing it. Only ResetCount clears it. if (SoundCount <= 0f) return; PlayChance = Descriptor.BaseChance / totalSoundCount * SoundCount; } /// /// CanHear (0x550FD0 continuous, 0x550F80 /// intermittent). Both compares are rendered as an unimplemented predicate /// by Binary Ninja and would port inverted. /// public bool CanHear() => Descriptor.IsContinuous ? CurrentVolume >= AmbientSoundConstants.MinVolume : PlayChance > 0f; /// /// PlayNow (continuous is a folded mov eax,1 — ALWAYS true; /// intermittent rolls against @ 0x550FA0). /// public bool PlayNow(ISoundRandom rng) { ArgumentNullException.ThrowIfNull(rng); return Descriptor.IsContinuous || rng.NextVariantRoll() <= PlayChance; } /// /// GetVolume: the crossfaded value for a continuous bed /// (0x551070 returns the AUTHORED volume for intermittent — the /// crossfade lives in its probability instead). /// public float GetVolume() => Descriptor.IsContinuous ? CurrentVolume : Descriptor.Volume; /// /// GetPlayInterval: intermittent rolls between the authored rates /// (0x551080); continuous uses min_rate alone /// (0x5510A0) — that rate IS the author's intended loop period, which /// is how retail fakes a sustained bed without a looping voice. /// public float GetPlayInterval(ISoundRandom rng) { ArgumentNullException.ThrowIfNull(rng); return Descriptor.IsContinuous ? Descriptor.MinRate : RollDice(Descriptor.MinRate, Descriptor.MaxRate, rng); } /// /// GetSoundPos @ 0x551350: offset the LISTENER's position in /// the XY plane, keeping their Z. Returns false for a continuous bed, whose /// base implementation is a folded xor eax,eax — no position at all, /// so it plays from centre. /// /// /// The distance is min + (max − min)·t², quadratically biased toward /// min; a linear lerp puts intermittent ambients audibly further away /// on average. /// /// public bool TryGetSoundPosition( Vector3 listenerPosition, ISoundRandom rng, out Vector3 position) { ArgumentNullException.ThrowIfNull(rng); position = listenerPosition; if (Descriptor.IsContinuous || _directions.Count == 0) return false; int index = (int)MathF.Floor(rng.NextVariantRoll() * _directions.Count); if (index >= _directions.Count) index = _directions.Count - 1; AmbientDirectionShell shell = _directions[index]; float spread = AmbientSoundConstants.HeadingSpread; float angle = AmbientSoundConstants.Heading(shell.Direction) + (rng.NextVariantRoll() * spread) - (spread * 0.5f); float t = rng.NextVariantRoll(); float distance = shell.MinDistance + ((shell.MaxDistance - shell.MinDistance) * t * t); // AC's compass convention: north is +Y, east is +X. position = new Vector3( listenerPosition.X + (MathF.Sin(angle) * distance), listenerPosition.Y + (MathF.Cos(angle) * distance), listenerPosition.Z); return true; } /// /// Random::RollDice @ 0x42C600, including its swap on an /// inverted range and its equal-bounds short circuit. /// internal static float RollDice(float min, float max, ISoundRandom rng) { if (min == max) return min; float lo = min, hi = max; if (max < min) { lo = max; hi = min; } return lo + ((hi - lo) * rng.NextVariantRoll()); } } /// /// One accumulated bearing for an intermittent ambient: a distance shell at a /// compass direction. /// public readonly record struct AmbientDirectionShell( AmbientDirection Direction, float MinDistance, float MaxDistance);