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;
}
}