using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.Audio; using DatReaderWriter.DBObjs; using DRWSound = DatReaderWriter.Enums.Sound; namespace AcDream.App.Audio; /// /// Drives retail's region ambient soundscape: rebuild on an objcell change, /// drain the deadline queue every frame, and play each firing as a one-shot. /// /// /// Retail's Ambient system is a weighted-accumulation + timer-queue /// engine, NOT looping voices. On every objcell change (24 m granularity) /// CellManager::ChangePosition @ 0x4559B0 rebuilds per-sound /// weights over the 3×3 landblock ring × 64 land cells each; 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 is simply a one-shot re-fired /// every min_rate seconds with a freshly rolled table pick and crossfade /// volume. /// /// /// /// Continuous beds play from centre (no position, no pan, no distance /// attenuation); intermittent ones play positionally at a random accumulated /// bearing. Both go through the ambient volume knob, which retail applies /// TWICE — once in PlayAmbientSound* and again inside /// GetAttenuation — so the slider is effectively squared. That quirk is /// reproduced here because two independent research lanes byte-confirmed the /// double application (see TS-65). /// /// public sealed class AmbientSoundController { private readonly OpenAlAudioEngine _engine; private readonly DatSoundCache _cache; private readonly AmbientSoundScheduler _scheduler; private readonly AmbientSoundGatherer _gatherer; private readonly ISoundRandom _rng; private readonly List _firings = []; private Region? _region; private Func _landblocks = static _ => null; private uint _currentObjCell; private Vector3 _listenerPosition; private double _clock; private bool _suspended; public AmbientSoundController( OpenAlAudioEngine engine, DatSoundCache cache, ISoundRandom? rng = null) { _engine = engine ?? throw new ArgumentNullException(nameof(engine)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _rng = rng ?? new SoundRandom(); _scheduler = new AmbientSoundScheduler(_rng); _gatherer = new AmbientSoundGatherer(_scheduler); } /// Live instance count. Diagnostic use. public int InstanceCount => _scheduler.Instances.Count; /// Instances holding a deadline. Diagnostic use. public int QueuedCount => _scheduler.QueuedCount; /// /// Install the region whose authored ambient data drives the soundscape, and /// the terrain-word source for the 3×3 ring. /// public void InstallRegion(Region region, Func landblocks) { _region = region ?? throw new ArgumentNullException(nameof(region)); _landblocks = landblocks ?? throw new ArgumentNullException(nameof(landblocks)); _currentObjCell = 0; _scheduler.Clear(); } /// /// Report the listener's cell and position. A change of objcell triggers the /// rebuild — retail's trigger is CellManager::ChangePosition, not a /// landblock streaming event, so the cadence is every 24 m rather than every /// 192 m. /// public void ObserveListener( uint objCellId, Vector3 position, Vector3 landblockLocalPosition, bool seenOutside = false) { _listenerPosition = position; if (_region is null || objCellId == _currentObjCell) return; // Latch the new cell FIRST and unconditionally, the way // CellManager::ChangePosition assigns load_pos at its tail. Clearing it // inside the indoor branch would leave the change edge permanently // armed while standing still indoors. _currentObjCell = objCellId; // Indoors is silent by design: retail's CEnvCell::add_ambient_sounds is // an empty folded ret and the EnvCell format carries no sound data. The // gate is `isOutdoorCell(pos) || curr_cell->seen_outside`, so an // interior that can see the sky still gets the OUTDOOR set — a cottage // does not cut the ambience dead. if (IsIndoorCell(objCellId) && !seenOutside) { _scheduler.Clear(); return; } _gatherer.Rebuild( _region, (objCellId >> 16 << 16) | 0xFFFFu, landblockLocalPosition, _landblocks, _clock); } /// /// Advance the ambient clock and fire everything now due. Called once per /// frame from the same tick that drives the rest of the effect system. /// public void Tick(double deltaSeconds) { if (deltaSeconds > 0) _clock += deltaSeconds; if (_suspended || !_engine.IsAvailable || _region is null) return; _firings.Clear(); _scheduler.Tick(_clock, _firings, _listenerPosition); Emit(); } /// /// Stop contributing while the world is being replaced. The scheduler's /// deadlines are dropped rather than paused: the next rebuild re-arms /// everything audible, which is what a cell change does anyway. Dropping /// them also avoids a salvo of every overdue bed firing at once on resume. /// public void Suspend() { _suspended = true; StopAll(); } public void Resume() => _suspended = false; /// Drop every instance and deadline (world teardown / reset). public void StopAll() { _scheduler.Clear(); _currentObjCell = 0; } private void Emit() { foreach (AmbientSoundFiring firing in _firings) Play(firing); _firings.Clear(); } private void Play(in AmbientSoundFiring firing) { SoundTable? table = _cache.GetSoundTable(firing.Instance.SoundTableDid); if (table is null) return; // The variant pick and the entry's probability gate apply to ambients // exactly as they do everywhere else — PlayAmbientSound* rolls the same // PlayProbability inline. var entry = SoundCookbook.Select( table, (DRWSound)(uint)firing.Instance.Descriptor.Sound, _rng); if (entry is null) return; uint waveId = (uint)entry.Id; if (waveId == 0) return; WaveData? wave = _cache.GetWave(waveId); if (wave is null) return; // Retail pre-multiplies by the ambient knob here and GetAttenuation // multiplies by it again — the squared-slider quirk (TS-65). float volume = firing.Volume * _engine.AmbientVolume; if (firing.Position is { } position) { _engine.PlayAmbient3DWave(waveId, wave, position, volume, entry.Priority); return; } // Continuous bed: from centre, no position and no attenuation — but // still through the SAME 16-voice priority pool as everything else. // Retail has one pool (SoundManager::playing_sounds_[0x10]); the UI pool // is ours, and parking beds there would let a portal cue chop one // mid-wave and would discard the entry's priority. _engine.PlayAmbientFromCenter(waveId, wave, volume, entry.Priority); } /// /// Outdoor land cells are 0x…FFFF style ids below 0x0100 in the cell /// word; anything above that is an EnvCell (indoor), which retail gives no /// ambients. /// private static bool IsIndoorCell(uint objCellId) => (objCellId & 0xFFFFu) >= 0x0100u; } /// /// The per-frame ambient step, as a typed collaborator. Extracted update owners /// may not retain delegates, so the effect phase takes this rather than an /// Action<float>. /// public interface IAmbientFramePhase { void TickAmbient(float deltaSeconds); } /// /// Binds the ambient controller to the live listener: reports the local /// player's cell and position (retail rebuilds on an objcell change, and reads /// the viewer's position), then drains the deadline queue. /// public sealed class AmbientFramePhase : IAmbientFramePhase { private readonly AmbientSoundController _ambient; private readonly IAmbientListenerSource _listener; public AmbientFramePhase(AmbientSoundController ambient, IAmbientListenerSource listener) { _ambient = ambient ?? throw new ArgumentNullException(nameof(ambient)); _listener = listener ?? throw new ArgumentNullException(nameof(listener)); } public void TickAmbient(float deltaSeconds) { if (_listener.TryGetListener(out AmbientListenerPose pose)) { _ambient.ObserveListener( pose.ObjCellId, pose.Position, pose.LandblockLocalPosition, pose.SeenOutside); } _ambient.Tick(deltaSeconds); } } /// /// The listener's pose, in both frames the ambient system needs. /// /// The land/env cell — the rebuild trigger. /// /// The streamed-frame position, used for PLAYBACK (it is the frame the audio /// engine's listener lives in). /// /// /// The landblock-local position, x/y in [0, 192), used for the CELL WALK. /// Mixing the two frames culls every contribution. /// /// /// True when an interior cell can see the sky; retail gives those the outdoor /// ambient set rather than silence. /// public readonly record struct AmbientListenerPose( uint ObjCellId, Vector3 Position, Vector3 LandblockLocalPosition, bool SeenOutside); /// Supplies the listener's current pose. public interface IAmbientListenerSource { bool TryGetListener(out AmbientListenerPose pose); } /// /// over the canonical local-player movement /// owner. Retail's ambient listener is the same viewer the mixer uses; the cell /// is what decides when to rebuild. /// public sealed class LocalPlayerAmbientListenerSource : IAmbientListenerSource { private readonly AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState _player; private readonly Func _indoorLandblockLocal; public LocalPlayerAmbientListenerSource( AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState player, Func? indoorLandblockLocal = null) { _player = player ?? throw new ArgumentNullException(nameof(player)); _indoorLandblockLocal = indoorLandblockLocal ?? ((_, _) => null); } public bool TryGetListener(out AmbientListenerPose pose) { if (_player.Controller is { } controller) { AcDream.Core.Physics.Position cell = controller.CellPosition; uint objCellId = controller.CellId; Vector3 landblockLocal = cell.Frame.Origin; bool seenOutside = false; // Retail's gate is `isOutdoorCell(pos) || curr_cell->seen_outside` // (TS-66, retired with this wiring): a sky-lit interior — a // cottage, an open shopfront — keeps the OUTDOOR ambient set, // while a sealed dungeon stays silent. The flag is the same // per-cell `CEnvCell.seen_outside` bit the physics cache already // carries for AdjustPosition (#107). // // Frames: an OUTDOOR Position's origin is already landblock-local, // but an ENVCELL's origin is CELL-local — it must go through the // cell's own transform (the dat authors cell Positions in // landblock coordinates) before the 3×3 walk can centre on it. // The resolver returns null when it cannot answer (cell record // not yet resident), which keeps the interior silent for that // rebuild rather than centring the walk on a wrong point. if ((objCellId & 0xFFFFu) >= 0x0100u) { if (_indoorLandblockLocal(objCellId, cell.Frame.Origin) is { } converted) { landblockLocal = converted; seenOutside = true; } } pose = new AmbientListenerPose( objCellId, controller.Position, landblockLocal, seenOutside); return true; } pose = default; return false; } }