acdream had no ambient system: StartAmbient minted a handle and played nothing. Retail's is a weighted-accumulation + timer-queue engine, not looping voices. On every objcell change (24 m) CellManager::ChangePosition rebuilds per-sound weights over the 3x3 landblock ring x 64 land cells each, decoding each cell's terrain word through the region file's terrain -> scene -> AmbientSTBDesc chain; playback is a min-heap of absolute deadlines drained from the frame tick, where each pop fires a one-shot and re-arms. A continuous bed (base_chance == 0) is non-positional, crossfaded by its share of the TOTAL weight, and re-fired every min_rate seconds — that rate is the author's intended loop period, and re-firing is how retail fakes a sustained bed with no looping voice, re-rolling the variant and the crossfade each time. An intermittent one keeps its authored volume, plays at a random accumulated compass bearing at min + (max-min)*t^2, and is dice-gated. Indoors is silent by design: CEnvCell's contributor is a folded ret and EnvCell carries no sound data. The Opus review caught four bugs before this landed, one fatal: - Cell offsets were built in ABSOLUTE world coordinates and differenced against the listener's STREAMED-frame position, so every one of 576 offsets came out ~32 km, every contribution was culled, and the whole feature was silent with nothing logged. Offsets are now landblock-local the way Position::get_offset builds them, and the streamed-frame position is carried separately for playback, where it belongs. - The cell's weight was added to the shared denominator once per DESCRIPTOR instead of once per CELL, dividing every bed's crossfade by the table's entry count — enough to push a typical authored volume under the 0.03 audibility floor. - The drain used where retail's UseTime is strictly below, so a descriptor authored with a zero rate re-armed at the same instant and spun the frame forever. - Arming only enqueued; retail's UpdatePlayQueue PLAYS and then re-arms, so a newly audible ambient was silent for a full period after the crossing that made it audible. Also: beds now go through retail's single 16-voice priority pool rather than acdream's UI pool (retail has one pool; parking beds in the UI pool let an A4 portal cue chop one mid-wave and discarded the authored priority), and CalcDir's in-block test is XY-only, since CalcWeight includes Z on purpose and CalcDir excludes it on purpose. Two behaviours are knowingly incomplete and registered rather than guessed at slice end: TS-66 (sky-lit interiors should keep the outdoor set) and TS-67 (contribution weight is computed in-plane). Retires TS-29. The frame-loop hook is a typed IAmbientFramePhase, not a callback — the first attempt used an Action<float> and the architecture guard ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks correctly rejected it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
9 KiB
C#
235 lines
9 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.Core.Audio;
|
||
|
||
/// <summary>
|
||
/// One ambient firing, as the scheduler hands it to the audio backend.
|
||
/// </summary>
|
||
/// <param name="Instance">The instance that fired (its descriptor names the slot).</param>
|
||
/// <param name="Volume">The volume to play at — crossfaded for a continuous bed.</param>
|
||
/// <param name="Position">
|
||
/// The world position, or null for a continuous bed — retail plays those through
|
||
/// <c>PlayAmbientSoundFromCenter</c>, with no position, no pan and no distance
|
||
/// attenuation: a stereo bed centred on the listener.
|
||
/// </param>
|
||
public readonly record struct AmbientSoundFiring(
|
||
AmbientSoundInstance Instance,
|
||
float Volume,
|
||
Vector3? Position);
|
||
|
||
/// <summary>
|
||
/// 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 <c>min_rate</c> seconds,
|
||
/// which is why an <c>AL_LOOPING</c> port sounds wrong (no re-randomised table
|
||
/// pick, no re-rolled crossfade volume, no gap).
|
||
///
|
||
/// <para>
|
||
/// Ports <c>Ambient::UpdatePlayQueue</c> @ <c>0x551A50</c>,
|
||
/// <c>Ambient::Play</c> @ <c>0x5517A0</c> and <c>Ambient::UseTime</c> @
|
||
/// <c>0x551880</c>.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class AmbientSoundScheduler
|
||
{
|
||
private readonly PriorityQueue<AmbientSoundInstance, double> _queue = new();
|
||
private readonly List<AmbientSoundInstance> _instances = [];
|
||
private readonly ISoundRandom _rng;
|
||
|
||
public AmbientSoundScheduler(ISoundRandom? rng = null) => _rng = rng ?? new SoundRandom();
|
||
|
||
/// <summary>Every live instance, in accumulation order.</summary>
|
||
public IReadOnlyList<AmbientSoundInstance> Instances => _instances;
|
||
|
||
/// <summary>Instances currently holding a deadline. Diagnostic use.</summary>
|
||
public int QueuedCount => _queue.Count;
|
||
|
||
/// <summary>Sum of every instance's weight — the crossfade denominator.</summary>
|
||
public float TotalSoundCount { get; private set; }
|
||
|
||
/// <summary>
|
||
/// Begin a rebuild. <c>Ambient::InitSounds</c> @ <c>0x5515D0</c> resets EVERY
|
||
/// existing instance's counters before the accumulation pass — skipping that
|
||
/// leaves stale bearings and probabilities alive indefinitely, because
|
||
/// <c>IntermitSound::UpdateSound</c> never clears them itself.
|
||
/// </summary>
|
||
public void BeginRebuild()
|
||
{
|
||
foreach (AmbientSoundInstance instance in _instances)
|
||
instance.ResetCount();
|
||
TotalSoundCount = 0f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Register an instance for this world, if it is not already tracked. Called
|
||
/// while walking the contributing land cells.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Contribute one land cell to every ambient its sound table authors —
|
||
/// <c>Ambient::AddSound</c> @ <c>0x551610</c>.
|
||
///
|
||
/// <para>
|
||
/// The cell's weight lands in <see cref="TotalSoundCount"/> exactly ONCE,
|
||
/// however many descriptors the table carries. Retail adds <c>w</c> to
|
||
/// <c>total_sound_count</c> 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.
|
||
/// </para>
|
||
/// </summary>
|
||
public void ContributeCell<TTable>(
|
||
Vector3 offset,
|
||
TTable table,
|
||
Func<TTable, int, AmbientSoundDescriptor> 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);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Contribute one land cell's weight to a single instance. Test seam and the
|
||
/// single-descriptor case; <see cref="ContributeCell{TTable}"/> is the
|
||
/// production path and owns the shared denominator.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Finish a rebuild: recompute every instance's crossfade against the shared
|
||
/// denominator, then arm any audible instance that is not already queued.
|
||
///
|
||
/// <para>
|
||
/// The <c>on_queue</c> 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.
|
||
/// </para>
|
||
/// </summary>
|
||
public void EndRebuild(
|
||
double now,
|
||
ICollection<AmbientSoundFiring>? 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);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Drain every deadline that has come due — <c>Ambient::UseTime</c>. 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.
|
||
/// </summary>
|
||
public void Tick(double now, ICollection<AmbientSoundFiring> 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);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Drop every instance and deadline — <c>Ambient::FlushSoundTables</c> /
|
||
/// <c>ReleaseSoundTables</c>, reached from <c>CellManager::Reset</c>.
|
||
/// </summary>
|
||
public void Clear()
|
||
{
|
||
foreach (AmbientSoundInstance instance in _instances)
|
||
instance.OnQueue = false;
|
||
_instances.Clear();
|
||
_queue.Clear();
|
||
TotalSoundCount = 0f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>Ambient::Play</c> @ <c>0x5517A0</c>: roll, emit if it fires, then
|
||
/// re-arm at <c>cur_time + GetPlayInterval()</c> regardless.
|
||
/// </summary>
|
||
private void Fire(
|
||
AmbientSoundInstance instance,
|
||
double now,
|
||
ICollection<AmbientSoundFiring>? 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;
|
||
}
|
||
}
|