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 == 0 ⇒ ConstantSound (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);