feat(audio): Campaign A slice A5 — retail's region ambient soundscape

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>
This commit is contained in:
Erik 2026-08-08 22:53:41 +02:00
parent 6eaa490bb3
commit 7c4dd1ade7
13 changed files with 1984 additions and 32 deletions

View file

@ -0,0 +1,211 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.DBObjs;
namespace AcDream.Core.Audio;
/// <summary>
/// Builds the live ambient set from the region file's authored data — retail's
/// <c>Ambient::InitSounds</c> plus <c>CLandBlock::add_ambient_sounds</c> @
/// <c>0x50AC10</c> and <c>LScape::add_ambient_sounds</c>.
///
/// <para>
/// <b>Where ambients come from.</b> Entirely <c>region.dat</c>:
/// <c>Region.SoundInfo.STBDesc[]</c> holds the <c>AmbientSTBDesc</c> entries,
/// <c>Region.SceneInfo.SceneTypes[i].StbIndex</c> points into that list, and
/// <c>Region.TerrainInfo.TerrainTypes[t].SceneTypes[s]</c> points at the scene
/// type. <c>0xFFFFFFFF</c> means none. There is no separate ambient dat range —
/// <c>AmbientSTBDesc.STBId</c> is an ordinary SoundTable DID.
/// </para>
///
/// <para>
/// <b>Granularity.</b> Selection is per LAND CELL, not per landblock: retail
/// walks the 8×8 cells of each landblock, decodes that cell's terrain word to
/// <c>(terrainType, sceneIndex)</c>, and positions the contribution at the
/// cell's SW vertex. Only the 3×3 landblock ring around the viewer contributes
/// (<c>LScape::add_ambient_sounds</c> feeds blocks whose
/// <c>get_block_orient</c> is ring ≤ 1).
/// </para>
///
/// <para>
/// <b>Indoors is silent.</b> <c>CEnvCell::add_ambient_sounds</c> exists in the
/// PDB but is ICF-folded onto a bare <c>ret</c>, and the EnvCell format carries
/// no sound field — corroborated independently by two research lanes. Dungeon
/// silence is retail-correct; any indoor ambient would be a new feature needing
/// a divergence row, not a port.
/// </para>
/// </summary>
public sealed class AmbientSoundGatherer
{
/// <summary>Land cells per landblock side.</summary>
public const int CellsPerSide = 8;
/// <summary>Terrain-word entries per landblock side (a 9×9 vertex grid).</summary>
private const int VerticesPerSide = 9;
/// <summary>Retail's "no entry" sentinel in the scene/STB index chain.</summary>
private const uint NoIndex = 0xFFFFFFFFu;
private readonly AmbientSoundScheduler _scheduler;
public AmbientSoundGatherer(AmbientSoundScheduler scheduler) =>
_scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler));
/// <summary>
/// Rebuild the ambient set for a listener standing at
/// <paramref name="listenerLocalPosition"/> — the listener's LANDBLOCK-LOCAL
/// position, x and y in <c>[0, 192)</c>, as retail's <c>Position</c> carries
/// it. <paramref name="landblocks"/> supplies the terrain words for each
/// landblock in the 3×3 ring, keyed by landblock id; a missing entry simply
/// contributes nothing.
///
/// <para>
/// <b>Frames matter here.</b> Offsets are computed the way retail's
/// <c>Position::get_offset</c> / <c>LandDefs::get_block_offset</c> do —
/// landblock delta plus in-block coordinates — NOT by differencing absolute
/// world coordinates. acdream's live <c>Position</c> is in a streamed frame
/// rebased on the streaming centre, so subtracting it from an absolute cell
/// coordinate yields tens of kilometres and culls every contribution. The
/// streamed-frame listener position is still needed for PLAYBACK, but it is
/// a separate value carried by the scheduler.
/// </para>
/// </summary>
public void Rebuild(
Region region,
uint viewerLandblockId,
Vector3 listenerLocalPosition,
Func<uint, ushort[]?> landblocks,
double now)
{
ArgumentNullException.ThrowIfNull(region);
ArgumentNullException.ThrowIfNull(landblocks);
_scheduler.BeginRebuild();
uint viewerX = viewerLandblockId >> 24;
uint viewerY = (viewerLandblockId >> 16) & 0xFFu;
for (int dx = -1; dx <= 1; dx++)
{
for (int dy = -1; dy <= 1; dy++)
{
long blockX = viewerX + dx;
long blockY = viewerY + dy;
if (blockX < 0 || blockX > 0xFF || blockY < 0 || blockY > 0xFF)
continue;
uint landblockId = ((uint)blockX << 24) | ((uint)blockY << 16) | 0xFFFFu;
ushort[]? terrain = landblocks(landblockId);
if (terrain is null || terrain.Length < VerticesPerSide * VerticesPerSide)
continue;
ContributeLandblock(
region,
dx,
dy,
terrain,
listenerLocalPosition);
}
}
_scheduler.EndRebuild(now);
}
private void ContributeLandblock(
Region region,
int blockDeltaX,
int blockDeltaY,
ushort[] terrain,
Vector3 listenerLocalPosition)
{
// The ring neighbour's origin RELATIVE to the listener's own landblock.
const float landblockLength = CellsPerSide * AmbientSoundConstants.LandCellLength;
float blockOriginX = blockDeltaX * landblockLength;
float blockOriginY = blockDeltaY * landblockLength;
// 8x8 CELLS, not the 9x9 vertex grid: each cell contributes once, at its
// south-west vertex.
for (int x = 0; x < CellsPerSide; x++)
{
for (int y = 0; y < CellsPerSide; y++)
{
ushort raw = terrain[(x * VerticesPerSide) + y];
uint terrainType = (uint)((raw >> 2) & 0x1F);
uint sceneIndex = (uint)((raw >> 11) & 0x1F);
if (!TryResolveStbDesc(region, terrainType, sceneIndex, out var stb))
continue;
// Retail positions each contribution at the land cell's SW
// vertex. Z stays planar: CalcDir ignores Z outright and
// CalcWeight's Z term is the terrain height difference, which we
// do not sample here (see the register row).
var offset = new Vector3(
blockOriginX + (x * AmbientSoundConstants.LandCellLength)
- listenerLocalPosition.X,
blockOriginY + (y * AmbientSoundConstants.LandCellLength)
- listenerLocalPosition.Y,
0f);
// Cheap reject before touching the descriptor list: beyond 120 m
// the weight is zero and retail's AddSound gate drops it.
if (offset.LengthSquared() > AmbientSoundConstants.MaxDistanceSq)
continue;
// Ambient::AddSound @ 0x551610 adds this cell's weight to the
// shared denominator ONCE, then feeds every descriptor in the
// table. Adding it per descriptor would divide each bed's
// crossfade by the entry count and push quiet beds under the
// 0.03 audibility floor.
_scheduler.ContributeCell(offset, stb!, static (stb, index) =>
{
var sound = stb.AmbientSounds[index];
return new AmbientSoundDescriptor(
(SoundId)(uint)sound.SType,
sound.Volume,
sound.BaseChance,
sound.MinRate,
sound.MaxRate);
});
}
}
}
/// <summary>
/// terrain type → scene type → STB descriptor, with retail's
/// <c>0xFFFFFFFF</c> "none" sentinel honoured at each hop.
/// </summary>
private static bool TryResolveStbDesc(
Region region,
uint terrainType,
uint sceneIndex,
out DatReaderWriter.Types.AmbientSTBDesc? stb)
{
stb = null;
var terrainTypes = region.TerrainInfo?.TerrainTypes;
if (terrainTypes is null || terrainType >= terrainTypes.Count)
return false;
var sceneTypes = terrainTypes[(int)terrainType].SceneTypes;
if (sceneIndex >= sceneTypes.Count)
return false;
uint sceneTypeIndex = sceneTypes[(int)sceneIndex];
var sceneList = region.SceneInfo?.SceneTypes;
if (sceneTypeIndex == NoIndex || sceneList is null || sceneTypeIndex >= sceneList.Count)
return false;
uint stbIndex = sceneList[(int)sceneTypeIndex].StbIndex;
var descriptors = region.SoundInfo?.STBDesc;
if (stbIndex == NoIndex || descriptors is null || stbIndex >= descriptors.Count)
return false;
var candidate = descriptors[(int)stbIndex];
if (candidate.STBId == 0 || candidate.AmbientSounds.Count == 0)
return false;
stb = candidate;
return true;
}
}

