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>
616 lines
25 KiB
C#
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);
|
|
}
|
|
}
|