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>
409 lines
16 KiB
C#
409 lines
16 KiB
C#
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 4–10 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);
|