Two items from listening-gate round 2. Mute: AcdreamToggleAudioMute (default Ctrl+M; bare M is selection) flips OpenAlAudioEngine.Muted, implemented as the AL LISTENER gain - unused since A2 moved all mixing to the CPU, so it is a free master switch that silences already-playing voices instantly and restores them exactly, without touching the retail mixing math, the -50 dB allocation cutoff, or any persisted volume setting. Rebindable like every other action; console line confirms each flip. Inn chatter: the user hears talk-and-laughter ambience in retail inns and not in acdream. Three installed-dat scans (pinned as conformance tests in EnvCellSoundEmitterInventoryTests) prove the mechanism is NOT client data: no interior static in the town landblock carries an ambient-slot sound table, no Setup among all 5,935 in the portal dat references one, and yet 23 sound tables carrying ONLY Ambient1..8 slots exist - pure soundscape banks with nothing client-side pointing at them. They are wire-bound: the server attaches one to an emitter object via CreateObject's sound-table field and fires the slots over 0xF750 - ACE implements exactly this (EmoteType.Sound heartbeat emotes -> GameMessageSound broadcast). Our 0xF750 receiver (slice A3) is live and now instrumented (ACDREAM_PROBE_SOUND_WIRE=1, via the new AudioDiagnostics owner per Code Structure Rule 5, with per-event drop reasons in AudioHookSink.PlayServerSound). A probed session against the local ACE received ZERO 0xF750 events across a town walkabout: the silence is server world-content (no emitters configured/firing), not a client drop. The first scan's assertion originally encoded the emitter-object hypothesis; the data refuted it, and the test now pins the negative so the conclusion cannot silently rot. Full Release suite green (the one failure during development was the hypothesis-pinning assertion, corrected to pin the finding). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
718 lines
28 KiB
C#
718 lines
28 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.Core.Audio;
|
||
using Silk.NET.OpenAL;
|
||
|
||
namespace AcDream.App.Audio;
|
||
|
||
/// <summary>
|
||
/// OpenAL-backed audio engine. Spatialization is NOT OpenAL's: retail creates
|
||
/// every gameplay buffer 2D (<c>m_3D = 0</c>) and computes a gain and a stereo
|
||
/// pan on the CPU per voice, so <see cref="RetailSoundMixer"/> owns that math
|
||
/// and AL is reduced to a voice bank. Every source is source-relative with
|
||
/// <c>AL_ROLLOFF_FACTOR = 0</c>.
|
||
///
|
||
/// <para>
|
||
/// Architecture:
|
||
/// <list type="bullet">
|
||
/// <item><description>
|
||
/// Single <see cref="ALContext"/> + <see cref="AL"/> bound to the
|
||
/// system default device. Cross-platform (WASAPI / WinMM /
|
||
/// PulseAudio / CoreAudio — whichever OpenAL-Soft picks).
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// Fixed 16-source pool for world sounds, allocated by
|
||
/// <see cref="RetailVoicePool"/>: a ring scan for a free or finished slot,
|
||
/// then eviction of the first slot whose DAT-authored priority is strictly
|
||
/// lower, else the sound is dropped. Retail's allocator is
|
||
/// <c>SoundManager::PlaySoundInternal</c> @ <c>0x0054FEC0</c> and it never
|
||
/// consults gain. (This comment previously cited <c>FUN_00550ad0</c> and
|
||
/// described gain-based eviction; that address is inside an
|
||
/// <c>IntrusiveHashTable</c> constructor and the behaviour was ours, not
|
||
/// retail's — both corrected in Campaign A slice A2, register row AP-28.)
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// Separate UI source pool (4 sources) for flat 2D UI clicks /
|
||
/// wooshes — not subject to the world pool's eviction game.
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// PCM buffer cache keyed by Wave dat id so the same footstep isn't
|
||
/// re-uploaded to the GL-equivalent AL buffers on every hit. Bounded
|
||
/// by a byte budget (<see cref="DefaultBufferByteBudget"/>) enforced
|
||
/// with LRU eviction — see <see cref="EvictBuffersOverBudget"/>. A
|
||
/// buffer still attached to a live source is never evicted (AL
|
||
/// rejects deleting a bound buffer); eviction re-queries live AL
|
||
/// source state rather than tracking a second copy of it.
|
||
/// </description></item>
|
||
/// </list>
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Thread-safety: the engine is called only from the render thread
|
||
/// (the same thread that drives <c>TickAnimations</c>). No locks inside.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Fail-open: when the OpenAL driver can't be initialised (missing
|
||
/// library on a headless CI box, or explicitly disabled via
|
||
/// <c>ACDREAM_NO_AUDIO=1</c>), <see cref="IsAvailable"/> is false and all
|
||
/// Play* calls are no-ops. This lets the rest of the client run
|
||
/// unaffected.
|
||
/// </para>
|
||
/// </summary>
|
||
internal interface IWorldAudioQuiescence
|
||
{
|
||
void SuspendWorldAudio();
|
||
void ResumeWorldAudio();
|
||
}
|
||
|
||
public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescence
|
||
{
|
||
// ── Backends ─────────────────────────────────────────────────────────────
|
||
private AL? _al;
|
||
private OpenAlResourceLifetime? _resources;
|
||
private bool _available;
|
||
private bool _disposed;
|
||
|
||
// ── Pools ────────────────────────────────────────────────────────────────
|
||
private const int PoolSize3D = 16; // retail 16-slot voice pool
|
||
private const int PoolSizeUi = 4;
|
||
|
||
// Slot state per 3D source, mirroring retail's `SoundPlayingData` —
|
||
// {buffer, priority, start_time}. There is no gain field: retail's
|
||
// allocator compares priority only, and its start_time is written but never
|
||
// read, so neither a gain nor a timestamp is carried here.
|
||
private sealed class Slot3D
|
||
{
|
||
public uint SourceId;
|
||
public uint OwnerId;
|
||
public bool InUse;
|
||
// The DAT-authored priority, a float in [0,1] — NOT an 0..7 int. 4,100
|
||
// of the shipped entries carry a sub-1.0 priority that an int cast
|
||
// collapsed to 0, which flattened the eviction ordering this field
|
||
// exists for. A2 makes eviction compare it.
|
||
public float Priority;
|
||
}
|
||
private readonly Slot3D[] _pool3D = CreateWorldSlots();
|
||
private int _pool3DCursor; // round-robin start
|
||
private bool _worldAudioSuspended;
|
||
|
||
private readonly uint[] _poolUi = new uint[PoolSizeUi];
|
||
|
||
// ── Listener (retail's SmartBox::viewer: origin + compass heading) ───────
|
||
private Vector3 _listenerPosition;
|
||
private float _listenerHeadingDegrees;
|
||
|
||
/// <summary>
|
||
/// Half-width of the stereo pan arc, in degrees — OpenAL Soft's own
|
||
/// front-left/front-right speaker angle for a stereo device, so a normalised
|
||
/// stereo position of ±1 lands exactly on a speaker.
|
||
///
|
||
/// <para>
|
||
/// Positions are NOT retail's pan scaled linearly onto this arc.
|
||
/// <see cref="RetailSoundMixer.StereoPositionFromPan"/> inverts the
|
||
/// constant-power pan law first, so retail's ±15 dB inter-channel difference
|
||
/// maps to ±0.775 of the arc and both channels stay live; a linear mapping
|
||
/// would put full deflection on the speaker angle itself, giving effectively
|
||
/// infinite separation where retail gives 15 dB. The pan's shape is retail's
|
||
/// throughout (sine of the compass bearing, dead centre inside 5 m, no
|
||
/// front/back and no elevation, frozen for the voice's life); only the pan
|
||
/// LAW is approximated, since OpenAL exposes no per-channel gain for a mono
|
||
/// source. Registered as AP-173.
|
||
/// </para>
|
||
/// </summary>
|
||
private const float MaxPanAzimuthDegrees = 30f;
|
||
|
||
// ── Buffer cache (Wave dat id → AL buffer) ───────────────────────────────
|
||
// Budget rationale: decoded PCM waves run ~100-500 KB each (same sizing
|
||
// as DatSoundCache's payload LRU, which this cache re-uploads from). 48
|
||
// MiB gives comfortable headroom for the working set of a play session
|
||
// (roughly 100-480 resident buffers) while keeping the native AL-side
|
||
// copy from growing without bound over a long-uptime process (the
|
||
// 30-bot headless-fleet target).
|
||
internal const long DefaultBufferByteBudget = 48L * 1024 * 1024; // 48 MiB
|
||
private readonly Dictionary<uint, uint> _bufferByWaveId = new();
|
||
private readonly AlBufferBudgetTracker _bufferBudget = new(DefaultBufferByteBudget);
|
||
|
||
// ── Public volume knobs ──────────────────────────────────────────────────
|
||
public float MasterVolume { get; set; } = 1f;
|
||
|
||
private bool _muted;
|
||
|
||
/// <summary>
|
||
/// Instant all-voices mute. Implemented as the AL LISTENER gain — unused
|
||
/// since A2 moved all mixing to the CPU, so it is a free master switch
|
||
/// that silences already-playing voices immediately and restores them
|
||
/// exactly, without touching the retail mixing math, the −50 dB
|
||
/// allocation cutoff, or any persisted volume setting.
|
||
/// </summary>
|
||
public bool Muted
|
||
{
|
||
get => _muted;
|
||
set
|
||
{
|
||
_muted = value;
|
||
if (_available && _al is not null)
|
||
_al.SetListenerProperty(ListenerFloat.Gain, value ? 0f : 1f);
|
||
}
|
||
}
|
||
public float SfxVolume { get; set; } = 1f;
|
||
public float AmbientVolume{ get; set; } = 0.8f;
|
||
public bool IsAvailable => _available;
|
||
|
||
/// <summary>Estimated bytes currently resident in the AL buffer cache. Diagnostic use only.</summary>
|
||
public long ResidentBufferBytes => _bufferBudget.ResidentBytes;
|
||
|
||
/// <summary>Number of AL buffers currently resident. Diagnostic use only.</summary>
|
||
public int ResidentBufferCount => _bufferBudget.Count;
|
||
|
||
public OpenAlAudioEngine()
|
||
: this(new SilkOpenAlResourceApiFactory())
|
||
{
|
||
}
|
||
|
||
internal OpenAlAudioEngine(IOpenAlResourceApiFactory apiFactory)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(apiFactory);
|
||
IOpenAlResourceApi api;
|
||
try
|
||
{
|
||
api = apiFactory.Create();
|
||
}
|
||
catch
|
||
{
|
||
return;
|
||
}
|
||
|
||
_al = api.AudioApi;
|
||
_resources = new OpenAlResourceLifetime(api);
|
||
try
|
||
{
|
||
if (!_resources.TryOpenDevice())
|
||
{
|
||
return;
|
||
}
|
||
if (!_resources.TryCreateContext())
|
||
{
|
||
DisableAfterInitializationFailure(
|
||
new InvalidOperationException("OpenAL could not create a context."));
|
||
return;
|
||
}
|
||
if (!_resources.TryMakeCurrent())
|
||
{
|
||
DisableAfterInitializationFailure(
|
||
new InvalidOperationException("OpenAL could not activate its context."));
|
||
return;
|
||
}
|
||
|
||
// Initialise 3D source pool.
|
||
for (int i = 0; i < PoolSize3D; i++)
|
||
{
|
||
uint src = _resources.Create3DSource();
|
||
_pool3D[i].SourceId = src;
|
||
}
|
||
|
||
// UI sources are source-relative (attached to listener) so they
|
||
// ignore 3D position.
|
||
for (int i = 0; i < PoolSizeUi; i++)
|
||
{
|
||
uint src = _resources.CreateUiSource();
|
||
_poolUi[i] = src;
|
||
}
|
||
|
||
// Global distance model = inverse-square clamped (classic retail feel).
|
||
api.DisableAlDistanceAttenuation();
|
||
|
||
_available = true;
|
||
}
|
||
catch (OpenAlInitializationException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (Exception failure)
|
||
{
|
||
DisableAfterInitializationFailure(failure);
|
||
}
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed)
|
||
return;
|
||
|
||
_available = false;
|
||
_resources?.RetryCleanup();
|
||
_disposed = _resources is null || _resources.IsCleanupComplete;
|
||
}
|
||
|
||
internal bool IsDisposalComplete =>
|
||
_disposed || _resources is null || _resources.IsCleanupComplete;
|
||
|
||
private void DisableAfterInitializationFailure(Exception failure)
|
||
{
|
||
_available = false;
|
||
if (_resources is null)
|
||
return;
|
||
|
||
try
|
||
{
|
||
_resources.RetryCleanup();
|
||
}
|
||
catch (AggregateException cleanupFailure)
|
||
{
|
||
throw new OpenAlInitializationException(
|
||
failure,
|
||
_resources,
|
||
cleanupFailure);
|
||
}
|
||
|
||
_al = null;
|
||
}
|
||
|
||
// ── IAudioEngine ─────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Records the listener pose retail's mixer reads: origin (for distance)
|
||
/// and compass heading (for pan). No AL listener orientation is published —
|
||
/// every voice is source-relative and its pan is computed on the CPU, so
|
||
/// AL's own panner must not also rotate the field.
|
||
/// </summary>
|
||
public void SetListener(float posX, float posY, float posZ, float headingDegrees)
|
||
{
|
||
_listenerPosition = new Vector3(posX, posY, posZ);
|
||
_listenerHeadingDegrees = headingDegrees;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The master multiply retail's <c>GetAttenuation</c> applies — the effect
|
||
/// knob for world/UI sounds, folded with acdream's extra master slider.
|
||
///
|
||
/// <para>
|
||
/// It is folded in HERE, before the mixer, rather than published as AL's
|
||
/// listener gain, because retail's audibility decisions are made against the
|
||
/// post-master value: the −50 dB no-allocate floor, the audible radius, and
|
||
/// the whole-decibel quantisation all move with the knob. Applying it
|
||
/// downstream as a listener gain would compute the cutoff against a louder
|
||
/// signal than the user hears, and would allocate voices at master 0 where
|
||
/// retail's <c>g <= 0</c> gate drops them.
|
||
/// </para>
|
||
/// </summary>
|
||
private float EffectMaster => MasterVolume * SfxVolume;
|
||
|
||
/// <summary>
|
||
/// Not exposed on IAudioEngine but used by the hook sink — play a raw
|
||
/// WaveData blob at a 3D position with full priority/volume controls.
|
||
/// Returns true on success, false if the buffer was rejected.
|
||
/// </summary>
|
||
public bool Play3DWave(
|
||
uint ownerId,
|
||
uint waveId,
|
||
WaveData wave,
|
||
Vector3 position,
|
||
float volume,
|
||
float priority)
|
||
{
|
||
if (_worldAudioSuspended || !_available || _al is null) return false;
|
||
|
||
// Retail computes gain and pan BEFORE touching the voice pool, and a
|
||
// sound that attenuates past -50 dB is never started at all — so it
|
||
// consumes no slot and evicts nothing. At volume × master == 1 that
|
||
// silence radius is about 94 metres.
|
||
RetailVoiceMix mix = RetailSoundMixer.Mix(
|
||
_listenerPosition,
|
||
_listenerHeadingDegrees,
|
||
position,
|
||
volume,
|
||
EffectMaster);
|
||
if (!mix.Play) return false;
|
||
|
||
uint buffer = EnsureBuffer(waveId, wave);
|
||
if (buffer == 0) return false;
|
||
|
||
int slotIdx = AcquireWorldSlot(priority);
|
||
if (slotIdx < 0) return false; // nothing lower-priority — drop
|
||
|
||
float gain = RetailSoundMixer.LinearGain(mix.Decibels);
|
||
var slot = _pool3D[slotIdx];
|
||
_al.SourceStop(slot.SourceId);
|
||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0); // detach old
|
||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
|
||
_al.SetSourceProperty(slot.SourceId, SourceFloat.Gain, gain);
|
||
// No pitch: retail never calls SetFrequency on a sound buffer, so
|
||
// there is no per-play pitch variation to reproduce.
|
||
ApplyPan(slot.SourceId, mix.Pan);
|
||
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
|
||
_al.SourcePlay(slot.SourceId);
|
||
|
||
slot.InUse = true;
|
||
slot.OwnerId = ownerId;
|
||
slot.Priority = priority;
|
||
_pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Projects the live pool into <see cref="RetailVoicePool"/>'s slot view and
|
||
/// takes its answer. The allocation policy itself lives in Core so it can be
|
||
/// tested without an AL device; this method only supplies the one piece of
|
||
/// state AL owns — whether each slot's voice is still playing.
|
||
/// </summary>
|
||
private int AcquireWorldSlot(float priority)
|
||
{
|
||
Span<VoiceSlotState> slots = stackalloc VoiceSlotState[PoolSize3D];
|
||
for (int i = 0; i < PoolSize3D; i++)
|
||
{
|
||
Slot3D s = _pool3D[i];
|
||
slots[i] = new VoiceSlotState(
|
||
Occupied: s.InUse,
|
||
StillPlaying: s.InUse && IsStillPlaying(s.SourceId),
|
||
Priority: s.Priority);
|
||
}
|
||
|
||
return RetailVoicePool.Acquire(slots, _pool3DCursor, priority);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Publishes retail's whole-decibel pan as a source-relative azimuth. The
|
||
/// source sits on a unit arc in front of the listener so a pan of 0 is dead
|
||
/// ahead (centred) and the deflection is purely left/right — retail
|
||
/// distinguishes neither front from back nor elevation. Distance plays no
|
||
/// part: rolloff is 0 and the CPU-computed gain is authoritative.
|
||
///
|
||
/// <para>
|
||
/// The azimuth comes from <see cref="RetailSoundMixer.StereoPositionFromPan"/>,
|
||
/// which inverts the constant-power pan law so the resulting inter-channel
|
||
/// difference is retail's ±15 dB rather than the full separation a linear
|
||
/// mapping onto the speaker angle would produce. See AP-172.
|
||
/// </para>
|
||
/// </summary>
|
||
private void ApplyPan(uint sourceId, int pan)
|
||
{
|
||
float position = RetailSoundMixer.StereoPositionFromPan(pan);
|
||
float azimuth = position * MaxPanAzimuthDegrees * (MathF.PI / 180f);
|
||
_al!.SetSourceProperty(sourceId, SourceBoolean.SourceRelative, true);
|
||
_al.SetSourceProperty(
|
||
sourceId,
|
||
SourceVector3.Position,
|
||
MathF.Sin(azimuth),
|
||
0f,
|
||
-MathF.Cos(azimuth));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Stops every world-space voice while preserving the independent UI
|
||
/// source pool. Retail suppresses ambient/object audio while cell loading
|
||
/// blocks world maintenance; stopped voices are not resumed afterward.
|
||
/// </summary>
|
||
public void SuspendWorldAudio()
|
||
{
|
||
_worldAudioSuspended = true;
|
||
for (int i = 0; i < _pool3D.Length; i++)
|
||
StopWorldSlot(_pool3D[i]);
|
||
}
|
||
|
||
public void ResumeWorldAudio() => _worldAudioSuspended = false;
|
||
|
||
internal void StopAllForOwner(uint ownerId)
|
||
{
|
||
if (ownerId == 0)
|
||
return;
|
||
|
||
for (int i = 0; i < _pool3D.Length; i++)
|
||
{
|
||
Slot3D slot = _pool3D[i];
|
||
if (slot.InUse && slot.OwnerId == ownerId)
|
||
StopWorldSlot(slot);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Play a raw WaveData blob as a 2D UI sound (no falloff, ignores
|
||
/// listener position).
|
||
/// </summary>
|
||
public bool PlayUiWave(uint waveId, WaveData wave, float volume = 1f)
|
||
{
|
||
if (!_available || _al is null) return false;
|
||
|
||
uint buffer = EnsureBuffer(waveId, wave);
|
||
if (buffer == 0) return false;
|
||
|
||
// UI pool: find a free source (first not-playing), else round-robin.
|
||
int slotIdx = -1;
|
||
for (int i = 0; i < PoolSizeUi; i++)
|
||
{
|
||
if (!IsStillPlaying(_poolUi[i])) { slotIdx = i; break; }
|
||
}
|
||
if (slotIdx < 0) slotIdx = 0; // always replace slot 0 as a last resort
|
||
|
||
// Retail's interface sounds go through PlaySoundFromCenter: pan 0, and
|
||
// GetAttenuation at distance 0 (so the flat branch), scaled by
|
||
// effect_sound_volume — NOT by interface_sound_volume, which retail
|
||
// registers as a preference and then never reads.
|
||
if (!RetailSoundMixer.TryGetAttenuation(0f, volume, EffectMaster, out int decibels))
|
||
return false;
|
||
|
||
uint src = _poolUi[slotIdx];
|
||
_al.SourceStop(src);
|
||
_al.SetSourceProperty(src, SourceInteger.Buffer, 0);
|
||
_al.SetSourceProperty(src, SourceInteger.Buffer, (int)buffer);
|
||
_al.SetSourceProperty(src, SourceFloat.Gain, RetailSoundMixer.LinearGain(decibels));
|
||
_al.SourcePlay(src);
|
||
return true;
|
||
}
|
||
|
||
// IAudioEngine implementations — the enum-based overloads are less
|
||
// useful than the raw-Wave overloads above, since the hook sink already
|
||
// has access to decoded WaveData. Left as no-ops for now; R5 defines
|
||
// SoundId as a sparse subset of retail enums.
|
||
|
||
public void PlayUi(SoundId id) { /* handled via AudioHookSink */ }
|
||
|
||
public void Play3D(SoundId id, float x, float y, float z) { /* handled via AudioHookSink */ }
|
||
|
||
/// <summary>
|
||
/// Play a positional ambient one-shot — retail's
|
||
/// <c>SoundManager::PlayAmbientSound</c> @ <c>0x00550820</c>. Identical to a
|
||
/// world sound except that <c>GetAttenuation</c> is told this is ambient, so
|
||
/// the AMBIENT volume knob is the master multiply rather than the effect one.
|
||
/// </summary>
|
||
public bool PlayAmbient3DWave(
|
||
uint waveId,
|
||
WaveData wave,
|
||
Vector3 position,
|
||
float volume,
|
||
float priority)
|
||
{
|
||
if (_worldAudioSuspended || !_available || _al is null) return false;
|
||
|
||
RetailVoiceMix mix = RetailSoundMixer.Mix(
|
||
_listenerPosition,
|
||
_listenerHeadingDegrees,
|
||
position,
|
||
volume,
|
||
AmbientMaster);
|
||
if (!mix.Play) return false;
|
||
|
||
uint buffer = EnsureBuffer(waveId, wave);
|
||
if (buffer == 0) return false;
|
||
|
||
int slotIdx = AcquireWorldSlot(priority);
|
||
if (slotIdx < 0) return false;
|
||
|
||
Slot3D slot = _pool3D[slotIdx];
|
||
_al.SourceStop(slot.SourceId);
|
||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
|
||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
|
||
_al.SetSourceProperty(
|
||
slot.SourceId,
|
||
SourceFloat.Gain,
|
||
RetailSoundMixer.LinearGain(mix.Decibels));
|
||
ApplyPan(slot.SourceId, mix.Pan);
|
||
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
|
||
_al.SourcePlay(slot.SourceId);
|
||
|
||
slot.InUse = true;
|
||
slot.OwnerId = 0;
|
||
slot.Priority = priority;
|
||
_pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Play a non-positional ambient bed — retail's
|
||
/// <c>SoundManager::PlayAmbientSoundFromCenter</c> @ <c>0x005508B0</c>. A
|
||
/// continuous ambient has no position at all (its <c>GetSoundPos</c> is a
|
||
/// folded <c>xor eax,eax</c>), so there is no pan and no distance
|
||
/// attenuation: it is a stereo bed centred on the listener. Distance 0 takes
|
||
/// the flat branch of the curve, scaled by the ambient knob.
|
||
/// </summary>
|
||
public bool PlayAmbientFromCenter(
|
||
uint waveId,
|
||
WaveData wave,
|
||
float volume,
|
||
float priority)
|
||
{
|
||
if (_worldAudioSuspended || !_available || _al is null) return false;
|
||
|
||
if (!RetailSoundMixer.TryGetAttenuation(0f, volume, AmbientMaster, out int decibels))
|
||
return false;
|
||
|
||
uint buffer = EnsureBuffer(waveId, wave);
|
||
if (buffer == 0) return false;
|
||
|
||
// The SAME 16-voice priority pool as everything else. Retail has exactly
|
||
// one (`SoundManager::playing_sounds_[0x10]`), reached by
|
||
// PlayAmbientSoundFromCenter @ 0x5508B0 -> PlaySoundInternal @ 0x54FEC0;
|
||
// the UI pool is acdream's own. Parking beds there would let a portal
|
||
// cue chop one mid-wave and would discard the authored priority.
|
||
int slotIdx = AcquireWorldSlot(priority);
|
||
if (slotIdx < 0) return false;
|
||
|
||
Slot3D slot = _pool3D[slotIdx];
|
||
_al.SourceStop(slot.SourceId);
|
||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
|
||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
|
||
_al.SetSourceProperty(
|
||
slot.SourceId,
|
||
SourceFloat.Gain,
|
||
RetailSoundMixer.LinearGain(decibels));
|
||
ApplyPan(slot.SourceId, 0); // dead centre: a bed has no bearing
|
||
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
|
||
_al.SourcePlay(slot.SourceId);
|
||
|
||
slot.InUse = true;
|
||
slot.OwnerId = 0;
|
||
slot.Priority = priority;
|
||
_pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The ambient master multiply, folded with acdream's extra master slider —
|
||
/// the ambient counterpart of <see cref="EffectMaster"/>. See AP-174.
|
||
/// </summary>
|
||
private float AmbientMaster => MasterVolume * AmbientVolume;
|
||
|
||
|
||
// ── Private helpers ──────────────────────────────────────────────────────
|
||
|
||
private uint EnsureBuffer(uint waveId, WaveData wave)
|
||
{
|
||
if (!_available || _al is null) return 0;
|
||
if (_bufferByWaveId.TryGetValue(waveId, out var existing))
|
||
{
|
||
// Buffer id 0 is the "unsupported format" negative marker — no
|
||
// payload, not tracked by the budget, nothing to touch.
|
||
if (existing != 0)
|
||
_bufferBudget.Touch(waveId);
|
||
return existing;
|
||
}
|
||
|
||
uint buf = _al.GenBuffer();
|
||
_resources!.OwnBuffer(buf);
|
||
BufferFormat fmt = PickFormat(wave);
|
||
if (fmt == 0)
|
||
{
|
||
_resources.ReleaseBuffer(buf);
|
||
_bufferByWaveId[waveId] = 0;
|
||
return 0;
|
||
}
|
||
|
||
fixed (byte* p = wave.PcmBytes)
|
||
_al.BufferData(buf, fmt, p, wave.PcmBytes.Length, wave.SampleRate);
|
||
|
||
_bufferByWaveId[waveId] = buf;
|
||
_bufferBudget.RecordCreated(waveId, buf, wave.PcmBytes.Length);
|
||
// The buffer we just created is protected for this call: the
|
||
// caller hasn't attached it to a source yet, so live AL state
|
||
// would (wrongly) report it as evictable.
|
||
EvictBuffersOverBudget(protectedBufferId: buf);
|
||
return buf;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Evict least-recently-used AL buffers until the resident-byte budget
|
||
/// is satisfied again. A buffer still bound to a live source (3D pool,
|
||
/// UI pool, or an ambient source) is protected — <c>alDeleteBuffers</c>
|
||
/// fails on a buffer that's still attached to a source — so eviction
|
||
/// never targets one; nor does it target <paramref name="protectedBufferId"/>,
|
||
/// the buffer <see cref="EnsureBuffer"/> just created for this call and
|
||
/// hasn't attached to a source yet. If every resident buffer is
|
||
/// protected the budget is temporarily exceeded rather than looping
|
||
/// forever; the bounded pool sizes (16 + 4 + ambient) cap how large
|
||
/// that overage can get. Evicted waves replay through
|
||
/// <see cref="EnsureBuffer"/> again on next use — re-upload from
|
||
/// <see cref="DatSoundCache"/>, identical to a first play.
|
||
/// </summary>
|
||
private void EvictBuffersOverBudget(uint protectedBufferId)
|
||
{
|
||
while (_bufferBudget.ResidentBytes > _bufferBudget.MaxBytes)
|
||
{
|
||
bool IsProtected(uint bufferId) =>
|
||
bufferId == protectedBufferId || IsBufferAttachedToAnySource(bufferId);
|
||
|
||
if (!_bufferBudget.TryEvictOldestUnprotected(
|
||
IsProtected, out uint evictedWaveId, out uint evictedBufferId))
|
||
{
|
||
break;
|
||
}
|
||
|
||
_bufferByWaveId.Remove(evictedWaveId);
|
||
_resources!.ReleaseBuffer(evictedBufferId);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when <paramref name="bufferId"/> is currently bound to any pool
|
||
/// source. Queried live from AL (<c>AL_BUFFER</c> on each source)
|
||
/// rather than tracked locally: AL's per-source state is the only
|
||
/// thing that actually determines whether <c>alDeleteBuffers</c> would
|
||
/// fail, and several call sites (<see cref="Play3DWave"/>,
|
||
/// <see cref="PlayUiWave"/>) set a source's buffer directly, so a
|
||
/// second local copy would be one more place to keep in sync.
|
||
/// </summary>
|
||
private bool IsBufferAttachedToAnySource(uint bufferId)
|
||
{
|
||
if (_al is null) return false;
|
||
|
||
for (int i = 0; i < PoolSize3D; i++)
|
||
{
|
||
if (IsSourceBoundTo(_pool3D[i].SourceId, bufferId)) return true;
|
||
}
|
||
for (int i = 0; i < PoolSizeUi; i++)
|
||
{
|
||
if (IsSourceBoundTo(_poolUi[i], bufferId)) return true;
|
||
}
|
||
// Ambients share the world and UI pools scanned above; there is no
|
||
// separate ambient source list any more (slice A5 deleted the
|
||
// looping-handle model retail never had).
|
||
return false;
|
||
}
|
||
|
||
private bool IsSourceBoundTo(uint sourceId, uint bufferId)
|
||
{
|
||
_al!.GetSourceProperty(sourceId, GetSourceInteger.Buffer, out int attached);
|
||
return (uint)attached == bufferId;
|
||
}
|
||
|
||
private static BufferFormat PickFormat(WaveData w)
|
||
{
|
||
return (w.ChannelCount, w.BitsPerSample) switch
|
||
{
|
||
(1, 8) => BufferFormat.Mono8,
|
||
(1, 16) => BufferFormat.Mono16,
|
||
(2, 8) => BufferFormat.Stereo8,
|
||
(2, 16) => BufferFormat.Stereo16,
|
||
_ => 0,
|
||
};
|
||
}
|
||
|
||
private bool IsStillPlaying(uint sourceId)
|
||
{
|
||
if (_al is null) return false;
|
||
_al.GetSourceProperty(sourceId, GetSourceInteger.SourceState, out int state);
|
||
return state == (int)SourceState.Playing;
|
||
}
|
||
|
||
private void StopWorldSlot(Slot3D slot)
|
||
{
|
||
if (_available && _al is not null && slot.SourceId != 0)
|
||
{
|
||
_al.SourceStop(slot.SourceId);
|
||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
|
||
}
|
||
|
||
slot.OwnerId = 0;
|
||
slot.Priority = 0f;
|
||
slot.InUse = false;
|
||
}
|
||
|
||
private static Slot3D[] CreateWorldSlots()
|
||
{
|
||
var slots = new Slot3D[PoolSize3D];
|
||
for (int i = 0; i < slots.Length; i++)
|
||
slots[i] = new Slot3D();
|
||
return slots;
|
||
}
|
||
}
|