using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Audio;
///
/// One ambient firing, as the scheduler hands it to the audio backend.
///
/// The instance that fired (its descriptor names the slot).
/// The volume to play at — crossfaded for a continuous bed.
///
/// The world position, or null for a continuous bed — retail plays those through
/// PlayAmbientSoundFromCenter, with no position, no pan and no distance
/// attenuation: a stereo bed centred on the listener.
///
public readonly record struct AmbientSoundFiring(
AmbientSoundInstance Instance,
float Volume,
Vector3? Position);
///
/// Retail's ambient playback engine: a min-heap of ABSOLUTE deadlines, drained
/// once per frame, where each pop plays a one-shot and immediately re-arms
/// itself. There is no looping voice anywhere in retail's audio path — a
/// "continuous" ambient is a one-shot re-fired every min_rate seconds,
/// which is why an AL_LOOPING port sounds wrong (no re-randomised table
/// pick, no re-rolled crossfade volume, no gap).
///
///
/// Ports Ambient::UpdatePlayQueue @ 0x551A50,
/// Ambient::Play @ 0x5517A0 and Ambient::UseTime @
/// 0x551880.
///
///
public sealed class AmbientSoundScheduler
{
private readonly PriorityQueue _queue = new();
private readonly List _instances = [];
private readonly ISoundRandom _rng;
public AmbientSoundScheduler(ISoundRandom? rng = null) => _rng = rng ?? new SoundRandom();
/// Every live instance, in accumulation order.
public IReadOnlyList Instances => _instances;
/// Instances currently holding a deadline. Diagnostic use.
public int QueuedCount => _queue.Count;
/// Sum of every instance's weight — the crossfade denominator.
public float TotalSoundCount { get; private set; }
///
/// Begin a rebuild. Ambient::InitSounds @ 0x5515D0 resets EVERY
/// existing instance's counters before the accumulation pass — skipping that
/// leaves stale bearings and probabilities alive indefinitely, because
/// IntermitSound::UpdateSound never clears them itself.
///
public void BeginRebuild()
{
foreach (AmbientSoundInstance instance in _instances)
instance.ResetCount();
TotalSoundCount = 0f;
}
///
/// Register an instance for this world, if it is not already tracked. Called
/// while walking the contributing land cells.
///
public AmbientSoundInstance Track(AmbientSoundDescriptor descriptor, uint soundTableDid)
{
foreach (AmbientSoundInstance existing in _instances)
{
if (existing.Descriptor == descriptor && existing.SoundTableDid == soundTableDid)
return existing;
}
var created = new AmbientSoundInstance(descriptor, soundTableDid);
_instances.Add(created);
return created;
}
///
/// Contribute one land cell to every ambient its sound table authors —
/// Ambient::AddSound @ 0x551610.
///
///
/// The cell's weight lands in exactly ONCE,
/// however many descriptors the table carries. Retail adds w to
/// total_sound_count before looping the descriptors, so with an
/// N-entry table the per-instance counts sum to N × the denominator — a
/// deliberate retail property, not an oversight. Adding the weight per
/// descriptor instead divides every bed's crossfade by N, which pushes a
/// typical authored volume under the 0.03 audibility floor and silences it.
///
///
public void ContributeCell(
Vector3 offset,
TTable table,
Func descriptorAt)
where TTable : DatReaderWriter.Types.AmbientSTBDesc
{
ArgumentNullException.ThrowIfNull(table);
ArgumentNullException.ThrowIfNull(descriptorAt);
float weight = AmbientSoundConstants.CalcWeight(offset);
if (weight <= 0f)
return;
AmbientDirection direction = AmbientSoundConstants.CalcDirection(offset);
TotalSoundCount += weight;
for (int i = 0; i < table.AmbientSounds.Count; i++)
{
AmbientSoundInstance instance = Track(descriptorAt(table, i), table.STBId);
instance.AddTo(weight, offset, direction);
}
}
///
/// Contribute one land cell's weight to a single instance. Test seam and the
/// single-descriptor case; is the
/// production path and owns the shared denominator.
///
public void Contribute(AmbientSoundInstance instance, Vector3 offset)
{
ArgumentNullException.ThrowIfNull(instance);
float weight = AmbientSoundConstants.CalcWeight(offset);
if (weight <= 0f)
return;
instance.AddTo(weight, offset, AmbientSoundConstants.CalcDirection(offset));
TotalSoundCount += weight;
}
///
/// Finish a rebuild: recompute every instance's crossfade against the shared
/// denominator, then arm any audible instance that is not already queued.
///
///
/// The on_queue guard is load-bearing in both directions. Re-arming
/// unconditionally restarts every ambient on every 24 m crossing — audible as
/// a machine-gun of one-shots. Never re-arming leaves a newly-audible ambient
/// silent until the next rebuild.
///
///
public void EndRebuild(
double now,
ICollection? firings = null,
Vector3 listenerPosition = default)
{
foreach (AmbientSoundInstance instance in _instances)
instance.UpdateSound(TotalSoundCount);
foreach (AmbientSoundInstance instance in _instances)
{
if (instance.OnQueue || !instance.CanHear())
continue;
// UpdatePlayQueue @ 0x551A50 calls Play for every on_queue == 0
// sound, and Play @ 0x5517A0 PLAYS first and then re-arms. There is
// no initial delay: an ambient that becomes audible at a crossing
// sounds at the crossing, not one min_rate later.
Fire(instance, now, firings, listenerPosition);
}
}
///
/// Drain every deadline that has come due — Ambient::UseTime. Each pop
/// either fires (and re-arms) or, if the instance can no longer be heard,
/// leaves the queue entirely until a later rebuild re-arms it.
///
public void Tick(double now, ICollection firings, Vector3 listenerPosition)
{
ArgumentNullException.ThrowIfNull(firings);
// Ambient::UseTime @ 0x551880 breaks on !(key < cur_time) — STRICTLY
// below. That is also what stops a descriptor authored with a zero rate
// from re-arming at the same instant and spinning this loop forever.
while (_queue.TryPeek(out _, out double deadline) && deadline < now)
{
AmbientSoundInstance instance = _queue.Dequeue();
instance.OnQueue = false;
// Ambient::Play @ 0x5517A0 — an inaudible instance drops off the
// queue rather than re-arming.
if (!instance.CanHear())
continue;
Fire(instance, now, firings, listenerPosition);
}
}
///
/// Drop every instance and deadline — Ambient::FlushSoundTables /
/// ReleaseSoundTables, reached from CellManager::Reset.
///
public void Clear()
{
foreach (AmbientSoundInstance instance in _instances)
instance.OnQueue = false;
_instances.Clear();
_queue.Clear();
TotalSoundCount = 0f;
}
///
/// Ambient::Play @ 0x5517A0: roll, emit if it fires, then
/// re-arm at cur_time + GetPlayInterval() regardless.
///
private void Fire(
AmbientSoundInstance instance,
double now,
ICollection? firings,
Vector3 listenerPosition)
{
if (firings is not null && instance.PlayNow(_rng))
{
Vector3? position =
instance.TryGetSoundPosition(listenerPosition, _rng, out Vector3 resolved)
? resolved
: null;
firings.Add(new AmbientSoundFiring(instance, instance.GetVolume(), position));
}
Enqueue(instance, now);
}
private void Enqueue(AmbientSoundInstance instance, double now)
{
_queue.Enqueue(instance, now + instance.GetPlayInterval(_rng));
instance.OnQueue = true;
}
}