acdream/tests/AcDream.Core.Tests/Audio/AmbientSoundTests.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

616 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Audio;
using Xunit;
namespace AcDream.Core.Tests.Audio;
/// <summary>
/// Conformance tests for retail's ambient runtime — the
/// <c>AmbientSound</c>/<c>ConstantSound</c>/<c>IntermitSound</c> family and the
/// deadline scheduler. Byte-decoded values and pseudocode:
/// <c>docs/research/2026-08-08-audio-retail-ambient-runtime.md</c>.
///
/// <para>
/// Five separate x87 compares in this subsystem are rendered by Binary Ninja as
/// an unimplemented predicate and would port INVERTED — including the one that
/// decides continuous vs intermittent. These tests pin the decoded polarity.
/// </para>
/// </summary>
public sealed class AmbientSoundTests
{
private sealed class ScriptedRandom(params float[] rolls) : ISoundRandom
{
private readonly Queue<float> _rolls = new(rolls);
public float NextVariantRoll() => _rolls.Count > 0 ? _rolls.Dequeue() : 0f;
public float NextProbabilityRoll() => NextVariantRoll();
}
private static AmbientSoundDescriptor Continuous(
float volume = 1f, float minRate = 5f) =>
new(SoundId.Ambient1, volume, BaseChance: 0f, minRate, MaxRate: 0f);
private static AmbientSoundDescriptor Intermittent(
float volume = 1f, float baseChance = 0.5f, float minRate = 4f, float maxRate = 10f) =>
new(SoundId.Ambient2, volume, baseChance, minRate, maxRate);
// ── The polarity that decides the whole model ──────────────────────────
[Fact]
public void BaseChanceZero_MeansContinuous_NotIntermittent()
{
// is_continuous = (base_chance == 0). BN renders this test inverted;
// porting its version yields silence rather than a wrong sound.
Assert.True(Continuous().IsContinuous);
Assert.False(Intermittent(baseChance: 0.5f).IsContinuous);
}
// ── CalcWeight ─────────────────────────────────────────────────────────
[Theory]
[InlineData(0f, 1f)] // on top of the listener
[InlineData(19.9f, 1f)] // inside the 20 m full-weight radius
[InlineData(20f, 1f)] // 400 == 400 is not > 400, and not < 400 -> 400/400
[InlineData(40f, 0.25f)] // 400/1600
[InlineData(120f, 400f / 14400f)]
[InlineData(120.1f, 0f)] // beyond the 120 m cull
[InlineData(500f, 0f)]
public void CalcWeight_IsFullInsideTwentyMetres_ThenInverseSquare(float distance, float expected)
{
float weight = AmbientSoundConstants.CalcWeight(new Vector3(distance, 0f, 0f));
Assert.Equal(expected, weight, 4);
}
// ── CalcDirection ──────────────────────────────────────────────────────
[Fact]
public void CalcDirection_InsideFourteenMetres_IsInViewerBlock()
{
// The threshold is min_dist_sq * 0.5 = 200 m^2 = 14.142 m — the ONLY
// place the squared value is halved. Reading it as min_dist would widen
// the omnidirectional zone from 14 m to 20 m.
Assert.Equal(
AmbientDirection.InViewerBlock,
AmbientSoundConstants.CalcDirection(new Vector3(14.1f, 0f, 0f)));
Assert.NotEqual(
AmbientDirection.InViewerBlock,
AmbientSoundConstants.CalcDirection(new Vector3(14.2f, 0f, 0f)));
}
[Theory]
[InlineData(0f, 50f, AmbientDirection.North)]
[InlineData(0f, -50f, AmbientDirection.South)]
[InlineData(50f, 0f, AmbientDirection.East)]
[InlineData(-50f, 0f, AmbientDirection.West)]
[InlineData(40f, 40f, AmbientDirection.Northeast)]
[InlineData(-40f, 40f, AmbientDirection.Northwest)]
[InlineData(40f, -40f, AmbientDirection.Southeast)]
[InlineData(-40f, -40f, AmbientDirection.Southwest)]
public void CalcDirection_SectorsMatchRetail(float x, float y, AmbientDirection expected)
{
Assert.Equal(expected, AmbientSoundConstants.CalcDirection(new Vector3(x, y, 0f)));
}
[Fact]
public void CalcDirection_DiagonalNeedsBothAxesWithinTwoTimes()
{
// Ratio gate is 2.0 both ways: 50/20 = 2.5 is NOT diagonal.
Assert.Equal(
AmbientDirection.North,
AmbientSoundConstants.CalcDirection(new Vector3(20f, 50f, 0f)));
// 40/30 = 1.33 IS diagonal.
Assert.Equal(
AmbientDirection.Northeast,
AmbientSoundConstants.CalcDirection(new Vector3(30f, 40f, 0f)));
}
// ── Crossfade: the shared denominator ──────────────────────────────────
[Fact]
public void ContinuousVolume_IsItsShareOfTheTotalWeight()
{
// Normalisation is by the sum over ALL ambients, not per-descriptor.
// Getting the denominator wrong changes the crossfade, not just level.
var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
scheduler.BeginRebuild();
AmbientSoundInstance grass = scheduler.Track(Continuous(volume: 1f), 0x20000001u);
AmbientSoundInstance shore = scheduler.Track(Continuous(volume: 1f), 0x20000002u);
// Three cells of grass, one of shore, all inside the full-weight radius.
scheduler.Contribute(grass, new Vector3(1f, 0f, 0f));
scheduler.Contribute(grass, new Vector3(2f, 0f, 0f));
scheduler.Contribute(grass, new Vector3(3f, 0f, 0f));
scheduler.Contribute(shore, new Vector3(4f, 0f, 0f));
scheduler.EndRebuild(now: 0);
Assert.Equal(4f, scheduler.TotalSoundCount, 4);
Assert.Equal(0.75f, grass.CurrentVolume, 4);
Assert.Equal(0.25f, shore.CurrentVolume, 4);
}
[Fact]
public void ContinuousBed_BelowTheAudibilityFloor_CannotBeHeard()
{
// ambient_sound_min_vol = 0.03 linear (about -30.5 dB).
var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
scheduler.BeginRebuild();
AmbientSoundInstance faint = scheduler.Track(Continuous(volume: 1f), 0x20000001u);
AmbientSoundInstance loud = scheduler.Track(Continuous(volume: 1f), 0x20000002u);
scheduler.Contribute(faint, new Vector3(119f, 0f, 0f)); // tiny weight
for (int i = 0; i < 5; i++)
scheduler.Contribute(loud, new Vector3(i, 0f, 0f)); // weight 1 each
scheduler.EndRebuild(now: 0);
Assert.True(faint.CurrentVolume < AmbientSoundConstants.MinVolume);
Assert.False(faint.CanHear());
Assert.True(loud.CanHear());
}
[Fact]
public void IntermittentPlayChance_ScalesWithItsShare_AndVolumeStaysAuthored()
{
var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
scheduler.BeginRebuild();
AmbientSoundInstance a = scheduler.Track(Intermittent(volume: 0.8f, baseChance: 0.6f), 1u);
AmbientSoundInstance b = scheduler.Track(Intermittent(volume: 0.8f, baseChance: 0.6f), 2u);
scheduler.Contribute(a, new Vector3(1f, 0f, 0f));
scheduler.Contribute(b, new Vector3(2f, 0f, 0f));
scheduler.EndRebuild(now: 0);
// Half the total weight each -> half the base chance.
Assert.Equal(0.3f, a.PlayChance, 4);
// Intermittent volume is NOT crossfaded; the crossfade is in the chance.
Assert.Equal(0.8f, a.GetVolume(), 4);
}
[Fact]
public void IntermittentUpdate_WithZeroWeight_LeavesPlayChanceAlone()
{
// The documented asymmetry: UpdateSound early-returns without zeroing.
// Only ResetCount clears it — which is why the rebuild MUST reset first.
var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
scheduler.BeginRebuild();
AmbientSoundInstance sound = scheduler.Track(Intermittent(baseChance: 0.6f), 1u);
scheduler.Contribute(sound, new Vector3(1f, 0f, 0f));
scheduler.EndRebuild(now: 0);
Assert.Equal(0.6f, sound.PlayChance, 4);
// A rebuild that contributes nothing must clear it via ResetCount.
scheduler.BeginRebuild();
scheduler.EndRebuild(now: 0);
Assert.Equal(0f, sound.PlayChance);
Assert.False(sound.CanHear());
}
[Fact]
public void Rebuild_ResetsStaleBearings()
{
var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
scheduler.BeginRebuild();
AmbientSoundInstance sound = scheduler.Track(Intermittent(), 1u);
scheduler.Contribute(sound, new Vector3(0f, 60f, 0f)); // north
scheduler.EndRebuild(now: 0);
Assert.Contains(
sound.Directions,
shell => shell.Direction == AmbientDirection.North);
scheduler.BeginRebuild();
scheduler.Contribute(sound, new Vector3(0f, -60f, 0f)); // south only
scheduler.EndRebuild(now: 0);
Assert.DoesNotContain(
sound.Directions,
shell => shell.Direction == AmbientDirection.North);
}
[Fact]
public void InViewerBlockContribution_SpreadsAcrossAllEightDirections()
{
var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
scheduler.BeginRebuild();
AmbientSoundInstance sound = scheduler.Track(Intermittent(), 1u);
scheduler.Contribute(sound, new Vector3(3f, 0f, 0f)); // inside 14.14 m
scheduler.EndRebuild(now: 0);
Assert.Equal(8, sound.Directions.Count);
Assert.All(sound.Directions, shell =>
{
Assert.Equal(AmbientSoundConstants.InBlockNearDistance, shell.MinDistance, 3);
Assert.Equal(AmbientSoundConstants.ShellHalfThickness, shell.MaxDistance, 3);
});
}
// ── PlayNow / positions / intervals ────────────────────────────────────
[Fact]
public void ContinuousPlayNow_IsAlwaysTrue()
{
// PlayNow for a continuous bed is a folded `mov eax,1; ret`.
AmbientSoundInstance sound = new(Continuous(), 1u);
Assert.True(sound.PlayNow(new ScriptedRandom(0.99f)));
}
[Theory]
[InlineData(0.4f, true)] // roll <= chance
[InlineData(0.5f, true)] // inclusive
[InlineData(0.6f, false)]
public void IntermittentPlayNow_RollsAgainstPlayChance(float roll, bool expected)
{
var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
scheduler.BeginRebuild();
AmbientSoundInstance sound = scheduler.Track(Intermittent(baseChance: 0.5f), 1u);
scheduler.Contribute(sound, new Vector3(1f, 0f, 0f));
scheduler.EndRebuild(now: 0);
Assert.Equal(0.5f, sound.PlayChance, 4);
Assert.Equal(expected, sound.PlayNow(new ScriptedRandom(roll)));
}
[Fact]
public void ContinuousBed_HasNoPosition()
{
// Its GetSoundPos is a folded `xor eax,eax` -> plays from centre.
AmbientSoundInstance sound = new(Continuous(), 1u);
Assert.False(sound.TryGetSoundPosition(
new Vector3(100f, 200f, 10f), new ScriptedRandom(0f), out _));
}
[Fact]
public void IntermittentPosition_OffsetsTheListenerAndKeepsTheirZ()
{
var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
scheduler.BeginRebuild();
AmbientSoundInstance sound = scheduler.Track(Intermittent(), 1u);
scheduler.Contribute(sound, new Vector3(0f, 60f, 0f)); // north shell
scheduler.EndRebuild(now: 0);
var listener = new Vector3(100f, 200f, 37f);
// rolls: shell index, bearing jitter (mid), distance t
Assert.True(sound.TryGetSoundPosition(
listener, new ScriptedRandom(0f, 0.5f, 1f), out Vector3 position));
Assert.Equal(37f, position.Z); // listener Z preserved
Assert.True(position.Y > listener.Y); // to the north
Assert.Equal(100f, position.X, 1); // no jitter at mid-cone
}
[Fact]
public void IntermittentDistance_IsQuadraticallyBiasedTowardMin()
{
var scheduler = new AmbientSoundScheduler(new ScriptedRandom(0f));
scheduler.BeginRebuild();
AmbientSoundInstance sound = scheduler.Track(Intermittent(), 1u);
scheduler.Contribute(sound, new Vector3(0f, 60f, 0f));
scheduler.EndRebuild(now: 0);
AmbientDirectionShell shell = sound.Directions[0];
var listener = Vector3.Zero;
// t = 0.5 -> min + (max-min)*0.25, NOT the linear midpoint.
sound.TryGetSoundPosition(
listener, new ScriptedRandom(0f, 0.5f, 0.5f), out Vector3 position);
float distance = position.Length();
float quadratic = shell.MinDistance + ((shell.MaxDistance - shell.MinDistance) * 0.25f);
float linear = shell.MinDistance + ((shell.MaxDistance - shell.MinDistance) * 0.5f);
Assert.Equal(quadratic, distance, 1);
Assert.NotEqual(linear, distance, 1);
}
[Fact]
public void ContinuousInterval_UsesMinRateOnly()
{
AmbientSoundInstance sound = new(Continuous(minRate: 7f), 1u);
Assert.Equal(7f, sound.GetPlayInterval(new ScriptedRandom(0.9f)));
}
[Fact]
public void IntermittentInterval_RollsBetweenTheAuthoredRates()
{
AmbientSoundInstance sound = new(Intermittent(minRate: 4f, maxRate: 10f), 1u);
Assert.Equal(7f, sound.GetPlayInterval(new ScriptedRandom(0.5f)), 3);
}
[Fact]
public void RollDice_SwapsAnInvertedRange()
{
Assert.Equal(
7f,
AmbientSoundInstance.RollDice(10f, 4f, new ScriptedRandom(0.5f)),
3);
}
// ── The scheduler ──────────────────────────────────────────────────────
[Fact]
public void Scheduler_FiresAtTheDeadlineAndReArms()
{
var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
var firings = new List<AmbientSoundFiring>();
scheduler.BeginRebuild();
AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 5f), 1u);
scheduler.Contribute(bed, Vector3.Zero);
scheduler.EndRebuild(now: 0, firings, Vector3.Zero);
// Arming PLAYS: Ambient::Play fires and then re-arms (see the dedicated
// test below). Clear that first firing to test the queue cadence.
firings.Clear();
scheduler.Tick(now: 4.9, firings, Vector3.Zero);
Assert.Empty(firings);
// Retail's drain is STRICTLY below the deadline (UseTime @ 0x551880
// breaks on !(key < cur_time)), so exactly-at does not fire.
scheduler.Tick(now: 5.0, firings, Vector3.Zero);
Assert.Empty(firings);
scheduler.Tick(now: 5.01, firings, Vector3.Zero);
Assert.Single(firings);
// Re-armed rather than dropped: a continuous bed is a repeating one-shot.
Assert.True(bed.OnQueue);
Assert.Equal(1, scheduler.QueuedCount);
firings.Clear();
scheduler.Tick(now: 10.1, firings, Vector3.Zero);
Assert.Single(firings);
}
[Fact]
public void Scheduler_ArmingPlaysImmediately_WithNoInitialDelay()
{
// UpdatePlayQueue @ 0x551A50 calls Play for every on_queue == 0 sound,
// and Play @ 0x5517A0 plays BEFORE re-arming. An ambient that becomes
// audible at a crossing must sound at the crossing, not one min_rate
// later — that delay is exactly the "ambience lags behind me" character.
var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
var firings = new List<AmbientSoundFiring>();
scheduler.BeginRebuild();
AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 30f), 1u);
scheduler.Contribute(bed, Vector3.Zero);
scheduler.EndRebuild(now: 100.0, firings, Vector3.Zero);
Assert.Single(firings);
Assert.True(bed.OnQueue);
}
[Fact]
public void Scheduler_ZeroPlayInterval_DoesNotSpinForever()
{
// A descriptor authored with a zero rate re-arms at the same instant.
// With a <= drain this loops until the process dies; retail's strict <
// makes it structurally impossible.
var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
scheduler.BeginRebuild();
AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 0f), 1u);
scheduler.Contribute(bed, Vector3.Zero);
scheduler.EndRebuild(now: 0);
var firings = new List<AmbientSoundFiring>();
scheduler.Tick(now: 1.0, firings, Vector3.Zero);
// One pop, then the re-armed deadline equals `now` and the loop stops.
Assert.Single(firings);
}
[Fact]
public void Scheduler_DoesNotReArmAnAlreadyQueuedInstanceOnRebuild()
{
// The on_queue guard: re-arming unconditionally on every 24 m crossing
// would machine-gun one-shots.
var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
scheduler.BeginRebuild();
AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 5f), 1u);
scheduler.Contribute(bed, Vector3.Zero);
scheduler.EndRebuild(now: 0);
Assert.Equal(1, scheduler.QueuedCount);
for (int i = 0; i < 5; i++)
{
scheduler.BeginRebuild();
scheduler.Contribute(bed, Vector3.Zero);
scheduler.EndRebuild(now: 0);
}
Assert.Equal(1, scheduler.QueuedCount);
}
[Fact]
public void Scheduler_ArmsAnInstanceThatBecomesAudible()
{
// The other half of the guard: a newly audible ambient must not stay
// silent until some later event.
var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
scheduler.BeginRebuild();
AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 5f), 1u);
scheduler.EndRebuild(now: 0); // no contribution -> inaudible
Assert.Equal(0, scheduler.QueuedCount);
scheduler.BeginRebuild();
scheduler.Contribute(bed, Vector3.Zero);
scheduler.EndRebuild(now: 0);
Assert.Equal(1, scheduler.QueuedCount);
}
[Fact]
public void Scheduler_DropsAnInstanceThatWentInaudible()
{
var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
scheduler.BeginRebuild();
AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 5f), 1u);
scheduler.Contribute(bed, Vector3.Zero);
scheduler.EndRebuild(now: 0);
// Walk away: rebuild with no contribution, so the bed is inaudible when
// its deadline arrives.
scheduler.BeginRebuild();
scheduler.EndRebuild(now: 0);
var firings = new List<AmbientSoundFiring>();
scheduler.Tick(now: 99.0, firings, Vector3.Zero);
Assert.Empty(firings);
Assert.Equal(0, scheduler.QueuedCount);
Assert.False(bed.OnQueue);
}
[Fact]
public void Scheduler_ContinuousFiringHasNoPosition_IntermittentDoes()
{
var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
scheduler.BeginRebuild();
AmbientSoundInstance bed = scheduler.Track(Continuous(minRate: 1f), 1u);
AmbientSoundInstance chirp = scheduler.Track(
Intermittent(baseChance: 1f, minRate: 1f, maxRate: 1f), 2u);
scheduler.Contribute(bed, Vector3.Zero);
scheduler.Contribute(chirp, new Vector3(0f, 60f, 0f));
scheduler.EndRebuild(now: 0);
var firings = new List<AmbientSoundFiring>();
scheduler.Tick(now: 1.01, firings, Vector3.Zero);
Assert.Equal(2, firings.Count);
Assert.Null(firings.Find(f => f.Instance == bed).Position);
Assert.NotNull(firings.Find(f => f.Instance == chirp).Position);
}
[Fact]
public void Scheduler_Clear_DropsEverything()
{
var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
scheduler.BeginRebuild();
AmbientSoundInstance bed = scheduler.Track(Continuous(), 1u);
scheduler.Contribute(bed, Vector3.Zero);
scheduler.EndRebuild(now: 0);
scheduler.Clear();
Assert.Equal(0, scheduler.QueuedCount);
Assert.Empty(scheduler.Instances);
Assert.False(bed.OnQueue);
Assert.Equal(0f, scheduler.TotalSoundCount);
}
// ── The gatherer: the region walk and the shared denominator ───────────
private static DatReaderWriter.DBObjs.Region SyntheticRegion(
int ambientEntriesPerTable)
{
// terrainType 0 -> sceneType index 0 -> scene 0 -> STB 0.
var region = new DatReaderWriter.DBObjs.Region
{
TerrainInfo = new DatReaderWriter.Types.TerrainDesc(),
SceneInfo = new DatReaderWriter.Types.SceneDesc(),
SoundInfo = new DatReaderWriter.Types.SoundDesc(),
};
var terrain = new DatReaderWriter.Types.TerrainType();
terrain.SceneTypes.Add(0u);
region.TerrainInfo.TerrainTypes.Add(terrain);
var scene = new DatReaderWriter.Types.SceneType { StbIndex = 0u };
region.SceneInfo.SceneTypes.Add(scene);
var stb = new DatReaderWriter.Types.AmbientSTBDesc { STBId = 0x20000001u };
for (int i = 0; i < ambientEntriesPerTable; i++)
{
stb.AmbientSounds.Add(new DatReaderWriter.Types.AmbientSoundDesc
{
SType = (DatReaderWriter.Enums.Sound)((uint)DatReaderWriter.Enums.Sound.Ambient1 + i),
Volume = 1f,
BaseChance = 0f, // continuous
MinRate = 5f,
MaxRate = 0f,
});
}
region.SoundInfo.STBDesc.Add(stb);
return region;
}
/// <summary>A landblock whose every terrain word selects terrain 0 / scene 0.</summary>
private static ushort[] UniformTerrain() => new ushort[81];
[Fact]
public void Gatherer_ListenerInsideTheBlock_AccumulatesWeight()
{
// The regression this exists for: the walk must build offsets in the
// LANDBLOCK-LOCAL frame. Differencing absolute world coordinates against
// acdream's streamed-frame position puts every cell tens of kilometres
// away, culls all 576 of them, and the whole feature goes silent with no
// error anywhere.
var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
var gatherer = new AmbientSoundGatherer(scheduler);
gatherer.Rebuild(
SyntheticRegion(ambientEntriesPerTable: 1),
viewerLandblockId: 0xA9B4FFFFu,
listenerLocalPosition: new Vector3(96f, 96f, 0f), // mid-landblock
landblocks: _ => UniformTerrain(),
now: 0);
Assert.True(scheduler.TotalSoundCount > 0f);
AmbientSoundInstance instance = Assert.Single(scheduler.Instances);
Assert.True(instance.CanHear());
}
[Fact]
public void Gatherer_TotalWeightCountsCellsOnce_NotOncePerAmbientEntry()
{
// Ambient::AddSound adds the cell's weight to the denominator ONCE and
// then loops the descriptors. Adding it per descriptor divides every
// bed's crossfade by the entry count — with three entries and a typical
// authored volume that lands under the 0.03 floor and goes silent.
var single = new AmbientSoundScheduler(new ScriptedRandom());
new AmbientSoundGatherer(single).Rebuild(
SyntheticRegion(1), 0xA9B4FFFFu, new Vector3(96f, 96f, 0f),
_ => UniformTerrain(), now: 0);
var triple = new AmbientSoundScheduler(new ScriptedRandom());
new AmbientSoundGatherer(triple).Rebuild(
SyntheticRegion(3), 0xA9B4FFFFu, new Vector3(96f, 96f, 0f),
_ => UniformTerrain(), now: 0);
// Same cells contributing in both runs => same denominator, regardless
// of how many ambients each table authors.
Assert.Equal(single.TotalSoundCount, triple.TotalSoundCount, 3);
Assert.Equal(3, triple.Instances.Count);
// And each of the three beds keeps a full share, not a third of one.
AmbientSoundInstance one = Assert.Single(single.Instances);
Assert.All(
triple.Instances,
bed => Assert.Equal(one.CurrentVolume, bed.CurrentVolume, 3));
}
[Fact]
public void Gatherer_MissingNeighbours_ContributeNothing()
{
var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
var gatherer = new AmbientSoundGatherer(scheduler);
gatherer.Rebuild(
SyntheticRegion(1),
0xA9B4FFFFu,
new Vector3(96f, 96f, 0f),
// Only the viewer's own landblock is loaded.
id => id == 0xA9B4FFFFu ? UniformTerrain() : null,
now: 0);
Assert.True(scheduler.TotalSoundCount > 0f);
}
[Fact]
public void Gatherer_UnauthoredTerrain_ProducesNoAmbients()
{
var scheduler = new AmbientSoundScheduler(new ScriptedRandom());
var region = SyntheticRegion(1);
region.SceneInfo.SceneTypes[0].StbIndex = 0xFFFFFFFFu; // retail's "none"
new AmbientSoundGatherer(scheduler).Rebuild(
region, 0xA9B4FFFFu, new Vector3(96f, 96f, 0f),
_ => UniformTerrain(), now: 0);
Assert.Empty(scheduler.Instances);
Assert.Equal(0f, scheduler.TotalSoundCount);
}
}