Two user findings from the Campaign A listening session. 1. The portal tunnel's in-flight sound was silent while its enter/exit cues played. The tunnel's authored SoundTweakedHook drained into the world 3-D path at its synthetic owner's origin (0,0,0) — after A2 that dies twice: the listener is usually beyond the -50 dB no-allocate radius, and the world pool is suspended for the whole transit hold. The cues the user COULD hear were on the interface bus, which has neither problem, and retail's tunnel is gmSmartBoxUI — UI-owned — so that bus is also the faithful route. UiPresentationHookSink now wraps the shared router for the tunnel: sound-bearing hooks go from-centre through the interface bus (AudioHookSink.OnUiHook); every other hook kind still reaches the particle/lighting/translucency sinks unchanged. 2. Ambience cut dead inside houses; retail keeps the outdoor soundscape in sky-lit interiors. This is TS-66, now retired: the ambient listener source resolves the per-cell CEnvCell.seen_outside bit through the physics cache (the same #107 field AdjustPosition reads) and converts the envcell-local origin through the cell's WorldTransform into landblock coordinates before the 3x3 walk centres on it — an outdoor Position's origin is already landblock-local, an envcell's is cell-local, and skipping that conversion would centre the walk wrongly by up to a landblock. A not-yet-resident cell record resolves to silence for that rebuild rather than a wrong walk. Sealed dungeons stay silent, which is retail-correct. The user also reports interiors carrying their own local sound in retail (hearth-type emitters). Statics already register their sound tables and route animation hooks, so the expectation is that the seen_outside fix plus existing emitters covers it; re-listen decides, and anything still missing becomes a precise follow-up. Full Release suite: 11,740 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
347 lines
13 KiB
C#
347 lines
13 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
///
|
||
/// <para>
|
||
/// Retail's <c>Ambient</c> system is a weighted-accumulation + timer-queue
|
||
/// engine, NOT looping voices. On every objcell change (24 m granularity)
|
||
/// <c>CellManager::ChangePosition</c> @ <c>0x4559B0</c> 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 <c>min_rate</c> seconds with a freshly rolled table pick and crossfade
|
||
/// volume.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// 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 <c>PlayAmbientSound*</c> and again inside
|
||
/// <c>GetAttenuation</c> — so the slider is effectively squared. That quirk is
|
||
/// reproduced here because two independent research lanes byte-confirmed the
|
||
/// double application (see TS-65).
|
||
/// </para>
|
||
/// </summary>
|
||
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<AmbientSoundFiring> _firings = [];
|
||
|
||
private Region? _region;
|
||
private Func<uint, ushort[]?> _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);
|
||
}
|
||
|
||
/// <summary>Live instance count. Diagnostic use.</summary>
|
||
public int InstanceCount => _scheduler.Instances.Count;
|
||
|
||
/// <summary>Instances holding a deadline. Diagnostic use.</summary>
|
||
public int QueuedCount => _scheduler.QueuedCount;
|
||
|
||
/// <summary>
|
||
/// Install the region whose authored ambient data drives the soundscape, and
|
||
/// the terrain-word source for the 3×3 ring.
|
||
/// </summary>
|
||
public void InstallRegion(Region region, Func<uint, ushort[]?> landblocks)
|
||
{
|
||
_region = region ?? throw new ArgumentNullException(nameof(region));
|
||
_landblocks = landblocks ?? throw new ArgumentNullException(nameof(landblocks));
|
||
_currentObjCell = 0;
|
||
_scheduler.Clear();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Report the listener's cell and position. A change of objcell triggers the
|
||
/// rebuild — retail's trigger is <c>CellManager::ChangePosition</c>, not a
|
||
/// landblock streaming event, so the cadence is every 24 m rather than every
|
||
/// 192 m.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public void Suspend()
|
||
{
|
||
_suspended = true;
|
||
StopAll();
|
||
}
|
||
|
||
public void Resume() => _suspended = false;
|
||
|
||
/// <summary>Drop every instance and deadline (world teardown / reset).</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Outdoor land cells are <c>0x…FFFF</c> style ids below 0x0100 in the cell
|
||
/// word; anything above that is an EnvCell (indoor), which retail gives no
|
||
/// ambients.
|
||
/// </summary>
|
||
private static bool IsIndoorCell(uint objCellId) => (objCellId & 0xFFFFu) >= 0x0100u;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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
|
||
/// <c>Action<float></c>.
|
||
/// </summary>
|
||
public interface IAmbientFramePhase
|
||
{
|
||
void TickAmbient(float deltaSeconds);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The listener's pose, in both frames the ambient system needs.
|
||
/// </summary>
|
||
/// <param name="ObjCellId">The land/env cell — the rebuild trigger.</param>
|
||
/// <param name="Position">
|
||
/// The streamed-frame position, used for PLAYBACK (it is the frame the audio
|
||
/// engine's listener lives in).
|
||
/// </param>
|
||
/// <param name="LandblockLocalPosition">
|
||
/// The landblock-local position, x/y in <c>[0, 192)</c>, used for the CELL WALK.
|
||
/// Mixing the two frames culls every contribution.
|
||
/// </param>
|
||
/// <param name="SeenOutside">
|
||
/// True when an interior cell can see the sky; retail gives those the outdoor
|
||
/// ambient set rather than silence.
|
||
/// </param>
|
||
public readonly record struct AmbientListenerPose(
|
||
uint ObjCellId,
|
||
Vector3 Position,
|
||
Vector3 LandblockLocalPosition,
|
||
bool SeenOutside);
|
||
|
||
/// <summary>Supplies the listener's current pose.</summary>
|
||
public interface IAmbientListenerSource
|
||
{
|
||
bool TryGetListener(out AmbientListenerPose pose);
|
||
}
|
||
|
||
/// <summary>
|
||
/// <see cref="IAmbientListenerSource"/> 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.
|
||
/// </summary>
|
||
public sealed class LocalPlayerAmbientListenerSource : IAmbientListenerSource
|
||
{
|
||
private readonly AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState _player;
|
||
private readonly Func<uint, Vector3, Vector3?> _indoorLandblockLocal;
|
||
|
||
public LocalPlayerAmbientListenerSource(
|
||
AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState player,
|
||
Func<uint, Vector3, Vector3?>? 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;
|
||
}
|
||
}
|