acdream/src/AcDream.Core/Audio/AmbientSoundModel.cs
Erik 7c4dd1ade7 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>
2026-08-08 22:53:41 +02:00

409 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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