View file

@ -0,0 +1,409 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Audio;
/// <summary>
/// Retail's <c>LandDefs::Direction</c> — where a contributing land cell sits
/// relative to the viewer's landblock. The jump table at <c>0x5A9A7C</c> gives
/// each one a compass heading in radians.
/// </summary>
public enum AmbientDirection
{
InViewerBlock = 0,
North = 1,
South = 2,
East = 3,
West = 4,
Northwest = 5,
Southwest = 6,
Northeast = 7,
Southeast = 8,
}
/// <summary>
/// One authored ambient entry — retail's <c>AmbientSoundDesc</c> (0x14 packed).
/// <c>IsContinuous</c> is DERIVED at unpack from <c>BaseChance == 0</c>, not
/// stored; Binary Ninja renders that compare inverted, and porting its version
/// yields silence rather than a wrong sound.
/// </summary>
/// <param name="Sound">The <see cref="SoundId"/> slot in the referenced SoundTable.</param>
/// <param name="Volume">Authored linear gain.</param>
/// <param name="BaseChance">0 ⇒ continuous; otherwise the per-fire probability base.</param>
/// <param name="MinRate">Re-fire interval floor, seconds.</param>
/// <param name="MaxRate">Re-fire interval ceiling, seconds (continuous ignores it).</param>
public readonly record struct AmbientSoundDescriptor(
SoundId Sound,
float Volume,
float BaseChance,
float MinRate,
float MaxRate)
{
/// <summary>
/// <c>base_chance == 0</c> ⇒ <c>ConstantSound</c> (a crossfaded,
/// non-positional bed re-fired every <see cref="MinRate"/> seconds);
/// non-zero ⇒ <c>IntermitSound</c>.
/// </summary>
public bool IsContinuous => BaseChance == 0f;
}
/// <summary>
/// Retail's ambient constants, byte-read from the PDB-paired binary. Every one
/// of these is elided or mis-rendered somewhere in the pseudo-C.
/// </summary>
public static class AmbientSoundConstants
{
/// <summary>Full-weight radius, metres (<c>0x81F148</c>).</summary>
public const float MinDistance = 20.0f;
/// <summary><see cref="MinDistance"/> squared (<c>0x81F14C</c>).</summary>
public const float MinDistanceSq = 400.0f;
/// <summary>Cull radius, metres (<c>0x81F150</c>).</summary>
public const float MaxDistance = 120.0f;
/// <summary><see cref="MaxDistance"/> squared (<c>0x81F154</c>).</summary>
public const float MaxDistanceSq = 14400.0f;
/// <summary>
/// Audibility floor for a continuous bed's crossfaded volume
/// (<c>0x81F158</c>) — about 30.5 dB.
/// </summary>
public const float MinVolume = 0.03f;
/// <summary>
/// Total jitter cone applied to an intermittent sound's bearing
/// (<c>0x81F1B0</c>): π/8 radians = 22.5°, so ±11.25°.
/// </summary>
public const float HeadingSpread = 0.392699093f;
/// <summary>
/// The "close enough to be anywhere around you" threshold in
/// <c>Ambient::CalcDir</c>: <see cref="MinDistanceSq"/> × 0.5 = 200 m²,
/// i.e. 14.142 m. This is the ONLY place the squared value is halved —
/// reading it as <see cref="MinDistance"/> would widen the omnidirectional
/// zone from 14 m to 20 m.
/// </summary>
public const float InViewerBlockDistanceSq = MinDistanceSq * 0.5f;
/// <summary>Half of <see cref="MinDistance"/> — the shell half-thickness, metres.</summary>
public const float ShellHalfThickness = MinDistance * 0.5f;
/// <summary>Near bound used for the omnidirectional spread, metres (<c>5.0f 1.0f</c>).</summary>
public const float InBlockNearDistance = 4.0f;
/// <summary>Metres per land cell (<c>LandDefs::square_length</c>, <c>0x799128</c>).</summary>
public const float LandCellLength = 24.0f;
/// <summary>Retail's per-direction compass heading, radians (jump table <c>0x5A9A7C</c>).</summary>
public static float Heading(AmbientDirection direction) => direction switch
{
AmbientDirection.North => 0.0f,
AmbientDirection.South => 3.14159274f,
AmbientDirection.East => 1.57079637f,
AmbientDirection.West => 4.71238899f,
AmbientDirection.Northwest => 5.49778700f,
AmbientDirection.Southwest => 3.92699075f,
AmbientDirection.Northeast => 0.78539819f,
AmbientDirection.Southeast => 2.35619450f,
// IN_VIEWER_BLOCK and anything out of range fall to 0.0.
_ => 0.0f,
};
/// <summary>
/// <c>Ambient::CalcWeight</c> @ <c>0x550DD0</c>: full weight inside 20 m,
/// inverse-square out to 120 m, nothing beyond. Binary Ninja dropped the
/// arithmetic entirely.
/// </summary>
public static float CalcWeight(Vector3 offset)
{
float distanceSq = offset.LengthSquared();
if (distanceSq > MaxDistanceSq) return 0f;
if (distanceSq < MinDistanceSq) return 1f;
return MinDistanceSq / distanceSq;
}
/// <summary>
/// <c>Ambient::CalcDir</c> @ <c>0x550E40</c>: which compass sector a
/// contributing cell falls in, or <see cref="AmbientDirection.InViewerBlock"/>
/// when it is inside <see cref="InViewerBlockDistanceSq"/>. A sector counts as
/// diagonal when neither axis dominates the other by more than 2×
/// (<c>0x7C5E24</c>).
/// </summary>
public static AmbientDirection CalcDirection(Vector3 offset)
{
float x = offset.X;
float y = offset.Y;
// CalcDir squares X and Y only — Z is deliberately absent here, where
// CalcWeight deliberately includes it. The two functions differ on
// purpose; collapsing them would widen or narrow the omnidirectional
// zone by the height difference.
if (((x * x) + (y * y)) < InViewerBlockDistanceSq)
return AmbientDirection.InViewerBlock;
float ax = MathF.Abs(x);
float ay = MathF.Abs(y);
const float diagonalRatio = 2.0f;
const float epsilon = 0.0002f; // F_EPSILON @ 0x7CB0A0
bool diagonal =
ax > epsilon && ay > epsilon
&& ay / ax <= diagonalRatio
&& ax / ay <= diagonalRatio;
if (diagonal)
{
return y >= 0f
? (x >= 0f ? AmbientDirection.Northeast : AmbientDirection.Northwest)
: (x >= 0f ? AmbientDirection.Southeast : AmbientDirection.Southwest);
}
if (ay >= ax)
return y >= 0f ? AmbientDirection.North : AmbientDirection.South;
return x >= 0f ? AmbientDirection.East : AmbientDirection.West;
}
}
/// <summary>
/// One live ambient instance — retail's <c>ConstantSound</c> or
/// <c>IntermitSound</c>. Accumulates weight (and, for the intermittent kind,
/// bearings) during a rebuild, then answers the four questions the scheduler
/// asks: can it be heard, should it fire now, how loud, and when again.
/// </summary>
public sealed class AmbientSoundInstance
{
private readonly List<AmbientDirectionShell> _directions = [];
public AmbientSoundInstance(AmbientSoundDescriptor descriptor, uint soundTableDid)
{
Descriptor = descriptor;
SoundTableDid = soundTableDid;
}
public AmbientSoundDescriptor Descriptor { get; }
/// <summary>The SoundTable the descriptor's slot is looked up in.</summary>
public uint SoundTableDid { get; }
/// <summary>Accumulated weight from every contributing land cell.</summary>
public float SoundCount { get; private set; }
/// <summary>Crossfaded volume — continuous instances only.</summary>
public float CurrentVolume { get; private set; }
/// <summary>Per-fire probability — intermittent instances only.</summary>
public float PlayChance { get; private set; }
/// <summary>True while this instance holds a slot in the deadline queue.</summary>
public bool OnQueue { get; set; }
public IReadOnlyList<AmbientDirectionShell> Directions => _directions;
/// <summary>
/// <c>ResetCount</c> (<c>0x550CD0</c> intermittent, <c>0x550D70</c>
/// continuous). Must run for EVERY instance before a rebuild accumulates:
/// <c>IntermitSound::UpdateSound</c> never clears <see cref="PlayChance"/>,
/// so a skipped reset leaves a stale bearing and probability alive forever.
/// Note retail does NOT reset a continuous instance's
/// <see cref="CurrentVolume"/> here.
/// </summary>
public void ResetCount()
{
SoundCount = 0f;
_directions.Clear();
if (!Descriptor.IsContinuous)
PlayChance = 0f;
}
/// <summary>
/// <c>AddTo</c> @ <c>0x551450</c>. A cell outside the viewer's own block
/// contributes a 20 m-thick shell at its bearing; a cell inside
/// <see cref="AmbientSoundConstants.InViewerBlockDistanceSq"/> could be
/// anywhere around the listener, so it contributes 410 m in all eight
/// directions.
/// </summary>
public void AddTo(float weight, Vector3 offset, AmbientDirection direction)
{
SoundCount += weight;
if (Descriptor.IsContinuous)
return; // continuous beds track weight only, never bearings
float distance = MathF.Sqrt(offset.LengthSquared());
float half = AmbientSoundConstants.ShellHalfThickness;
if (direction != AmbientDirection.InViewerBlock)
{
AddDirection(direction, distance - half, distance + half);
return;
}
AddDirection(AmbientDirection.North, AmbientSoundConstants.InBlockNearDistance, half);
AddDirection(AmbientDirection.South, AmbientSoundConstants.InBlockNearDistance, half);
AddDirection(AmbientDirection.East, AmbientSoundConstants.InBlockNearDistance, half);
AddDirection(AmbientDirection.West, AmbientSoundConstants.InBlockNearDistance, half);
AddDirection(AmbientDirection.Northwest, AmbientSoundConstants.InBlockNearDistance, half);
AddDirection(AmbientDirection.Southwest, AmbientSoundConstants.InBlockNearDistance, half);
AddDirection(AmbientDirection.Northeast, AmbientSoundConstants.InBlockNearDistance, half);
AddDirection(AmbientDirection.Southeast, AmbientSoundConstants.InBlockNearDistance, half);
}
/// <summary>
/// <c>AddDir</c> @ <c>0x550CF0</c>: widen an existing shell for this bearing
/// or append a new one. Retail keeps at most eight.
/// </summary>
private void AddDirection(AmbientDirection direction, float min, float max)
{
for (int i = 0; i < _directions.Count; i++)
{
if (_directions[i].Direction != direction)
continue;
AmbientDirectionShell existing = _directions[i];
_directions[i] = new AmbientDirectionShell(
direction,
MathF.Min(existing.MinDistance, min),
MathF.Max(existing.MaxDistance, max));
return;
}
if (_directions.Count >= 8)
return;
_directions.Add(new AmbientDirectionShell(direction, min, max));
}
/// <summary>
/// <c>UpdateSound</c> (<c>0x551540</c> continuous, <c>0x551310</c>
/// intermittent). <paramref name="totalSoundCount"/> is the sum over ALL
/// ambients, not per-descriptor — that denominator is what makes the mix a
/// terrain-share crossfade.
/// </summary>
public void UpdateSound(float totalSoundCount)
{
if (Descriptor.IsContinuous)
{
if (SoundCount == 0f)
{
CurrentVolume = 0f;
return;
}
CurrentVolume = Descriptor.Volume / totalSoundCount * SoundCount;
return;
}
// Intermittent: note the asymmetry — a zero weight leaves PlayChance
// ALONE rather than zeroing it. Only ResetCount clears it.
if (SoundCount <= 0f)
return;
PlayChance = Descriptor.BaseChance / totalSoundCount * SoundCount;
}
/// <summary>
/// <c>CanHear</c> (<c>0x550FD0</c> continuous, <c>0x550F80</c>
/// intermittent). Both compares are rendered as an unimplemented predicate
/// by Binary Ninja and would port inverted.
/// </summary>
public bool CanHear() =>
Descriptor.IsContinuous
? CurrentVolume >= AmbientSoundConstants.MinVolume
: PlayChance > 0f;
/// <summary>
/// <c>PlayNow</c> (continuous is a folded <c>mov eax,1</c> — ALWAYS true;
/// intermittent rolls against <see cref="PlayChance"/> @ <c>0x550FA0</c>).
/// </summary>
public bool PlayNow(ISoundRandom rng)
{
ArgumentNullException.ThrowIfNull(rng);
return Descriptor.IsContinuous || rng.NextVariantRoll() <= PlayChance;
}
/// <summary>
/// <c>GetVolume</c>: the crossfaded value for a continuous bed
/// (<c>0x551070</c> returns the AUTHORED volume for intermittent — the
/// crossfade lives in its probability instead).
/// </summary>
public float GetVolume() =>
Descriptor.IsContinuous ? CurrentVolume : Descriptor.Volume;
/// <summary>
/// <c>GetPlayInterval</c>: intermittent rolls between the authored rates
/// (<c>0x551080</c>); continuous uses <c>min_rate</c> alone
/// (<c>0x5510A0</c>) — that rate IS the author's intended loop period, which
/// is how retail fakes a sustained bed without a looping voice.
/// </summary>
public float GetPlayInterval(ISoundRandom rng)
{
ArgumentNullException.ThrowIfNull(rng);
return Descriptor.IsContinuous
? Descriptor.MinRate
: RollDice(Descriptor.MinRate, Descriptor.MaxRate, rng);
}
/// <summary>
/// <c>GetSoundPos</c> @ <c>0x551350</c>: offset the LISTENER's position in
/// the XY plane, keeping their Z. Returns false for a continuous bed, whose
/// base implementation is a folded <c>xor eax,eax</c> — no position at all,
/// so it plays from centre.
///
/// <para>
/// The distance is <c>min + (max min)·t²</c>, quadratically biased toward
/// <c>min</c>; a linear lerp puts intermittent ambients audibly further away
/// on average.
/// </para>
/// </summary>
public bool TryGetSoundPosition(
Vector3 listenerPosition,
ISoundRandom rng,
out Vector3 position)
{
ArgumentNullException.ThrowIfNull(rng);
position = listenerPosition;
if (Descriptor.IsContinuous || _directions.Count == 0)
return false;
int index = (int)MathF.Floor(rng.NextVariantRoll() * _directions.Count);
if (index >= _directions.Count)
index = _directions.Count - 1;
AmbientDirectionShell shell = _directions[index];
float spread = AmbientSoundConstants.HeadingSpread;
float angle = AmbientSoundConstants.Heading(shell.Direction)
+ (rng.NextVariantRoll() * spread)
- (spread * 0.5f);
float t = rng.NextVariantRoll();
float distance = shell.MinDistance
+ ((shell.MaxDistance - shell.MinDistance) * t * t);
// AC's compass convention: north is +Y, east is +X.
position = new Vector3(
listenerPosition.X + (MathF.Sin(angle) * distance),
listenerPosition.Y + (MathF.Cos(angle) * distance),
listenerPosition.Z);
return true;
}
/// <summary>
/// <c>Random::RollDice</c> @ <c>0x42C600</c>, including its swap on an
/// inverted range and its equal-bounds short circuit.
/// </summary>
internal static float RollDice(float min, float max, ISoundRandom rng)
{
if (min == max) return min;
float lo = min, hi = max;
if (max < min) { lo = max; hi = min; }
return lo + ((hi - lo) * rng.NextVariantRoll());
}
}
/// <summary>
/// One accumulated bearing for an intermittent ambient: a distance shell at a
/// compass direction.
/// </summary>
public readonly record struct AmbientDirectionShell(
AmbientDirection Direction,
float MinDistance,
float MaxDistance);

View file

@ -0,0 +1,235 @@
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;
}
}

View file

@ -76,9 +76,15 @@ public interface IAudioEngine : IDisposable
/// <summary>Play a 3D sound at a world position.</summary>
void Play3D(SoundId id, float x, float y, float z);
/// <summary>Start a looped ambient sound (landblock-attached).</summary>
int StartAmbient(SoundId id, float x, float y, float z);
void StopAmbient(int handle);
// A `StartAmbient(id, x, y, z)` / `StopAmbient(handle)` pair lived here
// until 2026-08-08 (Campaign A slice A5). It modelled a LOOPING,
// handle-owned ambient voice, which retail does not have: retail never sets
// the DirectSound loop flag, and a "continuous" ambient is a one-shot
// re-fired every min_rate seconds off an absolute-deadline queue, with a
// fresh variant pick and crossfade volume each time. The implementation was
// a stub that minted a handle and played nothing, while StopAmbient looked
// up a source that was never created. `AmbientSoundController` +
// `AmbientSoundScheduler` carry the real model.
/// <summary>Start music (fades out previous if any).</summary>
void PlayMusic(string resourceName, bool loop);