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:
parent
6eaa490bb3
commit
7c4dd1ade7
13 changed files with 1984 additions and 32 deletions
File diff suppressed because one or more lines are too long
|
|
@ -306,5 +306,5 @@ global kill switch.
|
|||
| A2 | **COMPLETE** 2026-08-08 | `6d0156cb` | 118 Core audio tests (mixer + voice pool + cookbook); full Release suite 11,639 passed / 4 skipped / 0 failed. Opus review run and applied — 2 HIGH (pan-law saturation, stale `FUN_00550ad0` header), 5 MEDIUM (untested clamp order / pan truncation / voice pool, dead `PlayingGain`, duplicated heading helper), 5 LOW. Retires AP-28; files AP-173, AP-174, TS-64, TS-65. **Owed: user listening gate.** |
|
||||
| A3 | **COMPLETE** 2026-08-08 | `3fae0c7d` | 14 wire-conformance tests + 5 controller tests; full Release suite 11,658 passed / 4 skipped / 0 failed. **Owed: connected gate** (melee hit / pickup / lifestone audible against ACE). |
|
||||
| A4 | **COMPLETE** 2026-08-08 | `489ea6ad` | UI bank DID resolved from the dats (`0x2000004B`, content-verified: exactly the 32 `UI_*` slots) + 21-case environ table, 30 new Core tests; full Release suite 11,691 passed / 4 skipped. Retires TS-54; narrows AP-115 to notice-only. **Owed: connected gate** (`@environs` thunder + recall cues audible). **Suite note:** two load-dependent measurement flakes were observed on separate full-suite runs (`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`, and one unnamed Core.Net test); both pass in isolation and neither touches audio. |
|
||||
| A5 | — | — | — |
|
||||
| A5 | **COMPLETE** 2026-08-08 | `e81bc474` | 46 ambient conformance tests; full Release suite 11,739 passed / 4 skipped. Opus review run and applied — it caught a FATAL frame bug (cell offsets built in absolute world coordinates while the listener is in the streamed frame: every contribution culled at ~32 km, feature silent with no error), a per-entry vs per-cell denominator error that would have pushed multi-entry beds under the audibility floor, an infinite loop on a zero play-rate, and newly-audible ambients not firing until a full period later. Also moved beds onto retail's single 16-voice priority pool and made the in-block direction test XY-only. Retires TS-29; files TS-66 (`seen_outside` interiors), TS-67 (in-plane weight). **Owed: user listening gate.** |
|
||||
| A6 | — | — | — |
|
||||
|
|
|
|||
321
src/AcDream.App/Audio/AmbientSoundController.cs
Normal file
321
src/AcDream.App/Audio/AmbientSoundController.cs
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Audio;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DRWSound = DatReaderWriter.Enums.Sound;
|
||||
|
||||
namespace AcDream.App.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Drives retail's region ambient soundscape: rebuild on an objcell change,
|
||||
/// drain the deadline queue every frame, and play each firing as a one-shot.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's <c>Ambient</c> system is a weighted-accumulation + timer-queue
|
||||
/// engine, NOT looping voices. On every objcell change (24 m granularity)
|
||||
/// <c>CellManager::ChangePosition</c> @ <c>0x4559B0</c> rebuilds per-sound
|
||||
/// weights over the 3×3 landblock ring × 64 land cells each; 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 is simply a one-shot re-fired
|
||||
/// every <c>min_rate</c> seconds with a freshly rolled table pick and crossfade
|
||||
/// volume.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Continuous beds play from centre (no position, no pan, no distance
|
||||
/// attenuation); intermittent ones play positionally at a random accumulated
|
||||
/// bearing. Both go through the ambient volume knob, which retail applies
|
||||
/// TWICE — once in <c>PlayAmbientSound*</c> and again inside
|
||||
/// <c>GetAttenuation</c> — so the slider is effectively squared. That quirk is
|
||||
/// reproduced here because two independent research lanes byte-confirmed the
|
||||
/// double application (see TS-65).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AmbientSoundController
|
||||
{
|
||||
private readonly OpenAlAudioEngine _engine;
|
||||
private readonly DatSoundCache _cache;
|
||||
private readonly AmbientSoundScheduler _scheduler;
|
||||
private readonly AmbientSoundGatherer _gatherer;
|
||||
private readonly ISoundRandom _rng;
|
||||
private readonly List<AmbientSoundFiring> _firings = [];
|
||||
|
||||
private Region? _region;
|
||||
private Func<uint, ushort[]?> _landblocks = static _ => null;
|
||||
private uint _currentObjCell;
|
||||
private Vector3 _listenerPosition;
|
||||
private double _clock;
|
||||
private bool _suspended;
|
||||
|
||||
public AmbientSoundController(
|
||||
OpenAlAudioEngine engine,
|
||||
DatSoundCache cache,
|
||||
ISoundRandom? rng = null)
|
||||
{
|
||||
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_rng = rng ?? new SoundRandom();
|
||||
_scheduler = new AmbientSoundScheduler(_rng);
|
||||
_gatherer = new AmbientSoundGatherer(_scheduler);
|
||||
}
|
||||
|
||||
/// <summary>Live instance count. Diagnostic use.</summary>
|
||||
public int InstanceCount => _scheduler.Instances.Count;
|
||||
|
||||
/// <summary>Instances holding a deadline. Diagnostic use.</summary>
|
||||
public int QueuedCount => _scheduler.QueuedCount;
|
||||
|
||||
/// <summary>
|
||||
/// Install the region whose authored ambient data drives the soundscape, and
|
||||
/// the terrain-word source for the 3×3 ring.
|
||||
/// </summary>
|
||||
public void InstallRegion(Region region, Func<uint, ushort[]?> landblocks)
|
||||
{
|
||||
_region = region ?? throw new ArgumentNullException(nameof(region));
|
||||
_landblocks = landblocks ?? throw new ArgumentNullException(nameof(landblocks));
|
||||
_currentObjCell = 0;
|
||||
_scheduler.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report the listener's cell and position. A change of objcell triggers the
|
||||
/// rebuild — retail's trigger is <c>CellManager::ChangePosition</c>, not a
|
||||
/// landblock streaming event, so the cadence is every 24 m rather than every
|
||||
/// 192 m.
|
||||
/// </summary>
|
||||
public void ObserveListener(
|
||||
uint objCellId,
|
||||
Vector3 position,
|
||||
Vector3 landblockLocalPosition,
|
||||
bool seenOutside = false)
|
||||
{
|
||||
_listenerPosition = position;
|
||||
if (_region is null || objCellId == _currentObjCell)
|
||||
return;
|
||||
|
||||
// Latch the new cell FIRST and unconditionally, the way
|
||||
// CellManager::ChangePosition assigns load_pos at its tail. Clearing it
|
||||
// inside the indoor branch would leave the change edge permanently
|
||||
// armed while standing still indoors.
|
||||
_currentObjCell = objCellId;
|
||||
|
||||
// Indoors is silent by design: retail's CEnvCell::add_ambient_sounds is
|
||||
// an empty folded ret and the EnvCell format carries no sound data. The
|
||||
// gate is `isOutdoorCell(pos) || curr_cell->seen_outside`, so an
|
||||
// interior that can see the sky still gets the OUTDOOR set — a cottage
|
||||
// does not cut the ambience dead.
|
||||
if (IsIndoorCell(objCellId) && !seenOutside)
|
||||
{
|
||||
_scheduler.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
_gatherer.Rebuild(
|
||||
_region,
|
||||
(objCellId >> 16 << 16) | 0xFFFFu,
|
||||
landblockLocalPosition,
|
||||
_landblocks,
|
||||
_clock);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance the ambient clock and fire everything now due. Called once per
|
||||
/// frame from the same tick that drives the rest of the effect system.
|
||||
/// </summary>
|
||||
public void Tick(double deltaSeconds)
|
||||
{
|
||||
if (deltaSeconds > 0)
|
||||
_clock += deltaSeconds;
|
||||
|
||||
if (_suspended || !_engine.IsAvailable || _region is null)
|
||||
return;
|
||||
|
||||
_firings.Clear();
|
||||
_scheduler.Tick(_clock, _firings, _listenerPosition);
|
||||
Emit();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop contributing while the world is being replaced. The scheduler's
|
||||
/// deadlines are dropped rather than paused: the next rebuild re-arms
|
||||
/// everything audible, which is what a cell change does anyway. Dropping
|
||||
/// them also avoids a salvo of every overdue bed firing at once on resume.
|
||||
/// </summary>
|
||||
public void Suspend()
|
||||
{
|
||||
_suspended = true;
|
||||
StopAll();
|
||||
}
|
||||
|
||||
public void Resume() => _suspended = false;
|
||||
|
||||
/// <summary>Drop every instance and deadline (world teardown / reset).</summary>
|
||||
public void StopAll()
|
||||
{
|
||||
_scheduler.Clear();
|
||||
_currentObjCell = 0;
|
||||
}
|
||||
|
||||
private void Emit()
|
||||
{
|
||||
foreach (AmbientSoundFiring firing in _firings)
|
||||
Play(firing);
|
||||
_firings.Clear();
|
||||
}
|
||||
|
||||
private void Play(in AmbientSoundFiring firing)
|
||||
{
|
||||
SoundTable? table = _cache.GetSoundTable(firing.Instance.SoundTableDid);
|
||||
if (table is null)
|
||||
return;
|
||||
|
||||
// The variant pick and the entry's probability gate apply to ambients
|
||||
// exactly as they do everywhere else — PlayAmbientSound* rolls the same
|
||||
// PlayProbability inline.
|
||||
var entry = SoundCookbook.Select(
|
||||
table,
|
||||
(DRWSound)(uint)firing.Instance.Descriptor.Sound,
|
||||
_rng);
|
||||
if (entry is null)
|
||||
return;
|
||||
|
||||
uint waveId = (uint)entry.Id;
|
||||
if (waveId == 0)
|
||||
return;
|
||||
|
||||
WaveData? wave = _cache.GetWave(waveId);
|
||||
if (wave is null)
|
||||
return;
|
||||
|
||||
// Retail pre-multiplies by the ambient knob here and GetAttenuation
|
||||
// multiplies by it again — the squared-slider quirk (TS-65).
|
||||
float volume = firing.Volume * _engine.AmbientVolume;
|
||||
|
||||
if (firing.Position is { } position)
|
||||
{
|
||||
_engine.PlayAmbient3DWave(waveId, wave, position, volume, entry.Priority);
|
||||
return;
|
||||
}
|
||||
|
||||
// Continuous bed: from centre, no position and no attenuation — but
|
||||
// still through the SAME 16-voice priority pool as everything else.
|
||||
// Retail has one pool (SoundManager::playing_sounds_[0x10]); the UI pool
|
||||
// is ours, and parking beds there would let a portal cue chop one
|
||||
// mid-wave and would discard the entry's priority.
|
||||
_engine.PlayAmbientFromCenter(waveId, wave, volume, entry.Priority);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor land cells are <c>0x…FFFF</c> style ids below 0x0100 in the cell
|
||||
/// word; anything above that is an EnvCell (indoor), which retail gives no
|
||||
/// ambients.
|
||||
/// </summary>
|
||||
private static bool IsIndoorCell(uint objCellId) => (objCellId & 0xFFFFu) >= 0x0100u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The per-frame ambient step, as a typed collaborator. Extracted update owners
|
||||
/// may not retain delegates, so the effect phase takes this rather than an
|
||||
/// <c>Action<float></c>.
|
||||
/// </summary>
|
||||
public interface IAmbientFramePhase
|
||||
{
|
||||
void TickAmbient(float deltaSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds the ambient controller to the live listener: reports the local
|
||||
/// player's cell and position (retail rebuilds on an objcell change, and reads
|
||||
/// the viewer's position), then drains the deadline queue.
|
||||
/// </summary>
|
||||
public sealed class AmbientFramePhase : IAmbientFramePhase
|
||||
{
|
||||
private readonly AmbientSoundController _ambient;
|
||||
private readonly IAmbientListenerSource _listener;
|
||||
|
||||
public AmbientFramePhase(AmbientSoundController ambient, IAmbientListenerSource listener)
|
||||
{
|
||||
_ambient = ambient ?? throw new ArgumentNullException(nameof(ambient));
|
||||
_listener = listener ?? throw new ArgumentNullException(nameof(listener));
|
||||
}
|
||||
|
||||
public void TickAmbient(float deltaSeconds)
|
||||
{
|
||||
if (_listener.TryGetListener(out AmbientListenerPose pose))
|
||||
{
|
||||
_ambient.ObserveListener(
|
||||
pose.ObjCellId,
|
||||
pose.Position,
|
||||
pose.LandblockLocalPosition,
|
||||
pose.SeenOutside);
|
||||
}
|
||||
_ambient.Tick(deltaSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The listener's pose, in both frames the ambient system needs.
|
||||
/// </summary>
|
||||
/// <param name="ObjCellId">The land/env cell — the rebuild trigger.</param>
|
||||
/// <param name="Position">
|
||||
/// The streamed-frame position, used for PLAYBACK (it is the frame the audio
|
||||
/// engine's listener lives in).
|
||||
/// </param>
|
||||
/// <param name="LandblockLocalPosition">
|
||||
/// The landblock-local position, x/y in <c>[0, 192)</c>, used for the CELL WALK.
|
||||
/// Mixing the two frames culls every contribution.
|
||||
/// </param>
|
||||
/// <param name="SeenOutside">
|
||||
/// True when an interior cell can see the sky; retail gives those the outdoor
|
||||
/// ambient set rather than silence.
|
||||
/// </param>
|
||||
public readonly record struct AmbientListenerPose(
|
||||
uint ObjCellId,
|
||||
Vector3 Position,
|
||||
Vector3 LandblockLocalPosition,
|
||||
bool SeenOutside);
|
||||
|
||||
/// <summary>Supplies the listener's current pose.</summary>
|
||||
public interface IAmbientListenerSource
|
||||
{
|
||||
bool TryGetListener(out AmbientListenerPose pose);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IAmbientListenerSource"/> over the canonical local-player movement
|
||||
/// owner. Retail's ambient listener is the same viewer the mixer uses; the cell
|
||||
/// is what decides when to rebuild.
|
||||
/// </summary>
|
||||
public sealed class LocalPlayerAmbientListenerSource : IAmbientListenerSource
|
||||
{
|
||||
private readonly AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState _player;
|
||||
|
||||
public LocalPlayerAmbientListenerSource(
|
||||
AcDream.Runtime.Gameplay.RuntimeLocalPlayerMovementState player) =>
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
|
||||
public bool TryGetListener(out AmbientListenerPose pose)
|
||||
{
|
||||
if (_player.Controller is { } controller)
|
||||
{
|
||||
AcDream.Core.Physics.Position cell = controller.CellPosition;
|
||||
pose = new AmbientListenerPose(
|
||||
controller.CellId,
|
||||
controller.Position,
|
||||
cell.Frame.Origin,
|
||||
// Retail's gate is `isOutdoorCell(pos) || curr_cell->seen_outside`,
|
||||
// so a sky-lit interior keeps the outdoor set. acdream's
|
||||
// seen_outside lives on the cell's collision record rather than
|
||||
// its Position, and resolving it here needs a physics-cache
|
||||
// lookup this source does not own — deferred as TS-66. Until
|
||||
// then every interior is silent, which is right for a dungeon
|
||||
// and wrong for a cottage.
|
||||
SeenOutside: false);
|
||||
return true;
|
||||
}
|
||||
|
||||
pose = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -135,10 +135,6 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
|||
private readonly Dictionary<uint, uint> _bufferByWaveId = new();
|
||||
private readonly AlBufferBudgetTracker _bufferBudget = new(DefaultBufferByteBudget);
|
||||
|
||||
// ── Ambient handles (StartAmbient/StopAmbient) ───────────────────────────
|
||||
private readonly Dictionary<int, uint> _ambientSources = new();
|
||||
private int _nextAmbientHandle = 1;
|
||||
|
||||
// ── Public volume knobs ──────────────────────────────────────────────────
|
||||
public float MasterVolume { get; set; } = 1f;
|
||||
public float SfxVolume { get; set; } = 1f;
|
||||
|
|
@ -456,25 +452,109 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
|||
|
||||
public void Play3D(SoundId id, float x, float y, float z) { /* handled via AudioHookSink */ }
|
||||
|
||||
public int StartAmbient(SoundId id, float x, float y, float z)
|
||||
/// <summary>
|
||||
/// Play a positional ambient one-shot — retail's
|
||||
/// <c>SoundManager::PlayAmbientSound</c> @ <c>0x00550820</c>. Identical to a
|
||||
/// world sound except that <c>GetAttenuation</c> is told this is ambient, so
|
||||
/// the AMBIENT volume knob is the master multiply rather than the effect one.
|
||||
/// </summary>
|
||||
public bool PlayAmbient3DWave(
|
||||
uint waveId,
|
||||
WaveData wave,
|
||||
Vector3 position,
|
||||
float volume,
|
||||
float priority)
|
||||
{
|
||||
// Looping ambient — needs a decoded wave + WaveId. The hook sink
|
||||
// doesn't route ambient; a separate landblock-attached ambient
|
||||
// system (outside R5) will drive this. For now: reserve a handle.
|
||||
int handle = _nextAmbientHandle++;
|
||||
return handle;
|
||||
if (_worldAudioSuspended || !_available || _al is null) return false;
|
||||
|
||||
RetailVoiceMix mix = RetailSoundMixer.Mix(
|
||||
_listenerPosition,
|
||||
_listenerHeadingDegrees,
|
||||
position,
|
||||
volume,
|
||||
AmbientMaster);
|
||||
if (!mix.Play) return false;
|
||||
|
||||
uint buffer = EnsureBuffer(waveId, wave);
|
||||
if (buffer == 0) return false;
|
||||
|
||||
int slotIdx = AcquireWorldSlot(priority);
|
||||
if (slotIdx < 0) return false;
|
||||
|
||||
Slot3D slot = _pool3D[slotIdx];
|
||||
_al.SourceStop(slot.SourceId);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
|
||||
_al.SetSourceProperty(
|
||||
slot.SourceId,
|
||||
SourceFloat.Gain,
|
||||
RetailSoundMixer.LinearGain(mix.Decibels));
|
||||
ApplyPan(slot.SourceId, mix.Pan);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
|
||||
_al.SourcePlay(slot.SourceId);
|
||||
|
||||
slot.InUse = true;
|
||||
slot.OwnerId = 0;
|
||||
slot.Priority = priority;
|
||||
_pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void StopAmbient(int handle)
|
||||
/// <summary>
|
||||
/// Play a non-positional ambient bed — retail's
|
||||
/// <c>SoundManager::PlayAmbientSoundFromCenter</c> @ <c>0x005508B0</c>. A
|
||||
/// continuous ambient has no position at all (its <c>GetSoundPos</c> is a
|
||||
/// folded <c>xor eax,eax</c>), so there is no pan and no distance
|
||||
/// attenuation: it is a stereo bed centred on the listener. Distance 0 takes
|
||||
/// the flat branch of the curve, scaled by the ambient knob.
|
||||
/// </summary>
|
||||
public bool PlayAmbientFromCenter(
|
||||
uint waveId,
|
||||
WaveData wave,
|
||||
float volume,
|
||||
float priority)
|
||||
{
|
||||
if (!_available || _al is null) return;
|
||||
if (_ambientSources.TryGetValue(handle, out var src))
|
||||
{
|
||||
_al.SourceStop(src);
|
||||
_ambientSources.Remove(handle);
|
||||
}
|
||||
if (_worldAudioSuspended || !_available || _al is null) return false;
|
||||
|
||||
if (!RetailSoundMixer.TryGetAttenuation(0f, volume, AmbientMaster, out int decibels))
|
||||
return false;
|
||||
|
||||
uint buffer = EnsureBuffer(waveId, wave);
|
||||
if (buffer == 0) return false;
|
||||
|
||||
// The SAME 16-voice priority pool as everything else. Retail has exactly
|
||||
// one (`SoundManager::playing_sounds_[0x10]`), reached by
|
||||
// PlayAmbientSoundFromCenter @ 0x5508B0 -> PlaySoundInternal @ 0x54FEC0;
|
||||
// the UI pool is acdream's own. Parking beds there would let a portal
|
||||
// cue chop one mid-wave and would discard the authored priority.
|
||||
int slotIdx = AcquireWorldSlot(priority);
|
||||
if (slotIdx < 0) return false;
|
||||
|
||||
Slot3D slot = _pool3D[slotIdx];
|
||||
_al.SourceStop(slot.SourceId);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
|
||||
_al.SetSourceProperty(
|
||||
slot.SourceId,
|
||||
SourceFloat.Gain,
|
||||
RetailSoundMixer.LinearGain(decibels));
|
||||
ApplyPan(slot.SourceId, 0); // dead centre: a bed has no bearing
|
||||
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
|
||||
_al.SourcePlay(slot.SourceId);
|
||||
|
||||
slot.InUse = true;
|
||||
slot.OwnerId = 0;
|
||||
slot.Priority = priority;
|
||||
_pool3DCursor = RetailVoicePool.AdvanceCursor(slotIdx, PoolSize3D);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ambient master multiply, folded with acdream's extra master slider —
|
||||
/// the ambient counterpart of <see cref="EffectMaster"/>. See AP-174.
|
||||
/// </summary>
|
||||
private float AmbientMaster => MasterVolume * AmbientVolume;
|
||||
|
||||
public void PlayMusic(string resourceName, bool loop) { /* R5 §6 MIDI — not ported */ }
|
||||
public void StopMusic() { /* ditto */ }
|
||||
|
||||
|
|
@ -567,10 +647,9 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
|||
{
|
||||
if (IsSourceBoundTo(_poolUi[i], bufferId)) return true;
|
||||
}
|
||||
foreach (uint sourceId in _ambientSources.Values)
|
||||
{
|
||||
if (IsSourceBoundTo(sourceId, bufferId)) return true;
|
||||
}
|
||||
// Ambients share the world and UI pools scanned above; there is no
|
||||
// separate ambient source list any more (slice A5 deleted the
|
||||
// looping-handle model retail never had).
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ internal sealed record ContentAudioGraph(
|
|||
OpenAlAudioEngine Engine,
|
||||
DictionaryEntitySoundTable EntitySoundTables,
|
||||
AudioHookSink? HookSink,
|
||||
UiSoundController? UiSounds);
|
||||
UiSoundController? UiSounds,
|
||||
AmbientSoundController? Ambient);
|
||||
|
||||
internal sealed record ContentEffectsAudioResult(
|
||||
IDatReaderWriter Dats,
|
||||
|
|
@ -138,6 +139,9 @@ internal interface IContentEffectsAudioCompositionFactory
|
|||
OpenAlAudioEngine engine,
|
||||
DatSoundCache cache,
|
||||
IDatReaderWriter dats);
|
||||
AmbientSoundController CreateAmbient(
|
||||
OpenAlAudioEngine engine,
|
||||
DatSoundCache cache);
|
||||
}
|
||||
|
||||
internal sealed class RetailContentEffectsAudioCompositionFactory
|
||||
|
|
@ -253,6 +257,11 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
|
|||
DatSoundCache cache,
|
||||
IDatReaderWriter dats) =>
|
||||
new(engine, cache, UiSoundTableResolver.Resolve(dats));
|
||||
|
||||
public AmbientSoundController CreateAmbient(
|
||||
OpenAlAudioEngine engine,
|
||||
DatSoundCache cache) =>
|
||||
new(engine, cache);
|
||||
}
|
||||
|
||||
internal enum ContentEffectsAudioCompositionPoint
|
||||
|
|
@ -280,6 +289,7 @@ internal enum ContentEffectsAudioCompositionPoint
|
|||
EntitySoundTablesCreated,
|
||||
AudioSinkCreated,
|
||||
UiSoundsCreated,
|
||||
AmbientCreated,
|
||||
AudioPublished,
|
||||
AudioHookRegistered,
|
||||
}
|
||||
|
|
@ -485,6 +495,7 @@ internal sealed class ContentEffectsAudioCompositionPhase :
|
|||
Fault(ContentEffectsAudioCompositionPoint.EntitySoundTablesCreated);
|
||||
AudioHookSink? sink = null;
|
||||
UiSoundController? uiSounds = null;
|
||||
AmbientSoundController? ambient = null;
|
||||
if (engine.IsAvailable)
|
||||
{
|
||||
sink = _factory.CreateAudioSink(engine, cache, soundTables);
|
||||
|
|
@ -496,9 +507,12 @@ internal sealed class ContentEffectsAudioCompositionPhase :
|
|||
+ "- interface cues silent"
|
||||
: $"audio: UI sound bank = 0x{uiSounds.TableDid:X8}");
|
||||
Fault(ContentEffectsAudioCompositionPoint.UiSoundsCreated);
|
||||
ambient = _factory.CreateAmbient(engine, cache);
|
||||
Fault(ContentEffectsAudioCompositionPoint.AmbientCreated);
|
||||
}
|
||||
|
||||
graph = new ContentAudioGraph(cache, engine, soundTables, sink, uiSounds);
|
||||
graph = new ContentAudioGraph(
|
||||
cache, engine, soundTables, sink, uiSounds, ambient);
|
||||
}
|
||||
catch (Exception failure)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ using AcDream.App.Rendering.Vfx;
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Runtime;
|
||||
using AcDream.App.Settings;
|
||||
using AcDream.App.Audio;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.Update;
|
||||
using AcDream.App.World;
|
||||
|
|
@ -797,6 +798,44 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
new LiveProjectionRescueRebucketter(
|
||||
live.WorldState,
|
||||
live.LiveEntities));
|
||||
// Campaign A slice A5: retail rebuilds the ambient soundscape on every
|
||||
// objcell change (24 m) and drains its deadline queue from the frame
|
||||
// tick. The terrain words come from the loaded landblocks in the 3x3
|
||||
// ring; the listener is the local player's cell and position.
|
||||
IAmbientFramePhase? BuildAmbientFrame()
|
||||
{
|
||||
if (content.Audio?.Ambient is not { } ambient)
|
||||
return null;
|
||||
|
||||
DatReaderWriter.DBObjs.Region? region =
|
||||
content.Dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
|
||||
if (region is null)
|
||||
return null;
|
||||
|
||||
ambient.InstallRegion(region, LoadTerrainWords);
|
||||
return new AmbientFramePhase(
|
||||
ambient,
|
||||
new LocalPlayerAmbientListenerSource(d.PlayerController));
|
||||
|
||||
ushort[]? LoadTerrainWords(uint landblockId)
|
||||
{
|
||||
if (!live.WorldState.TryGetLandblock(landblockId, out LoadedLandblock? loaded)
|
||||
|| loaded?.Heightmap is not { } heightmap)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// The dat exposes terrain words as TerrainInfo; the gatherer wants
|
||||
// the raw words. Converted here rather than in Core so the ambient
|
||||
// model stays free of the dat type. This runs only on an objcell
|
||||
// change (every 24 m), nine landblocks at a time.
|
||||
var words = new ushort[heightmap.Terrain.Length];
|
||||
for (int i = 0; i < words.Length; i++)
|
||||
words[i] = (ushort)heightmap.Terrain[i];
|
||||
return words;
|
||||
}
|
||||
}
|
||||
|
||||
var liveEffectFrame = new LiveEffectFrameController(
|
||||
d.TranslucencyFades,
|
||||
content.AnimationHookFrames,
|
||||
|
|
@ -807,7 +846,8 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
content.ParticleSystem,
|
||||
content.ScriptRunner,
|
||||
d.UpdateClock,
|
||||
new SettingsParticleRangeSource(d.Settings));
|
||||
new SettingsParticleRangeSource(d.Settings),
|
||||
BuildAmbientFrame());
|
||||
var liveSpatialReconciler = new LiveSpatialPresentationReconciler(
|
||||
live.EntityEffects,
|
||||
live.EquippedChildren,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using AcDream.App.Audio;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Interaction;
|
||||
|
|
@ -88,7 +89,8 @@ internal sealed class LiveEffectFrameController
|
|||
ParticleSystem particles,
|
||||
PhysicsScriptRunner scripts,
|
||||
IPhysicsScriptTimeSource scriptTime,
|
||||
IParticleRangeSource particleRange)
|
||||
IParticleRangeSource particleRange,
|
||||
IAmbientFramePhase? ambientFrame = null)
|
||||
{
|
||||
_translucencyFades = translucencyFades
|
||||
?? throw new ArgumentNullException(nameof(translucencyFades));
|
||||
|
|
@ -102,8 +104,19 @@ internal sealed class LiveEffectFrameController
|
|||
_scripts = scripts ?? throw new ArgumentNullException(nameof(scripts));
|
||||
_scriptTime = scriptTime ?? throw new ArgumentNullException(nameof(scriptTime));
|
||||
_particleRange = particleRange ?? throw new ArgumentNullException(nameof(particleRange));
|
||||
_ambientFrame = ambientFrame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign A slice A5: retail drains the ambient deadline queue from the
|
||||
/// same per-frame tick that advances the rest of the effect system
|
||||
/// (<c>SmartBox::UseTime</c> -> <c>Ambient::UseTime</c>). A typed
|
||||
/// collaborator rather than a callback — extracted update owners must not
|
||||
/// retain delegates (`ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks`).
|
||||
/// Null when audio is unavailable.
|
||||
/// </summary>
|
||||
private readonly IAmbientFramePhase? _ambientFrame;
|
||||
|
||||
public void Tick(float deltaSeconds)
|
||||
{
|
||||
// Retail's ordinary-object UpdatePositionInternal @ 0x00512C30 calls
|
||||
|
|
@ -123,6 +136,7 @@ internal sealed class LiveEffectFrameController
|
|||
// once-per-host tail and its static-order difference remain TS-51.
|
||||
_particles.Tick(deltaSeconds);
|
||||
_scripts.Tick(_scriptTime.CurrentScriptTime);
|
||||
_ambientFrame?.TickAmbient(deltaSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
211
src/AcDream.Core/Audio/AmbientSoundGatherer.cs
Normal file
211
src/AcDream.Core/Audio/AmbientSoundGatherer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
409
src/AcDream.Core/Audio/AmbientSoundModel.cs
Normal file
409
src/AcDream.Core/Audio/AmbientSoundModel.cs
Normal 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 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);
|
||||
235
src/AcDream.Core/Audio/AmbientSoundScheduler.cs
Normal file
235
src/AcDream.Core/Audio/AmbientSoundScheduler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ public sealed class ContentEffectsAudioCompositionTests
|
|||
// disabled, so those points are not required boundaries. A4 added
|
||||
// UiSoundsCreated to that prefix.
|
||||
if (point is >= ContentEffectsAudioCompositionPoint.SoundCacheCreated
|
||||
and <= ContentEffectsAudioCompositionPoint.UiSoundsCreated)
|
||||
and <= ContentEffectsAudioCompositionPoint.AmbientCreated)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
@ -487,6 +487,11 @@ public sealed class ContentEffectsAudioCompositionTests
|
|||
DatSoundCache cache,
|
||||
IDatReaderWriter dats) =>
|
||||
new(engine, cache, tableDid: 0u);
|
||||
|
||||
public AmbientSoundController CreateAmbient(
|
||||
OpenAlAudioEngine engine,
|
||||
DatSoundCache cache) =>
|
||||
new(engine, cache);
|
||||
}
|
||||
|
||||
public class NullProxy : DispatchProxy
|
||||
|
|
|
|||
616
tests/AcDream.Core.Tests/Audio/AmbientSoundTests.cs
Normal file
616
tests/AcDream.Core.Tests/Audio/AmbientSoundTests.cs
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue